新抠图算法 (#85)

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/85
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #85.
This commit is contained in:
2026-07-16 18:33:06 +08:00
committed by 段舒康
parent 94014392ed
commit aecabdacfd
51 changed files with 2905 additions and 763 deletions
@@ -2938,7 +2938,7 @@ describe('ImageCanvasEditorView generation integration', () => {
status: 'completed',
progress: 100,
phaseDetail: '生成已完成。',
warning: '连通域数量不足',
warning: '图集已生成,但自动拆分未完成:连通域数量不足',
completedAt: '2026-06-21T00:01:00.000Z',
updatedAt: '2026-06-21T00:01:00.000Z',
updatedAtMicros: 2,
@@ -1251,9 +1251,7 @@ export function ImageCanvasEditorView({
}
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
if (warning) {
showGenerationWarning(
`图集已生成,但自动拆分未完成:${warning}`,
);
showGenerationWarning(warning);
}
refreshEditorWalletBalance();
void loadEditorProject(projectId)
@@ -220,6 +220,8 @@ describe('ImageCanvasTaskSidebarView', () => {
createExternalTask({
jobId: 'active-current',
startedAt,
phaseDetail: '正在处理。',
progress: 70,
}),
createExternalTask({
jobId: 'active-other-project',
@@ -260,8 +262,8 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(await screen.findByText('图片画布生成图片')).toBeTruthy();
expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy();
expect(screen.getByText('正在生成第 2/4 段。')).toBeTruthy();
expect(screen.queryByText(/35%/u)).toBeNull();
expect(screen.getByText('正在处理。')).toBeTruthy();
expect(screen.queryByText(/\d{1,3}%/u)).toBeNull();
expect(screen.getByText(/已用时 1分/u)).toBeTruthy();
expect(screen.queryByText(/总进度/u)).toBeNull();
expect(screen.queryByText(/不该显示的外部项目任务/u)).toBeNull();
@@ -1029,7 +1029,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('submits character redraws through the character image prompt contract', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '角色换成蓝色披风' }),
createGenerated({
prompt: '角色换成蓝色披风',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
},
}),
);
render(
<SubmissionWorkflowHarness
@@ -1080,6 +1087,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:角色换成蓝色披风:-',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
});
it('submits video quick edits through the video API with the source video', async () => {
@@ -1653,7 +1663,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
});
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
it('refreshes the wallet and shows the warning after a queued character generation completes', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
@@ -1671,9 +1681,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
imageSrc: '/generated-editor-images/project/image.png',
width: 1024,
height: 1024,
prompt: '队列生成',
prompt: '队列角色生成',
}),
queueState: createQueueState({
warning:
'生成任务成功,后处理失败。',
}),
queueState: createQueueState(),
});
render(
<SubmissionWorkflowHarness
@@ -1682,9 +1695,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '队列生成',
id: 'dialog-character',
mode: 'character',
prompt: '队列角色生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
@@ -1712,6 +1725,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled();
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
@@ -1805,7 +1821,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning: '连通域数量不足',
warning:
'生成任务成功,后处理失败。',
}),
},
'editor-project-1',
@@ -1816,7 +1833,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
expect(onGenerationWarning).toHaveBeenCalledWith(
'图集已生成,但自动拆分未完成:连通域数量不足',
'生成任务成功,后处理失败。',
);
});
@@ -2388,6 +2405,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
},
});
render(
<SubmissionWorkflowHarness
@@ -2414,6 +2436,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
});
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
@@ -2545,7 +2570,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
createGenerated({
width: 768,
height: 768,
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
},
}),
);
render(
<SubmissionWorkflowHarness
@@ -2584,6 +2617,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:layer-generated-1:placeholder:-',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
});
it('moves character animation panels from generating to completed', async () => {
@@ -88,6 +88,9 @@ import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetEx
type CanvasSize = { width: number; height: number };
const EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX =
'图集已生成,但自动拆分未完成:';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
@@ -438,7 +441,21 @@ function notifyEditorGenerationWarning(
if (!reason) {
return;
}
callback?.(`图集已生成,但自动拆分未完成:${reason}`);
callback?.(reason);
}
function resolveEditorGenerationWarningMessage(
warning: string | null | undefined,
sliceWarning: string | null | undefined,
) {
const commonReason = warning?.trim();
if (commonReason) {
return commonReason;
}
const sliceReason = sliceWarning?.trim();
return sliceReason
? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}`
: undefined;
}
async function runEditorGenerationWithWalletRefresh<T>(
@@ -1097,7 +1114,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
resolveEditorGenerationWarningMessage(
generated.warning?.reason,
generated.sliceWarning?.reason,
),
onGenerationWarning,
);
if (
@@ -1234,7 +1254,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
rememberImageModel(submissionPlan.rememberImageModel);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
resolveEditorGenerationWarningMessage(
generated.warning?.reason,
generated.sliceWarning?.reason,
),
onGenerationWarning,
);
if (
@@ -1590,6 +1613,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
const characterWarningCallback = isCharacterRedraw
? onGenerationWarning
: undefined;
if (isCharacterRedraw) {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1597,6 +1629,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
)
) {
if (panelMode === 'quick-edit') {
@@ -1681,6 +1714,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setQuickEditPanel,
updateCanvasGenerationDialogById,
viewport,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
]);
@@ -1999,6 +2033,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
const characterWarningCallback =
imageGenerationInput.kind === 'character'
? onGenerationWarning
: undefined;
if (imageGenerationInput.kind === 'character') {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -2006,6 +2050,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
)
) {
return;
@@ -2078,6 +2123,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setGenerateDialog,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
],
);
@@ -1868,21 +1868,19 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
it('resolves private character image before removing background', async () => {
it('queues background removal for private character images', async () => {
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,resolved-character',
);
removeImageBackgroundMock.mockResolvedValueOnce({
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',
queueState: {
operationId: 'background-removal-task',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '已完成',
progress: 100,
updatedAtMicros: 1,
},
});
render(
<GenerationWorkflowHarness
@@ -1912,19 +1910,20 @@ describe('useImageCanvasGenerationWorkflow', () => {
sourceResourceId: 'resource-source',
});
});
await waitFor(() => {
expect(screen.getByTestId('task-refresh-key').textContent).toBe('1');
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图:resource-source:character:/generated-character-drafts/editor/background-removal/result.png:background-removal-task',
'/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'background-removal-task',
);
expect(screen.getByTestId('task-refresh-key').textContent).toBe('0');
});
it('opens a generating canvas placeholder when removing background in a project', async () => {
let resolveBackgroundRemoval: ((value: unknown) => void) | null = null;
removeImageBackgroundMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveBackgroundRemoval = resolve;
}),
() => new Promise(() => {}),
);
render(
@@ -1960,136 +1959,6 @@ describe('useImageCanvasGenerationWorkflow', () => {
}),
);
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await act(async () => {
resolveBackgroundRemoval?.({
imageSrc:
'/generated-character-drafts/editor/background-removal/project-result.png',
objectKey:
'generated-character-drafts/editor/background-removal/project-result.png',
assetObjectId: 'asset-object-background-removal',
width: 512,
height: 768,
taskId: 'background-removal-project-task',
elapsedMs: 1234,
provider: 'BiRefNet',
project: null,
});
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'project-result.png',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'background-removal-project-task',
);
});
it('preserves a just-created crop layer when applying a background removal project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
let resolveBackgroundRemoval: ((value: unknown) => void) | null = null;
removeImageBackgroundMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveBackgroundRemoval = resolve;
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
initialLayers={[
createLayer({
id: 'layer-crop-expand-1',
resourceId: 'resource-crop-expand',
title: '源图 裁扩',
src: '/generated-character-drafts/editor/crop-expand/project-1/result.png',
objectKey:
'generated-character-drafts/editor/crop-expand/project-1/result.png',
assetObjectId: 'asset-object-crop-expand',
sourceType: 'generated',
sourceResourceId: 'resource-source',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '去除背景' }));
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
await act(async () => {
resolveBackgroundRemoval?.({
imageSrc:
'/generated-character-drafts/editor/background-removal/project-result.png',
objectKey:
'generated-character-drafts/editor/background-removal/project-result.png',
assetObjectId: 'asset-object-background-removal',
width: 512,
height: 768,
taskId: 'background-removal-project-task',
elapsedMs: 1234,
provider: 'BiRefNet',
project: {
projectId: 'project-1',
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-background-removal-result',
resourceId: 'resource-background-removal',
title: '源图 裁扩 去背景',
x: 480,
y: 140,
width: 512,
height: 768,
originalWidth: 512,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-background-removal',
projectId: 'project-1',
imageSrc:
'/generated-character-drafts/editor/background-removal/project-result.png',
objectKey:
'generated-character-drafts/editor/background-removal/project-result.png',
assetObjectId: 'asset-object-background-removal',
width: 512,
height: 768,
sourceType: 'generated',
sourceResourceId: 'resource-crop-expand',
},
],
updatedAt: '2026-06-23T08:00:01.000Z',
},
});
});
const appliedProject = applyProjectSnapshot.mock.calls[0]?.[0];
expect(appliedProject?.layers).toEqual(
expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-crop-expand-1',
resourceId: 'resource-crop-expand',
title: '源图 裁扩',
}),
expect.objectContaining({
layerId: 'layer-background-removal-result',
resourceId: 'resource-background-removal',
}),
]),
);
expect(appliedProject?.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-crop-expand',
objectKey:
'generated-character-drafts/editor/crop-expand/project-1/result.png',
assetObjectId: 'asset-object-crop-expand',
}),
]),
);
});
it('opens UI design extraction as a mark selection state before submitting', () => {
@@ -1613,58 +1613,19 @@ export function useImageCanvasGenerationWorkflow({
}
: {}),
});
if (
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
backgroundRemovalDialogId,
applyBackgroundRemovalProjectSnapshot
? (project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
: undefined,
)
) {
return;
}
if (result.project && applyBackgroundRemovalProjectSnapshot) {
applyBackgroundRemovalProjectSnapshot(result.project);
return;
}
if (backgroundRemovalPlacement?.placeholder && applyProjectSnapshot) {
return;
}
updateSourceLayer(sourceLayer.id, (layer) => ({
...layer,
resourceId: result.resource?.resourceId ?? layer.resourceId,
src: result.imageSrc,
width: result.width,
height: result.height,
originalWidth: result.width,
originalHeight: result.height,
sourceType: result.sourceType ?? 'generated',
provider: result.provider ?? 'BiRefNet',
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,
}));
setActiveSidebarPanel('layers');
await applyQueuedEditorGenerationProject(
result,
projectId,
applyProjectSnapshot,
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
backgroundRemovalDialogId,
applyBackgroundRemovalProjectSnapshot
? (project) =>
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
: undefined,
);
} catch (error) {
if (backgroundRemovalDialogId) {
updateCanvasGenerationDialogById(
@@ -1689,11 +1650,9 @@ export function useImageCanvasGenerationWorkflow({
openPlacedCanvasGenerationDialog,
projectId,
refreshTaskListForQueuedGeneration,
setActiveSidebarPanel,
setImageContextMenu,
setMetadataLayer,
updateCanvasGenerationDialogById,
updateSourceLayer,
],
);
@@ -743,6 +743,11 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'vector-task-1',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
},
});
const result = await generateEditorImage({
@@ -750,6 +755,11 @@ describe('editorProjectClient', () => {
});
expect(result.taskId).toBe('vector-task-1');
expect(result.warning).toEqual({
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/generations',
expect.objectContaining({
@@ -1618,18 +1628,17 @@ describe('editorProjectClient', () => {
it('passes canvas completion context to background removal', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,cutout',
width: 512,
height: 512,
objectKey: 'generated-character-drafts/editor-cutouts/cutout.png',
assetObjectId: 'asset-object-cutout',
taskId: 'background-removal-1',
elapsedMs: 1200,
provider: 'BiRefNet',
project: null,
queueState: {
operationId: 'background-removal-1',
status: 'queued',
phaseLabel: '排队中',
phaseDetail: '排队中',
progress: 0,
updatedAtMicros: 1,
},
});
await removeEditorImageBackground({
const response = await removeEditorImageBackground({
sourceImageSrc: 'data:image/png;base64,source',
projectId: 'editor-project-1',
targetLayerId: 'layer-source',
@@ -1647,6 +1656,8 @@ describe('editorProjectClient', () => {
},
});
expect(response.queueState.operationId).toBe('background-removal-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/background-removals',
expect.objectContaining({
@@ -273,23 +273,12 @@ export type EditorImageGenerationResult = {
resource?: EditorProjectResourceSnapshot | null;
asset?: EditorAssetSnapshot | null;
project?: EditorProjectSnapshot | null;
warning?: EditorGenerationWarning | null;
queueState?: ExternalGenerationJobStatusRecord | null;
};
export type EditorBackgroundRemovalResult = {
imageSrc: string;
objectKey?: string | null;
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;
queueState: ExternalGenerationJobStatusRecord;
};
export type EditorIconSpritesheetIconResult = {
@@ -301,11 +290,13 @@ export type EditorIconSpritesheetIconResult = {
asset?: EditorAssetSnapshot | null;
};
export type EditorIconSpritesheetSliceWarning = {
export type EditorGenerationWarning = {
code: string;
reason: string;
};
export type EditorIconSpritesheetSliceWarning = EditorGenerationWarning;
export type EditorIconSpritesheetGenerationResult = {
spritesheetImageSrc: string;
spritesheetWidth: number;
@@ -321,6 +312,7 @@ export type EditorIconSpritesheetGenerationResult = {
spritesheetResource?: EditorProjectResourceSnapshot | null;
spritesheetAsset?: EditorAssetSnapshot | null;
project?: EditorProjectSnapshot | null;
warning?: EditorGenerationWarning | null;
queueState?: ExternalGenerationJobStatusRecord | null;
};