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