diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fd29a7f1b..9383414af 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图片画布素材库删除要匹配 sourceResourceId + +- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。 +- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId`、`assetObjectId`、`objectKey` 或 `src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。 +- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId` 与 `asset.sourceResourceId`。 +- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 +- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 + ## 编辑器生成按钮显示泥点后仍要查真实钱包预扣 - 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。 diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 9709de426..30f37fa60 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -77,7 +77,13 @@ vi.mock('../../services/assetReadUrlService', async () => { ); return { ...actual, - getSignedAssetReadUrl: getSignedAssetReadUrlMock, + getSignedAssetReadUrl: ( + ...args: Parameters + ) => + getSignedAssetReadUrlMock(...args) ?? + Promise.resolve( + `https://signed.example.com/${args[0]?.objectKey ?? 'asset'}`, + ), }; }); @@ -123,21 +129,36 @@ function createSizedPngFile(name: string, width: number, height: number) { function createTestImageAsset({ assetId, label, + imageSrc, + objectKey, + assetObjectId, + sourceType = 'uploaded', + sourceResourceId, }: { assetId: string; label: string; + imageSrc?: string; + objectKey?: string | null; + assetObjectId?: string | null; + sourceType?: 'uploaded' | 'generated' | 'mock_generated'; + sourceResourceId?: string | null; }) { - const objectKey = `generated-character-drafts/editor/asset-library/image/${assetId}.png`; + const resolvedObjectKey = + objectKey === undefined + ? `generated-character-drafts/editor/asset-library/image/${assetId}.png` + : objectKey; return { assetId, folderId: 'project', label, - imageSrc: `/${objectKey}`, - objectKey, - assetObjectId: `assetobj-${assetId}`, + imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''), + objectKey: resolvedObjectKey, + assetObjectId: + assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId, width: 320, height: 240, - sourceType: 'uploaded' as const, + sourceType, + sourceResourceId, }; } @@ -787,6 +808,84 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); }); + it('saves the canvas layout after deleting an asset linked by source resource id', async () => { + const user = userEvent.setup(); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '生成资源项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-generated-a', + resourceId: 'resource-generated-a', + title: '生成资源A', + x: 100, + y: 120, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + }, + ], + resources: [ + { + resourceId: 'resource-generated-a', + projectId: 'editor-project-default', + imageSrc: '/editor-test/project/generated-a.png', + width: 320, + height: 240, + sourceType: 'generated', + }, + ], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createTestImageAsset({ + assetId: 'asset-generated-a', + label: '生成资源A', + imageSrc: '/editor-test/asset-library/copied-a.png', + objectKey: null, + assetObjectId: null, + sourceType: 'generated', + sourceResourceId: 'resource-generated-a', + }), + ], + }); + render(); + + expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '删除素材生成资源A' })); + + await waitFor(() => { + expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull(); + }); + await waitFor(() => { + const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls + .map((call) => call[1]) + .find((layout) => + layout.layers.every( + (item: { layerId?: unknown }) => + item.layerId !== 'layer-generated-a', + ), + ); + expect(savedLayoutWithoutDeletedLayer).toBeTruthy(); + }); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a'); + }); + it('saves a library asset layer right after creating its canvas resource', async () => { const user = userEvent.setup(); createEditorProjectResourceMock.mockResolvedValueOnce({ diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 9bfa2b394..31922ec29 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -139,6 +139,7 @@ describe('ImageCanvasEditorModel', () => { persisted: true, objectKey: 'oss/asset-1.png', assetObjectId: 'object-1', + sourceResourceId: 'resource-source-1', }; const layer = createLayerFromAsset( @@ -158,11 +159,43 @@ describe('ImageCanvasEditorModel', () => { objectKey: 'oss/asset-1.png', assetObjectId: 'object-1', sourceAssetId: 'asset-1', + sourceResourceId: 'resource-source-1', }); expect(layer.x).toBe(-18); expect(layer.y).toBe(12); }); + it('preserves source resource ids from the persisted asset library', () => { + const library = normalizeAssetLibrary({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-generated', + folderId: 'project', + label: '生成素材', + imageSrc: '/generated-character-drafts/editor/asset-library/generated.png', + width: 640, + height: 640, + sourceType: 'generated', + sourceResourceId: 'resource-generated', + }, + ], + }); + + expect(library.assets[0]).toMatchObject({ + id: 'asset-generated', + sourceResourceId: 'resource-generated', + }); + }); + it('serializes and hydrates canvas layer metadata without embedding image payloads', () => { const layer: CanvasLayer = { id: 'layer-generated', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 594e6ca96..4bcdf4f18 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -179,6 +179,7 @@ export function createLayerFromAsset( taskId: asset.taskId, objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, + sourceResourceId: asset.sourceResourceId, durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, assetKind: asset.assetKind ?? assetKind, @@ -709,6 +710,7 @@ export function mapAssetLibrarySnapshot( taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, + sourceResourceId: asset.sourceResourceId ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), durationSeconds: asset.durationSeconds ?? undefined, @@ -970,11 +972,21 @@ export function escapeCssIdentifier(value: string) { } export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { + const assetSourceResourceId = asset.sourceResourceId?.trim(); + const assetObjectKey = asset.objectKey?.trim().replace(/^\/+/u, ''); + const layerObjectKey = layer.objectKey?.trim().replace(/^\/+/u, ''); + const assetSrc = asset.src.trim().replace(/^\/+/u, ''); + const layerSrc = layer.src.trim().replace(/^\/+/u, ''); return ( layer.sourceAssetId === asset.id || + Boolean( + assetSourceResourceId && + (layer.resourceId === assetSourceResourceId || + layer.sourceResourceId === assetSourceResourceId), + ) || Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) || - Boolean(asset.objectKey && layer.objectKey === asset.objectKey) || - layer.src === asset.src + Boolean(assetObjectKey && layerObjectKey === assetObjectKey) || + Boolean(assetSrc && layerSrc === assetSrc) ); } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 3153562df..441761985 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -47,6 +47,7 @@ export type EditorAsset = { taskId?: string; objectKey?: string; assetObjectId?: string; + sourceResourceId?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx index 6b38dcfeb..d61c6f0f7 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -148,10 +148,7 @@ function AssetCanvasBridgeHarness({ function AssetCleanupHarness({ deletedAssets = [], -}: { - deletedAssets?: EditorAsset[]; -}) { - const [layers, setLayers] = useState([ + initialLayers = [ createLayer({ id: 'linked', sourceAssetId: 'asset-1' }), createLayer({ id: 'kept', @@ -159,7 +156,12 @@ function AssetCleanupHarness({ src: 'data:image/png;base64,asset-2', sourceAssetId: 'asset-2', }), - ]); + ], +}: { + deletedAssets?: EditorAsset[]; + initialLayers?: CanvasLayer[]; +}) { + const [layers, setLayers] = useState(initialLayers); const [selectedLayerId, setSelectedLayerId] = useState('linked'); const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']); const cleanup = useImageCanvasAssetLayerCleanup({ @@ -249,4 +251,42 @@ describe('useImageCanvasAssetCanvasBridge', () => { expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('selected-many').textContent).toBe('kept'); }); + + it('removes canvas layers linked by the asset source resource id', () => { + render( + , + ); + + act(() => { + screen.getByRole('button', { name: '清理素材' }).click(); + }); + + expect(screen.getByTestId('layers').textContent).toBe('kept'); + }); });