feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -99,7 +99,7 @@ export async function executeImageGeneration({
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (isEmptyImageNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
if (isImageNode) return { ...node, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
return { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Prompt", width: parentConfig.width, height: parentConfig.height, metadata: { ...node.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined } };
return { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Prompt", width: parentConfig.width, height: parentConfig.height, metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined } };
}),
...(isImageNode ? [] : [rootNode]),
...childNodes,
@@ -118,7 +118,7 @@ export async function executeImageGeneration({
await Promise.all(
targetIds.map(async (targetId) => {
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: targetId, mode: "image", prompt: effectivePrompt, config: { ...generationConfig, count: "1" }, referenceImages, signal: controller.signal, metadata: { sourceNodeId: nodeId }, onTaskCreated: (task) => bindGenerationTask(targetId, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: targetId, mode: "image", prompt: effectivePrompt, config: { ...generationConfig, count: "1" }, referenceImages, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(targetId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);

View File

@@ -79,7 +79,7 @@ export async function executeVideoGeneration({
startGenerationRequest(videoId, nodeId, nodeId, controller);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
@@ -98,6 +98,7 @@ export async function executeAudioGeneration({
sourceNode,
effectivePrompt,
generationConfig,
generationContext,
controller,
projectId,
setNodes,
@@ -126,7 +127,7 @@ export async function executeAudioGeneration({
startGenerationRequest(audioId, nodeId, nodeId, controller);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: audioId, mode: "audio", prompt: effectivePrompt, config: generationConfig, signal: controller.signal, metadata: { sourceNodeId: nodeId }, onTaskCreated: (task) => bindGenerationTask(audioId, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: audioId, mode: "audio", prompt: effectivePrompt, config: generationConfig, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoiceKey: generationContext.resolvedCharacterVoices[0]?.voiceKey }, onTaskCreated: (task) => bindGenerationTask(audioId, task) });
if (!result.audio?.dataUrl) throw new Error("后端任务没有返回音频");
const audio = await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), generationConfig.audioFormat);
setNodes((current) => current.map((node) => (node.id === audioId ? { ...node, metadata: { ...node.metadata, ...audioMetadata(audio), prompt: effectivePrompt, ...buildAudioGenerationMetadata(generationConfig) } } : node)));

View File

@@ -1,20 +1,23 @@
import { CanvasNodeContextMenu } from "@/components/canvas/canvas-context-menu";
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
type CanvasProjectContextMenuProps = {
menu: ContextMenuState | null;
node: CanvasNodeData | null;
workspaceMode: CanvasWorkspaceMode;
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeType, position: Position) => void;
onAddNovel: (position: Position) => void;
onOpenDirector: (position?: Position) => void;
onUpload: (nodeId: string | undefined, position: Position) => void;
onOpenAssets: (position: Position) => void;
onOpenProjectCharacters: (position: Position) => void;
onUndo: () => void;
onRedo: () => void;
onPaste: (position: Position) => void;
@@ -23,12 +26,13 @@ type CanvasProjectContextMenuProps = {
onDeleteNode: (nodeId: string) => void;
onDeleteConnection: (connectionId: string) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onEditNode: (node: CanvasNodeData) => void;
onViewMedia: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onOpenDrawing: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onCopyContent: (node: CanvasNodeData | null) => void;
onCopyOssUrl: (node: CanvasNodeData | null) => void;
onCopyMediaUrl: (node: CanvasNodeData | null) => void;
onSetAssetCategory: (nodeId: string, category: CanvasAssetCategory) => void;
onToggleFrame: (node: CanvasNodeData) => void;
};
@@ -40,6 +44,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
menu={menu}
node={node}
workspaceMode={props.workspaceMode}
isProjectLinked={props.isProjectLinked}
canUndo={props.canUndo}
canRedo={props.canRedo}
canPaste={props.canPaste}
@@ -47,12 +52,10 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
}}
onAddNovel={() => {
if (menu.type === "canvas") props.onAddNovel(menu.position);
}}
onOpenDirector={props.onOpenDirector}
onUpload={() => props.onUpload(menu.type === "node" ? menu.nodeId : undefined, menuPosition())}
onOpenAssets={() => props.onOpenAssets(menuPosition())}
onOpenProjectCharacters={() => props.onOpenProjectCharacters(menuPosition())}
onUndo={props.onUndo}
onRedo={props.onRedo}
onPaste={() => props.onPaste(menuPosition())}
@@ -69,20 +72,23 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onSaveAsset={() => {
if (node) props.onSaveAsset(node);
}}
onViewImage={() => {
if (node) props.onViewImage(node);
}}
onEditNode={() => {
if (node) props.onEditNode(node);
onViewMedia={() => {
if (node) props.onViewMedia(node);
}}
onEditText={() => {
if (node) props.onEditText(node);
}}
onOpenDrawing={() => {
if (node) props.onOpenDrawing(node);
}}
onGenerateImage={() => {
if (node) props.onGenerateImage(node);
}}
onCopyContent={() => props.onCopyContent(node)}
onCopyOssUrl={() => props.onCopyOssUrl(node)}
onCopyMediaUrl={() => props.onCopyMediaUrl(node)}
onSetAssetCategory={(category) => {
if (menu.type === "node") props.onSetAssetCategory(menu.nodeId, category);
}}
onToggleFrame={() => {
if (node?.type === CanvasNodeType.Frame) props.onToggleFrame(node);
}}

View File

@@ -84,7 +84,7 @@ export function CanvasAgentChangeToast({ change, theme, onView, onUndo, onClose
<div data-canvas-no-zoom aria-live="polite" className="absolute bottom-20 right-4 z-[90] w-[320px] rounded-lg border p-3 shadow-lg backdrop-blur-xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="flex items-start gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><span className="size-2 rounded-full bg-current" /></span>
<span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Agent 已写回画布</span><span className="mt-0.5 block truncate text-[11px]" style={{ color: theme.node.muted }}>{change.summary}</span></span>
<span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Agent 已写回画布</span><span className="mt-0.5 block truncate text-[11px]" style={{ color: theme.node.muted }}>{change.summary} · 可撤销最近 {change.undoCount} 批</span></span>
<button type="button" className="grid size-7 place-items-center rounded-md opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭"><X className="size-3.5" /></button>
</div>
<div className="mt-3 flex items-center justify-end gap-1.5">

View File

@@ -2,7 +2,7 @@ import { Button, Modal } from "antd";
import { TaskDetailItem, taskStatusText } from "./canvas-project-feedback";
import type { GenerationTask, TaskLog } from "@/services/api/task-center";
import type { CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
type CanvasProjectStatusDialogsProps = {
theme: { node: { stroke: string; panel: string; muted: string; fill: string } };
@@ -47,8 +47,9 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
<div className="py-8 text-center text-base font-medium">暂未实现</div>
</Modal>
<Modal title="图片详情" open={Boolean(previewNode?.metadata?.content)} centered onCancel={onClosePreview} footer={null} width="auto" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}>
{previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || "图片"} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
<Modal title={previewNode?.type === CanvasNodeType.Video ? "视频预览" : "图片预览"} open={Boolean(previewNode?.metadata?.content)} centered onCancel={onClosePreview} footer={null} width="min(1200px, calc(100vw - 32px))" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? <video src={previewNode.metadata.content} controls className="max-h-[84vh] max-w-full bg-black object-contain" /> : null}
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? <img src={previewNode.metadata.content} alt={previewNode.title || "图片"} className="max-h-[84vh] max-w-full object-contain" /> : null}
</Modal>
<Modal

View File

@@ -1,12 +1,12 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Coins, Gauge, Home, Images, LoaderCircle, Menu, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal } from "antd";
import { Bot, Check, ChevronDown, Coins, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -37,6 +37,7 @@ type CanvasTopBarProps = {
mediaPerformanceMode: CanvasMediaPerformanceMode;
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
onOpenSearch: () => void;
projectContext?: CanvasContextSummary & { projectId: string; projectName: string };
};
export function CanvasTopBar({
@@ -64,6 +65,7 @@ export function CanvasTopBar({
mediaPerformanceMode,
onMediaPerformanceModeChange,
onOpenSearch,
projectContext,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
@@ -93,7 +95,7 @@ export function CanvasTopBar({
menu={{
items: [
{ key: "home", icon: <Home className="size-4" />, label: <Link to="/">主页</Link> },
{ key: "projects", icon: <Images className="size-4" />, label: <Link to="/canvas">我的画布</Link> },
{ key: "projects", icon: <LayoutGrid className="size-4" />, label: <Link to="/canvas">画布</Link> },
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
@@ -121,10 +123,11 @@ export function CanvasTopBar({
</button>
</Dropdown>
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
<div ref={titleRef} className="flex min-w-0 flex-col items-start">
{isTitleEditing ? (
<input
autoFocus
size={canvasTitleInputSize(titleDraft)}
value={titleDraft}
onChange={(event) => onTitleDraftChange(event.target.value)}
onBlur={onFinishTitleEditing}
@@ -132,14 +135,36 @@ export function CanvasTopBar({
if (event.key === "Enter") onFinishTitleEditing();
if (event.key === "Escape") onCancelTitleEditing();
}}
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
style={{ color: theme.node.text }}
className="h-8 w-auto min-w-12 max-w-[min(280px,42vw)] appearance-none border-0 bg-transparent p-0 text-left text-base font-semibold tracking-normal outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
style={{ color: theme.node.text, caretColor: theme.accent.primary, border: 0, boxShadow: "none", outline: "none" }}
aria-label="画布名称"
/>
) : (
<button type="button" className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current" onDoubleClick={onStartTitleEditing} title="双击修改画布名称">
{title}
</button>
<div className="flex min-w-0 items-center gap-0.5">
<button type="button" className="max-w-[280px] truncate text-left text-base font-semibold tracking-normal transition-opacity hover:opacity-75" onClick={onStartTitleEditing} title="点击修改画布名称">
{title}
</button>
<Tooltip title="重命名画布">
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition hover:bg-black/5 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<Pencil className="size-3.5" />
</button>
</Tooltip>
</div>
)}
{projectContext && !isTitleEditing ? (
<div className="mt-0.5 flex max-w-[360px] items-center gap-1.5 text-[10px]" style={{ color: theme.node.muted }}>
<Link to={`/projects/${projectContext.projectId}/overview`} className="inline-flex min-w-0 items-center gap-1 hover:underline" title={`返回项目:${projectContext.projectName}`}>
<FolderKanban className="size-3 shrink-0" />
<span className="max-w-[120px] truncate">{projectContext.projectName}</span>
</Link>
<span aria-hidden>·</span>
<button type="button" className="min-w-0 truncate hover:underline" onClick={onOpenSearch} title="搜索并定位章节或镜头">
{projectContext.chapterLabel || `${projectContext.nodeCount} 个节点`}
{projectContext.shotLabel ? ` · ${projectContext.shotLabel}` : ""}
{projectContext.selectedCount ? ` · 已选 ${projectContext.selectedCount}` : ""}
</button>
</div>
) : null}
</div>
</div>
@@ -175,7 +200,6 @@ export function CanvasTopBar({
</Link>
) : null}
<Button type="text" className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
@@ -323,6 +347,11 @@ function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
);
}
function canvasTitleInputSize(value: string) {
const visualLength = Array.from(value || "画布名称").reduce((length, character) => length + (character.codePointAt(0)! > 0xff ? 2 : 1), 0);
return Math.min(30, Math.max(5, visualLength));
}
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";

View File

@@ -3,7 +3,6 @@ import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent,
import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasNodeAnglePanel, type CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
@@ -18,6 +17,7 @@ type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
type CanvasProjectWorldLayersProps = {
projectId: string;
theme: CanvasTheme;
viewportScale: number;
connectionLayerBounds: { left: number; top: number; width: number; height: number };
@@ -36,8 +36,6 @@ type CanvasProjectWorldLayersProps = {
frameDropTargetId: string | null;
relatedNodeIds: Set<string>;
activeNodeId: string | null;
editingNodeId: string | null;
editRequestNonce: number;
selectionBox: SelectionBox | null;
batchChildCountById: Map<string, number>;
collapsingBatchIds: Set<string>;
@@ -47,7 +45,7 @@ type CanvasProjectWorldLayersProps = {
reduceMediaEffects: boolean;
resourceReferenceByNodeId: Map<string, CanvasResourceReference>;
mentionReferencesByNodeId: Map<string, CanvasResourceReference[]>;
angleNode: CanvasNodeData | null;
mediaEffectsDisabledNodeId?: string | null;
selectedNodeBounds: NodeBounds;
isNodeDragging: boolean;
selectionBoundsElementRef: RefObject<HTMLDivElement | null>;
@@ -70,13 +68,11 @@ type CanvasProjectWorldLayersProps = {
onCancelTask: (node: CanvasNodeData) => void;
onOpenTaskDetails: (node: CanvasNodeData) => void;
onOpenVersions: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onReplaceMedia: (node: CanvasNodeData) => void;
onOpenTextEditor: (node: CanvasNodeData) => void;
onOpenDirector: (node: CanvasNodeData) => void;
onOpenDocument: (node: CanvasNodeData) => void;
onCloseAngle: () => void;
onGenerateAngle: (params: CanvasImageAngleParams) => void;
onOpenDrawing: (node: CanvasNodeData) => void;
};
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
@@ -134,7 +130,6 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id}
isConnecting={Boolean(props.connectingParams)}
editRequestNonce={props.editingNodeId === node.id ? props.editRequestNonce : 0}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
batchClosing={Boolean(node.metadata?.batchRootId && props.collapsingBatchIds.has(node.metadata.batchRootId))}
@@ -142,10 +137,11 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
batchRecovering={props.collapsingBatchIds.has(node.id)}
batchMotion={props.batchMotionById.get(node.id)}
showImageInfo={props.showImageInfo}
reduceMediaEffects={props.reduceMediaEffects || props.isNodeDragging}
reduceMediaEffects={props.reduceMediaEffects || props.isNodeDragging || props.mediaEffectsDisabledNodeId === node.id}
resourceLabel={props.resourceReferenceByNodeId.get(node.id)}
mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES}
renderNodeContent={props.renderCanvasNodeContent}
drawingProjectId={props.projectId}
onMouseDown={props.onNodeMouseDown}
onHoverStart={props.onNodeHoverStart}
onHoverEnd={props.onNodeHoverEnd}
@@ -158,22 +154,16 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
onCancelTask={props.onCancelTask}
onOpenTaskDetails={props.onOpenTaskDetails}
onOpenVersions={props.onOpenVersions}
onGenerateImage={props.onGenerateImage}
onViewImage={props.onViewImage}
onReplaceMedia={props.onReplaceMedia}
onOpenTextEditor={props.onOpenTextEditor}
onOpenDirector={props.onOpenDirector}
onOpenDocument={props.onOpenDocument}
onOpenDrawing={props.onOpenDrawing}
onContextMenu={props.onNodeContextMenu}
/>
),
)}
{props.angleNode?.metadata?.content ? (
<div className="absolute z-[68]" style={{ left: props.angleNode.position.x, top: props.angleNode.position.y + props.angleNode.height + 16, width: Math.max(props.angleNode.width, 720) }}>
<CanvasNodeAnglePanel dataUrl={props.angleNode.metadata.content} onClose={props.onCloseAngle} onConfirm={props.onGenerateAngle} />
</div>
) : null}
{props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
<div
ref={props.selectionBoundsElementRef}

View File

@@ -56,11 +56,11 @@ export async function executeTextGeneration({
const answers = await Promise.all(
textTargetIds.map((targetNodeId) => {
let localStreamed = "";
return runBackendCanvasGenerationTask({ projectId, nodeId: targetNodeId, mode: "text", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, signal: controller.signal, metadata: { sourceNodeId: nodeId }, onTaskCreated: (task) => bindGenerationTask(targetNodeId, task) })
return runBackendCanvasGenerationTask({ projectId, nodeId: targetNodeId, mode: "text", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(targetNodeId, task) })
.then((result) => {
localStreamed = result.text || "";
streamed = localStreamed;
if (!isConfigNode) setNodes((current) => current.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: localStreamed, status: NODE_STATUS_LOADING } } : node)));
if (!isConfigNode) setNodes((current) => current.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: localStreamed, richText: undefined, status: NODE_STATUS_LOADING } } : node)));
return { nodeId: targetNodeId, content: localStreamed };
})
.finally(() => finishGenerationRequest(targetNodeId, controller));
@@ -71,11 +71,11 @@ export async function executeTextGeneration({
setNodes((current) =>
current.map((node) =>
childIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, richText: undefined, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node.id === nodeId && !editingTextNode
? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, richText: undefined, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } }
: node,
),
);

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { App, Button, Dropdown, Empty, Input, Select } from "antd";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Input, Modal, Select } from "antd";
import { Download, FileUp, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react";
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
@@ -13,7 +15,9 @@ import type { CanvasExportFile } from "@/types/canvas-export";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { listProjects } from "@/services/api/projects";
export default function CanvasPage() {
const { message } = App.useApp();
@@ -23,6 +27,7 @@ export default function CanvasPage() {
const autoOpenRef = useRef(false);
const [keyword, setKeyword] = useState("");
const [sort, setSort] = useState<"updated" | "name" | "nodes">("updated");
const [projectFilter, setProjectFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(24);
const hydrated = useCanvasStore((state) => state.hydrated);
@@ -30,6 +35,10 @@ export default function CanvasPage() {
const importProject = useCanvasStore((state) => state.importProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const updateProject = useCanvasStore((state) => state.updateProject);
const [associationOpen, setAssociationOpen] = useState(false);
const [associationProjectId, setAssociationProjectId] = useState("");
const projectQuery = useQuery({ queryKey: ["projects"], queryFn: listProjects });
const mode = searchParams.get("mode");
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
@@ -38,18 +47,32 @@ export default function CanvasPage() {
navigate(`/canvas/${id}${agentQuery}`);
};
const createAndEnter = () => {
void createCanvasProjectWithRemoteSync(`无限画布 ${projects.length + 1}`).then(({ id, syncError }) => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
enterProject(id);
});
};
const filteredProjects = useMemo(() => {
const query = keyword.trim().toLowerCase();
const values = query ? projects.filter((project) => project.title.toLowerCase().includes(query)) : [...projects];
const scoped = projects.filter((project) => projectFilter === "all" || (projectFilter === "independent" ? !project.projectId : project.projectId === projectFilter));
const values = query ? scoped.filter((project) => project.title.toLowerCase().includes(query)) : [...scoped];
values.sort((a, b) => sort === "name" ? a.title.localeCompare(b.title, "zh-CN") : sort === "nodes" ? b.nodes.length - a.nodes.length : new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return values;
}, [keyword, projects, sort]);
}, [keyword, projectFilter, projects, sort]);
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
const visibleProjects = filteredProjects.slice((page - 1) * pageSize, page * pageSize);
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const associateSelected = async (nextProjectId = associationProjectId) => {
const projectId = nextProjectId || undefined;
selectedIds.forEach((id) => updateProject(id, { projectId }));
try {
await saveRemoteUserDataNow();
message.success(projectId ? "已加入项目" : "已移出项目,画布仍保留");
setAssociationOpen(false);
} catch (error) {
message.error(error instanceof Error ? `画布关系保存失败:${error.message}` : "画布关系保存失败");
}
};
const importCanvas = async (file?: File) => {
if (!file) return;
try {
@@ -67,7 +90,26 @@ export default function CanvasPage() {
}),
),
);
data.projects.forEach((item) => importProject(item.project));
await Promise.all(data.projects.map(async (item) => {
const importedProjectId = importProject(item.project);
await Promise.all((item.drawingDocuments || []).map((document) => {
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
const renderFile = document.generationRender?.path ? zip.get(document.generationRender.path) : undefined;
const renderBlob = renderFile && !renderFile.type ? renderFile.slice(0, renderFile.size, document.generationRender?.mimeType || "image/png") : renderFile;
const render = renderBlob && document.generationRender
? {
blob: renderBlob,
pageId: document.generationRender.pageId,
width: document.generationRender.width,
height: document.generationRender.height,
mimeType: document.generationRender.mimeType,
background: document.generationRender.background,
} satisfies CanvasDrawingRenderDraft
: undefined;
return saveCanvasDrawing(importedProjectId, document.drawingId, document.snapshot, { ...document, version: 1, revision: Math.max(0, document.revision - 1) }, preview, render);
}));
}));
message.success(`已导入 ${data.projects.length} 个画布`);
} catch {
message.error("导入失败,请选择有效的画布压缩包");
@@ -83,7 +125,7 @@ export default function CanvasPage() {
enterProject(projects[0].id);
return;
}
void createCanvasProjectWithRemoteSync(`无限画布 ${projects.length + 1}`).then(({ id, syncError }) => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
enterProject(id);
});
@@ -92,53 +134,66 @@ export default function CanvasPage() {
if (hydrated && (mode === "new" || mode === "recent")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
return (
<WorkspacePage grid>
<WorkspacePage grid className="canvas-library-page">
<PageHeader
title="我的画布"
description="管理创作项目,快速继续最近的画布。"
meta={<span className="text-xs text-foreground/45">{hydrated ? `${filteredProjects.length} 个项目${selectedIds.length ? ` · 已选择 ${selectedIds.length} 个` : ""}` : "正在载入"}</span>}
icon="canvas"
title="画布"
description="管理自由创作空间和已加入项目的制作画布。"
meta={<span className="text-xs tabular-nums text-foreground/45">{hydrated ? `${filteredProjects.length} 个` : "载入中"}</span>}
actions={(
<>
{selectedIds.length ? (
<>
<Button disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}>导出选中</Button>
<Button danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除选中</Button>
</>
) : null}
{projects.length ? (
<Dropdown menu={{ items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}>
<Button aria-label="更多画布操作" icon={<MoreHorizontal className="size-4" />} />
<Button className="!h-9 !w-9 !p-0" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
</Dropdown>
) : null}
<Button disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>导入画布</Button>
<Button type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button>
<Button className="!h-9 !px-3.5" disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>导入</Button>
<Button className="!h-9 !px-4" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button>
</>
)}
/>
<ListToolbar active={Boolean(keyword || sort !== "updated")} onReset={() => { setKeyword(""); setSort("updated"); setPage(1); }}>
<Input allowClear className="w-full sm:w-72" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索画布名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-36" value={sort} onChange={(value) => { setSort(value); setPage(1); }} options={[{ label: "最近更新", value: "updated" }, { label: "名称排序", value: "name" }, { label: "节点数量", value: "nodes" }]} />
<ListToolbar
active={Boolean(keyword || projectFilter !== "all" || sort !== "updated")}
trailing={<span className="text-xs tabular-nums text-foreground/42">显示 {visibleProjects.length} / {filteredProjects.length}</span>}
onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }}
>
<div className="min-w-[220px] flex-1 sm:max-w-[380px]">
<Input allowClear className="w-full" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
</div>
<Select aria-label="按所属项目筛选" className="w-[168px]" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部项目", value: "all" }, { label: "自由画布", value: "independent" }, ...(projectQuery.data?.projects || []).map(({ project }) => ({ label: project.name, value: project.id }))]} />
<Select aria-label="画布排序" className="w-[136px]" value={sort} onChange={(value) => { setSort(value); setPage(1); }} options={[{ label: "最近更新", value: "updated" }, { label: "名称排序", value: "name" }, { label: "节点数量", value: "nodes" }]} />
</ListToolbar>
{selectedIds.length ? (
<div className="app-canvas-selection-toolbar mt-2 flex min-h-10 flex-wrap items-center gap-2 rounded-md border px-3 py-1.5 text-xs">
<strong className="mr-auto font-medium">已选 {selectedIds.length} 个画布</strong>
<Button size="small" disabled={!hydrated || projectQuery.isLoading} onClick={() => { setAssociationProjectId(selectedProjects[0]?.projectId || ""); setAssociationOpen(true); }}>加入项目</Button>
{selectedProjects.some((project) => project.projectId) ? <Button size="small" disabled={!hydrated} onClick={() => { setAssociationProjectId(""); void associateSelected(""); }}>移出项目</Button> : null}
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `影策画布-${selectedIds.length}个画布`)}>导出</Button>
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除</Button>
</div>
) : null}
{!hydrated ? (
<section className="flex min-h-[320px] items-center justify-center text-sm text-foreground/50">正在加载画布...</section>
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
) : visibleProjects.length ? (
<CollectionGrid>
<CollectionGrid className="sm:grid-cols-[repeat(auto-fill,minmax(272px,320px))] sm:justify-start">
{visibleProjects.map((project) => (
<CanvasProjectCard key={project.id} project={project} />
<CanvasProjectCard key={project.id} project={project} projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined} />
))}
</CollectionGrid>
) : (
<section className="flex min-h-[320px] flex-col items-center justify-center text-center">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={<span className="text-foreground/50">{keyword ? "没有匹配的画布" : "还没有画布"}</span>} />
{!keyword ? <Button className="mt-5" type="primary" icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button> : null}
</section>
<WorkspaceState icon="canvas" title={keyword ? "没有匹配的画布" : "创建一张可继续生长的画布"} description={keyword ? "换一个画布名称或重置筛选条件。" : "把文本、图片、视频和 Agent 操作连接成可追踪的创作流程。"} action={!keyword ? <Button type="primary" icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button> : undefined} />
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredProjects.length} pageSizeOptions={[12, 24, 48]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<Modal title="加入项目" open={associationOpen} okText="保存关联" cancelText="取消" okButtonProps={{ disabled: !associationProjectId, loading: projectQuery.isFetching }} onCancel={() => setAssociationOpen(false)} onOk={() => void associateSelected()}>
<p className="mb-3 text-sm text-foreground/60">选中的画布会保留原有节点和本地媒体,只增加项目关联。</p>
<Select className="w-full" value={associationProjectId || undefined} placeholder="选择项目" options={(projectQuery.data?.projects || []).map((item) => ({ label: item.project.name, value: item.project.id }))} onChange={setAssociationProjectId} />
</Modal>
</WorkspacePage>
);
}

View File

@@ -1,13 +1,16 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { uploadMediaFile } from "@/services/file-storage";
import { getResourceOSSUrl } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { App } from "antd";
@@ -17,9 +20,16 @@ import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-pa
import { CanvasAssistantPanel } from "@/components/canvas/canvas-assistant-panel";
import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray";
import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar";
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
import { CanvasDocumentEditorModal } from "@/components/canvas/canvas-document-editor-modal";
import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
@@ -27,6 +37,7 @@ import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
import { getProject } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
import { CanvasScriptEditor, CanvasScriptNodeContent, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/components/canvas/canvas-script-node";
@@ -36,7 +47,7 @@ import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-pa
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasAlignmentGuides, CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import {
createCanvasNode,
getInputSummary,
@@ -58,11 +69,14 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { useCanvasConnectionController } from "./use-canvas-connection-controller";
import { useCanvasAgentOperations } from "./use-canvas-agent-operations";
import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility";
import { useCanvasActiveTasks } from "./use-canvas-active-tasks";
import { useCanvasDocumentWorkflow } from "./use-canvas-document-workflow";
import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
import { useCanvasDirector } from "./use-canvas-director";
import { useCanvasGeneration } from "./use-canvas-generation";
import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
@@ -98,6 +112,7 @@ import {
import type { ReferenceImage } from "@/types/image";
const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
const NODE_STATUS_SUCCESS = "success" as const;
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
@@ -172,7 +187,6 @@ function InfiniteCanvasPage() {
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const assets = useAssetStore((state) => state.assets);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -198,10 +212,13 @@ function InfiniteCanvasPage() {
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
const [documentEditorNodeId, setDocumentEditorNodeId] = useState<string | null>(null);
const [textEditorNodeId, setTextEditorNodeId] = useState<string | null>(null);
const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState<string | null>(null);
const [drawingNodeId, setDrawingNodeId] = useState<string | null>(null);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editRequestNonce, setEditRequestNonce] = useState(0);
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
@@ -309,6 +326,16 @@ function InfiniteCanvasPage() {
cleanupAssetImages,
cleanupCanvasFiles,
});
const linkedProjectId = currentProject?.projectId || "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
useEffect(() => {
if (!projectLoaded || !linkedProjectQuery.data) return;
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
}, [linkedProjectQuery.data, projectLoaded, setNodes]);
const canvasContext = useMemo(
() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units),
[linkedProjectQuery.data?.units, nodes, selectedNodeIds],
);
const {
bindGenerationTask,
@@ -323,7 +350,7 @@ function InfiniteCanvasPage() {
taskDetail,
taskDetailLoading,
taskDetailLogs,
} = useCanvasGeneration({ projectId, projectLoaded, nodes, nodesRef, setNodes });
} = useCanvasGeneration({ projectId, domainProjectId: linkedProjectId, projectLoaded, nodes, nodesRef, setNodes });
useEffect(() => {
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
@@ -396,9 +423,37 @@ function InfiniteCanvasPage() {
setSelectedConnectionId,
setContextMenu,
setDialogNodeId,
setDrawingNodeId,
setToolbarNodeId,
});
useEffect(() => {
const preset = canvasStylePresets.find((item) => item.id === linkedProjectQuery.data?.project.stylePresetId);
if (!projectLoaded || !preset) return;
const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
const nextMetadata = {
content: preset.prompt,
prompt: preset.prompt,
status: NODE_STATUS_SUCCESS,
workflowKind: "styleboard" as const,
workflowTitle: "项目画风",
workflowDescription: preset.description,
stylePresetId: preset.id,
fontSize: 14,
locked: true,
};
if (current) {
if (current.metadata?.stylePresetId === preset.id && current.metadata?.content === preset.prompt && current.metadata?.locked) return;
setNodes((nodes) => nodes.map((node) => node.id === current.id ? { ...node, title: `项目画风 · ${preset.title}`, metadata: { ...node.metadata, ...nextMetadata } } : node));
return;
}
const node = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), nextMetadata);
node.title = `项目画风 · ${preset.title}`;
node.width = 420;
node.height = 240;
setNodes((nodes) => [...nodes, node]);
}, [getCanvasCenter, linkedProjectQuery.data?.project.stylePresetId, projectLoaded, setNodes]);
const {
assetPickerOpen,
closeAssetPicker,
@@ -410,6 +465,8 @@ function InfiniteCanvasPage() {
handleFileDragLeave,
handleFileDragOver,
handleImageInputChange,
handleProjectAssetsInsert,
handleProjectChapterInsert,
handleUploadRequest,
imageInputRef,
openAssetsAtPosition,
@@ -418,6 +475,8 @@ function InfiniteCanvasPage() {
startUploadStatus,
uploadStatus,
} = useCanvasUpload({
canvasId: projectId,
domainProjectId: linkedProjectId,
nodesRef,
selectedNodeIdsRef,
getCanvasCenter,
@@ -429,8 +488,19 @@ function InfiniteCanvasPage() {
setDialogNodeId,
});
const openProjectAssets = useCallback((initialCategory = "all", position?: Position) => {
setProjectAssetInitialCategory(initialCategory);
setProjectAssetInsertPosition(position);
setProjectAssetOpen(true);
}, []);
const closeProjectAssets = useCallback(() => {
setProjectAssetOpen(false);
setProjectAssetInsertPosition(undefined);
}, []);
const {
angleNodeId,
emotionNodeId,
annotationNodeId,
createImageReversePromptNodes,
cropImageNode,
@@ -438,6 +508,7 @@ function InfiniteCanvasPage() {
extractVideoLastFrame,
extractingVideoFrameNodeId,
generateAngleNode,
generateEmotionNode,
maskEditImageNode,
maskEditNodeId,
mergeSelectedVideos,
@@ -445,6 +516,7 @@ function InfiniteCanvasPage() {
mergeVideoProgress,
saveAnnotatedImageNode,
setAngleNodeId,
setEmotionNodeId,
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,
@@ -474,12 +546,14 @@ function InfiniteCanvasPage() {
bindGenerationTask,
});
const handleNodesDeleted = useCallback((removedIds: Set<string>, nextNodes: CanvasNodeData[]) => {
const handleNodesDeleted = useCallback((removedIds: Set<string>, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => {
const clearDeletedId = (current: string | null) => current && removedIds.has(current) ? null : current;
setHoveredNodeId(clearDeletedId);
setToolbarNodeId(clearDeletedId);
setDialogNodeId(clearDeletedId);
setEditingNodeId(clearDeletedId);
setTextEditorNodeId(clearDeletedId);
setCharacterReferenceNodeId(clearDeletedId);
setDrawingNodeId(clearDeletedId);
setInfoNodeId(clearDeletedId);
setCropNodeId(clearDeletedId);
setMaskEditNodeId(clearDeletedId);
@@ -487,17 +561,22 @@ function InfiniteCanvasPage() {
setSplitNodeId(clearDeletedId);
setUpscaleNodeId(clearDeletedId);
setAngleNodeId(clearDeletedId);
setEmotionNodeId(clearDeletedId);
setSuperResolveNodeId(clearDeletedId);
setPreviewNodeId(clearDeletedId);
setRunningNodeId(clearDeletedId);
setScriptEditorNodeId(clearDeletedId);
setDocumentEditorNodeId(clearDeletedId);
setDirectorNodeId(clearDeletedId);
setVersionCompareRootId(clearDeletedId);
setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
setContextMenu((current) => current?.type === "node" && removedIds.has(current.nodeId) ? null : current);
const removedDrawingIds = removedNodes.flatMap((node) => node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
if (removedDrawingIds.length) {
void Promise.all(removedDrawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId)))
.catch(() => message.warning("绘图节点已删除,但本地绘图缓存清理失败"));
}
cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
}, [chatSessions, cleanupCanvasFiles, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setMaskEditNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId]);
}, [chatSessions, cleanupCanvasFiles, message, projectId, setAngleNodeId, setAnnotationNodeId, setCropNodeId, setEmotionNodeId, setMaskEditNodeId, setSplitNodeId, setUpscaleNodeId, setRunningNodeId]);
const {
alignSelectedNodes,
@@ -515,6 +594,7 @@ function InfiniteCanvasPage() {
setPrimaryVersion,
toggleNodeLocked,
} = useCanvasNodeOperations({
projectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -539,6 +619,7 @@ function InfiniteCanvasPage() {
pendingConnectionCreate,
setConnecting,
} = useCanvasConnectionController({
projectId,
nodesRef,
connectionsRef,
viewportRef,
@@ -550,6 +631,7 @@ function InfiniteCanvasPage() {
setSelectedConnectionId,
setContextMenu,
setDialogNodeId,
setDrawingNodeId,
});
const handleCanvasSelectionStart = useCallback(() => {
@@ -565,9 +647,9 @@ function InfiniteCanvasPage() {
}, []);
const handleSelectedNodeClick = useCallback((node: CanvasNodeData) => {
if (node.metadata?.document?.kind === "novel") {
if (node.type === CanvasNodeType.Drawing) {
setDialogNodeId(null);
setDocumentEditorNodeId(node.id);
setDrawingNodeId(node.id);
} else if (node.type === CanvasNodeType.Script) {
setDialogNodeId(null);
} else if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Frame) {
@@ -582,7 +664,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
}, []);
const {
@@ -643,7 +724,6 @@ function InfiniteCanvasPage() {
handleNodePromptChange,
handleNodeResize,
handleNodeTitleChange,
openTextEditor,
openingBatchIds,
saveNodeAsset,
setBatchPrimary,
@@ -651,25 +731,24 @@ function InfiniteCanvasPage() {
toggleFrameCollapsed,
toggleNodeFreeResize,
} = useCanvasNodeEditor({
canvasId: projectId,
domainProjectId: linkedProjectId,
nodesRef,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setDocumentEditorNodeId,
setEditingNodeId,
setEditRequestNonce,
setToolbarNodeId,
setHoveredNodeId,
});
const {
activeDirectorScene,
activeDocumentNode,
activeNodeId,
activeScriptNode,
activeStylePresetId,
angleNode,
emotionNode,
annotationNode,
batchChildCountById,
batchMotionById,
@@ -720,17 +799,47 @@ function InfiniteCanvasPage() {
upscaleNodeId,
superResolveNodeId,
angleNodeId,
emotionNodeId,
previewNodeId,
contextMenu,
versionCompareRootId,
directorNodeId,
documentEditorNodeId,
scriptEditorNodeId,
dialogNodeId,
});
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
const { agentSnapshot, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
const canCreateDrawingFromConnection = pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
const openTextNodeEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) {
setCharacterReferenceNodeId(node.id);
return;
}
setTextEditorNodeId(node.id);
}, []);
const openDrawingNode = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Drawing) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(null);
setToolbarNodeId(null);
setDrawingNodeId(node.id);
}, []);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
projectTitle: currentProject?.title || "未命名画布",
nodes,
connections,
@@ -750,32 +859,15 @@ function InfiniteCanvasPage() {
focusSelection: fitCanvasSelection,
});
const {
analyzeDocumentCharacters,
analyzeDocumentNode,
createNovelNode,
documentAnalyzing,
documentCharacterAnalyzing,
documentSaving,
saveDocumentNode,
selectCanvasStyle,
} = useCanvasDocumentWorkflow({
projectId,
agentSnapshot,
applyAgentOps,
const { selectCanvasStyle } = useCanvasStyleWorkflow({
nodesRef,
connectionsRef,
selectedNodeIdsRef,
getCanvasCenter,
viewportSize: size,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setDocumentEditorNodeId,
setStylePickerOpen,
setViewport: handleViewportChange,
});
const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({
@@ -800,7 +892,6 @@ function InfiniteCanvasPage() {
openStoryInput,
progress: shortDramaProgress,
setGuideCollapsed: setShortDramaGuideCollapsed,
setStoryInputMode,
skipGuide: skipShortDramaGuide,
} = useCanvasShortDrama({
nodes,
@@ -814,26 +905,33 @@ function InfiniteCanvasPage() {
setSelectedNodeIds,
setSelectedConnectionId,
setStylePickerOpen,
setDocumentEditorNodeId,
fitCanvasSelection,
focusCanvasNode,
openTextEditor,
openTextEditor: openTextNodeEditor,
});
const clearCanvas = useCallback(() => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
if (drawingIds.length) {
void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId)))
.catch(() => message.warning("画布已清空,但部分本地绘图缓存清理失败"));
}
setNodes([]);
setConnections([]);
setTextEditorNodeId(null);
setDrawingNodeId(null);
setInfoNodeId(null);
setCropNodeId(null);
setMaskEditNodeId(null);
setAnnotationNodeId(null);
setAngleNodeId(null);
setEmotionNodeId(null);
setPreviewNodeId(null);
setRunningNodeId(null);
deselectCanvas();
setClearConfirmOpen(false);
clearCanvasFiles();
}, [clearCanvasFiles, deselectCanvas]);
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
useCanvasKeyboard({
nodesRef,
@@ -916,15 +1014,20 @@ function InfiniteCanvasPage() {
[message],
);
const copyNodeOssUrlToClipboard = useCallback(
const copyNodeMediaUrlToClipboard = useCallback(
async (node: CanvasNodeData | null) => {
try {
const ossURL = await getResourceOSSUrl(node?.metadata?.storageKey);
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(ossURL);
else if (!copyToClipboard(ossURL)) throw new Error("当前浏览器不支持写入剪贴板");
message.success("临时 OSS 地址已复制");
const storageKey = node?.metadata?.storageKey;
const content = node?.metadata?.content?.trim();
const resourceId = resourceIdFromStorageKey(storageKey);
const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : "";
const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址");
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL);
else if (!copyToClipboard(mediaURL)) throw new Error("当前浏览器不支持写入剪贴板");
message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制");
} catch (error) {
message.error(error instanceof Error ? error.message : "复制 OSS 地址失败");
message.error(error instanceof Error ? error.message : "媒体地址复制失败");
}
},
[message],
@@ -955,11 +1058,13 @@ function InfiniteCanvasPage() {
setSelectedConnectionId(null);
closeConnectionCreateMenu();
setToolbarNodeId(null);
setDialogNodeId(null);
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
}, [closeConnectionCreateMenu]);
const handleGenerateNode = useCanvasGenerationExecutor({
projectId,
domainProjectId: currentProject?.projectId,
activatedSkills,
nodesRef,
connectionsRef,
@@ -993,7 +1098,6 @@ function InfiniteCanvasPage() {
const {
addScriptRow,
analyzeDocumentChapter,
createAndGenerateScriptVideos,
createScriptActionBoards,
createScriptImageNodes,
@@ -1016,6 +1120,7 @@ function InfiniteCanvasPage() {
const handleRetryNode = useCanvasGenerationRetry({
projectId,
domainProjectId: currentProject?.projectId,
activatedSkills,
nodesRef,
connectionsRef,
@@ -1052,7 +1157,7 @@ function InfiniteCanvasPage() {
},
);
const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id };
const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(configNode);
const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(configNode);
const nextConnections = [...connectionsRef.current, connection];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
@@ -1067,7 +1172,7 @@ function InfiniteCanvasPage() {
const renderCanvasNodePanel = useCallback(
(panelNode: CanvasNodeData) => {
if (panelNode.type === CanvasNodeType.Script) return null;
if (panelNode.type === CanvasNodeType.Script || panelNode.type === CanvasNodeType.Drawing) return null;
return panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
@@ -1101,11 +1206,14 @@ function InfiniteCanvasPage() {
);
const renderCanvasNodeContent = useCallback((contentNode: CanvasNodeData) => {
if (contentNode.metadata?.workflowKind === "character" && contentNode.metadata.characterAssetId) {
return <CanvasCharacterReferenceNodeContent node={contentNode} />;
}
if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) {
return <CanvasStylePlaceholderNodeContent onChoose={() => setStylePickerOpen(true)} />;
}
if (contentNode.metadata?.workflowKind === "story_input") {
return <CanvasStoryInputNodeContent node={contentNode} onModeChange={(mode) => setStoryInputMode(contentNode.id, mode)} onEdit={() => openStoryInput(contentNode.id)} />;
return <CanvasStoryInputNodeContent node={contentNode} onEdit={() => openStoryInput(contentNode.id)} />;
}
if (contentNode.type === CanvasNodeType.Script) {
const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
@@ -1173,7 +1281,7 @@ function InfiniteCanvasPage() {
workspaceMode={workspaceMode}
/>
);
}, [addScriptRow, cancelSubmittedBatchItem, configInputsById, confirmStopGeneration, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, currentProject?.directorScenes, generateScriptImages, generateScriptRows, generateScriptVideos, handleConfigNodeChange, handleConnectStart, handleGenerateNode, handleNodeResize, mentionReferencesByNodeId, mergeVideosByIds, openDirectorWorkbench, openStoryInput, removeScriptRow, retryFailedBatchItems, runningNodeId, setStoryInputMode, stopRemainingBatchItems, updateScriptRow, viewport.k, workspaceMode]);
}, [addScriptRow, cancelSubmittedBatchItem, configInputsById, confirmStopGeneration, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, currentProject?.directorScenes, generateScriptImages, generateScriptRows, generateScriptVideos, handleConfigNodeChange, handleConnectStart, handleGenerateNode, handleNodeResize, mentionReferencesByNodeId, mergeVideosByIds, openDirectorWorkbench, openStoryInput, removeScriptRow, retryFailedBatchItems, runningNodeId, stopRemainingBatchItems, updateScriptRow, viewport.k, workspaceMode]);
const handleCanvasNodeHoverStart = useCallback((nodeId: string) => {
if (nodeDraggingRef.current) return;
@@ -1189,10 +1297,19 @@ function InfiniteCanvasPage() {
const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []);
const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
const editCanvasDirector = useCallback((node: CanvasNodeData) => openDirectorWorkbench(node.id), [openDirectorWorkbench]);
const locateProjectStyleNode = useCallback(() => {
const styleNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
if (!styleNode) {
message.info("项目画风节点正在同步,请稍后再试");
return;
}
focusCanvasNode(styleNode.id);
}, [focusCanvasNode, message, nodesRef]);
if (!projectLoaded) return <CanvasRefreshShell />;
return (
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
{currentProject?.projectId ? <CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} /> : null}
<section className="relative min-w-0 flex-1 overflow-hidden">
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
@@ -1219,6 +1336,11 @@ function InfiniteCanvasPage() {
mediaPerformanceMode={mediaPerformanceMode}
onMediaPerformanceModeChange={setMediaPerformanceMode}
onOpenSearch={() => setNodeSearchOpen(true)}
projectContext={currentProject?.projectId ? {
...canvasContext,
projectId: currentProject.projectId,
projectName: linkedProjectQuery.data?.project.name || currentProject.title,
} : undefined}
/>
<CanvasNodeSearchModal
@@ -1241,23 +1363,10 @@ function InfiniteCanvasPage() {
<CanvasActiveTaskPanel tasks={activeTasks} />
<CanvasShortDramaGuide progress={shortDramaProgress} collapsed={shortDramaGuideCollapsed} onToggle={() => setShortDramaGuideCollapsed((value) => !value)} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} />
{!currentProject?.projectId ? <CanvasShortDramaGuide progress={shortDramaProgress} collapsed={shortDramaGuideCollapsed} onToggle={() => setShortDramaGuideCollapsed((value) => !value)} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} /> : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<CanvasDocumentEditorModal
node={activeDocumentNode}
open={Boolean(activeDocumentNode)}
saving={documentSaving}
analyzing={documentAnalyzing}
characterAnalyzing={documentCharacterAnalyzing}
onClose={() => setDocumentEditorNodeId(null)}
onSave={(document, title) => { if (activeDocumentNode) void saveDocumentNode(activeDocumentNode, document, title); }}
onAnalyze={(document, title) => activeDocumentNode ? analyzeDocumentNode(activeDocumentNode, document, title) : undefined}
onAnalyzeCharacters={(document, title, chapter) => activeDocumentNode ? analyzeDocumentCharacters(activeDocumentNode, document, title, chapter) : undefined}
onAnalyzeChapter={async (document, chapter, title) => activeDocumentNode ? analyzeDocumentChapter(activeDocumentNode, document, chapter, title) : undefined}
/>
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
<InfiniteCanvas
@@ -1276,6 +1385,7 @@ function InfiniteCanvasPage() {
onFileDragOver={handleFileDragOver}
>
<CanvasProjectWorldLayers
projectId={projectId}
theme={theme}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
@@ -1294,8 +1404,6 @@ function InfiniteCanvasPage() {
frameDropTargetId={frameDropTargetId}
relatedNodeIds={relatedHighlight.nodeIds}
activeNodeId={activeNodeId}
editingNodeId={editingNodeId}
editRequestNonce={editRequestNonce}
selectionBox={selectionBox}
batchChildCountById={batchChildCountById}
collapsingBatchIds={collapsingBatchIds}
@@ -1305,7 +1413,7 @@ function InfiniteCanvasPage() {
reduceMediaEffects={reduceMediaEffects}
resourceReferenceByNodeId={resourceReferenceByNodeId}
mentionReferencesByNodeId={mentionReferencesByNodeId}
angleNode={angleNode}
mediaEffectsDisabledNodeId={emotionNodeId}
selectedNodeBounds={selectedNodeBounds}
isNodeDragging={isNodeDragging}
selectionBoundsElementRef={selectionBoundsElementRef}
@@ -1328,17 +1436,31 @@ function InfiniteCanvasPage() {
onCancelTask={cancelNodeTask}
onOpenTaskDetails={openCanvasNodeTaskDetails}
onOpenVersions={openCanvasNodeVersions}
onGenerateImage={generateImageFromTextNode}
onViewImage={viewCanvasNodeImage}
onReplaceMedia={(node) => handleUploadRequest(node.id)}
onOpenTextEditor={openTextNodeEditor}
onOpenDirector={editCanvasDirector}
onOpenDocument={openTextEditor}
onCloseAngle={() => setAngleNodeId(null)}
onGenerateAngle={(params) => { if (angleNode) void generateAngleNode(angleNode, params); }}
onOpenDrawing={openDrawingNode}
/>
</InfiniteCanvas>
{dialogNode && dialogNode.type !== CanvasNodeType.Script && !selectionBox ? (
{angleNode?.metadata?.content ? (
<CanvasNodePanelOverlay node={angleNode} viewport={viewport} containerRef={containerRef} panelWidth={580} panelHeight={350}>
<CanvasNodeAnglePanel dataUrl={angleNode.metadata.content} onClose={() => setAngleNodeId(null)} onConfirm={(params) => { void generateAngleNode(angleNode, params); }} />
</CanvasNodePanelOverlay>
) : null}
{emotionNode?.metadata?.content ? (
<CanvasEmotionWorkspace
node={emotionNode}
viewport={viewport}
containerRef={containerRef}
onClose={() => setEmotionNodeId(null)}
onConfirm={(payload: CanvasImageEmotionPayload) => { void generateEmotionNode(emotionNode, payload); }}
/>
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
{renderCanvasNodePanel(dialogNode)}
</CanvasNodePanelOverlay>
@@ -1346,9 +1468,9 @@ function InfiniteCanvasPage() {
<CanvasFileDropOverlay active={fileDropActive} theme={theme} />
{!nodes.length ? <CanvasShortDramaEmptyState onCreatePipeline={createShortDramaPipeline} onOpenAgent={() => { setCinematicAgentEntry(true); setAgentMode("online"); openAgent("online"); }} onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} onAddNovel={() => createNovelNode()} onAddScript={() => createNode(CanvasNodeType.Script)} /> : null}
{!nodes.length ? currentProject?.projectId ? <CanvasLinkedProjectEmptyState projectName={linkedProjectQuery.data?.project.name || currentProject.title} hasChapter={Boolean(linkedProjectQuery.data?.units.length)} onAddFirstChapter={() => { const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0]; if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position }); }} onOpenAssets={() => openProjectAssets()} onAddText={() => createNode(CanvasNodeType.Text)} /> : <CanvasShortDramaEmptyState onCreatePipeline={createShortDramaPipeline} onOpenAgent={() => { setCinematicAgentEntry(true); setAgentMode("online"); openAgent("online"); }} onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} onAddScript={() => createNode(CanvasNodeType.Script)} /> : null}
{pendingConnectionCreate ? <CanvasConnectionCreateMenu pending={pendingConnectionCreate} viewport={viewport} viewportSize={size} containerRef={containerRef} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{pendingConnectionCreate ? <CanvasConnectionCreateMenu pending={pendingConnectionCreate} viewport={viewport} viewportSize={size} containerRef={containerRef} canCreateDrawing={canCreateDrawingFromConnection} onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{selectedNodeBounds && !selectionBox && !isNodeDragging ? <CanvasProjectSelectionToolbar anchorRef={selectionBoundsElementRef} containerRef={containerRef} count={selectedNodeBounds.count} selectedVideoCount={selectedVideoNodes.length} mergingVideos={Boolean(mergeVideoProgress)} onAlign={alignSelectedNodes} onArrange={arrangeSelectedNodes} onCreateStoryboard={createStoryboardGroup} onCreateReferenceGroup={createReferenceGroup} onMergeVideos={() => void mergeSelectedVideos()} /> : null}
@@ -1359,14 +1481,14 @@ function InfiniteCanvasPage() {
{lastAgentChange ? <CanvasAgentChangeToast change={lastAgentChange} theme={theme} onView={viewLastAgentChange} onUndo={() => { undoAgentOps(); }} onClose={dismissLastAgentChange} /> : null}
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
node={isNodeDragging || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
containerRef={containerRef}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}
onEditText={openTextEditor}
onInfo={(node) => node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id)}
onEditText={openTextNodeEditor}
onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
@@ -1376,6 +1498,7 @@ function InfiniteCanvasPage() {
onSaveAsset={(node) => void saveNodeAsset(node)}
onAnnotate={(node) => setAnnotationNodeId(node.id)}
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
onEmotion={(node) => { setDialogNodeId(null); setEmotionNodeId((current) => current === node.id ? null : node.id); }}
onCrop={(node) => setCropNodeId(node.id)}
onSplit={(node) => setSplitNodeId(node.id)}
onUpscale={(node) => setUpscaleNodeId(node.id)}
@@ -1394,6 +1517,7 @@ function InfiniteCanvasPage() {
<CanvasToolbar
selectedCount={selectedNodeIds.size}
workspaceMode={workspaceMode}
isProjectLinked={Boolean(currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
backgroundMode={backgroundMode}
@@ -1402,10 +1526,10 @@ function InfiniteCanvasPage() {
onAddVideo={() => createNode(CanvasNodeType.Video)}
onAddAudio={() => createNode(CanvasNodeType.Audio)}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddNovel={createNovelNode}
onChooseStyle={() => setStylePickerOpen(true)}
onAddScript={() => createNode(CanvasNodeType.Script)}
onAddFrame={() => createNode(CanvasNodeType.Frame)}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onAddConfig={() => createNode(CanvasNodeType.Config)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
@@ -1419,29 +1543,31 @@ function InfiniteCanvasPage() {
onOpenMyAssets={() => {
openAssetsAtPosition();
}}
onOpenProjectCharacters={() => openProjectAssets("character")}
/>
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
<div data-canvas-no-zoom className="absolute bottom-4 left-4 z-50 flex items-end gap-2" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoomScale} onReset={resetViewport} isMiniMapOpen={isMiniMapOpen} onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} />
<CanvasAssetTray assetImages={imageAssets} canvasImages={canvasImageNodes} activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null} onInsertAssetImage={(asset) => void createImageAssetNode(asset)} onFocusCanvasImage={focusCanvasImageNode} />
<CanvasAssetTray assetImages={imageAssets} canvasImages={canvasImageNodes} showLibrary={!currentProject?.projectId} activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null} onInsertAssetImage={(asset) => void createImageAssetNode(asset)} onFocusCanvasImage={focusCanvasImageNode} />
</div>
<CanvasProjectContextMenu
menu={contextMenu}
node={contextMenuNode}
workspaceMode={workspaceMode}
isProjectLinked={Boolean(currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
onAddNovel={(position) => createNovelNode(position)}
onOpenDirector={createDirectorShot}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
onOpenAssets={openAssetsAtPosition}
onOpenProjectCharacters={(position) => openProjectAssets("character", position)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onPaste={pasteAtPosition}
@@ -1450,12 +1576,13 @@ function InfiniteCanvasPage() {
onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
onDeleteConnection={deleteConnection}
onSaveAsset={(node) => { void saveNodeAsset(node); }}
onViewImage={(node) => setPreviewNodeId(node.id)}
onEditNode={(node) => { setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); setDialogNodeId(node.id); }}
onEditText={openTextEditor}
onViewMedia={(node) => setPreviewNodeId(node.id)}
onEditText={openTextNodeEditor}
onOpenDrawing={openDrawingNode}
onGenerateImage={generateImageFromTextNode}
onCopyContent={(node) => { void copyNodeContentToClipboard(node); }}
onCopyOssUrl={(node) => { void copyNodeOssUrlToClipboard(node); }}
onCopyMediaUrl={(node) => { void copyNodeMediaUrlToClipboard(node); }}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => toggleFrameCollapsed(node.id)}
/>
@@ -1463,6 +1590,34 @@ function InfiniteCanvasPage() {
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
<CanvasCharacterReferenceModal
node={characterReferenceNode}
open={Boolean(characterReferenceNode)}
onClose={() => setCharacterReferenceNodeId(null)}
/>
<CanvasTextEditorModal
node={textEditorNode}
open={Boolean(textEditorNode)}
onClose={() => setTextEditorNodeId(null)}
onSave={(nodeId, title, content, richText) => {
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node));
}}
/>
{drawingNode ? <Suspense fallback={<div className="fixed inset-0 z-[500] grid place-items-center px-5" style={{ background: theme.canvas.background, color: theme.node.text }}><WorkspaceState icon="loading" title="正在加载绘图编辑器" description="正在准备绘图画布。" /></div>}>
<CanvasDrawingEditorModal
node={drawingNode}
projectId={projectId}
open={Boolean(drawingNode)}
onClose={() => setDrawingNodeId(null)}
onSaved={(nodeId, summary) => {
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount } } : node));
message.success("绘图已保存");
}}
/>
</Suspense> : null}
<CanvasScriptEditor
node={activeScriptNode}
open={Boolean(activeScriptNode)}
@@ -1477,7 +1632,7 @@ function InfiniteCanvasPage() {
/>
{directorNodeId && activeDirectorScene ? (
<Suspense fallback={<div className="fixed inset-0 z-[500] grid place-items-center" style={{ background: theme.canvas.background, color: theme.node.text }}>正在加载 3D 导演台...</div>}>
<Suspense fallback={<div className="fixed inset-0 z-[500] grid place-items-center px-5" style={{ background: theme.canvas.background, color: theme.node.text }}><WorkspaceState icon="loading" title="正在加载 3D 导演台" description="准备场景、镜头与空间控制。" /></div>}>
<CanvasDirectorWorkbench
open
scene={activeDirectorScene}
@@ -1529,7 +1684,8 @@ function InfiniteCanvasPage() {
onInsert={handleAssetInsert}
onClose={closeAssetPicker}
/>
{codexCompactAgent && !assistantMounted ? <CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} /> : null}
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} onClose={closeProjectAssets} onInsert={(payloads) => handleProjectAssetsInsert(payloads, projectAssetInsertPosition)} />
{codexCompactAgent && !assistantMounted ? <CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} /> : null}
</section>
{assistantMounted ? (
<CanvasAssistantPanel
@@ -1543,6 +1699,7 @@ function InfiniteCanvasPage() {
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
onPasteImage={pasteAssistantImage}
agentMode={agentMode}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { App, Button, Result } from "antd";
import { App, Button } from "antd";
import { Clapperboard, Eye, FileText, Image as ImageIcon, LockKeyhole, LogIn, Send, Share2, Video } from "lucide-react";
import { Link, useParams } from "react-router";
import { nanoid } from "nanoid";
@@ -11,6 +11,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canvasThemes } from "@/lib/canvas-theme";
import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
@@ -217,7 +218,7 @@ export default function SharedCanvasPage() {
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;
if (loading) return <FullScreenLoader label="正在打开共享画布" detail="读取节点、连线和视图状态" />;
if (loadError) return <div className="grid h-screen place-items-center" style={{ background: theme.canvas.background }}><Result status="404" title="分享链接不可用" subTitle={loadError} extra={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
return (
<main className="relative h-screen overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
@@ -251,10 +252,10 @@ export default function SharedCanvasPage() {
setContextMenu(null);
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
document.body.style.cursor = "grabbing";
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onGenerateImage={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeHoverToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeHoverToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<div className="absolute bottom-5 left-5 z-[70]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onReset={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[70] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
@@ -6,6 +6,7 @@ import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransf
type UseCanvasAgentOperationsOptions = {
projectId: string;
domainProjectId?: string;
projectTitle: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
@@ -26,12 +27,17 @@ type UseCanvasAgentOperationsOptions = {
};
export type CanvasAgentChange = {
id: string;
summary: string;
nodeIds: string[];
undoCount: number;
};
type CanvasAgentUndoBatch = { snapshot: CanvasAgentSnapshot; afterNodes: CanvasNodeData[]; afterConnections: CanvasConnection[]; change: Omit<CanvasAgentChange, "undoCount"> };
export function useCanvasAgentOperations({
projectId,
domainProjectId,
projectTitle,
nodes,
connections,
@@ -50,16 +56,32 @@ export function useCanvasAgentOperations({
setContextMenu,
focusSelection,
}: UseCanvasAgentOperationsOptions) {
const [undoSnapshot, setUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
const undoStackRef = useRef<CanvasAgentUndoBatch[]>([]);
const [undoOpsCount, setUndoOpsCount] = useState(0);
const [lastAgentChange, setLastAgentChange] = useState<CanvasAgentChange | null>(null);
const snapshot = useMemo<CanvasAgentSnapshot>(
() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }),
[connections, nodes, projectId, projectTitle, selectedNodeIds, viewport],
() => ({ projectId, domainProjectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }),
[connections, domainProjectId, nodes, projectId, projectTitle, selectedNodeIds, viewport],
);
useEffect(() => {
undoStackRef.current = [];
setUndoOpsCount(0);
setLastAgentChange(null);
}, [projectId]);
useEffect(() => {
const latest = undoStackRef.current.at(-1);
if (!latest || (latest.afterNodes === nodes && latest.afterConnections === connections)) return;
// Agent 撤销使用整批快照;用户继续编辑后必须失效,避免覆盖后续手工改动。
undoStackRef.current = [];
setUndoOpsCount(0);
setLastAgentChange(null);
}, [connections, nodes]);
const applyOps = useCallback((ops?: CanvasAgentOp[]) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const before = { projectId, domainProjectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
const beforeNodeIds = new Set(before.nodes.map((node) => node.id));
@@ -72,14 +94,19 @@ export function useCanvasAgentOperations({
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(nextSelectedNodeIds);
viewportRef.current = next.viewport;
setUndoSnapshot(before);
setNodes(next.nodes);
setConnections(next.connections);
setSelectedNodeIds(new Set(nextSelectedNodeIds));
setSelectedConnectionId(null);
setViewport(next.viewport);
setContextMenu(null);
if (safeOps.length) setLastAgentChange({ summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds });
if (safeOps.length) {
const change = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, summary: summarizeCanvasAgentOps(safeOps) || "画布操作已完成", nodeIds: affectedNodeIds };
undoStackRef.current = [...undoStackRef.current, { snapshot: before, afterNodes: next.nodes, afterConnections: next.connections, change }].slice(-10);
const nextUndoCount = undoStackRef.current.length;
setUndoOpsCount(nextUndoCount);
setLastAgentChange({ ...change, undoCount: nextUndoCount });
}
if (focusNodeIds.length) queueMicrotask(() => focusSelection());
if (generationOps.length) {
queueMicrotask(() => generationOps.forEach((op) => {
@@ -89,24 +116,33 @@ export function useCanvasAgentOperations({
}));
}
return { ...next, projectId, title: projectTitle, selectedNodeIds: nextSelectedNodeIds };
}, [connectionsRef, focusSelection, generateNodeRef, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, viewportRef]);
}, [connectionsRef, domainProjectId, focusSelection, generateNodeRef, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, viewportRef]);
const undoOps = useCallback(() => {
if (!undoSnapshot) return null;
nodesRef.current = undoSnapshot.nodes;
connectionsRef.current = undoSnapshot.connections;
selectedNodeIdsRef.current = new Set(undoSnapshot.selectedNodeIds);
viewportRef.current = undoSnapshot.viewport;
setNodes(undoSnapshot.nodes);
setConnections(undoSnapshot.connections);
setSelectedNodeIds(new Set(undoSnapshot.selectedNodeIds));
const batch = undoStackRef.current.at(-1);
if (!batch) return null;
if (batch.afterNodes !== nodesRef.current || batch.afterConnections !== connectionsRef.current) {
undoStackRef.current = [];
setUndoOpsCount(0);
setLastAgentChange(null);
return null;
}
undoStackRef.current.pop();
const restored = batch.snapshot;
nodesRef.current = restored.nodes;
connectionsRef.current = restored.connections;
selectedNodeIdsRef.current = new Set(restored.selectedNodeIds);
viewportRef.current = restored.viewport;
setNodes(restored.nodes);
setConnections(restored.connections);
setSelectedNodeIds(new Set(restored.selectedNodeIds));
setSelectedConnectionId(null);
setViewport(undoSnapshot.viewport);
setViewport(restored.viewport);
setContextMenu(null);
setUndoSnapshot(null);
setUndoOpsCount(undoStackRef.current.length);
setLastAgentChange(null);
return { ...undoSnapshot, projectId, title: projectTitle };
}, [connectionsRef, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, undoSnapshot, viewportRef]);
return { ...restored, projectId, domainProjectId, title: projectTitle };
}, [connectionsRef, domainProjectId, nodesRef, projectId, projectTitle, selectedNodeIdsRef, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setViewport, viewportRef]);
const viewLastAgentChange = useCallback(() => {
if (!lastAgentChange?.nodeIds.length) return;
@@ -119,7 +155,7 @@ export function useCanvasAgentOperations({
queueMicrotask(() => focusSelection());
}, [focusSelection, lastAgentChange, nodesRef, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
return { agentSnapshot: snapshot, applyAgentOps: applyOps, canUndoAgentOps: Boolean(undoSnapshot), dismissLastAgentChange: () => setLastAgentChange(null), lastAgentChange, undoAgentOps: undoOps, viewLastAgentChange };
return { agentSnapshot: snapshot, agentUndoCount: undoOpsCount, applyAgentOps: applyOps, canUndoAgentOps: undoOpsCount > 0, dismissLastAgentChange: () => setLastAgentChange(null), lastAgentChange, undoAgentOps: undoOps, viewLastAgentChange };
}
function agentAffectedNodeIds(ops: CanvasAgentOp[], nodes: CanvasNodeData[]) {

View File

@@ -4,12 +4,14 @@ import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
type UseCanvasConnectionControllerOptions = {
projectId: string;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
viewportRef: { current: ViewportTransform };
@@ -21,6 +23,7 @@ type UseCanvasConnectionControllerOptions = {
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setDrawingNodeId: Dispatch<SetStateAction<string | null>>;
};
type ConnectionDropTarget = {
@@ -34,6 +37,7 @@ const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_IDLE = "idle" as const;
export function useCanvasConnectionController({
projectId,
nodesRef,
connectionsRef,
viewportRef,
@@ -45,6 +49,7 @@ export function useCanvasConnectionController({
setSelectedConnectionId,
setContextMenu,
setDialogNodeId,
setDrawingNodeId,
}: UseCanvasConnectionControllerOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
@@ -98,7 +103,7 @@ export function useCanvasConnectionController({
setContextMenu(null);
}, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => {
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
const metadata = type === CanvasNodeType.Config
@@ -113,6 +118,35 @@ export function useCanvasConnectionController({
message.warning("配置节点之间不能连接");
return;
}
if (type === CanvasNodeType.Drawing) {
const sourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
const sourceUrl = sourceNode?.type === CanvasNodeType.Image ? sourceNode.metadata?.content : "";
if (pending.connection.handleType !== "source" || !sourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
message.error("只有已有图片内容的输出连线可以创建绘图");
return;
}
closeConnectionCreateMenu();
setConnecting(null);
try {
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, {
url: sourceUrl,
storageKey: sourceNode.metadata?.storageKey,
name: sourceNode.title || "来源图片",
mimeType: sourceNode.metadata?.mimeType,
});
newNode.title = `${sourceNode.title || "图片"} · 绘图`;
newNode.metadata = {
...newNode.metadata,
drawingRevision: saved.revision,
drawingUpdatedAt: saved.updatedAt,
drawingShapeCount: saved.shapeCount,
drawingPageCount: saved.pageCount,
};
} catch (error) {
message.error(error instanceof Error ? `创建绘图失败:${error.message}` : "创建绘图失败");
return;
}
}
const fromHandleId = connection.fromNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined;
const toHandleId = connection.toNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined;
const connected = { ...connection, fromHandleId, toHandleId };
@@ -120,10 +154,11 @@ export function useCanvasConnectionController({
setConnections((currentConnections) => [...currentConnections, { id: nanoid(), ...connected }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
if (type === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, nodesRef, setConnecting, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
}, [closeConnectionCreateMenu, effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
const world = screenToCanvas(clientX, clientY);

View File

@@ -1,279 +0,0 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import type { CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { parseDocumentCharacterBreakdown, partitionCharacterBreakdowns, upsertCharacterReferenceBoard } from "@/lib/canvas/canvas-character-reference";
import { createDocumentChapter } from "@/lib/canvas/canvas-document";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { backendProviderConfig, buildGenerationConfig, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { agentSessionFailureMessage, createAgentSession, queryAgentSession } from "@/services/api/task-center";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasDocumentChapter, type CanvasNodeData, type CanvasNodeMetadata, type CanvasRichDocument, type Position, type ViewportTransform } from "@/types/canvas";
type UseCanvasDocumentWorkflowOptions = {
projectId: string;
agentSnapshot: CanvasAgentSnapshot;
applyAgentOps: (ops?: CanvasAgentOp[]) => unknown;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
getCanvasCenter: () => Position;
viewportSize: { width: number; height: number };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setDocumentEditorNodeId: Dispatch<SetStateAction<string | null>>;
setStylePickerOpen: Dispatch<SetStateAction<boolean>>;
setViewport: (viewport: ViewportTransform) => void;
};
const NODE_STATUS_SUCCESS = "success" as const;
export function useCanvasDocumentWorkflow({
projectId,
agentSnapshot,
applyAgentOps,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
getCanvasCenter,
viewportSize,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setDocumentEditorNodeId,
setStylePickerOpen,
setViewport,
}: UseCanvasDocumentWorkflowOptions) {
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const [documentSaving, setDocumentSaving] = useState(false);
const [documentAnalyzing, setDocumentAnalyzing] = useState(false);
const [documentCharacterAnalyzing, setDocumentCharacterAnalyzing] = useState(false);
const commitNodes = useCallback((nodes: CanvasNodeData[]) => {
nodesRef.current = nodes;
setNodes(nodes);
}, [nodesRef, setNodes]);
const selectNode = useCallback((nodeId: string) => {
const selection = new Set([nodeId]);
selectedNodeIdsRef.current = selection;
setSelectedNodeIds(selection);
setSelectedConnectionId(null);
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const persistDocumentNode = useCallback((node: CanvasNodeData, document: CanvasRichDocument, title: string) => {
const persisted = { ...node, title: title || node.title, metadata: { ...node.metadata, content: document.plainText, document, status: NODE_STATUS_SUCCESS, errorDetails: undefined } };
commitNodes(nodesRef.current.map((item) => item.id === node.id ? persisted : item));
return persisted;
}, [commitNodes, nodesRef]);
const createNovelNode = useCallback((position?: Position) => {
const firstChapter = createDocumentChapter("第 1 章");
const node = createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), {
content: "",
status: NODE_STATUS_SUCCESS,
document: {
kind: "novel",
format: "tiptap-json",
json: firstChapter.json,
plainText: "",
characterCount: 0,
chapters: [firstChapter],
activeChapterId: firstChapter.id,
updatedAt: new Date().toISOString(),
},
fontSize: 13,
});
node.title = "小说 · 未命名";
node.width = 460;
node.height = 300;
commitNodes([...nodesRef.current, node]);
selectNode(node.id);
setDialogNodeId(null);
}, [commitNodes, getCanvasCenter, nodesRef, selectNode, setDialogNodeId]);
const selectCanvasStyle = useCallback((preset: CanvasStylePreset) => {
const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
const styleMetadata: CanvasNodeMetadata = {
content: preset.prompt,
prompt: preset.prompt,
status: NODE_STATUS_SUCCESS,
workflowKind: "styleboard",
workflowTitle: "项目画风",
workflowDescription: preset.description,
stylePresetId: preset.id,
fontSize: 14,
};
let styleNode: CanvasNodeData;
if (current) {
styleNode = { ...current, title: `画风 · ${preset.title}`, metadata: { ...current.metadata, ...styleMetadata } };
commitNodes(nodesRef.current.map((node) => node.id === current.id ? styleNode : node));
} else {
styleNode = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), styleMetadata);
styleNode.title = `画风 · ${preset.title}`;
styleNode.width = 420;
styleNode.height = 240;
commitNodes([...nodesRef.current, styleNode]);
}
selectNode(styleNode.id);
setDialogNodeId(null);
setStylePickerOpen(false);
message.success(`已应用“${preset.title}”画风`);
}, [commitNodes, getCanvasCenter, message, nodesRef, selectNode, setDialogNodeId, setStylePickerOpen]);
const saveDocumentNode = useCallback(async (node: CanvasNodeData, document: CanvasRichDocument, title: string) => {
setDocumentSaving(true);
try {
persistDocumentNode(node, document, title);
} finally {
setDocumentSaving(false);
}
}, [persistDocumentNode]);
const analyzeDocumentNode = useCallback(async (node: CanvasNodeData, document: CanvasRichDocument, title: string) => {
if (!document.plainText.trim()) return;
persistDocumentNode(node, document, title);
if (!isAiConfigReady(effectiveConfig, effectiveConfig.textModel || effectiveConfig.model)) {
openConfigDialog(true);
return;
}
setDocumentAnalyzing(true);
try {
const textModel = effectiveConfig.textModel || effectiveConfig.model;
const requestConfig = resolveModelRequestConfig(effectiveConfig, textModel);
const prompt = [
"请把下面的小说整理成可直接回写无限画布的影视项目工作流。",
"必须生成剧本、场景、风格板、参考素材组、结构化分镜、镜头配置和成片节点;场景名称和镜头顺序要稳定,分镜要能直接用于图片和视频生成。角色视觉资产由小说编辑器中的“角色拆解”单独生成,本工作流不得创建角色卡或角色文本节点。",
"如果当前画布已有 metadata.workflowKind=styleboard 的项目画风节点,必须复用该画风并连接到分镜或生成节点,不要重复创建风格板。",
`项目标题:${title}`,
"小说正文:",
document.plainText,
].join("\n\n");
const created = await createAgentSession({
projectId,
prompt,
canvasSnapshot: { ...agentSnapshot, nodes: nodesRef.current.map((item) => ({ ...item, metadata: { ...item.metadata, content: String(item.metadata?.content || "").slice(0, 500) } })) } as unknown as Record<string, unknown>,
config: backendProviderConfig({ ...effectiveConfig, model: requestConfig.model }),
});
let detail = created;
for (let attempt = 0; attempt < 120; attempt += 1) {
if (detail.session.status === "completed") break;
if (detail.session.status === "failed") throw new Error(agentSessionFailureMessage(detail));
await new Promise<void>((resolve) => window.setTimeout(resolve, 2000));
detail = await queryAgentSession(created.session.id);
}
if (detail.session.status !== "completed") throw new Error("小说拆解超时,请到任务中心查看状态");
if (!detail.session.canvasOpsJson) throw new Error("小说拆解没有返回画布操作");
const parsed = JSON.parse(detail.session.canvasOpsJson) as unknown;
const ops = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray((parsed as { ops?: unknown }).ops) ? (parsed as { ops: CanvasAgentOp[] }).ops : null;
if (!ops) throw new Error("小说拆解结果格式不正确");
applyAgentOps(ops);
message.success("小说已拆解,剧本、场景和分镜已回写画布;角色视觉资产请使用“角色拆解”生成");
setDocumentEditorNodeId(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "小说拆解失败");
} finally {
setDocumentAnalyzing(false);
}
}, [agentSnapshot, applyAgentOps, effectiveConfig, isAiConfigReady, message, nodesRef, openConfigDialog, persistDocumentNode, projectId, setDocumentEditorNodeId]);
const analyzeDocumentCharacters = useCallback(async (node: CanvasNodeData, document: CanvasRichDocument, title: string, chapter?: CanvasDocumentChapter) => {
const sourceText = (chapter?.plainText || document.plainText).trim();
if (!sourceText) return;
persistDocumentNode(node, document, title);
const styleNode = nodesRef.current.find((item) => item.metadata?.workflowKind === "styleboard" && item.metadata?.stylePresetId) || nodesRef.current.find((item) => item.metadata?.workflowKind === "styleboard");
const projectStyle = (styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim();
if (!projectStyle) {
message.warning("请先选择项目画风,再拆解角色");
setStylePickerOpen(true);
return;
}
const generationConfig = buildGenerationConfig(effectiveConfig, node, "text");
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
setDocumentCharacterAnalyzing(true);
try {
const sourceLabel = chapter ? `“${chapter.title}”` : "整本小说";
const prompt = [
`请从短剧项目《${title}》的${sourceLabel}中拆解需要建立视觉资产的角色,忽略只出现一次且不影响剧情的路人。`,
"合并同一角色的姓名、专属称谓和别名,角色名称必须简短稳定;aliases 只填写能唯一指向该角色的别名,不要填写爸爸、妈妈、姐姐、师父等可能指向多人的泛称。人物身份、年龄、外貌、服装、体态和道具优先依据正文,正文未明确时只能结合身份做克制补全。",
"下面的项目画风是全项目美术规范。角色外貌、服装材质、色彩、时代、渲染媒介和禁止项必须遵守它,但不要把项目画风误写成人物身份或剧情信息。",
`【项目画风】\n${projectStyle}`,
"为每个角色给出一条单张多视角转面参考图的补充提示词。转面图在同一画面依次展示正面全身、严格左侧面全身、严格背面全身,必须是同一角色、同一服装版本、同一发型、同一体型和同一道具,不设计剧情动作或镜头场景。",
"只返回 JSON,不要 Markdown 代码块、解释或其他文字。JSON 结构必须严格为:",
'{"characters":[{"name":"角色名","aliases":["别名或称谓"],"role":"剧情定位与人物关系","appearance":"年龄、脸型、五官、肤色、发型等稳定外貌","clothing":"固定服装版型、颜色、纹样和材质","physique":"身高、头身、体型和体态","personality":"稳定气质与表演基线","props":"固定道具及佩戴位置,没有则为空字符串","consistencyPrompt":"跨图片和镜头必须保持不变的角色约束","multiViewPrompt":"正面、侧面、背面转面展示需要强调的角色结构细节"}]}',
`${sourceLabel}正文:`,
sourceText,
].join("\n\n");
const result = await runBackendCanvasGenerationTask({
projectId,
nodeId: `character-breakdown:${node.id}:${chapter?.id || "document"}`,
mode: "text",
prompt,
config: generationConfig,
metadata: { sourceNodeId: node.id, chapterId: chapter?.id, operation: "character_breakdown" },
});
const characters = parseDocumentCharacterBreakdown(result.text || "");
const partitioned = partitionCharacterBreakdowns(characters, nodesRef.current);
let updateExisting = true;
if (partitioned.existing.length) {
updateExisting = await new Promise<boolean>((resolve) => {
modal.confirm({
title: `发现 ${partitioned.existing.length} 个已有角色`,
content: `画布中已存在:${partitioned.existing.map((character) => character.name).join("、")}。更新会保留已经生成的图片,只刷新角色提示词和背板归组。`,
okText: "更新已有角色",
cancelText: "仅创建新角色",
maskClosable: false,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
const processedCharacters = updateExisting ? characters : partitioned.newCharacters;
if (!processedCharacters.length) {
message.info("拆解出的角色在画布中均已存在,未创建重复角色");
return;
}
const upserted = upsertCharacterReferenceBoard({ nodes: nodesRef.current, connections: connectionsRef.current, documentNode: node, documentTitle: title, chapter, characters: processedCharacters, projectStyle });
nodesRef.current = upserted.nodes;
connectionsRef.current = upserted.connections;
setNodes(upserted.nodes);
setConnections(upserted.connections);
selectNode(upserted.frameId);
setDialogNodeId(null);
setDocumentEditorNodeId(null);
const frame = upserted.nodes.find((item) => item.id === upserted.frameId);
if (frame) {
const scale = Math.min(1, Math.max(0.45, Math.min((viewportSize.width - 120) / Math.max(1, frame.width), (viewportSize.height - 120) / Math.max(1, frame.height))));
setViewport({ x: viewportSize.width / 2 - (frame.position.x + frame.width / 2) * scale, y: viewportSize.height / 2 - (frame.position.y + frame.height / 2) * scale, k: scale });
}
message.success(`角色拆解完成:新建 ${upserted.createdCount} 个、更新 ${upserted.updatedCount} 个角色多视角待生成图片节点,已统一放入背板`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色拆解失败");
} finally {
setDocumentCharacterAnalyzing(false);
}
}, [connectionsRef, effectiveConfig, isAiConfigReady, message, modal, nodesRef, openConfigDialog, persistDocumentNode, projectId, selectNode, setConnections, setDialogNodeId, setDocumentEditorNodeId, setNodes, setStylePickerOpen, setViewport, viewportSize.height, viewportSize.width]);
return {
analyzeDocumentCharacters,
analyzeDocumentNode,
createNovelNode,
documentAnalyzing,
documentCharacterAnalyzing,
documentSaving,
saveDocumentNode,
selectCanvasStyle,
};
}

View File

@@ -7,6 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { generationFailureMetadata } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { UpdreamSkill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -18,6 +19,7 @@ import { executeTextGeneration } from "./canvas-text-generation-executor";
type UseCanvasGenerationExecutorOptions = {
projectId: string;
domainProjectId?: string;
activatedSkills: UpdreamSkill[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
@@ -43,6 +45,7 @@ export type CanvasNodeGenerationOptions = {
export function useCanvasGenerationExecutor({
projectId,
domainProjectId,
activatedSkills,
nodesRef,
connectionsRef,
@@ -59,7 +62,6 @@ export function useCanvasGenerationExecutor({
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
return useCallback(
async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => {
@@ -68,9 +70,9 @@ export function useCanvasGenerationExecutor({
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
}
const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
@@ -106,6 +108,8 @@ export function useCanvasGenerationExecutor({
try {
rawGenerationContext = await hydrateNodeGenerationContext(
buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt),
projectId,
domainProjectId,
);
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成任务准备失败";
@@ -121,6 +125,22 @@ export function useCanvasGenerationExecutor({
const expandedPrompt = expandSkillMentions(rawGenerationContext.prompt, activatedSkills);
const effectivePrompt = expandedPrompt.trim();
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (controller.signal.aborted) {
if (isPreparingEmptyImage) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined } } : node)));
finishGenerationRequest(nodeId, controller);
@@ -191,6 +211,6 @@ export function useCanvasGenerationExecutor({
setRunningNodeId(null);
}
},
[activatedSkills, bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, openConfigDialog, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
[activatedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
);
}

View File

@@ -6,6 +6,7 @@ import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-p
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { buildEmotionImageArtifacts, compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import {
buildAudioGenerationMetadata,
buildGenerationConfig,
@@ -21,16 +22,18 @@ import {
} from "@/lib/canvas/canvas-project-generation";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
import type { UpdreamSkill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { uploadImage } from "@/services/image-storage";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
type UseCanvasGenerationRetryOptions = {
projectId: string;
domainProjectId?: string;
activatedSkills: UpdreamSkill[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
@@ -47,11 +50,10 @@ const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
export function useCanvasGenerationRetry({ projectId, domainProjectId, activatedSkills, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
return useCallback(
async (node: CanvasNodeData) => {
@@ -60,12 +62,12 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef,
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
const retryMode: CanvasNodeGenerationMode = node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image";
const generationConfig =
let generationConfig =
hasSavedImageMetadata && savedImageMetadata
? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground) === "true" ? "true" : "false", count: "1" }
: { ...buildGenerationConfig(effectiveConfig, sourceNode, retryMode), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
@@ -74,16 +76,42 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef,
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
return;
}
const rawContext = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryPromptSource));
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
try {
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryPromptSource);
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId);
} catch (error) {
const failure = generationFailureMetadata(error, retryPromptSource);
message.error(failure.errorDetails);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item)));
return;
}
const context = rawContext ? { ...rawContext, prompt: expandSkillMentions(rawContext.prompt, activatedSkills) } : null;
const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim();
const prompt = (context?.characterReferences.length ? context.prompt : savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
return;
}
if (retryMode === "audio" && context?.characterReferences.length) {
if (context.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = context.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法重试角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
const generationType = savedImageMetadata?.generationType;
const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages = hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
const isEmotionRetry = Boolean(node.metadata?.emotionEdit);
if (isEmotionRetry && resolveModelRequestConfig(generationConfig, generationConfig.model).interfaceType !== "openai-image") {
message.error("表情编辑需要支持蒙版的 OpenAI Images 渠道,当前渠道已拒绝整图重绘");
return;
}
const useReferenceImages = isEmotionRetry ? false : context?.characterReferences.length ? true : generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages = isEmotionRetry ? [] : hasSavedImageMetadata && savedImageMetadata && !context?.characterReferences.length ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
if (useReferenceImages && !retryReferenceImages) {
markMissingReferences(node.id, setNodes);
message.error("参考图片已丢失,无法继续重试");
@@ -106,14 +134,14 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef,
try {
if (node.type === CanvasNodeType.Text) {
if (!context) return;
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "text", prompt, config: generationConfig, referenceImages: context.referenceImages, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "text", prompt, config: generationConfig, referenceImages: context.referenceImages, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.text) throw new Error("后端任务没有返回文本");
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: result.text, prompt, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : item)));
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: result.text, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } } : item)));
return;
}
if (node.type === CanvasNodeType.Video) {
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
@@ -121,14 +149,38 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef,
return;
}
if (node.type === CanvasNodeType.Audio) {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "audio", prompt, config: generationConfig, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "audio", prompt, config: generationConfig, signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoiceKey: context?.resolvedCharacterVoices[0]?.voiceKey }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
if (!result.audio?.dataUrl) throw new Error("后端任务没有返回音频");
const audio = await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), generationConfig.audioFormat);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...audioMetadata(audio), prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
return;
}
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const emotionEdit = node.metadata?.emotionEdit;
if (emotionEdit) {
const emotionSource = nodesRef.current.find((item) => item.id === emotionEdit.sourceNodeId);
if (!emotionSource?.metadata?.content) throw new Error("情绪编辑源图片已删除,无法重试");
const sourceDataUrl = await resolveImageUrl(emotionSource.metadata.storageKey, emotionSource.metadata.content, { cacheMiss: true });
if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片");
const artifacts = await buildEmotionImageArtifacts(sourceDataUrl, emotionEdit.faceBox, emotionSource.metadata.naturalWidth || 0, emotionSource.metadata.naturalHeight || 0);
const emotionConfig = { ...generationConfig, size: emotionGenerationSize(artifacts.editRegion), quality: !generationConfig.quality || generationConfig.quality === "auto" ? "high" : generationConfig.quality };
const sourceReference = sourceNodeReferenceImages(emotionSource)[0];
if (!sourceReference) throw new Error("情绪编辑源图片不可用");
const editReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-edit-region`, name: "emotion-edit-region.png", type: "image/png", dataUrl: artifacts.sourceDataUrl };
const characterReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-character`, name: `${emotionEdit.characterName}-face.jpg`, type: "image/jpeg", dataUrl: artifacts.characterDataUrl };
const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size };
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: emotionConfig, referenceImages: [editReference, characterReference], mask: { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl }, signal: controller.signal, metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [] }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const composited = await compositeEmotionImage(sourceDataUrl, image.dataUrl, artifacts.editRegion, emotionEdit.faceBox);
const uploadedImage = await uploadImage(composited);
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, node.width, node.height);
const generationMetadata = { ...buildImageGenerationMetadata("edit", emotionConfig, 1, [sourceReference]), size: `${artifacts.imageWidth}x${artifacts.imageHeight}` };
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Image, width: imageSize.width, height: imageSize.height, metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata, emotionEdit: nextEmotionEdit } } : item)));
return;
}
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [] }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploadedImage = await uploadImage(image.dataUrl);
@@ -148,7 +200,7 @@ export function useCanvasGenerationRetry({ projectId, activatedSkills, nodesRef,
setRunningNodeId(null);
}
},
[activatedSkills, bindGenerationTask, connectionsRef, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, openConfigDialog, projectId, setNodes, setRunningNodeId, startGenerationRequest],
[activatedSkills, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { getDataUrlByteSize } from "@/lib/image-utils";
import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { useAssetStore } from "@/stores/use-asset-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { cancelGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, waitForGenerationTask, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
@@ -19,6 +19,7 @@ type CanvasGenerationRequest = {
type UseCanvasGenerationOptions = {
projectId: string;
domainProjectId?: string;
projectLoaded: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
@@ -30,9 +31,9 @@ const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGeneration({ projectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) {
export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) {
const { message, modal } = App.useApp();
const addAsset = useAssetStore((state) => state.addAsset);
const queryClient = useQueryClient();
const generationRequestsRef = useRef(new Map<string, CanvasGenerationRequest>());
const recoveringTaskIdsRef = useRef(new Set<string>());
const autoSavedTaskIdsRef = useRef(new Set<string>());
@@ -146,35 +147,11 @@ export function useCanvasGeneration({ projectId, projectLoaded, nodes, nodesRef,
}));
}, [setNodes]);
const addGeneratedAsset = useCallback((node: CanvasNodeData, taskId: string) => {
if (!node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS) return;
const exists = useAssetStore.getState().assets.some((asset) => asset.metadata?.taskId === taskId || (asset.metadata?.source === "canvas-generation" && asset.metadata?.nodeId === node.id));
if (exists) return;
if (node.type === CanvasNodeType.Image) {
const dataUrl = node.metadata.storageKey ? "" : node.metadata.content;
addAsset({
kind: "image",
title: node.metadata.prompt?.slice(0, 24) || node.title || "画布图片",
coverUrl: node.metadata.content,
tags: [],
source: "Canvas",
data: { dataUrl, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), mimeType: node.metadata.mimeType || "image/png" },
metadata: { source: "canvas-generation", nodeId: node.id, taskId, prompt: node.metadata.prompt },
});
return;
}
if (node.type === CanvasNodeType.Video) {
addAsset({
kind: "video",
title: node.metadata.prompt?.slice(0, 24) || node.title || "画布视频",
coverUrl: "",
tags: [],
source: "Canvas",
data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" },
metadata: { source: "canvas-generation", nodeId: node.id, taskId, prompt: node.metadata.prompt },
});
}
}, [addAsset]);
const saveGeneratedAsset = useCallback(async (node: CanvasNodeData, taskId: string) => {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId });
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
}, [domainProjectId, projectId, queryClient, setNodes]);
const applyGenerationTaskResult = useCallback(async (nodeId: string, task: GenerationTask) => {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
@@ -228,13 +205,16 @@ export function useCanvasGeneration({ projectId, projectLoaded, nodes, nodesRef,
if (!projectLoaded) return;
nodes.forEach((node) => {
const taskId = node.metadata?.taskId;
if (!taskId || !node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS || (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video)) return;
const saveKey = `${taskId}:${node.id}`;
if (!taskId || !node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS || (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio)) return;
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}`;
if (autoSavedTaskIdsRef.current.has(saveKey)) return;
autoSavedTaskIdsRef.current.add(saveKey);
addGeneratedAsset(node, taskId);
void saveGeneratedAsset(node, taskId).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey);
message.warning(error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败");
});
});
}, [addGeneratedAsset, nodes, projectLoaded]);
}, [domainProjectId, message, nodes, projectLoaded, saveGeneratedAsset]);
return {
bindGenerationTask,

View File

@@ -7,6 +7,7 @@ import type { CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node
import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
@@ -14,18 +15,20 @@ import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canva
import {
buildGenerationConfig,
buildImageGenerationMetadata,
isGenerationCanceled,
nodeReferenceImage,
isGenerationCanceled,
runBackendCanvasGenerationTask,
} from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getMediaBlob } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import type { GenerationTask } from "@/services/api/task-center";
import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas";
import type { StartCanvasUploadStatus } from "./use-canvas-upload";
@@ -83,7 +86,6 @@ export function useCanvasMediaTools({
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const extractingVideoFrameNodeIdRef = useRef<string | null>(null);
const mergeVideoRunningRef = useRef(false);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
@@ -92,6 +94,7 @@ export function useCanvasMediaTools({
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [emotionNodeId, setEmotionNodeId] = useState<string | null>(null);
const [extractingVideoFrameNodeId, setExtractingVideoFrameNodeId] = useState<string | null>(null);
const [mergeVideoProgress, setMergeVideoProgress] = useState<MergeVideoProgress | null>(null);
@@ -284,7 +287,7 @@ export function useCanvasMediaTools({
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
const userPrompt = payload.prompt.trim();
@@ -317,7 +320,7 @@ export function useCanvasMediaTools({
finishGenerationRequest(childId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
if (!node.metadata?.content) return;
@@ -337,7 +340,7 @@ export function useCanvasMediaTools({
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
const childId = nanoid();
@@ -369,10 +372,62 @@ export function useCanvasMediaTools({
finishGenerationRequest(childId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, openConfigDialog, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
if (!node.metadata?.content) return;
const baseConfig = buildGenerationConfig(effectiveConfig, node, "image");
const providerSize = emotionGenerationSize(payload.editRegion);
const generationConfig = { ...baseConfig, count: "1", size: providerSize, quality: !baseConfig.quality || baseConfig.quality === "auto" ? "high" : baseConfig.quality };
if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); return; }
if (resolveModelRequestConfig(generationConfig, generationConfig.model).interfaceType !== "openai-image") {
message.error("表情编辑需要支持蒙版的 OpenAI Images 渠道,当前渠道已拒绝整图重绘");
return;
}
const source = nodeReferenceImage(node);
if (!source) return;
const editReference = {
id: `${node.id}-${payload.presetId}-edit-region`,
name: "emotion-edit-region.png",
type: "image/png",
dataUrl: payload.sourceDataUrl,
};
const characterReference = {
id: `${node.id}-${payload.presetId}-character`,
name: `${payload.characterName}-face.jpg`,
type: "image/jpeg",
dataUrl: payload.characterDataUrl,
};
const childId = nanoid();
const generationMetadata = { ...buildImageGenerationMetadata("edit", generationConfig, 1, [source]), size: `${payload.imageWidth}x${payload.imageHeight}` };
const emotionEdit = { sourceNodeId: node.id, characterName: payload.characterName, presetId: payload.presetId, intimacy: payload.intimacy, arousal: payload.arousal, label: payload.label, faceBox: payload.faceBox, editRegion: payload.editRegion, sourceWidth: payload.imageWidth, sourceHeight: payload.imageHeight, providerSize };
setEmotionNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: `${payload.characterName} · ${payload.label}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: payload.prompt, status: NODE_STATUS_LOADING, ...generationMetadata, emotionEdit } }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: payload.prompt, config: generationConfig, referenceImages: [editReference, characterReference], mask: { id: `${node.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "emotion", emotion: emotionEdit }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const composited = await compositeEmotionImage(node.metadata.content, image.dataUrl, payload.editRegion, payload.faceBox);
const uploaded = await uploadImage(composited);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: payload.prompt, ...generationMetadata, emotionEdit } } : item));
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = error instanceof Error ? error.message : "表情生成失败";
message.error(details);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
} finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); }
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
return {
angleNodeId,
emotionNodeId,
annotationNodeId,
createImageReversePromptNodes,
cropImageNode,
@@ -387,6 +442,8 @@ export function useCanvasMediaTools({
mergeVideoProgress,
saveAnnotatedImageNode,
setAngleNodeId,
generateEmotionNode,
setEmotionNodeId,
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,

View File

@@ -1,43 +1,41 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getDataUrlByteSize } from "@/lib/image-utils";
import { FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
import { applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { useAssetStore } from "@/stores/use-asset-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
canvasId: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setDocumentEditorNodeId: Dispatch<SetStateAction<string | null>>;
setEditingNodeId: Dispatch<SetStateAction<string | null>>;
setEditRequestNonce: Dispatch<SetStateAction<number>>;
setToolbarNodeId: Dispatch<SetStateAction<string | null>>;
setHoveredNodeId: Dispatch<SetStateAction<string | null>>;
};
export function useCanvasNodeEditor({
canvasId,
domainProjectId,
nodesRef,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setDocumentEditorNodeId,
setEditingNodeId,
setEditRequestNonce,
setToolbarNodeId,
setHoveredNodeId,
}: UseCanvasNodeEditorOptions) {
const { message } = App.useApp();
const addAsset = useAssetStore((state) => state.addAsset);
const queryClient = useQueryClient();
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
@@ -96,7 +94,7 @@ export function useCanvasNodeEditor({
}, [setNodes]);
const handleNodeContentChange = useCallback((nodeId: string, content: string) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node)));
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content, richText: undefined } } : node)));
}, [setNodes]);
const toggleBatchExpanded = useCallback((nodeId: string) => {
@@ -125,20 +123,6 @@ export function useCanvasNodeEditor({
);
}, [setNodes]);
const openTextEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
if (node.metadata?.document) {
setDocumentEditorNodeId(node.id);
setDialogNodeId(null);
return;
}
setDialogNodeId(node.id);
setEditingNodeId(node.id);
setEditRequestNonce((value) => value + 1);
}, [setDialogNodeId, setDocumentEditorNodeId, setEditRequestNonce, setEditingNodeId, setSelectedConnectionId, setSelectedNodeIds]);
const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => {
setNodes((current) => current.map((node) => {
if (node.id !== nodeId) return node;
@@ -154,7 +138,18 @@ export function useCanvasNodeEditor({
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
}, [setNodes]);
if (!patch.assetCategory) return;
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node?.metadata?.content?.trim()) return;
const updatedNode = applyNodeConfigPatch(node, patch);
void ensureCanvasNodeAsset({ canvasId, domainProjectId, node: updatedNode, source: "canvas-manual", category: patch.assetCategory })
.then(async (result) => {
setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
message.success("资产分类已更新");
})
.catch((error) => message.error(error instanceof Error ? error.message : "资产分类更新失败"));
}, [canvasId, domainProjectId, message, nodesRef, queryClient, setNodes]);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
@@ -162,24 +157,17 @@ export function useCanvasNodeEditor({
}, []);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.type === CanvasNodeType.Text) {
const content = node.metadata?.content?.trim();
if (!content) return message.error("没有可保存的文本");
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
message.success("已加入我的素材");
return;
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");
if (!node.metadata?.content?.trim()) return message.error("当前节点没有可保存的内容");
try {
const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-manual" });
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
message.success(result.linkedToProject ? "已加入项目资产" : "已加入我的素材");
} catch (error) {
message.error(error instanceof Error ? error.message : "素材保存失败");
}
if (node.type === CanvasNodeType.Video) {
if (!node.metadata?.content) return message.error("没有可保存的视频");
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
message.success("已加入我的素材");
return;
}
if (!node.metadata?.content) return message.error("没有可保存的图片");
const dataUrl = node.metadata.storageKey ? "" : node.metadata.content;
addAsset({ kind: "image", title: node.metadata?.prompt?.slice(0, 24) || "画布图片", coverUrl: node.metadata.content, tags: [], source: "Canvas", data: { dataUrl, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), mimeType: node.metadata.mimeType || "image/png" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
message.success("已加入我的素材");
}, [addAsset, message]);
}, [canvasId, domainProjectId, message, queryClient, setNodes]);
const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => {
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node)));
@@ -194,7 +182,6 @@ export function useCanvasNodeEditor({
handleNodePromptChange,
handleNodeResize,
handleNodeTitleChange,
openTextEditor,
openingBatchIds,
saveNodeAsset,
setBatchPrimary,

View File

@@ -8,6 +8,7 @@ import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project
import { getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
type CanvasClipboard = {
nodes: CanvasNodeData[];
@@ -15,6 +16,7 @@ type CanvasClipboard = {
};
type UseCanvasNodeOperationsOptions = {
projectId: string;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
selectedNodeIdsRef: { current: Set<string> };
@@ -25,13 +27,14 @@ type UseCanvasNodeOperationsOptions = {
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
onNodesDeleted: (removedIds: Set<string>, nextNodes: CanvasNodeData[]) => void;
onNodesDeleted: (removedIds: Set<string>, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => void;
};
const NODE_STATUS_IDLE = "idle" as const;
const NODE_STATUS_SUCCESS = "success" as const;
export function useCanvasNodeOperations({
projectId,
nodesRef,
connectionsRef,
selectedNodeIdsRef,
@@ -59,6 +62,29 @@ export function useCanvasNodeOperations({
setConnections(nextConnections);
}, [connectionsRef, setConnections]);
const cloneDrawingForNode = useCallback((source: CanvasNodeData, target: CanvasNodeData, failureMessage: string) => {
const sourceDrawingId = source.metadata?.drawingId;
const targetDrawingId = target.metadata?.drawingId;
if (!projectId || !sourceDrawingId || !targetDrawingId) return;
void cloneCanvasDrawing(projectId, sourceDrawingId, targetDrawingId).then((saved) => {
if (!saved) {
if (source.metadata?.drawingShapeCount) message.warning("原绘图内容未在本机找到,已创建空白副本");
return;
}
// 克隆落盘后提升修订号,确保已经挂载的新卡片重新读取派生预览。
commitNodes(nodesRef.current.map((node) => node.id === target.id ? {
...node,
metadata: {
...node.metadata,
drawingRevision: saved.revision,
drawingUpdatedAt: saved.updatedAt,
drawingShapeCount: saved.shapeCount,
drawingPageCount: saved.pageCount,
},
} : node));
}).catch(() => message.error(failureMessage));
}, [commitNodes, message, nodesRef, projectId]);
const selectNodes = useCallback((ids: Set<string>) => {
selectedNodeIdsRef.current = ids;
setSelectedNodeIds(ids);
@@ -76,7 +102,7 @@ export function useCanvasNodeOperations({
const node = createCanvasNode(type, position || getCanvasCenter(), configMetadata);
commitNodes([...nodesRef.current, node]);
selectNodes(new Set([node.id]));
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame) setDialogNodeId(node.id);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
}, [commitNodes, effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter, nodesRef, selectNodes, setDialogNodeId]);
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
@@ -208,11 +234,12 @@ export function useCanvasNodeOperations({
const deleteNodes = useCallback((ids: Set<string>) => {
if (!ids.size) return;
const result = removeCanvasNodes(nodesRef.current, ids);
const removedNodes = nodesRef.current.filter((node) => result.removedIds.has(node.id));
const nextConnections = connectionsRef.current.filter((connection) => !result.removedIds.has(connection.fromNodeId) && !result.removedIds.has(connection.toNodeId));
commitNodes(result.nodes);
commitConnections(nextConnections);
selectNodes(new Set());
onNodesDeleted(result.removedIds, result.nodes);
onNodesDeleted(result.removedIds, result.nodes, removedNodes);
}, [commitConnections, commitNodes, connectionsRef, nodesRef, onNodesDeleted, selectNodes]);
const deleteConnection = useCallback((connectionId: string) => {
@@ -230,6 +257,13 @@ export function useCanvasNodeOperations({
const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined;
const copiedNodes = sources.map((node) => {
const metadata: CanvasNodeMetadata = { ...node.metadata, frame: node.metadata?.frame ? { ...node.metadata.frame } : undefined };
if (node.type === CanvasNodeType.Drawing) {
metadata.drawingId = `${idMap.get(node.id)}-document`;
metadata.drawingRevision = 0;
metadata.drawingUpdatedAt = undefined;
metadata.drawingShapeCount = 0;
metadata.drawingPageCount = 1;
}
if (node.id === source.id && versionRootId) {
delete metadata.taskId;
delete metadata.taskStatus;
@@ -267,8 +301,13 @@ export function useCanvasNodeOperations({
commitNodes(nextNodes);
commitConnections([...connectionsRef.current, ...copiedConnections]);
selectNodes(new Set([id]));
if (!isFrameNode(source)) setDialogNodeId(id);
}, [commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]);
const sourceByTargetId = new Map(sources.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode]));
copiedNodes.filter((node) => node.type === CanvasNodeType.Drawing).forEach((targetNode) => {
const sourceNode = sourceByTargetId.get(targetNode.id);
if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新复制");
});
if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing) setDialogNodeId(id);
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]);
const setPrimaryVersion = useCallback((nodeId: string) => {
const target = nodesRef.current.find((node) => node.id === nodeId);
@@ -318,7 +357,9 @@ export function useCanvasNodeOperations({
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
position: { x: node.position.x + dx, y: node.position.y + dy },
parentId: node.parentId ? idMap.get(node.parentId) : undefined,
metadata: node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined,
metadata: node.type === CanvasNodeType.Drawing
? { ...node.metadata, frame: node.metadata?.frame ? { ...node.metadata.frame } : undefined, drawingId: `${idMap.get(node.id)}-document`, drawingRevision: 0, drawingUpdatedAt: undefined, drawingShapeCount: 0, drawingPageCount: 1 }
: node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined,
}));
const nextConnections = clipboard.connections.flatMap((connection, index) => {
const fromNodeId = idMap.get(connection.fromNodeId);
@@ -327,13 +368,18 @@ export function useCanvasNodeOperations({
});
commitNodes([...nodesRef.current, ...nextNodes]);
commitConnections([...connectionsRef.current, ...nextConnections]);
const sourceByTargetId = new Map(clipboard.nodes.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode]));
nextNodes.filter((node) => node.type === CanvasNodeType.Drawing).forEach((targetNode) => {
const sourceNode = sourceByTargetId.get(targetNode.id);
if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新粘贴");
});
const topLevelIds = new Set(nextNodes.filter((node) => !node.parentId).map((node) => node.id));
selectNodes(topLevelIds);
setContextMenu(null);
const primaryNode = nextNodes.find((node) => !node.parentId);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) ? primaryNode.id : null);
setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing ? primaryNode.id : null);
return true;
}, [commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]);
}, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]);
return {
alignSelectedNodes,

View File

@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRe
import { App } from "antd";
import { useNavigate } from "react-router";
import { removeLegacyCharacterCards } from "@/lib/canvas/canvas-character-reference";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listActivatedSkills, type UpdreamSkill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
@@ -87,11 +87,9 @@ export function useCanvasProjectLifecycle({
const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => {
if (cancelled) return;
// 历史角色卡迁移只清理废弃中间节点,生成后的角色图片继续保留。
const migrated = removeLegacyCharacterCards(restoredNodes, project.connections);
const snapshot: CanvasHistorySnapshot = {
nodes: migrated.nodes,
connections: migrated.connections,
nodes: restoredNodes,
connections: project.connections,
chatSessions: restoredSessions,
activeChatId: project.activeChatId || null,
backgroundMode: project.backgroundMode,
@@ -112,8 +110,7 @@ export function useCanvasProjectLifecycle({
};
const restore = async () => {
const resetNodes = resetInterruptedGeneration(project.nodes);
const initialNodes = removeLegacyCharacterCards(resetNodes, project.connections).nodes;
const initialNodes = resetInterruptedGeneration(project.nodes);
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
@@ -169,17 +166,22 @@ export function useCanvasProjectLifecycle({
}, [projectId, projectLoaded, updateProject, viewportRef]);
const createAndOpenProject = useCallback(() => {
void createCanvasProjectWithRemoteSync(`无限画布 ${useCanvasStore.getState().projects.length + 1}`).then(({ id, syncError }) => {
void createCanvasProjectWithRemoteSync(`自由画布 ${useCanvasStore.getState().projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
navigate(`/canvas/${id}`);
});
}, [message, navigate]);
const deleteCurrentProject = useCallback(() => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
if (drawingIds.length) {
void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId)))
.catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败"));
}
deleteProjects([projectId]);
cleanupAssetImages();
navigate("/canvas");
}, [cleanupAssetImages, deleteProjects, navigate, projectId]);
}, [cleanupAssetImages, deleteProjects, message, navigate, nodesRef, projectId]);
const renameCurrentProject = useCallback((title: string) => {
renameProject(projectId, title);

View File

@@ -35,11 +35,11 @@ type UseCanvasRenderModelOptions = {
upscaleNodeId: string | null;
superResolveNodeId: string | null;
angleNodeId: string | null;
emotionNodeId: string | null;
previewNodeId: string | null;
contextMenu: ContextMenuState | null;
versionCompareRootId: string | null;
directorNodeId: string | null;
documentEditorNodeId: string | null;
scriptEditorNodeId: string | null;
dialogNodeId: string | null;
};
@@ -66,11 +66,11 @@ export function useCanvasRenderModel({
upscaleNodeId,
superResolveNodeId,
angleNodeId,
emotionNodeId,
previewNodeId,
contextMenu,
versionCompareRootId,
directorNodeId,
documentEditorNodeId,
scriptEditorNodeId,
dialogNodeId,
}: UseCanvasRenderModelOptions) {
@@ -150,6 +150,7 @@ export function useCanvasRenderModel({
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
const emotionNode = emotionNodeId ? nodeById.get(emotionNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const contextMenuNode = contextMenu?.type === "node" ? nodeById.get(contextMenu.nodeId) || null : null;
const activeNodeId = selectedNodeIds.size > 1 ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
@@ -237,7 +238,6 @@ export function useCanvasRenderModel({
return map;
}, [connections, semanticNodes]);
const activeDirectorNode = useMemo(() => semanticNodes.find((node) => node.id === directorNodeId) || null, [directorNodeId, semanticNodes]);
const activeDocumentNode = useMemo(() => semanticNodes.find((node) => node.id === documentEditorNodeId && node.type === CanvasNodeType.Text) || null, [documentEditorNodeId, semanticNodes]);
const activeStylePresetId = useMemo(() => semanticNodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.stylePresetId, [semanticNodes]);
const activeScriptNode = useMemo(() => semanticNodes.find((node) => node.id === scriptEditorNodeId && node.type === CanvasNodeType.Script) || null, [scriptEditorNodeId, semanticNodes]);
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
@@ -253,11 +253,11 @@ export function useCanvasRenderModel({
return {
activeDirectorNode,
activeDirectorScene,
activeDocumentNode,
activeNodeId,
activeScriptNode,
activeStylePresetId,
angleNode,
emotionNode,
annotationNode,
batchChildCountById,
batchMotionById,

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { createShortDramaPipeline, deriveShortDramaProgress, persistShortDramaGuideDismissed, readShortDramaGuideDismissed, storyInputNodeWithMode, type CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama";
import { createShortDramaPipeline, deriveShortDramaProgress, persistShortDramaGuideDismissed, readShortDramaGuideDismissed, type CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama";
import type { CanvasConnection, CanvasNodeData, Position } from "@/types/canvas";
type UseCanvasShortDramaOptions = {
@@ -16,13 +16,12 @@ type UseCanvasShortDramaOptions = {
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setStylePickerOpen: Dispatch<SetStateAction<boolean>>;
setDocumentEditorNodeId: Dispatch<SetStateAction<string | null>>;
fitCanvasSelection: () => boolean;
focusCanvasNode: (nodeId: string) => void;
openTextEditor: (node: CanvasNodeData) => void;
};
export function useCanvasShortDrama({ nodes, connections, nodesRef, connectionsRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setStylePickerOpen, setDocumentEditorNodeId, fitCanvasSelection, focusCanvasNode, openTextEditor }: UseCanvasShortDramaOptions) {
export function useCanvasShortDrama({ nodes, connections, nodesRef, connectionsRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setStylePickerOpen, fitCanvasSelection, focusCanvasNode, openTextEditor }: UseCanvasShortDramaOptions) {
const { message } = App.useApp();
const dismissedRef = useRef(readShortDramaGuideDismissed());
const [guideCollapsed, setGuideCollapsed] = useState(dismissedRef.current);
@@ -51,19 +50,6 @@ export function useCanvasShortDrama({ nodes, connections, nodesRef, connectionsR
message.success("短剧流水线已创建");
}, [connectionsRef, fitCanvasSelection, getCanvasCenter, message, nodesRef, selectNodes, setConnections, setNodes, setStylePickerOpen]);
const setStoryInputMode = useCallback((nodeId: string, mode: "novel" | "brief") => {
const current = nodesRef.current.find((node) => node.id === nodeId);
if (!current || current.metadata?.workflowKind !== "story_input") return;
const updated = storyInputNodeWithMode(current, mode);
nodesRef.current = nodesRef.current.map((node) => node.id === nodeId ? updated : node);
setNodes(nodesRef.current);
selectNodes([nodeId]);
queueMicrotask(() => {
if (mode === "novel") setDocumentEditorNodeId(nodeId);
else openTextEditor(updated);
});
}, [nodesRef, openTextEditor, selectNodes, setDocumentEditorNodeId, setNodes]);
const openStoryInput = useCallback((nodeId?: string) => {
const storyNode = (nodeId ? nodesRef.current.find((node) => node.id === nodeId) : undefined)
|| nodesRef.current.find((node) => node.metadata?.workflowKind === "story_input")
@@ -113,7 +99,6 @@ export function useCanvasShortDrama({ nodes, connections, nodesRef, connectionsR
openStoryInput,
progress,
setGuideCollapsed,
setStoryInputMode,
skipGuide,
};
}

View File

@@ -17,15 +17,14 @@ import {
storyboardRowsFromTask,
} from "@/lib/canvas/canvas-project-domain";
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { navigateToSettings } from "@/lib/settings-navigation";
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
import { modelOptionName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import {
CanvasNodeType,
type CanvasConnection,
type CanvasDocumentChapter,
type CanvasGenerationBatchMode,
type CanvasNodeData,
type CanvasRichDocument,
type StoryboardRow,
} from "@/types/canvas";
@@ -56,7 +55,6 @@ export function useCanvasStoryboard({
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const confirmGenerationSubmission = useCallback((count: number, model: string, taskLabel: string) => new Promise<boolean>((resolve) => {
if (!count) return resolve(false);
@@ -117,7 +115,7 @@ export function useCanvasStoryboard({
const expandedPrompt = expandStoryboardTextMentions(prompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
const generationConfig = buildGenerationConfig(effectiveConfig, scriptNode, "text");
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined } } : node));
@@ -166,58 +164,7 @@ export function useCanvasStoryboard({
message.error(details);
return false;
}
}, [connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, openConfigDialog, projectId, setNodes]);
const analyzeDocumentChapter = useCallback(async (documentNode: CanvasNodeData, document: CanvasRichDocument, chapter: CanvasDocumentChapter, documentTitle: string) => {
if (!chapter.plainText.trim()) return;
let scriptNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Script && node.metadata?.documentNodeId === documentNode.id && node.metadata?.chapterId === chapter.id);
if (!scriptNode) {
const chapterIndex = Math.max(0, document.chapters?.findIndex((item) => item.id === chapter.id) ?? 0);
scriptNode = createCanvasNode(CanvasNodeType.Script, { x: documentNode.position.x + documentNode.width + 140, y: documentNode.position.y + chapterIndex * 140 }, {
workflowKind: "storyboard",
workflowTitle: `${chapter.title} · 分镜`,
workflowDescription: `由《${documentTitle}》${chapter.title}生成`,
documentNodeId: documentNode.id,
chapterId: chapter.id,
chapterTitle: chapter.title,
composerContent: chapter.plainText,
status: NODE_STATUS_IDLE,
});
scriptNode.title = `${chapter.title} · 分镜`;
const nextNodes = [...nodesRef.current, scriptNode];
nodesRef.current = nextNodes;
setNodes(nextNodes);
if (!connectionsRef.current.some((connection) => connection.fromNodeId === documentNode.id && connection.toNodeId === scriptNode?.id)) {
const connection = { id: nanoid(), fromNodeId: documentNode.id, toNodeId: scriptNode.id };
connectionsRef.current = [...connectionsRef.current, connection];
setConnections(connectionsRef.current);
}
} else {
const updatedScriptNode = {
...scriptNode,
title: `${chapter.title} · 分镜`,
metadata: {
...scriptNode.metadata,
workflowTitle: `${chapter.title} · 分镜`,
workflowDescription: `由《${documentTitle}》${chapter.title}生成`,
chapterTitle: chapter.title,
composerContent: chapter.plainText,
},
};
scriptNode = updatedScriptNode;
nodesRef.current = nodesRef.current.map((node) => node.id === updatedScriptNode.id ? updatedScriptNode : node);
setNodes(nodesRef.current);
}
const prompt = [
`请将《${documentTitle}》中的“${chapter.title}”拆分为结构化短剧分镜。`,
"镜头必须覆盖本章完整剧情,保持角色、场景和时间连续;每行给出画面描述、对白、景别、情绪、镜头运动、图片提示词和视频运动提示词。",
"章节正文:",
chapter.plainText,
].join("\n\n");
const succeeded = await generateScriptRows(scriptNode.id, prompt);
if (!succeeded) return;
return { storyboardNodeId: scriptNode.id };
}, [connectionsRef, generateScriptRows, nodesRef, setConnections, setNodes]);
}, [connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, projectId, setNodes]);
const ensureScriptImageNodes = useCallback((nodeId: string, rowIds: string[]) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
@@ -293,7 +240,7 @@ export function useCanvasStoryboard({
if (missing.length) return message.warning(`有 ${missing.length} 个镜头缺少画面描述或图片提示词`);
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
const activeNodeIds = activeGenerationBatchNodeIds(scriptNode, "storyboard_image");
@@ -305,7 +252,7 @@ export function useCanvasStoryboard({
if (!await confirmGenerationSubmission(targetRows.length, imageModel, "图片生成")) return;
const targets = ensureScriptImageNodes(nodeId, targetRows.map((row) => row.id));
if (enqueueGenerationBatch(nodeId, "storyboard_image", targets.map((target) => ({ rowId: target.row.id, nodeId: target.node.id })))) message.success("分镜图已加入生成队列");
}, [effectiveConfig, enqueueGenerationBatch, ensureScriptImageNodes, confirmGenerationSubmission, isAiConfigReady, message, nodesRef, openConfigDialog]);
}, [effectiveConfig, enqueueGenerationBatch, ensureScriptImageNodes, confirmGenerationSubmission, isAiConfigReady, message, nodesRef]);
const createScriptVideoNodes = useCallback((nodeId: string, silent = false, rowIds?: string[]) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
@@ -357,7 +304,7 @@ export function useCanvasStoryboard({
const createAndGenerateScriptVideos = useCallback(async (nodeId: string) => {
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, videoModel)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
let scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
@@ -403,7 +350,7 @@ export function useCanvasStoryboard({
setConnections(nextConnections);
setSelectedNodeIds(new Set(targets.map((target) => target.videoNode.id)));
if (enqueueGenerationBatch(nodeId, "storyboard_video", targets.map((target) => ({ rowId: target.row.id, nodeId: target.videoNode.id })))) message.success("镜头视频已加入生成队列");
}, [connectionsRef, confirmGenerationSubmission, createScriptVideoNodes, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, openConfigDialog, setConnections, setNodes, setSelectedNodeIds]);
}, [connectionsRef, confirmGenerationSubmission, createScriptVideoNodes, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
const createScriptActionBoards = useCallback(async (nodeId: string) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
@@ -411,7 +358,7 @@ export function useCanvasStoryboard({
if (!scriptNode || !rows.length) return;
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, imageModel)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
const actionBoardRows = rows.filter((row) => !nodesRef.current.some((node) => node.type === CanvasNodeType.Image && node.metadata?.workflowKind === "action_board" && node.metadata.shotIndex === row.shotNumber && Boolean(node.metadata.content)));
@@ -450,7 +397,7 @@ export function useCanvasStoryboard({
setNodes(nextNodes);
setConnections(nextConnections);
if (enqueueGenerationBatch(nodeId, "action_board", targets.map((target) => ({ rowId: target.row.id, nodeId: target.node.id })))) message.success("动作拆分板已加入生成队列");
}, [connectionsRef, confirmGenerationSubmission, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, openConfigDialog, setConnections, setNodes]);
}, [connectionsRef, confirmGenerationSubmission, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, setConnections, setNodes]);
const generateScriptVideos = useCallback(async (nodeId: string, rowIds: string[]) => {
let scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
@@ -461,7 +408,7 @@ export function useCanvasStoryboard({
if (readyRows.length !== rows.length) message.warning(`${rows.length - readyRows.length} 个镜头没有可用分镜图,已跳过`);
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, videoModel)) {
openConfigDialog(true);
navigateToSettings({ continueCreation: true });
return;
}
const activeNodeIds = activeGenerationBatchNodeIds(scriptNode, "storyboard_video");
@@ -504,11 +451,10 @@ export function useCanvasStoryboard({
setNodes(nextNodes);
setConnections(nextConnections);
if (enqueueGenerationBatch(nodeId, "storyboard_video", targets.map((target) => ({ rowId: target.row.id, nodeId: target.node.id })))) message.success("镜头视频已加入生成队列");
}, [connectionsRef, confirmGenerationSubmission, createScriptVideoNodes, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, openConfigDialog, setConnections, setNodes]);
}, [connectionsRef, confirmGenerationSubmission, createScriptVideoNodes, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, nodesRef, setConnections, setNodes]);
return {
addScriptRow,
analyzeDocumentChapter,
createAndGenerateScriptVideos,
createScriptActionBoards,
createScriptImageNodes,

View File

@@ -0,0 +1,65 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import type { CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
type UseCanvasStyleWorkflowOptions = {
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
getCanvasCenter: () => Position;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setStylePickerOpen: Dispatch<SetStateAction<boolean>>;
};
export function useCanvasStyleWorkflow({
nodesRef,
selectedNodeIdsRef,
getCanvasCenter,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setStylePickerOpen,
}: UseCanvasStyleWorkflowOptions) {
const { message } = App.useApp();
const selectCanvasStyle = useCallback((preset: CanvasStylePreset) => {
const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
const metadata: CanvasNodeMetadata = {
content: preset.prompt,
prompt: preset.prompt,
status: "success",
workflowKind: "styleboard",
workflowTitle: "项目画风",
workflowDescription: preset.description,
stylePresetId: preset.id,
fontSize: 14,
};
let styleNode: CanvasNodeData;
if (current) {
styleNode = { ...current, title: `画风 · ${preset.title}`, metadata: { ...current.metadata, ...metadata } };
nodesRef.current = nodesRef.current.map((node) => node.id === current.id ? styleNode : node);
} else {
styleNode = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), metadata);
styleNode.title = `画风 · ${preset.title}`;
styleNode.width = 420;
styleNode.height = 240;
nodesRef.current = [...nodesRef.current, styleNode];
}
setNodes(nodesRef.current);
const selection = new Set([styleNode.id]);
selectedNodeIdsRef.current = selection;
setSelectedNodeIds(selection);
setSelectedConnectionId(null);
setDialogNodeId(null);
setStylePickerOpen(false);
message.success(`已应用“${preset.title}”画风`);
}, [getCanvasCenter, message, nodesRef, selectedNodeIdsRef, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setStylePickerOpen]);
return { selectCanvasStyle };
}

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type DragEvent, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { CANVAS_IMAGE_ASSET_DND_TYPE } from "@/components/canvas/canvas-asset-tray";
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { CANVAS_PROJECT_CHAPTER_DND_TYPE, type CanvasProjectChapterPayload } from "@/components/canvas/canvas-project-sidebar";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
@@ -11,11 +13,15 @@ import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getProjectUnit } from "@/services/api/projects";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useAssetStore, type ImageAsset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import type { CanvasUploadStatus } from "./canvas-project-feedback";
type UseCanvasUploadOptions = {
canvasId: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
getCanvasCenter: () => Position;
@@ -38,6 +44,8 @@ const VIDEO_NODE_MAX_HEIGHT = 420;
const NODE_STATUS_SUCCESS = "success" as const;
export function useCanvasUpload({
canvasId,
domainProjectId,
nodesRef,
selectedNodeIdsRef,
getCanvasCenter,
@@ -49,6 +57,7 @@ export function useCanvasUpload({
setDialogNodeId,
}: UseCanvasUploadOptions) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const imageInputRef = useRef<HTMLInputElement>(null);
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
const assetInsertPositionRef = useRef<Position | null>(null);
@@ -92,8 +101,20 @@ export function useCanvasUpload({
if (dialog !== "preserve") setDialogNodeId(dialog === "open" ? nodeId : null);
}, [setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
const persistMediaNode = useCallback(async (node: CanvasNodeData) => {
try {
const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-upload" });
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
return true;
} catch (error) {
message.warning(error instanceof Error ? `媒体已添加到画布,但素材同步失败:${error.message}` : "媒体已添加到画布,但素材同步失败");
return false;
}
}, [canvasId, domainProjectId, message, queryClient, setNodes]);
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传图片", "读取图片文件");
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
@@ -111,7 +132,9 @@ export function useCanvasUpload({
};
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
progress.done("图片已添加到画布");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "图片上传失败";
@@ -119,7 +142,7 @@ export function useCanvasUpload({
message.error(details);
return false;
}
}, [message, selectInsertedNode, setNodes, startUploadStatus]);
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
try {
@@ -147,6 +170,7 @@ export function useCanvasUpload({
bytes: asset.data.bytes || getDataUrlByteSize(content.startsWith("data:") ? content : ""),
mimeType: asset.data.mimeType || "image/png",
prompt: typeof asset.metadata?.prompt === "string" ? asset.metadata.prompt : asset.title,
assetId: asset.id,
assetTags: asset.tags || [],
},
};
@@ -158,40 +182,46 @@ export function useCanvasUpload({
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传视频", "读取视频文件");
const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((current) => [...current, { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) }]);
const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
progress.done("视频已添加到画布");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
} catch (error) {
const details = error instanceof Error ? error.message : "视频上传失败";
progress.fail(details);
message.error(details);
}
}, [message, selectInsertedNode, setNodes, startUploadStatus]);
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传音频", "读取音频文件");
const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((current) => [...current, { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) }]);
const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "preserve");
progress.done("音频已添加到画布");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
} catch (error) {
const details = error instanceof Error ? error.message : "音频上传失败";
progress.fail(details);
message.error(details);
}
}, [message, selectInsertedNode, setNodes, startUploadStatus]);
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
const trimmed = text.trim();
@@ -206,6 +236,37 @@ export function useCanvasUpload({
return true;
}, [getCanvasCenter, selectInsertedNode, setContextMenu, setNodes]);
const handleProjectChapterInsert = useCallback(async (chapter: CanvasProjectChapterPayload, position?: Position) => {
let sourceText = chapter.sourceText;
if (sourceText === undefined) {
try {
sourceText = (await getProjectUnit(chapter.projectId, chapter.id)).unit.sourceText;
} catch (error) {
message.error(error instanceof Error ? `章节正文读取失败:${error.message}` : "章节正文读取失败");
return;
}
}
const content = htmlToPlainText(sourceText);
const node = createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), {
content,
prompt: content,
status: NODE_STATUS_SUCCESS,
workflowKind: "free",
workflowTitle: "项目章节",
workflowDescription: `第 ${chapter.position + 1} 章`,
chapterId: chapter.id,
chapterTitle: chapter.title,
fontSize: 14,
});
node.title = `章节 · ${chapter.title}`;
node.width = 460;
node.height = 280;
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, "preserve");
setContextMenu(null);
message.success(`已添加“${chapter.title}”`);
}, [getCanvasCenter, message, selectInsertedNode, setContextMenu, setNodes]);
const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => {
uploadTargetRef.current = { nodeId, position };
const target = nodeId ? nodesRef.current.find((node) => node.id === nodeId) : null;
@@ -222,15 +283,19 @@ export function useCanvasUpload({
}, [nodesRef]);
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (!currentNode) return false;
if (isAudioFile(file)) {
const progress = startUploadStatus("替换音频", "读取音频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, type: CanvasNodeType.Audio, title: file.name, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined } } : node));
const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "preserve");
progress.done("音频已替换,可撤销恢复");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "音频替换失败";
@@ -245,9 +310,11 @@ export function useCanvasUpload({
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node));
const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
progress.done("视频已替换,可撤销恢复");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "视频替换失败";
@@ -261,13 +328,15 @@ export function useCanvasUpload({
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
setNodes((current) => current.map((node) => node.id === nodeId ? {
...node,
const node = {
...currentNode,
type: CanvasNodeType.Image,
title: file.name,
metadata: {
...node.metadata,
...currentNode.metadata,
...imageMetadata(image),
assetId: undefined,
taskId: undefined,
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
@@ -284,9 +353,11 @@ export function useCanvasUpload({
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
} : node));
} satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
progress.done("图片已替换,可撤销恢复");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "图片替换失败";
@@ -294,7 +365,7 @@ export function useCanvasUpload({
message.error(details);
return false;
}
}, [message, selectInsertedNode, setNodes, startUploadStatus]);
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const pasteSystemClipboard = useCallback(async (position?: Position) => {
if (!navigator.clipboard) return;
@@ -351,6 +422,11 @@ export function useCanvasUpload({
event.preventDefault();
fileDragDepthRef.current = 0;
setFileDropActive(false);
const chapterPayload = parseProjectChapterPayload(event.dataTransfer.getData(CANVAS_PROJECT_CHAPTER_DND_TYPE));
if (chapterPayload) {
void handleProjectChapterInsert(chapterPayload, screenToCanvas(event.clientX, event.clientY));
return;
}
const imageAssetId = event.dataTransfer.getData(CANVAS_IMAGE_ASSET_DND_TYPE);
if (imageAssetId) {
const asset = useAssetStore.getState().assets.find((item): item is ImageAsset => item.kind === "image" && item.id === imageAssetId);
@@ -377,7 +453,7 @@ export function useCanvasUpload({
return;
}
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, message, nodesRef, replaceNodeMedia, screenToCanvas]);
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
@@ -387,7 +463,7 @@ export function useCanvasUpload({
}, []);
const handleFileDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
@@ -414,33 +490,90 @@ export function useCanvasUpload({
setAssetPickerOpen(false);
}, []);
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload) => {
const center = assetInsertPositionRef.current || getCanvasCenter();
const createAssetPayloadNode = useCallback(async (payload: InsertAssetPayload, center: Position) => {
if (payload.kind === "character") {
const width = 320;
const height = 260;
return {
id: `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: CanvasNodeType.Text,
title: payload.title,
position: { x: center.x - width / 2, y: center.y - height / 2 },
width,
height,
metadata: {
workflowKind: "character",
characterAssetId: payload.assetId,
characterVersionId: payload.versionId,
characterVersionPolicy: "current",
characterName: payload.title,
characterPrompt: payload.prompt,
characterAliases: payload.aliases,
characterDefinition: payload.definition,
characterCoverUrl: payload.coverUrl,
characterVisualStatus: payload.visualStatus,
characterVoiceStatus: payload.voiceStatus,
characterVoiceName: payload.voiceName,
characterVoiceProfile: payload.voiceProfile,
characterVoiceInstructions: payload.voiceInstructions,
assetId: payload.assetId,
status: NODE_STATUS_SUCCESS,
fontSize: 14,
},
} satisfies CanvasNodeData;
}
if (payload.kind === "text") {
const node = { ...createCanvasNode(CanvasNodeType.Text, center, { content: payload.content, status: NODE_STATUS_SUCCESS }), title: payload.content.slice(0, 32) || "Assistant Text" };
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, "preserve");
} else if (payload.kind === "video") {
const node = { ...createCanvasNode(CanvasNodeType.Text, center, { content: payload.content, status: NODE_STATUS_SUCCESS, assetId: payload.assetId }), title: payload.content.slice(0, 32) || "Assistant Text" };
return node;
}
if (payload.kind === "audio") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Audio, title: payload.title, position: { x: center.x - spec.width / 2, y: center.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { content: payload.url, storageKey: payload.storageKey, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "audio/mpeg", assetId: payload.assetId, status: NODE_STATUS_SUCCESS } } satisfies CanvasNodeData;
}
if (payload.kind === "video") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const size = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((current) => [...current, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height } }]);
selectInsertedNode(id, "preserve");
} else {
try {
const storedImage = payload.storageKey ? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(payload.dataUrl);
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((current) => [...current, { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: payload.title } }]);
selectInsertedNode(id, "open");
} catch (error) {
message.error(error instanceof Error ? error.message : "素材图片插入失败");
return;
}
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
}
const storedImage = payload.storageKey ? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(payload.dataUrl);
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
}, []);
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload) => {
const center = assetInsertPositionRef.current || getCanvasCenter();
try {
const node = await createAssetPayloadNode(payload, center);
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, payload.kind === "image" || payload.kind === "video" ? "open" : "preserve");
} catch (error) {
message.error(error instanceof Error ? error.message : "素材插入失败");
return;
}
closeAssetPicker();
}, [closeAssetPicker, getCanvasCenter, message, selectInsertedNode, setNodes]);
}, [closeAssetPicker, createAssetPayloadNode, getCanvasCenter, message, selectInsertedNode, setNodes]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position) => {
const origin = position || getCanvasCenter();
try {
const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, {
x: origin.x + (index % 3) * 380,
y: origin.y + Math.floor(index / 3) * 300,
})));
setNodes((current) => [...current, ...created]);
setSelectedNodeIds(new Set(created.map((node) => node.id)));
setSelectedConnectionId(null);
setDialogNodeId(null);
message.success(`已引入 ${created.length} 项项目资产`);
} catch (error) {
message.error(error instanceof Error ? error.message : "项目资产引入失败");
throw error;
}
}, [createAssetPayloadNode, getCanvasCenter, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
return {
assetPickerOpen,
@@ -453,6 +586,8 @@ export function useCanvasUpload({
handleFileDragLeave,
handleFileDragOver,
handleImageInputChange,
handleProjectAssetsInsert,
handleProjectChapterInsert,
handleUploadRequest,
imageInputRef,
openAssetsAtPosition,
@@ -466,3 +601,20 @@ export function useCanvasUpload({
function hasDraggedFiles(event: DragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes("Files");
}
function parseProjectChapterPayload(value: string): CanvasProjectChapterPayload | null {
if (!value) return null;
try {
const payload = JSON.parse(value) as Partial<CanvasProjectChapterPayload>;
const validSource = payload.sourceText === undefined || typeof payload.sourceText === "string";
return typeof payload.id === "string" && typeof payload.projectId === "string" && typeof payload.title === "string" && validSource && typeof payload.position === "number" ? payload as CanvasProjectChapterPayload : null;
} catch {
return null;
}
}
function htmlToPlainText(value: string) {
if (!value) return "";
const document = new DOMParser().parseFromString(value, "text/html");
return document.body.textContent?.trim() || "";
}

View File

@@ -119,7 +119,7 @@ export function useCanvasViewportController({
const scale = Math.min(1.18, Math.max(viewportRef.current.k, 0.72));
transitionViewportTo({ x: size.width / 2 - (node.position.x + node.width / 2) * scale, y: size.height / 2 - (node.position.y + node.height / 2) * scale, k: scale });
selectFocusedNode(node.id);
setDialogNodeId(node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing ? null : node.id);
}, [nodesRef, selectFocusedNode, setDialogNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
const resetViewport = useCallback(() => {