明确序列帧去背景处理模式
去背景弹窗要求用户显式选择纯色键控或通用智能分割。 纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。 通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。 入队与 durable worker 统一校验模式参数并持久化权威处理元数据。 补充双色键控、模式冲突、双路径路由和前端交互回归。 同步更新 Spine 技术方案与团队决策记录。
This commit is contained in:
+67
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user