切换图片画布抠图到 BiRefNet
新增图片画布去背景 BFF 配置与路由,服务端代理 BiRefNet 并持久化结果。
将手动去背景、角色图、图标 spritesheet、UI 素材提取和角色动作帧抠图切到 BiRefNet。
新增画布右上角任务侧栏并展示去背景任务耗时与状态。
同步图片画布技术文档和项目决策记录。
(cherry picked from commit 495fcc8801)
This commit is contained in:
@@ -132,6 +132,8 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
hoveredLayerId: null,
|
||||
canvasMarquee: null,
|
||||
canvasGenerationDialogs: [],
|
||||
backgroundRemovalTasks: [],
|
||||
isTaskSidebarOpen: false,
|
||||
generateDialog: null,
|
||||
cropExpandPanel: null,
|
||||
cropExpandSourceLayer: null,
|
||||
@@ -173,6 +175,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onUpdateLayerAssetKind: vi.fn(),
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onToggleTaskSidebar: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
|
||||
@@ -248,6 +248,20 @@ export type CanvasGenerationDialogState = GenerateDialogState & {
|
||||
mode: CanvasGenerationDialogMode;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type ImageContextMenuState = {
|
||||
layerId: string;
|
||||
x: number;
|
||||
|
||||
@@ -1499,6 +1499,8 @@ export function ImageCanvasEditorView() {
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
backgroundRemovalTasks: generationSurface.backgroundRemovalTasks,
|
||||
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
@@ -1545,6 +1547,7 @@ export function ImageCanvasEditorView() {
|
||||
onUpdateLayerAssetKind: updateLayerAssetKind,
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
|
||||
import { removeEditorImageBackground } from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CropExpandInsets = {
|
||||
left: number;
|
||||
@@ -27,8 +27,13 @@ export type CropExpandFrame = {
|
||||
|
||||
export type RasterEditResult = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
taskId?: string | null;
|
||||
elapsedMs?: number;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
function assertCanvasSupport() {
|
||||
@@ -209,17 +214,5 @@ export async function renderCropExpandImage({
|
||||
export async function removeImageBackground(
|
||||
source: string,
|
||||
): Promise<RasterEditResult> {
|
||||
const image = await loadImageElement(source);
|
||||
const { width, height } = resolveImageSize(image);
|
||||
const { canvas, context } = createRasterCanvas(width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
removeBackgroundFromRgba(imageData.data, width, height);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return {
|
||||
imageSrc: canvas.toDataURL('image/png'),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
return removeEditorImageBackground({ sourceImageSrc: source });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasTaskListItem,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasTool,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
|
||||
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
|
||||
import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView';
|
||||
import type {
|
||||
UiAssetExtractionState,
|
||||
UiAssetExtractionTool,
|
||||
@@ -52,6 +54,8 @@ export type ImageCanvasStageViewProps = {
|
||||
hoveredLayerId: string | null;
|
||||
canvasMarquee: CanvasMarqueeState | null;
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
backgroundRemovalTasks: CanvasTaskListItem[];
|
||||
isTaskSidebarOpen: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
cropExpandPanel: CropExpandPanelState | null;
|
||||
cropExpandSourceLayer: CanvasLayer | null;
|
||||
@@ -109,6 +113,7 @@ export type ImageCanvasStageViewProps = {
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
onToggleTaskSidebar: () => void;
|
||||
onCropExpandHandlePointerDown: (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
handle: CropExpandResizeHandle,
|
||||
@@ -186,6 +191,8 @@ export function ImageCanvasStageView({
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
backgroundRemovalTasks,
|
||||
isTaskSidebarOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel,
|
||||
cropExpandSourceLayer,
|
||||
@@ -228,6 +235,7 @@ export function ImageCanvasStageView({
|
||||
onUpdateLayerAssetKind,
|
||||
onGenerationFramePointerDown,
|
||||
onActivateGenerationDialog,
|
||||
onToggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenRedrawPanel,
|
||||
@@ -424,6 +432,14 @@ export function ImageCanvasStageView({
|
||||
onMinimapPointerDown={onMinimapPointerDown}
|
||||
/>
|
||||
|
||||
<ImageCanvasTaskSidebarView
|
||||
generationDialogs={canvasGenerationDialogs}
|
||||
backgroundRemovalTasks={backgroundRemovalTasks}
|
||||
open={isTaskSidebarOpen}
|
||||
onToggleOpen={onToggleTaskSidebar}
|
||||
onActivateGenerationDialog={onActivateGenerationDialog}
|
||||
/>
|
||||
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasTaskListItem,
|
||||
CanvasTaskStatus,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type TaskSidebarItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
status: CanvasTaskStatus;
|
||||
elapsedMs?: number;
|
||||
errorMessage?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ImageCanvasTaskSidebarViewProps = {
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
backgroundRemovalTasks: CanvasTaskListItem[];
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
};
|
||||
|
||||
function formatElapsedMs(elapsedMs?: number) {
|
||||
if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs)) {
|
||||
return null;
|
||||
}
|
||||
const seconds = elapsedMs / 1000;
|
||||
return seconds >= 10 ? `${Math.round(seconds)}秒` : `${seconds.toFixed(1)}秒`;
|
||||
}
|
||||
|
||||
function generationDialogTitle(dialog: CanvasGenerationDialogState) {
|
||||
switch (dialog.mode) {
|
||||
case 'quick-edit':
|
||||
return '局部修改';
|
||||
case 'character-animation':
|
||||
return '角色动画';
|
||||
case 'video':
|
||||
return '视频生成';
|
||||
case 'audio-sound-effect':
|
||||
return '音效生成';
|
||||
case 'audio-background-music':
|
||||
return '背景音乐';
|
||||
case 'icon':
|
||||
return '图标素材';
|
||||
case 'ui-design':
|
||||
return 'UI素材提取';
|
||||
case 'publication':
|
||||
return '宣发素材';
|
||||
case 'character':
|
||||
return '角色图片';
|
||||
case 'spec':
|
||||
return '图片规范';
|
||||
default:
|
||||
return '图片生成';
|
||||
}
|
||||
}
|
||||
|
||||
function generationDialogDetail(dialog: CanvasGenerationDialogState) {
|
||||
if (dialog.status === 'generating') {
|
||||
return '生成中';
|
||||
}
|
||||
if (dialog.status === 'failed') {
|
||||
return dialog.errorMessage || '生成失败';
|
||||
}
|
||||
return dialog.generatedLayerId ? '生成完成' : '待提交';
|
||||
}
|
||||
|
||||
function statusLabel(status: CanvasTaskStatus) {
|
||||
if (status === 'pending') {
|
||||
return '待提交';
|
||||
}
|
||||
if (status === 'running') {
|
||||
return '进行中';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '完成';
|
||||
}
|
||||
|
||||
function statusIcon(status: CanvasTaskStatus) {
|
||||
if (status === 'pending') {
|
||||
return <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
}
|
||||
if (status === 'running') {
|
||||
return <Loader2 className="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <XCircle className="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
}
|
||||
return <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function ImageCanvasTaskSidebarView({
|
||||
generationDialogs,
|
||||
backgroundRemovalTasks,
|
||||
open,
|
||||
onToggleOpen,
|
||||
onActivateGenerationDialog,
|
||||
}: ImageCanvasTaskSidebarViewProps) {
|
||||
const items = useMemo<TaskSidebarItem[]>(() => {
|
||||
const generationItems: TaskSidebarItem[] = generationDialogs.map(
|
||||
(dialog) => ({
|
||||
id: `generation-${dialog.id}`,
|
||||
title: generationDialogTitle(dialog),
|
||||
detail: generationDialogDetail(dialog),
|
||||
status:
|
||||
dialog.status === 'generating'
|
||||
? 'running'
|
||||
: dialog.status === 'failed'
|
||||
? 'failed'
|
||||
: dialog.generatedLayerId
|
||||
? 'done'
|
||||
: 'pending',
|
||||
errorMessage: dialog.errorMessage,
|
||||
onClick: () => onActivateGenerationDialog(dialog),
|
||||
}),
|
||||
);
|
||||
return [...backgroundRemovalTasks, ...generationItems];
|
||||
}, [backgroundRemovalTasks, generationDialogs, onActivateGenerationDialog]);
|
||||
|
||||
if (!open && items.length === 0) {
|
||||
return (
|
||||
<div className="image-canvas-editor__task-sidebar image-canvas-editor__task-sidebar--closed">
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__task-sidebar-toggle"
|
||||
label="任务列表"
|
||||
title="任务列表"
|
||||
icon={ListChecks}
|
||||
expanded={false}
|
||||
onClick={onToggleOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`image-canvas-editor__task-sidebar ${open ? 'image-canvas-editor__task-sidebar--open' : 'image-canvas-editor__task-sidebar--closed'}`}
|
||||
aria-label="画布任务列表"
|
||||
>
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__task-sidebar-toggle"
|
||||
label="任务列表"
|
||||
title="任务列表"
|
||||
icon={ListChecks}
|
||||
expanded={open}
|
||||
onClick={onToggleOpen}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="image-canvas-editor__task-sidebar-panel">
|
||||
<div className="image-canvas-editor__task-sidebar-header">
|
||||
<strong>任务列表</strong>
|
||||
<span>{items.length}</span>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="image-canvas-editor__task-sidebar-list">
|
||||
{items.map((item) => {
|
||||
const elapsedLabel = formatElapsedMs(item.elapsedMs);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`image-canvas-editor__task-sidebar-item image-canvas-editor__task-sidebar-item--${item.status}`}
|
||||
onClick={item.onClick}
|
||||
disabled={!item.onClick}
|
||||
>
|
||||
<span className="image-canvas-editor__task-sidebar-item-icon">
|
||||
{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>
|
||||
<span className="image-canvas-editor__task-sidebar-item-detail">
|
||||
{item.detail}
|
||||
{elapsedLabel ? ` · 耗时 ${elapsedLabel}` : ''}
|
||||
{item.errorMessage && item.detail !== item.errorMessage
|
||||
? ` · ${item.errorMessage}`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="image-canvas-editor__task-sidebar-item-status">
|
||||
{statusLabel(item.status)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -249,6 +249,14 @@ 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>
|
||||
<button type="button" onClick={workflow.openGenerateDialog}>
|
||||
打开生成
|
||||
</button>
|
||||
@@ -1580,9 +1588,16 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'data:image/png;base64,resolved-character',
|
||||
);
|
||||
removeImageBackgroundMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,no-background',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/background-removal/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/background-removal/result.png',
|
||||
assetObjectId: 'asset-object-background-removal',
|
||||
width: 512,
|
||||
height: 768,
|
||||
taskId: 'background-removal-task',
|
||||
elapsedMs: 1234,
|
||||
provider: 'BiRefNet',
|
||||
});
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
@@ -1598,18 +1613,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '去除背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||||
'generated-character-drafts/editor/private.png',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
|
||||
'data:image/png;base64,resolved-character',
|
||||
'/generated-character-drafts/editor/private.png',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-source:源图:-:character',
|
||||
'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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasTaskListItem,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
CanvasViewport,
|
||||
@@ -483,6 +484,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
applyProjectSnapshot,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [backgroundRemovalTasks, setBackgroundRemovalTasks] = useState<
|
||||
CanvasTaskListItem[]
|
||||
>([]);
|
||||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||||
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
@@ -538,6 +544,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
null,
|
||||
);
|
||||
const uiAssetExtractionMarkCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasGenerationDialogs.length > previousTaskCountRef.current) {
|
||||
setIsTaskSidebarOpen(true);
|
||||
}
|
||||
previousTaskCountRef.current = canvasGenerationDialogs.length;
|
||||
}, [canvasGenerationDialogs.length]);
|
||||
const quickEditSelectionMarkCounterRef = useRef(0);
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
useState<CharacterAnimationPanelState | null>(null);
|
||||
@@ -1371,28 +1384,77 @@ 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,
|
||||
},
|
||||
...currentTasks,
|
||||
]);
|
||||
try {
|
||||
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
sourceLayer.objectKey?.trim() || sourceLayer.src,
|
||||
);
|
||||
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;
|
||||
updateSourceLayer(sourceLayer.id, (layer) => ({
|
||||
...layer,
|
||||
resourceId: `local-resource-${taskId}`,
|
||||
src: result.imageSrc,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
originalWidth: result.width,
|
||||
originalHeight: result.height,
|
||||
objectKey: null,
|
||||
assetObjectId: null,
|
||||
sourceType: 'generated',
|
||||
provider: result.provider ?? 'BiRefNet',
|
||||
taskId: result.taskId ?? taskId,
|
||||
objectKey: result.objectKey ?? null,
|
||||
assetObjectId: result.assetObjectId ?? null,
|
||||
sourceResourceId: layer.resourceId,
|
||||
sourceAssetId: null,
|
||||
}));
|
||||
setBackgroundRemovalTasks((currentTasks) =>
|
||||
currentTasks.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
detail: '抠图完成',
|
||||
status: 'done',
|
||||
finishedAt,
|
||||
elapsedMs,
|
||||
provider: result.provider ?? 'BiRefNet',
|
||||
}
|
||||
: task,
|
||||
),
|
||||
);
|
||||
setActiveSidebarPanel('layers');
|
||||
} catch (error) {
|
||||
window.alert(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -2116,6 +2178,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openCropExpandPanel,
|
||||
startCropExpandFrameResize,
|
||||
removeSelectedLayerBackground,
|
||||
backgroundRemovalTasks,
|
||||
isTaskSidebarOpen,
|
||||
toggleTaskSidebar: () => setIsTaskSidebarOpen((open) => !open),
|
||||
extractUiDesignAssets,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickGenerationReferenceFromLayer,
|
||||
@@ -2145,6 +2210,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
closeGenerateComposer,
|
||||
cropExpandPanel,
|
||||
cropExpandSourceLayer,
|
||||
backgroundRemovalTasks,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
@@ -2177,6 +2243,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
isPickingUiDesignSpecFromCanvas,
|
||||
isUiDesignSpecMenuOpen,
|
||||
isMusicMenuOpen,
|
||||
isTaskSidebarOpen,
|
||||
isPublicationMenuOpen,
|
||||
isPublicationReferenceMenuOpen,
|
||||
isSpecMenuOpen,
|
||||
|
||||
+151
@@ -6172,6 +6172,157 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar--closed {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-toggle {
|
||||
flex: 0 0 auto;
|
||||
border-color: var(--image-canvas-brand-border-soft);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-panel {
|
||||
width: min(20rem, calc(100vw - 5.6rem));
|
||||
max-height: min(24rem, calc(100vh - 9rem));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.68rem 0.78rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-header strong {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-header span {
|
||||
display: inline-flex;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-list {
|
||||
display: grid;
|
||||
max-height: calc(min(24rem, calc(100vh - 9rem)) - 2.8rem);
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0.42rem;
|
||||
background: transparent;
|
||||
padding: 0.55rem;
|
||||
color: #334155;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item:disabled {
|
||||
color: #334155;
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item:not(:disabled):hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item-icon {
|
||||
display: inline-flex;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item--running
|
||||
.image-canvas-editor__task-sidebar-item-icon {
|
||||
background: #ecfeff;
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item--failed
|
||||
.image-canvas-editor__task-sidebar-item-icon {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item--done
|
||||
.image-canvas-editor__task-sidebar-item-icon {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item-title {
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item-detail {
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item-status {
|
||||
color: #64748b;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
|
||||
@@ -8,6 +8,7 @@ const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources';
|
||||
const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
|
||||
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
|
||||
'/api/editor/icon-spritesheets/generations';
|
||||
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_API =
|
||||
@@ -198,6 +199,10 @@ export type EditorImageEditInput = {
|
||||
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
|
||||
};
|
||||
|
||||
export type EditorBackgroundRemovalInput = {
|
||||
sourceImageSrc: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
@@ -216,6 +221,17 @@ export type EditorImageGenerationResult = {
|
||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||
};
|
||||
|
||||
export type EditorBackgroundRemovalResult = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
taskId?: string | null;
|
||||
elapsedMs?: number;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetIconResult = {
|
||||
name: string;
|
||||
imageSrc: string;
|
||||
@@ -353,7 +369,6 @@ export type EditorVideoGenerationResult = {
|
||||
resource?: EditorProjectResourceSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
project?: EditorProjectSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
||||
};
|
||||
|
||||
@@ -517,6 +532,7 @@ type EditorAssetResponse = {
|
||||
};
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult;
|
||||
type EditorIconSpritesheetGenerationResponse =
|
||||
EditorIconSpritesheetGenerationResult;
|
||||
type EditorVideoGenerationResponse = EditorVideoGenerationResult;
|
||||
@@ -866,6 +882,22 @@ export async function editEditorImage(input: EditorImageEditInput) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeEditorImageBackground(
|
||||
input: EditorBackgroundRemovalInput,
|
||||
) {
|
||||
return requestJson<EditorBackgroundRemovalResponse>(
|
||||
EDITOR_BACKGROUND_REMOVAL_API,
|
||||
jsonRequest('POST', {
|
||||
sourceImageSrc: input.sourceImageSrc,
|
||||
}),
|
||||
'去除背景失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: EDITOR_REQUEST_RETRY_OPTIONS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorCharacterAnimation(
|
||||
input: EditorCharacterAnimationGenerationInput,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user