fix(storyboard): 章节分镜任务补输出契约并穿透 text 包装解析镜头行

- 章节分镜任务 prompt 显式追加 {title, rows} JSON 输出契约:该链路不走服务端分镜模板(无 metadata.promptTemplateOperation),缺契约时模型按自然语言习惯返回 Markdown 表格,导致“分镜任务没有返回镜头行”
- 文本任务 result_json 外层统一为 {mode, reasoning, text} 包装,storyboardRowsFromTask 现穿透 text 内层解析分镜表,并兜底剥离代码块与首尾大括号截取;顶层直接落 {title, rows} 的结构保持兼容
- 分镜任务 input 显式携带 mode: text(后端仅对 video_ 前缀任务兜底)
- 新增 canvas-storyboard-rows-parse.test.ts 覆盖六种解析场景
This commit is contained in:
李祥生
2026-09-15 17:23:38 +08:00
parent 8db7cf0300
commit 446c263426
3 changed files with 108 additions and 3 deletions

View File

@@ -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<Partial<StoryboardRow>> } {
const parsed = JSON.parse(raw) as { title?: string; rows?: Array<Partial<StoryboardRow>>; 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<Partial<StoryboardRow>> };
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<Partial<StoryboardRow>> };
const result = parseStoryboardRowsPayload(task.resultJson || "{}");
if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行");
return {
title: result.title?.trim(),

View File

@@ -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,

View File

@@ -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("分镜任务没有返回镜头行");
});
});