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:
parent
4e654b6fed
commit
2e87a5ff4d
8 changed files with 586 additions and 21 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -46,4 +46,5 @@ next-env.d.ts
|
|||
|
||||
# uploaded assets (runtime-generated, not needed in repo)
|
||||
/public/uploads/
|
||||
/data/uploads/
|
||||
/assets/review-images/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
135
src/app/api/uploads/[...path]/route.ts
Normal file
135
src/app/api/uploads/[...path]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
src/lib/services/video-service.ts
Normal file
182
src/lib/services/video-service.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue