fix(canvas): 画布交互 - 优化节点视觉、分镜列与滑动性能

This commit is contained in:
ddCat
2026-08-22 13:34:30 +08:00
parent 227e548503
commit bce2055602
12 changed files with 42 additions and 71 deletions

View File

@@ -113,7 +113,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
} as CSSProperties;
const composerSurfaceStyle = {
...composerTokens,
border: `1px solid ${theme.node.edge}`,
border: `1px solid color-mix(in srgb, ${theme.node.edge} 42%, transparent)`,
background: theme.node.panel,
color: theme.node.text,
boxShadow: theme.node.shadow,

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Replace, Settings2, Star, Trash2, Type, Upload, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
@@ -95,7 +95,6 @@ export const CanvasNode = React.memo(function CanvasNode({
onOpenTaskDetails,
onOpenVersions,
onViewImage,
onReplaceMedia,
onOpenTextEditor,
onOpenDirector,
onOpenDrawing,
@@ -112,7 +111,6 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isComposerNode = data.type === CanvasNodeType.Config;
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
const hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video);
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
@@ -367,25 +365,6 @@ export const CanvasNode = React.memo(function CanvasNode({
/>
</div>
{hasReplaceAction && (!hasMediaContent || isSelected || hovered) ? (
<div
className="absolute right-3 top-3 z-[var(--node-z-overlay)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="canvas-node-inline-action grid size-9 place-items-center p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: theme.accent.primary }}
onClick={(event) => { event.stopPropagation(); onReplaceMedia?.(data); }}
aria-label={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
title={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
>
{hasMediaContent ? <Replace className="size-3.5" /> : <Upload className="size-3.5" />}
</button>
</div>
) : null}
{data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
<div
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
@@ -424,7 +403,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</button>
) : null}
{showStatusTrack ? (
<div className={`absolute top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${hasReplaceAction ? "right-14" : "right-3"} ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
@@ -638,7 +617,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
style={{
maxWidth: maxHeaderWidth,
borderRadius: "var(--r-sm)",
background: theme.node.panel,
background: "transparent",
paddingInline: "var(--space-1-half)",
color: active ? theme.node.text : theme.node.label,
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
@@ -653,7 +632,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
autoFocus
value={draft}
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded bg-transparent px-1.5 text-xs font-medium outline-none"
style={{ background: theme.spatial.elevated, color: theme.node.text }}
style={{ background: "transparent", color: theme.node.text }}
onChange={(event) => onDraftChange(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
onBlur={onCommit}

View File

@@ -242,7 +242,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
</section>
<section className="shrink-0 border-t border-border/70 p-2">
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/45 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
<Images className="size-3.5 text-foreground/42" />
<span className="flex-1 text-left">引用项目资产</span>
<span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span>

View File

@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, use
import { Button, Checkbox, Dropdown, Input, InputNumber, Modal, Segmented, Select, Table, Tooltip } from "antd";
import type { MenuProps } from "antd";
import type { ColumnsType } from "antd/es/table";
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react";
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
@@ -33,8 +33,17 @@ import type { TaskStatus } from "@/services/api/task-center";
const STORYBOARD_PROMPT_MIN_HEIGHT = 40;
const STORYBOARD_PROMPT_MAX_HEIGHT = 116;
const SCRIPT_GRID_TEMPLATE = "72px 150px minmax(280px, 1.4fr) minmax(220px, 1fr) 58px";
const SCRIPT_GRID_TEMPLATE = "72px minmax(220px, 1fr) minmax(300px, 1.4fr) minmax(220px, 1fr) 58px";
const EMPTY_STORYBOARD_ROWS: StoryboardRow[] = [];
const DEFAULT_STORYBOARD_COLUMNS: StoryboardColumn[] = ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"];
const LEGACY_STORYBOARD_COLUMNS: StoryboardColumn[] = ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
function resolveStoryboardVisibleColumns(columns?: StoryboardColumn[]) {
if (!columns?.length || (columns.length === LEGACY_STORYBOARD_COLUMNS.length && LEGACY_STORYBOARD_COLUMNS.every((column) => columns.includes(column)))) {
return DEFAULT_STORYBOARD_COLUMNS;
}
return columns;
}
const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "序号", value: "shotNumber" },
@@ -276,10 +285,8 @@ export function CanvasScriptNodeContent({
<HeaderCell borderColor={theme.node.stroke} align="center">
序号
</HeaderCell>
<HeaderCell borderColor={theme.node.stroke} align="center">
时长
</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>画面描述</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>画面</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>视频提示词</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>台词/旁白</HeaderCell>
<span className="text-center">操作</span>
</div>
@@ -308,16 +315,8 @@ export function CanvasScriptNodeContent({
</span>
) : null}
</div>
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}>
<Minus className="size-3" />
</SmallButton>
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
<SmallButton title="增加 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}>
<Plus className="size-3" />
</SmallButton>
</div>
<CompactInput value={row.plotDescription} placeholder="描述画面内容" onChange={(value) => onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} />
<CompactInput value={row.videoMotionPrompt} placeholder="描述视频运动、镜头和动作" onChange={(value) => onUpdateRow(row.id, { videoMotionPrompt: value })} borderColor={theme.node.stroke} />
<CompactInput value={row.dialogue} placeholder="台词或旁白" onChange={(value) => onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
<div className="grid h-full place-items-center">
<button
@@ -609,7 +608,7 @@ export function CanvasScriptEditor({
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const rows = node?.metadata?.storyboard?.rows || EMPTY_STORYBOARD_ROWS;
const visibleColumns = node?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
const visibleColumns = resolveStoryboardVisibleColumns(node?.metadata?.storyboard?.visibleColumns);
const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct";
const filteredRows = useMemo(() => {
const keyword = query.trim().toLowerCase();

View File

@@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
backgroundImage,
backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)",
transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
opacity: 0.42,
opacity: 0.46,
willChange: "transform",
}}
/>

View File

@@ -47,7 +47,7 @@ export const NODE_SPECS = {
workflowKind: "script",
storyboard: {
rows: [],
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: [],
},
},

View File

@@ -71,8 +71,8 @@ export const canvasThemes = {
dark: {
canvas: {
background: "#0b0b0b",
dot: "rgba(170,170,170,.22)",
line: "rgba(170,170,170,.10)",
dot: "rgba(178,178,178,.27)",
line: "rgba(178,178,178,.14)",
selectionFill: "rgba(255,255,255,.12)",
},
node: {

View File

@@ -45,7 +45,7 @@ export function createCanvasNode(type: CanvasNodeType, position: Position, metad
width: spec.width,
height: spec.height,
metadata: type === CanvasNodeType.Script
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], referenceNodeIds: [] } }
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"], referenceNodeIds: [] } }
: { ...spec.metadata, ...metadata, ...(type === CanvasNodeType.Drawing ? { drawingId: metadata?.drawingId || `${id}-document` } : {}) },
};
}
@@ -90,7 +90,7 @@ export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "imag
export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] {
return Array.from(new Set([
...(columns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]),
...(columns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"]),
"shotSize",
"narrativeIntent",
"viewerPOV",
@@ -240,7 +240,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
rows: (storyboard?.rows || []).map((item) => item.id !== rowId ? item : scriptNodeId === connection.fromNodeId
? { ...item, imageNodeId: linkedNode.type === CanvasNodeType.Image ? linkedNode.id : item.imageNodeId, videoNodeId: linkedNode.type === CanvasNodeType.Video ? linkedNode.id : item.videoNodeId }
: { ...item, referenceNodeIds: Array.from(new Set([...(item.referenceNodeIds || []), linkedNode.id])) }),
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: handleId === "storyboard:context" ? Array.from(new Set([...(storyboard?.referenceNodeIds || []), linkedNode.id])) : storyboard?.referenceNodeIds || [],
},
},

View File

@@ -53,7 +53,7 @@ export function createShortDramaPipeline(center: Position) {
workflowTitle: "分镜脚本",
storyboard: {
rows: [],
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: [],
},
});

View File

@@ -22,7 +22,7 @@ export function upsertProjectChapterStoryboard(
const storyboard: StoryboardData = {
rows,
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: existing?.metadata?.storyboard?.referenceNodeIds || [],
};
const scriptNode: CanvasNodeData = existing
@@ -75,7 +75,7 @@ function createChapterStoryboardNode(nodes: CanvasNodeData[], unit: Pick<Project
chapterTitle: unit.title,
storyboard: {
rows,
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: [],
},
});

View File

@@ -95,22 +95,15 @@ export function useCanvasRenderModel({
return hidden;
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
const connectionLayerBounds = useMemo(() => {
if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 };
const padding = 320;
let left = Number.POSITIVE_INFINITY;
let top = Number.POSITIVE_INFINITY;
let right = Number.NEGATIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
nodes.forEach((node) => {
left = Math.min(left, node.position.x - padding);
top = Math.min(top, node.position.y - padding);
right = Math.max(right, node.position.x + node.width + padding);
bottom = Math.max(bottom, node.position.y + node.height + padding);
});
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
}, [nodes]);
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
const left = -viewport.x / viewport.k - padding;
const top = -viewport.y / viewport.k - padding;
const width = viewportSize.width / viewport.k + padding * 2;
const height = viewportSize.height / viewport.k + padding * 2;
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 240 : 360) / viewport.k;
const padding = (reduceMediaEffects ? 128 : 192) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;

View File

@@ -80,7 +80,7 @@ export function useCanvasStoryboard({
...node.metadata,
storyboard: {
rows: updater(node.metadata?.storyboard?.rows || []),
visibleColumns: node.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: node.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [],
},
},
@@ -225,7 +225,7 @@ export function useCanvasStoryboard({
...scriptNode.metadata,
storyboard: {
rows: (scriptNode.metadata?.storyboard?.rows || []).map((row) => ({ ...row, imageNodeId: imageNodeByRowId.get(row.id) || row.imageNodeId })),
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: scriptNode.metadata?.storyboard?.referenceNodeIds || [],
},
},
@@ -313,7 +313,7 @@ export function useCanvasStoryboard({
...scriptNode.metadata,
storyboard: {
rows: allRows.map((row) => ({ ...row, videoNodeId: videoNodeByRowId.get(row.id) || row.videoNodeId })),
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
referenceNodeIds: scriptNode.metadata?.storyboard?.referenceNodeIds || [],
},
},