diff --git a/web/src/lib/canvas/project-chapter-storyboard.ts b/web/src/lib/canvas/project-chapter-storyboard.ts new file mode 100644 index 000000000..b7a95e66e --- /dev/null +++ b/web/src/lib/canvas/project-chapter-storyboard.ts @@ -0,0 +1,88 @@ +import { createCanvasNode, createStoryboardRow } from "@/lib/canvas/canvas-project-domain"; +import type { ProjectShot, ProjectUnit } from "@/services/api/projects"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardData, type StoryboardRow } from "@/types/canvas"; + +type ProjectChapterStoryboardInput = { + unit: Pick; + shots: ProjectShot[]; +}; + +export function upsertProjectChapterStoryboard( + nodes: CanvasNodeData[], + connections: CanvasConnection[], + { unit, shots }: ProjectChapterStoryboardInput, +) { + const existing = nodes.find((node) => node.type === CanvasNodeType.Script && node.metadata?.chapterId === unit.id); + const currentRows = new Map((existing?.metadata?.storyboard?.rows || []).map((row) => [row.id, row])); + const rows = shots + .filter((shot) => shot.unitId === unit.id) + .slice() + .sort((left, right) => left.position - right.position) + .map((shot, index) => projectShotRow(shot, index, currentRows)); + + const storyboard: StoryboardData = { + rows, + visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], + referenceNodeIds: existing?.metadata?.storyboard?.referenceNodeIds || [], + }; + const scriptNode: CanvasNodeData = existing + ? { + ...existing, + title: `分镜脚本 · ${unit.title}`, + metadata: { + ...existing.metadata, + status: "idle" as const, + workflowKind: "storyboard" as const, + workflowTitle: "章节分镜", + workflowDescription: `已导入 ${rows.length} 个镜头`, + chapterId: unit.id, + chapterTitle: unit.title, + storyboard, + }, + } + : createChapterStoryboardNode(nodes, unit, rows); + const nextNodes = existing ? nodes.map((node) => node.id === existing.id ? scriptNode : node) : [...nodes, scriptNode]; + const validRowHandles = new Set(rows.map((row) => `row:${row.id}`)); + const nextConnections = existing + ? connections + .filter((connection) => connection.fromNodeId !== existing.id || validStoryboardHandle(connection.fromHandleId, validRowHandles)) + .filter((connection) => connection.toNodeId !== existing.id || validStoryboardHandle(connection.toHandleId, validRowHandles)) + : connections; + return { nodes: nextNodes, connections: nextConnections, scriptNodeId: scriptNode.id, rowCount: rows.length }; +} + +function projectShotRow(shot: ProjectShot, index: number, currentRows: Map) { + const id = `project-shot:${shot.id}`; + const current = currentRows.get(id); + return createStoryboardRow(index + 1, { + ...current, + id, + shotNumber: index + 1, + durationSeconds: Math.max(1, Math.round(shot.durationMs / 1000) || 1), + plotDescription: shot.description.trim() || shot.title.trim(), + status: current?.status || "idle", + }); +} + +function createChapterStoryboardNode(nodes: CanvasNodeData[], unit: Pick, rows: StoryboardRow[]) { + const rightEdge = nodes.reduce((value, node) => Math.max(value, node.position.x + node.width), 0); + const node = createCanvasNode(CanvasNodeType.Script, { x: rightEdge + 540, y: 340 }, { + status: "idle", + workflowKind: "storyboard", + workflowTitle: "章节分镜", + workflowDescription: `已导入 ${rows.length} 个镜头`, + chapterId: unit.id, + chapterTitle: unit.title, + storyboard: { + rows, + visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], + referenceNodeIds: [], + }, + }); + node.title = `分镜脚本 · ${unit.title}`; + return node; +} + +function validStoryboardHandle(handleId: string | undefined, validRowHandles: Set) { + return !handleId || handleId === "storyboard:context" || validRowHandles.has(handleId); +} diff --git a/web/src/pages/projects/detail.tsx b/web/src/pages/projects/detail.tsx index fa4f00c1f..2e7b4fed1 100644 --- a/web/src/pages/projects/detail.tsx +++ b/web/src/pages/projects/detail.tsx @@ -4,10 +4,11 @@ import { ArrowLeft, BookOpenText, Images, LayoutDashboard, LayoutGrid, Plus, Set import { Link, Navigate, useNavigate, useParams } from "react-router"; import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync"; -import { getProject } from "@/services/api/projects"; +import { getProject, linkCanvasUnit } from "@/services/api/projects"; import { WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state"; import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon"; +import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard"; import ProjectAssetsView from "./detail/assets"; import ProjectCanvasesView from "./detail/canvases"; @@ -35,9 +36,31 @@ export default function ProjectDetailPage() { const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); }; const createCanvas = () => { if (detail.data?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; } - void createCanvasProjectWithRemoteSync(`${detail.data?.project.name || "项目"} · 新画布`, projectId).then(({ id, syncError }) => { - if (syncError) message.warning(syncError instanceof Error ? `画布已创建,项目关联稍后重试:${syncError.message}` : "画布已创建,项目关联稍后重试"); - else refreshProject(); + const activeChapterId = chapterId || sessionStorage.getItem(`project-active-chapter:${projectId}`) || ""; + const unit = activeView === "chapters" + ? detail.data?.units.find((item) => item.id === activeChapterId) || detail.data?.units.slice().sort((left, right) => left.position - right.position)[0] + : undefined; + const shots = unit ? detail.data?.shots.filter((shot) => shot.unitId === unit.id) || [] : []; + const seed = unit && shots.length ? upsertProjectChapterStoryboard([], [], { unit, shots }) : undefined; + const initialContent = seed ? { nodes: seed.nodes, connections: seed.connections } : undefined; + const title = unit ? `${unit.title} · ${shots.length ? "分镜画布" : "画布"}` : `${detail.data?.project.name || "项目"} · 新画布`; + void createCanvasProjectWithRemoteSync(title, projectId, initialContent).then(async ({ id, syncError }) => { + if (syncError) { + message.warning(syncError instanceof Error ? `画布已保存在本地,项目关联稍后重试:${syncError.message}` : "画布已保存在本地,项目关联稍后重试"); + navigate(`/canvas/${id}`); + return; + } + if (unit) { + try { + await linkCanvasUnit(projectId, { canvasId: id, unitId: unit.id, role: "storyboard" }); + } catch (error) { + refreshProject(); + message.error(error instanceof Error ? `画布已创建,但章节关联失败:${error.message}` : "画布已创建,但章节关联失败"); + return; + } + } + refreshProject(); + message.success(unit && shots.length ? `已创建章节画布并导入 ${shots.length} 个分镜` : unit ? "章节画布已创建并关联" : "项目画布已创建"); navigate(`/canvas/${id}`); }).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败")); }; @@ -63,7 +86,7 @@ export default function ProjectDetailPage() { - + {detail.data.project.status === "archived" ? : null} diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index 5e95eba00..39c972850 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { useMutation, useMutationState, useQuery } from "@tanstack/react-query"; +import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd"; import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react"; @@ -21,6 +21,7 @@ import { updateProjectAssetCategory, updateProjectCharacter, type ProjectAsset, + type ProjectDetail, } from "@/services/api/projects"; import { saveRemoteUserDataNow } from "@/services/user-data-sync"; import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store"; @@ -42,6 +43,7 @@ type CharacterForm = { name: string } & Record<(typeof characterFields)[number][ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) { const { message } = App.useApp(); + const queryClient = useQueryClient(); const personalAssets = useAssetStore((state) => state.assets); const updatePersonalAsset = useAssetStore((state) => state.updateAsset); const effectiveConfig = useEffectiveConfig(); @@ -66,7 +68,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character); const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length; const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]); - const categoryCounts = categories.map((value) => ({ value, count: value === "all" ? detail.assets.length : detail.assets.filter((asset) => asset.category === value).length })); + const categoryCounts = categories.map((value) => ({ + value, + count: value === "all" + ? detail.assets.length + pendingCandidates.length + : detail.assets.filter((asset) => asset.category === value).length + (value === "character" ? pendingCandidates.length : 0), + })); const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) }); const generatingAssets = useMutationState({ filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" }, @@ -82,9 +89,19 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") }); const confirmMutation = useMutation({ mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId), - onSuccess: ({ asset }, variables) => { syncPersonalCharacterProjection(asset); done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建"); }, + onSuccess: ({ asset }, variables) => { + // 候选确认后先精确更新当前项,剩余队列不依赖整页异步刷新才能继续操作。 + queryClient.setQueryData(["project", detail.project.id], (current) => current ? { + ...current, + assetCandidates: current.assetCandidates.map((candidate) => candidate.id === variables.candidateId ? { ...candidate, status: "confirmed", resolvedAssetId: asset.id, updatedAt: asset.updatedAt } : candidate), + assets: current.assets.some((item) => item.id === asset.id) ? current.assets.map((item) => item.id === asset.id ? asset : item) : [asset, ...current.assets], + } : current); + syncPersonalCharacterProjection(asset); + done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建"); + }, onError: failed("角色确认失败"), }); + const confirmingCandidateId = confirmMutation.isPending ? confirmMutation.variables?.candidateId || "" : ""; const saveCharacter = useMutation({ mutationFn: async (values: CharacterForm) => { const definition = characterDefinition(values); @@ -139,7 +156,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet

角色与资产

- {detail.assets.length} 项 + {detail.assets.length} 项已确认
{characterAssets.length} 个角色 @@ -156,9 +173,34 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
- {(category === "all" || category === "character") && pendingCandidates.length ?
剧情识别出的角色
{pendingCandidates.map((candidate) =>
{candidate.name}
待确认角色卡 · 来自章节分析
{characterAssets.length ? ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}> : null}
)}
: null} -
{category === "all" ? "全部资产" : categoryLabel(category)}{visibleAssets.length} 项
- {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
: } + {(category === "all" || category === "character") && pendingCandidates.length ? ( +
+
+
剧情识别出的角色
+ 剩余 {pendingCandidates.length} 个待确认 +
+
+ {pendingCandidates.map((candidate) => { + const confirming = confirmingCandidateId === candidate.id; + return ( +
+ +
+
{candidate.name}
+
待确认角色卡 · 来自章节分析
+
+ + {characterAssets.length ? ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}> : null} +
+
+
+ ); + })} +
+
+ ) : null} +
{category === "all" ? "全部资产" : categoryLabel(category)}{visibleAssets.length} 项已确认
+ {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
: (category === "all" || category === "character") && pendingCandidates.length ? null : }
diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx index c0f1b2b97..9bf06f681 100644 --- a/web/src/pages/projects/detail/chapters.tsx +++ b/web/src/pages/projects/detail/chapters.tsx @@ -29,6 +29,7 @@ import { Link2, List, ListOrdered, + LayoutGrid, Minus, MoreHorizontal, MoveVertical, @@ -51,6 +52,7 @@ import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspa import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal"; import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference"; import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document"; +import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard"; import { navigateToSettings } from "@/lib/settings-navigation"; import { createProjectAssetCandidates, @@ -58,11 +60,14 @@ import { deleteProjectUnit, getProjectUnit, importProjectUnits, + linkCanvasUnit, reorderProjectUnits, replaceProjectUnitShots, updateProjectUnit, type ProjectUnit, } from "@/services/api/projects"; +import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared"; @@ -89,8 +94,10 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD const [draftHtml, setDraftHtml] = useState(""); const [dirty, setDirty] = useState(false); const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">(""); + const [importingCanvasId, setImportingCanvasId] = useState(""); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const localCanvases = useCanvasStore((state) => state.projects); const listRef = useRef(null); const deferredSearchQuery = useDeferredValue(searchQuery.trim().toLocaleLowerCase("zh-CN")); const orderedUnits = useMemo(() => { @@ -106,6 +113,11 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD const selectedUnit = selectedUnitQuery.data?.unit || selectedUnitSummary; const chapterNumberById = useMemo(() => new Map(orderedUnits.map((unit, index) => [unit.id, index + 1])), [orderedUnits]); const canvasCountByUnitId = useMemo(() => detail.canvasUnitLinks.reduce>((result, link) => result.set(link.unitId, (result.get(link.unitId) || 0) + 1), new Map()), [detail.canvasUnitLinks]); + const projectCanvasTargets = useMemo(() => { + const targets = new Map(detail.canvases.map((canvas) => [canvas.id, { id: canvas.id, title: canvas.title }])); + localCanvases.filter((canvas) => canvas.projectId === detail.project.id).forEach((canvas) => targets.set(canvas.id, { id: canvas.id, title: canvas.title })); + return Array.from(targets.values()); + }, [detail.canvases, detail.project.id, localCanvases]); const visibleUnits = useMemo(() => { if (!deferredSearchQuery) return orderedUnits; const numericQuery = /^\d+$/.test(deferredSearchQuery) ? deferredSearchQuery.replace(/^0+/, "") || "0" : ""; @@ -296,6 +308,43 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD 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("请先生成本章分镜"); + return; + } + setImportingCanvasId(targetCanvasId || "new"); + try { + let canvasId = targetCanvasId || ""; + if (canvasId) { + const canvas = useCanvasStore.getState().openProject(canvasId); + if (!canvas) throw new Error("目标画布尚未同步到本地,请刷新后重试"); + const merged = upsertProjectChapterStoryboard(canvas.nodes, canvas.connections, { unit: selectedUnit, shots }); + useCanvasStore.getState().updateProject(canvasId, { nodes: merged.nodes, connections: merged.connections, projectId: detail.project.id }); + await saveRemoteUserDataNow(); + } else { + const seed = upsertProjectChapterStoryboard([], [], { unit: selectedUnit, shots }); + const created = await createCanvasProjectWithRemoteSync(`${selectedUnit.title} · 分镜画布`, detail.project.id, { nodes: seed.nodes, connections: seed.connections }); + canvasId = created.id; + if (created.syncError) { + message.warning(created.syncError instanceof Error ? `分镜画布已保存在本地,章节关联稍后重试:${created.syncError.message}` : "分镜画布已保存在本地,章节关联稍后重试"); + navigate(`/canvas/${canvasId}`); + return; + } + } + await linkCanvasUnit(detail.project.id, { canvasId, unitId: selectedUnit.id, role: "storyboard" }); + refreshProject(); + message.success(`已将 ${shots.length} 个分镜导入画布并关联本章`); + navigate(`/canvas/${canvasId}`); + } catch (error) { + refreshProject(); + message.error(error instanceof Error ? `分镜导入失败:${error.message}` : "分镜导入失败"); + } finally { + setImportingCanvasId(""); + } + }; const selectChapter = (unitId: string) => { if (unitId === selectedId) return; if (dirty) { message.warning("请先保存当前章节,再切换章节"); return; } @@ -396,15 +445,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
{selectedUnit ? (
-
+
第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章
{ setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
{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}
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index abeffab7f..182fb8a48 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -82,8 +82,9 @@ export function scheduleRemoteUserDataSync() { }, 1200); } -export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string) { +export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { const id = useCanvasStore.getState().createProject(title, projectId); + if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") }; try { await saveRemoteUserDataNow();