183 lines
5.4 KiB
TypeScript

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
type EditPlan,
editOutputSize,
planTimeline,
compiledSubtitles,
subtitlesToSrt,
subtitlesToAss,
SECOND,
} 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<string, string>,
musicFile: string | undefined,
dir: string,
progress: (stage: string) => Promise<void> = 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");
await renderFfmpeg([
...inputs,
"-map",
"0:v:0",
"-map",
asset.hasAudio ? "0:a:0" : "1:a:0",
"-vf",
`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`,
"-af",
`asetpts=PTS-STARTPTS,atempo=${clip.speed},aresample=48000,aformat=channel_layouts=stereo,volume=${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[] = [];
if (subtitles.length) filters.push("[0:v]ass=filename=subtitles.ass[v]");
if (musicFile && plan.music) {
inputs.push("-ss", String(plan.music.start / SECOND), "-i", musicFile);
filters.push(
`[1:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,volume=${plan.music.gain},apad,atrim=duration=${duration}[music]`,
`[0:a][music]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95:level=0[a]`,
);
}
await progress("输出成片");
await renderFfmpeg(
[
...inputs,
...(filters.length ? ["-filter_complex", filters.join(";")] : []),
"-map",
subtitles.length ? "[v]" : "0:v:0",
"-map",
musicFile && plan.music ? "[a]" : "0:a:0",
"-c:v",
subtitles.length ? "libx264" : "copy",
...(subtitles.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 };
}