- {stage.template.name}
+ {stage.stageDefinition?.name ?? stage.template.name}
{stage.status.replace(/_/g, " ")}
{stage.startDate && (
diff --git a/src/components/views/production-timeline.tsx b/src/components/views/production-timeline.tsx
index 26938c7..6cb6700 100644
--- a/src/components/views/production-timeline.tsx
+++ b/src/components/views/production-timeline.tsx
@@ -63,6 +63,7 @@ interface Stage {
completedDate: string | null;
dueDate: string | null;
template: { name: string; slug: string; order: number };
+ stageDefinition?: { name: string; slug: string; order: number } | null;
assignments: { user: { id: string; name: string | null; image: string | null } }[];
}
@@ -686,7 +687,7 @@ export function ProductionTimeline({
{/* Stage name */}
{barPx > 55 && (
- {stage.template.name}
+ {stage.stageDefinition?.name ?? stage.template.name}
)}
@@ -719,7 +720,7 @@ export function ProductionTimeline({
- {stage.template.name}
+ {stage.stageDefinition?.name ?? stage.template.name}
{stage.status.replace(/_/g, " ")}
{stage.assignments.length > 0 && (
diff --git a/src/hooks/use-calendar.ts b/src/hooks/use-calendar.ts
index 71fb61a..f01fade 100644
--- a/src/hooks/use-calendar.ts
+++ b/src/hooks/use-calendar.ts
@@ -21,6 +21,11 @@ export interface CalendarEvent {
name: string;
slug: string;
};
+ stageDefinition?: {
+ id: string;
+ name: string;
+ slug: string;
+ } | null;
deliverable: {
id: string;
name: string;
diff --git a/src/hooks/use-video-player.ts b/src/hooks/use-video-player.ts
new file mode 100644
index 0000000..752da6b
--- /dev/null
+++ b/src/hooks/use-video-player.ts
@@ -0,0 +1,368 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Hls from "hls.js";
+
+export type PlaybackSpeed = 0.25 | 0.5 | 1 | 1.5 | 2;
+
+const SPEEDS: PlaybackSpeed[] = [0.25, 0.5, 1, 1.5, 2];
+
+export interface UseVideoPlayerReturn {
+ videoRef: React.RefObject;
+ containerRef: React.RefObject;
+ // State
+ currentTime: number;
+ duration: number;
+ isPlaying: boolean;
+ playbackSpeed: PlaybackSpeed;
+ volume: number;
+ isMuted: boolean;
+ isFullscreen: boolean;
+ isReady: boolean;
+ isBuffering: boolean;
+ fps: number;
+ // Actions
+ play: () => void;
+ pause: () => void;
+ togglePlay: () => void;
+ seek: (time: number) => void;
+ seekRelative: (delta: number) => void;
+ stepFrame: (direction: 1 | -1) => void;
+ setPlaybackSpeed: (speed: PlaybackSpeed) => void;
+ cycleSpeedUp: () => void;
+ cycleSpeedDown: () => void;
+ setVolume: (vol: number) => void;
+ toggleMute: () => void;
+ toggleFullscreen: () => void;
+ toggleLoop: () => void;
+ isLooping: boolean;
+ // HLS
+ loadSource: (hlsUrl: string | null, mp4Url: string | null) => void;
+}
+
+export function useVideoPlayer(fps: number = 24): UseVideoPlayerReturn {
+ const videoRef = useRef(null);
+ const containerRef = useRef(null);
+ const hlsRef = useRef(null);
+
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [playbackSpeed, setPlaybackSpeedState] = useState(1);
+ const [volume, setVolumeState] = useState(1);
+ const [isMuted, setIsMuted] = useState(false);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(false);
+ const [isLooping, setIsLooping] = useState(false);
+
+ // ── Source loading (HLS + MP4 fallback) ──────────────────────────
+ const loadSource = useCallback(
+ (hlsUrl: string | null, mp4Url: string | null) => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ // Cleanup previous HLS instance
+ if (hlsRef.current) {
+ hlsRef.current.destroy();
+ hlsRef.current = null;
+ }
+
+ setIsReady(false);
+ setCurrentTime(0);
+ setDuration(0);
+ setIsPlaying(false);
+
+ if (hlsUrl && Hls.isSupported()) {
+ const hls = new Hls({
+ enableWorker: true,
+ startLevel: -1, // auto quality
+ });
+ hls.loadSource(hlsUrl);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ setIsReady(true);
+ });
+ hls.on(Hls.Events.ERROR, (_event, data) => {
+ if (data.fatal && mp4Url) {
+ // Fallback to raw MP4
+ console.warn("[VideoPlayer] HLS fatal error, falling back to MP4");
+ hls.destroy();
+ hlsRef.current = null;
+ video.src = mp4Url;
+ }
+ });
+ hlsRef.current = hls;
+ } else if (hlsUrl && video.canPlayType("application/vnd.apple.mpegurl")) {
+ // Native HLS (Safari)
+ video.src = hlsUrl;
+ } else if (mp4Url) {
+ // Direct MP4
+ video.src = mp4Url;
+ }
+ },
+ []
+ );
+
+ // ── Video event listeners ────────────────────────────────────────
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const onTimeUpdate = () => setCurrentTime(video.currentTime);
+ const onDurationChange = () => setDuration(video.duration || 0);
+ const onPlay = () => setIsPlaying(true);
+ const onPause = () => setIsPlaying(false);
+ const onCanPlay = () => {
+ setIsReady(true);
+ setIsBuffering(false);
+ };
+ const onWaiting = () => setIsBuffering(true);
+ const onPlaying = () => setIsBuffering(false);
+
+ video.addEventListener("timeupdate", onTimeUpdate);
+ video.addEventListener("durationchange", onDurationChange);
+ video.addEventListener("play", onPlay);
+ video.addEventListener("pause", onPause);
+ video.addEventListener("canplay", onCanPlay);
+ video.addEventListener("waiting", onWaiting);
+ video.addEventListener("playing", onPlaying);
+
+ return () => {
+ video.removeEventListener("timeupdate", onTimeUpdate);
+ video.removeEventListener("durationchange", onDurationChange);
+ video.removeEventListener("play", onPlay);
+ video.removeEventListener("pause", onPause);
+ video.removeEventListener("canplay", onCanPlay);
+ video.removeEventListener("waiting", onWaiting);
+ video.removeEventListener("playing", onPlaying);
+ };
+ }, []);
+
+ // ── Fullscreen listener ──────────────────────────────────────────
+ useEffect(() => {
+ const onFsChange = () => {
+ setIsFullscreen(!!document.fullscreenElement);
+ };
+ document.addEventListener("fullscreenchange", onFsChange);
+ return () => document.removeEventListener("fullscreenchange", onFsChange);
+ }, []);
+
+ // ── Playback actions ─────────────────────────────────────────────
+ const play = useCallback(() => {
+ videoRef.current?.play();
+ }, []);
+
+ const pause = useCallback(() => {
+ videoRef.current?.pause();
+ }, []);
+
+ const togglePlay = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.paused ? video.play() : video.pause();
+ }, []);
+
+ const seek = useCallback((time: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
+ }, []);
+
+ const seekRelative = useCallback(
+ (delta: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ seek(video.currentTime + delta);
+ },
+ [seek]
+ );
+
+ const stepFrame = useCallback(
+ (direction: 1 | -1) => {
+ const video = videoRef.current;
+ if (!video || !video.paused) return;
+ const frameDuration = 1 / fps;
+ video.currentTime = Math.max(
+ 0,
+ Math.min(video.currentTime + direction * frameDuration, video.duration)
+ );
+ },
+ [fps]
+ );
+
+ const setPlaybackSpeed = useCallback((speed: PlaybackSpeed) => {
+ const video = videoRef.current;
+ if (video) video.playbackRate = speed;
+ setPlaybackSpeedState(speed);
+ }, []);
+
+ const cycleSpeedUp = useCallback(() => {
+ const idx = SPEEDS.indexOf(playbackSpeed);
+ if (idx < SPEEDS.length - 1) setPlaybackSpeed(SPEEDS[idx + 1]);
+ }, [playbackSpeed, setPlaybackSpeed]);
+
+ const cycleSpeedDown = useCallback(() => {
+ const idx = SPEEDS.indexOf(playbackSpeed);
+ if (idx > 0) setPlaybackSpeed(SPEEDS[idx - 1]);
+ }, [playbackSpeed, setPlaybackSpeed]);
+
+ const setVolume = useCallback((vol: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ const clamped = Math.max(0, Math.min(1, vol));
+ video.volume = clamped;
+ setVolumeState(clamped);
+ if (clamped > 0 && video.muted) {
+ video.muted = false;
+ setIsMuted(false);
+ }
+ }, []);
+
+ const toggleMute = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.muted = !video.muted;
+ setIsMuted(video.muted);
+ }, []);
+
+ const toggleFullscreen = useCallback(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ if (document.fullscreenElement) {
+ document.exitFullscreen();
+ } else {
+ container.requestFullscreen();
+ }
+ }, []);
+
+ const toggleLoop = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.loop = !video.loop;
+ setIsLooping(video.loop);
+ }, []);
+
+ // ── Keyboard shortcuts ───────────────────────────────────────────
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ )
+ return;
+
+ switch (e.key) {
+ case " ":
+ case "k":
+ case "K":
+ e.preventDefault();
+ togglePlay();
+ break;
+ case "j":
+ case "J":
+ e.preventDefault();
+ seekRelative(-5);
+ break;
+ case "l":
+ case "L":
+ e.preventDefault();
+ seekRelative(5);
+ break;
+ case "ArrowLeft":
+ e.preventDefault();
+ if (videoRef.current?.paused) {
+ stepFrame(-1);
+ } else {
+ seekRelative(-5);
+ }
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ if (videoRef.current?.paused) {
+ stepFrame(1);
+ } else {
+ seekRelative(5);
+ }
+ break;
+ case ",":
+ e.preventDefault();
+ stepFrame(-1);
+ break;
+ case ".":
+ e.preventDefault();
+ stepFrame(1);
+ break;
+ case "f":
+ case "F":
+ e.preventDefault();
+ toggleFullscreen();
+ break;
+ case "m":
+ case "M":
+ e.preventDefault();
+ toggleMute();
+ break;
+ case "[":
+ e.preventDefault();
+ cycleSpeedDown();
+ break;
+ case "]":
+ e.preventDefault();
+ cycleSpeedUp();
+ break;
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [
+ togglePlay,
+ seekRelative,
+ stepFrame,
+ toggleFullscreen,
+ toggleMute,
+ cycleSpeedUp,
+ cycleSpeedDown,
+ ]);
+
+ // ── Cleanup HLS on unmount ───────────────────────────────────────
+ useEffect(() => {
+ return () => {
+ if (hlsRef.current) {
+ hlsRef.current.destroy();
+ hlsRef.current = null;
+ }
+ };
+ }, []);
+
+ return {
+ videoRef,
+ containerRef,
+ currentTime,
+ duration,
+ isPlaying,
+ playbackSpeed,
+ volume,
+ isMuted,
+ isFullscreen,
+ isReady,
+ isBuffering,
+ fps,
+ play,
+ pause,
+ togglePlay,
+ seek,
+ seekRelative,
+ stepFrame,
+ setPlaybackSpeed,
+ cycleSpeedUp,
+ cycleSpeedDown,
+ setVolume,
+ toggleMute,
+ toggleFullscreen,
+ toggleLoop,
+ isLooping,
+ loadSource,
+ };
+}
diff --git a/src/lib/chat/tool-executor.ts b/src/lib/chat/tool-executor.ts
index f513544..e16da49 100644
--- a/src/lib/chat/tool-executor.ts
+++ b/src/lib/chat/tool-executor.ts
@@ -172,6 +172,7 @@ export async function executeTool(
where: { deliverableId: { in: relevantDeliverableIds } },
include: {
template: { select: { name: true, slug: true, order: true } },
+ stageDefinition: { select: { name: true, slug: true, order: true } },
},
orderBy: { template: { order: "asc" } },
});
@@ -181,9 +182,9 @@ export async function executeTool(
}
stagesMap.get(s.deliverableId)!.push({
stageId: s.id,
- stageName: s.template.name,
- stageSlug: s.template.slug,
- order: s.template.order,
+ stageName: s.stageDefinition?.name ?? s.template.name,
+ stageSlug: s.stageDefinition?.slug ?? s.template.slug,
+ order: s.stageDefinition?.order ?? s.template.order,
status: s.status,
subStatus: s.subStatus,
});
@@ -269,6 +270,7 @@ export async function executeTool(
stages: {
include: {
template: { select: { name: true, slug: true, order: true } },
+ stageDefinition: { select: { name: true, slug: true, order: true } },
},
orderBy: { template: { order: "asc" } },
},
@@ -289,9 +291,9 @@ export async function executeTool(
projectCode: d.project.projectCode,
stages: d.stages.map((s) => ({
stageId: s.id,
- stageName: s.template.name,
- stageSlug: s.template.slug,
- order: s.template.order,
+ stageName: s.stageDefinition?.name ?? s.template.name,
+ stageSlug: s.stageDefinition?.slug ?? s.template.slug,
+ order: s.stageDefinition?.order ?? s.template.order,
status: s.status,
subStatus: s.subStatus,
})),
diff --git a/src/lib/services/assignment-service.ts b/src/lib/services/assignment-service.ts
index c940976..968c920 100644
--- a/src/lib/services/assignment-service.ts
+++ b/src/lib/services/assignment-service.ts
@@ -93,6 +93,7 @@ export async function getMyWork(userId: string) {
deliverableStage: {
include: {
template: true,
+ stageDefinition: true,
deliverable: {
include: {
project: { select: { id: true, name: true, projectCode: true } },
diff --git a/src/lib/services/calendar-service.ts b/src/lib/services/calendar-service.ts
index 1ded363..3564695 100644
--- a/src/lib/services/calendar-service.ts
+++ b/src/lib/services/calendar-service.ts
@@ -51,6 +51,7 @@ export async function getCalendarEvents(filters: CalendarFilters) {
where,
include: {
template: true,
+ stageDefinition: true,
deliverable: {
include: {
project: true,
diff --git a/src/lib/services/deliverable-service.ts b/src/lib/services/deliverable-service.ts
index 2f1bc2f..4742a34 100644
--- a/src/lib/services/deliverable-service.ts
+++ b/src/lib/services/deliverable-service.ts
@@ -138,6 +138,7 @@ export async function listDeliverables(projectId: string) {
stages: {
include: {
template: true,
+ stageDefinition: { include: { dependsOn: true } },
assignments: { include: { user: true } },
},
orderBy: { template: { order: "asc" } },
@@ -154,6 +155,7 @@ export async function getDeliverable(id: string) {
stages: {
include: {
template: true,
+ stageDefinition: { include: { dependsOn: true } },
assignments: { include: { user: true } },
},
orderBy: { template: { order: "asc" } },
diff --git a/src/lib/services/embedding-service.ts b/src/lib/services/embedding-service.ts
index f0163ae..93537c8 100644
--- a/src/lib/services/embedding-service.ts
+++ b/src/lib/services/embedding-service.ts
@@ -71,7 +71,7 @@ export function buildDeliverableText(deliverable: {
notes?: string | null;
cmfSku?: string | null;
project?: { name: string; projectCode: string } | null;
- stages?: { template: { name: string }; status: string }[];
+ stages?: { template: { name: string }; stageDefinition?: { name: string } | null; status: string }[];
}): string {
const parts = [
`Deliverable: ${deliverable.name}`,
@@ -85,7 +85,7 @@ export function buildDeliverableText(deliverable: {
if (deliverable.stages?.length) {
const stageInfo = deliverable.stages
- .map((s) => `${s.template.name}: ${s.status}`)
+ .map((s) => `${s.stageDefinition?.name ?? s.template.name}: ${s.status}`)
.join("; ");
parts.push(`Pipeline stages: ${stageInfo}`);
}
@@ -176,8 +176,11 @@ export async function updateDeliverableEmbedding(
include: {
project: { select: { name: true, projectCode: true } },
stages: {
- include: { template: { select: { name: true } } },
- select: { status: true, template: true },
+ include: {
+ template: { select: { name: true } },
+ stageDefinition: { select: { name: true } },
+ },
+ select: { status: true, template: true, stageDefinition: true },
},
},
});
diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts
index 034a1d4..153b2b4 100644
--- a/src/lib/services/project-service.ts
+++ b/src/lib/services/project-service.ts
@@ -20,7 +20,7 @@ export async function getProject(id: string, organizationId: string) {
deliverables: {
include: {
stages: {
- include: { template: true },
+ include: { template: true, stageDefinition: true },
orderBy: { template: { order: "asc" } },
},
},
diff --git a/src/lib/services/upload-service.ts b/src/lib/services/upload-service.ts
index a7b6870..b5b17cd 100644
--- a/src/lib/services/upload-service.ts
+++ b/src/lib/services/upload-service.ts
@@ -1,18 +1,37 @@
-import { writeFile, mkdir, unlink } from "fs/promises";
-import { existsSync } from "fs";
+import { writeFile, mkdir, unlink, readdir, rm } from "fs/promises";
+import { createWriteStream, existsSync } from "fs";
import path from "path";
import sharp from "sharp";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
+import { prisma } from "@/lib/prisma";
+import {
+ extractMetadata,
+ extractThumbnail,
+ transcodeToHLS,
+} from "@/lib/services/video-service";
+/** Images stay in /public for static serving (backward compat) */
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "revisions");
-const ALLOWED_TYPES = [
+/** Videos go to a mounted volume, served via /api/uploads streaming route */
+const VIDEO_UPLOADS_DIR =
+ process.env.VIDEO_UPLOADS_DIR ||
+ (process.env.NODE_ENV === "production"
+ ? "/data/uploads/revisions"
+ : path.join(process.cwd(), "data", "uploads", "revisions"));
+
+const ALLOWED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/tiff",
];
-const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
+const ALLOWED_VIDEO_TYPES = ["video/mp4"];
+
+const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB
+const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500MB
export interface UploadedImage {
url: string;
@@ -38,14 +57,14 @@ export async function processAndStoreImage(
imageType: "reference" | "current" | "screenshot"
): Promise {
// Validate type
- if (!ALLOWED_TYPES.includes(file.type)) {
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
throw new Error(
`Unsupported file type: ${file.type}. Allowed: PNG, JPEG, WebP, TIFF.`
);
}
// Validate size
- if (file.size > MAX_FILE_SIZE) {
+ if (file.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 50MB.`
);
@@ -134,7 +153,6 @@ export async function deleteRevisionImage(
const revisionDir = path.join(UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) return;
- const { readdir } = await import("fs/promises");
const files = await readdir(revisionDir);
for (const file of files) {
@@ -143,3 +161,173 @@ export async function deleteRevisionImage(
}
}
}
+
+// ─── Video Upload ──────────────────────────────────────────────
+
+export type VideoType = "video" | "referenceVideo";
+
+export interface UploadedVideo {
+ url: string;
+ hlsUrl: string | null;
+ status: "processing" | "ready" | "failed";
+ thumbnailUrl: string | null;
+ filename: string;
+ size: number;
+ width: number;
+ height: number;
+ duration: number;
+ fps: number;
+ codec: string;
+ uploadedAt: string;
+}
+
+/**
+ * Process and store an uploaded video for a revision.
+ *
+ * Flow:
+ * 1. Validate type & size
+ * 2. Stream-write raw MP4 to disk (avoids buffering 500MB in memory)
+ * 3. Extract metadata via ffprobe (instant — headers only)
+ * 4. Extract thumbnail JPEG
+ * 5. Return immediately with status: "processing"
+ * 6. Kick off async HLS transcoding, update revision on completion
+ */
+export async function processAndStoreVideo(
+ revisionId: string,
+ file: File,
+ videoType: VideoType
+): Promise {
+ // Validate MIME type
+ if (!ALLOWED_VIDEO_TYPES.includes(file.type)) {
+ throw new Error(
+ `Unsupported file type: ${file.type}. Allowed: MP4.`
+ );
+ }
+
+ // Validate size
+ if (file.size > MAX_VIDEO_SIZE) {
+ throw new Error(
+ `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 500MB.`
+ );
+ }
+
+ // Ensure upload directory
+ const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
+ if (!existsSync(revisionDir)) {
+ await mkdir(revisionDir, { recursive: true });
+ }
+
+ const timestamp = Date.now();
+ const videoFilename = `${videoType}_${timestamp}.mp4`;
+ const videoPath = path.join(revisionDir, videoFilename);
+ const videoUrl = `/api/uploads/revisions/${revisionId}/${videoFilename}`;
+
+ // Stream-write the file to disk
+ const arrayBuffer = await file.arrayBuffer();
+ const nodeStream = Readable.from(Buffer.from(arrayBuffer));
+ await pipeline(nodeStream, createWriteStream(videoPath));
+
+ // Extract metadata (reads headers — fast; returns defaults if no FFmpeg)
+ const metadata = await extractMetadata(videoPath);
+
+ // Extract thumbnail (skipped if no FFmpeg)
+ const thumbFilename = `${videoType}_${timestamp}_thumb.jpg`;
+ const thumbPath = path.join(revisionDir, thumbFilename);
+ const thumbTime = Math.min(1, metadata.duration * 0.1 || 1);
+ const thumbCreated = await extractThumbnail(videoPath, thumbPath, thumbTime);
+ const thumbnailUrl = thumbCreated
+ ? `/api/uploads/revisions/${revisionId}/${thumbFilename}`
+ : null;
+
+ // HLS output paths
+ const hlsDir = path.join(revisionDir, `${videoType}_${timestamp}_hls`);
+ const hlsUrl = `/api/uploads/revisions/${revisionId}/${videoType}_${timestamp}_hls/index.m3u8`;
+
+ const uploaded: UploadedVideo = {
+ url: videoUrl,
+ hlsUrl: null,
+ status: "processing",
+ thumbnailUrl,
+ filename: file.name,
+ size: file.size,
+ width: metadata.width,
+ height: metadata.height,
+ duration: metadata.duration,
+ fps: metadata.fps,
+ codec: metadata.codec,
+ uploadedAt: new Date().toISOString(),
+ };
+
+ // Kick off async HLS transcoding — don't await
+ transcodeToHLS(videoPath, hlsDir)
+ .then(async (result) => {
+ if (!result) {
+ // FFmpeg not available — mark as ready with raw MP4 only (no HLS)
+ console.log(`[Video] FFmpeg unavailable — serving raw MP4 for ${revisionId}/${videoType}`);
+ }
+
+ // Update the revision attachments with HLS URL (if available) + ready status
+ const revision = await prisma.revision.findUnique({
+ where: { id: revisionId },
+ select: { attachments: true },
+ });
+ const attachments = (revision?.attachments as Record) ?? {};
+ const videoKey = videoType === "video" ? "video" : "referenceVideo";
+ const existing = attachments[videoKey] as Record | undefined;
+
+ if (existing) {
+ attachments[videoKey] = {
+ ...existing,
+ hlsUrl: result ? hlsUrl : null,
+ status: "ready",
+ };
+ await prisma.revision.update({
+ where: { id: revisionId },
+ data: { attachments },
+ });
+ }
+ console.log(`[Video] Processing complete for ${revisionId}/${videoType}${result ? " (HLS)" : " (MP4 only)"}`);
+ })
+ .catch(async (err) => {
+ console.error(`[Video] HLS transcoding failed for ${revisionId}:`, err);
+ // Mark as failed but keep raw MP4 as fallback
+ const revision = await prisma.revision.findUnique({
+ where: { id: revisionId },
+ select: { attachments: true },
+ });
+ const attachments = (revision?.attachments as Record) ?? {};
+ const videoKey = videoType === "video" ? "video" : "referenceVideo";
+ const existing = attachments[videoKey] as Record | undefined;
+
+ if (existing) {
+ attachments[videoKey] = { ...existing, status: "failed" };
+ await prisma.revision.update({
+ where: { id: revisionId },
+ data: { attachments },
+ }).catch(() => {});
+ }
+ });
+
+ return uploaded;
+}
+
+/**
+ * Delete uploaded video files and HLS segments for a revision.
+ */
+export async function deleteRevisionVideo(
+ revisionId: string,
+ videoType: VideoType
+): Promise {
+ const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
+ if (!existsSync(revisionDir)) return;
+
+ const files = await readdir(revisionDir);
+
+ for (const file of files) {
+ if (file.startsWith(`${videoType}_`)) {
+ const fullPath = path.join(revisionDir, file);
+ // Could be a directory (HLS segments) or a file
+ await rm(fullPath, { recursive: true, force: true }).catch(() => {});
+ }
+ }
+}
diff --git a/src/lib/services/video-service.ts b/src/lib/services/video-service.ts
new file mode 100644
index 0000000..475c20f
--- /dev/null
+++ b/src/lib/services/video-service.ts
@@ -0,0 +1,220 @@
+/**
+ * Video Service — FFmpeg/ffprobe wrapper for HLS transcoding,
+ * thumbnail extraction, and metadata parsing.
+ *
+ * Graceful degradation: when FFmpeg is not installed (e.g., local dev
+ * on Windows without FFmpeg), functions return sensible defaults instead
+ * of crashing. Videos still upload and play as raw MP4 — only HLS
+ * transcoding and thumbnail extraction are skipped.
+ */
+
+import { execFile } from "child_process";
+import { mkdir } from "fs/promises";
+import { existsSync } from "fs";
+import path from "path";
+import { promisify } from "util";
+
+const execFileAsync = promisify(execFile);
+
+/** Cached result of FFmpeg availability check */
+let _ffmpegAvailable: boolean | null = null;
+
+/**
+ * Check whether ffprobe/ffmpeg are available on PATH.
+ * Result is cached for the process lifetime.
+ */
+export async function isFFmpegAvailable(): Promise {
+ if (_ffmpegAvailable !== null) return _ffmpegAvailable;
+ try {
+ await execFileAsync("ffprobe", ["-version"]);
+ _ffmpegAvailable = true;
+ } catch {
+ console.warn(
+ "[Video] FFmpeg/ffprobe not found on PATH — video processing will be skipped. " +
+ "Install FFmpeg locally or run in Docker for full functionality."
+ );
+ _ffmpegAvailable = false;
+ }
+ return _ffmpegAvailable;
+}
+
+export interface VideoMetadata {
+ duration: number; // seconds
+ width: number;
+ height: number;
+ fps: number;
+ codec: string;
+}
+
+/**
+ * Extract video metadata using ffprobe.
+ * Reads headers only — fast even on large files.
+ * Returns defaults if FFmpeg is not available.
+ */
+export async function extractMetadata(
+ filePath: string
+): Promise {
+ if (!(await isFFmpegAvailable())) {
+ return { duration: 0, width: 0, height: 0, fps: 24, codec: "unknown" };
+ }
+
+ const { stdout } = await execFileAsync("ffprobe", [
+ "-v",
+ "quiet",
+ "-print_format",
+ "json",
+ "-show_format",
+ "-show_streams",
+ "-select_streams",
+ "v:0",
+ filePath,
+ ]);
+
+ const probe = JSON.parse(stdout);
+ const stream = probe.streams?.[0];
+ const format = probe.format;
+
+ if (!stream) {
+ throw new Error("No video stream found in file");
+ }
+
+ // Parse frame rate from r_frame_rate (e.g., "24/1" or "24000/1001")
+ let fps = 24;
+ if (stream.r_frame_rate) {
+ const [num, den] = stream.r_frame_rate.split("/").map(Number);
+ if (den && den > 0) fps = Math.round((num / den) * 100) / 100;
+ }
+
+ return {
+ duration: parseFloat(format?.duration ?? stream.duration ?? "0"),
+ width: stream.width ?? 0,
+ height: stream.height ?? 0,
+ fps,
+ codec: stream.codec_name ?? "unknown",
+ };
+}
+
+/**
+ * Extract a thumbnail JPEG from the video at a given time.
+ * Returns false if FFmpeg is not available (no thumbnail generated).
+ */
+export async function extractThumbnail(
+ inputPath: string,
+ outputPath: string,
+ timeSeconds: number = 1
+): Promise {
+ if (!(await isFFmpegAvailable())) return false;
+
+ const dir = path.dirname(outputPath);
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true });
+ }
+
+ await execFileAsync("ffmpeg", [
+ "-y",
+ "-ss",
+ String(timeSeconds),
+ "-i",
+ inputPath,
+ "-vframes",
+ "1",
+ "-vf",
+ "scale='min(400,iw)':-2",
+ "-q:v",
+ "3",
+ outputPath,
+ ]);
+ return true;
+}
+
+/**
+ * Transcode an MP4 to HLS (.m3u8 + .ts segments).
+ *
+ * Single quality tier matching source resolution, 6-second segments.
+ * Uses -preset fast for reasonable speed on server hardware.
+ * Returns null if FFmpeg is not available.
+ */
+export async function transcodeToHLS(
+ inputPath: string,
+ outputDir: string
+): Promise<{ playlistPath: string } | null> {
+ if (!(await isFFmpegAvailable())) return null;
+ if (!existsSync(outputDir)) {
+ await mkdir(outputDir, { recursive: true });
+ }
+
+ const playlistPath = path.join(outputDir, "index.m3u8");
+ const segmentPattern = path.join(outputDir, "segment_%03d.ts");
+
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-y",
+ "-i",
+ inputPath,
+ // Video: re-encode to H.264 for maximum browser compatibility
+ "-c:v",
+ "libx264",
+ "-preset",
+ "fast",
+ "-crf",
+ "23",
+ // Ensure keyframes align with segment boundaries for clean seeking
+ "-g",
+ "150", // keyframe every ~6s at typical frame rates
+ "-keyint_min",
+ "150",
+ "-sc_threshold",
+ "0",
+ // Audio: AAC passthrough or transcode
+ "-c:a",
+ "aac",
+ "-b:a",
+ "128k",
+ // HLS output
+ "-f",
+ "hls",
+ "-hls_time",
+ "6",
+ "-hls_list_size",
+ "0", // keep all segments in playlist
+ "-hls_segment_filename",
+ segmentPattern,
+ playlistPath,
+ ],
+ { timeout: 600_000 } // 10 minute timeout for large files
+ );
+
+ return { playlistPath };
+}
+
+/**
+ * Extract a single frame at a specific timestamp as JPEG.
+ * Used for server-side frame extraction fallback (A7.3).
+ */
+export async function extractFrame(
+ inputPath: string,
+ timeSeconds: number
+): Promise {
+ const { stdout } = await execFileAsync(
+ "ffmpeg",
+ [
+ "-ss",
+ String(timeSeconds),
+ "-i",
+ inputPath,
+ "-vframes",
+ "1",
+ "-f",
+ "image2",
+ "-c:v",
+ "mjpeg",
+ "-q:v",
+ "2",
+ "pipe:1",
+ ],
+ { encoding: "buffer", maxBuffer: 10 * 1024 * 1024 }
+ );
+
+ return stdout as unknown as Buffer;
+}