Add UI-design asset extraction & spritesheet support

Introduce a new UI design asset extraction flow: add POST /api/editor/ui-designs/assets/extractions handler that always uses gpt-image-2 with the fixed prompt "提取画面中的所有独立并整理成spritesheet"; parse Data URL references, call OpenAI image edits, return a spritesheet and sliced icon assets. Increase request body limit for image Data URLs and wire the route with bearer auth. Add platform-image slicing: slice_generated_icon_spritesheet_all_by_connected_components (with auto-naming "素材 1..N", foreground estimation and heuristics) and export it. Add Rust tests for the handler, prompt constant, and slicing behavior. Update frontend client and integration tests to call the new extractEditorUiDesignAssets API and verify spritesheet + icon layers are added to the canvas and persisted. Update multiple docs to describe the extraction UI, toolbar placement, and related audio/playback pitfalls. Minor related updates: toolbar button ordering and audio duration / signed read-url notes.
This commit is contained in:
2026-06-19 10:09:19 +08:00
parent ea29887d9c
commit 097170836d
36 changed files with 1092 additions and 110 deletions
@@ -20,6 +20,7 @@ import {
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorAssetMock = vi.hoisted(() => vi.fn());
@@ -42,6 +43,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
return {
...actual,
editEditorImage: editEditorImageMock,
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
createEditorAsset: createEditorAssetMock,
createEditorAssetFolder: createEditorAssetFolderMock,
createEditorProjectResource: createEditorProjectResourceMock,
@@ -64,6 +66,7 @@ describe('ImageCanvasEditorView generation integration', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
generateEditorIconSpritesheetMock,
extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimationMock,
editEditorImageMock,
createEditorAssetMock,
@@ -2264,6 +2267,102 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByText('动作')).toBeTruthy();
});
it('extracts UI design assets from the floating toolbar and keeps the spritesheet on canvas', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-ui-extract',
title: 'UI素材提取画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-ui-design',
resourceId: 'resource-ui-design',
title: '战斗UI设计图',
src: 'data:image/png;base64,dWktZGVzaWdu',
x: 120,
y: 140,
width: 560,
height: 315,
originalWidth: 2048,
originalHeight: 1152,
zIndex: 2,
sourceType: 'generated',
prompt: '战斗UI',
actualPrompt: '战斗UI',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-design-task-1',
assetKind: 'ui-design',
},
],
resources: [],
updatedAt: '2026-06-18T00:00:00.000Z',
});
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,dWktc2hlZXQ=',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [
{
name: '素材 1',
imageSrc: 'data:image/png;base64,YXNzZXQtMQ==',
width: 96,
height: 96,
},
],
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-extract-task-1',
priceMudPoints: 12,
});
render(<ImageCanvasEditorView />);
const uiDesignImage = await screen.findByAltText(
'画布图片:战斗UI设计图',
);
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
button: 0,
pointerId: 156,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 156,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith({
sourceImageSrc: 'data:image/png;base64,dWktZGVzaWdu',
});
});
expect(
await screen.findByAltText('画布图片:战斗UI设计图 素材图集'),
).toBeTruthy();
expect(screen.getByAltText('画布图片:素材 1')).toBeTruthy();
expect(screen.getByText('图集')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-ui-extract',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '战斗UI设计图 素材图集',
assetKind: 'icon-spritesheet',
}),
expect.objectContaining({
title: '素材 1',
assetKind: 'icon',
}),
]),
}),
);
});
});
it('opens quick edit from the floating toolbar, keeps the source as the last reference, and generates from a new frame', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit',
@@ -141,25 +141,30 @@ describe('ImageCanvasEditorModel', () => {
originalWidth: 420,
originalHeight: 120,
zIndex: 5,
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
},
new Map([
[
'resource-audio',
{ imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3' },
],
]),
);
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
durationSeconds: 2.4,
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-audio',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
});
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
});
});
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
@@ -135,6 +135,7 @@ export function createLayerFromAsset(
taskId: asset.taskId,
objectKey: asset.objectKey,
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
} satisfies CanvasLayer;
}
@@ -162,6 +163,7 @@ export function serializeLayer(
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
groupId: layer.groupId,
@@ -344,6 +346,7 @@ export function hydrateCanvasGenerationDialog(
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
: undefined,
audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds),
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: stringOrUndefined(snapshot.errorMessage),
@@ -400,6 +403,13 @@ export function hydrateLayer(
taskId: stringOrNull(snapshot.taskId),
objectKey: stringOrNull(snapshot.objectKey),
assetObjectId: stringOrNull(snapshot.assetObjectId),
durationSeconds:
audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(
resourcesById.get(resourceId) as
| ({ imageSrc: string } & { durationSeconds?: unknown })
| undefined,
),
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
@@ -443,6 +453,7 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
})),
};
}
@@ -478,6 +489,17 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function audioDurationOrNull(value: unknown) {
if (value && typeof value === 'object' && 'durationSeconds' in value) {
return audioDurationOrNull(
(value as { durationSeconds?: unknown }).durationSeconds,
);
}
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: undefined;
}
export function bpmOrNull(value: unknown) {
if (value === null) {
return null;
@@ -631,6 +653,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'character' ||
value === 'character-animation' ||
value === 'icon' ||
value === 'icon-spritesheet' ||
value === 'icon-spec' ||
value === 'ui-design' ||
value === 'video' ||
@@ -165,6 +165,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
onPasteCanvasClipboard: vi.fn(),
@@ -13,6 +13,7 @@ export type CanvasAssetKind =
| 'character'
| 'character-animation'
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'ui-design'
| 'video'
@@ -188,6 +189,7 @@ export type GenerateDialogState = {
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏。
soundTempo?: number | null;
makeInstrumental?: boolean;
audioDurationSeconds?: number | null;
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
@@ -16,6 +16,7 @@ export type AuthValue = NonNullable<
export type ImageCanvasEditorViewServiceMocks = {
generateEditorImageMock: Mock;
generateEditorIconSpritesheetMock: Mock;
extractEditorUiDesignAssetsMock?: Mock;
generateEditorCharacterAnimationMock: Mock;
editEditorImageMock: Mock;
createEditorAssetMock: Mock;
@@ -214,6 +215,7 @@ export async function readZipText(zip: JSZip, path: string) {
export function setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
generateEditorIconSpritesheetMock,
extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimationMock,
editEditorImageMock,
createEditorAssetMock,
@@ -323,6 +325,7 @@ export function setupImageCanvasEditorViewTestLifecycle({
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
extractEditorUiDesignAssetsMock?.mockReset();
generateEditorCharacterAnimationMock.mockReset();
editEditorImageMock.mockReset();
createEditorAssetMock.mockReset();
@@ -561,6 +561,7 @@ export function ImageCanvasEditorView() {
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
@@ -923,6 +924,7 @@ export function ImageCanvasEditorView() {
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onExtractUiDesignAssets: extractUiDesignAssets,
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
onDownloadLayer: exportLayerImage,
onPasteCanvasClipboard: pasteCanvasClipboard,
@@ -220,31 +220,42 @@ describe('ImageCanvasGenerationLayerModel', () => {
generationInputs: createGenerationInputs(),
});
expect(layers).toHaveLength(3);
expect(layers).toHaveLength(4);
expect(layers[0]).toMatchObject({
id: 'layer-icon-11',
title: '返回按钮',
id: 'layer-icon-spritesheet-11',
title: '图标素材图集 11',
x: 200,
y: 100,
width: 512,
height: 512,
assetKind: 'icon-spritesheet',
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
});
expect(layers[1]).toMatchObject({
id: 'layer-icon-12',
title: '返回按钮',
x: 744,
y: 100,
width: 256,
height: 256,
assetKind: 'icon',
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
});
expect(layers[1]).toMatchObject({
id: 'layer-icon-12',
expect(layers[2]).toMatchObject({
id: 'layer-icon-13',
title: '设置按钮',
x: 200,
x: 744,
y: 380,
width: 512,
height: 256,
assetKind: 'icon',
});
expect(layers[2]).toMatchObject({
id: 'layer-icon-13',
expect(layers[3]).toMatchObject({
id: 'layer-icon-14',
title: '提示按钮',
x: 200,
x: 744,
y: 660,
width: 128,
height: 128,
@@ -266,6 +277,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
taskId: 'sound-task-1',
priceMudPoints: 10,
audioKind: 'sound-effect',
durationSeconds: 75,
},
generatedIndex: 14,
title: '游戏音效 14',
@@ -303,6 +315,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
durationSeconds: 75,
});
});
@@ -13,7 +13,10 @@ import type {
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel';
import {
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
@@ -46,6 +49,7 @@ type IconSpritesheetResultLayerOptions = {
viewport: CanvasViewport;
generationInputs: CanvasGenerationInputs;
frame?: GenerateDialogState['placeholder'];
spritesheetTitle?: string;
};
type VideoResultLayerOptions = {
@@ -226,6 +230,7 @@ export function createIconSpritesheetResultLayers({
viewport,
generationInputs,
frame,
spritesheetTitle,
}: IconSpritesheetResultLayerOptions): CanvasLayer[] {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const startX =
@@ -234,11 +239,42 @@ export function createIconSpritesheetResultLayers({
frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2;
const spacing = 24;
const maxRowWidth = 560;
let cursorX = startX;
const spritesheetOriginalWidth =
generated.spritesheetWidth || ICON_FRAME_ORIGINAL_SIZE.width;
const spritesheetOriginalHeight =
generated.spritesheetHeight || ICON_FRAME_ORIGINAL_SIZE.height;
const spritesheetDisplaySize = resolveLayerResolutionSize(
spritesheetOriginalWidth,
spritesheetOriginalHeight,
ICON_FRAME_DISPLAY_SIZE,
);
const spritesheetLayer: CanvasLayer = {
id: `layer-icon-spritesheet-${startIndex}`,
resourceId: `local-resource-icon-spritesheet-${startIndex}`,
title: spritesheetTitle ?? `图标素材图集 ${startIndex}`,
src: generated.spritesheetImageSrc,
x: startX,
y: startY,
width: spritesheetDisplaySize.width,
height: spritesheetDisplaySize.height,
originalWidth: spritesheetOriginalWidth,
originalHeight: spritesheetOriginalHeight,
zIndex: startIndex + 10,
sourceType: 'generated',
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon-spritesheet',
generationInputs,
};
const iconStartX = startX + spritesheetDisplaySize.width + 32;
let cursorX = iconStartX;
let cursorY = startY;
let rowHeight = 0;
return iconResults.map((icon, index) => {
const iconLayers = iconResults.map((icon, index) => {
const originalWidth = icon.width || 128;
const originalHeight = icon.height || 128;
const { width, height } = resolveLayerResolutionSize(
@@ -246,13 +282,13 @@ export function createIconSpritesheetResultLayers({
originalHeight,
{ width: 128, height: 128 },
);
if (cursorX > startX && cursorX + width - startX > maxRowWidth) {
cursorX = startX;
if (cursorX > iconStartX && cursorX + width - iconStartX > maxRowWidth) {
cursorX = iconStartX;
cursorY += rowHeight + spacing;
rowHeight = 0;
}
const generatedIndex = startIndex + index;
const generatedIndex = startIndex + index + 1;
const layer: CanvasLayer = {
id: `layer-icon-${generatedIndex}`,
resourceId: `local-resource-icon-${generatedIndex}`,
@@ -279,6 +315,8 @@ export function createIconSpritesheetResultLayers({
rowHeight = Math.max(rowHeight, height);
return layer;
});
return [spritesheetLayer, ...iconLayers];
}
export function createVideoResultLayer({
@@ -377,6 +415,7 @@ export function createAudioResultLayer({
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
durationSeconds: generated.durationSeconds ?? undefined,
generationInputs,
};
}
@@ -66,6 +66,8 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
'原图按钮',
'冻结按钮',
];
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
'提取画面中的所有独立并整理成spritesheet';
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
@@ -266,6 +268,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.assetKind === 'icon') {
return '图标';
}
if (layer.assetKind === 'icon-spritesheet') {
return '图集';
}
if (layer.assetKind === 'icon-spec') {
return '图标规范';
}
@@ -316,6 +321,9 @@ export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'icon') {
return '图标素材图片';
}
if (layer.assetKind === 'icon-spritesheet') {
return '图标素材图集';
}
if (layer.assetKind === 'icon-spec') {
return '图标规范图片';
}
@@ -578,6 +586,24 @@ export function buildIconGenerationInputs(
};
}
export function buildUiDesignAssetExtractionGenerationInputs(
sourceLayer: CanvasLayer,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
references: [
{
title: 'UI设计图',
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
};
}
export function buildEditGenerationInputs(
title: '修改要求' | '快速编辑提示词' | '重绘提示词',
prompt: string,
@@ -131,6 +131,40 @@ describe('ImageCanvasMetadataModalView', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders generated audio metadata with duration instead of resolution', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '游戏背景音乐',
src: '/generated-character-drafts/editor-audios/music.wav',
mediaType: 'audio',
assetKind: 'background-music',
originalWidth: 420,
originalHeight: 120,
model: 'suno',
taskId: 'audio-task-75',
durationSeconds: 75,
generationInputs: {
fields: [{ title: '音乐描述', value: '紧张的地下城循环音乐' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '音频信息' });
expect(within(dialog).getByText('音频类型')).toBeTruthy();
expect(within(dialog).getByText('游戏背景音乐')).toBeTruthy();
expect(within(dialog).getByText('音乐描述')).toBeTruthy();
expect(within(dialog).getByText('紧张的地下城循环音乐')).toBeTruthy();
expect(within(dialog).getByText('时长')).toBeTruthy();
expect(within(dialog).getByText('1:15')).toBeTruthy();
expect(within(dialog).queryByText('Resolution')).toBeNull();
expect(within(dialog).queryByText('420 x 120 px')).toBeNull();
});
it('does not render a dialog when no layer is selected', () => {
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
@@ -1,6 +1,7 @@
import { UnifiedModal } from '../common/UnifiedModal';
import { formatLayerImageType } from './ImageCanvasGenerationModel';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
export type ImageCanvasMetadataModalViewProps = {
layer: CanvasLayer | null;
@@ -77,10 +78,24 @@ export function ImageCanvasMetadataModalView({
</dd>
<dt>Model</dt>
<dd>{layer.model ?? '-'}</dd>
<dt>Resolution</dt>
<dd>
{layer.originalWidth} x {layer.originalHeight} px
</dd>
{layer.mediaType === 'audio' ? (
<>
<dt></dt>
<dd>
{formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
)}
</dd>
</>
) : (
<>
<dt>Resolution</dt>
<dd>
{layer.originalWidth} x {layer.originalHeight} px
</dd>
</>
)}
<dt>Task</dt>
<dd>{formatTaskIdForDisplay(layer.taskId)}</dd>
<dt>Object</dt>
@@ -40,6 +40,7 @@ function renderSelectedToolbar(
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
...overrides,
@@ -88,6 +89,32 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(toolbar).queryByRole('button', { name: /.*/u }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '提取素材' }),
).toBeNull();
});
it('renders UI design asset extraction after remove background', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'ui-design',
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'提取素材',
'重绘',
'下载按钮',
]);
fireEvent.click(within(toolbar).getByRole('button', { name: '提取素材' }));
expect(props.onExtractUiDesignAssets).toHaveBeenCalledWith(layer);
});
it('renders character animation only for character layers', () => {
@@ -115,6 +142,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
/>,
@@ -130,6 +158,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
/>,
@@ -2,6 +2,7 @@ import {
Crop,
Download,
Eraser,
Scissors,
Sparkles,
WandSparkles,
} from 'lucide-react';
@@ -18,6 +19,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
onDownloadLayer: (layer: CanvasLayer) => void;
};
@@ -29,6 +31,7 @@ export function ImageCanvasSelectedLayerToolbarView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onExtractUiDesignAssets,
onOpenCharacterAnimationPanel,
onDownloadLayer,
}: ImageCanvasSelectedLayerToolbarViewProps) {
@@ -69,6 +72,17 @@ export function ImageCanvasSelectedLayerToolbarView({
icon={Eraser}
onClick={() => onRemoveBackground(selectedLayer)}
/>
{selectedLayer.assetKind === 'ui-design' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="提取素材"
title="提取素材"
icon={<Scissors className="h-4 w-4" />}
onClick={() => onExtractUiDesignAssets(selectedLayer)}
>
<span></span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'character' ? (
<EditorIconButton
label="生成动画"
@@ -100,6 +100,7 @@ export type ImageCanvasStageViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
onDownloadLayer: (layer: CanvasLayer) => void;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
@@ -189,6 +190,7 @@ export function ImageCanvasStageView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onExtractUiDesignAssets,
onOpenCharacterAnimationPanel,
onDownloadLayer,
onPasteCanvasClipboard,
@@ -275,6 +277,7 @@ export function ImageCanvasStageView({
onOpenRedrawPanel={onOpenRedrawPanel}
onOpenCropExpandPanel={onOpenCropExpandPanel}
onRemoveBackground={onRemoveBackground}
onExtractUiDesignAssets={onExtractUiDesignAssets}
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
onDownloadLayer={onDownloadLayer}
/>
@@ -376,16 +376,18 @@ describe('ImageCanvasWorldView', () => {
ariaLabel: '音效生成占位图',
generatorLabel: 'Sound Generator',
badgeLabel: '音效',
metricLabel: '时长 --:--',
},
{
mode: 'audio-background-music',
ariaLabel: '背景音乐生成占位图',
generatorLabel: 'Music Generator',
badgeLabel: '背景音乐',
metricLabel: '时长 --:--',
},
])(
'keeps $mode generation title size and badge readable when zoomed out',
({ mode, ariaLabel, generatorLabel, badgeLabel }) => {
({ mode, ariaLabel, generatorLabel, badgeLabel, metricLabel }) => {
const dialog = createGenerationDialog({
id: `dialog-zoom-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
@@ -406,9 +408,9 @@ describe('ImageCanvasWorldView', () => {
),
).toBe(inverseScale);
expect(
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText(metricLabel ?? '1024 x 768') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue(
@@ -10,6 +10,7 @@ import {
import { useState } from 'react';
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
} from 'react';
@@ -140,6 +141,7 @@ function buildInverseScaleStyle(
'--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`,
'--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`,
'--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`,
'--image-canvas-editor-frame-action-offset': `calc(-0.75rem * ${inverseScale})`,
};
}
@@ -156,15 +158,15 @@ export type ImageCanvasWorldViewProps = {
cropExpandSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
event: ReactPointerEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onLayerClick: (
event: ReactMouseEvent<HTMLButtonElement>,
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLButtonElement>,
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onLayerMouseEnter: (layerId: string) => void;
@@ -195,6 +197,76 @@ const CROP_EXPAND_HANDLES: Array<{
{ value: 'left', label: '左边' },
];
function stopMediaEventPropagation(
event: ReactMouseEvent<HTMLElement> | ReactPointerEvent<HTMLElement>,
) {
event.stopPropagation();
}
function handleLayerKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.currentTarget.click();
}
function getLayerMetricLabel(layer: CanvasLayer) {
if (layer.mediaType === 'audio') {
return formatCanvasDurationMetric(layer.durationSeconds);
}
return `${Math.round(layer.originalWidth)} x ${Math.round(
layer.originalHeight,
)} px`;
}
function isAudioGenerationDialog(dialog: CanvasGenerationDialogState) {
return (
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'
);
}
function ImageCanvasAudioLayerCard({ layer }: { layer: CanvasLayer }) {
const [loadedDurationSeconds, setLoadedDurationSeconds] = useState<
number | null
>(null);
const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
refreshKey: layer.taskId ?? layer.resourceId,
});
const prompt = layer.prompt?.trim();
const durationSeconds = layer.durationSeconds ?? loadedDurationSeconds;
return (
<div className="image-canvas-editor__audio-card">
{prompt ? (
<span className="image-canvas-editor__audio-card-title">{prompt}</span>
) : null}
<audio
src={resolvedUrl || undefined}
controls
preload="metadata"
aria-label={`画布音频:${layer.title}`}
onClick={stopMediaEventPropagation}
onPointerDown={stopMediaEventPropagation}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration;
setLoadedDurationSeconds(
Number.isFinite(nextDuration) && nextDuration > 0
? nextDuration
: null,
);
}}
/>
<span className="image-canvas-editor__audio-card-meta">
{formatCanvasDurationMetric(durationSeconds)}
</span>
</div>
);
}
export function ImageCanvasWorldView({
viewport,
snapGuide,
@@ -262,10 +334,13 @@ export function ImageCanvasWorldView({
generateDialog.sourceLayerId === layer.id
? '修改中'
: null;
const LayerElement = layer.mediaType === 'audio' ? 'div' : 'button';
return (
<button
<LayerElement
key={layer.id}
type="button"
{...(layer.mediaType === 'audio'
? { role: 'button', tabIndex: 0 }
: { type: 'button' })}
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
@@ -278,18 +353,17 @@ export function ImageCanvasWorldView({
onPointerDown={(event) => onLayerPointerDown(event, layer)}
onClick={(event) => onLayerClick(event, layer)}
onContextMenu={(event) => onLayerContextMenu(event, layer)}
onKeyDown={
layer.mediaType === 'audio'
? handleLayerKeyboardActivation
: undefined
}
onMouseEnter={() => onLayerMouseEnter(layer.id)}
onMouseLeave={() => onLayerMouseLeave(layer.id)}
aria-label={`选择${layer.title}`}
>
{layer.mediaType === 'audio' ? (
<div className="image-canvas-editor__audio-card">
<audio
src={layer.src}
controls
aria-label={`画布音频:${layer.title}`}
/>
</div>
<ImageCanvasAudioLayerCard layer={layer} />
) : layer.mediaType === 'video' ? (
<video
className="image-canvas-editor__layer-video"
@@ -311,7 +385,7 @@ export function ImageCanvasWorldView({
)}
{kindLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind} image-canvas-editor__kind-badge--beside-info`}
style={inverseScaleStyle}
>
{kindLabel}
@@ -320,11 +394,7 @@ export function ImageCanvasWorldView({
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className={`image-canvas-editor__metadata-corner ${
kindLabel
? 'image-canvas-editor__metadata-corner--beside-kind'
: ''
}`}
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${
layer.mediaType === 'audio'
? '音频'
@@ -347,8 +417,7 @@ export function ImageCanvasWorldView({
className="image-canvas-editor__size-badge"
style={inverseScaleStyle}
>
{Math.round(layer.originalWidth)} x{' '}
{Math.round(layer.originalHeight)} px
{getLayerMetricLabel(layer)}
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
@@ -359,7 +428,7 @@ export function ImageCanvasWorldView({
{layerGeneratingLabel}
</span>
) : null}
</button>
</LayerElement>
);
})}
{canvasMarquee ? (
@@ -481,8 +550,11 @@ export function ImageCanvasWorldView({
className="image-canvas-editor__generation-frame-size"
style={inverseScaleStyle}
>
{dialog.placeholder.originalWidth} x{' '}
{dialog.placeholder.originalHeight}
{isAudioGenerationDialog(dialog)
? formatCanvasDurationMetric(
dialog.audioDurationSeconds ?? null,
)
: `${dialog.placeholder.originalWidth} x ${dialog.placeholder.originalHeight}`}
</span>
) : null}
<span className="image-canvas-editor__generation-frame-icon">
@@ -19,6 +19,7 @@ import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGene
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
@@ -34,6 +35,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
return {
...actual,
editEditorImage: editEditorImageMock,
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
@@ -243,6 +245,16 @@ function SubmissionWorkflowHarness({
>
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer);
}
}}
>
UI素材
</button>
<button
type="button"
onClick={() => void workflow.submitCharacterAnimation()}
@@ -275,6 +287,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
beforeEach(() => {
resolveEditorImageReferenceDataUrlMock.mockReset();
editEditorImageMock.mockReset();
extractEditorUiDesignAssetsMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
@@ -445,6 +458,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('submits icon spritesheets, trims descriptions, and remembers the model', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '图标素材',
actualPrompt: '图标素材',
model: 'gpt-image-2',
@@ -498,21 +514,78 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-1:返回按钮:-:icon',
'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-2:设置按钮:-:icon',
'layer-icon-2:返回按钮:-:icon',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-3:设置按钮:-:icon',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1');
expect(screen.getByTestId('dialog').textContent).toBe(
'icon:idle:open:layer-icon-1:placeholder:-',
'icon:idle:open:layer-icon-spritesheet-1:placeholder:-',
);
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
});
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
priceMudPoints: 12,
iconImageSrcs: [
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
],
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
'generated/ui-design.png',
);
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith({
sourceImageSrc: 'data:image/png;base64,ui-design.png',
});
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-2:素材 1:-:icon',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
@@ -9,6 +9,7 @@ import {
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
import {
editEditorImage,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
@@ -39,6 +40,9 @@ import {
buildCharacterAnimationGenerationInputs,
buildEditGenerationInputs,
buildQuickEditGenerationInputs,
buildUiDesignAssetExtractionGenerationInputs,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
isCanvasGenerationDialog,
normalizeQuickEditPromptSourceReference,
resolveImageGenerationErrorMessage,
@@ -269,9 +273,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
options: {
spritesheetTitle?: string;
} = {},
) => {
if (dialogId && !hasCanvasGenerationDialogById(dialogId)) {
return;
return [];
}
const startIndex = layerCounterRef.current + 1;
const nextLayers = createIconSpritesheetResultLayers({
@@ -282,10 +289,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
viewport,
generationInputs,
frame,
spritesheetTitle: options.spritesheetTitle,
});
if (!nextLayers.length) {
return;
return [];
}
layerCounterRef.current += nextLayers.length;
addGeneratedLayersToCanvas(nextLayers);
@@ -301,6 +309,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}));
}
setActiveTool('select');
return nextLayers;
},
[
addGeneratedLayersToCanvas,
@@ -471,6 +480,51 @@ export function useImageCanvasGenerationSubmissionWorkflow({
],
);
const extractUiDesignAssets = useCallback(
async (sourceLayer: CanvasLayer) => {
if (sourceLayer.assetKind !== 'ui-design') {
return;
}
try {
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
const generated = await extractEditorUiDesignAssets({
sourceImageSrc,
});
const extractionFrame = {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
};
const nextLayers = addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
buildUiDesignAssetExtractionGenerationInputs(sourceLayer),
extractionFrame,
undefined,
{
spritesheetTitle: `${sourceLayer.title} 素材图集`,
},
);
if (nextLayers.length) {
fitLayers([sourceLayer, ...nextLayers]);
}
} catch (error) {
window.alert(
error instanceof Error && error.message.trim()
? error.message
: '提取素材失败',
);
}
},
[addIconSpritesheetResultLayers, fitLayers],
);
const submitIconSpritesheetGeneration = useCallback(
async (dialog: GenerateDialogState) => {
if (dialog.mode !== 'icon') {
@@ -873,12 +927,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return useMemo(
() => ({
submitIconSpritesheetGeneration,
extractUiDesignAssets,
submitQuickEdit,
submitImageGeneration,
submitCharacterAnimation,
}),
[
submitCharacterAnimation,
extractUiDesignAssets,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
@@ -928,12 +928,36 @@ export function useImageCanvasGenerationWorkflow({
[characterAnimationDialog, setGenerateDialog],
);
const {
extractUiDesignAssets: submitUiDesignAssetExtraction,
submitCharacterAnimation,
submitIconSpritesheetGeneration,
submitImageGeneration,
submitQuickEdit,
} = generationSubmissionWorkflow;
const extractUiDesignAssets = useCallback(
(sourceLayer: CanvasLayer) => {
closeGenerationTransientState();
setMetadataLayer(null);
setQuickEditPanel(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
selectSingleLayer(sourceLayer.id);
setActiveTool('select');
void submitUiDesignAssetExtraction(sourceLayer);
},
[
closeGenerationTransientState,
selectSingleLayer,
setActiveTool,
setCharacterAnimationPanel,
setCropExpandPanel,
setMetadataLayer,
setQuickEditPanel,
submitUiDesignAssetExtraction,
],
);
const updateSpecFormValue = useCallback(
(key: keyof SpecFormValues, value: string) => {
setGenerateDialog((currentDialog) =>
@@ -1071,6 +1095,7 @@ export function useImageCanvasGenerationWorkflow({
openCropExpandPanel,
startCropExpandFrameResize,
removeSelectedLayerBackground,
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
@@ -1119,6 +1144,7 @@ export function useImageCanvasGenerationWorkflow({
openCharacterGenerationDialog,
openCropExpandPanel,
openEditDialog,
extractUiDesignAssets,
openGenerateDialog,
openIconGenerationDialog,
openRedrawPanel,
@@ -106,6 +106,7 @@ export function useImageCanvasProjectPersistence({
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
})
.then((resource) => {
@@ -207,7 +208,10 @@ export function useImageCanvasProjectPersistence({
const resourcesById = new Map(
project.resources.map((resource) => [
resource.resourceId,
{ imageSrc: resource.imageSrc },
{
imageSrc: resource.imageSrc,
durationSeconds: resource.durationSeconds,
},
]),
);
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
+33 -3
View File
@@ -4040,16 +4040,37 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.image-canvas-editor__audio-card {
display: grid;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
gap: 0.38rem;
border-radius: 0.85rem;
background: linear-gradient(135deg, #0f172a, #263449);
padding: 0.85rem;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18);
}
.image-canvas-editor__audio-card-title,
.image-canvas-editor__audio-card-meta {
overflow: hidden;
color: rgba(255, 255, 255, 0.86);
font-size: 0.74rem;
font-weight: 800;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__audio-card-title {
color: #ffffff;
}
.image-canvas-editor__audio-card-meta {
color: rgba(226, 232, 240, 0.72);
}
.image-canvas-editor__audio-card audio {
width: 100%;
}
@@ -4436,7 +4457,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.image-canvas-editor__metadata-corner--beside-kind {
right: var(--image-canvas-editor-beside-kind-offset, 3.95rem);
right: var(--image-canvas-editor-corner-offset, 0.35rem);
}
.image-canvas-editor__spec-badge {
@@ -4479,6 +4500,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform-origin: top right;
}
.image-canvas-editor__kind-badge--beside-info {
right: var(--image-canvas-editor-beside-kind-offset, 3.95rem);
}
.image-canvas-editor__kind-badge--spec {
background: rgba(37, 99, 235, 0.9);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24);
@@ -4499,6 +4524,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
box-shadow: 0 8px 18px rgba(16, 185, 129, 0.24);
}
.image-canvas-editor__kind-badge--icon-spritesheet {
background: rgba(20, 184, 166, 0.92);
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.image-canvas-editor__kind-badge--icon-spec {
background: rgba(13, 148, 136, 0.92);
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.24);
@@ -9,6 +9,7 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
@@ -666,6 +667,49 @@ describe('editorProjectClient', () => {
);
});
it('extracts UI design assets through the dedicated backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 1024,
spritesheetHeight: 1024,
iconImageSrcs: [
{
name: '素材 1',
imageSrc: 'data:image/png;base64,asset-1',
width: 128,
height: 128,
},
],
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-asset-extraction-1',
priceMudPoints: 12,
});
const result = await extractEditorUiDesignAssets({
sourceImageSrc: 'data:image/png;base64,ui-design',
});
expect(result.taskId).toBe('ui-asset-extraction-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/ui-designs/assets/extractions',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceImageSrc: 'data:image/png;base64,ui-design',
}),
}),
'提取UI设计图素材失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
it('passes spec generation size and kind to the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,spec',
@@ -6,6 +6,8 @@ const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
'/api/editor/icon-spritesheets/generations';
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_API =
'/api/editor/ui-designs/assets/extractions';
const EDITOR_CHARACTER_ANIMATION_GENERATION_API =
'/api/editor/character-animations/generations';
const EDITOR_VIDEO_GENERATION_API = '/api/editor/videos/generations';
@@ -106,6 +108,10 @@ export type EditorIconSpritesheetGenerationInput = {
priceMudPoints: number;
};
export type EditorUiDesignAssetExtractionInput = {
sourceImageSrc: string;
};
export type EditorImageEditInput = {
prompt: string;
sourceImageSrc: string;
@@ -577,6 +583,24 @@ export async function generateEditorIconSpritesheet(
);
}
export async function extractEditorUiDesignAssets(
input: EditorUiDesignAssetExtractionInput,
) {
return requestJson<EditorIconSpritesheetGenerationResponse>(
EDITOR_UI_DESIGN_ASSET_EXTRACTION_API,
jsonRequest('POST', {
sourceImageSrc: input.sourceImageSrc,
}),
'提取UI设计图素材失败',
{
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
},
);
}
export async function editEditorImage(input: EditorImageEditInput) {
return requestJson<EditorImageGenerationResponse>(
EDITOR_IMAGE_EDIT_API,