75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
import { spawn } from "child_process";
|
|
import { StoryConfig } from "./config";
|
|
import * as path from "path";
|
|
import ffmpeg from "ffmpeg-static";
|
|
|
|
function escapeForFilter(filePath: string): string {
|
|
return filePath.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/,/g, "\\,").replace(/'/g, "\\'");
|
|
}
|
|
|
|
export async function createVideo(
|
|
storyName: string,
|
|
storyConfig: StoryConfig,
|
|
imageFiles: string[],
|
|
chunkDurations: number[],
|
|
srtPath: string
|
|
): Promise<void> {
|
|
const ffmpegPath: string = (ffmpeg as unknown as string) || "ffmpeg";
|
|
const audioPath = path.resolve("stories", storyName, "final_audio", "final.mp3");
|
|
const videoPath = path.resolve("stories", storyName, "video", "final.mp4");
|
|
const totalDuration = chunkDurations.reduce((a, b) => a + b, 0);
|
|
const resolution = storyConfig.config.export_settings?.resolution || "1024x1024";
|
|
|
|
const inputs = imageFiles.map((file) => ["-loop", "1", "-i", file]).flat();
|
|
inputs.push("-i", audioPath);
|
|
|
|
const filterGraph = imageFiles
|
|
.map((_, i) => {
|
|
const duration = chunkDurations[i];
|
|
const zoompan = `zoompan=z='min(zoom+0.0015,1.5)':d=${25 * duration}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${resolution}`;
|
|
return `[${i}:v]${zoompan},fade=t=out:st=${duration - 1}:d=1[v${i}]`;
|
|
})
|
|
.join(";");
|
|
|
|
const streamSpecifiers = imageFiles.map((_, i) => `[v${i}]`).join("");
|
|
const escapedSrt = escapeForFilter(srtPath);
|
|
const concatGraph = `${filterGraph};${streamSpecifiers}concat=n=${imageFiles.length}:v=1:a=0,format=yuv420p[v0]`;
|
|
const finalFilterGraph = `${concatGraph};[v0]subtitles='${escapedSrt}'[v]`;
|
|
|
|
const args = [
|
|
"-y",
|
|
...inputs,
|
|
"-filter_complex",
|
|
finalFilterGraph,
|
|
"-map",
|
|
"[v]",
|
|
"-map",
|
|
`${imageFiles.length}:a`,
|
|
"-c:v",
|
|
"libx264",
|
|
"-tune",
|
|
"stillimage",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-t",
|
|
totalDuration.toString(),
|
|
videoPath,
|
|
];
|
|
|
|
const ffmpegProcess = spawn(ffmpegPath, args);
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
ffmpegProcess.on("close", (code: number | null) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`ffmpeg process exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|