优化画布任务计时与去背景占位
移除画布任务列表的百分比展示 生成中任务显示阶段和已用时,排队任务不计时 去除背景在项目画布中创建生成占位并通过后端快照完成 补充客户端、工作流和后端契约测试 同步图片画布技术文档和项目记忆
This commit is contained in:
@@ -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 }
|
||||
: {}),
|
||||
}),
|
||||
'去除背景失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user