| import fs from 'fs' |
| import path from 'path' |
| import { createLogger } from '../../../utils/logger' |
| import { cleanManimCode } from '../../../utils/manim-code-cleaner' |
| import { executeManimCommand, type ManimExecuteOptions } from '../../../utils/manim-executor' |
| import { findVideoFile } from '../../../utils/file-utils' |
| import { addBackgroundMusic } from '../../../audio/bgm-mixer' |
| import { ensureJobNotCancelled } from '../../../services/job-cancel' |
| import { storeJobStage } from '../../../services/job-store' |
| import { resolveJobTimeoutMs } from '../../../utils/job-timeout' |
| import { resolveRenderCacheWorkspace } from '../../../utils/render-cache-workspace' |
| import { |
| createRenderFailureEvent, |
| extractCodeSnippet, |
| inferErrorMessage, |
| inferErrorType, |
| isRenderFailureFeatureEnabled, |
| sanitizeFullCode, |
| sanitizeStderrPreview, |
| sanitizeStdoutPreview |
| } from '../../../render-failure' |
| import type { GenerationResult } from './analysis-step' |
| import type { CustomApiConfig, PromptOverrides, VideoConfig } from '../../../types' |
| import type { RenderResult } from './render-step-types' |
| import { executeRenderWithRetry } from './render-with-retry' |
|
|
| const logger = createLogger('RenderVideoStep') |
|
|
| function writeVideoIntoWorkspace(workspaceDirectory: string | undefined, jobId: string, sourceVideoPath: string): string | undefined { |
| if (!workspaceDirectory) { |
| return undefined |
| } |
|
|
| const workspaceOutputDir = path.join(workspaceDirectory, 'renders', jobId) |
| fs.mkdirSync(workspaceOutputDir, { recursive: true }) |
| const workspaceVideoPath = path.join(workspaceOutputDir, 'output.mp4') |
| fs.copyFileSync(sourceVideoPath, workspaceVideoPath) |
| return workspaceVideoPath |
| } |
|
|
| function resolveModel(customApiConfig?: unknown): string | undefined { |
| const model = (customApiConfig as Partial<CustomApiConfig> | undefined)?.model |
| const normalized = typeof model === 'string' ? model.trim() : '' |
| return normalized || undefined |
| } |
|
|
| export async function renderVideo( |
| jobId: string, |
| concept: string, |
| quality: string, |
| codeResult: GenerationResult, |
| timings: Record<string, number>, |
| customApiConfig?: unknown, |
| videoConfig?: VideoConfig, |
| promptOverrides?: PromptOverrides, |
| onStageUpdate?: () => Promise<void>, |
| clientId?: string, |
| workspaceDirectory?: string, |
| renderCacheKey?: string |
| ): Promise<RenderResult> { |
| const { code, usedAI, generationType, sceneDesign } = codeResult |
|
|
| const frameRate = videoConfig?.frameRate || 15 |
| const timeoutMs = resolveJobTimeoutMs(videoConfig) |
|
|
| logger.info('Rendering video', { jobId, quality, usedAI, frameRate, timeoutMs }) |
|
|
| const stableRenderCacheKey = renderCacheKey || workspaceDirectory || `job-${jobId}` |
| const cacheWorkspace = resolveRenderCacheWorkspace(stableRenderCacheKey, 'video') |
| const tempDir = cacheWorkspace.tempDir |
| const mediaDir = cacheWorkspace.mediaDir |
| const codeFile = cacheWorkspace.codeFile |
| const outputDir = path.join(process.cwd(), 'public', 'videos') |
|
|
| const logRenderFailure = async (args: { |
| attempt: number |
| code: string |
| codeSnippet?: string |
| stderr: string |
| stdout: string |
| peakMemoryMB: number |
| exitCode?: number |
| promptRole: string |
| }): Promise<void> => { |
| if (!isRenderFailureFeatureEnabled()) { |
| return |
| } |
|
|
| try { |
| await createRenderFailureEvent({ |
| job_id: jobId, |
| attempt: args.attempt, |
| output_mode: 'video', |
| error_type: inferErrorType(args.stderr), |
| error_message: inferErrorMessage(args.stderr), |
| stderr_preview: sanitizeStderrPreview(args.stderr), |
| stdout_preview: sanitizeStdoutPreview(args.stdout), |
| code_snippet: extractCodeSnippet(args.codeSnippet || args.code), |
| full_code: sanitizeFullCode(args.code), |
| peak_memory_mb: args.peakMemoryMB, |
| exit_code: args.exitCode, |
| recovered: false, |
| model: resolveModel(customApiConfig), |
| prompt_version: process.env.PROMPT_VERSION?.trim() || null, |
| prompt_role: args.promptRole, |
| client_id: clientId || null, |
| concept: concept || null |
| }) |
| } catch (error) { |
| console.error('[RenderVideoStep] Failed to record render failure:', error) |
| } |
| } |
|
|
| fs.mkdirSync(tempDir, { recursive: true }) |
| fs.mkdirSync(mediaDir, { recursive: true }) |
| fs.mkdirSync(outputDir, { recursive: true }) |
|
|
| let lastRenderedCode = code |
| let lastRenderPeakMemoryMB = 0 |
|
|
| const renderCode = async (candidateCode: string): Promise<{ |
| success: boolean |
| stderr: string |
| stdout: string |
| peakMemoryMB: number |
| exitCode?: number |
| codeSnippet?: string |
| }> => { |
| await ensureJobNotCancelled(jobId) |
| const cleaned = cleanManimCode(candidateCode) |
| lastRenderedCode = cleaned.code |
|
|
| if (cleaned.changes.length > 0) { |
| logger.info('Manim code cleaned', { |
| jobId, |
| changes: cleaned.changes, |
| originalLength: candidateCode.length, |
| cleanedLength: cleaned.code.length |
| }) |
| } |
|
|
| fs.writeFileSync(codeFile, cleaned.code, 'utf-8') |
|
|
| const options: ManimExecuteOptions = { |
| jobId, |
| quality, |
| frameRate, |
| format: 'mp4', |
| sceneName: 'MainScene', |
| tempDir, |
| mediaDir, |
| timeoutMs |
| } |
|
|
| const result = await executeManimCommand(codeFile, options) |
| lastRenderPeakMemoryMB = result.peakMemoryMB |
| return { |
| ...result, |
| codeSnippet: cleaned.code |
| } |
| } |
|
|
| if (usedAI) { |
| logger.info('Using local code-retry for video render', { jobId, hasSceneDesign: !!sceneDesign }) |
| await storeJobStage(jobId, 'generating') |
| } else { |
| logger.info('Using single render attempt for video', { |
| jobId, |
| reason: 'not_ai_generated' |
| }) |
| } |
|
|
| if (onStageUpdate) await onStageUpdate() |
|
|
| const retryResult = await executeRenderWithRetry({ |
| concept, |
| outputMode: 'video', |
| sceneDesign, |
| promptOverrides, |
| customApiConfig, |
| initialCode: code, |
| usedAI, |
| timings, |
| renderCode, |
| logRenderFailure, |
| ensureJobNotCancelled: async () => ensureJobNotCancelled(jobId) |
| }) |
| const finalCode = usedAI ? retryResult.finalCode : lastRenderedCode |
|
|
| await ensureJobNotCancelled(jobId) |
| const videoPath = findVideoFile(mediaDir, quality, frameRate) |
| if (!videoPath) { |
| throw new Error('Video file not found after render') |
| } |
|
|
| const outputFilename = `${jobId}.mp4` |
| const outputPath = path.join(outputDir, outputFilename) |
| fs.copyFileSync(videoPath, outputPath) |
|
|
| if (videoConfig?.bgm !== false) { |
| await addBackgroundMusic(outputPath) |
| } |
|
|
| const workspaceVideoPath = writeVideoIntoWorkspace(workspaceDirectory, jobId, outputPath) |
|
|
| return { |
| jobId, |
| concept, |
| outputMode: 'video', |
| code: finalCode, |
| codeLanguage: 'manim-python', |
| usedAI, |
| generationType, |
| quality, |
| videoUrl: `/videos/${outputFilename}`, |
| workspaceVideoPath, |
| renderPeakMemoryMB: lastRenderPeakMemoryMB |
| } |
| }
|
|
|