修复图集素材标签持久化与拆分校验
Project CI / Native shell tests (push) Successful in 2m40s
Project CI / Backend tests (push) Successful in 3m16s
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Frontend tests (push) Successful in 2m5s

项目资源复用增加素材类型匹配,避免同源旧类型资源回流
标签保存期间禁用拆分并在服务端确认后回写资源引用
补齐乱序请求、快照刷新、失败回滚和撤销历史保护
新增前后端回归测试并同步图集入口设计文档
This commit is contained in:
2026-07-29 11:06:56 +08:00
parent d7f0c45164
commit 44f47e5db1
13 changed files with 711 additions and 57 deletions
@@ -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` 校验错误,不继续调用上游生成。
@@ -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::<Vec<_>>();
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 =
@@ -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;
@@ -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<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
);
render(<ImageCanvasEditorView />);
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<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
);
render(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
@@ -2119,6 +2279,167 @@ describe('ImageCanvasEditorView', () => {
).toBe(false);
});
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
const staleCharacterResource =
createDeferred<EditorProjectResourceSnapshot>();
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => failedUndoResource.promise);
render(<ImageCanvasEditorView />);
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<EditorProjectResourceSnapshot>();
const currentAtlasResource =
createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => currentAtlasResource.promise);
render(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
@@ -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();
@@ -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<string, number>());
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
new Map<string, Set<number>>(),
);
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(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<CanvasHistoryFeedback | null>(null);
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
useState<ReadonlySet<string>>(() => 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,
@@ -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',
@@ -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<string>;
}): 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)
@@ -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()}
@@ -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' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
label={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
title={
isPersistingAssetKind
? '素材类型保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'
}
icon={
isSplittingIconSpritesheet ? (
isPersistingAssetKind || isSplittingIconSpritesheet ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={isSplittingIconSpritesheet}
aria-busy={isSplittingIconSpritesheet}
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
onClick={() => onSplitIconSpritesheet(selectedLayer)}
>
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
<span>
{isPersistingAssetKind
? '保存中'
: isSplittingIconSpritesheet
? '拆图中'
: '拆分图集'}
</span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'ui-design' ? (
@@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = {
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
persistingAssetKindLayerIds?: ReadonlySet<string>;
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}
@@ -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);
});
@@ -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<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
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<number>) => {
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,
};
@@ -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),
);