修复序列帧后处理结果应用与存储幂等

视频直接转换和序列帧去背景按 operation 隔离对象存储路径
队列后处理失败统一返回脱敏用户文案
inline 转换和去背景结果直接应用服务端项目快照
修复去背景加载状态和相关前端回归测试
This commit is contained in:
2026-08-15 14:27:55 +08:00
parent 13afdd7a3b
commit 2c85ea0b83
13 changed files with 196 additions and 40 deletions
@@ -73,7 +73,7 @@ describe('ImageCanvasBottomToolbarView', () => {
);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
).toHaveLength(12);
).toHaveLength(13);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
).toHaveLength(2);
@@ -3452,7 +3452,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
fireEvent.click(characterLayer.closest('button')!);
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: 'Spine序列帧动画生成' }),
@@ -8,9 +8,9 @@ import type {
EditorAssetSnapshot,
EditorCharacterAnimationBackgroundColor,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorCharacterAnimationResult,
EditorImageGenerationStyle,
EditorImageSequenceFrameResult,
EditorPixelArtSnapInput,
@@ -497,6 +497,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
assetObjectId: undefined,
resourceId: 'resource-source',
sourceAssetId: undefined,
assetKind: 'character',
width: 1024,
height: 768,
},
@@ -2393,8 +2393,6 @@ export function appendGenerationReference(
}
: dialog;
}
const hasVideoMotionReference =
dialog.generationReferences?.[0]?.mediaType === 'video';
if (mediaType === 'audio') {
return dialog;
}
@@ -31,9 +31,9 @@ import {
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
canOpenRedrawPanel,
createCanvasLayerReference,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import { ApiClientError } from '../../services/apiClient';
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
@@ -4532,12 +4533,21 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('submits direct video conversion without prompt or model compatibility fields', async () => {
it('applies the inline direct-conversion project without creating a duplicate local layer', async () => {
const appendedLayers = vi.fn();
const frames = [
{ imageSrc: '/converted/frame-1.png', width: 640, height: 360 },
{ imageSrc: '/converted/frame-2.png', width: 640, height: 360 },
];
const applyProjectSnapshot = vi.fn();
const backendProject: EditorProjectSnapshot = {
projectId: 'editor-project-1',
title: '视频转换项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-15T00:00:00.000Z',
};
convertEditorCharacterAnimationVideoMock.mockResolvedValueOnce({
resultType: 'video-conversion',
taskId: 'conversion-task-1',
@@ -4561,6 +4571,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
imageSequenceFrames: frames,
imageSequenceDurationMs: 2_500,
},
project: backendProject,
});
const sourceReference = {
id: 'conversion-source',
@@ -4574,6 +4585,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onAppendCanvasLayers={appendedLayers}
initialDialog={{
id: 'dialog-video-conversion',
@@ -4626,18 +4638,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(request).not.toHaveProperty('prompt');
expect(request).not.toHaveProperty('promptText');
expect(request).not.toHaveProperty('model');
await waitFor(() => expect(appendedLayers).toHaveBeenCalledTimes(1));
const layer = appendedLayers.mock.calls[0]?.[0]?.[0];
expect(layer).toMatchObject({
taskId: 'conversion-task-1',
provider: 'FFmpeg',
imageSequenceDurationMs: 2_500,
assetKind: 'character-animation',
mediaType: 'image-sequence',
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
});
expect(layer.prompt).toBeUndefined();
expect(layer.actualPrompt).toBeUndefined();
expect(layer.model).toBeUndefined();
expect(appendedLayers).not.toHaveBeenCalled();
});
it('does not reuse animation lineage as the direct conversion source resource', async () => {
@@ -2809,6 +2809,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
return;
}
if (result.project && applyProjectSnapshot) {
applyProjectSnapshot(result.project);
if (result.asset) {
upsertGeneratedAsset?.(result.asset);
}
setCharacterAnimationPanel((currentPanel) =>
currentPanel
? { ...currentPanel, status: 'completed', result }
: currentPanel,
);
return;
}
if (canvasDialog) {
addCharacterAnimationResultLayer(
result,
@@ -3070,6 +3082,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
onQueuedGenerationTask,
onGenerationWarning,
upsertGeneratedAsset,
]);
return useMemo(
@@ -52,6 +52,7 @@ const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const splitEditorCharacterAnimationFramesMock = vi.hoisted(() => vi.fn());
const removeEditorCharacterAnimationBackgroundMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
@@ -91,6 +92,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
splitEditorCharacterAnimationFrames:
splitEditorCharacterAnimationFramesMock,
removeEditorCharacterAnimationBackground:
removeEditorCharacterAnimationBackgroundMock,
};
});
@@ -941,6 +944,21 @@ function GenerationWorkflowHarness({
? '拆帧中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!)
}
>
去除序列帧背景
</button>
<output aria-label="序列帧去背景状态">
{workflow.removingBackgroundCharacterAnimationLayerIds.has(
layers[0]!.id,
)
? '去背景中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
@@ -2988,6 +3006,89 @@ describe('useImageCanvasGenerationWorkflow', () => {
},
);
it('applies an inline character-animation background-removal project and publishes removal state', async () => {
const deferred = createDeferred<{
taskId: string;
frames: Array<{ imageSrc: string; width: number; height: number }>;
frameCount: number;
durationMs: number;
frameWidth: number;
frameHeight: number;
priceMudPoints: number;
project: EditorProjectSnapshot;
}>();
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const backendProject: EditorProjectSnapshot = {
projectId: 'project-1',
title: '序列帧去背景项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-source',
resourceId: 'resource-source',
},
],
resources: [],
updatedAt: '2026-08-15T00:00:00.000Z',
};
removeEditorCharacterAnimationBackgroundMock.mockReturnValueOnce(
deferred.promise,
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
refreshAssetLibrary={refreshAssetLibrary}
initialLayers={[
createLayer({
title: '勇者奔跑',
assetKind: 'character-animation',
mediaType: 'image-sequence',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '去除序列帧背景' }));
expect(
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
).toBe('去背景中');
expect(removeEditorCharacterAnimationBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
sourceLayerId: 'layer-source',
sourceResourceId: 'resource-source',
}),
);
deferred.resolve({
taskId: 'background-removal-task',
frames: [
{ imageSrc: '/frames/1.png', width: 320, height: 240 },
{ imageSrc: '/frames/2.png', width: 320, height: 240 },
],
frameCount: 2,
durationMs: 2_000,
frameWidth: 320,
frameHeight: 240,
priceMudPoints: 0,
project: backendProject,
});
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject, {
type: 'remove-background',
count: 1,
});
});
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
expect(
screen.getByRole('status', { name: '序列帧去背景状态' }).textContent,
).toBe('空闲');
});
it('queues background removal for private character images', async () => {
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,resolved-character',
@@ -3730,7 +3730,7 @@ export function useImageCanvasGenerationWorkflow({
sourceLayer,
),
});
await applyQueuedEditorGenerationProject(
const queued = await applyQueuedEditorGenerationProject(
result,
projectId,
(project) =>
@@ -3745,6 +3745,15 @@ export function useImageCanvasGenerationWorkflow({
(project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
);
if (!queued && result.project && applyProjectSnapshot) {
applyProjectSnapshot(
preserveSourceLayerInProjectSnapshot(result.project, sourceLayer),
{
type: 'remove-background',
count: 1,
},
);
}
try {
await Promise.resolve(refreshAssetLibrary?.());
} catch {
@@ -3870,9 +3879,6 @@ export function useImageCanvasGenerationWorkflow({
let rejectedCharacterReference: 'video' | 'character' | null = null;
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode === 'character-animation') {
const isVideoAppearanceReference =
currentDialog.characterAnimationWorkflow !== 'video-conversion' &&
currentDialog.generationReferences?.[0]?.mediaType === 'video';
const isAllowedCharacterAnimationReference =
currentDialog.characterAnimationWorkflow === 'video-conversion'
? layer.mediaType === 'video' ||
@@ -4995,6 +5001,8 @@ export function useImageCanvasGenerationWorkflow({
splittingIconSpritesheetLayerIds,
splitSelectedCharacterAnimationFrames,
splittingCharacterAnimationLayerIds,
removeSelectedCharacterAnimationBackground,
removingBackgroundCharacterAnimationLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
@@ -3,6 +3,7 @@
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import {
completeEditorBackgroundMusicPrompt,
convertEditorCharacterAnimationVideo,
createEditorAsset,
createEditorAssetFolder,
createEditorProject,
@@ -19,7 +20,6 @@ import {
EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
convertEditorCharacterAnimationVideo,
generateEditorCharacterAnimation,
generateEditorIconSpec,
generateEditorIconSpritesheet,