完善画布任务与标准绿幕抠除链路

统一编辑器标准绿幕提示词和本地绿幕透明化后处理

生成标准绿幕资产前保存源图到 OSS 并下沉字节级抠绿到 platform-image

将手动去背景接入外部生成队列与画布任务侧栏

修复任务侧栏分页、滚动、计时、焦点与完成信息展示

补充右键菜单关闭和素材复制下载交互

同步编辑器相关文档和项目记忆
This commit is contained in:
2026-06-30 11:29:54 +08:00
parent 10b170e7a2
commit a8c19e132d
37 changed files with 1375 additions and 334 deletions
@@ -143,6 +143,34 @@ describe('ImageCanvasAssetRowView', () => {
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
});
it('closes the right click menu when row actions run on the same asset', () => {
const asset = createAsset({
sourceResourceId: 'resource-asset-1',
publicShowcaseEnabled: true,
});
const addAssetLayer = vi.fn();
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, addAssetLayer, onDownloadAsset });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
expect(screen.getByRole('menu')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
expect(addAssetLayer).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
@@ -210,13 +210,19 @@ export function ImageCanvasAssetRowView({
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => onDownloadAsset(asset)}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => startRenamingAsset(asset)}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
@@ -251,6 +257,7 @@ export function ImageCanvasAssetRowView({
if (isUploadingAsset || isFailedUpload) {
return;
}
closeMenu();
if (suppressAssetClickRef.current) {
return;
}
@@ -82,6 +82,22 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
});
it('closes canvas context menus when another operation starts outside', () => {
const props = renderContextMenus({
contextMenu: {
kind: 'blank',
x: 10,
y: 12,
canvasPoint: { x: 18, y: 24 },
},
});
fireEvent.pointerDown(document.body);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
});
it('renders layer context commands and forwards layer operations', () => {
const layer = createLayer({
assetKind: 'character',
@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
@@ -72,11 +74,44 @@ export function ImageCanvasContextMenusView({
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const menuRef = useRef<HTMLDivElement | null>(null);
const closeMenus = useCallback(() => {
onCloseContextMenu();
onCloseImageContextMenu();
}, [onCloseContextMenu, onCloseImageContextMenu]);
useEffect(() => {
if (!contextMenu && !imageContextMenu) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current?.contains(event.target as Node)) {
return;
}
closeMenus();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenus();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', closeMenus, true);
window.addEventListener('resize', closeMenus);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', closeMenus, true);
window.removeEventListener('resize', closeMenus);
};
}, [closeMenus, contextMenu, imageContextMenu]);
return (
<>
{contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
role="menu"
aria-label={
@@ -334,6 +369,7 @@ export function ImageCanvasContextMenusView({
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
style={{
left: imageContextMenu.x,
@@ -3029,9 +3029,9 @@ describe('ImageCanvasEditorView generation integration', () => {
},
],
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-extract-task-1',
@@ -3134,7 +3134,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
}),
]),
references: expect.arrayContaining([
@@ -3163,7 +3163,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
}),
]),
}),
@@ -136,7 +136,7 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
localTaskItems: [],
taskListRefreshKey: 0,
isTaskSidebarOpen: false,
generateDialog: null,
cropExpandPanel: null,
@@ -180,7 +180,6 @@ function createStageProps(): ImageCanvasStageViewProps {
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusExternalTask: vi.fn(),
onFocusLocalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
@@ -252,19 +252,6 @@ export type CanvasGenerationDialogState = GenerateDialogState & {
export type CanvasTaskStatus = 'pending' | 'running' | 'done' | 'failed';
export type CanvasTaskListItem = {
id: string;
title: string;
detail: string;
status: CanvasTaskStatus;
startedAt: number;
finishedAt?: number;
elapsedMs?: number;
provider?: string;
errorMessage?: string;
focusLayerId?: string;
};
export type ImageContextMenuState = {
layerId: string;
x: number;
@@ -42,7 +42,6 @@ import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasTaskListItem,
CanvasTool,
CanvasViewport,
CharacterReferenceImage,
@@ -647,7 +646,9 @@ export function ImageCanvasEditorView() {
);
const focusCanvasLayerById = useCallback(
(layerId: string) => {
const targetLayer = layersRef.current.find((layer) => layer.id === layerId);
const targetLayer = layersRef.current.find(
(layer) => layer.id === layerId,
);
if (!targetLayer) {
return;
}
@@ -656,7 +657,12 @@ export function ImageCanvasEditorView() {
setActiveSidebarPanel('layers');
focusCanvasBounds(targetLayer);
},
[focusCanvasBounds, selectSingleLayer, setActiveSidebarPanel, setActiveTool],
[
focusCanvasBounds,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
],
);
const focusCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
@@ -673,7 +679,9 @@ export function ImageCanvasEditorView() {
setSelectedLayerIds([sourceLayer.id]);
setActiveTool('generate');
setActiveSidebarPanel('layers');
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
setViewport(
resolveQuickEditFocusViewport({ sourceLayer, canvasSize }),
);
return;
}
}
@@ -725,14 +733,6 @@ export function ImageCanvasEditorView() {
},
[focusCanvasGenerationDialog, focusCanvasLayerById],
);
const focusLocalCanvasTask = useCallback(
(task: CanvasTaskListItem) => {
if (task.focusLayerId) {
focusCanvasLayerById(task.focusLayerId);
}
},
[focusCanvasLayerById],
);
const persistGeneratedAsset = useCallback(
(layer: CanvasLayer) => {
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
@@ -1735,7 +1735,7 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
localTaskItems: generationSurface.backgroundRemovalTasks,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
@@ -1784,7 +1784,6 @@ export function ImageCanvasEditorView() {
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onFocusLocalTask: focusLocalCanvasTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openLayerGenerationDialog,
@@ -481,7 +481,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
},
{ title: 'model', value: 'gpt-image-2' },
],
@@ -313,7 +313,7 @@ describe('ImageCanvasGenerationModel', () => {
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
},
],
references: [
@@ -98,7 +98,7 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
'冻结按钮',
];
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。';
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。';
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
@@ -1,4 +1,8 @@
import { removeEditorImageBackground } from '../../services/image-editor/editorProjectClient';
import {
removeEditorImageBackground,
type EditorBackgroundRemovalInput,
type EditorBackgroundRemovalResult,
} from '../../services/image-editor/editorProjectClient';
export type CropExpandInsets = {
left: number;
@@ -212,7 +216,9 @@ export async function renderCropExpandImage({
}
export async function removeImageBackground(
source: string,
): Promise<RasterEditResult> {
return removeEditorImageBackground({ sourceImageSrc: source });
input: string | EditorBackgroundRemovalInput,
): Promise<EditorBackgroundRemovalResult> {
return removeEditorImageBackground(
typeof input === 'string' ? { sourceImageSrc: input } : input,
);
}
@@ -9,15 +9,12 @@ import type {
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
ExternalGenerationTaskRecord,
} from '../../../packages/shared/src/contracts/externalGeneration';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type {
CanvasAssetKind,
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasTaskListItem,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -58,7 +55,7 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
localTaskItems: CanvasTaskListItem[];
taskListRefreshKey: number;
isTaskSidebarOpen: boolean;
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
@@ -118,7 +115,6 @@ export type ImageCanvasStageViewProps = {
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onFocusLocalTask: (task: CanvasTaskListItem) => void;
onToggleTaskSidebar: () => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
@@ -198,7 +194,7 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
localTaskItems,
taskListRefreshKey,
isTaskSidebarOpen,
generateDialog,
cropExpandPanel,
@@ -243,7 +239,6 @@ export function ImageCanvasStageView({
onGenerationFramePointerDown,
onActivateGenerationDialog,
onFocusExternalTask,
onFocusLocalTask,
onToggleTaskSidebar,
onCropExpandHandlePointerDown,
onOpenQuickEditPanel,
@@ -443,11 +438,10 @@ export function ImageCanvasStageView({
<ImageCanvasTaskSidebarView
projectId={projectId}
localTasks={localTaskItems}
refreshKey={taskListRefreshKey}
open={isTaskSidebarOpen}
onToggleOpen={onToggleTaskSidebar}
onFocusExternalTask={onFocusExternalTask}
onFocusLocalTask={onFocusLocalTask}
/>
<ImageCanvasBottomToolbarView
@@ -220,9 +220,8 @@ describe('ImageCanvasTaskSidebarView', () => {
/>,
);
expect(
await screen.findByText(/ 2\/4 · 35%/u),
).toBeTruthy();
expect(await screen.findByText('生成图片')).toBeTruthy();
expect(screen.getByText(/ 35% · 2\/4 /u)).toBeTruthy();
expect(screen.getByText(/ 1/u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
await waitFor(() =>
@@ -287,33 +286,138 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(screen.queryByText(//u)).toBeNull();
});
it('shows local background-removal tasks in the task list immediately', async () => {
const focusLocalTask = vi.fn();
const startedAt = Date.now() - 4000;
const localTask = {
id: 'background-removal-1',
title: '去背景:源图',
detail: '正在抠图',
status: 'running' as const,
startedAt,
focusLayerId: 'layer-source',
};
it('refreshes external tasks when queued generation bumps the refresh key', async () => {
let activeTasks: ExternalGenerationTaskRecord[] = [];
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: activeTasks.length,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: options.statuses?.includes('running') ? activeTasks : [],
}),
);
const { rerender } = render(
<ImageCanvasTaskSidebarView
projectId="project-1"
refreshKey={0}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await waitFor(() => {
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('暂无任务')).toBeTruthy();
activeTasks = [
createExternalTask({
jobId: 'queued-after-submit',
requestLabel: '角色生成',
status: 'queued',
}),
];
rerender(
<ImageCanvasTaskSidebarView
projectId="project-1"
refreshKey={1}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
expect(await screen.findByText('角色生成')).toBeTruthy();
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4);
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();
return createExternalTask({
jobId: `done-${index + 1}`,
requestLabel: `已完成任务 ${index + 1}`,
status: 'completed',
requestPrompt: `提示词 ${index + 1}`,
completedAt,
updatedAt: completedAt,
});
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: completedTasks.length,
updatedAtMicros: 1,
},
tasks: options.statuses?.includes('running')
? []
: completedTasks.slice(0, options.limit ?? 20),
}),
);
render(
<ImageCanvasTaskSidebarView
projectId="project-1"
localTasks={[localTask]}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onFocusLocalTask={focusLocalTask}
/>,
);
expect(await screen.findByText('去背景:源图')).toBeTruthy();
expect(screen.getByText(/ · /u)).toBeTruthy();
await waitFor(() => {
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2);
});
expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
includeAcknowledgedTerminal: true,
limit: 20,
statuses: ['completed', 'failed'],
}),
);
fireEvent.click(screen.getByText('去背景:源图'));
expect(focusLocalTask).toHaveBeenCalledWith(localTask);
fireEvent.click(screen.getByRole('tab', { name: //u }));
expect(await screen.findByText('已完成任务 20')).toBeTruthy();
expect(screen.queryByText('已完成任务 21')).toBeNull();
const list = document.querySelector(
'.image-canvas-editor__task-sidebar-list',
);
expect(list).not.toBeNull();
Object.defineProperty(list, 'scrollHeight', {
configurable: true,
value: 1000,
});
Object.defineProperty(list, 'clientHeight', {
configurable: true,
value: 200,
});
Object.defineProperty(list, 'scrollTop', {
configurable: true,
value: 800,
});
fireEvent.scroll(list!);
await waitFor(() => {
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4);
});
expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
includeAcknowledgedTerminal: true,
limit: 40,
statuses: ['completed', 'failed'],
}),
);
expect(await screen.findByText('已完成任务 40')).toBeTruthy();
});
});
@@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import {
type WheelEvent as ReactWheelEvent,
type UIEvent as ReactUIEvent,
useCallback,
useEffect,
useMemo,
@@ -17,12 +18,10 @@ import {
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasTaskListItem,
CanvasTaskStatus,
} from './ImageCanvasEditorTypes';
import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
const COMPLETED_TASK_LIST_LIMIT = 20;
const COMPLETED_TASK_LIST_MAX_LIMIT = 100;
const ACTIVE_TASK_LIST_LIMIT = 100;
const ACTIVE_TASK_POLL_INTERVAL_MS = 4000;
@@ -32,6 +31,7 @@ type TaskSidebarItem = {
id: string;
title: string;
detail: string;
progressDetail?: string;
status: CanvasTaskStatus;
startedAt?: number;
finishedAt?: number;
@@ -43,11 +43,10 @@ type TaskSidebarItem = {
type ImageCanvasTaskSidebarViewProps = {
projectId?: string | null;
localTasks?: CanvasTaskListItem[];
refreshKey?: number;
open: boolean;
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onFocusLocalTask?: (task: CanvasTaskListItem) => void;
};
function parseTaskTimeMs(value?: string | null) {
@@ -103,13 +102,23 @@ function externalTaskDetail(
}
const phaseLabel = task.phaseLabel.trim();
const phaseDetail = task.phaseDetail.trim();
const progressLabel =
return phaseLabel || phaseDetail || task.requestLabel.trim() || task.jobKind;
}
function externalTaskProgressDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (!isActiveTaskStatus(status)) {
return null;
}
const values = [
Number.isFinite(task.progress) && task.progress > 0
? `总进度 ${Math.min(100, Math.max(0, Math.round(task.progress)))}%`
: null;
return [phaseLabel, phaseDetail, progressLabel]
.filter((value): value is string => Boolean(value))
.join(' · ');
: null,
task.phaseDetail.trim(),
].filter((value): value is string => Boolean(value));
return values.length > 0 ? values.join(' · ') : null;
}
function stopTaskSidebarWheel(event: ReactWheelEvent<HTMLElement>) {
@@ -215,26 +224,65 @@ function mergeExternalTaskRefresh(
);
}
function trimStoredExternalTasks(
tasks: ExternalGenerationTaskRecord[],
completedLimit: number,
) {
return mergeExternalTasks(
tasks.filter(isActiveExternalTask),
tasks
.filter(isTerminalExternalTask)
.sort(
(left, right) =>
(parseTaskTimeMs(right.completedAt) ??
parseTaskTimeMs(right.updatedAt) ??
0) -
(parseTaskTimeMs(left.completedAt) ??
parseTaskTimeMs(left.updatedAt) ??
0),
)
.slice(0, completedLimit),
);
}
export function ImageCanvasTaskSidebarView({
projectId,
localTasks = [],
refreshKey = 0,
open,
onToggleOpen,
onFocusExternalTask,
onFocusLocalTask,
}: ImageCanvasTaskSidebarViewProps) {
const normalizedProjectId = projectId?.trim() ?? '';
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
const [now, setNow] = useState(() => Date.now());
const [externalTasks, setExternalTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const [completedListState, setCompletedListState] = useState(() => ({
limit: COMPLETED_TASK_LIST_LIMIT,
projectId: normalizedProjectId,
}));
const completedListLimit =
completedListState.projectId === normalizedProjectId
? completedListState.limit
: COMPLETED_TASK_LIST_LIMIT;
const previousActiveItemCountRef = useRef(0);
const filterVisibleExternalTasks = useCallback(
(tasks: ExternalGenerationTaskRecord[]) =>
tasks.filter((task) => isEditorCanvasTaskForProject(task, projectId)),
[projectId],
tasks.filter((task) =>
isEditorCanvasTaskForProject(task, normalizedProjectId),
),
[normalizedProjectId],
);
useEffect(() => {
setCompletedListState((state) =>
state.projectId === normalizedProjectId
? state
: { limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId },
);
}, [normalizedProjectId]);
useEffect(() => {
let disposed = false;
const controller = new AbortController();
@@ -246,7 +294,7 @@ export function ImageCanvasTaskSidebarView({
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: COMPLETED_TASK_LIST_LIMIT,
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
@@ -257,9 +305,12 @@ export function ImageCanvasTaskSidebarView({
return;
}
setExternalTasks(
mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
trimStoredExternalTasks(
mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
),
completedListLimit,
),
);
})
@@ -272,7 +323,7 @@ export function ImageCanvasTaskSidebarView({
disposed = true;
controller.abort();
};
}, [filterVisibleExternalTasks]);
}, [completedListLimit, filterVisibleExternalTasks, refreshKey]);
const activeExternalTaskIds = useMemo(
() =>
@@ -300,7 +351,7 @@ export function ImageCanvasTaskSidebarView({
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: COMPLETED_TASK_LIST_LIMIT,
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
@@ -315,7 +366,10 @@ export function ImageCanvasTaskSidebarView({
filterVisibleExternalTasks(completedResponse.tasks),
);
setExternalTasks((currentTasks) =>
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
trimStoredExternalTasks(
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
completedListLimit,
),
);
})
.catch(() => {
@@ -345,13 +399,42 @@ export function ImageCanvasTaskSidebarView({
}, [
activeExternalTaskIds,
activeExternalTaskKey,
completedListLimit,
filterVisibleExternalTasks,
]);
const handleTaskListScroll = useCallback(
(event: ReactUIEvent<HTMLDivElement>) => {
if (activeTab !== 'completed') {
return;
}
const element = event.currentTarget;
const remaining =
element.scrollHeight - element.scrollTop - element.clientHeight;
if (remaining > 24) {
return;
}
setCompletedListState((state) => {
const limit =
state.projectId === normalizedProjectId
? state.limit
: COMPLETED_TASK_LIST_LIMIT;
return {
limit: Math.min(
COMPLETED_TASK_LIST_MAX_LIMIT,
limit + COMPLETED_TASK_LIST_LIMIT,
),
projectId: normalizedProjectId,
};
});
},
[activeTab, normalizedProjectId],
);
useEffect(() => {
const hasRunningTask =
externalTasks.some((task) => task.status === 'running') ||
localTasks.some((task) => task.status === 'running');
const hasRunningTask = externalTasks.some(
(task) => task.status === 'running',
);
if (!hasRunningTask) {
return undefined;
}
@@ -359,7 +442,7 @@ export function ImageCanvasTaskSidebarView({
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timerId);
}, [externalTasks, localTasks]);
}, [externalTasks]);
const items = useMemo<TaskSidebarItem[]>(() => {
const externalVisibleTasks = mergeExternalTasks(
@@ -377,7 +460,7 @@ export function ImageCanvasTaskSidebarView({
parseTaskTimeMs(left.updatedAt) ??
0),
)
.slice(0, COMPLETED_TASK_LIST_LIMIT),
.slice(0, completedListLimit),
);
const externalItems = externalVisibleTasks.map<TaskSidebarItem>((task) => {
const status = canvasTaskStatusFromExternalTask(task.status);
@@ -389,6 +472,7 @@ export function ImageCanvasTaskSidebarView({
id: `external-${task.jobId}`,
title: task.requestLabel.trim() || task.jobKind,
detail: externalTaskDetail(task, status),
progressDetail: externalTaskProgressDetail(task, status) ?? undefined,
status,
startedAt,
finishedAt,
@@ -408,27 +492,8 @@ export function ImageCanvasTaskSidebarView({
0,
};
});
const localItems = localTasks.map<TaskSidebarItem>((task) => ({
id: `local-${task.id}`,
title: task.title,
detail: task.detail,
status: task.status,
startedAt: task.startedAt,
finishedAt: task.finishedAt,
elapsedMs:
task.status === 'running'
? now - task.startedAt
: task.elapsedMs ??
(task.finishedAt ? task.finishedAt - task.startedAt : undefined),
errorMessage: task.errorMessage,
onClick:
task.focusLayerId && onFocusLocalTask
? () => onFocusLocalTask(task)
: undefined,
sortTime: task.finishedAt ?? task.startedAt,
}));
return [...localItems, ...externalItems];
}, [externalTasks, localTasks, now, onFocusExternalTask, onFocusLocalTask]);
return externalItems;
}, [completedListLimit, externalTasks, now, onFocusExternalTask]);
const activeItems = useMemo(
() =>
@@ -451,7 +516,7 @@ export function ImageCanvasTaskSidebarView({
(left, right) =>
right.sortTime - left.sortTime || left.id.localeCompare(right.id),
)
.slice(0, COMPLETED_TASK_LIST_LIMIT),
.slice(0, COMPLETED_TASK_LIST_MAX_LIMIT),
[items],
);
@@ -535,7 +600,10 @@ export function ImageCanvasTaskSidebarView({
</button>
</div>
{visibleItems.length > 0 ? (
<div className="image-canvas-editor__task-sidebar-list">
<div
className="image-canvas-editor__task-sidebar-list"
onScroll={handleTaskListScroll}
>
{visibleItems.map((item) => {
const elapsedLabel = formatElapsedMs(item.elapsedMs);
const finishedLabel =
@@ -544,6 +612,13 @@ export function ImageCanvasTaskSidebarView({
: null;
const detailText = [
item.detail,
item.errorMessage && item.detail !== item.errorMessage
? item.errorMessage
: null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const timeText = [
elapsedLabel
? item.status === 'running'
? `已用时 ${elapsedLabel}`
@@ -554,12 +629,12 @@ export function ImageCanvasTaskSidebarView({
? `失败于 ${finishedLabel}`
: `完成于 ${finishedLabel}`
: null,
item.errorMessage && item.detail !== item.errorMessage
? item.errorMessage
: null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
const titleText = [detailText, item.progressDetail, timeText]
.filter((value): value is string => Boolean(value))
.join(' · ');
return (
<button
key={item.id}
@@ -572,18 +647,33 @@ export function ImageCanvasTaskSidebarView({
{statusIcon(item.status)}
</span>
<span className="image-canvas-editor__task-sidebar-item-main">
<span className="image-canvas-editor__task-sidebar-item-title">
{item.title}
<span className="image-canvas-editor__task-sidebar-item-title-row">
<span className="image-canvas-editor__task-sidebar-item-title">
{item.title}
</span>
<span className="image-canvas-editor__task-sidebar-item-status">
{statusLabel(item.status)}
</span>
</span>
<span
className="image-canvas-editor__task-sidebar-item-detail"
title={detailText}
title={titleText}
>
{detailText}
</span>
</span>
<span className="image-canvas-editor__task-sidebar-item-status">
{statusLabel(item.status)}
{item.progressDetail ? (
<span
className="image-canvas-editor__task-sidebar-item-progress"
title={item.progressDetail}
>
{item.progressDetail}
</span>
) : null}
{timeText ? (
<span className="image-canvas-editor__task-sidebar-item-time">
{timeText}
</span>
) : null}
</span>
</button>
);
@@ -163,6 +163,7 @@ function SubmissionWorkflowHarness({
projectId = undefined,
upsertGeneratedAsset = undefined,
applyProjectSnapshot = undefined,
onQueuedGenerationTask = undefined,
onWalletBalanceMayHaveChanged = undefined,
}: {
initialDialog?: GenerateDialogState | null;
@@ -177,6 +178,9 @@ function SubmissionWorkflowHarness({
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['applyProjectSnapshot'];
onQueuedGenerationTask?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onQueuedGenerationTask'];
onWalletBalanceMayHaveChanged?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onWalletBalanceMayHaveChanged'];
@@ -251,6 +255,7 @@ function SubmissionWorkflowHarness({
assetFolderId: projectId ? 'project' : undefined,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
});
@@ -1160,6 +1165,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const backendProject = {
projectId: 'editor-project-1',
@@ -1183,6 +1189,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
@@ -1206,6 +1213,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(refreshTaskList).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
});
@@ -1221,6 +1231,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
vi.useFakeTimers();
try {
const refreshWalletBalance = vi.fn();
const refreshTaskList = vi.fn();
const runningState = createQueueState({
status: 'running',
phaseLabel: '生成中',
@@ -1242,6 +1253,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
@@ -1269,6 +1281,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await Promise.resolve();
await Promise.resolve();
});
expect(refreshTaskList).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
});
@@ -1704,9 +1717,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1758,9 +1771,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1798,9 +1811,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1853,7 +1866,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
}),
]),
}),
@@ -1868,9 +1881,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -142,6 +142,7 @@ type GenerationSubmissionWorkflowOptions = {
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -214,16 +215,18 @@ async function waitForEditorGenerationQueue(
return current;
}
async function applyQueuedEditorGenerationProject(
export async function applyQueuedEditorGenerationProject(
response: { queueState?: ExternalGenerationJobStatusRecord | null },
projectId: string | null | undefined,
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
onQueuedGenerationTask?: () => void,
onWalletBalanceMayHaveChanged?: () => void,
) {
const queueState = queuedStateFromResponse(response);
if (!queueState) {
return false;
}
onQueuedGenerationTask?.();
let terminalState: ExternalGenerationJobStatusRecord | null = null;
try {
terminalState = await waitForEditorGenerationQueue(queueState);
@@ -312,6 +315,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
@@ -795,6 +799,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -902,6 +907,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1087,6 +1093,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1157,6 +1164,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1269,6 +1277,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1424,6 +1433,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1461,6 +1471,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1507,6 +1518,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1566,6 +1578,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1615,6 +1628,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -1764,6 +1778,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
result,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
@@ -249,14 +249,7 @@ function GenerationWorkflowHarness({
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
<span data-testid="background-tasks">
{workflow.backgroundRemovalTasks
.map(
(task) =>
`${task.title}:${task.status}:${task.elapsedMs ?? '-'}:${task.provider ?? '-'}`,
)
.join('|') || '-'}
</span>
<span data-testid="task-refresh-key">{workflow.taskListRefreshKey}</span>
<button type="button" onClick={workflow.openGenerateDialog}>
</button>
@@ -1616,16 +1609,21 @@ describe('useImageCanvasGenerationWorkflow', () => {
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
'/generated-character-drafts/editor/private.png',
);
expect(removeImageBackgroundMock).toHaveBeenCalledWith({
sourceImageSrc: '/generated-character-drafts/editor/private.png',
projectId: undefined,
targetLayerId: 'layer-source',
assetKind: 'character',
generationInputs: undefined,
assetFolderId: undefined,
assetLabel: '源图 去背景',
sourceResourceId: 'resource-source',
});
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:character:/generated-character-drafts/editor/background-removal/result.png:background-removal-task',
);
expect(screen.getByTestId('background-tasks').textContent).toContain(
'去背景:源图:done:1234:BiRefNet',
);
expect(screen.getByTestId('task-refresh-key').textContent).toBe('0');
});
it('opens UI design extraction as a mark selection state before submitting', () => {
@@ -18,7 +18,6 @@ import type {
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import type {
CanvasGenerationDialogState,
CanvasTaskListItem,
CanvasLayer,
CanvasTool,
CanvasViewport,
@@ -98,7 +97,10 @@ import {
type UiAssetExtractionTool,
updateUiAssetExtractionDraftMark,
} from './ImageCanvasUiAssetExtractionModel';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
import {
applyQueuedEditorGenerationProject,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
type CanvasSize = { width: number; height: number };
@@ -484,10 +486,12 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot,
onWalletBalanceMayHaveChanged,
}: GenerationWorkflowOptions) {
const [backgroundRemovalTasks, setBackgroundRemovalTasks] = useState<
CanvasTaskListItem[]
>([]);
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
const [taskListRefreshKey, setTaskListRefreshKey] = useState(0);
const refreshTaskListForQueuedGeneration = useCallback(() => {
setIsTaskSidebarOpen(true);
setTaskListRefreshKey((key) => key + 1);
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
@@ -1384,82 +1388,74 @@ export function useImageCanvasGenerationWorkflow({
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
const startedAt = Date.now();
const taskId = `background-removal-${startedAt}-${Math.random().toString(36).slice(2, 8)}`;
setIsTaskSidebarOpen(true);
setBackgroundRemovalTasks((currentTasks) => [
{
id: taskId,
title: `去背景:${sourceLayer.title}`,
detail: '正在抠图',
status: 'running',
startedAt,
focusLayerId: sourceLayer.id,
},
...currentTasks,
]);
try {
const sourceObjectKey = sourceLayer.objectKey?.trim();
const sourceImageSrc = sourceObjectKey
? `/${sourceObjectKey.replace(/^\/+/u, '')}`
: await resolveEditorImageReferenceDataUrl(sourceLayer.src);
const result = await removeImageBackground(sourceImageSrc);
const finishedAt = Date.now();
const elapsedMs = result.elapsedMs ?? finishedAt - startedAt;
const result = await removeImageBackground({
sourceImageSrc,
projectId,
targetLayerId: sourceLayer.id,
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
assetFolderId,
assetLabel: `${sourceLayer.title} 去背景`,
sourceResourceId: sourceLayer.resourceId,
});
if (
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (result.project && applyProjectSnapshot) {
applyProjectSnapshot(result.project);
return;
}
updateSourceLayer(sourceLayer.id, (layer) => ({
...layer,
resourceId: `local-resource-${taskId}`,
resourceId: result.resource?.resourceId ?? layer.resourceId,
src: result.imageSrc,
width: result.width,
height: result.height,
originalWidth: result.width,
originalHeight: result.height,
sourceType: 'generated',
sourceType: result.sourceType ?? 'generated',
provider: result.provider ?? 'BiRefNet',
taskId: result.taskId ?? taskId,
objectKey: result.objectKey ?? null,
assetObjectId: result.assetObjectId ?? null,
sourceResourceId: layer.resourceId,
taskId: result.taskId ?? null,
objectKey: result.resource?.objectKey ?? result.objectKey ?? null,
assetObjectId:
result.resource?.assetObjectId ?? result.assetObjectId ?? null,
sourceResourceId:
result.resource?.sourceResourceId ?? sourceLayer.resourceId,
sourceAssetId: null,
prompt: result.resource?.prompt ?? layer.prompt,
actualPrompt: result.resource?.actualPrompt ?? layer.actualPrompt,
model: result.resource?.model ?? layer.model,
assetKind:
(result.resource?.assetKind as CanvasLayer['assetKind']) ??
layer.assetKind,
generationInputs:
result.resource?.generationInputs ?? layer.generationInputs,
generatedAssetSnapshot: result.asset ?? layer.generatedAssetSnapshot,
}));
setBackgroundRemovalTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
detail: '抠图完成',
status: 'done',
finishedAt,
elapsedMs,
provider: result.provider ?? 'BiRefNet',
}
: task,
),
);
setActiveSidebarPanel('layers');
} catch (error) {
const finishedAt = Date.now();
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: '去除背景失败';
setBackgroundRemovalTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
detail: '抠图失败',
status: 'failed',
finishedAt,
elapsedMs: finishedAt - startedAt,
errorMessage,
}
: task,
),
);
throw error;
}
},
[
applyProjectSnapshot,
assetFolderId,
onWalletBalanceMayHaveChanged,
projectId,
refreshTaskListForQueuedGeneration,
resolveEditorImageReferenceDataUrl,
setActiveSidebarPanel,
setImageContextMenu,
@@ -1604,7 +1600,6 @@ export function useImageCanvasGenerationWorkflow({
}),
);
}, []);
const generationSubmissionWorkflow =
useImageCanvasGenerationSubmissionWorkflow({
layers,
@@ -1636,6 +1631,7 @@ export function useImageCanvasGenerationWorkflow({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask: refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
});
@@ -2179,7 +2175,7 @@ export function useImageCanvasGenerationWorkflow({
openCropExpandPanel,
startCropExpandFrameResize,
removeSelectedLayerBackground,
backgroundRemovalTasks,
taskListRefreshKey,
isTaskSidebarOpen,
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
extractUiDesignAssets,
@@ -2211,7 +2207,6 @@ export function useImageCanvasGenerationWorkflow({
closeGenerateComposer,
cropExpandPanel,
cropExpandSourceLayer,
backgroundRemovalTasks,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
@@ -2245,6 +2240,7 @@ export function useImageCanvasGenerationWorkflow({
isUiDesignSpecMenuOpen,
isMusicMenuOpen,
isTaskSidebarOpen,
taskListRefreshKey,
isPublicationMenuOpen,
isPublicationReferenceMenuOpen,
isSpecMenuOpen,
@@ -2285,6 +2281,7 @@ export function useImageCanvasGenerationWorkflow({
updateIconDescriptionsText,
updateSpecFormValue,
rememberImageModel,
refreshTaskListForQueuedGeneration,
],
);
}
+30 -3
View File
@@ -6371,8 +6371,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.image-canvas-editor__task-sidebar-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.55rem;
width: 100%;
border: 0;
@@ -6438,7 +6438,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.image-canvas-editor__task-sidebar-item-main {
display: grid;
min-width: 0;
gap: 0.12rem;
gap: 0.16rem;
}
.image-canvas-editor__task-sidebar-item-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
align-items: center;
gap: 0.45rem;
}
.image-canvas-editor__task-sidebar-item-title {
@@ -6459,10 +6467,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap;
}
.image-canvas-editor__task-sidebar-item-progress {
overflow: hidden;
color: #334155;
font-size: 0.68rem;
font-weight: 750;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__task-sidebar-item-time {
color: #64748b;
font-size: 0.68rem;
font-weight: 750;
line-height: 1.25;
white-space: nowrap;
}
.image-canvas-editor__task-sidebar-item-status {
color: #64748b;
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.image-canvas-editor__panel-dock {
@@ -201,6 +201,14 @@ export type EditorImageEditInput = {
export type EditorBackgroundRemovalInput = {
sourceImageSrc: string;
projectId?: string | null;
targetLayerId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
sourceResourceId?: string | null;
taskId?: string | null;
};
export type EditorImageGenerationResult = {
@@ -227,9 +235,14 @@ export type EditorBackgroundRemovalResult = {
assetObjectId?: string | null;
width: number;
height: number;
sourceType?: 'generated';
taskId?: string | null;
elapsedMs?: number;
provider?: string;
resource?: EditorProjectResourceSnapshot | null;
asset?: EditorAssetSnapshot | null;
project?: EditorProjectSnapshot | null;
queueState?: ExternalGenerationJobStatusRecord | null;
};
export type EditorIconSpritesheetIconResult = {
@@ -889,6 +902,18 @@ export async function removeEditorImageBackground(
EDITOR_BACKGROUND_REMOVAL_API,
jsonRequest('POST', {
sourceImageSrc: input.sourceImageSrc,
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.targetLayerId ? { targetLayerId: input.targetLayerId } : {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.taskId ? { taskId: input.taskId } : {}),
}),
'去除背景失败',
{