Update editor generation pricing defaults
Unify and lower default mud-point prices across editor generation features and update related API/schema, docs and tests. Changes set gpt-image-2 to 1K=3 / 2K=5, audio1.0 to 5, and chirp-v5 to 12, and update server default JSON, frontend models/workflows, OpenAPI schema (require aspectRatio/imageSize/priceMudPoints for UI extraction), multiple UX docs (quick-edit viewport, UI extraction panel, pricing guidance) and accompanying tests to match the new defaults and validation rules.
This commit is contained in:
@@ -1237,7 +1237,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||||
.textContent,
|
||||
).toContain('生成40泥点');
|
||||
).toContain('生成5泥点');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '平台跳跃玩法' },
|
||||
@@ -1472,7 +1472,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-gpt-model-1',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
@@ -1538,7 +1538,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2756,7 +2756,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'ui-extract-task-1',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2779,8 +2779,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const extractionToolbar = await screen.findByRole('toolbar', {
|
||||
name: 'UI素材框选工具',
|
||||
});
|
||||
expect(extractionToolbar).toBeTruthy();
|
||||
const extractionPanel = await screen.findByRole('dialog', {
|
||||
name: 'UI素材提取',
|
||||
});
|
||||
expect(
|
||||
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
|
||||
within(extractionPanel).getByRole<HTMLButtonElement>('button', {
|
||||
name: '提取',
|
||||
}).disabled,
|
||||
).toBe(true);
|
||||
@@ -2817,12 +2821,15 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
|
||||
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole<HTMLButtonElement>('button', {
|
||||
name: '提取',
|
||||
}).disabled,
|
||||
).toBe(false);
|
||||
fireEvent.click(
|
||||
within(extractionToolbar).getByRole('button', { name: '提取' }),
|
||||
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole(
|
||||
'button',
|
||||
{ name: '提取' },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2841,6 +2848,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
priceMudPoints: 3,
|
||||
projectId: 'editor-project-ui-extract',
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
@@ -2902,6 +2912,69 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('closes UI design asset extraction when clicking outside the source and panel', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract-dismiss',
|
||||
title: 'UI素材提取外部关闭画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-ui-design-dismiss',
|
||||
resourceId: 'resource-ui-design-dismiss',
|
||||
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-dismiss-task-1',
|
||||
assetKind: 'ui-design',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T00:00:00.000Z',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const uiDesignImage = await screen.findByAltText(
|
||||
'画布图片:背包UI设计图',
|
||||
);
|
||||
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 256,
|
||||
clientX: 180,
|
||||
clientY: 180,
|
||||
});
|
||||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||||
pointerId: 256,
|
||||
clientX: 180,
|
||||
clientY: 180,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'UI素材提取' }),
|
||||
).toBeTruthy();
|
||||
fireEvent.pointerDown(screen.getByTestId('image-canvas-world'), {
|
||||
button: 0,
|
||||
pointerId: 257,
|
||||
clientX: 24,
|
||||
clientY: 24,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'UI素材提取' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-quick-edit',
|
||||
|
||||
@@ -74,11 +74,15 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']).toBe(3);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(5);
|
||||
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||||
).toBe(5);
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
).toBe(12);
|
||||
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
|
||||
expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy();
|
||||
expect(
|
||||
@@ -90,8 +94,8 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(3);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(5);
|
||||
expect(
|
||||
calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
|
||||
@@ -75,8 +75,8 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
|
||||
'2K': 24,
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
'1K': 20,
|
||||
'2K': 40,
|
||||
'1K': 3,
|
||||
'2K': 5,
|
||||
},
|
||||
} as const;
|
||||
export const SPEC_GENERATION_COST =
|
||||
@@ -338,10 +338,10 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
} as const;
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 10,
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 5,
|
||||
} as const;
|
||||
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 5,
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 12,
|
||||
} as const;
|
||||
export const EDITOR_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditFocusViewport,
|
||||
resolveQuickEditPanelStyle,
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
@@ -129,6 +130,21 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
).toEqual({ left: 768, top: 88 });
|
||||
});
|
||||
|
||||
it('fits tall quick edit sources above the reserved bottom composer area', () => {
|
||||
const sourceLayer = createLayer({ x: 100, y: 50, width: 360, height: 1600 });
|
||||
const viewport = resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
});
|
||||
const sourceTop = viewport.y + sourceLayer.y * viewport.scale;
|
||||
const sourceBottom = sourceTop + sourceLayer.height * viewport.scale;
|
||||
|
||||
expect(viewport.scale).toBeCloseTo(0.1925, 4);
|
||||
expect(sourceTop).toBeCloseTo(56, 2);
|
||||
expect(sourceBottom).toBeLessThanOrEqual(364);
|
||||
expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640);
|
||||
});
|
||||
|
||||
it('positions quick edit and character animation panels with viewport bounds', () => {
|
||||
const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 });
|
||||
const quickEditPanel: QuickEditPanelState = {
|
||||
@@ -155,7 +171,7 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 880, top: 280 });
|
||||
).toEqual({ left: 880, top: 376 });
|
||||
expect(
|
||||
resolveCharacterAnimationPanelStyle({
|
||||
panel: characterPanel,
|
||||
|
||||
@@ -20,6 +20,12 @@ type CanvasSize = {
|
||||
|
||||
type OverlayAnchor = Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
const QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN = 56;
|
||||
const QUICK_EDIT_FOCUS_SOURCE_TOP = 56;
|
||||
const QUICK_EDIT_PANEL_GAP = 12;
|
||||
const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260;
|
||||
const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4;
|
||||
|
||||
export type CanvasOverlayStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -102,6 +108,42 @@ export function resolveSelectedToolbarStyle({
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasViewport {
|
||||
const safeSourceWidth = Math.max(1, sourceLayer.width);
|
||||
const safeSourceHeight = Math.max(1, sourceLayer.height);
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
canvasSize.width - QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN * 2,
|
||||
);
|
||||
const availableHeight = Math.max(
|
||||
1,
|
||||
canvasSize.height -
|
||||
QUICK_EDIT_FOCUS_SOURCE_TOP -
|
||||
QUICK_EDIT_PANEL_GAP -
|
||||
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
availableWidth / safeSourceWidth,
|
||||
availableHeight / safeSourceHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
scale,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(sourceLayer.x + sourceLayer.width / 2) * scale,
|
||||
y: QUICK_EDIT_FOCUS_SOURCE_TOP - sourceLayer.y * scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
@@ -126,9 +168,14 @@ export function resolveQuickEditPanelStyle({
|
||||
top: clamp(
|
||||
viewport.y +
|
||||
(sourceLayer.y + sourceLayer.height) * viewport.scale +
|
||||
12,
|
||||
12,
|
||||
Math.max(12, canvasSize.height - 360),
|
||||
QUICK_EDIT_PANEL_GAP,
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
Math.max(
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
canvasSize.height -
|
||||
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
'生成3泥点',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -117,13 +117,13 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerEnd: () => void;
|
||||
onCancelUiAssetExtraction: () => void;
|
||||
onSubmitUiAssetExtraction: () => void;
|
||||
onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void;
|
||||
onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void;
|
||||
onQuickEditSelectionPointerEnd: () => void;
|
||||
|
||||
@@ -26,25 +26,48 @@ export type UiAssetExtractionMark =
|
||||
|
||||
export type UiAssetExtractionState = {
|
||||
sourceLayerId: string;
|
||||
tool: UiAssetExtractionTool;
|
||||
tool: UiAssetExtractionTool | null;
|
||||
marks: UiAssetExtractionMark[];
|
||||
draftMark: UiAssetExtractionMark | null;
|
||||
status: 'idle' | 'extracting' | 'failed';
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export const UI_ASSET_EXTRACTION_ASPECT_RATIO = '1:1';
|
||||
export const UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE = '1K';
|
||||
export const UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE = '2K';
|
||||
export const UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD = 6;
|
||||
|
||||
export type UiAssetExtractionImageSize =
|
||||
| typeof UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE
|
||||
| typeof UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE;
|
||||
|
||||
export function createUiAssetExtractionState(
|
||||
sourceLayerId: string,
|
||||
options: { initialTool?: UiAssetExtractionTool | null } = {},
|
||||
): UiAssetExtractionState {
|
||||
return {
|
||||
sourceLayerId,
|
||||
tool: 'rect',
|
||||
tool: options.initialTool === undefined ? 'rect' : options.initialTool,
|
||||
marks: [],
|
||||
draftMark: null,
|
||||
status: 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveUiAssetExtractionGenerationPlan(markCount: number): {
|
||||
aspectRatio: typeof UI_ASSET_EXTRACTION_ASPECT_RATIO;
|
||||
imageSize: UiAssetExtractionImageSize;
|
||||
} {
|
||||
return {
|
||||
aspectRatio: UI_ASSET_EXTRACTION_ASPECT_RATIO,
|
||||
imageSize:
|
||||
markCount > UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD
|
||||
? UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE
|
||||
: UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiAssetExtractionDraftMark({
|
||||
tool,
|
||||
id,
|
||||
|
||||
@@ -73,11 +73,98 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '椭圆框选' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '矩形框选' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '画笔自由框选' }),
|
||||
);
|
||||
|
||||
expect(handleToolChange).toHaveBeenCalledWith('brush');
|
||||
expect(handleToolChange).toHaveBeenNthCalledWith(1, null);
|
||||
expect(handleToolChange).toHaveBeenNthCalledWith(2, 'brush');
|
||||
});
|
||||
|
||||
it('renders UI extraction tools on the right and a bottom composer with previews and gpt-image-2 pricing', () => {
|
||||
const marks = Array.from({ length: 7 }, (_, index) => ({
|
||||
id: `mark-${index + 1}`,
|
||||
tool: 'rect' as const,
|
||||
x: index * 8,
|
||||
y: index * 6,
|
||||
width: 48,
|
||||
height: 36,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({ marks })}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('toolbar', { name: 'UI素材框选工具' }).className,
|
||||
).toContain('image-canvas-editor__ui-extraction-toolbar--right');
|
||||
const panel = screen.getByRole('dialog', { name: 'UI素材提取' });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__ui-extraction-panel',
|
||||
);
|
||||
expect(within(panel).getByText('仅提取被红色框框选的素材并整理成spritesheet')).toBeTruthy();
|
||||
expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7);
|
||||
expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K');
|
||||
expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2');
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '取消框选' }),
|
||||
).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '提取' }).textContent).toContain('5泥点');
|
||||
});
|
||||
|
||||
it('crops mark previews from the same source coordinates as the red selection', () => {
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 160,
|
||||
y: 120,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByLabelText('框选区域预览 1');
|
||||
const previewImage = within(preview).getByRole('img', {
|
||||
name: '源图框选预览 1',
|
||||
}) as HTMLImageElement;
|
||||
|
||||
expect(previewImage.getAttribute('src')).toBe('data:image/png;base64,ui');
|
||||
expect(previewImage.style.width).toBe('800%');
|
||||
expect(previewImage.style.height).toBe('800%');
|
||||
expect(previewImage.style.left).toBe('-200%');
|
||||
expect(previewImage.style.top).toBe('-200%');
|
||||
});
|
||||
|
||||
it('shows numbered labels for quick edit red selections', () => {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Circle, MousePointer2, Paintbrush, Square, X } from 'lucide-react';
|
||||
import { Circle, Paintbrush, Square, X } from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
UiAssetExtractionMark,
|
||||
UiAssetExtractionState,
|
||||
UiAssetExtractionTool,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import { buildUiAssetExtractionBrushPath } from './ImageCanvasUiAssetExtractionModel';
|
||||
import {
|
||||
buildUiAssetExtractionBrushPath,
|
||||
resolveUiAssetExtractionGenerationPlan,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
|
||||
type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
variant?: 'ui-extraction' | 'quick-edit';
|
||||
@@ -18,7 +25,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
state: UiAssetExtractionState | null;
|
||||
onToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onPointerStart: (point: { x: number; y: number }) => void;
|
||||
onPointerMove: (point: { x: number; y: number }) => void;
|
||||
onPointerEnd: () => void;
|
||||
@@ -70,6 +77,60 @@ function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMarkPreviewBounds(
|
||||
mark: UiAssetExtractionMark,
|
||||
sourceLayer: CanvasLayer,
|
||||
) {
|
||||
if (mark.tool === 'brush') {
|
||||
const xs = mark.points.map((point) => point.x);
|
||||
const ys = mark.points.map((point) => point.y);
|
||||
const left = Math.min(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const right = Math.max(...xs);
|
||||
const bottom = Math.max(...ys);
|
||||
return {
|
||||
x: Number.isFinite(left) ? left : 0,
|
||||
y: Number.isFinite(top) ? top : 0,
|
||||
width: Math.max(1, Number.isFinite(right - left) ? right - left : 1),
|
||||
height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: Math.min(mark.x, mark.x + mark.width),
|
||||
y: Math.min(mark.y, mark.y + mark.height),
|
||||
width: Math.max(1, Math.abs(mark.width)),
|
||||
height: Math.max(1, Math.abs(mark.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkPreview(
|
||||
mark: UiAssetExtractionMark,
|
||||
index: number,
|
||||
sourceLayer: CanvasLayer,
|
||||
) {
|
||||
const bounds = resolveMarkPreviewBounds(mark, sourceLayer);
|
||||
const originalWidth = Math.max(1, sourceLayer.originalWidth);
|
||||
const originalHeight = Math.max(1, sourceLayer.originalHeight);
|
||||
return (
|
||||
<div
|
||||
key={mark.id}
|
||||
aria-label={`框选区域预览 ${index + 1}`}
|
||||
className="image-canvas-editor__ui-extraction-preview"
|
||||
style={{
|
||||
backgroundImage: `url("${sourceLayer.src}")`,
|
||||
backgroundPosition: `${(bounds.x / originalWidth) * 100}% ${
|
||||
(bounds.y / originalHeight) * 100
|
||||
}%`,
|
||||
backgroundSize: `${(originalWidth / bounds.width) * 100}% ${
|
||||
(originalHeight / bounds.height) * 100
|
||||
}%`,
|
||||
}}
|
||||
>
|
||||
<span>{index + 1}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMark(
|
||||
mark: UiAssetExtractionMark,
|
||||
index: number,
|
||||
@@ -139,6 +200,13 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
const isQuickEdit = variant === 'quick-edit';
|
||||
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
|
||||
const canSubmit = state.marks.length > 0 && state.status !== 'extracting';
|
||||
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
|
||||
state.marks.length,
|
||||
);
|
||||
const extractionPrice = calculateEditorIconSpritesheetPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
extractionPlan.imageSize,
|
||||
);
|
||||
const screenFrame = {
|
||||
left: viewport.x + sourceLayer.x * viewport.scale,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
@@ -149,77 +217,34 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`image-canvas-editor__ui-extraction-toolbar ${
|
||||
isQuickEdit
|
||||
? 'image-canvas-editor__ui-extraction-toolbar--right'
|
||||
: ''
|
||||
}`}
|
||||
className="image-canvas-editor__ui-extraction-toolbar image-canvas-editor__ui-extraction-toolbar--right"
|
||||
role="toolbar"
|
||||
aria-label={isQuickEdit ? '快速编辑框选工具' : 'UI素材框选工具'}
|
||||
style={{
|
||||
left: isQuickEdit
|
||||
? screenFrame.left + screenFrame.width + 12
|
||||
: screenFrame.left + screenFrame.width / 2,
|
||||
top: isQuickEdit
|
||||
? screenFrame.top + screenFrame.height / 2
|
||||
: Math.max(10, screenFrame.top - 12),
|
||||
left: screenFrame.left + screenFrame.width + 12,
|
||||
top: screenFrame.top + screenFrame.height / 2,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isQuickEdit ? null : (
|
||||
<>
|
||||
<span className="image-canvas-editor__ui-extraction-toolbar-title">
|
||||
<MousePointer2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
框选素材
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{TOOL_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = state.tool === option.value;
|
||||
return (
|
||||
<PlatformIconButton
|
||||
key={option.value}
|
||||
variant="darkMini"
|
||||
aria-pressed={isActive}
|
||||
className={
|
||||
state.tool === option.value
|
||||
isActive
|
||||
? 'image-canvas-editor__ui-extraction-tool--active'
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
title={option.label}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
onClick={() => onToolChange(option.value)}
|
||||
onClick={() => onToolChange(isActive ? null : option.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isQuickEdit ? null : (
|
||||
<>
|
||||
<span className="image-canvas-editor__ui-extraction-count">
|
||||
{state.marks.length}
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{state.status === 'extracting' ? '提取中' : '提取'}
|
||||
</PlatformActionButton>
|
||||
<PlatformIconButton
|
||||
variant="darkMini"
|
||||
label="取消框选"
|
||||
title="取消框选"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className="image-canvas-editor__ui-extraction-overlay"
|
||||
@@ -236,6 +261,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
width: screenFrame.width,
|
||||
height: screenFrame.height,
|
||||
zIndex: sourceLayer.zIndex + 40,
|
||||
pointerEvents: state.tool ? 'auto' : 'none',
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -270,6 +296,67 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
renderMark(mark, index, showIndexLabels && index < state.marks.length),
|
||||
)}
|
||||
</svg>
|
||||
{!isQuickEdit ? (
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
|
||||
role="dialog"
|
||||
aria-label="UI素材提取"
|
||||
style={{
|
||||
left: screenFrame.left + screenFrame.width / 2,
|
||||
top: screenFrame.top + screenFrame.height + 12,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__ui-extraction-panel-head">
|
||||
仅提取被红色框框选的素材并整理成spritesheet
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-preview-list"
|
||||
aria-label="预览列表"
|
||||
>
|
||||
{state.marks.length
|
||||
? state.marks.map((mark, index) =>
|
||||
renderMarkPreview(mark, index, sourceLayer),
|
||||
)
|
||||
: (
|
||||
<span className="image-canvas-editor__ui-extraction-preview-empty">
|
||||
框选后显示预览
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__ui-extraction-panel-footer">
|
||||
<span
|
||||
className="image-canvas-editor__readonly-generation-option"
|
||||
aria-label="计划规格"
|
||||
>
|
||||
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
|
||||
</span>
|
||||
<span
|
||||
className="image-canvas-editor__readonly-generation-option image-canvas-editor__ui-extraction-model"
|
||||
aria-label="固定模型"
|
||||
>
|
||||
{IMAGE_MODEL_GPT_IMAGE_2}
|
||||
</span>
|
||||
<PlatformIconButton
|
||||
label="取消框选"
|
||||
title="取消框选"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
aria-label="提取"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{state.status === 'extracting'
|
||||
? '提取中'
|
||||
: `提取 · ${extractionPrice}泥点`}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{state.errorMessage ? (
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-error"
|
||||
|
||||
@@ -305,16 +305,14 @@ function SubmissionWorkflowHarness({
|
||||
onClick={() => {
|
||||
if (sourceLayer) {
|
||||
void workflow.extractUiDesignAssets(sourceLayer, {
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
marks: Array.from({ length: 7 }, (_, index) => ({
|
||||
id: `mark-${index + 1}`,
|
||||
tool: 'rect' as const,
|
||||
x: 10 + index * 8,
|
||||
y: 20 + index * 6,
|
||||
width: 80,
|
||||
height: 60,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -1191,7 +1189,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1271,7 +1269,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('renders selected UI marks into the extraction reference image', async () => {
|
||||
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
||||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
spritesheetWidth: 512,
|
||||
@@ -1300,24 +1298,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith({
|
||||
source: 'data:image/png;base64,ui-design.png',
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'data:image/png;base64,ui-design.png',
|
||||
marks: expect.arrayContaining([
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks,
|
||||
).toHaveLength(7);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 5,
|
||||
generationInputs: expect.objectContaining({
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -51,11 +51,13 @@ import {
|
||||
buildUiDesignAssetExtractionGenerationInputs,
|
||||
buildVideoGenerationInputs,
|
||||
calculateCharacterAnimationPrice,
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
calculateEditorVideoPrice,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
isCanvasGenerationDialog,
|
||||
@@ -74,6 +76,7 @@ import type {
|
||||
UiAssetExtractionMark,
|
||||
UiAssetExtractionState,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
|
||||
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
@@ -575,12 +578,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
}
|
||||
const generationInputs =
|
||||
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
|
||||
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
|
||||
options.marks?.length ?? 0,
|
||||
);
|
||||
const priceMudPoints = calculateEditorIconSpritesheetPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
extractionPlan.imageSize,
|
||||
);
|
||||
const generated = await extractEditorUiDesignAssets({
|
||||
sourceImageSrc,
|
||||
projectId,
|
||||
generationInputs,
|
||||
assetFolderId,
|
||||
spritesheetLabel: `${sourceLayer.title} 素材图集`,
|
||||
aspectRatio: extractionPlan.aspectRatio,
|
||||
imageSize: extractionPlan.imageSize,
|
||||
priceMudPoints,
|
||||
});
|
||||
const extractionFrame = {
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
|
||||
@@ -202,7 +202,7 @@ function GenerationWorkflowHarness({
|
||||
</span>
|
||||
<span data-testid="ui-extraction">
|
||||
{workflow.uiAssetExtractionState
|
||||
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}`
|
||||
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="ui-extraction-source">
|
||||
@@ -210,7 +210,7 @@ function GenerationWorkflowHarness({
|
||||
</span>
|
||||
<span data-testid="quick-edit-selection">
|
||||
{workflow.quickEditSelectionState
|
||||
? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool}:${workflow.quickEditSelectionState.marks.length}`
|
||||
? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="character-animation">
|
||||
@@ -506,6 +506,12 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
填写快速编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.changeQuickEditSelectionTool('rect')}
|
||||
>
|
||||
选择快速编辑矩形框选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.changeQuickEditSelectionTool('ellipse')}
|
||||
@@ -724,7 +730,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
model: 'audio1.0',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'task-sound',
|
||||
priceMudPoints: 10,
|
||||
priceMudPoints: 5,
|
||||
audioKind: 'sound-effect',
|
||||
durationSeconds: 4,
|
||||
});
|
||||
@@ -738,7 +744,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
prompt: '金币跳出音',
|
||||
model: 'audio1.0',
|
||||
duration: 8,
|
||||
priceMudPoints: 10,
|
||||
priceMudPoints: 5,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
@@ -1008,24 +1014,57 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens quick edit below the source layer and moves the viewport to keep the panel clear', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
it('opens quick edit with inactive selection tools and keeps tall sources above the panel', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({ x: 100, y: 50, width: 360, height: 1600 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe('layer-source');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('170:-60:1');
|
||||
const viewportParts = screen
|
||||
.getByTestId('viewport')
|
||||
.textContent!.split(':')
|
||||
.map(Number);
|
||||
if (viewportParts.length !== 3) {
|
||||
throw new Error('viewport test output should contain x, y and scale');
|
||||
}
|
||||
const [viewportX, viewportY, viewportScale] = viewportParts as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const sourceTop = viewportY + 50 * viewportScale;
|
||||
const sourceBottom = sourceTop + 1600 * viewportScale;
|
||||
|
||||
expect(viewportX).toBeGreaterThan(300);
|
||||
expect(viewportScale).toBeLessThan(1);
|
||||
expect(sourceTop).toBeCloseTo(56, 2);
|
||||
expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640);
|
||||
});
|
||||
|
||||
it('adds numbered quick edit red selections and appends matching prompt lines', () => {
|
||||
it('adds numbered quick edit red selections only after enabling a selection tool', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择快速编辑矩形框选' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:rect:1',
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
centerViewportOnPlacement,
|
||||
chooseGenerationPlacement,
|
||||
} from './ImageCanvasGenerationPlacementModel';
|
||||
import { resolveQuickEditFocusViewport } from './ImageCanvasOverlayModel';
|
||||
import {
|
||||
buildCropExpandInsetsFromFrame,
|
||||
removeImageBackground,
|
||||
@@ -110,8 +111,6 @@ const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
|
||||
'genarrative.imageCanvas.lastCharacterSpecReference';
|
||||
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
|
||||
'genarrative.imageCanvas.lastIconSpecReference';
|
||||
const QUICK_EDIT_VIEWPORT_SCALE = 1;
|
||||
const QUICK_EDIT_VIEWPORT_SOURCE_TOP = 80;
|
||||
|
||||
function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) {
|
||||
const nextLine = `对${markNumber}号红色圈选框里的内容做以下修改:`;
|
||||
@@ -787,25 +786,18 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setCropExpandPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
|
||||
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id);
|
||||
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
|
||||
initialTool: null,
|
||||
});
|
||||
quickEditSelectionStateRef.current = nextSelectionState;
|
||||
setQuickEditSelectionState(nextSelectionState);
|
||||
quickEditSelectionMarkCounterRef.current = 0;
|
||||
selectSingleLayer(sourceLayer.id);
|
||||
setViewport({
|
||||
scale: QUICK_EDIT_VIEWPORT_SCALE,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(sourceLayer.x + sourceLayer.width / 2) *
|
||||
QUICK_EDIT_VIEWPORT_SCALE,
|
||||
y:
|
||||
QUICK_EDIT_VIEWPORT_SOURCE_TOP -
|
||||
sourceLayer.y * QUICK_EDIT_VIEWPORT_SCALE,
|
||||
});
|
||||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||||
setActiveTool('generate');
|
||||
},
|
||||
[
|
||||
canvasSize.width,
|
||||
canvasSize,
|
||||
selectSingleLayer,
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
setActiveTool,
|
||||
@@ -1287,10 +1279,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
selectSingleLayer(sourceLayer.id);
|
||||
setActiveTool('select');
|
||||
setUiAssetExtractionState(
|
||||
createUiAssetExtractionState(sourceLayer.id),
|
||||
createUiAssetExtractionState(sourceLayer.id, { initialTool: 'rect' }),
|
||||
);
|
||||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||||
},
|
||||
[
|
||||
canvasSize,
|
||||
closeGenerationTransientState,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
@@ -1302,12 +1296,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
|
||||
const changeUiAssetExtractionTool = useCallback(
|
||||
(tool: UiAssetExtractionTool) => {
|
||||
(tool: UiAssetExtractionTool | null) => {
|
||||
setUiAssetExtractionState((currentState) =>
|
||||
currentState
|
||||
? {
|
||||
...currentState,
|
||||
tool,
|
||||
tool: currentState.tool === tool ? null : tool,
|
||||
draftMark: null,
|
||||
status: currentState.status === 'failed' ? 'idle' : currentState.status,
|
||||
errorMessage:
|
||||
@@ -1322,12 +1316,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
|
||||
const changeQuickEditSelectionTool = useCallback(
|
||||
(tool: UiAssetExtractionTool) => {
|
||||
(tool: UiAssetExtractionTool | null) => {
|
||||
updateQuickEditSelectionState((currentState) =>
|
||||
currentState
|
||||
? {
|
||||
...currentState,
|
||||
tool,
|
||||
tool: currentState.tool === tool ? null : tool,
|
||||
draftMark: null,
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
@@ -1341,7 +1335,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const startUiAssetExtractionPointer = useCallback(
|
||||
(point: { x: number; y: number }) => {
|
||||
setUiAssetExtractionState((currentState) => {
|
||||
if (!currentState || currentState.status === 'extracting') {
|
||||
if (
|
||||
!currentState ||
|
||||
!currentState.tool ||
|
||||
currentState.status === 'extracting'
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
uiAssetExtractionMarkCounterRef.current += 1;
|
||||
@@ -1363,7 +1361,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const startQuickEditSelectionPointer = useCallback(
|
||||
(point: { x: number; y: number }) => {
|
||||
updateQuickEditSelectionState((currentState) => {
|
||||
if (!currentState) {
|
||||
if (!currentState || !currentState.tool) {
|
||||
return currentState;
|
||||
}
|
||||
quickEditSelectionMarkCounterRef.current += 1;
|
||||
|
||||
+118
-8
@@ -7490,12 +7490,12 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
padding: 0.32rem;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.3);
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.35rem;
|
||||
color: #334155;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
@@ -7504,6 +7504,27 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-toolbar button {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 0.45rem;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-toolbar button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #8fb8ff;
|
||||
background: #eef5ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-toolbar-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -7515,8 +7536,9 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-tool--active {
|
||||
border-color: rgba(255, 255, 255, 0.48);
|
||||
background: rgba(239, 68, 68, 0.92);
|
||||
border-color: #38bdf8;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-count {
|
||||
@@ -7539,6 +7561,94 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-panel {
|
||||
z-index: 49;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(4.8rem, auto) auto;
|
||||
min-height: 10.4rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-panel-head {
|
||||
grid-column: 1 / -1;
|
||||
color: #0f172a;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-preview-list {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
min-height: 4.8rem;
|
||||
gap: 0.46rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 1rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.46rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-preview {
|
||||
position: relative;
|
||||
width: 4.8rem;
|
||||
min-width: 4.8rem;
|
||||
height: 4.8rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(239, 68, 68, 0.42);
|
||||
border-radius: 0.88rem;
|
||||
background-color: #ffffff;
|
||||
background-repeat: no-repeat;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-preview span {
|
||||
position: absolute;
|
||||
top: 0.28rem;
|
||||
left: 0.28rem;
|
||||
display: inline-flex;
|
||||
width: 1.24rem;
|
||||
height: 1.24rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-preview-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-panel-footer {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-panel
|
||||
.image-canvas-editor__readonly-generation-option {
|
||||
display: inline-flex;
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.72rem;
|
||||
background: transparent;
|
||||
padding: 0 0.66rem;
|
||||
color: #475569;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-model {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.image-canvas-editor__ui-extraction-hit-area {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
@@ -843,6 +843,9 @@ describe('editorProjectClient', () => {
|
||||
projectId: 'editor-project-1',
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 5,
|
||||
generationInputs: {
|
||||
fields: [{ title: '来源', value: '战斗UI设计图' }],
|
||||
references: [],
|
||||
@@ -864,6 +867,9 @@ describe('editorProjectClient', () => {
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 5,
|
||||
}),
|
||||
}),
|
||||
'提取UI设计图素材失败',
|
||||
|
||||
@@ -161,6 +161,9 @@ export type EditorIconSpritesheetGenerationInput = {
|
||||
|
||||
export type EditorUiDesignAssetExtractionInput = {
|
||||
sourceImageSrc: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
priceMudPoints?: number;
|
||||
projectId?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
@@ -723,6 +726,11 @@ export async function extractEditorUiDesignAssets(
|
||||
...(input.spritesheetLabel
|
||||
? { spritesheetLabel: input.spritesheetLabel }
|
||||
: {}),
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
...(typeof input.priceMudPoints === 'number'
|
||||
? { priceMudPoints: input.priceMudPoints }
|
||||
: {}),
|
||||
}),
|
||||
'提取UI设计图素材失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user