import { spawn } from 'child_process' import { createLogger } from './logger' import { getProcessMemory } from './process-memory' import type { ManimExecuteOptions, ManimExecutionResult } from './manim-executor' const logger = createLogger('ManimExecutorRuntime') const STDOUT_LOG_INTERVAL_MS = 5000 const PROGRESS_LOG_INTERVAL_MS = 3000 const MEMORY_MONITOR_INTERVAL_MS = 2000 const IS_PRODUCTION = process.env.NODE_ENV === 'production' function parseBooleanEnv(value: string | undefined): boolean | undefined { if (typeof value !== 'string') return undefined const normalized = value.trim().toLowerCase() if (['1', 'true', 'yes', 'on'].includes(normalized)) return true if (['0', 'false', 'no', 'off'].includes(normalized)) return false return undefined } const renderMemoryLogEnabled = parseBooleanEnv(process.env.RENDER_MEMORY_LOG_ENABLED) ?? !IS_PRODUCTION const RESOLUTION_MAP: Record = { low: { width: 854, height: 480 }, medium: { width: 1280, height: 720 }, high: { width: 1920, height: 1080 } } export interface NormalizedExecuteOptions { jobId: string quality: string frameRate: number format: 'mp4' | 'png' sceneName: string tempDir: string mediaDir: string timeoutMs: number } export interface ExecutionState { stdout: string stderr: string peakMemoryMB: number lastProgressLogAt: number lastStdoutLogAt: number } export function normalizeExecuteOptions(options: ManimExecuteOptions): NormalizedExecuteOptions { return { jobId: options.jobId, quality: options.quality, frameRate: options.frameRate ?? 15, format: options.format ?? 'mp4', sceneName: options.sceneName ?? 'MainScene', tempDir: options.tempDir, mediaDir: options.mediaDir, timeoutMs: options.timeoutMs ?? 10 * 60 * 1000 } } export function buildManimArgs(codeFile: string, options: NormalizedExecuteOptions): string[] { const resolution = RESOLUTION_MAP[options.quality] || RESOLUTION_MAP.medium const args = [ 'render', '--format', options.format, '--fps', options.frameRate.toString(), '--resolution', `${resolution.width},${resolution.height}`, '--media_dir', options.mediaDir ] if (options.format === 'png') { args.push('-s') } args.push(codeFile, options.sceneName) return args } export function createExecutionState(): ExecutionState { const now = Date.now() return { stdout: '', stderr: '', peakMemoryMB: 0, lastProgressLogAt: now, lastStdoutLogAt: now } } export function startMemoryMonitor( proc: ReturnType, options: NormalizedExecuteOptions, state: ExecutionState ): NodeJS.Timeout { return setInterval(async () => { if (!proc.pid) { return } const memory = await getProcessMemory(proc.pid) if (memory === null) { return } if (memory > state.peakMemoryMB) { state.peakMemoryMB = memory } if (renderMemoryLogEnabled) { logger.info(`Job ${options.jobId}: Manim 内存使用(进程树总和)`, { memoryMB: memory, peakMemoryMB: state.peakMemoryMB }) } }, MEMORY_MONITOR_INTERVAL_MS) } export function handleStdoutData(state: ExecutionState, jobId: string, text: string): void { state.stdout += text const elapsedSinceLastStdoutLog = Date.now() - state.lastStdoutLogAt if (elapsedSinceLastStdoutLog > STDOUT_LOG_INTERVAL_MS) { logger.info(`Job ${jobId}: Manim 进度输出`, { output: text.trim(), totalOutputLength: state.stdout.length }) state.lastStdoutLogAt = Date.now() } if (!text.includes('%') && !text.includes('it/s')) { return } const elapsedSinceLastProgressLog = Date.now() - state.lastProgressLogAt if (elapsedSinceLastProgressLog > PROGRESS_LOG_INTERVAL_MS) { logger.info(`Job ${jobId}: 渲染进度`, { progress: text.trim() }) state.lastProgressLogAt = Date.now() } } export function handleStderrData(state: ExecutionState, jobId: string, text: string): void { state.stderr += text logger.info(`Job ${jobId}: Manim stderr 实时输出`, { output: text.trim(), totalStderrLength: state.stderr.length }) } export function elapsedSeconds(startTime: number): string { return ((Date.now() - startTime) / 1000).toFixed(1) } export function buildResult( success: boolean, state: ExecutionState, stderrOverride?: string ): ManimExecutionResult { return { success, stdout: state.stdout, stderr: stderrOverride ?? state.stderr, peakMemoryMB: state.peakMemoryMB } }