完善画布任务与标准绿幕抠除链路
统一编辑器标准绿幕提示词和本地绿幕透明化后处理 生成标准绿幕资产前保存源图到 OSS 并下沉字节级抠绿到 platform-image 将手动去背景接入外部生成队列与画布任务侧栏 修复任务侧栏分页、滚动、计时、焦点与完成信息展示 补充右键菜单关闭和素材复制下载交互 同步编辑器相关文档和项目记忆
This commit is contained in:
@@ -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
@@ -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 } : {}),
|
||||
}),
|
||||
'去除背景失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user