import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises"; import path from "node:path"; import { captionFontFile } from "./font-files"; import { captionFont } from "./fonts"; 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 selectedFonts = [ ...new Set( compiledSubtitles(plan) .map( (caption) => captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily)?.id, ) .filter((file): file is NonNullable => Boolean(file)), ), ]; if (selectedFonts.length) { const fontDir = path.join(dir, "fonts"); await mkdir(fontDir, { recursive: true }); const source = process.env.SMART_EDIT_FONTS_DIR || path.resolve(process.cwd(), "public/fonts/smart-edit"); for (const id of selectedFonts) { const file = captionFont(id)!.file; try { await progress("准备字幕字体"); await copyFile(await captionFontFile(id), path.join(fontDir, file)); } catch { throw Error(`字幕字体文件缺失:${file},请安装剪辑字体包后重新导出`); } } // Latin faces use the same packaged Chinese fallback in both renderers. if (!selectedFonts.includes("source-han")) await copyFile(path.join(source, "source-han.otf"), path.join(fontDir, "source-han.otf")); } 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 pad = clip.padBefore || clip.padAfter ? `,tpad=start_mode=clone:start_duration=${clip.padBefore / SECOND}:stop_mode=clone:stop_duration=${clip.padAfter / SECOND}` : ""; 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,trim=duration=${clip.duration / SECOND},format=yuv420p${pad}`; 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 || clip.muted ? 0 : clip.gain},atrim=duration=${clip.duration / SECOND}${clip.padBefore ? `,adelay=${Math.round(clip.padBefore / 1000)}:all=1` : ""},apad`, "-t", String((clip.duration + clip.padBefore + clip.padAfter) / 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]format=yuv444p,settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`, ); const audioFilters = timeline.clips.map( (c, i) => `[${i}:a]atrim=duration=${(c.duration + c.padBefore + c.padAfter) / SECOND},asetpts=PTS-STARTPTS[ain${i}]`, ); let video = "vin0", audio = "ain0"; for (let i = 1; i < normalized.length; i++) { const clip = timeline.clips[i]; if (clip.overlap) { const length = clip.overlap / SECOND, offset = clip.transitionStart / SECOND; if (clip.transitionType === "fade") { filters.push( `[${video}][vin${i}]xfade=transition=fade:duration=${length}:offset=${offset}[v${i}]`, ); } else { // Two native fades meet at a solid-color midpoint, matching the browser overlay. // Avoid custom per-pixel expressions: they are prohibitively slow at 1080P. const half = length / 2, color = clip.transitionType === "fadeblack" ? "black" : "white"; filters.push( `color=c=${color}:s=${width}x${height}:r=30:d=${length},format=yuv444p,settb=AVTB[solid${i}]`, `[vin${i}]trim=start=${half},setpts=PTS-STARTPTS[tail${i}]`, `[${video}][solid${i}]xfade=transition=fade:duration=${half}:offset=${offset}[dip${i}]`, `[dip${i}][tail${i}]xfade=transition=fade:duration=${half}:offset=${offset + half}[v${i}]`, ); } audioFilters.push(`[${audio}][ain${i}]acrossfade=d=${length}:c1=tri:c2=tri[a${i}]`); } else { filters.push(`[${video}][vin${i}]concat=n=2:v=1:a=0[v${i}]`); audioFilters.push(`[${audio}][ain${i}]concat=n=2:v=0:a=1[a${i}]`); } video = `v${i}`; audio = `a${i}`; } const videoFile = path.join(dir, "joined-video.mp4"), audioFile = path.join(dir, "joined-audio.m4a"); await renderFfmpeg([ ...normalized.flatMap((file) => ["-threads", "2", "-i", file]), "-filter_complex", filters.join(";"), "-map", `[${video}]`, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-maxrate", "6M", "-bufsize", "12M", "-threads", "2", "-pix_fmt", "yuv420p", videoFile, ]); // Keep framesync video and full-length audio fades in separate passes. Otherwise // FFmpeg 4 can finish the audio graph before emitting buffered video at offset 0. await renderFfmpeg([ ...normalized.flatMap((file) => ["-i", file]), "-filter_complex", audioFilters.join(";"), "-map", `[${audio}]`, "-vn", "-c:a", "aac", audioFile, ]); await renderFfmpeg([ "-i", videoFile, "-i", audioFile, "-map", "0:v:0", "-map", "1:a:0", "-c", "copy", 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 ? [ selectedFonts.length ? "ass=filename=subtitles.ass:fontsdir=fonts" : "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); const offsetEnvelope = clip.fadeInStart || clip.fadeOutEnd ? `,volume='${fadeIn ? `min(1,${clip.fadeInStart || 0}+(1-${clip.fadeInStart || 0})*t/${fadeIn})` : "1"}*${fadeOut ? `min(1,${clip.fadeOutEnd || 0}+(1-${clip.fadeOutEnd || 0})*(${length}-t)/${fadeOut})` : "1"}':eval=frame` : `${fadeIn ? `,afade=t=in:d=${fadeIn}` : ""}${fadeOut ? `,afade=t=out:st=${length - fadeOut}:d=${fadeOut}` : ""}`; filters.push( `[${audioInput}:a]asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${clip.muted ? 0 : clip.gain},atrim=duration=${length}${offsetEnvelope},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 }; }