修复序列帧后处理结果应用与存储幂等
视频直接转换和序列帧去背景按 operation 隔离对象存储路径 队列后处理失败统一返回脱敏用户文案 inline 转换和去背景结果直接应用服务端项目快照 修复去背景加载状态和相关前端回归测试
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user