切换图片画布抠图到 BiRefNet

新增图片画布去背景 BFF 配置与路由,服务端代理 BiRefNet 并持久化结果。

将手动去背景、角色图、图标 spritesheet、UI 素材提取和角色动作帧抠图切到 BiRefNet。

新增画布右上角任务侧栏并展示去背景任务耗时与状态。

同步图片画布技术文档和项目决策记录。

(cherry picked from commit 495fcc8801)
This commit is contained in:
2026-06-29 15:02:14 +08:00
parent f40a45e01b
commit 08f188e26e
16 changed files with 1056 additions and 277 deletions
@@ -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>
);
}