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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
57
web/test/canvas-storyboard-rows-parse.test.ts
Normal file
57
web/test/canvas-storyboard-rows-parse.test.ts
Normal 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("分镜任务没有返回镜头行");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user