Update server.js
Browse files
server.js
CHANGED
|
@@ -783,10 +783,19 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 783 |
);
|
| 784 |
|
| 785 |
// ------------------------------------------------------------------
|
| 786 |
-
// 3. Render frames and build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
// ------------------------------------------------------------------
|
| 788 |
-
const
|
| 789 |
-
const
|
|
|
|
|
|
|
|
|
|
| 790 |
|
| 791 |
for (let i = 0; i < usedQuestions.length; i++) {
|
| 792 |
const q = usedQuestions[i];
|
|
@@ -798,20 +807,29 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 798 |
const rFrame = path.join(framesDir, `q${i + 1}_result.png`);
|
| 799 |
await renderFrame(q, "result", rFrame);
|
| 800 |
|
| 801 |
-
|
| 802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
if (onProgress) onProgress(i + 1, usedQuestions.length);
|
| 805 |
}
|
| 806 |
|
| 807 |
-
|
| 808 |
-
const lastResult = path.join(framesDir, `q${usedQuestions.length}_result.png`);
|
| 809 |
-
concatList.push(`file '${lastResult}'`);
|
| 810 |
-
fs.writeFileSync(framesList, concatList.join("\n"));
|
| 811 |
|
| 812 |
// ------------------------------------------------------------------
|
| 813 |
-
// 4. Concatenate
|
| 814 |
-
// audioDir is derived from the files themselves (parent directory).
|
| 815 |
// ------------------------------------------------------------------
|
| 816 |
const audioDir = path.dirname(usedAudio[0]);
|
| 817 |
const combinedAudio = path.join(audioDir, "combined.mp3");
|
|
@@ -819,9 +837,8 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 819 |
|
| 820 |
const videoPath = path.join(videosDir, `wyr_${Date.now()}.mp4`);
|
| 821 |
|
| 822 |
-
// Probe
|
| 823 |
-
//
|
| 824 |
-
// and with the concat demuxer the video stream duration is often reported wrong.
|
| 825 |
const audioDurationSecs = await new Promise((resolve, reject) => {
|
| 826 |
ffmpeg.ffprobe(combinedAudio, (err, meta) => {
|
| 827 |
if (err) return reject(err);
|
|
@@ -834,18 +851,13 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 834 |
|
| 835 |
await new Promise((resolve, reject) => {
|
| 836 |
ffmpeg()
|
| 837 |
-
.
|
| 838 |
-
//
|
| 839 |
-
|
| 840 |
-
.inputOptions(["-f
|
| 841 |
.input(combinedAudio)
|
| 842 |
-
// No -r on input: the concat demuxer ignores it for image inputs.
|
| 843 |
-
// Duration is driven purely by the 'duration' lines in frames_list.txt.
|
| 844 |
-
// -filter:v fps=30 resamples the variable-rate concat output to constant 30fps,
|
| 845 |
-
// which libx264 requires. -t pins the output to the real audio length so we
|
| 846 |
-
// don't get cut short or run long regardless of what concat reports.
|
| 847 |
.outputOptions([
|
| 848 |
-
|
| 849 |
"-c:v libx264",
|
| 850 |
"-pix_fmt yuv420p",
|
| 851 |
"-preset fast",
|
|
|
|
| 783 |
);
|
| 784 |
|
| 785 |
// ------------------------------------------------------------------
|
| 786 |
+
// 3. Render frames and build a sequential image list at 30fps.
|
| 787 |
+
// The concat demuxer ignores 'duration' lines for PNG inputs on
|
| 788 |
+
// this ffmpeg build (Duration: N/A, treats them as raw 25fps stream).
|
| 789 |
+
// Solution: use the image2 demuxer at a fixed 30fps and write each
|
| 790 |
+
// frame as a symlink repeated the correct number of times.
|
| 791 |
+
// Frame count = round(durationSeconds * FPS) — no ffmpeg duration
|
| 792 |
+
// parsing needed, timing is mathematically exact.
|
| 793 |
// ------------------------------------------------------------------
|
| 794 |
+
const FPS = 30;
|
| 795 |
+
const seqDir = path.join(framesDir, "seq");
|
| 796 |
+
fs.mkdirSync(seqDir, { recursive: true });
|
| 797 |
+
|
| 798 |
+
let seqIndex = 0; // global counter across all frames
|
| 799 |
|
| 800 |
for (let i = 0; i < usedQuestions.length; i++) {
|
| 801 |
const q = usedQuestions[i];
|
|
|
|
| 807 |
const rFrame = path.join(framesDir, `q${i + 1}_result.png`);
|
| 808 |
await renderFrame(q, "result", rFrame);
|
| 809 |
|
| 810 |
+
// Write symlinks for question phase
|
| 811 |
+
const qFrameCount = Math.max(1, Math.round(questionDuration * FPS));
|
| 812 |
+
for (let f = 0; f < qFrameCount; f++) {
|
| 813 |
+
const linkPath = path.join(seqDir, `frame${String(seqIndex).padStart(6, "0")}.png`);
|
| 814 |
+
fs.symlinkSync(qFrame, linkPath);
|
| 815 |
+
seqIndex++;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
// Write symlinks for result phase
|
| 819 |
+
const rFrameCount = Math.max(1, Math.round(resultDuration * FPS));
|
| 820 |
+
for (let f = 0; f < rFrameCount; f++) {
|
| 821 |
+
const linkPath = path.join(seqDir, `frame${String(seqIndex).padStart(6, "0")}.png`);
|
| 822 |
+
fs.symlinkSync(rFrame, linkPath);
|
| 823 |
+
seqIndex++;
|
| 824 |
+
}
|
| 825 |
|
| 826 |
if (onProgress) onProgress(i + 1, usedQuestions.length);
|
| 827 |
}
|
| 828 |
|
| 829 |
+
console.log(`🖼 Total frames written: ${seqIndex} @ ${FPS}fps = ${(seqIndex / FPS).toFixed(1)}s`);
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
// ------------------------------------------------------------------
|
| 832 |
+
// 4. Concatenate audio files.
|
|
|
|
| 833 |
// ------------------------------------------------------------------
|
| 834 |
const audioDir = path.dirname(usedAudio[0]);
|
| 835 |
const combinedAudio = path.join(audioDir, "combined.mp3");
|
|
|
|
| 837 |
|
| 838 |
const videoPath = path.join(videosDir, `wyr_${Date.now()}.mp4`);
|
| 839 |
|
| 840 |
+
// Probe audio so we can set -t explicitly (guards against any off-by-one
|
| 841 |
+
// between frame count and audio length).
|
|
|
|
| 842 |
const audioDurationSecs = await new Promise((resolve, reject) => {
|
| 843 |
ffmpeg.ffprobe(combinedAudio, (err, meta) => {
|
| 844 |
if (err) return reject(err);
|
|
|
|
| 851 |
|
| 852 |
await new Promise((resolve, reject) => {
|
| 853 |
ffmpeg()
|
| 854 |
+
// image2 demuxer at fixed 30fps — reads frame000000.png, frame000001.png, ...
|
| 855 |
+
// Each file appears exactly the right number of times so timing is exact.
|
| 856 |
+
.input(path.join(seqDir, "frame%06d.png"))
|
| 857 |
+
.inputOptions(["-f image2", `-r ${FPS}`])
|
| 858 |
.input(combinedAudio)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
.outputOptions([
|
| 860 |
+
`-r ${FPS}`,
|
| 861 |
"-c:v libx264",
|
| 862 |
"-pix_fmt yuv420p",
|
| 863 |
"-preset fast",
|