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