import { execFile } from "child_process"; import { promisify } from "util"; import { mkdtemp, readdir, rm, writeFile, stat } from "fs/promises"; import { createWriteStream } from "fs"; import { tmpdir } from "os"; import { join } from "path"; const execFileAsync = promisify(execFile); export interface ProbeResult { duration: number; width: number; height: number; fps: number; codec: string; } export async function probeVideo(filePath: string): Promise { const { stdout } = await execFileAsync("ffprobe", [ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath, ]); const info = JSON.parse(stdout); const videoStream = info.streams?.find((s: any) => s.codec_type === "video"); if (!videoStream) throw new Error("No video stream found"); let fps = 24; if (videoStream.r_frame_rate) { const [num, den] = videoStream.r_frame_rate.split("/").map(Number); if (den > 0) fps = num / den; } return { duration: parseFloat(info.format?.duration || videoStream.duration || "0"), width: videoStream.width || 0, height: videoStream.height || 0, fps: Math.round(fps * 100) / 100, codec: videoStream.codec_name || "unknown", }; } export interface ExtractOptions { fps: number; width: number; height: number; format: "jpeg" | "png"; quality?: number; } export async function extractFramesToDir( videoPath: string, options: ExtractOptions, ): Promise<{ dir: string; files: string[] }> { const dir = await mkdtemp(join(tmpdir(), "retrotoon-frames-")); const ext = options.format === "png" ? "png" : "jpg"; const pattern = join(dir, `frame_%06d.${ext}`); const filters = [`fps=${options.fps}`]; if (options.width > 0 && options.height > 0) { filters.push(`scale=${options.width}:${options.height}`); } const args = [ "-i", videoPath, "-vf", filters.join(","), ]; if (options.format === "jpeg") { args.push("-q:v", String(options.quality ?? 5)); } args.push("-y", pattern); await execFileAsync("ffmpeg", args, { maxBuffer: 50 * 1024 * 1024, timeout: 600_000, }); const allFiles = await readdir(dir); const frameFiles = allFiles .filter((f) => f.startsWith("frame_") && f.endsWith(`.${ext}`)) .sort(); return { dir, files: frameFiles }; } export async function extractAudioFile(videoPath: string, outputPath: string): Promise { await execFileAsync("ffmpeg", [ "-i", videoPath, "-vn", "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", "-y", outputPath, ], { maxBuffer: 50 * 1024 * 1024, timeout: 300_000, }); return outputPath; } export interface AssembleOptions { fps: number; format: "mp4" | "webm"; audioPath?: string | null; } export async function detectSceneCutsFromVideo( videoPath: string, threshold: number = 0.3, ): Promise> { const { stdout } = await execFileAsync("ffprobe", [ "-v", "quiet", "-show_frames", "-of", "json", "-f", "lavfi", `movie=${videoPath},select='gt(scene,${threshold})'`, ], { maxBuffer: 100 * 1024 * 1024, timeout: 300_000, }).catch(() => ({ stdout: "" })); if (!stdout) { const { stdout: stdout2 } = await execFileAsync("ffmpeg", [ "-i", videoPath, "-vf", `select='gt(scene,${threshold})',showinfo`, "-vsync", "vfr", "-f", "null", "-", ], { maxBuffer: 100 * 1024 * 1024, timeout: 300_000, }).catch((err) => ({ stdout: "", stderr: err.stderr || "" })); const lines = (stdout2 || "").split("\n"); const cuts: Array<{ frame: number; score: number; time: number }> = []; for (const line of lines) { const nMatch = line.match(/n:\s*(\d+)/); const ptsMatch = line.match(/pts_time:\s*([\d.]+)/); if (nMatch && ptsMatch) { cuts.push({ frame: parseInt(nMatch[1]), score: threshold, time: parseFloat(ptsMatch[1]), }); } } return cuts; } try { const data = JSON.parse(stdout); return (data.frames || []).map((f: any, i: number) => ({ frame: i, score: parseFloat(f.tags?.lavfi_scene_score || "0"), time: parseFloat(f.pts_time || f.best_effort_timestamp_time || "0"), })); } catch { return []; } } export async function assembleVideo( frameDir: string, frameFiles: string[], outputPath: string, options: AssembleOptions, ): Promise<{ path: string; duration: number; size: number }> { const ext = frameFiles[0]?.split(".").pop() || "jpg"; const concatListPath = join(frameDir, "concat.txt"); const lines = frameFiles.map(f => `file '${join(frameDir, f)}'`); lines.forEach((_, i) => { lines[i] += `\nduration ${1 / options.fps}`; }); await writeFile(concatListPath, lines.join("\n"), "utf-8"); const args = [ "-f", "concat", "-safe", "0", "-i", concatListPath, ]; if (options.audioPath) { args.push("-i", options.audioPath); args.push("-c:a", "aac", "-b:a", "192k"); args.push("-shortest"); } if (options.format === "mp4") { args.push("-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "medium", "-crf", "23"); } else { args.push("-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0"); } args.push("-r", String(options.fps)); args.push("-movflags", "+faststart"); args.push("-y", outputPath); await execFileAsync("ffmpeg", args, { maxBuffer: 100 * 1024 * 1024, timeout: 600_000, }); const info = await stat(outputPath); const duration = frameFiles.length / options.fps; return { path: outputPath, duration, size: info.size }; } export async function cleanupDir(dir: string): Promise { await rm(dir, { recursive: true, force: true }); }