import { logInfo as _ulogInfo, logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core' /** * 分镜生成多阶段处理器 * 将分镜生成拆分为3个独立阶段,每阶段控制在Vercel时间限制内 * * 每个阶段失败后重试一次 */ import { executeAiTextStep } from '@/lib/ai-runtime' import { logAIAnalysis } from '@/lib/logging/semantic' import { buildCharactersIntroduction } from '@/lib/constants' import type { Locale } from '@/i18n/routing' import { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n' import { buildPromptAssetContext, compileAssetPromptFragments, } from '@/lib/assets/services/asset-prompt-context' // 阶段类型 export type StoryboardPhase = 1 | '2-cinematography' | '2-acting' | 3 type JsonRecord = Record export type ClipCharacterRef = string | { name?: string | null } type CharacterAppearance = { changeReason?: string | null descriptions?: string | null selectedIndex?: number | null description?: string | null } export type CharacterAsset = { name: string appearances?: CharacterAppearance[] } export type LocationAsset = { name: string images?: Array<{ isSelected?: boolean description?: string | null }> } export type PropAsset = { name: string summary?: string | null } type ClipAsset = { id?: string start?: string | number | null end?: string | number | null startText?: string | null endText?: string | null characters?: string | null location?: string | null props?: string | null content?: string | null screenplay?: string | null } type SessionAsset = { user: { id: string name: string } } type NovelPromotionAssetData = { analysisModel: string characters: CharacterAsset[] locations: LocationAsset[] props?: PropAsset[] } export type StoryboardPanel = JsonRecord & { panel_number?: number description?: string location?: string source_text?: string characters?: unknown props?: unknown srt_range?: unknown[] scene_type?: string shot_type?: string camera_move?: string video_prompt?: string duration?: number photographyPlan?: JsonRecord actingNotes?: unknown } export type PhotographyRule = JsonRecord & { panel_number?: number composition?: string lighting?: string color_palette?: string atmosphere?: string technical_notes?: string } export type ActingDirection = JsonRecord & { panel_number?: number characters?: unknown } function isJsonRecord(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null } function parseClipCharacters(raw: string | null | undefined): ClipCharacterRef[] { if (!raw) return [] try { const parsed = JSON.parse(raw) return Array.isArray(parsed) ? (parsed as ClipCharacterRef[]) : [] } catch { return [] } } function parseScreenplay(raw: string | null | undefined): unknown { if (!raw) return null try { return JSON.parse(raw) } catch { return null } } // 阶段进度映射 export const PHASE_PROGRESS: Record = { '1': { start: 10, end: 40, label: '规划分镜', labelKey: 'phases.planning' }, '2-cinematography': { start: 40, end: 55, label: '设计摄影', labelKey: 'phases.cinematography' }, '2-acting': { start: 55, end: 70, label: '设计演技', labelKey: 'phases.acting' }, '3': { start: 70, end: 100, label: '补充细节', labelKey: 'phases.detail' } } // 中间结果存储接口 export interface PhaseResult { clipId: string planPanels?: StoryboardPanel[] photographyRules?: PhotographyRule[] actingDirections?: ActingDirection[] // 演技指导数据 finalPanels?: StoryboardPanel[] } // ========== 辅助函数 ========== // 根据 clip.characters 筛选角色形象列表 export function getFilteredAppearanceList(characters: CharacterAsset[], clipCharacters: ClipCharacterRef[]): string { return compileAssetPromptFragments(buildPromptAssetContext({ characters, locations: [], props: [], clipCharacters, clipLocation: null, clipProps: [], })).appearanceListText } // 根据 clip.characters 筛选角色完整描述 export function getFilteredFullDescription(characters: CharacterAsset[], clipCharacters: ClipCharacterRef[]): string { return compileAssetPromptFragments(buildPromptAssetContext({ characters, locations: [], props: [], clipCharacters, clipLocation: null, clipProps: [], })).fullDescriptionText } // 根据 clip.location 筛选场景描述 export function getFilteredLocationsDescription( locations: LocationAsset[], clipLocation: string | null, locale: Locale = 'zh', ): string { return compileAssetPromptFragments(buildPromptAssetContext({ characters: [], locations, props: [], clipCharacters: [], clipLocation, clipProps: [], locale, })).locationDescriptionText } function parseClipProps(raw: string | null | undefined): string[] { if (!raw) return [] try { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) } catch { return [] } } // 格式化Clip标识(支持SRT模式和Agent模式) export function formatClipId(clip: ClipAsset): string { // SRT 模式 if (clip.start !== undefined && clip.start !== null) { return `${clip.start}-${clip.end}` } // Agent 模式 if (clip.startText && clip.endText) { return `${clip.startText.substring(0, 10)}...~...${clip.endText.substring(0, 10)}` } // 回退 return clip.id?.substring(0, 8) || 'unknown' } // 解析JSON响应 function parseJsonResponse(responseText: string, clipId: string, phase: number): T[] { let jsonText = responseText.trim() jsonText = jsonText.replace(/^```json\s*/i, '').replace(/^```\s*/, '').replace(/\s*```$/, '') const firstBracket = jsonText.indexOf('[') const lastBracket = jsonText.lastIndexOf(']') if (firstBracket === -1 || lastBracket === -1 || lastBracket <= firstBracket) { throw new Error(`Phase ${phase}: JSON格式错误 clip ${clipId}`) } jsonText = jsonText.substring(firstBracket, lastBracket + 1) const result = JSON.parse(jsonText) if (!Array.isArray(result) || result.length === 0) { throw new Error(`Phase ${phase}: 返回空数据 clip ${clipId}`) } const normalized = result.filter(isJsonRecord) as T[] if (normalized.length === 0) { throw new Error(`Phase ${phase}: 数据结构错误 clip ${clipId}`) } return normalized } // ========== Phase 1: 基础分镜规划 ========== export async function executePhase1( clip: ClipAsset, novelPromotionData: NovelPromotionAssetData, session: SessionAsset, projectId: string, projectName: string, locale: Locale, taskId?: string ): Promise { const clipId = formatClipId(clip) void taskId _ulogInfo(`[Phase 1] Clip ${clipId}: 开始基础分镜规划...`) // 读取提示词模板 const planPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_PLAN, locale) // 解析clip数据 const clipCharacters = parseClipCharacters(clip.characters) const clipLocation = clip.location || null const clipProps = parseClipProps(clip.props) // 构建资产信息 const charactersLibName = novelPromotionData.characters.map((c) => c.name).join(', ') || '无' const locationsLibName = novelPromotionData.locations.map((l) => l.name).join(', ') || '无' const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters, clipCharacters) const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters) const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({ characters: [], locations: [], props: novelPromotionData.props || [], clipCharacters: [], clipLocation: null, clipProps, })).propsDescriptionText const charactersIntroduction = buildCharactersIntroduction(novelPromotionData.characters) // 构建clip JSON const clipJson = JSON.stringify({ id: clip.id, content: clip.content, characters: clipCharacters, location: clipLocation, props: clipProps, }, null, 2) // 读取剧本 const screenplay = parseScreenplay(clip.screenplay) if (clip.screenplay && !screenplay) { _ulogWarn(`[Phase 1] Clip ${clipId}: 剧本JSON解析失败`) } // 构建提示词 let planPrompt = planPromptTemplate .replace('{characters_lib_name}', charactersLibName) .replace('{locations_lib_name}', locationsLibName) .replace('{characters_introduction}', charactersIntroduction) .replace('{characters_appearance_list}', filteredAppearanceList) .replace('{characters_full_description}', filteredFullDescription) .replace('{props_description}', filteredPropsDescription) .replace('{clip_json}', clipJson) if (screenplay) { planPrompt = planPrompt.replace('{clip_content}', `【剧本格式】\n${JSON.stringify(screenplay, null, 2)}`) } else { planPrompt = planPrompt.replace('{clip_content}', clip.content || '') } // 记录发送给 AI 的完整 prompt logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'STORYBOARD_PHASE1_PROMPT', input: { 片段标识: clipId, 完整提示词: planPrompt }, model: novelPromotionData.analysisModel }) // 调用AI(失败后重试一次) let planPanels: StoryboardPanel[] = [] for (let attempt = 1; attempt <= 2; attempt++) { try { const planResult = await executeAiTextStep({ userId: session.user.id, model: novelPromotionData.analysisModel, messages: [{ role: 'user', content: planPrompt }], reasoning: true, projectId, action: 'storyboard_phase1_plan', meta: { stepId: 'storyboard_phase1_plan', stepTitle: '分镜规划', stepIndex: 1, stepTotal: 1, }, }) const planResponseText = planResult.text if (!planResponseText) { throw new Error(`Phase 1: 无响应 clip ${clipId}`) } planPanels = parseJsonResponse(planResponseText, clipId, 1) // 统计有效分镜数量 const validPanelCount = planPanels.filter(panel => panel.description && panel.description !== '无' && panel.location !== '无' ).length _ulogInfo(`[Phase 1] Clip ${clipId}: 共 ${planPanels.length} 个分镜,其中 ${validPanelCount} 个有效分镜`) if (validPanelCount === 0) { throw new Error(`Phase 1: 返回全部为空分镜 clip ${clipId}`) } // ========== 检测 source_text 字段,缺失则重试 ========== const missingSourceText = planPanels.some(panel => !panel.source_text) if (missingSourceText && attempt === 1) { _ulogWarn(`[Phase 1] Clip ${clipId}: 有分镜缺少source_text,尝试重试...`) continue } // 成功,跳出循环 break } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error) _ulogError(`[Phase 1] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`) if (attempt === 2) throw error } } // 记录第一阶段完整输出 logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'STORYBOARD_PHASE1_OUTPUT', output: { 片段标识: clipId, 总分镜数: planPanels.length, 第一阶段完整结果: planPanels }, model: novelPromotionData.analysisModel }) _ulogInfo(`[Phase 1] Clip ${clipId}: 生成 ${planPanels.length} 个基础分镜`) return { clipId, planPanels } } // ========== Phase 2: 摄影规则生成 ========== export async function executePhase2( clip: ClipAsset, planPanels: StoryboardPanel[], novelPromotionData: NovelPromotionAssetData, session: SessionAsset, projectId: string, projectName: string, locale: Locale, taskId?: string ): Promise { const clipId = formatClipId(clip) void taskId _ulogInfo(`[Phase 2] Clip ${clipId}: 开始生成摄影规则...`) // 读取提示词 const cinematographerPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_CINEMATOGRAPHER, locale) // 解析clip数据 const clipCharacters = parseClipCharacters(clip.characters) const clipLocation = clip.location || null const clipProps = parseClipProps(clip.props) const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters) const filteredLocationsDescription = getFilteredLocationsDescription( novelPromotionData.locations, clipLocation, locale, ) const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({ characters: [], locations: [], props: novelPromotionData.props || [], clipCharacters: [], clipLocation: null, clipProps, })).propsDescriptionText // 构建提示词 const cinematographerPrompt = cinematographerPromptTemplate .replace('{panels_json}', JSON.stringify(planPanels, null, 2)) .replace('{panel_count}', planPanels.length.toString()) .replace(/\{panel_count\}/g, planPanels.length.toString()) .replace('{locations_description}', filteredLocationsDescription) .replace('{characters_info}', filteredFullDescription) .replace('{props_description}', filteredPropsDescription) let photographyRules: PhotographyRule[] = [] // 失败后重试一次 for (let attempt = 1; attempt <= 2; attempt++) { try { const cinematographerResult = await executeAiTextStep({ userId: session.user.id, model: novelPromotionData.analysisModel, messages: [{ role: 'user', content: cinematographerPrompt }], reasoning: true, projectId, action: 'storyboard_phase2_cinematography', meta: { stepId: 'storyboard_phase2_cinematography', stepTitle: '摄影规则', stepIndex: 1, stepTotal: 1, }, }) const responseText = cinematographerResult.text if (!responseText) { throw new Error(`Phase 2: 无响应 clip ${clipId}`) } photographyRules = parseJsonResponse(responseText, clipId, 2) _ulogInfo(`[Phase 2] Clip ${clipId}: 成功生成 ${photographyRules.length} 个镜头的摄影规则`) // 记录摄影方案生成结果 logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'CINEMATOGRAPHER_PLAN', output: { 片段标识: clipId, 镜头数量: planPanels.length, 摄影规则数量: photographyRules.length, 摄影规则: photographyRules }, model: novelPromotionData.analysisModel }) // 成功,跳出循环 break } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) _ulogError(`[Phase 2] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`) if (attempt === 2) throw e } } return { clipId, planPanels, photographyRules } } // ========== Phase 2-Acting: 演技指导生成 ========== export async function executePhase2Acting( clip: ClipAsset, planPanels: StoryboardPanel[], novelPromotionData: NovelPromotionAssetData, session: SessionAsset, projectId: string, projectName: string, locale: Locale, taskId?: string ): Promise { const clipId = formatClipId(clip) void taskId _ulogInfo(`[Phase 2-Acting] ==========================================`) _ulogInfo(`[Phase 2-Acting] Clip ${clipId}: 开始生成演技指导...`) _ulogInfo(`[Phase 2-Acting] planPanels 数量: ${planPanels.length}`) _ulogInfo(`[Phase 2-Acting] projectId: ${projectId}, projectName: ${projectName}`) // 读取提示词 const actingPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_ACTING_DIRECTION, locale) // 解析clip数据 const clipCharacters = parseClipCharacters(clip.characters) const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters) // 构建提示词 const actingPrompt = actingPromptTemplate .replace('{panels_json}', JSON.stringify(planPanels, null, 2)) .replace('{panel_count}', planPanels.length.toString()) .replace(/\{panel_count\}/g, planPanels.length.toString()) .replace('{characters_info}', filteredFullDescription) let actingDirections: ActingDirection[] = [] // 失败后重试一次 for (let attempt = 1; attempt <= 2; attempt++) { try { const actingResult = await executeAiTextStep({ userId: session.user.id, model: novelPromotionData.analysisModel, messages: [{ role: 'user', content: actingPrompt }], reasoning: true, projectId, action: 'storyboard_phase2_acting', meta: { stepId: 'storyboard_phase2_acting', stepTitle: '演技指导', stepIndex: 1, stepTotal: 1, }, }) const responseText = actingResult.text if (!responseText) { throw new Error(`Phase 2-Acting: 无响应 clip ${clipId}`) } actingDirections = parseJsonResponse(responseText, clipId, 2) _ulogInfo(`[Phase 2-Acting] Clip ${clipId}: 成功生成 ${actingDirections.length} 个镜头的演技指导`) // 记录演技指导生成结果 logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'ACTING_DIRECTION_PLAN', output: { 片段标识: clipId, 镜头数量: planPanels.length, 演技指导数量: actingDirections.length, 演技指导: actingDirections }, model: novelPromotionData.analysisModel }) // 成功,跳出循环 break } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) _ulogError(`[Phase 2-Acting] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`) if (attempt === 2) throw e } } return { clipId, planPanels, actingDirections } } // ========== Phase 3: 补充细节和video_prompt ========== export async function executePhase3( clip: ClipAsset, planPanels: StoryboardPanel[], photographyRules: PhotographyRule[], novelPromotionData: NovelPromotionAssetData, session: SessionAsset, projectId: string, projectName: string, locale: Locale, taskId?: string ): Promise { const clipId = formatClipId(clip) void taskId _ulogInfo(`[Phase 3] Clip ${clipId}: 开始补充镜头细节...`) // 读取提示词 const detailPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_DETAIL, locale) // 解析clip数据 const clipCharacters = parseClipCharacters(clip.characters) const clipLocation = clip.location || null const clipProps = parseClipProps(clip.props) const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters) const filteredLocationsDescription = getFilteredLocationsDescription( novelPromotionData.locations, clipLocation, locale, ) const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({ characters: [], locations: [], props: novelPromotionData.props || [], clipCharacters: [], clipLocation: null, clipProps, })).propsDescriptionText // 构建提示词 const detailPrompt = detailPromptTemplate .replace('{panels_json}', JSON.stringify(planPanels, null, 2)) .replace('{characters_age_gender}', filteredFullDescription) // 改用完整描述 .replace('{locations_description}', filteredLocationsDescription) .replace('{props_description}', filteredPropsDescription) // 记录发送给 AI 的完整 prompt logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'STORYBOARD_PHASE3_PROMPT', input: { 片段标识: clipId, 完整提示词: detailPrompt }, model: novelPromotionData.analysisModel }) void photographyRules let finalPanels: StoryboardPanel[] = [] // 失败后重试一次 for (let attempt = 1; attempt <= 2; attempt++) { try { const detailResult = await executeAiTextStep({ userId: session.user.id, model: novelPromotionData.analysisModel, messages: [{ role: 'user', content: detailPrompt }], reasoning: true, projectId, action: 'storyboard_phase3_detail', meta: { stepId: 'storyboard_phase3_detail', stepTitle: '镜头细化', stepIndex: 1, stepTotal: 1, }, }) const detailResponseText = detailResult.text if (!detailResponseText) { throw new Error(`Phase 3: 无响应 clip ${clipId}`) } finalPanels = parseJsonResponse(detailResponseText, clipId, 3) // 记录第三阶段完整输出(过滤前) logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'STORYBOARD_PHASE3_OUTPUT', output: { 片段标识: clipId, 总分镜数: finalPanels.length, 第三阶段完整结果_过滤前: finalPanels }, model: novelPromotionData.analysisModel }) // 过滤掉"无"的空分镜 const beforeFilterCount = finalPanels.length finalPanels = finalPanels.filter((panel) => panel.description && panel.description !== '无' && panel.location !== '无' ) _ulogInfo(`[Phase 3] Clip ${clipId}: 过滤空分镜 ${beforeFilterCount} -> ${finalPanels.length} 个有效分镜`) if (finalPanels.length === 0) { throw new Error(`Phase 3: 过滤后无有效分镜 clip ${clipId}`) } // 注意:photographyRules的合并已移至route.ts中,与并行执行的Phase 2结果合并 // 记录最终输出 logAIAnalysis(session.user.id, session.user.name, projectId, projectName, { action: 'STORYBOARD_FINAL_OUTPUT', output: { 片段标识: clipId, 过滤前总数: beforeFilterCount, 过滤后有效数: finalPanels.length, 最终有效分镜: finalPanels }, model: novelPromotionData.analysisModel }) // 成功,跳出循环 break } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) _ulogError(`[Phase 3] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`) if (attempt === 2) throw e } } _ulogInfo(`[Phase 3] Clip ${clipId}: 完成 ${finalPanels.length} 个镜头细节`) return { clipId, finalPanels } }