diff --git a/backend/internal/service/resource_delete.go b/backend/internal/service/resource_delete.go index 89839da17..b8a9808bf 100644 --- a/backend/internal/service/resource_delete.go +++ b/backend/internal/service/resource_delete.go @@ -72,18 +72,40 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er if snapshotErr != nil { return snapshotErr } + sharedAssetResourceIDs := map[string]struct{}{} for _, reference := range snapshot.Direct { if _, exists := ownedIDSet[reference.ResourceID]; exists { + if reference.Kind == "素材" { + sharedAssetResourceIDs[reference.ResourceID] = struct{}{} + continue + } usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) } } for _, document := range snapshot.Documents { - primaryReferenced := documentReferencesResources(document.PrimaryJSON, ownedIDSet) - secondaryReferenced := documentReferencesResources(document.SecondaryJSON, ownedIDSet) - if primaryReferenced || secondaryReferenced { + referencedIDs := documentReferencedResourceIDs(document.PrimaryJSON, ownedIDSet) + for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, ownedIDSet) { + referencedIDs[resourceID] = struct{}{} + } + if len(referencedIDs) > 0 { + if document.Kind == "素材" { + for resourceID := range referencedIDs { + sharedAssetResourceIDs[resourceID] = struct{}{} + } + continue + } usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) } } + if len(sharedAssetResourceIDs) > 0 { + deletableOwnedIDs := ownedIDs[:0] + for _, resourceID := range ownedIDs { + if _, shared := sharedAssetResourceIDs[resourceID]; !shared { + deletableOwnedIDs = append(deletableOwnedIDs, resourceID) + } + } + ownedIDs = deletableOwnedIDs + } } if message := resourceOccupiedMessage(usages); message != "" { return BadAuthRequest(message) @@ -233,13 +255,18 @@ func isResourceLocatorField(field string) bool { } func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bool { + return len(documentReferencedResourceIDs(raw, resourceIDs)) > 0 +} + +func documentReferencedResourceIDs(raw string, resourceIDs map[string]struct{}) map[string]struct{} { + matched := map[string]struct{}{} raw = strings.TrimSpace(raw) if raw == "" || len(resourceIDs) == 0 { - return false + return matched } + found := map[string]struct{}{} var value any if err := json.Unmarshal([]byte(raw), &value); err == nil { - found := map[string]struct{}{} if scalar, ok := value.(string); ok { if resourceID := canvasResourceID(scalar); resourceID != "" { found[resourceID] = struct{}{} @@ -247,19 +274,16 @@ func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bo } else { walkReferenceDocument(value, "", found) } - for resourceID := range found { - if _, exists := resourceIDs[resourceID]; exists { - return true - } + } else if resourceID := canvasResourceID(raw); resourceID != "" { + // cover_url 等数据库列可以直接保存一个资源 URL,而不是 JSON。 + found[resourceID] = struct{}{} + } + for resourceID := range found { + if _, exists := resourceIDs[resourceID]; exists { + matched[resourceID] = struct{}{} } - return false } - // cover_url 等数据库列可以直接保存一个资源 URL,而不是 JSON。 - if resourceID := canvasResourceID(raw); resourceID != "" { - _, exists := resourceIDs[resourceID] - return exists - } - return false + return matched } func sortedReferenceIDs(values map[string]struct{}) []string { diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go index d8e63c133..8e2b15538 100644 --- a/backend/internal/service/resource_delete_test.go +++ b/backend/internal/service/resource_delete_test.go @@ -164,6 +164,67 @@ func TestDeleteAssetDatabaseFailureLeavesPhysicalObjectAndNoOutbox(t *testing.T) } } +func TestDeleteAssetKeepsResourceSharedByIndependentAsset(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ID: "resource-shared", UserID: "user-1", Provider: "local", ObjectKey: "users/user-1/image/shared.png", Status: model.ResourceStatusReady} + target := model.Asset{ID: "asset-target", UserID: "user-1", Title: "待删除素材", PayloadJSON: `{"data":{"storageKey":"resource:resource-shared"}}`} + independent := model.Asset{ID: "asset-independent", UserID: "user-1", Title: "生成图片", PayloadJSON: `{"data":{"storageKey":"resource:resource-shared"}}`} + for _, item := range []any{&resource, &target, &independent} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.DeleteUserAsset("user-1", target.ID); err != nil { + t.Fatalf("DeleteUserAsset() error = %v", err) + } + + var targetCount, independentCount, resourceCount, jobCount int64 + if err := db.Model(&model.Asset{}).Where("id = ?", target.ID).Count(&targetCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Asset{}).Where("id = ?", independent.ID).Count(&independentCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.ResourceDeletionJob{}).Count(&jobCount).Error; err != nil { + t.Fatal(err) + } + if targetCount != 0 || independentCount != 1 || resourceCount != 1 || jobCount != 0 { + t.Fatalf("unexpected delete result: target=%d independent=%d resource=%d jobs=%d", targetCount, independentCount, resourceCount, jobCount) + } +} + +func TestDeleteAssetStillRejectsLiveCanvasResourceReference(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ID: "resource-canvas", UserID: "user-1", Provider: "local", ObjectKey: "users/user-1/image/canvas.png", Status: model.ResourceStatusReady} + asset := model.Asset{ID: "asset-canvas", UserID: "user-1", Title: "画布素材", PayloadJSON: `{"data":{"storageKey":"resource:resource-canvas"}}`} + canvas := model.CanvasProject{ID: "canvas-live", UserID: "user-1", Title: "仍在使用的画布", PayloadJSON: `{"nodes":[{"data":{"storageKey":"resource:resource-canvas"}}]}`} + for _, item := range []any{&resource, &asset, &canvas} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + err := svc.DeleteUserAsset("user-1", asset.ID) + if err == nil || !strings.Contains(err.Error(), "画布「仍在使用的画布」") { + t.Fatalf("DeleteUserAsset() error = %v, want live canvas reference", err) + } + + var assetCount, resourceCount int64 + if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { + t.Fatal(err) + } + if assetCount != 1 || resourceCount != 1 { + t.Fatalf("blocked delete changed data: asset=%d resource=%d", assetCount, resourceCount) + } +} + func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { svc, db, dataDir := newResourceDeletionTestService(t) objectKey := "users/user-1/image/queued.png" diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 6901d2b8e..da88cfc4f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -126,6 +126,14 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在任务运行中和任务刚完成尚未写回结果时分别刷新项目,确认对应章节继续显示累计计时或“已生成”,成功结果会自动写回;再检查无新角色、生成失败和重新生成替换确认场景的按钮恢复、错误提示和旧分镜保护。 - [ ] 在浅色、深色、常规桌面和窄屏下检查全局头部:项目名、状态、项目导航、右侧全局操作和新建画布/章节选择不重叠,导航可横向滚动,内容区高度确实增加。 +## 画布生成副本与结果落点 + +- [x] 图片、视频节点右键菜单新增“创建生成副本”:副本保留提示词、模型参数、首尾帧设置和原有参考入边,但不加入原节点的参数版本族。 +- [x] 图片与视频生成统一使用结构化结果落点;生成副本、复制粘贴节点和参数变体直接回填当前节点,普通已有媒体再次生成继续创建新版本,不再依赖节点标题判断。 +- [x] 参考入边复用、图片/视频原地生成策略专项测试、TypeScript 检查与前端生产构建通过。 +- [ ] 分别对带一张、多张参考图及首尾帧设置的图片和视频使用“创建生成副本”,确认副本提示词、模型参数、参考缩略图和连线与原节点一致;连续生成多次时只更新副本自身,不新增下一级节点。 +- [ ] 对普通已有图片、普通已有视频、参数变体和旧版复制节点分别生成,确认普通媒体创建并行版本,参数变体和复制节点原地更新;刷新画布后结果落点不改变。 + ## 画布生成结果命名 - [x] 快捷键复制节点生成图片后保留 `_copyN`,参数变体生成图片后保留 `A/B/C/D` 版本标识;批量输出追加结果序号。 @@ -574,6 +582,35 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在拉取镜像、创建备份、迁移、启动和健康检查阶段分别模拟失败,确认停服前失败不会改变运行版本,停服后失败进入回退,回退失败明确显示“需人工介入”。 - [ ] 更新期间刷新页面、关闭浏览器和 Backend 短暂不可用,确认宿主机操作不中断,恢复后日志与最终状态完整。 +## 大型画布媒体节点性能 + +大型画布已修复 LibTV 图片强制并发加载、缩略图反向覆盖原始尺寸、节点操作 Context 失稳和小范围平移频繁卸载媒体节点的问题;以下场景仍需使用真实大型画布确认: + +- [x] 前端专项测试覆盖 LibTV 延迟加载、尺寸回写保护、节点渲染滞回、Context 稳定引用,以及缩放/平移期间不得隐藏节点壳;TypeScript 检查与生产构建通过。 +- [ ] 导入包含数百个图片、视频节点的 LibTV 画布,确认初次打开不会同时加载全部图片,平移和反向平移时已显示图片不再反复闪烁或重新解码。 +- [ ] 在自动、性能、画质三种模式下分别缩放和平移,确认操作期间视口内节点持续可见,远离视口的节点仍会卸载,选中、拖动、连线和节点尺寸保持正确。 +- [ ] 使用浏览器 Performance 与 Network 面板记录导入前后数据,确认平移期间主线程长任务、图片并发请求和节点重复渲染明显下降。 + +## 大型画布进入详情性能 + +画布列表进入详情已增加详情模块预取、点击即时反馈和可中断的数据恢复渲染;节点引用改为一次建图索引,列表封面结果按节点数组缓存,避免延迟随节点数快速放大。以下场景仍需使用真实大型画布确认: + +- [x] 专项测试覆盖详情模块预取、点击反馈时序、批量引用索引语义;TypeScript 检查与生产构建通过。 +- [ ] 首次打开应用后悬停并点击包含数百节点的 LibTV 画布,确认卡片立即出现“正在打开”,随后进入详情加载态,不再数秒无反馈。 +- [ ] 分别使用空画布、100 节点和数百节点画布记录从点击到详情可见的耗时,确认增长接近线性且列表返回后再次进入更快。 + +## 画布保存被历史素材阻断 + +- [x] 素材远端同步会在发送副本中移除超过后端约束的历史 `primaryVersionId`,合法项目资产版本 UUID 原样保留,本地素材对象不被修改。 +- [x] 后端 36 字符强校验保持不变;前端专项测试覆盖非法历史值清理、合法 UUID 保留与本地对象不可变。 +- [ ] 使用曾出现“素材主版本 ID 不能超过 36 个字符”的账号编辑并保存画布,确认云端同步成功,刷新后画布布局仍然存在。 + +## 独立素材共享文件删除 + +- [x] 素材删除不再把另一条素材共用同一资源文件误判为素材实体引用;删除当前素材时保留其他素材仍使用的资源记录与物理文件。 +- [x] 项目、镜头等真实业务引用仍会阻止素材删除;后端专项测试覆盖两个独立素材共享同一资源的删除场景。 +- [ ] 在素材库删除曾由同一生成结果产生的重复素材,确认当前素材删除成功,其他素材仍可预览和下载;实际仍关联项目或镜头的素材继续显示引用来源。 + ## 管理后台存储资源 管理后台已新增资源列表、筛选、去重容量统计、预览下载和安全批量删除;以下场景仍需使用真实资源数据确认: diff --git a/web/package.json b/web/package.json index b8bb28ee2..1491c6a24 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,7 @@ "build": "bun run build:bridge && tsc --noEmit && vite build", "pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts", "typecheck": "tsc --noEmit", - "test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts", + "test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts", "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs", "start": "vite preview --host 0.0.0.0 --port 3000", diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index c7cc2d4e5..f332c6fea 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -38,6 +38,7 @@ type CanvasNodeContextMenuProps = { onRedo: () => void; onPaste: () => void; onCopyNode: () => void; + onCreateGenerationCopy: () => void; onDuplicate: () => void; onDelete: () => void; onSaveAsset: () => void; @@ -72,6 +73,7 @@ export function CanvasNodeContextMenu({ onRedo, onPaste, onCopyNode, + onCreateGenerationCopy, onDuplicate, onDelete, onSaveAsset, @@ -201,6 +203,7 @@ export function CanvasNodeContextMenu({ } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} /> + } label="创建生成副本" onClick={() => runAction(onCreateGenerationCopy)} /> } label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="删除节点" danger onClick={() => runAction(onDelete)} /> diff --git a/web/src/components/canvas/canvas-folder-card.tsx b/web/src/components/canvas/canvas-folder-card.tsx index 328c08a4c..9b5d36465 100644 --- a/web/src/components/canvas/canvas-folder-card.tsx +++ b/web/src/components/canvas/canvas-folder-card.tsx @@ -1,5 +1,5 @@ import { Dropdown, Input } from "antd"; -import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { Download, LoaderCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import type { KeyboardEvent } from "react"; import { ProjectPreview } from "@/components/canvas/canvas-project-card"; @@ -12,10 +12,12 @@ type CanvasFolderCardProps = { project: CanvasProject; projectName?: string; onClick: () => void; + onPrefetch?: () => void; + opening?: boolean; }; /** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */ -export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) { +export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, opening = false }: CanvasFolderCardProps) { const renameProject = useCanvasStore((state) => state.renameProject); const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds); const editingId = useCanvasUiStore((state) => state.editingProjectId); @@ -42,10 +44,11 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder }; return ( -
-
!editing && onClick()} onKeyDown={handleOpenKeyDown}> +
+
!editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx index 47cea0404..2a55a75d9 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -3,7 +3,7 @@ import { Group, Leafer, Path, Rect } from "leafer-ui"; import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections"; import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection"; -import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport"; +import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasSelectionPreview, type CanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport"; import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas"; @@ -37,6 +37,18 @@ type LeaferScene = { type UnderlayScene = LeaferScene & { connections: Group; + connectionEntries: Map; + connectionIdsByNodeId: Map>; + dragPreview: CanvasNodeDragPreview | null; + dragPreviewConnectionIds: Set; +}; + +type ConnectionSceneEntry = { + path: Path; + connection: CanvasDisplayConnection["connection"]; + from: CanvasNodeData; + to: CanvasNodeData; + signature: string; }; type OverlayScene = LeaferScene & { @@ -104,11 +116,15 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => { syncSelection(overlay.selection, selection, propsRef.current.theme); }); + const unsubscribeNodeDrag = subscribeCanvasNodeDragPreview(container, (preview) => { + applyConnectionDragPreview(underlay, propsRef.current, preview); + }); resize(); return () => { unsubscribe(); unsubscribeSelection(); + unsubscribeNodeDrag(); resizeObserver.disconnect(); window.removeEventListener("resize", resize); underlay.leafer.destroy(true); @@ -173,7 +189,7 @@ function createUnderlayScene(host: HTMLDivElement): UnderlayScene { const connections = new Group({ hittable: false }); world.add(connections); leafer.add(world); - return { leafer, world, host, connections }; + return { leafer, world, host, connections, connectionEntries: new Map(), connectionIdsByNodeId: new Map(), dragPreview: null, dragPreviewConnectionIds: new Set() }; } function createOverlayScene(host: HTMLDivElement): OverlayScene { @@ -194,22 +210,109 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene { } function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) { - scene.connections.removeAll(true); - props.displayConnections.forEach(({ connection, from, to }) => { + const nextIds = new Set(props.displayConnections.map(({ connection }) => connection.id)); + for (const [connectionId, entry] of scene.connectionEntries) { + if (nextIds.has(connectionId)) continue; + entry.path.remove(); + scene.connectionEntries.delete(connectionId); + } + + scene.connectionIdsByNodeId.clear(); + const previewIds = scene.dragPreview ? scene.dragPreview.nodeIds : null; + for (const { connection, from, to } of props.displayConnections) { const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id); - const path = new Path({ - path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD, - stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted, - strokeWidth: emphasized ? 1.6 : 1, - strokeScaleFixed: true, - strokeCap: "round", - opacity: emphasized ? 0.52 : 0.24, - hittable: false, - }); - scene.connections.add(path); + const signature = connectionSceneSignature(connection, from, to, props, emphasized); + let entry = scene.connectionEntries.get(connection.id); + if (!entry) { + const path = new Path({ hittable: false }); + scene.connections.add(path); + entry = { path, connection, from, to, signature: "" }; + scene.connectionEntries.set(connection.id, entry); + } + entry.connection = connection; + entry.from = from; + entry.to = to; + if (entry.signature !== signature || scene.dragPreview) { + syncConnectionPath(entry, props, scene.dragPreview, previewIds); + entry.signature = signature; + } + for (const nodeId of [from.id, to.id]) { + const connectionIds = scene.connectionIdsByNodeId.get(nodeId) || new Set(); + connectionIds.add(connection.id); + scene.connectionIdsByNodeId.set(nodeId, connectionIds); + } + } + + scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, scene.dragPreview); +} + +function connectionSceneSignature(connection: CanvasDisplayConnection["connection"], from: CanvasNodeData, to: CanvasNodeData, props: CanvasLeaferGraphicsLayerProps, emphasized: boolean) { + return [ + connection.id, + connection.fromNodeId, + connection.toNodeId, + connection.fromHandleId || "", + connection.toHandleId || "", + from.id, + from.position.x, + from.position.y, + from.width, + from.height, + to.id, + to.position.x, + to.position.y, + to.width, + to.height, + props.scriptScrollTopById[from.id] || 0, + props.scriptScrollTopById[to.id] || 0, + emphasized ? "active" : "idle", + props.theme.accent.primary, + props.theme.node.muted, + ].join("|"); +} + +function syncConnectionPath(entry: ConnectionSceneEntry, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null, previewIds: ReadonlySet | null = preview?.nodeIds || null) { + const from = translatePreviewNode(entry.from, previewIds, preview); + const to = translatePreviewNode(entry.to, previewIds, preview); + const emphasized = props.selectedConnectionId === entry.connection.id || props.relatedConnectionIds.has(entry.connection.id); + entry.path.set({ + path: canvasConnectionPath(entry.connection, from, to, props.scriptScrollTopById[entry.from.id] || 0, props.scriptScrollTopById[entry.to.id] || 0).pathD, + stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted, + strokeWidth: emphasized ? 1.6 : 1, + strokeScaleFixed: true, + strokeCap: "round", + opacity: emphasized ? 0.52 : 0.24, + hittable: false, }); } +function translatePreviewNode(node: CanvasNodeData, previewIds: ReadonlySet | null, preview: CanvasNodeDragPreview | null) { + if (!preview || !previewIds?.has(node.id) || (preview.x === 0 && preview.y === 0)) return node; + return { ...node, position: { x: node.position.x + preview.x, y: node.position.y + preview.y } }; +} + +function collectPreviewConnectionIds(scene: UnderlayScene, preview: CanvasNodeDragPreview | null) { + const connectionIds = new Set(); + if (!preview) return connectionIds; + for (const nodeId of preview.nodeIds) { + scene.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => connectionIds.add(connectionId)); + } + return connectionIds; +} + +function applyConnectionDragPreview(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null) { + const affectedConnectionIds = new Set(scene.dragPreviewConnectionIds); + const previewIds = preview?.nodeIds || null; + collectPreviewConnectionIds(scene, preview).forEach((connectionId) => affectedConnectionIds.add(connectionId)); + scene.dragPreview = preview; + scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, preview); + for (const connectionId of affectedConnectionIds) { + const entry = scene.connectionEntries.get(connectionId); + if (!entry) continue; + syncConnectionPath(entry, props, preview, previewIds); + } +} + function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) { const selection = props.selectionBox; scene.selection.visible = Boolean(selection); diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts index 8029ac512..0e6174b11 100644 --- a/web/src/components/canvas/canvas-node-action-context.ts +++ b/web/src/components/canvas/canvas-node-action-context.ts @@ -11,6 +11,10 @@ export type CanvasNodeActionContextValue = { deleteNode?: (node: CanvasNodeData) => void; /** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */ updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void; + /** 更新单个节点;媒体尺寸回写使用它,避免触发全量节点时间戳归并。 */ + updateNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void; + /** 合并延迟提交媒体测量结果,避免首屏图片同时解码时反复刷新画布。 */ + updateMediaNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void; /** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */ resizeNode?: (nodeId: string, size: { width: number; height: number }) => void; /** 打开节点级肖像排查工作台;任务生命周期由画布页面持有。 */ diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index ce9e09955..f3f268977 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -419,7 +419,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) { const playerBoxRef = useRef(null); - const { updateMetadata } = useCanvasNodeActions(); + const { updateMediaNode } = useCanvasNodeActions(); const { url, loading } = useNodeResourceUrl(node, mediaActive); const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); @@ -434,7 +434,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false if (video.videoWidth <= 0 || video.videoHeight <= 0) return; setVideoSize({ width: video.videoWidth, height: video.videoHeight }); if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) { - updateMetadata?.(node.id, { naturalWidth: video.videoWidth, naturalHeight: video.videoHeight }); + updateMediaNode?.(node.id, (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: video.videoWidth, naturalHeight: video.videoHeight } })); } }; video.addEventListener("loadedmetadata", handleLoadedMetadata); @@ -446,7 +446,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false video.removeEventListener("timeupdate", handleTimeUpdate); video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; - }, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMetadata, url]); + }, [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 ; @@ -535,7 +535,8 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); const importedFromLibTV = node.metadata?.importSource?.provider === "libtv"; - const { resizeNode, updateMetadata } = useCanvasNodeActions(); + const { updateMediaNode } = useCanvasNodeActions(); + const measuredSizeRef = useRef<{ width: number; height: number } | null>(null); /** * 让节点跟随图片真实比例。 @@ -548,23 +549,34 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat * 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。 */ const fitToImage = (element: HTMLImageElement) => { + // LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。 + if (importedFromLibTV) return; const naturalWidth = element.naturalWidth; const naturalHeight = element.naturalHeight; if (!naturalWidth || !naturalHeight) return; - if (node.metadata?.naturalWidth !== naturalWidth || node.metadata?.naturalHeight !== naturalHeight) { - updateMetadata?.(node.id, { naturalWidth, naturalHeight }); - } - if (node.metadata?.freeResize || node.metadata?.manualSize) return; - const size = fitNodeSize(naturalWidth, naturalHeight); - // 差不到 1px 就别动,避免无意义的状态写入。 - if (Math.abs(size.width - node.width) < 1 && Math.abs(size.height - node.height) < 1) return; - resizeNode?.(node.id, size); + if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return; + measuredSizeRef.current = { width: naturalWidth, height: naturalHeight }; + updateMediaNode?.(node.id, (current) => { + const metadata = current.metadata; + const needsMetadata = metadata?.naturalWidth !== naturalWidth || metadata?.naturalHeight !== naturalHeight; + if (current.metadata?.freeResize || current.metadata?.manualSize) { + return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current; + } + const size = fitNodeSize(naturalWidth, naturalHeight); + const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1; + if (!needsMetadata && !needsResize) return current; + return { + ...current, + ...(needsResize ? size : {}), + metadata: needsMetadata ? { ...metadata, naturalWidth, naturalHeight } : metadata, + }; + }); }; return (
- {url ? {node.title} event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
} + {url ? {node.title} event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
}
); @@ -584,18 +596,23 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { : node.metadata?.previewContent || (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content); const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); - const [url, setUrl] = useState(isRemoteResource ? "" : fallback); + // Inline data URLs are already local, but decoding thousands of them is + // still expensive. Images must wait for the same viewport gate as remote + // resources; otherwise DOM virtualization does not reduce image work. + const isLazyVisual = node.type === CanvasNodeType.Image; + const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback); const [loading, setLoading] = useState(isRemoteResource && eager); useEffect(() => { let cancelled = false; if (!isRemoteResource) { - setUrl(fallback); + setUrl(isLazyVisual && !eager ? "" : fallback); setLoading(false); return; } setUrl(""); setLoading(eager); + // 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。 const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); void resolve.then((cached) => { if (!cancelled) setUrl(cached || (eager ? fallback : "")); @@ -605,7 +622,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [eager, fallback, isRemoteResource, storageKey]); + }, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]); return { url, loading }; } diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx index 87a70d28a..a37cf992c 100644 --- a/web/src/components/canvas/canvas-project-card.tsx +++ b/web/src/components/canvas/canvas-project-card.tsx @@ -12,6 +12,9 @@ import { resolveBackendApiUrl } from "@/stores/use-config-store"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { cn } from "@/lib/utils"; +type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string }; +const projectPreviewMediaCache = new WeakMap(); + export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) { return