Merge pull request #3 from packman86/feature/video-review

Feature/video review
This commit is contained in:
Leivur R. Djurhuus 2026-03-18 12:20:14 -05:00 committed by GitHub
commit 35c19f0cfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2082 additions and 70 deletions

View file

@ -24,6 +24,9 @@ ANTHROPIC_API_KEY=""
# Cron / Scheduler
CRON_SECRET="" # Secret for /api/cron/* endpoints. Generate with: openssl rand -hex 32
# Video uploads — override storage directory (default: /data/uploads in prod, ./data/uploads in dev)
# VIDEO_UPLOADS_DIR="/data/uploads"
# Ollama (AI — embeddings, search, chat fallback)
# Local Ollama instance for embeddings, LLM summarization, and chat fallback.
# No data leaves the network. Zero ongoing AI costs.

1
.gitignore vendored
View file

@ -46,4 +46,5 @@ next-env.d.ts
# uploaded assets (runtime-generated, not needed in repo)
/public/uploads/
/data/uploads/
/assets/review-images/

View file

@ -1,5 +1,8 @@
FROM node:22-alpine AS base
# FFmpeg for video transcoding (HLS), thumbnail extraction, and metadata
RUN apk add --no-cache ffmpeg
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
@ -38,6 +41,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src/generated ./src/generated
# Create uploads directory for video/media storage (mounted as volume)
RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads
USER nextjs
EXPOSE 3000

View file

@ -59,6 +59,8 @@ services:
NODE_ENV: production
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
volumes:
- uploads_data:/data/uploads
depends_on:
db:
condition: service_healthy
@ -70,3 +72,4 @@ services:
volumes:
pgdata:
ollama_data:
uploads_data:

7
package-lock.json generated
View file

@ -26,6 +26,7 @@
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
@ -9202,6 +9203,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/hono": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",

View file

@ -37,6 +37,7 @@
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",

View file

@ -403,7 +403,7 @@ model DeliverableStage {
feedbackItems FeedbackItem[]
reviewSessionItems ReviewSessionItem[]
@@unique([deliverableId, templateId])
@@unique([deliverableId, stageDefinitionId])
@@index([deliverableId])
@@index([stageDefinitionId])
@@index([organizationId])

View file

@ -351,7 +351,7 @@ export default function DashboardPage() {
className="flex items-center gap-3 text-sm"
>
<CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--status-approved)]" />
<span className="font-medium">{item.template.name}</span>
<span className="font-medium">{item.stageDefinition?.name ?? item.template.name}</span>
<span className="text-[var(--muted-foreground)]">
on {item.deliverable.name}
</span>

View file

@ -15,6 +15,7 @@ interface Assignment {
id: string;
status: string;
template: { name: string; order: number };
stageDefinition?: { name: string; order: number } | null;
deliverable: {
id: string;
name: string;
@ -83,8 +84,8 @@ export default function MyWorkPage() {
{items
.sort(
(a, b) =>
a.deliverableStage.template.order -
b.deliverableStage.template.order
(a.deliverableStage.stageDefinition?.order ?? a.deliverableStage.template.order) -
(b.deliverableStage.stageDefinition?.order ?? b.deliverableStage.template.order)
)
.map((assignment) => (
<div
@ -97,7 +98,7 @@ export default function MyWorkPage() {
{assignment.deliverableStage.deliverable.name}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{assignment.deliverableStage.template.name}
{assignment.deliverableStage.stageDefinition?.name ?? assignment.deliverableStage.template.name}
</p>
</div>
</div>

View file

@ -112,7 +112,7 @@ export default function DeliverableDetailPage() {
}
const stages = (deliverable.stages ?? []).sort(
(a: any, b: any) => a.template.order - b.template.order
(a: any, b: any) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
);
return (
@ -236,8 +236,8 @@ export default function DeliverableDetailPage() {
{stages.map((stage: any, idx: number) => {
const available = TRANSITIONS[stage.status] ?? [];
const assignments = stage.assignments ?? [];
const isGate = stage.template.isCriticalGate;
const isOptional = stage.template.isOptional;
const isGate = stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate;
const isOptional = stage.stageDefinition?.isOptional ?? stage.template.isOptional;
return (
<Card
key={stage.id}
@ -270,10 +270,10 @@ export default function DeliverableDetailPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-[var(--muted-foreground)]">
{stage.template.order}.
{stage.stageDefinition?.order ?? stage.template.order}.
</span>
<CardTitle className="text-sm font-semibold">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</CardTitle>
{isGate && (
<Badge
@ -311,7 +311,7 @@ export default function DeliverableDetailPage() {
{/* Dates — clickable popover for overrides */}
<StageDatePopover
stageId={stage.id}
stageName={stage.template.name}
stageName={stage.stageDefinition?.name ?? stage.template.name}
startDate={stage.startDate}
dueDate={stage.dueDate}
completedDate={stage.completedDate}

View file

@ -13,6 +13,8 @@ import {
Columns2,
Loader2,
Images,
Film,
ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -33,8 +35,10 @@ import {
type ComparisonMode,
} from "@/components/review/comparison-toolbar";
import { ImageUploadZone } from "@/components/review/image-upload-zone";
import { VideoUploadZone } from "@/components/review/video-upload-zone";
import { ImageGallery } from "@/components/review/image-gallery";
import { AnnotationLayer } from "@/components/review/annotation-layer";
import { VideoPlayer } from "@/components/review/video-player";
import { ReviewSidebar } from "@/components/review/review-sidebar";
import { useDeliverable } from "@/hooks/use-deliverables";
import { useRevisions, useCreateRevision } from "@/hooks/use-revisions";
@ -52,9 +56,24 @@ interface AttachedImage {
originalUrl?: string;
}
interface AttachedVideo {
url: string;
hlsUrl: string | null;
status: "processing" | "ready" | "failed";
thumbnailUrl: string | null;
filename: string;
size: number;
duration: number;
fps: number;
width: number;
height: number;
}
interface RevisionAttachments {
referenceImage?: AttachedImage;
currentImage?: AttachedImage;
video?: AttachedVideo;
referenceVideo?: AttachedVideo;
}
interface RevisionImage {
@ -84,6 +103,9 @@ export default function ReviewPage() {
const [uploadPanelOpen, setUploadPanelOpen] = useState(false);
const [activeImageUrl, setActiveImageUrl] = useState<string | null>(null);
// ── Viewer mode: image or video ─────────────────────────────────────
const [viewerMode, setViewerMode] = useState<"image" | "video">("image");
// ── Gallery strip state ──────────────────────────────────────────────
const [galleryOpen, setGalleryOpen] = useState(true);
@ -105,7 +127,7 @@ export default function ReviewPage() {
const stages = useMemo(() => {
if (!deliverable?.stages) return [];
return [...deliverable.stages].sort(
(a: any, b: any) => a.template.order - b.template.order
(a: any, b: any) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
);
}, [deliverable]);
@ -232,6 +254,39 @@ export default function ReviewPage() {
return match?.revisionId ?? null;
}, [galleryImages, activeImageUrl]);
// ── Active video attachment (for video mode) ──────────────────────
const activeVideo = useMemo(() => {
if (!activeRevisionId) return null;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
if (!rev) return null;
const att = rev.attachments as RevisionAttachments | null;
return att?.video ?? null;
}, [activeRevisionId, revisions]);
// Auto-switch to video mode when a video exists but no image
useEffect(() => {
if (!activeRevisionId) return;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
const att = rev?.attachments as RevisionAttachments | null;
const hasImage = !!(att?.currentImage || att?.referenceImage);
const hasVideo = !!att?.video;
if (hasVideo && !hasImage) {
setViewerMode("video");
} else if (hasImage && !hasVideo) {
setViewerMode("image");
}
// When both exist: keep the user's current selection
}, [activeRevisionId, revisions]);
const hasImageAttachment = useMemo(() => {
if (!activeRevisionId) return false;
const rev = revisions.find((r: any) => r.id === activeRevisionId);
const att = rev?.attachments as RevisionAttachments | null;
return !!(att?.currentImage || att?.referenceImage);
}, [activeRevisionId, revisions]);
const hasVideoAttachment = !!activeVideo;
// ── Image URLs for CMF probe sampling ──────────────────────────────
const { workingImageUrl, referenceImageUrl } = useMemo(() => {
if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
@ -423,10 +478,10 @@ export default function ReviewPage() {
</Button>
<div className="flex items-center gap-1.5 rounded-md border bg-[var(--background)] px-2.5 py-1">
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
{selectedStage.template.order}
{selectedStage.stageDefinition?.order ?? selectedStage.template.order}
</span>
<span className="text-xs font-medium">
{selectedStage.template.name}
{selectedStage.stageDefinition?.name ?? selectedStage.template.name}
</span>
<StageStatusBadge status={selectedStage.status} />
</div>
@ -444,8 +499,32 @@ export default function ReviewPage() {
{/* Right: actions */}
<div className="flex items-center gap-1.5">
{/* Image/Video toggle */}
{hasImageAttachment && hasVideoAttachment && (
<div className="flex items-center rounded-md border bg-[var(--background)]">
<Button
size="sm"
variant={viewerMode === "image" ? "default" : "ghost"}
className="h-7 rounded-r-none px-2 text-xs"
onClick={() => setViewerMode("image")}
>
<ImageIcon className="mr-1 h-3 w-3" />
Image
</Button>
<Button
size="sm"
variant={viewerMode === "video" ? "default" : "ghost"}
className="h-7 rounded-l-none px-2 text-xs"
onClick={() => setViewerMode("video")}
>
<Film className="mr-1 h-3 w-3" />
Video
</Button>
</div>
)}
{/* Compare toggle */}
{!comparisonActive && galleryImages.length >= 2 && (
{!comparisonActive && galleryImages.length >= 2 && viewerMode === "image" && (
<Button
size="sm"
variant="outline"
@ -467,9 +546,9 @@ export default function ReviewPage() {
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:max-w-sm">
<SheetHeader>
<SheetTitle className="text-sm">Upload Images</SheetTitle>
<SheetTitle className="text-sm">Upload Media</SheetTitle>
<SheetDescription className="sr-only">
Upload reference and current render images for review
Upload images and video for review
</SheetDescription>
</SheetHeader>
<Separator className="my-3" />
@ -503,6 +582,31 @@ export default function ReviewPage() {
onUploadComplete={handleUploadComplete}
/>
</div>
<Separator className="my-2" />
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Video
</p>
<VideoUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
videoType="video"
existingVideo={latestAttachments.video}
onUploadComplete={handleUploadComplete}
/>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Reference Video
</p>
<VideoUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
videoType="referenceVideo"
existingVideo={latestAttachments.referenceVideo}
onUploadComplete={handleUploadComplete}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-8 text-center">
@ -567,6 +671,15 @@ export default function ReviewPage() {
flipB={flipB}
className="min-h-0 flex-1"
/>
) : viewerMode === "video" && activeVideo ? (
<VideoPlayer
hlsUrl={activeVideo.hlsUrl}
mp4Url={activeVideo.url}
posterUrl={activeVideo.thumbnailUrl}
fps={activeVideo.fps || 24}
status={activeVideo.status}
className="min-h-0 flex-1"
/>
) : (
<ImageViewer
src={imageOverride ?? activeImageUrl}

View file

@ -4,14 +4,27 @@ import { prisma } from "@/lib/prisma";
import {
processAndStoreImage,
deleteRevisionImage,
processAndStoreVideo,
deleteRevisionVideo,
} from "@/lib/services/upload-service";
import type { UploadedImage } from "@/lib/services/upload-service";
import type { UploadedImage, UploadedVideo, VideoType } from "@/lib/services/upload-service";
type Params = { params: Promise<{ stageId: string; revisionId: string }> };
type UploadType = "reference" | "current" | "screenshot" | "video" | "referenceVideo";
const IMAGE_TYPES = ["reference", "current", "screenshot"] as const;
const VIDEO_TYPES = ["video", "referenceVideo"] as const;
const ALL_TYPES = [...IMAGE_TYPES, ...VIDEO_TYPES] as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Prisma JSON field
type JsonValue = any;
interface Attachments {
referenceImage?: UploadedImage;
currentImage?: UploadedImage;
video?: UploadedVideo;
referenceVideo?: UploadedVideo;
}
// POST /api/stages/:stageId/revisions/:revisionId/upload
@ -30,14 +43,37 @@ export async function POST(request: Request, { params }: Params) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const imageType = formData.get("type") as "reference" | "current" | "screenshot" | null;
const uploadType = formData.get("type") as UploadType | null;
if (!file) return badRequest("No file provided");
if (!imageType || !["reference", "current", "screenshot"].includes(imageType)) {
return badRequest('Image type must be "reference", "current", or "screenshot"');
if (!uploadType || !ALL_TYPES.includes(uploadType as (typeof ALL_TYPES)[number])) {
return badRequest(
`Type must be one of: ${ALL_TYPES.join(", ")}`
);
}
// Process and store the image
// ─── Video upload path ─────────────────────────────────
if ((VIDEO_TYPES as readonly string[]).includes(uploadType)) {
const videoType = uploadType as VideoType;
const uploaded = await processAndStoreVideo(revisionId, file, videoType);
// Update revision attachments with video metadata
const existing = (revision.attachments as Attachments) ?? {};
const updated: Attachments = {
...existing,
[videoType]: uploaded,
};
await prisma.revision.update({
where: { id: revisionId },
data: { attachments: updated as JsonValue },
});
return NextResponse.json(uploaded, { status: 201 });
}
// ─── Image upload path (existing) ──────────────────────
const imageType = uploadType as "reference" | "current" | "screenshot";
const uploaded = await processAndStoreImage(revisionId, file, imageType);
// Screenshots are stored as annotation data, not in revision attachments
@ -54,7 +90,7 @@ export async function POST(request: Request, { params }: Params) {
await prisma.revision.update({
where: { id: revisionId },
data: { attachments: updated },
data: { attachments: updated as JsonValue },
});
return NextResponse.json(uploaded, { status: 201 });
@ -69,7 +105,7 @@ export async function POST(request: Request, { params }: Params) {
}
}
// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current
// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current|video|referenceVideo
export async function DELETE(request: Request, { params }: Params) {
const { error } = await getAuthSession();
if (error) return error;
@ -77,10 +113,13 @@ export async function DELETE(request: Request, { params }: Params) {
try {
const { stageId, revisionId } = await params;
const url = new URL(request.url);
const imageType = url.searchParams.get("type") as "reference" | "current" | null;
const deleteType = url.searchParams.get("type") as string | null;
if (!imageType || !["reference", "current"].includes(imageType)) {
return badRequest('Query param "type" must be "reference" or "current"');
const validDeleteTypes = ["reference", "current", "video", "referenceVideo"];
if (!deleteType || !validDeleteTypes.includes(deleteType)) {
return badRequest(
`Query param "type" must be one of: ${validDeleteTypes.join(", ")}`
);
}
const revision = await prisma.revision.findFirst({
@ -88,17 +127,32 @@ export async function DELETE(request: Request, { params }: Params) {
});
if (!revision) return notFound("Revision not found");
// Delete files from disk
const existing = (revision.attachments as Attachments) ?? {};
// ─── Video delete path ─────────────────────────────────
if ((VIDEO_TYPES as readonly string[]).includes(deleteType)) {
const videoType = deleteType as VideoType;
await deleteRevisionVideo(revisionId, videoType);
const { [videoType]: _removed, ...rest } = existing;
await prisma.revision.update({
where: { id: revisionId },
data: { attachments: Object.keys(rest).length > 0 ? (rest as JsonValue) : null },
});
return NextResponse.json({ ok: true });
}
// ─── Image delete path (existing) ──────────────────────
const imageType = deleteType as "reference" | "current";
await deleteRevisionImage(revisionId, imageType);
// Remove from attachments JSON
const existing = (revision.attachments as Attachments) ?? {};
const key = imageType === "reference" ? "referenceImage" : "currentImage";
const { [key]: _removed, ...rest } = existing;
await prisma.revision.update({
where: { id: revisionId },
data: { attachments: Object.keys(rest).length > 0 ? rest : null },
data: { attachments: Object.keys(rest).length > 0 ? (rest as JsonValue) : null },
});
return NextResponse.json({ ok: true });

View file

@ -0,0 +1,135 @@
/**
* Streaming file server for uploaded media (videos, HLS segments, thumbnails).
*
* Serves files from /data/uploads (or local dev equivalent) with proper
* MIME types, Range header support for seeking, and caching headers.
*/
import { NextRequest, NextResponse } from "next/server";
import { stat, open } from "fs/promises";
import path from "path";
const VIDEO_UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads"
: path.join(process.cwd(), "data", "uploads"));
const MIME_TYPES: Record<string, string> = {
".m3u8": "application/vnd.apple.mpegurl",
".ts": "video/mp2t",
".mp4": "video/mp4",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
type Params = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, { params }: Params) {
const segments = (await params).path;
const relativePath = segments.join("/");
// Prevent directory traversal
if (relativePath.includes("..")) {
return new NextResponse("Forbidden", { status: 403 });
}
const filePath = path.join(VIDEO_UPLOADS_DIR, relativePath);
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
return new NextResponse("Not found", { status: 404 });
}
if (!fileStat.isFile()) {
return new NextResponse("Not found", { status: 404 });
}
const fileSize = fileStat.size;
const rangeHeader = request.headers.get("range");
// HLS playlists and segments get aggressive caching
const cacheControl =
ext === ".m3u8"
? "public, max-age=2" // playlist: short cache, allows near-live updates
: ext === ".ts"
? "public, max-age=31536000, immutable" // segments never change
: "public, max-age=3600"; // thumbnails, MP4s: 1 hour
// Range request support (for MP4 seeking)
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) {
return new NextResponse("Invalid range", { status: 416 });
}
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
if (start >= fileSize || end >= fileSize) {
return new NextResponse("Range not satisfiable", {
status: 416,
headers: { "Content-Range": `bytes */${fileSize}` },
});
}
const chunkSize = end - start + 1;
const fileHandle = await open(filePath, "r");
const stream = fileHandle.createReadStream({ start, end });
// Convert Node stream to Web ReadableStream
const webStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk) => controller.enqueue(chunk));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
cancel() {
stream.destroy();
fileHandle.close();
},
});
return new NextResponse(webStream, {
status: 206,
headers: {
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Content-Length": String(chunkSize),
"Accept-Ranges": "bytes",
"Cache-Control": cacheControl,
},
});
}
// Full file response
const fileHandle = await open(filePath, "r");
const stream = fileHandle.createReadStream();
const webStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk) => controller.enqueue(chunk));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
cancel() {
stream.destroy();
fileHandle.close();
},
});
return new NextResponse(webStream, {
headers: {
"Content-Type": contentType,
"Content-Length": String(fileSize),
"Accept-Ranges": "bytes",
"Cache-Control": cacheControl,
},
});
}

View file

@ -84,7 +84,7 @@ export function CalendarEventPill({
</div>
<div className="text-xs">
<span className="text-muted-foreground">Stage:</span>{" "}
<span className="font-medium">{event.template.name}</span>
<span className="font-medium">{event.stageDefinition?.name ?? event.template.name}</span>
</div>
{event.startDate && event.dueDate && (
<div className="text-xs">
@ -142,7 +142,7 @@ export function CalendarEventPill({
{event.deliverable.name}
</div>
<div className="text-[10px] text-muted-foreground/70 mt-0.5">
{event.template.name}
{event.stageDefinition?.name ?? event.template.name}
{event.assignments.length > 0 &&
` \u00b7 ${event.assignments.map((a) => a.user.name).join(", ")}`}
</div>

View file

@ -552,7 +552,7 @@ export function CalendarGrid({
<span className="truncate">
{seg.event.deliverable.project.projectCode}
{" \u00b7 "}
{seg.event.template.name}
{seg.event.stageDefinition?.name ?? seg.event.template.name}
</span>
{seg.continuesRight && (
<span className="ml-auto shrink-0 opacity-60">
@ -599,7 +599,7 @@ export function CalendarGrid({
Stage:
</span>{" "}
<span className="font-medium">
{seg.event.template.name}
{seg.event.stageDefinition?.name ?? seg.event.template.name}
</span>
</div>
{seg.event.startDate &&

View file

@ -160,7 +160,7 @@ export function DeliverableFormDialog({
type="number"
min="0"
placeholder="0"
{...register("assetCount", { valueAsNumber: true })}
{...register("assetCount", { setValueAs: (v: string) => v === "" ? undefined : parseInt(v, 10) })}
/>
</div>

View file

@ -7,6 +7,7 @@ interface Stage {
id: string;
status: string;
template: { name: string; order: number; isCriticalGate: boolean; isOptional: boolean };
stageDefinition?: { name: string; order: number } | null;
}
const STATUS_COLORS: Record<string, string> = {
@ -21,7 +22,9 @@ const STATUS_COLORS: Record<string, string> = {
};
export function PipelineProgress({ stages }: { stages: Stage[] }) {
const sorted = [...stages].sort((a, b) => a.template.order - b.template.order);
const sorted = [...stages].sort(
(a, b) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
);
const completed = sorted.filter(
(s) =>
s.status === "APPROVED" ||
@ -50,7 +53,7 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) {
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<span className="font-medium">{stage.template.name}</span>
<span className="font-medium">{stage.stageDefinition?.name ?? stage.template.name}</span>
<span className="ml-1.5 opacity-70">
{stage.status.replace(/_/g, " ")}
</span>

View file

@ -293,7 +293,7 @@ export function ProjectFormDialog({
step="0.01"
min="0"
placeholder="0.00"
{...register("estimatedCost", { valueAsNumber: true })}
{...register("estimatedCost", { setValueAs: (v: string) => v === "" ? undefined : parseFloat(v) })}
/>
</div>
<div className="space-y-2">
@ -304,7 +304,7 @@ export function ProjectFormDialog({
step="0.01"
min="0"
placeholder="0.00"
{...register("actualCost", { valueAsNumber: true })}
{...register("actualCost", { setValueAs: (v: string) => v === "" ? undefined : parseFloat(v) })}
/>
</div>
</div>

View file

@ -54,6 +54,7 @@ interface SessionItem {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
deliverable: {
id: string;
name: string;
@ -299,7 +300,7 @@ export function SessionBuilder({
{deliverable.name}
</span>
<span className="text-[10px] text-[var(--muted-foreground)]">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2">

View file

@ -45,6 +45,7 @@ interface SessionItem {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
stageDefinition?: { id: string; name: string; slug: string; order: number } | null;
deliverable: {
id: string;
name: string;
@ -377,7 +378,7 @@ export function SessionPresenter({
</h2>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</span>
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
</div>
@ -558,7 +559,7 @@ export function SessionPresenter({
</TooltipTrigger>
<TooltipContent className="text-xs">
{item.deliverableStage.deliverable.name} {" "}
{item.deliverableStage.template.name}
{item.deliverableStage.stageDefinition?.name ?? item.deliverableStage.template.name}
</TooltipContent>
</Tooltip>
);

View file

@ -23,6 +23,7 @@ interface SessionItem {
id: string;
status: string;
template: { id: string; name: string; order: number };
stageDefinition?: { id: string; name: string; order: number } | null;
deliverable: {
id: string;
name: string;
@ -147,7 +148,7 @@ export function SessionSummary({ items, onItemClick }: SessionSummaryProps) {
</p>
<div className="mt-0.5 flex items-center gap-1">
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</span>
</div>
</div>

View file

@ -0,0 +1,272 @@
"use client";
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Maximize,
Minimize,
Repeat,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { VideoFrameDisplay } from "./video-frame-display";
import type { PlaybackSpeed } from "@/hooks/use-video-player";
interface VideoControlsProps {
currentTime: number;
duration: number;
fps: number;
isPlaying: boolean;
playbackSpeed: PlaybackSpeed;
volume: number;
isMuted: boolean;
isFullscreen: boolean;
isLooping: boolean;
onTogglePlay: () => void;
onSeekRelative: (delta: number) => void;
onStepFrame: (direction: 1 | -1) => void;
onSetPlaybackSpeed: (speed: PlaybackSpeed) => void;
onSetVolume: (vol: number) => void;
onToggleMute: () => void;
onToggleFullscreen: () => void;
onToggleLoop: () => void;
className?: string;
}
const SPEED_OPTIONS: PlaybackSpeed[] = [0.25, 0.5, 1, 1.5, 2];
export function VideoControls({
currentTime,
duration,
fps,
isPlaying,
playbackSpeed,
volume,
isMuted,
isFullscreen,
isLooping,
onTogglePlay,
onSeekRelative,
onStepFrame,
onSetPlaybackSpeed,
onSetVolume,
onToggleMute,
onToggleFullscreen,
onToggleLoop,
className,
}: VideoControlsProps) {
return (
<div
className={`flex items-center gap-1 ${className ?? ""}`}
>
{/* Left group: playback controls + timecode */}
<div className="flex items-center gap-0.5">
{/* Frame back */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onStepFrame(-1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Previous frame ()
</TooltipContent>
</Tooltip>
{/* Skip back 5s */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onSeekRelative(-5)}
>
<SkipBack className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
-5 seconds (J)
</TooltipContent>
</Tooltip>
{/* Play/Pause */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={onTogglePlay}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
{/* Skip forward 5s */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onSeekRelative(5)}
>
<SkipForward className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
+5 seconds (L)
</TooltipContent>
</Tooltip>
{/* Frame forward */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onStepFrame(1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Next frame ()
</TooltipContent>
</Tooltip>
{/* Timecode */}
<div className="ml-1.5">
<VideoFrameDisplay
currentTime={currentTime}
duration={duration}
fps={fps}
className="text-[var(--muted-foreground)]"
/>
</div>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right group: speed, loop, volume, fullscreen */}
<div className="flex items-center gap-0.5">
{/* Playback speed */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 px-1.5 text-[10px] font-mono tabular-nums"
onClick={() => {
const idx = SPEED_OPTIONS.indexOf(playbackSpeed);
const next = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
onSetPlaybackSpeed(next);
}}
>
{playbackSpeed}x
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Speed ([ / ])
</TooltipContent>
</Tooltip>
{/* Loop */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`h-7 w-7 p-0 ${isLooping ? "text-[var(--primary)]" : ""}`}
onClick={onToggleLoop}
>
<Repeat className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Loop
</TooltipContent>
</Tooltip>
{/* Volume */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onToggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-3.5 w-3.5" />
) : (
<Volume2 className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Mute (M)
</TooltipContent>
</Tooltip>
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={(e) => onSetVolume(parseFloat(e.target.value))}
className="h-1 w-14 cursor-pointer appearance-none rounded-full bg-[var(--muted)] accent-[var(--primary)] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary)]"
/>
</div>
{/* Fullscreen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onToggleFullscreen}
>
{isFullscreen ? (
<Minimize className="h-3.5 w-3.5" />
) : (
<Maximize className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Fullscreen (F)
</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
/**
* Timecode display in HH:MM:SS:FF format (frames based on fps).
*/
interface VideoFrameDisplayProps {
currentTime: number;
duration: number;
fps: number;
className?: string;
}
function toTimecode(seconds: number, fps: number): string {
if (!isFinite(seconds) || seconds < 0) return "00:00:00:00";
const totalFrames = Math.floor(seconds * fps);
const ff = totalFrames % fps;
const totalSeconds = Math.floor(seconds);
const ss = totalSeconds % 60;
const mm = Math.floor(totalSeconds / 60) % 60;
const hh = Math.floor(totalSeconds / 3600);
return `${pad(hh)}:${pad(mm)}:${pad(ss)}:${pad(ff)}`;
}
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
export function VideoFrameDisplay({
currentTime,
duration,
fps,
className,
}: VideoFrameDisplayProps) {
return (
<span className={`font-mono text-[10px] tabular-nums ${className ?? ""}`}>
{toTimecode(currentTime, fps)}
<span className="text-[var(--muted-foreground)] opacity-50"> / </span>
{toTimecode(duration, fps)}
</span>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Film, Loader2 } from "lucide-react";
import { useVideoPlayer } from "@/hooks/use-video-player";
import { VideoTimeline } from "./video-timeline";
import { VideoControls } from "./video-controls";
export interface VideoPlayerState {
currentTime: number;
duration: number;
isPlaying: boolean;
fps: number;
videoWidth: number;
videoHeight: number;
}
interface VideoPlayerProps {
/** HLS playlist URL (.m3u8) — preferred */
hlsUrl?: string | null;
/** Raw MP4 URL — fallback if HLS unavailable */
mp4Url?: string | null;
/** Poster/thumbnail image */
posterUrl?: string | null;
/** Video frame rate (for timecode + frame stepping) */
fps?: number;
/** Processing status from upload pipeline */
status?: "processing" | "ready" | "failed";
className?: string;
/** Render prop for overlays (annotations in A7.3) */
renderOverlay?: (state: VideoPlayerState) => React.ReactNode;
}
export function VideoPlayer({
hlsUrl,
mp4Url,
posterUrl,
fps = 24,
status,
className,
renderOverlay,
}: VideoPlayerProps) {
const player = useVideoPlayer(fps);
const [videoDimensions, setVideoDimensions] = useState({ width: 0, height: 0 });
// Load source when URLs change
useEffect(() => {
if (status === "ready" || !hlsUrl) {
player.loadSource(hlsUrl ?? null, mp4Url ?? null);
} else if (mp4Url) {
// HLS not ready yet — use MP4 fallback
player.loadSource(null, mp4Url);
}
}, [hlsUrl, mp4Url, status]); // eslint-disable-line react-hooks/exhaustive-deps
// Track video dimensions
const handleLoadedMetadata = useCallback(() => {
const video = player.videoRef.current;
if (video) {
setVideoDimensions({
width: video.videoWidth,
height: video.videoHeight,
});
}
}, [player.videoRef]);
// Compute buffered progress (0-1)
const buffered = useMemo(() => {
const video = player.videoRef.current;
if (!video || !video.buffered.length || !video.duration) return 0;
return video.buffered.end(video.buffered.length - 1) / video.duration;
}, [player.videoRef, player.currentTime]); // eslint-disable-line react-hooks/exhaustive-deps
const videoState: VideoPlayerState = {
currentTime: player.currentTime,
duration: player.duration,
isPlaying: player.isPlaying,
fps,
videoWidth: videoDimensions.width,
videoHeight: videoDimensions.height,
};
const hasSource = !!(hlsUrl || mp4Url);
const isProcessing = status === "processing";
return (
<div
ref={player.containerRef}
className={`relative flex flex-col bg-[#1a1a1a] ${className ?? ""}`}
>
{/* Video viewport */}
<div
className="relative flex-1 flex items-center justify-center overflow-hidden"
onClick={player.togglePlay}
>
{!hasSource && !isProcessing && (
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]">
<Film className="h-12 w-12 opacity-30" />
<p className="text-sm">No video loaded</p>
<p className="text-xs opacity-60">
Upload a video to start reviewing
</p>
</div>
)}
{isProcessing && (
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">Processing video...</p>
<p className="text-xs opacity-60">
HLS transcoding in progress playback will start automatically
</p>
</div>
)}
{player.isBuffering && hasSource && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-white/60" />
</div>
)}
<video
ref={player.videoRef}
className="max-h-full max-w-full"
poster={posterUrl ?? undefined}
playsInline
onLoadedMetadata={handleLoadedMetadata}
onDoubleClick={(e) => {
e.stopPropagation();
player.toggleFullscreen();
}}
/>
{/* Overlay slot for annotations (A7.3) */}
{renderOverlay?.(videoState)}
</div>
{/* Timeline + Controls bar */}
{hasSource && (
<div
className="shrink-0 border-t border-white/10 bg-[var(--card)] px-3 py-1.5"
onClick={(e) => e.stopPropagation()}
>
<VideoTimeline
currentTime={player.currentTime}
duration={player.duration}
buffered={buffered}
onSeek={player.seek}
className="mb-1.5"
/>
<VideoControls
currentTime={player.currentTime}
duration={player.duration}
fps={fps}
isPlaying={player.isPlaying}
playbackSpeed={player.playbackSpeed}
volume={player.volume}
isMuted={player.isMuted}
isFullscreen={player.isFullscreen}
isLooping={player.isLooping}
onTogglePlay={player.togglePlay}
onSeekRelative={player.seekRelative}
onStepFrame={player.stepFrame}
onSetPlaybackSpeed={player.setPlaybackSpeed}
onSetVolume={player.setVolume}
onToggleMute={player.toggleMute}
onToggleFullscreen={player.toggleFullscreen}
onToggleLoop={player.toggleLoop}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,138 @@
"use client";
import { useCallback, useRef, useState } from "react";
interface VideoTimelineProps {
currentTime: number;
duration: number;
buffered: number; // 0-1, how much is buffered
onSeek: (time: number) => void;
/** Optional annotation markers (for A7.3) */
markers?: { time: number; color: string }[];
className?: string;
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function VideoTimeline({
currentTime,
duration,
buffered,
onSeek,
markers,
className,
}: VideoTimelineProps) {
const trackRef = useRef<HTMLDivElement>(null);
const [hoverTime, setHoverTime] = useState<number | null>(null);
const [hoverX, setHoverX] = useState(0);
const [isScrubbing, setIsScrubbing] = useState(false);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const bufferedPct = duration > 0 ? buffered * 100 : 0;
const getTimeFromX = useCallback(
(clientX: number): number => {
const track = trackRef.current;
if (!track || duration <= 0) return 0;
const rect = track.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return pct * duration;
},
[duration]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
setIsScrubbing(true);
const time = getTimeFromX(e.clientX);
onSeek(time);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[getTimeFromX, onSeek]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const x = e.clientX - rect.left;
setHoverX(x);
setHoverTime(getTimeFromX(e.clientX));
if (isScrubbing) {
onSeek(getTimeFromX(e.clientX));
}
},
[getTimeFromX, isScrubbing, onSeek]
);
const handlePointerUp = useCallback(() => {
setIsScrubbing(false);
}, []);
const handlePointerLeave = useCallback(() => {
setHoverTime(null);
setIsScrubbing(false);
}, []);
return (
<div className={`group relative ${className ?? ""}`}>
{/* Hover time tooltip */}
{hoverTime !== null && (
<div
className="pointer-events-none absolute -top-7 z-10 -translate-x-1/2 rounded bg-[var(--popover)] px-1.5 py-0.5 text-[10px] font-mono text-[var(--popover-foreground)] shadow-md"
style={{ left: hoverX }}
>
{formatTime(hoverTime)}
</div>
)}
{/* Track */}
<div
ref={trackRef}
className="relative h-1.5 cursor-pointer rounded-full bg-[var(--muted)] transition-all group-hover:h-2.5"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
>
{/* Buffered range */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-[var(--foreground)]/10"
style={{ width: `${bufferedPct}%` }}
/>
{/* Progress */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-[var(--primary)]"
style={{ width: `${progress}%` }}
/>
{/* Playhead */}
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--primary)] opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
style={{ left: `${progress}%` }}
/>
{/* Annotation markers (A7.3 prep) */}
{markers?.map((marker, i) => {
const pos = duration > 0 ? (marker.time / duration) * 100 : 0;
return (
<div
key={i}
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ left: `${pos}%`, backgroundColor: marker.color }}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,259 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Upload, Film, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { VideoType } from "@/lib/services/upload-service";
interface ExistingVideo {
url: string;
thumbnailUrl: string | null;
filename: string;
duration: number;
status: "processing" | "ready" | "failed";
}
interface VideoUploadZoneProps {
stageId: string;
revisionId: string;
videoType: VideoType;
existingVideo?: ExistingVideo | null;
onUploadComplete: () => void;
}
function formatDuration(seconds: number): string {
if (!isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function VideoUploadZone({
stageId,
revisionId,
videoType,
existingVideo,
onUploadComplete,
}: VideoUploadZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = useCallback(
async (file: File) => {
setIsUploading(true);
setUploadProgress(0);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("type", videoType);
// Use XMLHttpRequest for progress tracking on large files
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(
"POST",
`/api/stages/${stageId}/revisions/${revisionId}/upload`
);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setUploadProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.error || "Upload failed"));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(formData);
});
const label = videoType === "video" ? "Video" : "Reference video";
toast.success(`${label} uploaded — transcoding in progress`);
onUploadComplete();
} catch (e) {
toast.error("Upload failed", {
description: e instanceof Error ? e.message : "Unknown error",
});
} finally {
setIsUploading(false);
setUploadProgress(0);
}
},
[stageId, revisionId, videoType, onUploadComplete]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
},
[uploadFile]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
e.target.value = "";
},
[uploadFile]
);
const handleDelete = useCallback(async () => {
try {
const res = await fetch(
`/api/stages/${stageId}/revisions/${revisionId}/upload?type=${videoType}`,
{ method: "DELETE" }
);
if (!res.ok) throw new Error("Delete failed");
toast.success("Video removed");
onUploadComplete();
} catch {
toast.error("Failed to remove video");
}
}, [stageId, revisionId, videoType, onUploadComplete]);
// Existing video: show thumbnail with status + actions
if (existingVideo) {
return (
<div className="group relative">
<div className="relative overflow-hidden rounded border">
{existingVideo.thumbnailUrl ? (
<img
src={existingVideo.thumbnailUrl}
alt={existingVideo.filename}
className="h-24 w-full object-cover"
/>
) : (
<div className="flex h-24 w-full items-center justify-center bg-[var(--muted)]">
<Film className="h-8 w-8 text-[var(--muted-foreground)]" />
</div>
)}
{/* Status badge */}
<div className="absolute bottom-1 left-1 flex items-center gap-1">
{existingVideo.status === "processing" && (
<span className="flex items-center gap-1 rounded bg-yellow-500/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</span>
)}
{existingVideo.status === "ready" && (
<span className="rounded bg-green-600/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
Ready
</span>
)}
{existingVideo.status === "failed" && (
<span className="rounded bg-red-600/80 px-1.5 py-0.5 text-[9px] font-medium text-white">
Failed
</span>
)}
<span className="rounded bg-black/60 px-1.5 py-0.5 font-mono text-[9px] text-white">
{formatDuration(existingVideo.duration)}
</span>
</div>
{/* Hover overlay with actions */}
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-white/20 hover:text-white"
onClick={() => fileInputRef.current?.click()}
>
Replace
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-red-500/50 hover:text-white"
onClick={handleDelete}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="video/mp4"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}
// Empty state: drop zone
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors",
isDragging
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--muted-foreground)]",
isUploading && "pointer-events-none"
)}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-[var(--primary)]" />
<p className="text-xs font-medium">Uploading {uploadProgress}%</p>
<div className="h-1 w-32 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<>
<Upload className="mb-2 h-6 w-6 text-[var(--muted-foreground)]" />
<p className="text-xs font-medium">
{videoType === "video" ? "Video" : "Reference Video"}
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
Drop file or click to browse
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
MP4 up to 500MB
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="video/mp4"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}

View file

@ -37,12 +37,12 @@ export function StageDetailSheet({
<SheetHeader>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-[var(--muted-foreground)]">
{stage.template.order}.
{stage.stageDefinition?.order ?? stage.template.order}.
</span>
<SheetTitle className="text-base">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</SheetTitle>
{stage.template.isCriticalGate && (
{(stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate) && (
<Badge
variant="outline"
className="h-5 gap-0.5 border-[var(--accent)] text-[10px] text-[var(--accent)]"

View file

@ -45,6 +45,10 @@ interface Stage {
name: string;
order: number;
};
stageDefinition?: {
name: string;
order: number;
} | null;
}
interface Deliverable {
@ -148,7 +152,7 @@ export function GanttTimeline({
const rows = useMemo(() => {
return deliverables.map((deliv) => {
const sortedStages = [...deliv.stages].sort(
(a, b) => a.template.order - b.template.order
(a, b) => (a.stageDefinition?.order ?? a.template.order) - (b.stageDefinition?.order ?? b.template.order)
);
const bars = sortedStages
@ -379,7 +383,7 @@ export function GanttTimeline({
{/* Stage name on bar (if wide enough) */}
{barPixelWidth > 60 && (
<span className="text-[8px] font-semibold text-white/90 pl-1.5 truncate">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</span>
)}
@ -405,7 +409,7 @@ export function GanttTimeline({
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-semibold">{stage.template.name}</p>
<p className="font-semibold">{stage.stageDefinition?.name ?? stage.template.name}</p>
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
{stage.startDate && (
<p className="text-[10px] text-[var(--muted-foreground)]">

View file

@ -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 && (
<span className="text-[7px] font-semibold text-white/90 pl-1 truncate">
{stage.template.name}
{stage.stageDefinition?.name ?? stage.template.name}
</span>
)}
@ -719,7 +720,7 @@ export function ProductionTimeline({
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-semibold">{stage.template.name}</p>
<p className="font-semibold">{stage.stageDefinition?.name ?? stage.template.name}</p>
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
{stage.assignments.length > 0 && (
<p className="text-[10px] text-[var(--muted-foreground)]">

View file

@ -21,6 +21,11 @@ export interface CalendarEvent {
name: string;
slug: string;
};
stageDefinition?: {
id: string;
name: string;
slug: string;
} | null;
deliverable: {
id: string;
name: string;

View file

@ -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<HTMLVideoElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
// 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<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeedState] = useState<PlaybackSpeed>(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,
};
}

View file

@ -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,
})),

View file

@ -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 } },

View file

@ -51,6 +51,7 @@ export async function getCalendarEvents(filters: CalendarFilters) {
where,
include: {
template: true,
stageDefinition: true,
deliverable: {
include: {
project: true,

View file

@ -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" } },

View file

@ -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 },
},
},
});

View file

@ -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" } },
},
},

View file

@ -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<UploadedImage> {
// 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<UploadedVideo> {
// 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<string, unknown>) ?? {};
const videoKey = videoType === "video" ? "video" : "referenceVideo";
const existing = attachments[videoKey] as Record<string, unknown> | 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<string, unknown>) ?? {};
const videoKey = videoType === "video" ? "video" : "referenceVideo";
const existing = attachments[videoKey] as Record<string, unknown> | 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<void> {
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(() => {});
}
}
}

View file

@ -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<boolean> {
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<VideoMetadata> {
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<boolean> {
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<Buffer> {
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;
}