diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index cf6fe7712..51bf1d987 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -14,6 +14,7 @@ - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 - 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 +- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 ## 面板结构 @@ -81,4 +82,5 @@ - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 +- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ccd6e6f0d..5bebf74c8 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -2087,6 +2087,7 @@ fn create_editor_project_resource( project_id.as_str(), owner_user_id.as_str(), source_resource_id.as_ref(), + asset_kind.as_deref(), asset_object_id.as_ref(), object_key.as_ref(), image_src.as_str(), @@ -4948,6 +4949,7 @@ fn find_reusable_project_resource_for_input( project_id: &str, owner_user_id: &str, source_resource_id: Option<&String>, + asset_kind: Option<&str>, asset_object_id: Option<&String>, object_key: Option<&String>, image_src: &str, @@ -4963,14 +4965,15 @@ fn find_reusable_project_resource_for_input( .by_editor_project_resource_project_id() .filter(&project_key) .filter(|resource| { - resource.owner_user_id == owner_user_id - && (resource.resource_id == source_resource_id - || resource - .source_resource_id - .as_deref() - .map(str::trim) - .is_some_and(|value| value == source_resource_id)) - && project_resource_matches_media(resource, asset_object_id, object_key, image_src) + project_resource_matches_reuse_input( + resource, + owner_user_id, + source_resource_id, + asset_kind, + asset_object_id, + object_key, + image_src, + ) }) .collect::>(); matches.sort_by(|left, right| { @@ -4988,6 +4991,26 @@ fn find_reusable_project_resource_for_input( matches.into_iter().next() } +fn project_resource_matches_reuse_input( + resource: &EditorProjectResource, + owner_user_id: &str, + source_resource_id: &str, + asset_kind: Option<&str>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + resource.owner_user_id == owner_user_id + && (resource.resource_id == source_resource_id + || resource + .source_resource_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value == source_resource_id)) + && resource.asset_kind.as_deref() == asset_kind + && project_resource_matches_media(resource, asset_object_id, object_key, image_src) +} + fn matching_optional_text_ref(left: Option<&String>, right: Option<&String>) -> bool { match (normalize_optional_ref(left), normalize_optional_ref(right)) { (Some(left_value), Some(right_value)) => left_value == right_value, @@ -9283,6 +9306,44 @@ mod tests { } } + #[test] + fn editor_project_resource_reuse_rejects_different_asset_kind() { + let resource = generated_editor_project_resource( + "resource-derived", + "task-1", + Some("resource-source"), + ); + + assert!(!project_resource_matches_reuse_input( + &resource, + "user-1", + "resource-source", + Some("icon-spritesheet"), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + )); + } + + #[test] + fn editor_project_resource_reuse_accepts_same_asset_kind() { + let resource = generated_editor_project_resource( + "resource-derived", + "task-1", + Some("resource-source"), + ); + + assert!(project_resource_matches_reuse_input( + &resource, + "user-1", + "resource-source", + Some("icon"), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + )); + } + #[test] fn normalize_editor_generation_pricing_models_accepts_complete_matrix() { let normalized = diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index f6d0e73f2..61435b221 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -303,9 +303,11 @@ export type CanvasHistoryActionType = export type CanvasHistoryAction = { type: CanvasHistoryActionType; count?: number; + layerIds?: string[]; }; export type CanvasHistoryEntry = { + entryId: number; snapshot: CanvasHistorySnapshot; action: CanvasHistoryAction; createdAt: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index b4380fdb2..9f0360f90 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -111,6 +111,7 @@ const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); @@ -136,6 +137,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { createEditorAsset: createEditorAssetMock, createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, + splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, @@ -322,6 +324,10 @@ describe('ImageCanvasEditorView', () => { }); confirmEditorAgentToolCallMock.mockReset(); cancelEditorAgentToolCallMock.mockReset(); + splitEditorIconSpritesheetMock.mockReset(); + splitEditorIconSpritesheetMock.mockImplementation( + () => new Promise(() => undefined), + ); getPlatformProfileDashboardMock.mockResolvedValue({ walletBalance: 1234, totalPlayTimeMs: 0, @@ -2041,6 +2047,160 @@ describe('ImageCanvasEditorView', () => { }); }); + it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => { + const persistedResource = createDeferred(); + createEditorProjectResourceMock.mockImplementationOnce( + () => persistedResource.promise, + ); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + + const pendingSplitButton = await screen.findByRole('button', { + name: '素材类型保存中', + }); + expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true); + expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true'); + fireEvent.click(pendingSplitButton); + expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled(); + + await act(async () => { + persistedResource.resolve({ + resourceId: 'resource-confirmed-atlas', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spritesheet', + }); + await persistedResource.promise; + }); + + const splitButton = await screen.findByRole('button', { + name: '拆分图集', + }); + expect((splitButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(splitButton); + await waitFor(() => { + expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'editor-project-default', + sourceResourceId: 'resource-confirmed-atlas', + }), + ); + }); + }); + + it('rolls back the asset kind and resource when persistence fails', async () => { + const persistedResource = createDeferred(); + createEditorProjectResourceMock.mockImplementationOnce( + () => persistedResource.promise, + ); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + await screen.findByRole('button', { name: '素材类型保存中' }); + + await act(async () => { + persistedResource.reject(new Error('资源类型写入失败')); + try { + await persistedResource.promise; + } catch { + // The component consumes the rejection and restores the prior layer. + } + }); + + await within(layerButton).findByRole('button', { + name: '素材标签:未知', + }); + expect( + screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + }); + + it('rolls back when the resource response keeps the old asset kind', async () => { + createEditorProjectResourceMock.mockResolvedValueOnce({ + resourceId: 'resource-stale-icon-spec', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spec', + }); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + expect( + screen.getByText( + '素材类型保存失败,已恢复原标签:服务器未确认新的素材类型', + ), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + }); + it('persists an undone asset kind and ignores the older pending response', async () => { const characterResource = createDeferred(); const restoredResource = createDeferred(); @@ -2119,6 +2279,167 @@ describe('ImageCanvasEditorView', () => { ).toBe(false); }); + it('rolls back an overlapping undo failure to the last confirmed resource', async () => { + const staleCharacterResource = + createDeferred(); + const failedUndoResource = createDeferred(); + createEditorProjectResourceMock + .mockImplementationOnce(() => staleCharacterResource.promise) + .mockImplementationOnce(() => failedUndoResource.promise); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '角色' }, + ), + ); + await within(layerButton).findByRole('button', { + name: '素材标签:角色', + }); + + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + failedUndoResource.reject(new Error('撤销后资源写入失败')); + try { + await failedUndoResource.promise; + } catch { + // The latest failure must restore the server-confirmed original layer. + } + }); + + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + expect( + screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'), + ).toBeTruthy(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + + const undoButton = screen.getByRole('button', { name: '撤销' }); + expect((undoButton as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(undoButton); + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); + + await act(async () => { + staleCharacterResource.resolve({ + resourceId: 'resource-stale-character-after-failed-undo', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'character', + }); + await staleCharacterResource.promise; + }); + + expect( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ).toBeTruthy(); + expect( + saveEditorProjectLayoutMock.mock.calls.some((call) => + call[1].layers.some( + (layer: { resourceId?: unknown }) => + layer.resourceId === 'resource-stale-character-after-failed-undo', + ), + ), + ).toBe(false); + }); + + it('keeps the newer asset kind pending when an older request fails', async () => { + const staleCharacterResource = + createDeferred(); + const currentAtlasResource = + createDeferred(); + createEditorProjectResourceMock + .mockImplementationOnce(() => staleCharacterResource.promise) + .mockImplementationOnce(() => currentAtlasResource.promise); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '角色' }, + ), + ); + await within(layerButton).findByRole('button', { + name: '素材标签:角色', + }); + + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:角色' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + await screen.findByRole('button', { name: '素材类型保存中' }); + + await act(async () => { + staleCharacterResource.reject(new Error('旧请求失败')); + try { + await staleCharacterResource.promise; + } catch { + // The stale rejection must not affect the newer request state. + } + }); + + expect( + within(layerButton).getByRole('button', { name: '素材标签:图集' }), + ).toBeTruthy(); + const stillPendingButton = screen.getByRole('button', { + name: '素材类型保存中', + }); + expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByText(/素材类型保存失败/u)).toBeNull(); + + await act(async () => { + currentAtlasResource.resolve({ + resourceId: 'resource-current-atlas', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spritesheet', + }); + await currentAtlasResource.promise; + }); + + expect( + await screen.findByRole('button', { name: '拆分图集' }), + ).toBeTruthy(); + }); + it('offers Lovart-style zoom menu commands', async () => { render(); @@ -2251,8 +2572,7 @@ describe('ImageCanvasEditorView', () => { object_image_id: 'source-image', prompt: '把图片换成像素风', }, - displayArgs: - createEditImageToolCallDisplayArgs('把图片换成像素风'), + displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'), images: [], error: null, }, @@ -2273,8 +2593,7 @@ describe('ImageCanvasEditorView', () => { status: 'not_completed', externalJobId: 'task-edit-canvas-1', args: {}, - displayArgs: - createEditImageToolCallDisplayArgs('把图片换成像素风'), + displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'), images: [], error: null, }, @@ -2335,9 +2654,7 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - expect(world.style.transform).toBe( - 'translate(-80px, -120px) scale(0.5)', - ); + expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)'); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 2e4db9067..c30236015 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -316,10 +316,15 @@ export function ImageCanvasEditorView({ const discardCanvasHistoryEntriesContainingLayerRef = useRef< (matchesLayer: (layer: CanvasLayer) => boolean) => void >(() => {}); - const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>( - () => {}, - ); + const persistRestoredAssetKindRef = useRef< + (layer: CanvasLayer, historyEntryId?: number) => void + >(() => {}); const layerResourcePersistenceVersionRef = useRef(new Map()); + const assetKindRollbackLayerByIdRef = useRef(new Map()); + const assetKindHistoryEntryIdsByLayerIdRef = useRef( + new Map>(), + ); + const persistingAssetKindLayerIdsRef = useRef(new Set()); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const closeGenerationTransientStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); @@ -785,8 +790,10 @@ export function ImageCanvasEditorView({ ); const canvasHistoryEffects = useMemo( () => ({ - persistRestoredAssetKind: (layer: CanvasLayer) => - persistRestoredAssetKindRef.current(layer), + persistRestoredAssetKind: ( + layer: CanvasLayer, + historyEntryId?: number, + ) => persistRestoredAssetKindRef.current(layer, historyEntryId), }), [], ); @@ -796,6 +803,7 @@ export function ImageCanvasEditorView({ getCanvasHistorySnapshot, captureCanvasHistory, discardCanvasHistoryEntriesContainingLayer, + discardCanvasHistoryEntriesById, undoCanvasChange, redoCanvasChange, } = useCanvasHistory({ @@ -809,6 +817,8 @@ export function ImageCanvasEditorView({ discardCanvasHistoryEntriesContainingLayer; const [historyFeedback, setHistoryFeedback] = useState(null); + const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] = + useState>(() => new Set()); useEffect(() => { if (!historyFeedback) { return; @@ -1187,18 +1197,105 @@ export function ImageCanvasEditorView({ .then(applyGeneratedProjectSnapshot) .catch(() => undefined); }, [applyGeneratedProjectSnapshot, projectId]); + const setLayerAssetKindPersistencePending = useCallback( + (layerId: string, isPending: boolean) => { + const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current); + if (isPending) { + nextLayerIds.add(layerId); + } else { + nextLayerIds.delete(layerId); + } + persistingAssetKindLayerIdsRef.current = nextLayerIds; + setPersistingAssetKindLayerIds(nextLayerIds); + }, + [], + ); const persistUpdatedLayerResource = useCallback( - (layer: CanvasLayer) => { + ( + layer: CanvasLayer, + rollbackLayer?: CanvasLayer, + historyEntryId?: number, + ) => { + const requestVersion = + (layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1; + layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion); + const confirmedRollbackLayer = rollbackLayer + ? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer) + : undefined; + if (confirmedRollbackLayer) { + assetKindRollbackLayerByIdRef.current.set( + layer.id, + confirmedRollbackLayer, + ); + if (historyEntryId !== undefined) { + const historyEntryIds = new Set( + assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id), + ); + historyEntryIds.add(historyEntryId); + assetKindHistoryEntryIdsByLayerIdRef.current.set( + layer.id, + historyEntryIds, + ); + } + setLayerAssetKindPersistencePending(layer.id, true); + } + const rollbackLatestRequest = (error: unknown) => { + if ( + layerResourcePersistenceVersionRef.current.get(layer.id) !== + requestVersion || + !confirmedRollbackLayer + ) { + return; + } + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + assetKind: confirmedRollbackLayer.assetKind, + resourceId: confirmedRollbackLayer.resourceId, + } + : currentLayer, + ); + layersRef.current = nextLayers; + return nextLayers; + }); + setMetadataLayer((currentLayer) => + currentLayer?.id === layer.id + ? { + ...currentLayer, + assetKind: confirmedRollbackLayer.assetKind, + resourceId: confirmedRollbackLayer.resourceId, + } + : currentLayer, + ); + const historyEntryIds = + assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id); + if (historyEntryIds) { + discardCanvasHistoryEntriesById(historyEntryIds); + } + assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id); + assetKindRollbackLayerByIdRef.current.delete(layer.id); + setLayerAssetKindPersistencePending(layer.id, false); + const reason = + error instanceof Error && error.message.trim() + ? error.message.trim() + : '请稍后重试'; + setHistoryFeedback((currentFeedback) => ({ + id: (currentFeedback?.id ?? 0) + 1, + tone: 'warning', + text: `素材类型保存失败,已恢复原标签:${reason}`, + })); + }; if (!projectId) { + rollbackLatestRequest(new Error('项目尚未就绪')); return; } const imageSrc = resolveLayerProjectResourceImageSrc(layer); if (!imageSrc) { + rollbackLatestRequest(new Error('素材图片尚未就绪')); return; } - const requestVersion = - (layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1; - layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion); void createEditorProjectResource(projectId, { imageSrc, objectKey: layer.objectKey, @@ -1215,30 +1312,68 @@ export function ImageCanvasEditorView({ sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, - }).then((resource) => { - if ( - layerResourcePersistenceVersionRef.current.get(layer.id) !== - requestVersion - ) { - return; - } - setLayers((currentLayers) => { - const nextLayers = currentLayers.map((currentLayer) => - currentLayer.id === layer.id + }) + .then((resource) => { + if ( + layerResourcePersistenceVersionRef.current.get(layer.id) !== + requestVersion + ) { + return; + } + if ( + confirmedRollbackLayer && + (resource.assetKind !== layer.assetKind || + !resource.resourceId.trim()) + ) { + rollbackLatestRequest(new Error('服务器未确认新的素材类型')); + return; + } + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + assetKind: layer.assetKind, + resourceId: resource.resourceId, + } + : currentLayer, + ); + layersRef.current = nextLayers; + return nextLayers; + }); + setMetadataLayer((currentLayer) => + currentLayer?.id === layer.id ? { ...currentLayer, + assetKind: layer.assetKind, resourceId: resource.resourceId, } : currentLayer, ); - layersRef.current = nextLayers; - return nextLayers; - }); - }); + if (confirmedRollbackLayer) { + assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id); + assetKindRollbackLayerByIdRef.current.delete(layer.id); + setLayerAssetKindPersistencePending(layer.id, false); + } + }) + .catch(rollbackLatestRequest); }, - [projectId], + [ + discardCanvasHistoryEntriesById, + projectId, + setLayerAssetKindPersistencePending, + ], ); - persistRestoredAssetKindRef.current = persistUpdatedLayerResource; + persistRestoredAssetKindRef.current = (layer, historyEntryId) => { + const rollbackLayer = layersRef.current.find( + (currentLayer) => currentLayer.id === layer.id, + ); + persistUpdatedLayerResource( + layer, + rollbackLayer ?? layer, + historyEntryId, + ); + }; const { assetExportStatus, isExportingAssets, @@ -1793,7 +1928,11 @@ export function ImageCanvasEditorView({ if (!targetLayer || targetLayer.assetKind === assetKind) { return; } - captureCanvasHistory({ type: 'change-asset-kind', count: 1 }); + const historyEntryId = captureCanvasHistory({ + type: 'change-asset-kind', + count: 1, + layerIds: [layerId], + }); const nextLayer = { ...targetLayer, assetKind, @@ -1808,7 +1947,7 @@ export function ImageCanvasEditorView({ setMetadataLayer((currentLayer) => currentLayer?.id === layerId ? nextLayer : currentLayer, ); - persistUpdatedLayerResource(nextLayer); + persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId); setImageContextMenu(null); setContextMenu(null); }, @@ -2216,6 +2355,7 @@ export function ImageCanvasEditorView({ generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds, + persistingAssetKindLayerIds, uploadDropTarget, contextMenu, canvasClipboard, @@ -2266,7 +2406,12 @@ export function ImageCanvasEditorView({ onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, onRemoveBackground: removeSelectedLayerBackground, - onSplitIconSpritesheet: splitSelectedIconSpritesheet, + onSplitIconSpritesheet: (layer: CanvasLayer) => { + if (persistingAssetKindLayerIdsRef.current.has(layer.id)) { + return; + } + void splitSelectedIconSpritesheet(layer); + }, onExtractUiDesignAssets: extractUiDesignAssets, onUiAssetExtractionToolChange: changeUiAssetExtractionTool, onUiAssetExtractionModelChange: changeUiAssetExtractionModel, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index df80f1392..25401a6f4 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -183,6 +183,22 @@ describe('ImageCanvasHistoryModel', () => { ]); }); + it('preserves the current asset kind for layers outside the history action target', () => { + const targetLayer = createLayer({ assetKind: 'icon-spritesheet', x: 10 }); + const currentLayer = createLayer({ assetKind: 'character', x: 90 }); + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([currentLayer]), + target: createSnapshot([targetLayer]), + assetKindLayerIds: new Set(), + }); + + expect(merged.layers[0]).toMatchObject({ + assetKind: 'character', + x: 10, + }); + }); + it('restores the complete target layer after that layer was deleted', () => { const targetLayer = createLayer({ resourceId: 'deleted-resource', diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index 6f2c2dd71..8ace4aed9 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -131,6 +131,7 @@ function cloneDialogPlaceholder( function mergeLayerForHistoryRestore( target: CanvasLayer, current: CanvasLayer | undefined, + restoreAssetKind: boolean, ): CanvasLayer { if (!current) { return { ...target }; @@ -141,7 +142,7 @@ function mergeLayerForHistoryRestore( y: target.y, zIndex: target.zIndex, groupId: target.groupId, - assetKind: target.assetKind, + assetKind: restoreAssetKind ? target.assetKind : current.assetKind, hidden: target.hidden, locked: target.locked, flipX: target.flipX, @@ -186,9 +187,11 @@ function mergeDialogForHistoryRestore< export function mergeCanvasHistorySnapshotForRestore({ current, target, + assetKindLayerIds, }: { current: CanvasHistorySnapshot; target: CanvasHistorySnapshot; + assetKindLayerIds?: ReadonlySet; }): CanvasHistorySnapshot { const currentLayerById = new Map( current.layers.map((layer) => [layer.id, layer] as const), @@ -210,7 +213,11 @@ export function mergeCanvasHistorySnapshotForRestore({ return { ...target, layers: target.layers.map((layer) => - mergeLayerForHistoryRestore(layer, currentLayerById.get(layer.id)), + mergeLayerForHistoryRestore( + layer, + currentLayerById.get(layer.id), + assetKindLayerIds?.has(layer.id) ?? true, + ), ), generateDialog: target.generateDialog ? mergeTargetDialog(target.generateDialog) diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index ba91a51c7..b06f0013e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -43,6 +43,7 @@ function renderSelectedToolbar( onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), isSplittingIconSpritesheet: false, + isPersistingAssetKind: false, onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), @@ -183,6 +184,25 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); }); + it('does not invoke atlas splitting while the selected asset kind is persisting', () => { + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isPersistingAssetKind: true, + }); + const splitButton = screen.getByRole('button', { + name: '素材类型保存中', + }); + + expect(splitButton.getAttribute('aria-busy')).toBe('true'); + expect((splitButton as HTMLButtonElement).disabled).toBe(true); + expect(splitButton.textContent).toContain('保存中'); + + fireEvent.click(splitButton); + + expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); + }); + it('keeps only remodel and download for audio layers', () => { const layer = createLayer({ title: '游戏音效', @@ -322,6 +342,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} @@ -340,6 +361,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 5c187ea95..b1a423324 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -23,6 +23,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; isSplittingIconSpritesheet: boolean; + isPersistingAssetKind: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; @@ -37,6 +38,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenCropExpandPanel, onRemoveBackground, isSplittingIconSpritesheet, + isPersistingAssetKind, onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, @@ -123,20 +125,38 @@ export function ImageCanvasSelectedLayerToolbarView({ {selectedLayer.assetKind === 'icon-spritesheet' ? ( ) : ( ) } - disabled={isSplittingIconSpritesheet} - aria-busy={isSplittingIconSpritesheet} + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} onClick={() => onSplitIconSpritesheet(selectedLayer)} > - {isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + ) : null} {selectedLayer.assetKind === 'ui-design' ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 9341e22c2..fee6cf878 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = { generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; + persistingAssetKindLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; canvasClipboard: CanvasClipboard | null; @@ -232,6 +233,7 @@ export function ImageCanvasStageView({ generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, + persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, canvasClipboard, @@ -399,6 +401,9 @@ export function ImageCanvasStageView({ selectedLayer && splittingIconSpritesheetLayerIds.has(selectedLayer.id), )} + isPersistingAssetKind={Boolean( + selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index 3db9b51e5..a45157ad6 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -533,6 +533,7 @@ describe('useCanvasHistory', () => { resourceId: 'resource-first-icon', assetKind: undefined, }), + expect.any(Number), ); act(() => { @@ -543,6 +544,7 @@ describe('useCanvasHistory', () => { ); expect(persistRestoredAssetKind).toHaveBeenLastCalledWith( expect.objectContaining({ assetKind: 'icon' }), + persistRestoredAssetKind.mock.calls[0]?.[1], ); expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2); }); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts index fa91a6117..c15f8a953 100644 --- a/src/components/image-editor/useCanvasHistory.ts +++ b/src/components/image-editor/useCanvasHistory.ts @@ -42,7 +42,10 @@ type CanvasHistoryResetters = { }; type CanvasHistoryEffects = { - persistRestoredAssetKind?: (layer: CanvasLayer) => void; + persistRestoredAssetKind?: ( + layer: CanvasLayer, + historyEntryId?: number, + ) => void; }; function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState { @@ -74,6 +77,7 @@ export function useCanvasHistory({ }) { const undoStackRef = useRef([]); const redoStackRef = useRef([]); + const nextHistoryEntryIdRef = useRef(0); const [historyVersion, setHistoryVersion] = useState(0); const getCanvasHistorySnapshot = useCallback( @@ -93,11 +97,21 @@ export function useCanvasHistory({ ); const restoreCanvasHistorySnapshot = useCallback( - (snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => { + ( + snapshot: CanvasHistorySnapshot, + action?: CanvasHistoryAction, + historyEntryId?: number, + ) => { const currentSnapshot = getCanvasHistorySnapshot(); const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({ current: currentSnapshot, target: snapshot, + assetKindLayerIds: + action?.type === 'change-asset-kind' + ? action.layerIds?.length + ? new Set(action.layerIds) + : undefined + : new Set(), }); setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer }))); setters.setViewport({ ...mergedSnapshot.viewport }); @@ -122,7 +136,7 @@ export function useCanvasHistory({ for (const layer of mergedSnapshot.layers) { const currentLayer = currentLayerById.get(layer.id); if (currentLayer && currentLayer.assetKind !== layer.assetKind) { - effects?.persistRestoredAssetKind?.({ ...layer }); + effects?.persistRestoredAssetKind?.({ ...layer }, historyEntryId); } } } @@ -135,9 +149,12 @@ export function useCanvasHistory({ action: CanvasHistoryAction, options: { snapshot?: CanvasHistorySnapshot } = {}, ) => { + nextHistoryEntryIdRef.current += 1; + const entryId = nextHistoryEntryIdRef.current; undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId, snapshot: options.snapshot ?? getCanvasHistorySnapshot(), action, createdAt: Date.now(), @@ -145,6 +162,7 @@ export function useCanvasHistory({ ]; redoStackRef.current = []; setHistoryVersion((version) => version + 1); + return entryId; }, [getCanvasHistorySnapshot], ); @@ -168,6 +186,28 @@ export function useCanvasHistory({ [], ); + const discardCanvasHistoryEntriesById = useCallback( + (entryIds: ReadonlySet) => { + if (entryIds.size === 0) { + return; + } + const keepEntry = (entry: CanvasHistoryEntry) => + !entryIds.has(entry.entryId); + const nextUndoStack = undoStackRef.current.filter(keepEntry); + const nextRedoStack = redoStackRef.current.filter(keepEntry); + if ( + nextUndoStack.length === undoStackRef.current.length && + nextRedoStack.length === redoStackRef.current.length + ) { + return; + } + undoStackRef.current = nextUndoStack; + redoStackRef.current = nextRedoStack; + setHistoryVersion((version) => version + 1); + }, + [], + ); + const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => { const previousEntry = undoStackRef.current.at(-1); if (!previousEntry) { @@ -191,12 +231,17 @@ export function useCanvasHistory({ redoStackRef.current = [ ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId: previousEntry.entryId, snapshot: currentSnapshot, action: previousEntry.action, createdAt: Date.now(), }, ]; - restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action); + restoreCanvasHistorySnapshot( + previousEntry.snapshot, + previousEntry.action, + previousEntry.entryId, + ); setHistoryVersion((version) => version + 1); return { status: 'success', action: previousEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); @@ -223,12 +268,17 @@ export function useCanvasHistory({ undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId: nextEntry.entryId, snapshot: currentSnapshot, action: nextEntry.action, createdAt: Date.now(), }, ]; - restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action); + restoreCanvasHistorySnapshot( + nextEntry.snapshot, + nextEntry.action, + nextEntry.entryId, + ); setHistoryVersion((version) => version + 1); return { status: 'success', action: nextEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); @@ -241,6 +291,7 @@ export function useCanvasHistory({ restoreCanvasHistorySnapshot, captureCanvasHistory, discardCanvasHistoryEntriesContainingLayer, + discardCanvasHistoryEntriesById, undoCanvasChange, redoCanvasChange, }; diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 720f0f78f..4aa6ecc98 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -355,7 +355,11 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length }); + captureCanvasHistory({ + type: 'change-asset-kind', + count: targetIds.length, + layerIds: targetIds, + }); setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), );