From 2e87a5ff4da89bb29723e4a7a0e7eff0d5b12c91 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Tue, 17 Mar 2026 23:42:54 -0500 Subject: [PATCH] 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; +}