Files
ttstd-infinite-canvas/web/test/canvas-storyboard-rows-parse.test.ts
李祥生 446c263426 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 覆盖六种解析场景
2026-09-15 17:28:28 +08:00

58 lines
3.3 KiB
TypeScript

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