import { spawn } from 'child_process' import { createLogger } from './logger' import { registerManimProcess, unregisterManimProcess, wasManimProcessCancelled } from './manim-process-registry' import { buildManimArgs, buildResult, createExecutionState, elapsedSeconds, handleStderrData, handleStdoutData, normalizeExecuteOptions, startMemoryMonitor } from './manim-executor-runtime' const logger = createLogger('ManimExecutor') export interface ManimExecutionResult { success: boolean stdout: string stderr: string peakMemoryMB: number exitCode?: number } export interface ManimExecuteOptions { jobId: string quality: string frameRate?: number format?: 'mp4' | 'png' sceneName?: string tempDir: string mediaDir: string timeoutMs?: number } export function executeManimCommand( codeFile: string, options: ManimExecuteOptions ): Promise { const normalizedOptions = normalizeExecuteOptions(options) const args = buildManimArgs(codeFile, normalizedOptions) logger.info(`Job ${normalizedOptions.jobId}: starting manim process`, { command: `manim ${args.join(' ')}`, cwd: normalizedOptions.tempDir }) return new Promise((resolve) => { const startTime = Date.now() const state = createExecutionState() const proc = spawn('manim', args, { cwd: normalizedOptions.tempDir }) registerManimProcess(normalizedOptions.jobId, proc) const memoryMonitor = startMemoryMonitor(proc, normalizedOptions, state) let timeoutTimer: NodeJS.Timeout | null = null let settled = false const settle = (result: ManimExecutionResult): void => { if (settled) { return } settled = true if (timeoutTimer) { clearTimeout(timeoutTimer) } clearInterval(memoryMonitor) unregisterManimProcess(normalizedOptions.jobId) resolve(result) } proc.stdout.on('data', (data) => { handleStdoutData(state, normalizedOptions.jobId, data.toString()) }) proc.stderr.on('data', (data) => { handleStderrData(state, normalizedOptions.jobId, data.toString()) }) timeoutTimer = setTimeout(() => { const elapsed = elapsedSeconds(startTime) logger.warn(`Job ${normalizedOptions.jobId}: manim render timeout (${elapsed}s), killing process`, { peakMemoryMB: state.peakMemoryMB }) proc.kill('SIGKILL') settle( buildResult( false, state, state.stderr || `Manim render timeout (${Math.round(normalizedOptions.timeoutMs / 1000)} seconds)` ) ) }, normalizedOptions.timeoutMs) proc.on('close', (code) => { const elapsed = elapsedSeconds(startTime) const cancelled = wasManimProcessCancelled(normalizedOptions.jobId) if (cancelled) { logger.warn(`Job ${normalizedOptions.jobId}: Manim cancelled`, { elapsed: `${elapsed}s` }) settle(buildResult(false, state, 'Job cancelled', code ?? undefined)) return } if (code === 0) { logger.info(`Job ${normalizedOptions.jobId}: manim completed`, { elapsed: `${elapsed}s`, exitCode: code, stdoutLength: state.stdout.length, stderrLength: state.stderr.length, peakMemoryMB: state.peakMemoryMB }) settle(buildResult(true, state, undefined, code ?? undefined)) return } logger.error(`Job ${normalizedOptions.jobId}: manim exited with error`, { elapsed: `${elapsed}s`, exitCode: code, stdoutLength: state.stdout.length, stderrLength: state.stderr.length, stderrPreview: state.stderr.slice(-500), peakMemoryMB: state.peakMemoryMB }) settle(buildResult(false, state, undefined, code ?? undefined)) }) proc.on('error', (error) => { const elapsed = elapsedSeconds(startTime) const cancelled = wasManimProcessCancelled(normalizedOptions.jobId) if (cancelled) { logger.warn(`Job ${normalizedOptions.jobId}: Manim cancelled`, { elapsed: `${elapsed}s` }) settle(buildResult(false, state, 'Job cancelled')) return } logger.error(`Job ${normalizedOptions.jobId}: manim process start failed`, { elapsed: `${elapsed}s`, errorMessage: error.message, errorStack: error.stack }) settle(buildResult(false, state, error.message)) }) }) }