import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { type EditPlan, editOutputSize, planTimeline, compiledSubtitles, subtitlesToSrt, subtitlesToAss, SECOND, defaultVideoTransform, } from "./contract"; import { ffmpeg, mediaSafety } from "./local"; const renderFfmpeg = (args: string[], cwd?: string) => ffmpeg(args, cwd, 600000); export async function renderEditPlan( plan: EditPlan, files: Record, musicFile: string | undefined, dir: string, progress: (stage: string) => Promise = async () => {}, ) { const timeline = planTimeline(plan), duration = timeline.duration / SECOND; const { width, height } = editOutputSize(plan); const normalized: string[] = []; for (let i = 0; i < timeline.clips.length; i++) { await progress(`处理片段 ${i + 1}/${timeline.clips.length}`); const clip = timeline.clips[i], asset = plan.assets.find((a) => a.id === clip.assetId)!; const output = path.join(dir, `clip-${i}.mp4`); const length = (clip.sourceOut - clip.sourceIn) / SECOND; const inputs = [ ...mediaSafety, "-ss", String(clip.sourceIn / SECOND), "-t", String(length), "-i", files[clip.assetId], ]; if (!asset.hasAudio) inputs.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"); const t = clip.transform || defaultVideoTransform; const transformWidth = Math.max(2, Math.round((width * t.scale) / 2) * 2), transformHeight = Math.max(2, Math.round((height * t.scale) / 2) * 2); const transformX = Math.round((width - transformWidth) / 2 + t.x * width), transformY = Math.round((height - transformHeight) / 2 + t.y * height); const normalize = `setpts=(PTS-STARTPTS)/${clip.speed},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p`; const transformed = t.scale !== 1 || t.x !== 0 || t.y !== 0; await renderFfmpeg([ ...inputs, "-map", transformed ? "[v]" : "0:v:0", "-map", asset.hasAudio ? "0:a:0" : "1:a:0", ...(transformed ? [ "-filter_complex", `[0:v]${normalize},scale=${transformWidth}:${transformHeight}[fg];color=c=black:s=${width}x${height}:r=30[bg];[bg][fg]overlay=x=${transformX}:y=${transformY}:shortest=1[v]`, ] : ["-vf", normalize]), "-af", `asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.audioDetached ? 0 : clip.gain},apad`, "-t", String(clip.duration / SECOND), "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-maxrate", "6M", "-bufsize", "12M", "-threads", "2", "-c:a", "aac", "-ar", "48000", "-ac", "2", output, ]); normalized.push(output); } await progress("合成时间轴"); const joined = path.join(dir, "joined.mp4"); if (!timeline.clips.some((clip) => clip.overlap) || normalized.length === 1) { await writeFile( path.join(dir, "concat.txt"), normalized.map((_, i) => `file 'clip-${i}.mp4'`).join("\n"), ); await renderFfmpeg( ["-f", "concat", "-safe", "1", "-i", "concat.txt", "-c", "copy", joined], dir, ); } else { // Normalize first: all xfade inputs have identical pixel format, timebase and dimensions. const filters: string[] = normalized.map( (_, i) => `[${i}:v]settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`, ); let video = "vin0", audio = "0:a"; for (let i = 1; i < normalized.length; i++) { const clip = timeline.clips[i]; if (clip.overlap) filters.push( `[${video}][vin${i}]xfade=transition=${clip.transitionType}:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`, `[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`, ); else filters.push(`[${video}][${audio}][vin${i}][${i}:a]concat=n=2:v=1:a=1[v${i}][a${i}]`); video = `v${i}`; audio = `a${i}`; } await renderFfmpeg([ ...normalized.flatMap((file) => ["-threads", "2", "-i", file]), "-filter_complex", filters.join(";"), "-map", `[${video}]`, "-map", `[${audio}]`, "-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-maxrate", "6M", "-bufsize", "12M", "-threads", "2", "-c:a", "aac", joined, ]); } const subtitles = compiledSubtitles(plan); const srt = path.join(dir, "subtitles.srt"); await writeFile(srt, subtitlesToSrt(subtitles)); await writeFile(path.join(dir, "subtitles.ass"), subtitlesToAss(plan, width, height)); const output = path.join(dir, "result.mp4"); const inputs = ["-i", joined]; const filters: string[] = []; const videoTail = Math.max(0, duration - timeline.clips[timeline.clips.length - 1].end / SECOND); const videoFilters = [ ...(videoTail ? [`tpad=stop_mode=add:stop_duration=${videoTail}`] : []), ...(subtitles.length ? ["ass=filename=subtitles.ass"] : []), ]; if (videoFilters.length) filters.push(`[0:v]${videoFilters.join(",")}[v]`); filters.push(`[0:a]apad,atrim=duration=${duration}[baseaudio]`); const mixLabels = ["[baseaudio]"]; let audioInput = 1; if (musicFile && plan.music) { inputs.push("-ss", String(plan.music.start / SECOND), "-i", musicFile); filters.push( `[${audioInput}:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,volume=${plan.music.gain},apad,atrim=duration=${duration}[music]`, ); mixLabels.push("[music]"); audioInput++; } for (const [index, clip] of timeline.audioClips.entries()) { inputs.push( ...mediaSafety, "-ss", String(clip.sourceIn / SECOND), "-t", String((clip.sourceOut - clip.sourceIn) / SECOND), "-i", files[clip.assetId], ); const length = clip.duration / SECOND, fadeIn = Math.min(clip.fadeIn, length), fadeOut = Math.min(clip.fadeOut, length); filters.push( `[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.gain},atrim=duration=${length}${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""},adelay=${Math.round(clip.start / 1000)}:all=1[track${index}]`, ); mixLabels.push(`[track${index}]`); audioInput++; } if (mixLabels.length > 1) filters.push( `${mixLabels.join("")}amix=inputs=${mixLabels.length}:duration=first:normalize=0,alimiter=limit=0.95:level=0[a]`, ); await progress("输出成片"); await renderFfmpeg( [ ...inputs, ...(filters.length ? ["-filter_complex", filters.join(";")] : []), "-map", videoFilters.length ? "[v]" : "0:v:0", "-map", mixLabels.length > 1 ? "[a]" : "[baseaudio]", "-c:v", videoFilters.length ? "libx264" : "copy", ...(videoFilters.length ? [ "-preset", "veryfast", "-crf", "21", "-maxrate", "6M", "-bufsize", "12M", "-threads", "2", ] : []), "-c:a", "aac", "-t", String(duration), "-movflags", "+faststart", output, ], dir, ); return { file: output, srt, edl: JSON.stringify(plan, null, 2), width, height, duration }; }