明确序列帧去背景处理模式

去背景弹窗要求用户显式选择纯色键控或通用智能分割。

纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。

通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。

入队与 durable worker 统一校验模式参数并持久化权威处理元数据。

补充双色键控、模式冲突、双路径路由和前端交互回归。

同步更新 Spine 技术方案与团队决策记录。
This commit is contained in:
2026-08-17 15:27:13 +08:00
parent ae5c2d8821
commit 3d145379f2
13 changed files with 665 additions and 33 deletions
@@ -0,0 +1,67 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EditorCharacterAnimationBackgroundRemovalMode } from '../../services/image-editor/editorProjectClient';
import { ImageCanvasCharacterAnimationBackgroundRemovalDialog } from './ImageCanvasCharacterAnimationBackgroundRemovalDialog';
function DialogHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void }) {
const [mode, setMode] =
useState<EditorCharacterAnimationBackgroundRemovalMode>('solid-color');
const [screenColor, setScreenColor] = useState('#00FF00');
return (
<ImageCanvasCharacterAnimationBackgroundRemovalDialog
open
mode={mode}
screenColor={screenColor}
onModeChange={setMode}
onScreenColorChange={setScreenColor}
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
}
afterEach(() => {
delete (window as typeof window & { EyeDropper?: unknown }).EyeDropper;
});
describe('ImageCanvasCharacterAnimationBackgroundRemovalDialog', () => {
it('requires an explicit pure-color or general mode selection', () => {
const onConfirm = vi.fn();
render(<DialogHarness onConfirm={onConfirm} />);
expect(
(screen.getByRole('radio', { name: /纯色背景抠图/ }) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText('纯色背景颜色') as HTMLInputElement).value,
).toBe('#00ff00');
fireEvent.click(screen.getByRole('radio', { name: /通用智能抠图/ }));
expect(screen.queryByLabelText('纯色背景颜色')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '开始处理' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('uses the browser eyedropper result as the solid background color', async () => {
const open = vi.fn().mockResolvedValue({ sRGBHex: '#336699' });
(window as typeof window & { EyeDropper?: unknown }).EyeDropper = class {
open = open;
};
render(<DialogHarness />);
fireEvent.click(screen.getByRole('button', { name: '吸取画布颜色' }));
await waitFor(() =>
expect(
(screen.getByLabelText('纯色背景颜色') as HTMLInputElement).value,
).toBe('#336699'),
);
expect(open).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,137 @@
import { useCallback, useState } from 'react';
import type { EditorCharacterAnimationBackgroundRemovalMode } from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
type EyeDropperConstructor = new () => {
open: () => Promise<{ sRGBHex: string }>;
};
type ImageCanvasCharacterAnimationBackgroundRemovalDialogProps = {
open: boolean;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor: string;
busy?: boolean;
onModeChange: (mode: EditorCharacterAnimationBackgroundRemovalMode) => void;
onScreenColorChange: (screenColor: string) => void;
onClose: () => void;
onConfirm: () => void;
};
function resolveEyeDropper() {
return (window as typeof window & { EyeDropper?: EyeDropperConstructor })
.EyeDropper;
}
export function ImageCanvasCharacterAnimationBackgroundRemovalDialog({
open,
mode,
screenColor,
busy = false,
onModeChange,
onScreenColorChange,
onClose,
onConfirm,
}: ImageCanvasCharacterAnimationBackgroundRemovalDialogProps) {
const [isPickingColor, setIsPickingColor] = useState(false);
const pickScreenColor = useCallback(async () => {
const EyeDropper = resolveEyeDropper();
if (!EyeDropper || isPickingColor) {
return;
}
setIsPickingColor(true);
try {
const result = await new EyeDropper().open();
if (/^#[0-9a-f]{6}$/i.test(result.sRGBHex)) {
onScreenColorChange(result.sRGBHex.toUpperCase());
}
} catch {
// 用户取消系统吸管不属于错误,保持当前颜色即可。
} finally {
setIsPickingColor(false);
}
}, [isPickingColor, onScreenColorChange]);
return (
<UnifiedConfirmDialog
open={open}
title="序列帧去背景"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="开始处理"
busy={busy}
busyConfirmLabel="提交中"
confirmDisabled={
mode === 'solid-color' && !/^#[0-9A-F]{6}$/i.test(screenColor)
}
showCancel
size="md"
panelClassName="platform-remap-surface"
>
<fieldset className="grid gap-3" aria-label="去背景方式">
<label className="flex cursor-pointer gap-3 rounded-xl border border-[var(--platform-border-soft)] p-3">
<input
type="radio"
name="character-animation-background-removal-mode"
value="solid-color"
checked={mode === 'solid-color'}
disabled={busy}
onChange={() => onModeChange('solid-color')}
/>
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-[var(--platform-text-muted)]">
使
</span>
</span>
</label>
<label className="flex cursor-pointer gap-3 rounded-xl border border-[var(--platform-border-soft)] p-3">
<input
type="radio"
name="character-animation-background-removal-mode"
value="general"
checked={mode === 'general'}
disabled={busy}
onChange={() => onModeChange('general')}
/>
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-[var(--platform-text-muted)]">
</span>
</span>
</label>
</fieldset>
{mode === 'solid-color' ? (
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="color"
aria-label="纯色背景颜色"
value={screenColor}
disabled={busy}
onChange={(event) =>
onScreenColorChange(event.currentTarget.value.toUpperCase())
}
className="h-9 w-12 cursor-pointer rounded border border-[var(--platform-border-soft)] bg-transparent p-0.5"
/>
</label>
<code className="text-xs">{screenColor}</code>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
disabled={busy || isPickingColor || !resolveEyeDropper()}
onClick={() => void pickScreenColor()}
>
{isPickingColor ? '取色中' : '吸取画布颜色'}
</PlatformActionButton>
</div>
) : null}
</UnifiedConfirmDialog>
);
}
@@ -18,6 +18,7 @@ import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorCharacterAnimationBackgroundRemovalMode,
type EditorProjectSnapshot,
loadEditorGenerationPricing,
loadEditorProject,
@@ -34,6 +35,7 @@ import {
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import type { ImageCanvasActionResult } from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import { ImageCanvasCharacterAnimationBackgroundRemovalDialog } from './ImageCanvasCharacterAnimationBackgroundRemovalDialog';
import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
@@ -209,6 +211,12 @@ function openCanvasStartupTool(
generationSurface.openGenerateDialog();
}
type CharacterAnimationBackgroundRemovalDraft = {
sourceLayer: CanvasLayer;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor: string;
};
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
@@ -507,6 +515,10 @@ export function ImageCanvasEditorView({
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [
characterAnimationBackgroundRemovalDraft,
setCharacterAnimationBackgroundRemovalDraft,
] = useState<CharacterAnimationBackgroundRemovalDraft | null>(null);
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
useState<CanvasGenerationDialogState | null>(null);
const [imageContextMenu, setImageContextMenu] =
@@ -2673,7 +2685,11 @@ export function ImageCanvasEditorView({
},
onRemoveCharacterAnimationBackground: (layer: CanvasLayer) => {
void flushProjectPersistence().then(() =>
removeSelectedCharacterAnimationBackground(layer),
setCharacterAnimationBackgroundRemovalDraft({
sourceLayer: layer,
mode: 'solid-color',
screenColor: '#00FF00',
}),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
@@ -2744,6 +2760,42 @@ export function ImageCanvasEditorView({
onClose: () => setMetadataLayer(null),
}}
/>
<ImageCanvasCharacterAnimationBackgroundRemovalDialog
open={Boolean(characterAnimationBackgroundRemovalDraft)}
mode={characterAnimationBackgroundRemovalDraft?.mode ?? 'solid-color'}
screenColor={
characterAnimationBackgroundRemovalDraft?.screenColor ?? '#00FF00'
}
busy={Boolean(
characterAnimationBackgroundRemovalDraft &&
removingBackgroundCharacterAnimationLayerIds.has(
characterAnimationBackgroundRemovalDraft.sourceLayer.id,
),
)}
onModeChange={(mode) =>
setCharacterAnimationBackgroundRemovalDraft((draft) =>
draft ? { ...draft, mode } : draft,
)
}
onScreenColorChange={(screenColor) =>
setCharacterAnimationBackgroundRemovalDraft((draft) =>
draft ? { ...draft, screenColor } : draft,
)
}
onClose={() => setCharacterAnimationBackgroundRemovalDraft(null)}
onConfirm={() => {
const draft = characterAnimationBackgroundRemovalDraft;
if (!draft) {
return;
}
void removeSelectedCharacterAnimationBackground(draft.sourceLayer, {
mode: draft.mode,
...(draft.mode === 'solid-color'
? { screenColor: draft.screenColor }
: {}),
}).then(() => setCharacterAnimationBackgroundRemovalDraft(null));
}}
/>
<PlatformDangerConfirmDialog
open={Boolean(pendingGenerationDeleteDialog)}
title="删除生成中的占位图"
@@ -947,7 +947,10 @@ function GenerationWorkflowHarness({
<button
type="button"
onClick={() =>
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!)
void workflow.removeSelectedCharacterAnimationBackground(layers[0]!, {
mode: 'solid-color',
screenColor: '#00FF00',
})
}
>
@@ -3088,6 +3091,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: 'project-1',
sourceLayerId: 'layer-source',
sourceResourceId: 'resource-source',
mode: 'solid-color',
screenColor: '#00FF00',
}),
);
@@ -19,6 +19,7 @@ import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMe
import {
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorCharacterAnimationBackgroundRemovalMode,
type EditorPixelArtSnapInput,
type EditorPixelArtSnapResult,
type EditorProjectLayerSnapshot,
@@ -3676,7 +3677,13 @@ export function useImageCanvasGenerationWorkflow({
);
const removeSelectedCharacterAnimationBackground = useCallback(
async (sourceLayer: CanvasLayer) => {
async (
sourceLayer: CanvasLayer,
options: {
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor?: string;
},
) => {
if (
!canRemoveCharacterAnimationBackground(sourceLayer) ||
!canSplitCharacterAnimationFrames(sourceLayer) ||
@@ -3716,6 +3723,10 @@ export function useImageCanvasGenerationWorkflow({
projectId,
sourceLayerId: sourceLayer.id,
sourceResourceId: sourceLayer.resourceId,
mode: options.mode,
...(options.mode === 'solid-color' && options.screenColor
? { screenColor: options.screenColor }
: {}),
assetFolderId,
assetLabel,
...(backgroundRemovalPlacement?.placeholder
@@ -36,6 +36,7 @@ import {
optimizeEditorSoundEffectPrompt,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
removeEditorCharacterAnimationBackground,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
@@ -2552,6 +2553,36 @@ describe('editorProjectClient', () => {
);
});
it('sends explicit character animation background removal modes and only sends color for solid mode', async () => {
requestJsonMock.mockResolvedValue({
queueState: { operationId: 'character-removal-1', status: 'queued' },
});
await removeEditorCharacterAnimationBackground({
projectId: 'editor-project-1',
sourceLayerId: 'layer-sequence-1',
sourceResourceId: 'resource-sequence-1',
mode: 'solid-color',
screenColor: '#336699',
});
await removeEditorCharacterAnimationBackground({
projectId: 'editor-project-1',
sourceLayerId: 'layer-sequence-1',
sourceResourceId: 'resource-sequence-1',
mode: 'general',
screenColor: '#00FF00',
});
const solidBody = JSON.parse(requestJsonMock.mock.calls[0]?.[1]?.body);
const generalBody = JSON.parse(requestJsonMock.mock.calls[1]?.[1]?.body);
expect(solidBody).toMatchObject({
mode: 'solid-color',
screenColor: '#336699',
});
expect(generalBody).toMatchObject({ mode: 'general' });
expect(generalBody).not.toHaveProperty('screenColor');
});
it('passes canvas completion context to background removal', async () => {
requestJsonMock.mockResolvedValueOnce({
queueState: {
@@ -634,10 +634,16 @@ export type EditorImageSequenceFrameResult = {
height: number;
};
export type EditorCharacterAnimationBackgroundRemovalMode =
| 'solid-color'
| 'general';
export type EditorCharacterAnimationBackgroundRemovalInput = {
projectId: string;
sourceLayerId: string;
sourceResourceId: string;
mode: EditorCharacterAnimationBackgroundRemovalMode;
screenColor?: string | null;
assetFolderId?: string | null;
assetLabel?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
@@ -1672,6 +1678,10 @@ export async function removeEditorCharacterAnimationBackground(
projectId: input.projectId,
sourceLayerId: input.sourceLayerId,
sourceResourceId: input.sourceResourceId,
mode: input.mode,
...(input.mode === 'solid-color' && input.screenColor
? { screenColor: input.screenColor }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.canvasCompletion