File size: 10,850 Bytes
b1df986
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import fs from 'fs'
import path from 'path'
import { performance } from 'node:perf_hooks'
import { createLogger } from '../../../../shared/logging/logger'
import { cleanManimCode } from '../../../../utils/manim-code-cleaner'
import { executeManimCommand, type ManimExecuteOptions } from '../../../../utils/manim-executor'
import {
  cleanupNarrationWorkspace,
  muxNarrationIntoVideo,
  resolveNarrationConfig,
  resolveNarrationWorkspace
} from '../../../manim/narration-mixer'
import { publishNarrationForReuse } from '../../../manim/narration-reuse'
import { burnSubtitles } from '../../../manim/hardsub'
import { ensureJobNotCancelled } from '../../../job/job-cancel'
import { isJobCancelled } from '../../../job/job-cancel-store'
import { storeJobStage } from '../../../../infrastructure/redis/job/redis-job-state-store'
import { resolveJobTimeoutMs } from '../../../../utils/job-timeout'
import { resolveRenderCacheWorkspace } from '../../../../utils/render-cache-workspace'
import { workspaceRenderOutputDirectory } from '../../../workspace-output-directory'
import {
  createRenderFailureEvent,
  extractCodeSnippet,
  inferErrorMessage,
  inferErrorType,
  isRenderFailureFeatureEnabled,
  sanitizeFullCode,
  sanitizeStderrPreview,
  sanitizeStdoutPreview
} from '../../../../operations/render-failures'
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'
import { extractCodeContext } from '../../../../workflow/code-retry/utils'

const logger = createLogger('RenderVideoStep')

function logNarrationSyncReport(jobId: string, syncReportPath: string | undefined): void {
  if (!syncReportPath) {
    return
  }
  try {
    const report = JSON.parse(fs.readFileSync(syncReportPath, 'utf-8')) as unknown
    logger.info('Narration sync report', { jobId, syncReportPath, report })
  } catch (error) {
    logger.warn('Narration sync report was not written', { jobId, syncReportPath, error: String(error) })
  }
}

function writeVideoIntoWorkspace(workspaceDirectory: string | undefined, jobId: string, sourceVideoPath: string, versionDirectory?: string): string | undefined {
  if (!workspaceDirectory) {
    return undefined
  }

  const workspaceOutputDir = workspaceRenderOutputDirectory(workspaceDirectory, jobId, versionDirectory)
  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,
  workspaceOutputDirectory?: string,
  sceneName?: string
): Promise<RenderResult> {
  const { code, usedAI, generationType, sceneDesign } = codeResult

  const frameRate = videoConfig?.frameRate || 60
  const timeoutMs = resolveJobTimeoutMs(videoConfig)

  logger.info('Rendering video', { jobId, quality, usedAI, frameRate, timeoutMs })

  const stableRenderCacheKey = renderCacheKey || workspaceDirectory || `job-${jobId}`
  const cacheWorkspace = resolveRenderCacheWorkspace(stableRenderCacheKey, 'video', jobId)
  const tempDir = cacheWorkspace.tempDir
  const mediaDir = cacheWorkspace.mediaDir
  const codeFile = cacheWorkspace.codeFile
  const outputDir = path.join(process.cwd(), 'public', 'videos')
  const narrationWorkspace = codeResult.narration
    ? resolveNarrationWorkspace(stableRenderCacheKey, jobId)
    : undefined
  const narrationSyncReportPath = narrationWorkspace?.syncReportPath

  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
  let renderedVideoPath: string | undefined

  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,
      isCancelled: () => isJobCancelled(jobId),
      tempDir,
      mediaDir,
      timeoutMs,
      narrationManifestPath: codeResult.narration?.manifestPath,
      narrationSyncReportPath
    }

    const renderStart = performance.now()
    const result = await executeManimCommand(codeFile, options)
    timings.render = (timings.render || 0) + (performance.now() - renderStart)
    renderedVideoPath = result.success ? result.outputPath : undefined
    lastRenderPeakMemoryMB = result.peakMemoryMB
    return {
      ...result,
      codeSnippet: result.success ? undefined : extractCodeContext(cleaned.code, result.stderr)
    }
  }

  if (usedAI) {
    logger.info('Using local code-retry for video render', { jobId, hasSceneDesign: !!sceneDesign })
  } else {
    logger.info('Using single render attempt for video', {
      jobId,
      reason: 'not_ai_generated'
    })
  }

  if (onStageUpdate) await onStageUpdate()

  const retryResult = await executeRenderWithRetry({
    referenceImages: codeResult.referenceImages,
    problemPlan: codeResult.problemPlan,
    concept,
    outputMode: 'video',
    sceneDesign,
    promptOverrides,
    customApiConfig,
    initialCode: code,
    usedAI,
    timings,
    renderCode,
    logRenderFailure,
    ensureJobNotCancelled: async () => ensureJobNotCancelled(jobId),
    onRetryStart: async () => {
      await storeJobStage(jobId, 'retrying', { status: 'processing' })
    }
  }).catch((error: unknown) => {
    // A cancelled or failed render must not leave this run's narration sidecar behind.
    cleanupNarrationWorkspace(narrationWorkspace?.outputDirectory)
    throw error
  })
  const finalCode = usedAI ? retryResult.finalCode : lastRenderedCode

  await ensureJobNotCancelled(jobId)
  const videoPath = renderedVideoPath
  if (!videoPath) {
    throw new Error('Video file not found after render')
  }

  if (codeResult.narration) {
    await storeJobStage(jobId, 'muxing', { status: 'processing' })
    const muxStart = performance.now()
    await muxNarrationIntoVideo({
      videoPath,
      narration: codeResult.narration
    })
    timings.mux = (timings.mux || 0) + (performance.now() - muxStart)
    logNarrationSyncReport(jobId, narrationSyncReportPath)

    const narrationConfig = resolveNarrationConfig(videoConfig, 'video')
    if (narrationConfig) {
      publishNarrationForReuse({
        renderCacheKey: stableRenderCacheKey,
        jobId,
        config: narrationConfig,
        assets: codeResult.narration
      })
    }
  }

  const outputFilename = `${jobId}.mp4`
  const outputPath = path.join(outputDir, outputFilename)
  fs.copyFileSync(videoPath, outputPath)

  // The web player reads a WebVTT sidecar through `<track>`; the MP4's own mov_text
  // track is for desktop players. The URL is derived from the video URL, so a film
  // without narration simply has no sidecar to fetch.
  const subtitleVttPath = codeResult.narration?.subtitleVttPath
  if (subtitleVttPath && fs.existsSync(subtitleVttPath)) {
    fs.copyFileSync(subtitleVttPath, path.join(outputDir, `${jobId}.vtt`))
  }

  // Optional burn-in for downloads: preview keeps the soft track, the burned file is
  // a separate artifact. A failure degrades to the normal video, never fails the job.
  let hardsubUrl: string | undefined
  const vttInOutput = path.join(outputDir, `${jobId}.vtt`)
  if (videoConfig?.burnSubtitles === true && fs.existsSync(vttInOutput)) {
    const burnStart = performance.now()
    try {
      await burnSubtitles({
        directory: outputDir,
        videoBasename: outputFilename,
        subtitleBasename: `${jobId}.vtt`,
        outputBasename: `${jobId}-hardsub.mp4`
      })
      timings.hardsub = (timings.hardsub || 0) + (performance.now() - burnStart)
      hardsubUrl = `/videos/${jobId}-hardsub.mp4`
    } catch (error) {
      logger.warn('Subtitle burn failed; delivering the video without burned captions', {
        jobId,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  const workspaceVideoPath = writeVideoIntoWorkspace(workspaceDirectory, jobId, outputPath, workspaceOutputDirectory)

  return {
    jobId,
    concept,
    outputMode: 'video',
    code: finalCode,
    codeLanguage: 'manim-python',
    usedAI,
    generationType,
    quality,
    videoUrl: `/videos/${outputFilename}`,
    hardsubUrl,
    workspaceVideoPath,
    renderPeakMemoryMB: lastRenderPeakMemoryMB
  }
}