ManimCat-show / src /queues /processors /steps /render-images.ts
Bin29's picture
Sync from main: e764154 feat(plot-skill): add math-exam-diagram SKILL.md for exam-style math figures
abcf568
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 { findImageFile } from '../../../utils/file-utils'
import { ensureJobNotCancelled } from '../../../services/job-cancel'
import { JobCancelledError } from '../../../utils/errors'
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('RenderImageStep')
function writeImagesIntoWorkspace(workspaceDirectory: string | undefined, jobId: string, sourceImagePaths: string[]): string[] | undefined {
if (!workspaceDirectory || sourceImagePaths.length === 0) {
return undefined
}
const workspaceOutputDir = path.join(workspaceDirectory, 'renders', jobId)
fs.mkdirSync(workspaceOutputDir, { recursive: true })
return sourceImagePaths.map((sourcePath, index) => {
const suffix = path.extname(sourcePath) || '.png'
const workspaceImagePath = path.join(workspaceOutputDir, `image-${String(index + 1).padStart(3, '0')}${suffix}`)
fs.copyFileSync(sourcePath, workspaceImagePath)
return workspaceImagePath
})
}
interface ImageCodeBlock {
index: number
code: string
}
interface ImageRenderAttempt {
success: boolean
stderr: string
stdout: string
peakMemoryMB: number
imageUrls: string[]
outputPaths: string[]
exitCode?: number
failedCode?: string
}
function parseImageCodeBlocks(code: string): ImageCodeBlock[] {
const blocks: ImageCodeBlock[] = []
const blockRegex = /###\s*YON_IMAGE_(\d+)_START\s*###([\s\S]*?)###\s*YON_IMAGE_\1_END\s*###/g
let match: RegExpExecArray | null
while ((match = blockRegex.exec(code)) !== null) {
const index = parseInt(match[1], 10)
const blockCode = match[2].trim()
if (!Number.isFinite(index) || !blockCode) {
continue
}
blocks.push({ index, code: blockCode })
}
if (blocks.length === 0) {
throw new Error('No YON_IMAGE code blocks were found')
}
const remaining = code
.replace(/###\s*YON_IMAGE_(\d+)_START\s*###[\s\S]*?###\s*YON_IMAGE_\1_END\s*###/g, '')
.trim()
if (remaining.length > 0) {
throw new Error('Image mode only accepts code inside YON_IMAGE blocks')
}
blocks.sort((a, b) => a.index - b.index)
return blocks
}
function detectSceneName(code: string): string {
const match = code.match(/class\s+([A-Za-z_]\w*)\s*\([^)]*Scene[^)]*\)\s*:/)
if (match?.[1]) {
return match[1]
}
throw new Error('Image code block is missing a renderable Scene class')
}
function clearPreviousImages(outputDir: string, jobId: string): void {
const prefix = `${jobId}-`
if (!fs.existsSync(outputDir)) {
return
}
for (const entry of fs.readdirSync(outputDir)) {
if (entry.startsWith(prefix) && entry.endsWith('.png')) {
fs.rmSync(path.join(outputDir, entry), { force: true })
}
}
}
function resolveModel(customApiConfig?: unknown): string | undefined {
const model = (customApiConfig as Partial<CustomApiConfig> | undefined)?.model
const normalized = typeof model === 'string' ? model.trim() : ''
return normalized || undefined
}
async function renderImageBlocks(
jobId: string,
quality: string,
code: string,
frameRate: number,
timeoutMs: number,
renderCacheKey: string,
outputDir: string
): Promise<ImageRenderAttempt> {
try {
await ensureJobNotCancelled(jobId)
logger.info('Image parse stage started', { jobId, stage: 'image-parse' })
const blocks = parseImageCodeBlocks(code)
logger.info('Image parse stage completed', { jobId, stage: 'image-parse', blockCount: blocks.length })
clearPreviousImages(outputDir, jobId)
const imageUrls: string[] = []
let peakMemoryMB = 0
for (const block of blocks) {
await ensureJobNotCancelled(jobId)
const stageName = `image-render-${block.index}`
logger.info('Image render stage started', { jobId, stage: stageName, blockIndex: block.index })
const blockWorkspace = resolveRenderCacheWorkspace(renderCacheKey, 'image', block.index)
const blockDir = blockWorkspace.tempDir
const mediaDir = blockWorkspace.mediaDir
const codeFile = blockWorkspace.codeFile
const cleaned = cleanManimCode(block.code)
const sceneName = detectSceneName(cleaned.code)
fs.writeFileSync(codeFile, cleaned.code, 'utf-8')
const options: ManimExecuteOptions = {
jobId,
quality,
frameRate,
format: 'png',
sceneName,
tempDir: blockDir,
mediaDir,
timeoutMs
}
const renderResult = await executeManimCommand(codeFile, options)
peakMemoryMB = Math.max(peakMemoryMB, renderResult.peakMemoryMB)
if (!renderResult.success) {
return {
success: false,
stderr: `Image ${block.index} render failed: ${renderResult.stderr || 'Manim render failed'}`,
stdout: renderResult.stdout || '',
peakMemoryMB,
imageUrls: [],
outputPaths: [],
exitCode: renderResult.exitCode,
failedCode: cleaned.code
}
}
const imagePath = findImageFile(mediaDir, sceneName)
if (!imagePath) {
return {
success: false,
stderr: `Image ${block.index} render completed but no PNG output was found`,
stdout: '',
peakMemoryMB,
imageUrls: [],
outputPaths: [],
failedCode: cleaned.code
}
}
const outputFilename = `${jobId}-${block.index}.png`
const outputPath = path.join(outputDir, outputFilename)
fs.copyFileSync(imagePath, outputPath)
imageUrls.push(`/images/${outputFilename}`)
logger.info('Image render stage completed', { jobId, stage: stageName, outputFilename })
}
return {
success: true,
stderr: '',
stdout: '',
peakMemoryMB,
imageUrls,
outputPaths: blocks.map((block) => path.join(outputDir, `${jobId}-${block.index}.png`))
}
} catch (error) {
if (error instanceof JobCancelledError) {
throw error
}
return {
success: false,
stderr: error instanceof Error ? error.message : String(error),
stdout: '',
peakMemoryMB: 0,
imageUrls: [],
outputPaths: []
}
}
}
export async function renderImages(
jobId: string,
concept: string,
quality: string,
codeResult: GenerationResult,
timings?: Record<string, number>,
videoConfig?: VideoConfig,
customApiConfig?: unknown,
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)
const stableRenderCacheKey = renderCacheKey || workspaceDirectory || `job-${jobId}`
const outputDir = path.join(process.cwd(), 'public', 'images')
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: 'image',
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('[RenderImageStep] Failed to record render failure:', error)
}
}
fs.mkdirSync(outputDir, { recursive: true })
await ensureJobNotCancelled(jobId)
if (onStageUpdate) {
await onStageUpdate()
}
let finalImageUrls: string[] = []
let finalImageOutputPaths: string[] = []
let peakMemoryMB = 0
const renderWithCode = async (candidateCode: string): Promise<{
success: boolean
stderr: string
stdout: string
peakMemoryMB: number
exitCode?: number
codeSnippet?: string
}> => {
const attempt = await renderImageBlocks(jobId, quality, candidateCode, frameRate, timeoutMs, stableRenderCacheKey, outputDir)
peakMemoryMB = Math.max(peakMemoryMB, attempt.peakMemoryMB)
if (attempt.success) {
finalImageUrls = attempt.imageUrls
finalImageOutputPaths = attempt.outputPaths
}
return {
success: attempt.success,
stderr: attempt.stderr,
stdout: attempt.stdout,
peakMemoryMB: attempt.peakMemoryMB,
exitCode: attempt.exitCode,
codeSnippet: attempt.failedCode
}
}
const { finalCode } = await executeRenderWithRetry({
concept,
outputMode: 'image',
sceneDesign,
promptOverrides,
customApiConfig,
initialCode: code,
usedAI,
timings,
renderCode: renderWithCode,
logRenderFailure,
ensureJobNotCancelled: async () => ensureJobNotCancelled(jobId)
})
await ensureJobNotCancelled(jobId)
const workspaceImagePaths = writeImagesIntoWorkspace(workspaceDirectory, jobId, finalImageOutputPaths)
return {
jobId,
concept,
outputMode: 'image',
code: finalCode,
codeLanguage: 'manim-python',
usedAI,
generationType,
quality,
imageUrls: finalImageUrls,
imageCount: finalImageUrls.length,
workspaceImagePaths,
renderPeakMemoryMB: peakMemoryMB || undefined
}
}