Add video upload with HLS streaming infrastructure (A7.1)

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) <noreply@anthropic.com>
This commit is contained in:
Leivur Djurhuus 2026-03-17 23:42:54 -05:00
parent 4e654b6fed
commit 2e87a5ff4d
8 changed files with 586 additions and 21 deletions

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,37 @@
import { writeFile, mkdir, unlink } from "fs/promises";
import { existsSync } from "fs";
import { writeFile, mkdir, unlink, readdir, rm } from "fs/promises";
import { createWriteStream, existsSync } from "fs";
import path from "path";
import sharp from "sharp";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { prisma } from "@/lib/prisma";
import {
extractMetadata,
extractThumbnail,
transcodeToHLS,
} from "@/lib/services/video-service";
/** Images stay in /public for static serving (backward compat) */
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "revisions");
const ALLOWED_TYPES = [
/** Videos go to a mounted volume, served via /api/uploads streaming route */
const VIDEO_UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads/revisions"
: path.join(process.cwd(), "data", "uploads", "revisions"));
const ALLOWED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/tiff",
];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_VIDEO_TYPES = ["video/mp4"];
const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500MB
export interface UploadedImage {
url: string;
@ -38,14 +57,14 @@ export async function processAndStoreImage(
imageType: "reference" | "current" | "screenshot"
): Promise<UploadedImage> {
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
throw new Error(
`Unsupported file type: ${file.type}. Allowed: PNG, JPEG, WebP, TIFF.`
);
}
// Validate size
if (file.size > MAX_FILE_SIZE) {
if (file.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 50MB.`
);
@ -134,7 +153,6 @@ export async function deleteRevisionImage(
const revisionDir = path.join(UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) return;
const { readdir } = await import("fs/promises");
const files = await readdir(revisionDir);
for (const file of files) {
@ -143,3 +161,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<UploadedVideo> {
// Validate MIME type
if (!ALLOWED_VIDEO_TYPES.includes(file.type)) {
throw new Error(
`Unsupported file type: ${file.type}. Allowed: MP4.`
);
}
// Validate size
if (file.size > MAX_VIDEO_SIZE) {
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 500MB.`
);
}
// Ensure upload directory
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) {
await mkdir(revisionDir, { recursive: true });
}
const timestamp = Date.now();
const videoFilename = `${videoType}_${timestamp}.mp4`;
const videoPath = path.join(revisionDir, videoFilename);
const videoUrl = `/api/uploads/revisions/${revisionId}/${videoFilename}`;
// Stream-write the file to disk
const arrayBuffer = await file.arrayBuffer();
const nodeStream = Readable.from(Buffer.from(arrayBuffer));
await pipeline(nodeStream, createWriteStream(videoPath));
// Extract metadata (reads headers — fast)
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<string, unknown>) ?? {};
const videoKey = videoType === "video" ? "video" : "referenceVideo";
const existing = attachments[videoKey] as Record<string, unknown> | 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<string, unknown>) ?? {};
const videoKey = videoType === "video" ? "video" : "referenceVideo";
const existing = attachments[videoKey] as Record<string, unknown> | undefined;
if (existing) {
attachments[videoKey] = { ...existing, status: "failed" };
await prisma.revision.update({
where: { id: revisionId },
data: { attachments },
}).catch(() => {});
}
});
return uploaded;
}
/**
* Delete uploaded video files and HLS segments for a revision.
*/
export async function deleteRevisionVideo(
revisionId: string,
videoType: VideoType
): Promise<void> {
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) return;
const files = await readdir(revisionDir);
for (const file of files) {
if (file.startsWith(`${videoType}_`)) {
const fullPath = path.join(revisionDir, file);
// Could be a directory (HLS segments) or a file
await rm(fullPath, { recursive: true, force: true }).catch(() => {});
}
}
}

View file

@ -0,0 +1,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<VideoMetadata> {
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<void> {
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<Buffer> {
const { stdout } = await execFileAsync(
"ffmpeg",
[
"-ss",
String(timeSeconds),
"-i",
inputPath,
"-vframes",
"1",
"-f",
"image2",
"-c:v",
"mjpeg",
"-q:v",
"2",
"pipe:1",
],
{ encoding: "buffer", maxBuffer: 10 * 1024 * 1024 }
);
return stdout as unknown as Buffer;
}