优化画布任务计时与去背景占位

移除画布任务列表的百分比展示

生成中任务显示阶段和已用时,排队任务不计时

去除背景在项目画布中创建生成占位并通过后端快照完成

补充客户端、工作流和后端契约测试

同步图片画布技术文档和项目记忆
This commit is contained in:
2026-06-30 19:24:08 +08:00
parent cf585b6316
commit 3530e9251e
9 changed files with 239 additions and 27 deletions
@@ -2944,6 +2944,7 @@
- 背景:图片画布的图片、改图、图标素材、UI 素材提取、角色动作、视频和音频生成都可能长时间等待外部 provider;如果继续由 HTTP handler 同步执行,生产只能扩 API 进程,不能独立扩生成吞吐。
- 决策:`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画板所有外部 provider 生成入口统一入 `external_generation_job`job kind 使用 `editor_image_generation``editor_image_edit``editor_background_removal``editor_icon_spritesheet_generation``editor_ui_design_asset_extraction``editor_character_animation_generation``editor_video_generation``editor_sound_effect_generation``editor_background_music_generation`。worker 成功后由后端写 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`;前端只轮询 BFF job 状态并重新读取项目快照,不从队列 payload 或本地临时状态重建完成图层。
- 2026-06-29 补充:手动点击图层“去除背景”也属于图片画布外部 provider 任务,`/api/editor/images/background-removals` 在 queue 模式只入队 `editor_background_removal`worker 完成后用新 resource 原地替换目标 layer。任务列表只展示服务器 `external_generation_job` 返回的任务,禁止再用前端 local task 伪造抠图进度。
- 2026-06-30 补充:手动“去除背景”在有项目上下文时也创建画布生成占位并随请求提交 `canvasCompletion`worker / BFF 完成后通过现有生成完成链路把结果写入该占位;无 `canvasCompletion` 的旧路径才原地替换目标 layer。画布任务列表展示服务器阶段文案,生成中才显示耗时,排队不计时也不展示百分比。
- 补充:带 `dialogId``canvasCompletion` 必须读取后端当前 layout 中的最新 generation dialog placeholder;等待期间用户移动占位时,结果层要跟随最新占位。无 dialog 的重绘 / UI 素材提取等入口使用明确的右侧完成占位;生成器已删除时不把结果重新塞回画布。
- 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorProjectClient.ts`
- 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml``npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts``npm run typecheck``npm run check:encoding``git diff --check`
File diff suppressed because one or more lines are too long
@@ -241,6 +241,7 @@ pub struct EditorBackgroundRemovalRequest {
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1903,14 +1904,25 @@ pub(crate) async fn remove_editor_image_background_for_owner(
},
)
.await?;
let completed_project = complete_editor_canvas_background_removal(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
)
.await?
};
Ok(json_success_body(
Some(&request_context),
@@ -149,6 +149,8 @@ describe('ImageCanvasTaskSidebarView', () => {
.filter((text) => text.includes('角色图片') || text.includes('图标素材'));
expect(activeTitles[0]).toContain('角色图片');
expect(activeTitles[1]).toContain('图标素材');
expect(activeTitles[0]).toContain('已用时');
expect(activeTitles[1]).not.toContain('已用时');
await waitFor(() =>
expect(
screen
@@ -237,7 +239,10 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(await screen.findByText('图片画布生成图片')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/35% · 2\/4 /u)).toBeTruthy();
expect(
screen.getByText(/ 2\/4 · 1/u),
).toBeTruthy();
expect(screen.queryByText(/35%/u)).toBeNull();
expect(screen.getByText(/ 1/u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(//u)).toBeNull();
@@ -118,9 +118,6 @@ function externalTaskProgressDetail(
return null;
}
const values = [
Number.isFinite(task.progress) && task.progress > 0
? `${Math.min(100, Math.max(0, Math.round(task.progress)))}%`
: null,
task.phaseDetail.trim(),
].filter((value): value is string => Boolean(value));
return values.length > 0 ? values.join(' · ') : null;
@@ -175,6 +172,10 @@ function isActiveTaskStatus(status: CanvasTaskStatus) {
return status === 'running' || status === 'pending';
}
function isTimedTaskStatus(status: CanvasTaskStatus) {
return status === 'running';
}
function activeTaskSortPriority(status: CanvasTaskStatus) {
if (status === 'running') {
return 0;
@@ -466,9 +467,9 @@ export function ImageCanvasTaskSidebarView({
startedAt,
finishedAt,
elapsedMs:
status === 'running' && startedAt
isTimedTaskStatus(status) && startedAt
? now - startedAt
: startedAt && finishedAt
: !isActiveTaskStatus(status) && startedAt && finishedAt
? finishedAt - startedAt
: undefined,
errorMessage: task.error ?? undefined,
@@ -609,7 +610,7 @@ export function ImageCanvasTaskSidebarView({
.join(' · ');
const timeText = [
elapsedLabel
? item.status === 'running'
? isTimedTaskStatus(item.status)
? `已用时 ${elapsedLabel}`
: `耗时 ${elapsedLabel}`
: null,
@@ -621,10 +622,18 @@ export function ImageCanvasTaskSidebarView({
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const progressText = [
item.progressDetail,
isTimedTaskStatus(item.status) ? timeText : null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const titleText = [detailText, item.progressDetail, timeText]
.filter((value): value is string => Boolean(value))
.join(' · ');
const metaTimeText = timeText;
const metaTimeText = isActiveTaskStatus(item.status)
? null
: timeText;
return (
<button
key={item.id}
@@ -658,12 +667,12 @@ export function ImageCanvasTaskSidebarView({
>
{detailText}
</span>
{item.progressDetail ? (
{progressText ? (
<span
className="image-canvas-editor__task-sidebar-item-progress"
title={item.progressDetail}
title={progressText}
>
{item.progressDetail}
{progressText}
</span>
) : null}
</span>
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -101,9 +101,15 @@ function createGenerated(overrides = {}) {
function GenerationWorkflowHarness({
initialLayers = [createLayer()],
initialViewport = { x: 10, y: 20, scale: 2 },
projectId,
applyProjectSnapshot,
}: {
initialLayers?: CanvasLayer[];
initialViewport?: { x: number; y: number; scale: number };
projectId?: string;
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshot'];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [viewport, setViewport] = useState(initialViewport);
@@ -149,6 +155,8 @@ function GenerationWorkflowHarness({
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
projectId,
applyProjectSnapshot,
});
const activeDialog = dialogs.generateDialog;
@@ -1626,6 +1634,69 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('task-refresh-key').textContent).toBe('0');
});
it('opens a generating canvas placeholder when removing background in a project', async () => {
let resolveBackgroundRemoval: ((value: unknown) => void) | null = null;
removeImageBackgroundMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveBackgroundRemoval = resolve;
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '去除背景' }));
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder',
);
expect(screen.getByTestId('generation-dialogs').textContent).toContain(
'generation-dialog-1:quick-edit:closed',
);
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
targetLayerId: 'layer-source',
assetLabel: '源图 去背景',
canvasCompletion: expect.objectContaining({
dialogId: 'generation-dialog-1',
title: '源图 去背景',
placeholder: expect.objectContaining({
width: 320,
height: 240,
}),
}),
}),
);
await act(async () => {
resolveBackgroundRemoval?.({
imageSrc:
'/generated-character-drafts/editor/background-removal/project-result.png',
objectKey:
'generated-character-drafts/editor/background-removal/project-result.png',
assetObjectId: 'asset-object-background-removal',
width: 512,
height: 768,
taskId: 'background-removal-project-task',
elapsedMs: 1234,
provider: 'BiRefNet',
project: null,
});
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'project-result.png',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'background-removal-project-task',
);
});
it('opens UI design extraction as a mark selection state before submitting', () => {
render(<GenerationWorkflowHarness />);
@@ -736,8 +736,10 @@ export function useImageCanvasGenerationWorkflow({
(draft: Omit<CanvasGenerationDialogState, 'id'>) => {
const draftPlaceholder = draft.placeholder;
if (!draftPlaceholder) {
openCanvasGenerationDialog(draft);
return;
return {
dialogId: openCanvasGenerationDialog(draft),
placeholder: undefined,
};
}
// 中文注释:所有画布生成入口统一先走 placement 模型,避免新占位压住已有图层或生成占位。
const placement = chooseGenerationPlacement({
@@ -747,7 +749,7 @@ export function useImageCanvasGenerationWorkflow({
layers,
generationDialogs: canvasGenerationDialogs,
});
openCanvasGenerationDialog({
const dialogId = openCanvasGenerationDialog({
...draft,
placeholder: placement,
});
@@ -758,6 +760,7 @@ export function useImageCanvasGenerationWorkflow({
placement,
}),
);
return { dialogId, placeholder: placement };
},
[
canvasGenerationDialogs,
@@ -1388,6 +1391,26 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
const assetLabel = `${sourceLayer.title} 去背景`;
const backgroundRemovalDialog =
projectId && applyProjectSnapshot
? createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: '去除背景',
status: 'generating',
frame: {
width: sourceLayer.width,
height: sourceLayer.height,
},
})
: null;
const backgroundRemovalPlacement = backgroundRemovalDialog
? openPlacedCanvasGenerationDialog({
...backgroundRemovalDialog,
composerOpen: false,
})
: undefined;
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
try {
const sourceObjectKey = sourceLayer.objectKey?.trim();
const sourceImageSrc = sourceObjectKey
@@ -1400,8 +1423,17 @@ export function useImageCanvasGenerationWorkflow({
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
assetFolderId,
assetLabel: `${sourceLayer.title} 去背景`,
assetLabel,
sourceResourceId: sourceLayer.resourceId,
...(backgroundRemovalPlacement?.placeholder
? {
canvasCompletion: {
dialogId: backgroundRemovalDialogId,
title: assetLabel,
placeholder: backgroundRemovalPlacement.placeholder,
},
}
: {}),
});
if (
await applyQueuedEditorGenerationProject(
@@ -1418,6 +1450,9 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot(result.project);
return;
}
if (backgroundRemovalPlacement?.placeholder && applyProjectSnapshot) {
return;
}
updateSourceLayer(sourceLayer.id, (layer) => ({
...layer,
resourceId: result.resource?.resourceId ?? layer.resourceId,
@@ -1447,6 +1482,16 @@ export function useImageCanvasGenerationWorkflow({
}));
setActiveSidebarPanel('layers');
} catch (error) {
if (backgroundRemovalDialogId) {
updateCanvasGenerationDialogById(backgroundRemovalDialogId, (dialog) => ({
...dialog,
status: 'failed',
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '去除背景失败',
}));
}
throw error;
}
},
@@ -1454,12 +1499,14 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot,
assetFolderId,
onWalletBalanceMayHaveChanged,
openPlacedCanvasGenerationDialog,
projectId,
refreshTaskListForQueuedGeneration,
resolveEditorImageReferenceDataUrl,
setActiveSidebarPanel,
setImageContextMenu,
setMetadataLayer,
updateCanvasGenerationDialogById,
updateSourceLayer,
],
);
@@ -22,6 +22,7 @@ import {
loadEditorAssetLibrary,
loadEditorProject,
loadOrCreateRecentEditorProject,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
updateEditorAsset,
@@ -1389,4 +1390,66 @@ describe('editorProjectClient', () => {
}),
);
});
it('passes canvas completion context to background removal', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cutout',
width: 512,
height: 512,
objectKey: 'generated-character-drafts/editor-cutouts/cutout.png',
assetObjectId: 'asset-object-cutout',
taskId: 'background-removal-1',
elapsedMs: 1200,
provider: 'BiRefNet',
project: null,
});
await removeEditorImageBackground({
sourceImageSrc: 'data:image/png;base64,source',
projectId: 'editor-project-1',
targetLayerId: 'layer-source',
canvasCompletion: {
dialogId: 'generation-dialog-1',
title: '源图 去背景',
placeholder: {
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/background-removals',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceImageSrc: 'data:image/png;base64,source',
projectId: 'editor-project-1',
targetLayerId: 'layer-source',
canvasCompletion: {
dialogId: 'generation-dialog-1',
title: '源图 去背景',
placeholder: {
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
}),
}),
'去除背景失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: editorRetryOptionsExpectation,
}),
);
});
});
@@ -209,6 +209,7 @@ export type EditorBackgroundRemovalInput = {
assetLabel?: string | null;
sourceResourceId?: string | null;
taskId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
export type EditorImageGenerationResult = {
@@ -914,6 +915,9 @@ export async function removeEditorImageBackground(
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.taskId ? { taskId: input.taskId } : {}),
...(input.canvasCompletion
? { canvasCompletion: input.canvasCompletion }
: {}),
}),
'去除背景失败',
{