322 lines
11 KiB
TypeScript

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<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 selectedFonts = [
...new Set(
compiledSubtitles(plan)
.map(
(caption) =>
captionFont((caption as { style?: { fontFamily?: string } }).style?.fontFamily)?.id,
)
.filter((file): file is NonNullable<typeof file> => 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 };
}