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:
2026-06-22 21:10:43 +08:00
parent 6e475af269
commit 2007f1f9ce
30 changed files with 911 additions and 181 deletions
@@ -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
View File
@@ -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设计图素材失败',
{