From 9690f2793080f72134ccc05a29133c7d44303821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 20:34:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8D=E5=85=BC=E5=AE=B9=E6=A0=87=E7=AD=BE=E5=9B=BE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端按媒体族禁用不兼容标签,并在更新入口再次校验。 重载历史非法覆盖时保留图层、回退资源默认类型并显示提示。 只重写命中的布局项并自动保存清理后的覆盖,保留其他历史项。 补充标签矩阵、菜单限制及保存重载回归测试并同步架构决策文档。 --- .../shared-memory/decision-log.md | 2 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasEditorModel.test.ts | 61 ++++++++++++++- .../image-editor/ImageCanvasEditorModel.ts | 70 ++++++++++++++++- .../image-editor/ImageCanvasEditorView.tsx | 15 +++- .../ImageCanvasWorldView.test.tsx | 22 ++++++ .../image-editor/ImageCanvasWorldView.tsx | 52 ++++++++----- .../useImageCanvasProjectPersistence.test.tsx | 75 +++++++++++++++++++ .../useImageCanvasProjectPersistence.ts | 47 ++++++++++-- 9 files changed, 317 insertions(+), 29 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 61e891e7e..30ee7765c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5982,6 +5982,8 @@ - local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。 - schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::)] asset_kind_override: Option`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`。 - 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。 +- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族。前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。 +- 历史恢复:客户端读取到已持久化的跨族 override 时必须保留图层、回退资源默认类型、显示明确提示并自动提交清理后的布局;不得再用 `hydrateLayer() -> null -> filter(Boolean)` 静默隐藏持久层仍存在的图层。修复保存只替换命中图层的规范化布局项,其他尚不能 hydrate 的历史项原样保留,避免修复一个标签时顺带删除无关数据。 ## 2026-08-03 Agent Runtime 原生工具合同本地失败关闭 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 10d916ed3..1394d50a1 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -14,7 +14,7 @@ - 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 - 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 -- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 +- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。标签覆盖必须与资源默认类型属于同一媒体族:`character-animation` 独立为动作族,`video` 独立为视频族,`audio/sound-effect/background-music` 属于音频族,其余类型和空默认值属于图片族;标签菜单禁用跨族选项,更新回调再次校验。加载到历史跨族 override 时不得静默过滤图层,必须清除内存 override、按资源默认类型恢复图层、向用户提示并排队修复保存。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 3161c3190..a6fc38e42 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CANVAS_WORLD_ORIGIN, @@ -9,6 +9,7 @@ import { formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, + isCanvasAssetKindOverrideCompatible, normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveLayerResourceAssetKind, @@ -737,6 +738,64 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('falls back from an incompatible persisted override without dropping the layer', () => { + const onAssetKindOverrideFallback = vi.fn(); + const hydrated = hydrateLayer( + { + layerId: 'layer-image-with-action-label', + resourceId: 'resource-image', + title: '普通图片', + x: 10, + y: 20, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'uploaded', + assetKindOverride: 'character-animation', + }, + new Map([ + ['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }], + ]), + { onAssetKindOverrideFallback }, + ); + + expect(hydrated).toMatchObject({ + id: 'layer-image-with-action-label', + src: '/read/image.png', + mediaType: 'image', + resourceAssetKind: 'image', + assetKindOverride: null, + assetKind: 'image', + }); + expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({ + layerId: 'layer-image-with-action-label', + resourceId: 'resource-image', + resourceAssetKind: 'image', + rejectedAssetKindOverride: 'character-animation', + }); + }); + + it('uses the same media-family compatibility matrix for layer labels', () => { + expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe( + true, + ); + expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true); + expect( + isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'), + ).toBe(true); + expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false); + expect( + isCanvasAssetKindOverrideCompatible('image', 'character-animation'), + ).toBe(false); + expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe( + false, + ); + expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false); + expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true); + }); + it('hydrates audio display duration only from resource generation inputs', () => { const hydrated = hydrateLayer( { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index d771a3b3d..ccd77d644 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -729,6 +729,11 @@ export function hydrateCanvasGenerationDialog( export function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, + options: { + onAssetKindOverrideFallback?: ( + issue: CanvasAssetKindOverrideFallbackIssue, + ) => void; + } = {}, ): CanvasLayer | null { const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; @@ -742,12 +747,25 @@ export function hydrateLayer( resource?.imageSequenceFrames, ); const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind); - const assetKindOverride = Object.prototype.hasOwnProperty.call( + const hasPersistedAssetKindOverride = Object.prototype.hasOwnProperty.call( snapshot, 'assetKindOverride', - ) + ); + const persistedAssetKindOverride = hasPersistedAssetKindOverride ? canvasAssetKindOrNull(snapshot.assetKindOverride) : canvasAssetKindOrNull(snapshot.assetKind); + const assetKindOverrideFellBack = Boolean( + resource && + hasPersistedAssetKindOverride && + persistedAssetKindOverride && + !isCanvasAssetKindOverrideCompatible( + resourceAssetKind, + persistedAssetKindOverride, + ), + ); + const assetKindOverride = assetKindOverrideFellBack + ? null + : persistedAssetKindOverride; const assetKind = assetKindOverride ?? resourceAssetKind; const isSelfContainedLocalResource = !resource && isSelfContainedLegacyLocalImageSequence(snapshot); @@ -788,6 +806,14 @@ export function hydrateLayer( ) { return null; } + if (assetKindOverrideFellBack && persistedAssetKindOverride) { + options.onAssetKindOverrideFallback?.({ + layerId, + resourceId, + resourceAssetKind, + rejectedAssetKindOverride: persistedAssetKindOverride, + }); + } let resourcePersistenceState: CanvasLayer['resourcePersistenceState']; if (resource) { resourcePersistenceState = 'registered'; @@ -1215,6 +1241,46 @@ export type CanvasLayerResourceMetadata = { updatedAt?: string | null; }; +export type CanvasAssetKindOverrideFallbackIssue = { + layerId: string; + resourceId: string; + resourceAssetKind: CanvasAssetKind | null; + rejectedAssetKindOverride: CanvasAssetKind; +}; + +type CanvasAssetMediaFamily = + 'image' | 'character-animation' | 'video' | 'audio'; + +function resolveCanvasAssetMediaFamily( + assetKind: CanvasAssetKind | null | undefined, +): CanvasAssetMediaFamily { + if (assetKind === 'character-animation') { + return 'character-animation'; + } + if (assetKind === 'video') { + return 'video'; + } + if ( + assetKind === 'audio' || + assetKind === 'sound-effect' || + assetKind === 'background-music' + ) { + return 'audio'; + } + return 'image'; +} + +export function isCanvasAssetKindOverrideCompatible( + resourceAssetKind: CanvasAssetKind | null | undefined, + assetKindOverride: CanvasAssetKind | null, +) { + return ( + assetKindOverride === null || + resolveCanvasAssetMediaFamily(resourceAssetKind) === + resolveCanvasAssetMediaFamily(assetKindOverride) + ); +} + export function resolveLayerResourceAssetKind( layer: CanvasLayer, ): CanvasAssetKind | null { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 26d2c8c6f..502fd7c59 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -38,6 +38,7 @@ import { canvasAssetKindOrNull, DEFAULT_CANVAS_BACKGROUND_COLOR, generationInputsOrNull, + isCanvasAssetKindOverrideCompatible, isInlineEditorMediaSource, resolveContextMenuPosition, resolveLayerResourceAssetKind, @@ -1206,6 +1207,7 @@ export function ImageCanvasEditorView({ const { projectId, isProjectReady, + assetKindFallbackNotice, appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, @@ -1423,6 +1425,11 @@ export function ImageCanvasEditorView({ generationSurface.refreshTaskList(); }, [generationSurface]); const showGenerationWarning = generationSurface.showGenerationWarning; + useEffect(() => { + if (assetKindFallbackNotice) { + showGenerationWarning(assetKindFallbackNotice.message); + } + }, [assetKindFallbackNotice, showGenerationWarning]); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { @@ -1878,12 +1885,16 @@ export function ImageCanvasEditorView({ if (!targetLayer || targetLayer.assetKind === assetKind) { return; } + const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer); + if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) { + showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。'); + return; + } captureCanvasHistory({ type: 'change-asset-kind', count: 1, layerIds: [layerId], }); - const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer); const assetKindOverride = assetKind === resourceAssetKind ? null : assetKind; const nextLayer = { @@ -1905,7 +1916,7 @@ export function ImageCanvasEditorView({ setImageContextMenu(null); setContextMenu(null); }, - [captureCanvasHistory], + [captureCanvasHistory, showGenerationWarning], ); const selectAllCanvasObjects = useCallback(() => { const layerSelectionIds = layersRef.current diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 44633ba3e..c0fd28cda 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1067,6 +1067,28 @@ describe('ImageCanvasWorldView', () => { } }); + it('disables labels from other media families', () => { + const imageLayer = createLayer({ + resourceAssetKind: 'image', + assetKind: 'character', + }); + renderWorldView({ layers: [imageLayer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); + + const menu = screen.getByRole('menu', { name: '选择素材标签' }); + expect( + within(menu) + .getByRole('menuitem', { name: '图标' }) + .hasAttribute('disabled'), + ).toBe(false); + for (const label of ['动作', '视频', '音效', '背景音乐']) { + const item = within(menu).getByRole('menuitem', { name: label }); + expect(item.hasAttribute('disabled')).toBe(true); + expect(item.title).toBe('该标签与素材的媒体类型不兼容'); + } + }); + it('hides resolution and duration text for focused audio generation frames', () => { const dialog = createGenerationDialog({ id: 'dialog-audio', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index dcfc3f2ba..ea4403a98 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -31,7 +31,11 @@ import { import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel'; +import { + CANVAS_WORLD_SIZE, + isCanvasAssetKindOverrideCompatible, + resolveLayerResourceAssetKind, +} from './ImageCanvasEditorModel'; import type { CanvasAssetKind, CanvasGenerationDialogState, @@ -1125,23 +1129,35 @@ export function ImageCanvasWorldView({ onWheelCapture={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > - {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( - { - event.stopPropagation(); - onUpdateLayerAssetKind(layer.id, option.value); - setOpenLayerKindMenuId(null); - }} - > - {option.label} - - ))} + {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => { + const compatible = isCanvasAssetKindOverrideCompatible( + resolveLayerResourceAssetKind(layer), + option.value, + ); + return ( + { + event.stopPropagation(); + onUpdateLayerAssetKind(layer.id, option.value); + setOpenLayerKindMenuId(null); + }} + > + {option.label} + + ); + })} ) : null} diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 3dae18b95..5847244c2 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -468,6 +468,9 @@ function ProjectPersistenceHarness({ .join(',')} {canvasBackgroundColor} + + {persistence.assetKindFallbackNotice?.message ?? '-'} + {String(flushCompleted)}