File size: 6,130 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
/**
 * 概念分析步骤
 * 分析用户输入,决定生成策略
 */

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<AnalysisResult> {
  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<GenerationResult> {
  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<string, number>,
  customApiConfig?: CustomApiConfig,
  promptOverrides?: PromptOverrides,
  referenceImages?: ReferenceImage[],
  problemPlan?: ProblemFramingPlan,
  narration?: NarrationStageRequest
): Promise<GenerationResult> {
  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
  }
}