From 72c0a037de2599a9d6d26c224c94216858f1f1e1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 18:57:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(canvas):=20=E4=BB=BB=E5=8A=A1=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=81=A2=E5=A4=8D=20-=20=E5=85=BC=E5=AE=B9=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E6=96=87=E6=9C=AC=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/lib/canvas/canvas-project-domain.ts | 12 +++++++++- web/src/services/api/generation-task.ts | 24 +++++++++++++++++-- web/test/canvas-storyboard-rows-parse.test.ts | 11 +++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index e489e2290..e846276fe 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -139,6 +139,14 @@ function parseStoryboardRowsPayload(raw: string): { title?: string; rows?: Array } } } + if (parsed.text && typeof parsed.text === "object") { + try { + const nested = parseStoryboardRowsPayload(JSON.stringify(parsed.text)); + if (Array.isArray(nested.rows)) return nested; + } catch { + // 对象形式的 text 不是分镜载荷时,交由统一缺行校验报错。 + } + } return parsed; } @@ -146,7 +154,9 @@ export function storyboardRowsFromTask(task: GenerationTask) { const result = parseStoryboardRowsPayload(task.resultJson || "{}"); if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行"); return { - title: result.title?.trim(), + // 旧任务和部分模型会把 title 返回成对象/数组;恢复路径必须只接受字符串, + // 否则可选链只能防 undefined,仍会在对象上调用 trim 使整页恢复失败。 + title: typeof result.title === "string" ? result.title.trim() : undefined, rows: result.rows.map((row, index) => { const next = createStoryboardRow(index + 1, { ...row, diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index ecf078ae7..b1962c7be 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -498,7 +498,27 @@ function omittedImageQuality(value: string | undefined) { export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult { if (!task.resultJson) throw new Error("后端任务没有返回结果"); - const result = JSON.parse(task.resultJson) as BackendGenerationResult; + const result = JSON.parse(task.resultJson) as BackendGenerationResult & { text?: unknown }; if (!result || typeof result !== "object") throw new Error("后端任务结果格式错误"); - return result; + return { ...result, text: normalizeBackendText(result.text) }; +} + +function normalizeBackendText(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) { + const text = value.map((item) => normalizeBackendText(item)).filter((item): item is string => Boolean(item)).join(""); + return text || undefined; + } + if (typeof value === "object") { + const record = value as Record; + for (const key of ["text", "content", "output_text", "value"]) { + if (!(key in record)) continue; + const nested = normalizeBackendText(record[key]); + if (nested !== undefined) return nested; + } + // 某些结构化文本任务直接把 JSON 载荷放进 text 对象,保留 JSON 供上层契约解析。 + return JSON.stringify(value); + } + return String(value); } diff --git a/web/test/canvas-storyboard-rows-parse.test.ts b/web/test/canvas-storyboard-rows-parse.test.ts index a954cc5ab..7eca40de9 100644 --- a/web/test/canvas-storyboard-rows-parse.test.ts +++ b/web/test/canvas-storyboard-rows-parse.test.ts @@ -44,6 +44,17 @@ describe("storyboardRowsFromTask", () => { expect(storyboardRowsFromTask(task).rows).toHaveLength(1); }); + test("title 不是字符串时仍可恢复任务结果", () => { + const task = storyboardTask(JSON.stringify({ title: { value: "章节" }, rows: [{ shotNumber: 1, plotDescription: "单镜头" }] })); + expect(storyboardRowsFromTask(task).title).toBeUndefined(); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("text 为结构化对象时仍可解析分镜行", () => { + const task = storyboardTask(JSON.stringify({ text: { 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 | 全景/拉远 | 桃园结义 |" })); From 64832b2fb5e4462571d99b13a8f13da4b3afc150 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 21 Sep 2026 20:13:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(canvas):=20=E4=BB=BB=E5=8A=A1=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=81=A2=E5=A4=8D=20-=20=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=E5=88=86=E9=95=9C=E8=A1=8C=E6=96=87=E6=9C=AC=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/lib/canvas/canvas-project-domain.ts | 47 +++++++++++++++++-- web/test/canvas-storyboard-rows-parse.test.ts | 18 +++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index e846276fe..2d0a76e55 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -150,6 +150,42 @@ function parseStoryboardRowsPayload(raw: string): { title?: string; rows?: Array return parsed; } +function normalizeStoryboardText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(normalizeStoryboardText).filter(Boolean).join("、"); + if (!value || typeof value !== "object") return ""; + const record = value as Record; + for (const key of ["text", "content", "value", "description", "name"]) { + if (!(key in record)) continue; + const nested = normalizeStoryboardText(record[key]); + if (nested) return nested; + } + return ""; +} + +function normalizeStoryboardRow(row: Partial): Partial { + return { + ...row, + plotDescription: normalizeStoryboardText(row.plotDescription), + dialogue: normalizeStoryboardText(row.dialogue), + narrativeIntent: normalizeStoryboardText(row.narrativeIntent), + viewerPOV: normalizeStoryboardText(row.viewerPOV), + performanceBlocking: normalizeStoryboardText(row.performanceBlocking), + shotSize: normalizeStoryboardText(row.shotSize), + emotion: normalizeStoryboardText(row.emotion), + lightingAndAtmosphere: normalizeStoryboardText(row.lightingAndAtmosphere), + audioEffects: normalizeStoryboardText(row.audioEffects), + camera: normalizeStoryboardText(row.camera), + motion: normalizeStoryboardText(row.motion), + timeBeats: normalizeStoryboardText(row.timeBeats), + imageGenerationPrompt: normalizeStoryboardText(row.imageGenerationPrompt), + videoMotionPrompt: normalizeStoryboardText(row.videoMotionPrompt), + continuityOut: normalizeStoryboardText(row.continuityOut), + negativePrompt: normalizeStoryboardText(row.negativePrompt), + }; +} + export function storyboardRowsFromTask(task: GenerationTask) { const result = parseStoryboardRowsPayload(task.resultJson || "{}"); if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行"); @@ -158,16 +194,17 @@ export function storyboardRowsFromTask(task: GenerationTask) { // 否则可选链只能防 undefined,仍会在对象上调用 trim 使整页恢复失败。 title: typeof result.title === "string" ? result.title.trim() : undefined, rows: result.rows.map((row, index) => { + const source = row && typeof row === "object" ? row : {}; const next = createStoryboardRow(index + 1, { - ...row, + ...normalizeStoryboardRow(source), id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`, shotNumber: index + 1, status: "idle", - assetBindings: normalizeStoryboardAssetBindings(row.assetBindings), + assetBindings: normalizeStoryboardAssetBindings(Array.isArray(source.assetBindings) ? source.assetBindings : undefined), }); - next.characters = Array.isArray(row.characters) ? row.characters : []; - next.mustHave = Array.isArray(row.mustHave) ? row.mustHave : []; - next.optionalDetails = Array.isArray(row.optionalDetails) ? row.optionalDetails : []; + next.characters = Array.isArray(source.characters) ? source.characters : []; + next.mustHave = Array.isArray(source.mustHave) ? source.mustHave : []; + next.optionalDetails = Array.isArray(source.optionalDetails) ? source.optionalDetails : []; return next; }), }; diff --git a/web/test/canvas-storyboard-rows-parse.test.ts b/web/test/canvas-storyboard-rows-parse.test.ts index 7eca40de9..5fecc88c5 100644 --- a/web/test/canvas-storyboard-rows-parse.test.ts +++ b/web/test/canvas-storyboard-rows-parse.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { storyboardRowsFromTask } from "../src/lib/canvas/canvas-project-domain"; +import { storyboardRowsToProjectShots } from "../src/pages/projects/detail/chapter-storyboard-production"; import type { GenerationTask } from "../src/services/api/task-center"; function storyboardTask(resultJson: string): GenerationTask { @@ -55,6 +56,23 @@ describe("storyboardRowsFromTask", () => { expect(storyboardRowsFromTask(task).rows).toHaveLength(1); }); + test("镜头文本字段不是字符串时仍可继续落库", () => { + const task = storyboardTask(JSON.stringify({ rows: [{ + plotDescription: { text: "对象形式的剧情" }, + dialogue: ["台词一", "台词二"], + camera: { value: "中景" }, + motion: { description: "缓慢推进" }, + timeBeats: 3, + }] })); + const result = storyboardRowsFromTask(task); + const shots = storyboardRowsToProjectShots(result.rows); + expect(shots[0]?.description).toBe("对象形式的剧情"); + expect(shots[0]?.revision.dialogue).toBe("台词一、台词二"); + expect(shots[0]?.revision.cameraAngle).toBe("中景"); + expect(shots[0]?.revision.cameraMovement).toBe("缓慢推进"); + expect(shots[0]?.revision.actionBeats).toEqual([{ description: "3" }]); + }); + test("text 内层不是 JSON 时抛出缺行错误", () => { // 无契约时期模型返回 Markdown 表格的真实场景。 const task = storyboardTask(JSON.stringify({ mode: "text", text: "| 7-41 | 全景/拉远 | 桃园结义 |" }));