import { copyFileSync, existsSync, readdirSync, unlinkSync } from 'fs'; import { join } from 'path'; import pkg from 'common-utils'; import { exec, spawn } from 'child_process'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; import { platform } from 'os'; import { renderSSR } from './ssr.js'; import path from 'path'; import { renderProxy } from './proxy-renderer.js'; import { ProcessKiller } from './utils/ProcessKiller.js'; const { UnzipFiles, Utils, ZipFiles } = pkg; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export async function explodeUrl(fileUrl, jobId, dir, zipFile) { await Utils.downloadFile(fileUrl, zipFile, true); await UnzipFiles(zipFile, dir); } export async function listOutputFiles(jobId) { let outDir = join(__dirname, 'out'); let manuFile = join(__dirname, `public/original_manuscript.json`); copyFileSync(manuFile, join(__dirname, 'out', `original_manuscript.json`)); let outputFiles = readdirSync(outDir).map((fname) => { const filePath = join(outDir, fname); return filePath; }); return outputFiles; } export async function generateOutputBundle(jobId, outputFiles) { let outFile = join(__dirname, 'out', `output-${jobId}.zip`); if (existsSync(outFile)) { unlinkSync(outFile); } await ZipFiles(outputFiles, outFile); return outFile; } export function getNpmScript(mediaType) { if (mediaType === 'image') { return 'still'; } else if (!mediaType || mediaType === 'video') { return 'render'; } else { return mediaType; } } export async function doRender( jobId, originalManuscript, sendToObserver, target = 'render', ssrOptions, proxyOptions, controller) { const composition = originalManuscript?.meta?.renderComposition || 'SemibitComposition'; // Determine file extension based on codec let defaultBuildParams = ` --audio-codec mp3 --image-format=jpeg --enable-multi-process-on-linux --quality=70 --timeout 60000 --concurrency 1 --gl=angle `; let tempBuildParams = ' ' + (originalManuscript?.meta?.generationConfig?.extras?.buildParams || defaultBuildParams) + ' '; if (originalManuscript?.meta?.generationConfig?.extras?.additionalBuildParams) { tempBuildParams += ' ' + originalManuscript?.meta?.generationConfig?.extras?.additionalBuildParams + ' '; } // Extract codec from build params to determine file extension const codecMatch = tempBuildParams.match(/--codec[=\s]+([^\s]+)/); const codec = codecMatch ? codecMatch[1] : 'h264'; let videoExtension = '.mp4'; if (codec === 'vp8' || codec === 'vp9') { videoExtension = '.webm'; } else if (codec === 'prores') { videoExtension = '.mov'; } let outFile = path.join(process.cwd(), `out`, `${jobId}-video${videoExtension}`); if (target.includes('still')) { outFile = path.join(process.cwd(), `out`, `${jobId}-still.jpg`); } if (ssrOptions) { await renderSSR(outFile, ssrOptions.startFrame, ssrOptions.endFrame, controller) sendToObserver(jobId, 'completed'); return outFile } else if (proxyOptions) { await renderProxy(outFile, jobId, proxyOptions, controller) sendToObserver(jobId, 'completed'); return outFile } const renderComposition = composition || 'SemibitComposition'; // Use the build params we computed above for codec detection const buildParams = tempBuildParams; // Directly call remotion instead of going through npm scripts to avoid parameter parsing issues const remotionArgs = ['-y', 'remotion', target, ...buildParams.trim().split(/\s+/).filter(arg => arg), renderComposition, outFile]; const cmd = `npx ${remotionArgs.join(' ')}`; const spawnOptions = { detached: true, // Always detach to create new process group shell: true, // Use shell for proper parameter parsing stdio: ['ignore', 'pipe', 'pipe'] // Ensure we can capture output }; // On Unix systems, create a new process group for easier cleanup if (platform() !== 'win32') { spawnOptions.detached = true; } const childProcess = spawn('npx', remotionArgs, spawnOptions); let isProcessKilled = false; const processKiller = new ProcessKiller(); if (controller && controller.stop) { controller.stop = async () => { console.log('Stopping render studio cli process'); if (isProcessKilled) { console.log('Process already terminated'); return; } isProcessKilled = true; // Use the ProcessKiller utility for comprehensive termination const success = await processKiller.terminateProcess(childProcess.pid, { gracefulTimeout: 2000, forceTimeout: 1000, processPattern: "remotion.*render", onProgress: (message) => console.log(message) }); if (success) { console.log('Process termination completed'); } else { console.log('Process termination attempted (may have already been terminated)'); } } } console.log('Starting video render. ' + cmd); console.log(`Spawned process with PID: ${childProcess.pid}`); // Track the process for cleanup processKiller.trackPid(childProcess.pid); let updateCounter = 0; childProcess.stdout.on('data', (data) => { sendToObserver(jobId, data); if (!process.env.is_pm2) console.log(data?.toString()); if (updateCounter++ % 100 == 0 || updateCounter < 5) { if (data?.split?.('\n')?.[0]) console.log(data?.split?.('\n')?.[0]); } }); childProcess.stderr.on('data', (data) => { sendToObserver(jobId, data); console.error(data.toString()); }); return new Promise((resolve, reject) => { childProcess.on('close', (code, signal) => { isProcessKilled = true; // Mark process as terminated console.log(`Render process closed with code: ${code}, signal: ${signal}`); // Clean up tracked PIDs processKiller.clearTrackedPids(); sendToObserver(jobId, code === 0 ? 'completed' : 'failed'); if (code === 0) { resolve(outFile); console.log(`'${target}' completed successfully.`); } else { const message = signal === 'SIGTERM' || signal === 'SIGKILL' ? `'${target}' was terminated by user request.` : `'${target}' failed with code ${code}.`; reject({ message }); console.error(message); } }); childProcess.on('error', (error) => { isProcessKilled = true; // Mark process as terminated processKiller.clearTrackedPids(); console.error('Child process error:', error); sendToObserver(jobId, 'failed'); reject(error); }); // Handle process exit childProcess.on('exit', (code, signal) => { console.log(`Render process exited with code: ${code}, signal: ${signal}`); }); }); } export function clear(skipOutput, skipPublic, skipUploads) { try { const preserve = ['assets', 'mp3']; if (!skipPublic) Utils.clearFolder(join(__dirname, './public'), preserve); if (!skipOutput) Utils.clearFolder(join(__dirname, './out')); if (!skipUploads) Utils.clearFolder(join(__dirname, './uploads')); } catch (e) { } }