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

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

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

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

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

同步图片画布技术文档和项目记忆
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
@@ -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 }
: {}),
}),
'去除背景失败',
{