// 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); }); } }