154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { type EditPlan, planTimeline, compiledSubtitles, subtitlesToSrt, SECOND } from "./contract";
|
|
import { ffmpeg, mediaSafety } from "./local";
|
|
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 size = plan.resolution === "1080p" ? 1080 : 720;
|
|
const [width, height] =
|
|
plan.ratio === "16:9"
|
|
? [Math.round((size * 16) / 9), size]
|
|
: plan.ratio === "9:16"
|
|
? [size, Math.round((size * 16) / 9)]
|
|
: [size, size];
|
|
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 ffmpeg([
|
|
...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",
|
|
"-threads",
|
|
"2",
|
|
"-c:a",
|
|
"aac",
|
|
"-ar",
|
|
"48000",
|
|
"-ac",
|
|
"2",
|
|
output,
|
|
]);
|
|
normalized.push(output);
|
|
}
|
|
await progress("合成时间轴");
|
|
const joined = path.join(dir, "joined.mp4");
|
|
if (plan.transition === 0 || normalized.length === 1) {
|
|
await writeFile(
|
|
path.join(dir, "concat.txt"),
|
|
normalized.map((_, i) => `file 'clip-${i}.mp4'`).join("\n"),
|
|
);
|
|
await ffmpeg(["-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[] = [];
|
|
let video = "0:v",
|
|
audio = "0:a";
|
|
for (let i = 1; i < normalized.length; i++) {
|
|
const clip = timeline.clips[i];
|
|
filters.push(
|
|
`[${video}][${i}:v]xfade=transition=fade:duration=${clip.overlap / SECOND}:offset=${clip.start / SECOND}[v${i}]`,
|
|
`[${audio}][${i}:a]acrossfade=d=${clip.overlap / SECOND}:c1=tri:c2=tri[a${i}]`,
|
|
);
|
|
video = `v${i}`;
|
|
audio = `a${i}`;
|
|
}
|
|
await ffmpeg([
|
|
...normalized.flatMap((file) => ["-i", file]),
|
|
"-filter_complex",
|
|
filters.join(";"),
|
|
"-map",
|
|
`[${video}]`,
|
|
"-map",
|
|
`[${audio}]`,
|
|
"-t",
|
|
String(duration),
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"veryfast",
|
|
"-crf",
|
|
"21",
|
|
"-threads",
|
|
"2",
|
|
"-c:a",
|
|
"aac",
|
|
joined,
|
|
]);
|
|
}
|
|
const subtitles = compiledSubtitles(plan);
|
|
const srt = path.join(dir, "subtitles.srt");
|
|
await writeFile(srt, subtitlesToSrt(subtitles));
|
|
const output = path.join(dir, "result.mp4");
|
|
const inputs = ["-i", joined];
|
|
const filters: string[] = [];
|
|
if (subtitles.length)
|
|
filters.push(
|
|
"[0:v]subtitles=filename=subtitles.srt:force_style='FontSize=22,Outline=2,MarginV=30'[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 ffmpeg(
|
|
[
|
|
...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", "-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 };
|
|
}
|