diff --git a/.env.example b/.env.example index 7a9a218..f847119 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index 6da28d3..5ffb0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ next-env.d.ts # uploaded assets (runtime-generated, not needed in repo) /public/uploads/ +/data/uploads/ /assets/review-images/ diff --git a/Dockerfile b/Dockerfile index 970188b..38c10f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 6333a43..cfd6164 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index 35fb915..da2149b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 42abbe7..8438e07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 222cfb1..8f11c58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -403,7 +403,7 @@ model DeliverableStage { feedbackItems FeedbackItem[] reviewSessionItems ReviewSessionItem[] - @@unique([deliverableId, templateId]) + @@unique([deliverableId, stageDefinitionId]) @@index([deliverableId]) @@index([stageDefinitionId]) @@index([organizationId]) diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index f36bacc..b7a2837 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -351,7 +351,7 @@ export default function DashboardPage() { className="flex items-center gap-3 text-sm" > - {item.template.name} + {item.stageDefinition?.name ?? item.template.name} on {item.deliverable.name} diff --git a/src/app/(app)/my-work/page.tsx b/src/app/(app)/my-work/page.tsx index d292ad3..e947724 100644 --- a/src/app/(app)/my-work/page.tsx +++ b/src/app/(app)/my-work/page.tsx @@ -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) => (

- {assignment.deliverableStage.template.name} + {assignment.deliverableStage.stageDefinition?.name ?? assignment.deliverableStage.template.name}

diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index 762f031..944dab2 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -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 (
- {stage.template.order}. + {stage.stageDefinition?.order ?? stage.template.order}. - {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name} {isGate && ( (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() {
- {selectedStage.template.order} + {selectedStage.stageDefinition?.order ?? selectedStage.template.order} - {selectedStage.template.name} + {selectedStage.stageDefinition?.name ?? selectedStage.template.name}
@@ -444,8 +499,32 @@ export default function ReviewPage() { {/* Right: actions */}
+ {/* Image/Video toggle */} + {hasImageAttachment && hasVideoAttachment && ( +
+ + +
+ )} + {/* Compare toggle */} - {!comparisonActive && galleryImages.length >= 2 && ( + {!comparisonActive && galleryImages.length >= 2 && viewerMode === "image" && (
+ +
+

+ Video +

+ +
+
+

+ Reference Video +

+ +
) : (
@@ -567,6 +671,15 @@ export default function ReviewPage() { flipB={flipB} className="min-h-0 flex-1" /> + ) : viewerMode === "video" && activeVideo ? ( + ) : ( }; +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 }); diff --git a/src/app/api/uploads/[...path]/route.ts b/src/app/api/uploads/[...path]/route.ts new file mode 100644 index 0000000..6a6f974 --- /dev/null +++ b/src/app/api/uploads/[...path]/route.ts @@ -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 = { + ".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, + }, + }); +} diff --git a/src/components/calendar/calendar-event-pill.tsx b/src/components/calendar/calendar-event-pill.tsx index 8caa140..303c8ad 100644 --- a/src/components/calendar/calendar-event-pill.tsx +++ b/src/components/calendar/calendar-event-pill.tsx @@ -84,7 +84,7 @@ export function CalendarEventPill({
Stage:{" "} - {event.template.name} + {event.stageDefinition?.name ?? event.template.name}
{event.startDate && event.dueDate && (
@@ -142,7 +142,7 @@ export function CalendarEventPill({ {event.deliverable.name}
- {event.template.name} + {event.stageDefinition?.name ?? event.template.name} {event.assignments.length > 0 && ` \u00b7 ${event.assignments.map((a) => a.user.name).join(", ")}`}
diff --git a/src/components/calendar/calendar-grid.tsx b/src/components/calendar/calendar-grid.tsx index 4a15984..e51d95c 100644 --- a/src/components/calendar/calendar-grid.tsx +++ b/src/components/calendar/calendar-grid.tsx @@ -552,7 +552,7 @@ export function CalendarGrid({ {seg.event.deliverable.project.projectCode} {" \u00b7 "} - {seg.event.template.name} + {seg.event.stageDefinition?.name ?? seg.event.template.name} {seg.continuesRight && ( @@ -599,7 +599,7 @@ export function CalendarGrid({ Stage: {" "} - {seg.event.template.name} + {seg.event.stageDefinition?.name ?? seg.event.template.name} {seg.event.startDate && diff --git a/src/components/deliverables/deliverable-form-dialog.tsx b/src/components/deliverables/deliverable-form-dialog.tsx index d570c40..29e27ef 100644 --- a/src/components/deliverables/deliverable-form-dialog.tsx +++ b/src/components/deliverables/deliverable-form-dialog.tsx @@ -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) })} /> diff --git a/src/components/deliverables/pipeline-progress.tsx b/src/components/deliverables/pipeline-progress.tsx index 807808e..6369d0a 100644 --- a/src/components/deliverables/pipeline-progress.tsx +++ b/src/components/deliverables/pipeline-progress.tsx @@ -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 = { @@ -21,7 +22,9 @@ const STATUS_COLORS: Record = { }; 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[] }) { /> - {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name} {stage.status.replace(/_/g, " ")} diff --git a/src/components/projects/project-form-dialog.tsx b/src/components/projects/project-form-dialog.tsx index 198ae4f..eeb2031 100644 --- a/src/components/projects/project-form-dialog.tsx +++ b/src/components/projects/project-form-dialog.tsx @@ -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) })} />
@@ -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) })} />
diff --git a/src/components/review/session-builder.tsx b/src/components/review/session-builder.tsx index 6b81b0e..581574f 100644 --- a/src/components/review/session-builder.tsx +++ b/src/components/review/session-builder.tsx @@ -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} - — {stage.template.name} + — {stage.stageDefinition?.name ?? stage.template.name}
diff --git a/src/components/review/session-presenter.tsx b/src/components/review/session-presenter.tsx index b0e27ba..0294c34 100644 --- a/src/components/review/session-presenter.tsx +++ b/src/components/review/session-presenter.tsx @@ -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({
- {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name}
@@ -558,7 +559,7 @@ export function SessionPresenter({ {item.deliverableStage.deliverable.name} —{" "} - {item.deliverableStage.template.name} + {item.deliverableStage.stageDefinition?.name ?? item.deliverableStage.template.name} ); diff --git a/src/components/review/session-summary.tsx b/src/components/review/session-summary.tsx index a13a689..7c0dc21 100644 --- a/src/components/review/session-summary.tsx +++ b/src/components/review/session-summary.tsx @@ -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) {

- {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name}
diff --git a/src/components/review/video-controls.tsx b/src/components/review/video-controls.tsx new file mode 100644 index 0000000..7ffb943 --- /dev/null +++ b/src/components/review/video-controls.tsx @@ -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 ( +
+ {/* Left group: playback controls + timecode */} +
+ {/* Frame back */} + + + + + + Previous frame (←) + + + + {/* Skip back 5s */} + + + + + + -5 seconds (J) + + + + {/* Play/Pause */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + + {/* Skip forward 5s */} + + + + + + +5 seconds (L) + + + + {/* Frame forward */} + + + + + + Next frame (→) + + + + {/* Timecode */} +
+ +
+
+ + {/* Spacer */} +
+ + {/* Right group: speed, loop, volume, fullscreen */} +
+ {/* Playback speed */} + + + + + + Speed ([ / ]) + + + + {/* Loop */} + + + + + + Loop + + + + {/* Volume */} +
+ + + + + + Mute (M) + + + 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)]" + /> +
+ + {/* Fullscreen */} + + + + + + Fullscreen (F) + + +
+
+ ); +} diff --git a/src/components/review/video-frame-display.tsx b/src/components/review/video-frame-display.tsx new file mode 100644 index 0000000..e0babfe --- /dev/null +++ b/src/components/review/video-frame-display.tsx @@ -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 ( + + {toTimecode(currentTime, fps)} + / + {toTimecode(duration, fps)} + + ); +} diff --git a/src/components/review/video-player.tsx b/src/components/review/video-player.tsx new file mode 100644 index 0000000..42e078b --- /dev/null +++ b/src/components/review/video-player.tsx @@ -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 ( +
+ {/* Video viewport */} +
+ {!hasSource && !isProcessing && ( +
+ +

No video loaded

+

+ Upload a video to start reviewing +

+
+ )} + + {isProcessing && ( +
+ +

Processing video...

+

+ HLS transcoding in progress — playback will start automatically +

+
+ )} + + {player.isBuffering && hasSource && ( +
+ +
+ )} + +
+ + {/* Timeline + Controls bar */} + {hasSource && ( +
e.stopPropagation()} + > + + +
+ )} +
+ ); +} diff --git a/src/components/review/video-timeline.tsx b/src/components/review/video-timeline.tsx new file mode 100644 index 0000000..516a855 --- /dev/null +++ b/src/components/review/video-timeline.tsx @@ -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(null); + const [hoverTime, setHoverTime] = useState(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 ( +
+ {/* Hover time tooltip */} + {hoverTime !== null && ( +
+ {formatTime(hoverTime)} +
+ )} + + {/* Track */} +
+ {/* Buffered range */} +
+ + {/* Progress */} +
+ + {/* Playhead */} +
+ + {/* Annotation markers (A7.3 prep) */} + {markers?.map((marker, i) => { + const pos = duration > 0 ? (marker.time / duration) * 100 : 0; + return ( +
+ ); + })} +
+
+ ); +} diff --git a/src/components/review/video-upload-zone.tsx b/src/components/review/video-upload-zone.tsx new file mode 100644 index 0000000..2c64a15 --- /dev/null +++ b/src/components/review/video-upload-zone.tsx @@ -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(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((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) => { + 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 ( +
+
+ {existingVideo.thumbnailUrl ? ( + {existingVideo.filename} + ) : ( +
+ +
+ )} + + {/* Status badge */} +
+ {existingVideo.status === "processing" && ( + + + Processing + + )} + {existingVideo.status === "ready" && ( + + Ready + + )} + {existingVideo.status === "failed" && ( + + Failed + + )} + + {formatDuration(existingVideo.duration)} + +
+ + {/* Hover overlay with actions */} +
+ + +
+
+ +
+ ); + } + + // Empty state: drop zone + return ( +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => !isUploading && fileInputRef.current?.click()} + role="button" + tabIndex={0} + > + {isUploading ? ( +
+ +

Uploading… {uploadProgress}%

+
+
+
+
+ ) : ( + <> + +

+ {videoType === "video" ? "Video" : "Reference Video"} +

+

+ Drop file or click to browse +

+

+ MP4 — up to 500MB +

+ + )} + +
+ ); +} diff --git a/src/components/stages/stage-detail-sheet.tsx b/src/components/stages/stage-detail-sheet.tsx index 3ac2c32..d9c09d2 100644 --- a/src/components/stages/stage-detail-sheet.tsx +++ b/src/components/stages/stage-detail-sheet.tsx @@ -37,12 +37,12 @@ export function StageDetailSheet({
- {stage.template.order}. + {stage.stageDefinition?.order ?? stage.template.order}. - {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name} - {stage.template.isCriticalGate && ( + {(stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate) && ( { 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 && ( - {stage.template.name} + {stage.stageDefinition?.name ?? stage.template.name} )} @@ -405,7 +409,7 @@ export function GanttTimeline({
-

{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; +}