From 2e87a5ff4da89bb29723e4a7a0e7eff0d5b12c91 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 17 Mar 2026 23:42:54 -0500 Subject: [PATCH 1/4] Add video upload with HLS streaming infrastructure (A7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FFmpeg in Docker for transcoding, thumbnail extraction, and metadata parsing. Videos stored in /data/uploads (mounted volume), served via streaming API route with Range headers and HLS segment caching. Upload flow: stream-write MP4 → ffprobe metadata → thumbnail → async HLS transcode → update revision status to ready. New files: - video-service.ts: FFmpeg/ffprobe wrapper (HLS, thumbnails, metadata) - /api/uploads/[...path]: streaming file server with Range support Modified: - upload-service.ts: video handling, 500MB limit, async HLS pipeline - upload route: accepts video/referenceVideo types - Dockerfile: ffmpeg + /data/uploads directory - docker-compose.yml: uploads_data volume Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 3 + .gitignore | 1 + Dockerfile | 6 + docker-compose.yml | 3 + .../revisions/[revisionId]/upload/route.ts | 82 ++++++-- src/app/api/uploads/[...path]/route.ts | 135 ++++++++++++ src/lib/services/upload-service.ts | 195 +++++++++++++++++- src/lib/services/video-service.ts | 182 ++++++++++++++++ 8 files changed, 586 insertions(+), 21 deletions(-) create mode 100644 src/app/api/uploads/[...path]/route.ts create mode 100644 src/lib/services/video-service.ts 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/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts index ea54d97..775c48f 100644 --- a/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts +++ b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts @@ -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 }); 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/lib/services/upload-service.ts b/src/lib/services/upload-service.ts index a7b6870..a1234f5 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,166 @@ 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) + const metadata = await extractMetadata(videoPath); + + // Extract thumbnail + const thumbFilename = `${videoType}_${timestamp}_thumb.jpg`; + const thumbPath = path.join(revisionDir, thumbFilename); + const thumbTime = Math.min(1, metadata.duration * 0.1); + await extractThumbnail(videoPath, thumbPath, thumbTime); + const thumbnailUrl = `/api/uploads/revisions/${revisionId}/${thumbFilename}`; + + // 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 () => { + // Update the revision attachments with HLS URL + 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, + status: "ready", + }; + await prisma.revision.update({ + where: { id: revisionId }, + data: { attachments }, + }); + } + console.log(`[Video] HLS transcoding complete for ${revisionId}/${videoType}`); + }) + .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..5c3063c --- /dev/null +++ b/src/lib/services/video-service.ts @@ -0,0 +1,182 @@ +/** + * Video Service — FFmpeg/ffprobe wrapper for HLS transcoding, + * thumbnail extraction, and metadata parsing. + */ + +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); + +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. + */ +export async function extractMetadata( + filePath: string +): Promise { + 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. + */ +export async function extractThumbnail( + inputPath: string, + outputPath: string, + timeSeconds: number = 1 +): Promise { + 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, + ]); +} + +/** + * 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. + */ +export async function transcodeToHLS( + inputPath: string, + outputDir: string +): Promise<{ playlistPath: string }> { + 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; +} From 4d78655ce218404b04b9f0d1a376d4858ffc74f7 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 17 Mar 2026 23:49:38 -0500 Subject: [PATCH 2/4] Add video player component with HLS streaming and review page integration (A7.2) Custom video player with hls.js for instant HLS playback with MP4 fallback. Full keyboard-driven controls matching NLE conventions: Space/K play/pause, J/L skip 5s, arrow/comma/period frame step, [/] speed, F fullscreen, M mute. Timecode display in HH:MM:SS:FF. Components: - video-player.tsx: Core player with HLS/MP4 source loading - video-controls.tsx: Play, seek, speed, volume, fullscreen, loop - video-timeline.tsx: Scrub bar with hover time preview + marker slots - video-frame-display.tsx: Timecode display (HH:MM:SS:FF) - video-upload-zone.tsx: Drag-drop upload with progress bar (XHR) - use-video-player.ts: Player state hook with keyboard shortcuts Review page integration: - Auto-detects video vs image attachments per revision - Image/Video toggle when both exist on same revision - Upload panel extended with video + reference video zones - VideoPlayer renders in place of ImageViewer when in video mode Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 7 + package.json | 1 + .../[deliverableId]/review/page.tsx | 119 +++++- src/components/review/video-controls.tsx | 272 +++++++++++++ src/components/review/video-frame-display.tsx | 44 +++ src/components/review/video-player.tsx | 174 +++++++++ src/components/review/video-timeline.tsx | 138 +++++++ src/components/review/video-upload-zone.tsx | 259 ++++++++++++ src/hooks/use-video-player.ts | 368 ++++++++++++++++++ 9 files changed, 1379 insertions(+), 3 deletions(-) create mode 100644 src/components/review/video-controls.tsx create mode 100644 src/components/review/video-frame-display.tsx create mode 100644 src/components/review/video-player.tsx create mode 100644 src/components/review/video-timeline.tsx create mode 100644 src/components/review/video-upload-zone.tsx create mode 100644 src/hooks/use-video-player.ts 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/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx index f82a2e3..5a3f1d8 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -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(null); + // ── Viewer mode: image or video ───────────────────────────────────── + const [viewerMode, setViewerMode] = useState<"image" | "video">("image"); + // ── Gallery strip state ────────────────────────────────────────────── const [galleryOpen, setGalleryOpen] = useState(true); @@ -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 }; @@ -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 ? ( + ) : ( 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/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, + }; +} From 77f69757e1b18a84ae31c51a09a308afcd83a931 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 17 Mar 2026 23:56:21 -0500 Subject: [PATCH 3/4] Graceful FFmpeg fallback for local dev without FFmpeg installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video upload now works without FFmpeg on PATH — metadata extraction returns defaults, thumbnail is skipped, HLS transcoding is skipped, and video is marked as ready with raw MP4 serving only. A one-time warning is logged. Full HLS pipeline activates when FFmpeg is present (Docker or local install). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/services/upload-service.ts | 25 +++++++++++------- src/lib/services/video-service.ts | 42 ++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/lib/services/upload-service.ts b/src/lib/services/upload-service.ts index a1234f5..b5b17cd 100644 --- a/src/lib/services/upload-service.ts +++ b/src/lib/services/upload-service.ts @@ -227,15 +227,17 @@ export async function processAndStoreVideo( const nodeStream = Readable.from(Buffer.from(arrayBuffer)); await pipeline(nodeStream, createWriteStream(videoPath)); - // Extract metadata (reads headers — fast) + // Extract metadata (reads headers — fast; returns defaults if no FFmpeg) const metadata = await extractMetadata(videoPath); - // Extract thumbnail + // 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); - await extractThumbnail(videoPath, thumbPath, thumbTime); - const thumbnailUrl = `/api/uploads/revisions/${revisionId}/${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`); @@ -258,8 +260,13 @@ export async function processAndStoreVideo( // Kick off async HLS transcoding — don't await transcodeToHLS(videoPath, hlsDir) - .then(async () => { - // Update the revision attachments with HLS URL + ready status + .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 }, @@ -271,7 +278,7 @@ export async function processAndStoreVideo( if (existing) { attachments[videoKey] = { ...existing, - hlsUrl, + hlsUrl: result ? hlsUrl : null, status: "ready", }; await prisma.revision.update({ @@ -279,7 +286,7 @@ export async function processAndStoreVideo( data: { attachments }, }); } - console.log(`[Video] HLS transcoding complete for ${revisionId}/${videoType}`); + console.log(`[Video] Processing complete for ${revisionId}/${videoType}${result ? " (HLS)" : " (MP4 only)"}`); }) .catch(async (err) => { console.error(`[Video] HLS transcoding failed for ${revisionId}:`, err); diff --git a/src/lib/services/video-service.ts b/src/lib/services/video-service.ts index 5c3063c..475c20f 100644 --- a/src/lib/services/video-service.ts +++ b/src/lib/services/video-service.ts @@ -1,6 +1,11 @@ /** * 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"; @@ -11,6 +16,28 @@ 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; @@ -22,10 +49,15 @@ export interface VideoMetadata { /** * 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", @@ -64,12 +96,15 @@ export async function extractMetadata( /** * 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 { +): Promise { + if (!(await isFFmpegAvailable())) return false; + const dir = path.dirname(outputPath); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); @@ -89,6 +124,7 @@ export async function extractThumbnail( "3", outputPath, ]); + return true; } /** @@ -96,11 +132,13 @@ export async function extractThumbnail( * * 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 }> { +): Promise<{ playlistPath: string } | null> { + if (!(await isFFmpegAvailable())) return null; if (!existsSync(outputDir)) { await mkdir(outputDir, { recursive: true }); } From ec420f79d6dd09bfd6c928f3dd61ac367c042d6c Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Wed, 18 Mar 2026 12:19:00 -0500 Subject: [PATCH 4/4] Fix dynamic pipeline stages: form submissions, unique constraint, and stage name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related bugs fixed: 1. Form save buttons silently failing — valueAsNumber on empty number inputs produced NaN, which Zod rejected without visible errors on hidden tabs. Replaced with setValueAs that converts empty strings to undefined. 2. Unique constraint violation on deliverable stage creation — dynamic pipeline stages without matching global template slugs all fell back to globalTemplates[0], creating duplicate (deliverableId, templateId) pairs. Changed constraint from @@unique([deliverableId, templateId]) to @@unique([deliverableId, stageDefinitionId]). 3. Stage names showing wrong template — all UI components read stage.template.name exclusively, ignoring stageDefinition from the dynamic pipeline system. Updated 13 components, 6 services, and all relevant Prisma queries to prefer stageDefinition over template for display. Co-Authored-By: Claude Opus 4.6 (1M context) --- prisma/schema.prisma | 2 +- src/app/(app)/dashboard/page.tsx | 2 +- src/app/(app)/my-work/page.tsx | 7 ++++--- .../deliverables/[deliverableId]/page.tsx | 12 ++++++------ .../deliverables/[deliverableId]/review/page.tsx | 6 +++--- src/components/calendar/calendar-event-pill.tsx | 4 ++-- src/components/calendar/calendar-grid.tsx | 4 ++-- .../deliverables/deliverable-form-dialog.tsx | 2 +- src/components/deliverables/pipeline-progress.tsx | 7 +++++-- src/components/projects/project-form-dialog.tsx | 4 ++-- src/components/review/session-builder.tsx | 3 ++- src/components/review/session-presenter.tsx | 5 +++-- src/components/review/session-summary.tsx | 3 ++- src/components/stages/stage-detail-sheet.tsx | 6 +++--- src/components/views/gantt-timeline.tsx | 10 +++++++--- src/components/views/production-timeline.tsx | 5 +++-- src/hooks/use-calendar.ts | 5 +++++ src/lib/chat/tool-executor.ts | 14 ++++++++------ src/lib/services/assignment-service.ts | 1 + src/lib/services/calendar-service.ts | 1 + src/lib/services/deliverable-service.ts | 2 ++ src/lib/services/embedding-service.ts | 11 +++++++---- src/lib/services/project-service.ts | 2 +- 23 files changed, 72 insertions(+), 46 deletions(-) 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 && ( { 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]); @@ -478,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}
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/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/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" } }, }, },