diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 2d0a76e55..b9be0333b 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -120,6 +120,22 @@ export function storyboardRowsOutputContract(requirement: string) { ].join("\n"); } +// 分镜文本任务实际发出的 prompt:技能上下文只描述工作流、不约束输出结构,画布节点和章节分镜又都没有 +// metadata.promptTemplateOperation(后端只在有该字段时编译服务端模板),所以契约必须由调用方显式拼上, +// 否则模型按自然语言习惯返回 Markdown 表格或直接续写正文,解析阶段拿不到 rows。 +// 镜头数与每镜时长只能写进契约文本:后端 canvasGenerationInput 不读 input.shotCount / shotDurationSeconds, +// 仅放进 input 时节点上的「N 镜」「每镜 N 秒」对模型不可见。 +export function withStoryboardOutputContract(prompt: string, options: { shotCount?: number; shotDurationSeconds?: number } = {}) { + const shotCount = Math.trunc(Number(options.shotCount) || 0); + const shotDurationSeconds = Math.trunc(Number(options.shotDurationSeconds) || 0); + const requirement = [ + shotCount > 0 ? `全片共 ${shotCount} 个镜头` : "", + shotDurationSeconds > 0 ? `每镜约 ${shotDurationSeconds} 秒,durationSeconds 填写该值` : "", + "每个镜头必须能独立用于生成首帧图片和镜头视频。", + ].filter(Boolean).join(";"); + return [prompt, storyboardRowsOutputContract(requirement)].join("\n\n"); +} + // 文本任务的 result_json 外层是 {mode, reasoning, text} 包装,分镜表在 text 字段的内层 JSON 字符串里; // 历史模板路径可能直接落 {title, rows}。两种结构都要能解析,内层再做代码块剥离与首尾大括号截取兜底, // 模型偶发违反契约(包 ```json 或混入解释文字)时仍能取出分镜表。 diff --git a/web/src/pages/canvas/use-canvas-storyboard.ts b/web/src/pages/canvas/use-canvas-storyboard.ts index d087269fa..ba32ddd22 100644 --- a/web/src/pages/canvas/use-canvas-storyboard.ts +++ b/web/src/pages/canvas/use-canvas-storyboard.ts @@ -17,6 +17,7 @@ import { expandStoryboardTextMentions, storyboardRowsFromTask, storyboardPromptTemplateMetadata, + withStoryboardOutputContract, } from "@/lib/canvas/canvas-project-domain"; import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references"; import { buildStoryboardAssetCatalog } from "@/lib/canvas/canvas-storyboard-assets"; @@ -168,16 +169,18 @@ export function useCanvasStoryboard({ assertCurrent(); setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node)); assertCurrent(); + // 输入框内容只是剧情,拆镜要求与镜头数、每镜时长都必须随契约一起发出去。 + const storyboardPrompt = withStoryboardOutputContract(skillExecution.prompt, { shotCount: requestedShotCount, shotDurationSeconds }); const request = { projectId, type: "canvas_text", operation: "storyboard", - prompt: skillExecution.prompt, + prompt: storyboardPrompt, model: generationConfig.model, ...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}), input: { mode: "text", - prompt: skillExecution.prompt, + prompt: storyboardPrompt, canvasAssets: buildStoryboardAssetCatalog(nodesRef.current), requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。", projectStyle: storyboardContext.projectStyle, diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts index 0fedddca2..5bc69d1a3 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, storyboardRowsOutputContract } from "@/lib/canvas/canvas-project-domain"; +import { storyboardRowsFromTask, withStoryboardOutputContract } 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"; @@ -91,10 +91,7 @@ export async function generateChapterStoryboard(input: ChapterStoryboardGenerati selectedSkillIds: input.selectedSkillIds, }); // 技能上下文只描述工作流,不会约束输出结构;必须显式带上输出契约,否则模型返回 Markdown 表格。 - const prompt = [ - skillExecution.prompt, - storyboardRowsOutputContract("每个镜头必须能独立用于生成首帧图片和镜头视频。"), - ].join("\n\n"); + const prompt = withStoryboardOutputContract(skillExecution.prompt); const task = await createGenerationTask({ projectId: input.projectId, type: "canvas_text", diff --git a/web/test/canvas-storyboard-output-contract.test.ts b/web/test/canvas-storyboard-output-contract.test.ts new file mode 100644 index 000000000..a7e5e9525 --- /dev/null +++ b/web/test/canvas-storyboard-output-contract.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { withStoryboardOutputContract } from "../src/lib/canvas/canvas-project-domain"; + +// 画布「分镜脚本」节点与章节分镜都把用户输入原样交给模型,拆镜要求与 JSON 契约只能由调用方显式拼上: +// 漏掉契约时模型按自然语言习惯回复(Markdown 表格、甚至续写正文),storyboardRowsFromTask 便报 +// 「分镜任务没有返回镜头行」。节点上的「N 镜」「每镜 N 秒」同理只能写进契约文本, +// 后端 canvasGenerationInput 不读 input.shotCount / input.shotDurationSeconds。 +describe("withStoryboardOutputContract", () => { + test("原样保留调用方 prompt 并追加受保护输出契约", () => { + const source = "我睁开眼的时候,先闻到了一股煎鸡蛋的香味。"; + const prompt = withStoryboardOutputContract(source); + expect(prompt.startsWith(source)).toBe(true); + expect(prompt).toContain("【受保护输出契约】"); + expect(prompt).toContain('- 顶层固定为 {"title": string, "rows": object[]},rows 至少 1 项。'); + expect(prompt).toContain("只返回一个 JSON 对象,不要 Markdown、表格、代码块、注释或任何解释文字。"); + expect(prompt).toContain("videoMotionPrompt"); + }); + + test("镜头数与每镜时长写进契约文本", () => { + const prompt = withStoryboardOutputContract("剧情正文", { shotCount: 3, shotDurationSeconds: 5 }); + expect(prompt).toContain("全片共 3 个镜头"); + expect(prompt).toContain("每镜约 5 秒,durationSeconds 填写该值"); + }); + + test("未选镜头数或时长时按剧情决定,不写入 0 镜或 0 秒", () => { + const prompt = withStoryboardOutputContract("剧情正文", { shotCount: 0, shotDurationSeconds: 0 }); + expect(prompt).not.toContain("共 0 个镜头"); + expect(prompt).not.toContain("每镜约 0 秒"); + expect(prompt).toContain("1 项。每个镜头必须能独立用于生成首帧图片和镜头视频。"); + }); + + test("章节分镜沿用默认要求,与画布链路共用同一份契约", () => { + const prompt = withStoryboardOutputContract("章节正文"); + expect(prompt).toContain("1 项。每个镜头必须能独立用于生成首帧图片和镜头视频。"); + expect(prompt).toContain("不要为压缩数量删减关键情节。"); + }); +});