修复序列帧去背景队列与幂等发布

序列帧去背景改用本地绿幕算法并在 durable 父任务内并行处理。

将去背景操作加入生成结果白名单,并按 operation_id 隔离帧对象路径。

去背景结果发布为新的序列资源、素材和画布图层,同时保留原始序列。

同步前端生成占位、相关测试及队列与资源契约文档。
This commit is contained in:
2026-08-15 11:59:50 +08:00
parent 05f0e2db06
commit 13afdd7a3b
11 changed files with 404 additions and 125 deletions
@@ -14,6 +14,7 @@ import {
closeGenerateComposerDialog,
createAudioRedrawGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationBackgroundRemovalDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterAnimationPanelDraft,
createCharacterGenerationDialogDraft,
@@ -559,6 +560,33 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates a separate character animation placement for background removal', () => {
expect(
createCharacterAnimationBackgroundRemovalDialogDraft({
sourceLayer: createLayer({
id: 'layer-animation',
assetKind: 'character-animation',
}),
assetLabel: '源动画 去背景',
}),
).toMatchObject({
mode: 'character-animation',
prompt: '去除背景',
assetLabel: '源动画 去背景',
status: 'generating',
composerOpen: false,
sourceLayerId: 'layer-animation',
placeholder: {
x: 472,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
},
});
});
it('restores remodel prompts from user-visible generation input snapshots only', () => {
const cases: Array<{
fields: NonNullable<CanvasLayer['generationInputs']>['fields'];
@@ -869,9 +869,7 @@ function getNormalizedString(
function getNormalizedStringArray(sourceLayer: CanvasLayer, id: string) {
return (sourceLayer.generationInputs?.fields ?? [])
.filter((field) => field.id === id)
.flatMap((field) =>
typeof field.value === 'string' ? [field.value] : [],
);
.flatMap((field) => (typeof field.value === 'string' ? [field.value] : []));
}
function getNormalizedNumber(
@@ -2196,6 +2194,33 @@ export function createQuickEditGenerationDialogDraft({
};
}
export function createCharacterAnimationBackgroundRemovalDialogDraft({
sourceLayer,
assetLabel,
status = 'generating',
}: {
sourceLayer: CanvasLayer;
assetLabel?: string;
status?: CanvasGenerationDialogState['status'];
}): Omit<CanvasGenerationDialogState, 'id'> {
return {
mode: 'character-animation',
prompt: '去除背景',
assetLabel,
status,
composerOpen: false,
sourceLayerId: sourceLayer.id,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
originalWidth: sourceLayer.originalWidth || sourceLayer.width,
originalHeight: sourceLayer.originalHeight || sourceLayer.height,
},
};
}
export function createLegacyImageRedrawGenerationDialogDraft({
sourceLayer,
canvasSize,
@@ -67,6 +67,7 @@ import {
closeGenerateComposerDialog,
createAudioRedrawGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationBackgroundRemovalDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterGenerationDialogDraft,
createEditDialogDraft,
@@ -3697,13 +3698,33 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
const assetLabel = `${sourceLayer.title} 去背景`;
const backgroundRemovalDialog = applyProjectSnapshot
? createCharacterAnimationBackgroundRemovalDialogDraft({
sourceLayer,
assetLabel,
})
: null;
const backgroundRemovalPlacement = backgroundRemovalDialog
? openPlacedCanvasGenerationDialog(backgroundRemovalDialog)
: undefined;
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
try {
const result = await removeEditorCharacterAnimationBackground({
projectId,
sourceLayerId: sourceLayer.id,
sourceResourceId: sourceLayer.resourceId,
assetFolderId,
assetLabel: `${sourceLayer.title} 去背景`,
assetLabel,
...(backgroundRemovalPlacement?.placeholder
? {
canvasCompletion: {
dialogId: backgroundRemovalDialogId,
title: assetLabel,
placeholder: backgroundRemovalPlacement.placeholder,
},
}
: {}),
generationInputs: buildDeterministicGenerationInputs(
'character-animation.remove-background',
sourceLayer,
@@ -3712,10 +3733,17 @@ export function useImageCanvasGenerationWorkflow({
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
(project) =>
applyProjectSnapshot?.(project, {
type: 'remove-background',
count: 1,
}),
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
showGenerationWarning,
backgroundRemovalDialogId,
(project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
);
try {
await Promise.resolve(refreshAssetLibrary?.());
@@ -3723,6 +3751,19 @@ export function useImageCanvasGenerationWorkflow({
// 服务端已原子落库成功;列表刷新失败不能把成功事实伪装成去背景失败。
}
} catch (error) {
if (backgroundRemovalDialogId) {
updateCanvasGenerationDialogById(
backgroundRemovalDialogId,
(dialog) => ({
...dialog,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '去除序列帧背景失败',
}),
);
}
window.alert(
error instanceof Error && error.message.trim()
? error.message
@@ -3747,6 +3788,7 @@ export function useImageCanvasGenerationWorkflow({
assetFolderId,
closeGenerationTransientState,
onWalletBalanceMayHaveChanged,
openPlacedCanvasGenerationDialog,
projectId,
refreshAssetLibrary,
refreshTaskListForQueuedGeneration,
@@ -3755,6 +3797,7 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer,
setQuickEditPanel,
showGenerationWarning,
updateCanvasGenerationDialogById,
],
);