import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { toVideoTimeRange, type VideoTimeRange } from "./media-contract"; export type MediaRect = { x: number; y: number; width: number; height: number }; export function toVideoPixelRect(rect: MediaRect, width: number, height: number) { if ( ![rect.x, rect.y, rect.width, rect.height, width, height].every(Number.isFinite) || rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1.001 || rect.y + rect.height > 1.001 ) throw new Error("裁剪区域无效"); const even = (n: number) => Math.floor(n / 2) * 2; const x = even(rect.x * width), y = even(rect.y * height); const w = Math.min(even(rect.width * width), even(width - x)), h = Math.min(even(rect.height * height), even(height - y)); if (w < 2 || h < 2) throw new Error("裁剪区域太小"); return { x, y, width: w, height: h }; } const run = promisify(execFile); const inputSafety = [ "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,avi,mpeg,mpegts,flv,ogg", ]; export async function processVideoFile( input: string, output: string, operation: "crop" | "extract-audio" | "silent-video" | "trim" | "frame" | "slow", rect?: MediaRect, range?: VideoTimeRange, options?: { kind?: "first" | "last" | "current"; time?: number; factor?: number }, ) { const { stdout } = await run( "ffprobe", ["-v", "error", ...inputSafety, "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }, ); const info = JSON.parse(stdout), video = info.streams?.find((s: any) => s.codec_type === "video"); if (!video) throw new Error("素材中没有视频画面"); if ( !(Number(video.width) > 0 && Number(video.height) > 0) || Number(video.width) * Number(video.height) > 33554432 ) throw new Error("视频尺寸不受支持,请使用 8K 以内的视频"); const duration = Number(video.duration || info.format?.duration || 0); if (!Number.isFinite(duration) || duration <= 0 || duration > 600) throw new Error("本地视频处理支持 10 分钟以内的视频"); let args: string[]; let dimensions = { width: Number(video.width), height: Number(video.height) }; let outputDuration = duration; if (operation === "frame") { const [numerator, denominator] = String(video.avg_frame_rate || "30/1") .split("/") .map(Number); const fps = numerator / denominator; const last = Math.max(0, duration - 1 / (Number.isFinite(fps) && fps > 0 ? fps : 30)); const time = options?.kind === "last" ? last : options?.kind === "current" ? Math.min(last, Math.max(0, Number(options.time) || 0)) : 0; args = ["-ss", String(time), "-map", "0:v:0", "-frames:v", "1", "-an", "-c:v", "png"]; outputDuration = 0; } else if (operation === "slow") { const factor = options?.factor; if (!factor || ![2, 3, 5].includes(factor)) throw Error("慢放倍数无效"); outputDuration = duration * factor; if (outputDuration > 600) throw Error("慢放后视频不能超过 10 分钟"); let speed = 1 / factor; const filters: string[] = []; while (speed < 0.5) { filters.push("atempo=0.5"); speed /= 0.5; } filters.push(`atempo=${speed}`); args = [ "-map", "0:v:0", "-map", "0:a?", "-vf", `setpts=${factor}*(PTS-STARTPTS),scale=trunc(iw/2)*2:trunc(ih/2)*2`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", ]; if (info.streams.some((stream: any) => stream.codec_type === "audio")) args.push("-af", filters.join(","), "-c:a", "aac"); args.push("-movflags", "+faststart"); dimensions = { width: Math.floor(dimensions.width / 2) * 2, height: Math.floor(dimensions.height / 2) * 2, }; } else if (operation === "extract-audio") { if (!info.streams.some((s: any) => s.codec_type === "audio")) throw new Error("原视频不包含音轨"); args = ["-map", "0:a:0", "-vn", "-c:a", "aac", "-b:a", "192k"]; } else if (operation === "silent-video") { args = ["-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart"]; } else if (operation === "trim") { if (!range) throw new Error("请选择剪辑范围"); const selected = toVideoTimeRange(range, duration); outputDuration = selected.duration; dimensions = { width: Math.floor(dimensions.width / 2) * 2, height: Math.floor(dimensions.height / 2) * 2, }; args = [ "-ss", String(selected.start), "-t", String(selected.duration), "-map", "0:v:0", "-map", "0:a?", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", ]; } else { if (!rect) throw new Error("请选择裁剪区域"); const crop = toVideoPixelRect(rect, dimensions.width, dimensions.height); dimensions = crop; args = [ "-map", "0:v:0", "-map", "0:a?", "-vf", `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", ]; } await run( "ffmpeg", [ "-nostdin", "-y", "-v", "error", "-threads", "2", "-filter_threads", "1", ...inputSafety, "-i", input, ...args, "-threads", "2", output, ], { timeout: 180000, maxBuffer: 1024 * 1024 }, ); if (operation === "frame") { const probe = await run( "ffprobe", [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", output, ], { timeout: 15000, maxBuffer: 1024 * 1024 }, ); const frame = JSON.parse(probe.stdout).streams?.[0]; if (!frame?.width || !frame?.height) throw Error("未截取到有效视频帧"); dimensions = { width: Number(frame.width), height: Number(frame.height) }; } return { duration: outputDuration, ...dimensions }; }