From 940b716e5c11f00fa64d4e826e620aadba229d86 Mon Sep 17 00:00:00 2001 From: ddCat Date: Tue, 28 Jul 2026 19:42:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=89=A7=E6=83=85=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=20-=20=E7=A7=BB=E9=99=A4=E9=87=8D=E5=A4=8D=E4=BB=98?= =?UTF-8?q?=E8=B4=B9=E7=9A=84=E5=88=86=E9=95=9C=E7=94=9F=E6=88=90=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #43 --- web/src/pages/projects/detail/chapters.tsx | 50 ++++--------------- .../projects/detail/project-chapter-ai.ts | 45 ----------------- 2 files changed, 9 insertions(+), 86 deletions(-) diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx index 9bf06f681..e15fee68c 100644 --- a/web/src/pages/projects/detail/chapters.tsx +++ b/web/src/pages/projects/detail/chapters.tsx @@ -18,7 +18,6 @@ import { Bold, Check, ChevronDown, - Clapperboard, Code2, Crosshair, Eraser, @@ -62,7 +61,6 @@ import { importProjectUnits, linkCanvasUnit, reorderProjectUnits, - replaceProjectUnitShots, updateProjectUnit, type ProjectUnit, } from "@/services/api/projects"; @@ -71,13 +69,13 @@ import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared"; -import { extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai"; +import { extractChapterCharacters } from "./project-chapter-ai"; const CHAPTER_ROW_HEIGHT = 52; const MAX_NOVEL_IMPORT_CHAPTERS = 2500; -export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) { - const { message, modal } = App.useApp(); +export default function ProjectChaptersView({ detail, refreshProject, onCreateCanvas }: ProjectDetailViewProps) { + const { message } = App.useApp(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { chapterId = "" } = useParams(); @@ -93,7 +91,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD const [draftTitle, setDraftTitle] = useState(""); const [draftHtml, setDraftHtml] = useState(""); const [dirty, setDirty] = useState(false); - const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">(""); + const [extractingCharacters, setExtractingCharacters] = useState(false); const [importingCanvasId, setImportingCanvasId] = useState(""); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); @@ -259,7 +257,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD try { const input = chapterAnalysisInput(); if (!input) return; - setAnalysisKind("characters"); + setExtractingCharacters(true); const characters = await extractChapterCharacters(input); // 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。 const knownNames = new Set(detail.assetCandidates @@ -276,43 +274,14 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD } catch (error) { message.error(error instanceof Error ? error.message : "角色提取失败"); } finally { - setAnalysisKind(""); + setExtractingCharacters(false); } }; - const replaceStoryboard = async () => { - try { - const input = chapterAnalysisInput(); - if (!input) return; - setAnalysisKind("storyboard"); - const shots = await generateChapterStoryboard(input); - await replaceProjectUnitShots(detail.project.id, input.chapterId, shots); - refreshProject(); - message.success(`已生成 ${shots.length} 个章节分镜`); - } catch (error) { - message.error(error instanceof Error ? error.message : "分镜生成失败"); - } finally { - setAnalysisKind(""); - } - }; - const generateStoryboard = () => { - const existingCount = selectedUnit ? chapterShotCount(selectedUnit.id) : 0; - if (!existingCount) { - void replaceStoryboard(); - return; - } - modal.confirm({ - title: "重新生成本章分镜?", - content: `当前 ${existingCount} 个镜头会被新分镜整体替换,已关联的镜头素材不会删除,但会解除镜头引用。`, - okText: "重新生成", - cancelText: "取消", - onOk: replaceStoryboard, - }); - }; const importStoryboardToCanvas = async (targetCanvasId?: string) => { if (!selectedUnit) return; const shots = detail.shots.filter((shot) => shot.unitId === selectedUnit.id); if (!shots.length) { - message.warning("请先生成本章分镜"); + message.warning("本章没有可导入的历史分镜"); return; } setImportingCanvasId(targetCanvasId || "new"); @@ -452,9 +421,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}·{formatCount(wordCount)} 字·{chapterCanvasCount(selectedUnit.id)} 个画布
- - - {chapterShotCount(selectedUnit.id) ? , label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: , label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}> : null} + + {chapterShotCount(selectedUnit.id) ? , label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: , label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}> : }
diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts index 644cab700..9b33497f4 100644 --- a/web/src/pages/projects/detail/project-chapter-ai.ts +++ b/web/src/pages/projects/detail/project-chapter-ai.ts @@ -2,12 +2,6 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-gene import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference"; import type { AiConfig } from "@/stores/use-config-store"; -export type ChapterStoryboardDraft = { - title: string; - description: string; - durationMs: number; -}; - type ChapterAnalysisInput = { projectId: string; projectName: string; @@ -33,20 +27,6 @@ export async function extractChapterCharacters(input: ChapterAnalysisInput): Pro return parseCharacterBreakdown(result); } -export async function generateChapterStoryboard(input: ChapterAnalysisInput): Promise { - const prompt = [ - `请把短剧项目《${input.projectName}》的章节“${input.chapterTitle}”拆成可直接制作的分镜镜头。`, - "镜头顺序必须忠于正文事件顺序;每个镜头只承担一个清楚的叙事动作。描述必须包含主体、动作、场景、景别、机位、镜头运动、光线和必要对白,不要写抽象评价。单镜头建议 3 至 8 秒。", - `【项目画风】\n${input.projectStyle || "项目尚未指定画风,保持镜头描述中性、可执行。"}`, - "只返回 JSON,不要 Markdown 或解释。JSON 结构必须严格为:", - '{"shots":[{"title":"镜头名称","description":"可直接用于分镜制作的完整镜头描述","durationSeconds":5}]}', - "【章节正文】", - input.sourceText, - ].join("\n\n"); - const result = await runProjectTextTask(input, "chapter_storyboard", prompt); - return parseChapterStoryboard(result); -} - async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, prompt: string) { const model = input.config.textModel || input.config.model; const result = await runBackendCanvasGenerationTask({ @@ -60,28 +40,3 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string if (!result.text?.trim()) throw new Error("模型没有返回可用结果"); return result.text; } - -function parseChapterStoryboard(raw: string): ChapterStoryboardDraft[] { - const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); - const start = unfenced.indexOf("{"); - const end = unfenced.lastIndexOf("}"); - if (start < 0 || end < start) throw new Error("分镜生成没有返回可识别的 JSON"); - let parsed: unknown; - try { - parsed = JSON.parse(unfenced.slice(start, end + 1)); - } catch (error) { - throw new Error(`分镜结果格式不正确:${error instanceof Error ? error.message : "无法解析 JSON"}`); - } - const shots = parsed && typeof parsed === "object" ? (parsed as { shots?: unknown }).shots : undefined; - if (!Array.isArray(shots)) throw new Error("分镜结果缺少 shots 数组"); - const result = shots.flatMap((item, index) => { - if (!item || typeof item !== "object") return []; - const value = item as Record; - const description = String(value.description || "").trim(); - if (!description) return []; - const durationSeconds = Math.min(30, Math.max(1, Number(value.durationSeconds) || 5)); - return [{ title: String(value.title || `镜头 ${index + 1}`).trim(), description, durationMs: Math.round(durationSeconds * 1000) }]; - }); - if (!result.length) throw new Error("分镜结果中没有可用镜头"); - return result; -}