File size: 5,744 Bytes
7ad7585
 
8dd5bba
 
 
 
 
7ad7585
8dd5bba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ad7585
8dd5bba
ad6b549
3ec2e41
7504977
 
 
 
 
 
 
ad6b549
 
 
 
 
 
8dd5bba
7ad7585
ad6b549
7ad7585
 
 
3ec2e41
7ad7585
8dd5bba
7ad7585
3ec2e41
8dd5bba
7ad7585
 
8dd5bba
7ad7585
 
 
 
8dd5bba
7ad7585
 
 
 
 
8dd5bba
7ad7585
 
8dd5bba
7ad7585
 
 
3ec2e41
7ad7585
 
3ec2e41
7ad7585
ad6b549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ec2e41
ad6b549
7504977
 
ad6b549
8dd5bba
ad6b549
8dd5bba
ad6b549
 
 
 
 
 
7ad7585
ad6b549
 
7504977
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdfc7ce
7504977
 
 
3dcaad7
7504977
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8dd5bba
 
7504977
7ad7585
 
8dd5bba
7ad7585
 
8dd5bba
7ad7585
8dd5bba
7ad7585
 
8dd5bba
7ad7585
 
8dd5bba
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// FULL FIXED VERSION — eliminates concat=n=1 and all copy filters

import { spawn } from 'child_process';
import os from 'os';
import path from 'path';
import fs from 'fs';

const ffmpegLocation = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';

export class CaptionRenderer {
  validateCaption(originalManuscript) {
    let captionFiles = originalManuscript.transcript.map(item => item.audioCaptionFile);
    // make sure the caption files are in ass format and exist
    for (let captionFile of captionFiles) {
      if (!captionFile || !captionFile.endsWith('.ass')) {
        throw new Error('Invalid caption file format. Expected .ass files for item ' + (captionFiles.indexOf(captionFile) + 1) + '. Did you forget to use `caption` plugin?');
      }
    }
    return true;
  }

  async doRender(jobId, originalManuscript, onLog, npmScript, options, controller) {
    const outDir = path.join(process.cwd(), 'out');
    if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });

    const segmentFiles = [];

    // Helper to clean up intermediate files
    function cleanupFiles(files) {
      for (const f of files) {
        try { fs.unlinkSync(f); } catch (e) { /* ignore */ }
      }
    }

    // Render each transcript section separately
    for (const [i, section] of originalManuscript.transcript.entries()) {
      const segmentFile = path.join(outDir, `${jobId}_segment_${i}.mp4`);
      const inputs = [];
      const filter = [];
      let inputIndex = 0;

      const media = section.mediaAbsPaths || [];
      if (!media.length) continue;

      media.forEach(m => {
        inputs.push('-i', m.path);
        inputIndex++;
      });

      inputs.push('-i', section.audioFullPath);
      inputIndex++;

      let clips = [];
      let elapsed = 0;

      media.forEach((m, j) => {
        let d = m.prompt?.durationSec || m.durationSec;
        d = Math.min(d, section.durationInSeconds - elapsed);
        if (d <= 0) return;

        const label = `v${i}_${j}`;
        filter.push(`[${inputIndex - media.length - 1 + j}:v]trim=duration=${d},setpts=PTS-STARTPTS[${label}]`);
        clips.push(`[${label}]`);
        elapsed += d;
      });

      const vcat = `vc${i}`;
      filter.push(`${clips.join('')}concat=n=${clips.length}:v=1:a=0[${vcat}]`);

      const sub = `vs${i}`;
      const esc = section.audioCaptionFile.replace(/\\/g, '/').replace(/:/g, '\\:');
      filter.push(`[${vcat}]subtitles='${esc}'[${sub}]`);

      const vout = `vout${i}`;
      const aout = `aout${i}`;

      filter.push(`[${sub}]null[${vout}]`);
      filter.push(`[${inputIndex - 1}:a]anull[${aout}]`);

      const args = [
        ...inputs,
        '-filter_complex', filter.join(';'),
        '-map', `[${vout}]`,
        '-map', `[${aout}]`,
        '-c:v', 'libx264',
        '-c:a', 'aac',
        '-y', segmentFile
      ];

      await this.runFFmpeg(args, controller, onLog);
      segmentFiles.push(segmentFile);
    }

    // Concatenate all segments together
    const resultFile = path.join(outDir, `${jobId}_final.mp4`);
    const concatFile = path.join(outDir, `${jobId}_combined_final.mp4`);
    const concatList = path.join(outDir, `${jobId}_concat_list.txt`);

    fs.writeFileSync(concatList, segmentFiles.map(f => `file '${f}'`).join('\n'));

    const concatArgs = [
      '-f', 'concat',
      '-safe', '0',
      '-i', concatList,
      '-c', 'copy',
      '-y', concatFile
    ];
    await this.runFFmpeg(concatArgs, controller, onLog);

    // Add background music if present
    const { bgMusic, bgMusicVolume = 0.25 } = originalManuscript;
    if (bgMusic) {
      const finalWithMusic = resultFile; // Overwrite the _final.mp4 file
      const getDuration = (filePath) => {
        try {
          const ffprobeSync = require('child_process').spawnSync;
          const result = ffprobeSync(ffmpegLocation.replace('ffmpeg', 'ffprobe'), [
            '-v', 'error',
            '-show_entries', 'format=duration',
            '-of', 'default=noprint_wrappers=1:nokey=1',
            filePath
          ]);
          return parseFloat(result.stdout.toString().trim());
        } catch (e) {
          return null;
        }
      };
      const videoDuration = getDuration(concatFile);
      const atrimArg = (videoDuration && !isNaN(videoDuration)) ? `,atrim=end=${videoDuration}` : '';

      const voiceVolume = options?.voiceVolume || 1.5;
      const musicArgs = [
        '-i', concatFile,
        '-i', bgMusic,
        '-filter_complex', `[0:a]volume=${voiceVolume}[voice];[1:a]volume=${bgMusicVolume}${atrimArg}[bgm];[voice][bgm]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixeda]`,
        '-map', '0:v',
        '-map', '[mixeda]',
        '-c:v', 'copy',
        '-c:a', 'aac',
        '-y', finalWithMusic
      ];
      await this.runFFmpeg(musicArgs, controller, onLog);
      segmentFiles.push(concatFile);
    }
    else {
      fs.renameSync(concatFile, resultFile);
    }

    cleanupFiles([...segmentFiles, concatList]);
    return resultFile;
  }


  runFFmpeg(args, controller, onLog) {
    console.log('FFMPEG cmd:', args.join(' '));

    return new Promise((resolve, reject) => {
      const p = spawn(ffmpegLocation, args, { detached: true });

      if (controller) controller.stop = () => p.kill('SIGKILL');

      p.stderr.on('data', d => onLog && onLog(d.toString()));
      p.stdout.on('data', d => onLog && onLog(d.toString()));

      p.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
      p.on('error', reject);
    });
  }
}