@@ -72,7 +87,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
创建时间
@@ -112,7 +127,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
menu={{
onClick: ({ domEvent }) => domEvent.stopPropagation(),
items: [
- { key: "export", icon:
, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "画布") },
+ { key: "export", icon:
, label: "导出画布", onClick: () => void exportProject() },
{ type: "divider" },
{ key: "delete", danger: true, icon:
, label: "删除", onClick: () => setDeleteIds([project.id]) },
],
diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx
index fe1b7188f..734f2f196 100644
--- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx
+++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx
@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
-import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Scissors, SlidersHorizontal, Smile, Sparkles, Upload, ZoomIn } from "lucide-react";
+import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Upload, Scaling } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
+import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
@@ -26,92 +27,137 @@ type ImageToolDefinition = {
id: ImageNodeActionToolId;
label: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode;
+ group: NodeToolbarGroup;
+ order: number;
+ section?: string;
+ description?: string;
+ active?: (node: CanvasNodeData) => boolean;
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
};
const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
+ section: "生成信息",
label: "复制提示词",
icon: () =>
,
+ group: "more",
+ order: 50,
run: (node, handlers) => handlers.onCopyPrompt(node),
},
{
id: "reversePrompt",
+ section: "生成信息",
label: "反推提示词",
icon: () =>
,
+ group: "more",
+ order: 60,
run: (node, handlers) => handlers.onReversePrompt(node),
},
{
id: "replace",
label: "替换图片",
icon: () =>
,
+ group: "more",
+ section: "素材",
+ order: 45,
run: (node, handlers) => handlers.onUpload(node),
},
{
id: "resize",
- label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
+ label: "锁定宽高比",
+ section: "节点管理",
+ active: (node) => !node.metadata?.freeResize,
icon: (node) => (node.metadata?.freeResize ?
:
),
+ group: "more",
+ order: 70,
run: (node, handlers) => handlers.onToggleFreeResize(node),
},
{
id: "annotation",
+ section: "拆分与标记",
+ description: "添加标记,另存为新图片",
label: "标注",
icon: () =>
,
+ group: "process",
+ order: 40,
run: (node, handlers) => handlers.onAnnotate(node),
},
{
id: "maskEdit",
- label: "局部编辑",
+ label: "局部重绘",
+ description: "涂抹要修改的区域,生成新图片",
icon: () =>
,
+ group: "primary",
+ order: 10,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "emotion",
- label: "情绪",
+ label: "表情调整",
+ section: "人像调整",
+ description: "调整人物表情,生成新图片",
icon: () =>
,
+ group: "portrait",
+ order: 50,
run: (node, handlers) => handlers.onEmotion(node),
},
{
id: "portraitTexture",
- label: "人物质感",
+ label: "质感调整",
+ section: "人像调整",
+ description: "设置肤质、光影与融合,再执行生成",
icon: () =>
,
+ group: "portrait",
+ order: 60,
run: (node, handlers) => handlers.onPortraitTexture(node),
},
{
id: "crop",
- label: "剪裁",
- icon: () =>
,
+ label: "裁剪",
+ section: "构图与尺寸",
+ description: "保留所选区域,生成新图片",
+ icon: () =>
,
+ group: "process",
+ order: 10,
run: (node, handlers) => handlers.onCrop(node),
},
{
id: "split",
- label: "切图",
+ label: "宫格切分",
+ section: "拆分与标记",
+ description: "按行列拆成多个图片节点",
icon: () =>
,
+ group: "process",
+ order: 30,
run: (node, handlers) => handlers.onSplit(node),
},
{
id: "upscale",
- label: "放大",
- icon: () =>
,
+ label: "调整尺寸",
+ section: "构图与尺寸",
+ description: "插值放大像素尺寸,不是 AI 超分",
+ icon: () =>
,
+ group: "process",
+ order: 20,
run: (node, handlers) => handlers.onUpscale(node),
},
- {
- id: "superResolve",
- label: "超分",
- icon: () =>
,
- run: (node, handlers) => handlers.onSuperResolve(node),
- },
{
id: "angle",
- label: "多视角",
+ label: "多角度",
+ section: "视角",
+ description: "调整摄影机角度,生成新视角",
icon: () =>
,
+ group: "viewpoint",
+ order: 20,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "view",
- label: "查看大图",
+ label: "预览",
icon: () =>
,
+ group: "utility",
+ order: 10,
run: (node, handlers) => handlers.onViewImage(node),
},
];
@@ -121,6 +167,11 @@ export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageTool
id: tool.id,
label: resolveToolText(tool.label, node),
icon: tool.icon(node),
+ group: tool.group,
+ order: tool.order,
+ section: tool.section,
+ description: tool.description,
+ active: tool.active?.(node),
onClick: () => tool.run(node, handlers),
}));
}
diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
index 2a55a75d9..169d04781 100644
--- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
+++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
@@ -324,7 +324,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
scene.selectionBounds.visible = Boolean(bounds && !selection);
if (bounds && !selection) {
syncSelectionBounds(scene.selectionBounds, bounds, viewportScale);
- scene.selectionBounds.stroke = props.theme.accent.primary;
+ scene.selectionBounds.stroke = props.theme.node.label;
}
const connecting = props.connectingParams;
@@ -373,8 +373,9 @@ function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme)
y: Math.min(selection.startWorldY, selection.currentWorldY),
width: Math.abs(selection.currentWorldX - selection.startWorldX),
height: Math.abs(selection.currentWorldY - selection.startWorldY),
- fill: theme.canvas.selectionFill,
- stroke: theme.accent.primary,
+ fill: "transparent",
+ stroke: theme.node.label,
+ opacity: selection.hitMode === "intersect" ? 0.82 : 0.68,
});
}
@@ -382,10 +383,18 @@ function syncViewport(viewport: ViewportTransform, width: number, height: number
const scale = Math.max(viewport.k, 0.05);
for (const scene of [underlay, overlay]) scene.world.set({ x: viewport.x, y: viewport.y, scaleX: scale, scaleY: scale });
- overlay.selection.strokeWidth = 1 / scale;
- overlay.selection.cornerRadius = 2 / scale;
+ overlay.selection.set({
+ strokeWidth: 1 / scale,
+ cornerRadius: 2 / scale,
+ dashPattern: [4 / scale, 4 / scale],
+ });
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
- overlay.selectionBounds.set({ strokeWidth: 1 / scale, cornerRadius: 12 / scale });
+ overlay.selectionBounds.set({
+ strokeWidth: 1 / scale,
+ cornerRadius: 2 / scale,
+ dashPattern: [4 / scale, 4 / scale],
+ opacity: 0.68,
+ });
overlay.draft.set({ strokeWidth: 1.4 / scale, dashPattern: [8 / scale, 8 / scale] });
overlay.guides.set({
visible: typeof props.alignmentGuides.vertical === "number" || typeof props.alignmentGuides.horizontal === "number",
diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx
index 29d52f0f5..f2d18d8bc 100644
--- a/web/src/components/canvas/canvas-node-content.tsx
+++ b/web/src/components/canvas/canvas-node-content.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
-import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, RefreshCw, Video } from "lucide-react";
+import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -14,7 +14,8 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
-import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
+import { cacheResourceObjectUrl, getCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
+import { resolveMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
@@ -56,6 +57,7 @@ export type CanvasNodeContentProps = {
onToggleBatch?: () => void;
reduceMediaEffects?: boolean;
mediaActive?: boolean;
+ onMediaPlayRequest?: (nodeId: string) => void;
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
@@ -419,11 +421,10 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
return content;
}
-function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false }: CanvasNodeContentProps) {
+function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) {
const playerBoxRef = useRef
(null);
const { updateMediaNode } = useCanvasNodeActions();
- const hasPassivePreview = Boolean(canvasNodeVideoPreviewUrl(node));
- const { url, loading } = useNodeResourceUrl(node, mediaActive || !hasPassivePreview);
+ const { url, loading } = useVideoPlaybackUrl(node, mediaActive);
const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
const [currentTimeMs, setCurrentTimeMs] = useState(0);
@@ -452,7 +453,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
if (!node.metadata?.content) return } label="空视频节点" color={theme.node.placeholder} />;
- if (!mediaActive) return ;
+ if (!mediaActive) return onMediaPlayRequest?.(node.id)} />;
if (!url) return } label={loading ? "正在加载视频" : "视频资源不可用"} />;
const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height);
@@ -464,7 +465,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
return (
-
+ scheduleResourceBlobCache(node.metadata?.storageKey || "")} />
{activeEntry && activeEntry.text.trim() ? : null}
@@ -487,18 +488,20 @@ function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
return ;
}
-function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick & { sourceUrl: string; sourceLoading: boolean }) {
+function InactiveVideoPreview({ node, theme, onPlay }: Pick & { onPlay: () => void }) {
+ const previewRef = useRef(null);
+ const nearViewport = useNearViewport(previewRef);
const previewUrl = canvasNodeVideoPreviewUrl(node);
const { updateMetadata } = useCanvasNodeActions();
const updateMetadataRef = useRef(updateMetadata);
- const [hydrating, setHydrating] = useState(() => !previewUrl && Boolean(node.metadata?.content));
+ const [hydrating, setHydrating] = useState(false);
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
useEffect(() => {
- if (previewUrl || !node.metadata?.content || !updateMetadataRef.current) {
+ if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current) {
setHydrating(false);
return;
}
@@ -513,32 +516,47 @@ function InactiveVideoPreview({ node, theme, sourceUrl, sourceLoading }: Pick controller.abort();
- }, [node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
+ }, [nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
if (previewUrl) {
- return 
;
- }
- if (sourceUrl) {
- return
-