diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 5738fc752..69b9afaef 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -98,8 +98,49 @@ export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): Storyb ])) as StoryboardColumn[]; } +// 分镜文本任务的模型侧输出契约,字段与 storyboardRowsFromTask 的解析结构一一对应。 +// 后端只有带 metadata.promptTemplateOperation 的任务才会编译服务端分镜模板(含 JSON 契约), +// 章节分镜走的是自定义 prompt,缺这段约束时模型会按自然语言习惯返回 Markdown 表格,解析阶段拿不到 rows。 +export function storyboardRowsOutputContract(requirement: string) { + return [ + "【受保护输出契约】", + "- 只返回一个 JSON 对象,不要 Markdown、表格、代码块、注释或任何解释文字。", + `- 顶层固定为 {"title": string, "rows": object[]},rows 至少 1 项。${requirement}`, + "- rows 每项对应一个镜头,按剧情顺序排列;字段缺失时填空字符串或空数组,不要省略字段:", + " shotNumber(整数,从 1 递增)、durationSeconds(整数,1-60)、plotDescription(本镜可见的剧情与关键动作)、", + " dialogue(本镜实际念出的台词或简短旁白,超长必须拆镜)、characters(本镜出现的角色名数组)、", + " narrativeIntent、viewerPOV、performanceBlocking、shotSize(景别)、emotion、lightingAndAtmosphere、", + " audioEffects、camera、motion、timeBeats、imageGenerationPrompt(首帧画面提示词)、", + " videoMotionPrompt(运镜与结尾状态)、continuityOut、negativePrompt、", + " mustHave(数组,最多 3 项)、optionalDetails(数组)。", + "- 镜头数量以完整覆盖剧情因果、人物关系和关键台词为准,不要为压缩数量删减关键情节。", + ].join("\n"); +} + +// 文本任务的 result_json 外层是 {mode, reasoning, text} 包装,分镜表在 text 字段的内层 JSON 字符串里; +// 历史模板路径可能直接落 {title, rows}。两种结构都要能解析,内层再做代码块剥离与首尾大括号截取兜底, +// 模型偶发违反契约(包 ```json 或混入解释文字)时仍能取出分镜表。 +function parseStoryboardRowsPayload(raw: string): { title?: string; rows?: Array> } { + const parsed = JSON.parse(raw) as { title?: string; rows?: Array>; text?: unknown }; + if (Array.isArray(parsed.rows)) return parsed; + if (typeof parsed.text === "string" && parsed.text.trim()) { + const inner = parsed.text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, ""); + const start = inner.indexOf("{"); + const end = inner.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + const innerParsed = JSON.parse(inner.slice(start, end + 1)) as { title?: string; rows?: Array> }; + if (Array.isArray(innerParsed.rows)) return innerParsed; + } catch { + // 内层不是合法 JSON 时落到外层结构,由调用方的缺行校验统一报错。 + } + } + } + return parsed; +} + export function storyboardRowsFromTask(task: GenerationTask) { - const result = JSON.parse(task.resultJson || "{}") as { title?: string; rows?: Array> }; + const result = parseStoryboardRowsPayload(task.resultJson || "{}"); if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行"); return { title: result.title?.trim(), diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts index 333169cec..0fedddca2 100644 --- a/web/src/pages/projects/detail/project-chapter-ai.ts +++ b/web/src/pages/projects/detail/project-chapter-ai.ts @@ -1,6 +1,6 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts"; -import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain"; +import { storyboardRowsFromTask, storyboardRowsOutputContract } from "@/lib/canvas/canvas-project-domain"; import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown"; import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference"; import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task"; @@ -90,14 +90,21 @@ export async function generateChapterStoryboard(input: ChapterStoryboardGenerati skills: input.skills, selectedSkillIds: input.selectedSkillIds, }); + // 技能上下文只描述工作流,不会约束输出结构;必须显式带上输出契约,否则模型返回 Markdown 表格。 + const prompt = [ + skillExecution.prompt, + storyboardRowsOutputContract("每个镜头必须能独立用于生成首帧图片和镜头视频。"), + ].join("\n\n"); const task = await createGenerationTask({ projectId: input.projectId, type: "canvas_text", operation: "storyboard", - prompt: skillExecution.prompt, + prompt, model, ...(logicalModelIDForConfig(config) ? { logicalModelId: logicalModelIDForConfig(config) } : {}), input: { + // 后端只在 video_ 前缀的任务上兜底 mode,文本任务必须显式带上。 + mode: "text", canvasAssets: input.assets, requirements: "输出可直接写入分镜制作并继续生成分镜图、动作预演和镜头视频的分镜表。", projectStyle: input.projectStyle, diff --git a/web/test/canvas-storyboard-rows-parse.test.ts b/web/test/canvas-storyboard-rows-parse.test.ts new file mode 100644 index 000000000..a954cc5ab --- /dev/null +++ b/web/test/canvas-storyboard-rows-parse.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { storyboardRowsFromTask } from "../src/lib/canvas/canvas-project-domain"; +import type { GenerationTask } from "../src/services/api/task-center"; + +function storyboardTask(resultJson: string): GenerationTask { + return { id: "task-1", resultJson } as GenerationTask; +} + +// 后端 canvas_text 任务统一落 {mode, reasoning, text} 包装,模型输出正文嵌在 text 里; +// 分镜解析必须穿透包装取内层 {title, rows},同时兼容模板路径直接落顶层的结构。 +describe("storyboardRowsFromTask", () => { + test("从 {mode, reasoning, text} 包装的 text 内层解析分镜行", () => { + const payload = { title: "桃源三结义", rows: [ + { shotNumber: 1, durationSeconds: 8, plotDescription: "朝堂开篇", dialogue: "", characters: ["刘备"], mustHave: ["列队"], optionalDetails: [] }, + { shotNumber: 2, durationSeconds: 10, plotDescription: "告示墙前叹气", dialogue: "唉……", characters: ["刘备", "关羽"] }, + ] }; + const task = storyboardTask(JSON.stringify({ mode: "text", reasoning: "思考过程", text: JSON.stringify(payload) })); + const result = storyboardRowsFromTask(task); + expect(result.title).toBe("桃源三结义"); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].shotNumber).toBe(1); + expect(result.rows[0].status).toBe("idle"); + expect(result.rows[0].characters).toEqual(["刘备"]); + expect(result.rows[1].characters).toEqual(["刘备", "关羽"]); + }); + + test("text 内层被 ```json 代码块包裹时仍可解析", () => { + const payload = { title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] }; + const wrappedText = "```json\n" + JSON.stringify(payload) + "\n```"; + const task = storyboardTask(JSON.stringify({ mode: "text", text: wrappedText })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("text 内层混入解释文字时按首尾大括号截取解析", () => { + const payload = { title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] }; + const noisyText = "以下是分镜表:\n" + JSON.stringify(payload) + "\n如需调整请告知。"; + const task = storyboardTask(JSON.stringify({ mode: "text", text: noisyText })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("兼容顶层直接落 {title, rows} 的结构", () => { + const task = storyboardTask(JSON.stringify({ title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("text 内层不是 JSON 时抛出缺行错误", () => { + // 无契约时期模型返回 Markdown 表格的真实场景。 + const task = storyboardTask(JSON.stringify({ mode: "text", text: "| 7-41 | 全景/拉远 | 桃园结义 |" })); + expect(() => storyboardRowsFromTask(task)).toThrow("分镜任务没有返回镜头行"); + }); + + test("rows 为空数组时抛出缺行错误", () => { + const task = storyboardTask(JSON.stringify({ mode: "text", text: JSON.stringify({ title: "空", rows: [] }) })); + expect(() => storyboardRowsFromTask(task)).toThrow("分镜任务没有返回镜头行"); + }); +});