/** * 概念分析步骤 * 分析用户输入,决定生成策略 */ import { performance } from 'node:perf_hooks' import { generateTwoStageAIManimCode } from '../../../../workflow/concept-design/concept-designer' import { storeProcessingStage } from '../video-processor-utils' import { ensureJobNotCancelled } from '../../../job/job-cancel' import { isJobCancelled } from '../../../job/job-cancel-store' import { cleanupNarrationWorkspace } from '../../../manim/narration-mixer' import { createLogger } from '../../../../shared/logging/logger' import type { NarrationAssets, NarrationConfig } from '../../../../workflow/narration/types' import type { CustomApiConfig, OutputMode, PromptOverrides, ReferenceImage, ProblemFramingPlan } from '../../../../types' const logger = createLogger('AnalysisStep') /** Server-resolved narration input for one generation run. */ export interface NarrationStageRequest { config: NarrationConfig outputDirectory: string } /** * 概念分析结果 */ export interface AnalysisResult { analysisType: 'latex' | 'template' | 'ai' | 'fallback' code: string | null needsAI: boolean } /** * 代码生成结果 */ export interface GenerationResult { code: string usedAI: boolean generationType: string sceneDesign?: string problemPlan?: ProblemFramingPlan referenceImages?: ReferenceImage[] narration?: NarrationAssets } /** * 分析概念 */ export async function analyzeConcept( jobId: string, concept: string, _quality: string, outputMode: OutputMode ): Promise { logger.info('Analyzing concept', { jobId, concept, outputMode }) logger.info('Using AI for all concepts (template shortcuts disabled)', { jobId, outputMode }) return { analysisType: 'ai', code: null, needsAI: true } } /** * 生成代码 */ export async function generateCode( jobId: string, concept: string, _quality: string, outputMode: OutputMode, analyzeResult: AnalysisResult, customApiConfig?: CustomApiConfig, promptOverrides?: PromptOverrides, referenceImages?: ReferenceImage[], problemPlan?: ProblemFramingPlan, narration?: NarrationStageRequest ): Promise { const { analysisType, code, needsAI } = analyzeResult logger.info('Generating code', { jobId, outputMode, needsAI, analysisType, hasImages: !!referenceImages?.length }) if (needsAI) { const narrationAbort = new AbortController() let cancellationWatcher: NodeJS.Timeout | undefined if (narration) { // The LLM stages checkpoint through `ensureJobNotCancelled`; TTS is a separate child process, // so give it a durable signal and turn the Redis marker into an abort. cancellationWatcher = setInterval(() => { void isJobCancelled(jobId) .then((cancelled) => { if (cancelled) narrationAbort.abort() }) .catch(() => undefined) }, 1000) cancellationWatcher.unref?.() } try { logger.info('Using two-stage AI generation', { jobId, hasImages: !!referenceImages?.length, narration: !!narration }) const result = await generateTwoStageAIManimCode( concept, outputMode, customApiConfig, promptOverrides, referenceImages, () => ensureJobNotCancelled(jobId), problemPlan, async (stage) => { await storeProcessingStage(jobId, stage, { status: 'processing' }) }, narration ? { config: narration.config, outputDirectory: narration.outputDirectory, signal: narrationAbort.signal, abort: () => narrationAbort.abort() } : undefined ) if (result.code && result.code.length > 0) { logger.info('Two-stage AI code generation succeeded', { jobId, length: result.code.length, hasSceneDesign: !!result.sceneDesign, hasNarration: !!result.narration }) return { code: result.code, usedAI: true, generationType: 'two-stage-ai', sceneDesign: result.sceneDesign, problemPlan, referenceImages, narration: result.narration } } throw new Error('Two-stage AI returned an empty code result') } catch (error) { narrationAbort.abort() if (narration) { cleanupNarrationWorkspace(narration.outputDirectory) } // A cancelled TTS run must surface as JobCancelledError (with the stored reason), not as a // generic narration failure that Bull might retry. if (narration && await isJobCancelled(jobId)) { await ensureJobNotCancelled(jobId) } logger.error('AI generation failed', { jobId, error: String(error) }) throw error } finally { if (cancellationWatcher) clearInterval(cancellationWatcher) } } if (code) { logger.info('Using pre-generated code', { jobId, length: code.length }) return { code, usedAI: false, generationType: analysisType, problemPlan, referenceImages } } throw new Error('No renderable code was generated') } /** * 分析并生成(合并分析+生成) */ export async function analyzeAndGenerate( jobId: string, concept: string, quality: string, outputMode: OutputMode, timings: Record, customApiConfig?: CustomApiConfig, promptOverrides?: PromptOverrides, referenceImages?: ReferenceImage[], problemPlan?: ProblemFramingPlan, narration?: NarrationStageRequest ): Promise { const analyzeStart = performance.now() const analysisResult = await analyzeConcept(jobId, concept, quality, outputMode) timings.analyze = performance.now() - analyzeStart const generateStart = performance.now() try { return await generateCode( jobId, concept, quality, outputMode, analysisResult, customApiConfig, promptOverrides, referenceImages, problemPlan, narration ) } finally { timings.generate = performance.now() - generateStart } }