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:
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
web/src/pages/canvas/use-canvas-style-workflow.ts
Normal file
65
web/src/pages/canvas/use-canvas-style-workflow.ts
Normal 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 };
|
||||
}
|
||||
@@ -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() || "";
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user