Add editor generation model pricing admin UI & API

Introduce centralized model pricing for editor generations: add EditorGenerationPricing types and admin API client methods (GET/POST), backend default config and runtime override support, and server route/handler changes to expose admin editor-generation-pricing. Add an AdminEditorGenerationPricingPage component, styles, route entry, icon, and tests, plus a test for admin routes. Update docs and decision log to describe the pricing contract and management workflow. This enables managing per-model pricing (perGeneration / perSecond, flat or tiered) from the admin panel and exposes runtime pricing to the main site.
This commit is contained in:
2026-06-22 16:57:29 +08:00
parent 0c78a6dc21
commit 163b2fa077
59 changed files with 3002 additions and 514 deletions
@@ -171,6 +171,7 @@ export function ImageCanvasBasicGenerationComposerView({
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
@@ -294,6 +294,7 @@ export function ImageCanvasCharacterGenerationComposerView({
cost={calculateEditorImageGenerationPrice({
kind: 'character',
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
canvasDisplayScaleToViewportScale,
canvasDisplayViewportToViewport,
CANVAS_WORLD_ORIGIN,
createLayerFromAsset,
formatCanvasDisplayScalePercent,
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
@@ -10,6 +13,8 @@ import {
serializeCanvasLayout,
serializeLayer,
splitCanvasLayoutItems,
viewportScaleToCanvasDisplayScale,
viewportToCanvasDisplayViewport,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
@@ -18,6 +23,23 @@ import type {
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('maps viewport scale to the user-facing canvas zoom scale', () => {
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
expect(
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
).toEqual({ x: 10, y: 20, scale: 0.5 });
expect(
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
).toEqual({ x: 10, y: 20, scale: 1 });
});
it('normalizes valid canvas background hex values and rejects invalid input', () => {
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
@@ -31,6 +31,7 @@ export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.24;
export const MAX_SCALE = 3.2;
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const TOOLBAR_HALF_WIDTH = 132;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const SNAP_THRESHOLD_SCREEN_PX = 18;
@@ -77,6 +78,39 @@ export function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
export function formatCanvasDisplayScalePercent(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
}
export function canvasDisplayScaleToViewportScale(displayScale: number) {
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
}
export function viewportScaleToCanvasDisplayScale(scale: number) {
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
}
export function viewportToCanvasDisplayViewport(
viewport: CanvasViewport,
): CanvasViewport {
return {
...viewport,
scale: viewportScaleToCanvasDisplayScale(viewport.scale),
};
}
export function canvasDisplayViewportToViewport(
viewport: CanvasViewport,
): CanvasViewport {
return {
...viewport,
scale: canvasDisplayScaleToViewportScale(viewport.scale),
};
}
export function formatImageSizeValue(width: number, height: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
@@ -1093,7 +1093,7 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
expect(
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
@@ -1216,6 +1216,17 @@ describe('ImageCanvasEditorView', () => {
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
});
it('renders 100 percent zoom with the previous 50 percent visual scale', () => {
render(<ImageCanvasEditorView />);
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
expect(
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
).toBe('translate(0px, 0px) scale(0.5)');
});
it('selects multiple canvas layers with shift click', async () => {
render(<ImageCanvasEditorView />);
@@ -1497,8 +1508,8 @@ describe('ImageCanvasEditorView', () => {
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 21,
clientX: -60,
clientY: 180,
clientX: 30,
clientY: 150,
});
expect(
@@ -12,6 +12,7 @@ import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
loadEditorGenerationPricing,
} from '../../services/image-editor/editorProjectClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
@@ -33,6 +34,7 @@ import type {
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
@@ -57,6 +59,7 @@ import {
export function ImageCanvasEditorView() {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -78,6 +81,24 @@ export function ImageCanvasEditorView() {
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
useEffect(() => {
let isMounted = true;
void loadEditorGenerationPricing()
.then((pricing) => {
if (!isMounted) {
return;
}
applyEditorGenerationPricingConfig(pricing);
setGenerationPricingVersion((version) => version + 1);
})
.catch(() => {
// 中文注释:价格接口异常时保留前端内置兜底,不阻断画板使用。
});
return () => {
isMounted = false;
};
}, []);
const selectedLayerIdsRef = useRef<string[]>([]);
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
@@ -510,6 +510,23 @@ describe('ImageCanvasGenerationComposerView', () => {
const paramsPanel = screen.getByRole('menu', {
name: '视频参数选项',
});
const ratioChoice = within(paramsPanel).getByRole('button', {
name: '比例 16:9',
});
expect(ratioChoice.className).toContain(
'image-canvas-editor__option-popover-choice--ratio',
);
expect(
ratioChoice
.querySelector('.image-canvas-editor__ratio-wireframe')
?.getAttribute('data-ratio'),
).toBe('16:9');
expect(
within(paramsPanel)
.getByRole('button', { name: '比例 21:9' })
.querySelector('.image-canvas-editor__ratio-wireframe')
?.getAttribute('data-ratio'),
).toBe('21:9');
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
target: { value: '5' },
});
@@ -430,11 +430,12 @@ function ImageCanvasVideoGenerationComposerView({
<span className="image-canvas-editor__option-popover-title">
比例
</span>
<div className="image-canvas-editor__option-popover-items">
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={aspectRatio === option}
className="image-canvas-editor__option-popover-choice--ratio"
disabled={isGenerating}
ariaLabel={`比例 ${option}`}
onClick={() =>
@@ -443,7 +444,12 @@ function ImageCanvasVideoGenerationComposerView({
})
}
>
{option}
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={option}
aria-hidden="true"
/>
<span>{option}</span>
</VideoOptionChoice>
))}
</div>
@@ -32,7 +32,10 @@ function ImageOptionsHarness({
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
onRememberImageModel={onRememberImageModel}
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成角色形象"
/>
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
@@ -7,6 +7,7 @@ import type {
} from './ImageCanvasEditorTypes';
import {
BACKGROUND_MUSIC_MODEL_SUNO,
applyEditorGenerationPricingConfig,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
@@ -30,12 +31,11 @@ import {
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
EDITOR_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_OPTIONS,
getGenerationFrameAriaLabel,
@@ -52,26 +52,30 @@ import {
} from './ImageCanvasGenerationModel';
describe('ImageCanvasGenerationModel', () => {
afterEach(() => {
applyEditorGenerationPricingConfig({
models: EDITOR_MODEL_MUD_POINT_CONFIG,
});
});
it('为所有编辑器生成模型提供显式定价配置', () => {
expect(
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG),
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG),
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG),
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5);
expect(
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
).toBeTruthy();
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40);
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
expect(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
).toBe(5);
@@ -84,23 +88,25 @@ describe('ImageCanvasGenerationModel', () => {
it('所有生成按钮价格都从模型定价配置推导', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
model: IMAGE_MODEL_GPT_IMAGE_2,
imageSize: '1K',
}),
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]);
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
@@ -119,9 +125,7 @@ describe('ImageCanvasGenerationModel', () => {
);
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
.toBe(
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
CHARACTER_ANIMATION_MODEL
]['720p'] * 6,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
@@ -131,6 +135,46 @@ describe('ImageCanvasGenerationModel', () => {
);
});
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
applyEditorGenerationPricingConfig({
models: {
...EDITOR_MODEL_MUD_POINT_CONFIG,
[IMAGE_MODEL_GPT_IMAGE_2]: {
unit: 'perGeneration',
prices: {
'1K': 29,
'2K': 58,
},
},
[VIDEO_MODEL_SEEDANCE_2]: {
unit: 'perSecond',
prices: {
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
'720p': 26,
},
},
[DEFAULT_SOUND_EFFECT_MODEL]: {
unit: 'perGeneration',
price: 15,
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price: 9,
},
},
});
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(29);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(58);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
130,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(15);
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
9,
);
});
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
@@ -1,4 +1,5 @@
import { ApiClientError } from '../../services/apiClient';
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
@@ -22,24 +23,18 @@ import {
type PublicationMaterialsWorkflow,
} from './ImageCanvasPublicationMaterialsModel';
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
image: 12,
spec: 5,
character: 12,
icon: 12,
uiDesign: 12,
videoPerSecond: {
'480p': 10,
'720p': 20,
},
characterAnimationPerSecond: {
'480p': 10,
'720p': 20,
},
soundEffect: 10,
backgroundMusic: 5,
} as const;
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
IMAGE_MODEL_NANOBANANA2,
'nanobanana2',
'nano-banana',
]);
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const SPEC_GENERATION_SIZE = '2048x1152';
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
@@ -73,27 +68,19 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
'publication-detail-gallery': { width: 360, height: 640 },
'publication-promo-poster': { width: 560, height: 315 },
};
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
IMAGE_MODEL_NANOBANANA2,
'nanobanana2',
'nano-banana',
]);
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
// 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。
[IMAGE_MODEL_GPT_IMAGE_2]: 20,
} as const;
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
[IMAGE_MODEL_NANOBANANA2]: {
'0.5K': 8,
'1K': 12,
'2K': 24,
},
[IMAGE_MODEL_GPT_IMAGE_2]: {
'1K': 20,
'2K': 40,
},
} as const;
export const SPEC_GENERATION_COST =
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL];
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][SPEC_GENERATION_IMAGE_SIZE];
export const ICON_DESCRIPTION_LIMIT = 100;
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
gameName: '',
@@ -322,9 +309,9 @@ export const SOUND_EFFECT_DURATION_OPTIONS = Array.from(
export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5';
export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO;
const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
'1080p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'] * 2,
'480p': 10,
'720p': 20,
'1080p': 40,
} as const;
const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = {
'480p': 12,
@@ -346,22 +333,63 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG,
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
// 中文注释:前端不展示 Veo 入口,但客户端类型和后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
} as const;
export const EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = {
[CHARACTER_ANIMATION_MODEL]: {
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'],
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'],
},
} as const;
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
[SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
[SOUND_EFFECT_MODEL_VIDU]: 10,
} as const;
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
[BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
[BACKGROUND_MUSIC_MODEL_SUNO]: 5,
} as const;
export const EDITOR_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_NANOBANANA2]: {
unit: 'perGeneration',
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2],
},
[IMAGE_MODEL_GPT_IMAGE_2]: {
unit: 'perGeneration',
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
},
[VIDEO_MODEL_SEEDANCE_2_FAST]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2_FAST],
},
[VIDEO_MODEL_SEEDANCE_2]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
},
[VIDEO_MODEL_KLING_3]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3],
},
[VIDEO_MODEL_KLING_3_OMNI]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI],
},
[VIDEO_MODEL_VEO_3_1]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
},
[VIDEO_MODEL_VEO_3_1_FAST]: {
unit: 'perSecond',
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
},
[SOUND_EFFECT_MODEL_VIDU]: {
unit: 'perGeneration',
price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU],
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
},
} satisfies EditorGenerationPricingConfig['models'];
let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = {
models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG),
};
export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [
{ label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU },
] as const;
@@ -653,17 +681,73 @@ export function formatLayerImageType(layer: CanvasLayer) {
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
}
function normalizeImagePriceSize(
model: string,
imageSize: string | null | undefined,
) {
const normalizedImageSize = getSafeImageSizeValue(imageSize);
if (model === IMAGE_MODEL_NANOBANANA2 && normalizedImageSize === '0.5K') {
return '0.5K';
}
if (normalizedImageSize === '2K') {
return '2K';
}
return '1K';
}
function getModelPrices(model: string | null | undefined) {
const normalizedModel = model?.trim() || '';
return normalizedModel
? runtimeEditorGenerationPricingConfig.models[normalizedModel]
: undefined;
}
function readModelTierPrice({
model,
fallbackModel,
tier,
fallbackTier,
}: {
model: string;
fallbackModel: string;
tier: string;
fallbackTier: string;
}) {
const modelPricing = getModelPrices(model) ?? getModelPrices(fallbackModel);
const fallbackPricing = getModelPrices(fallbackModel);
return (
modelPricing?.prices?.[tier] ??
modelPricing?.prices?.[fallbackTier] ??
modelPricing?.price ??
fallbackPricing?.prices?.[tier] ??
fallbackPricing?.prices?.[fallbackTier] ??
fallbackPricing?.price ??
0
);
}
function readModelFlatPrice(model: string, fallbackModel: string) {
return (
getModelPrices(model)?.price ??
getModelPrices(fallbackModel)?.price ??
0
);
}
export function calculateCharacterAnimationPrice(
model: string | null | undefined,
resolution: EditorCharacterAnimationResolution,
durationSeconds: number,
) {
const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim();
const modelRates =
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL];
return modelRates[resolution] * durationSeconds;
return (
readModelTierPrice({
model: normalizedModel,
fallbackModel: CHARACTER_ANIMATION_MODEL,
tier: resolution,
fallbackTier: '480p',
}) * durationSeconds
);
}
export function calculateEditorVideoPrice(
@@ -672,83 +756,100 @@ export function calculateEditorVideoPrice(
durationSeconds: number,
) {
const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim();
const modelRates =
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL];
return modelRates[resolution] * durationSeconds;
return (
readModelTierPrice({
model: normalizedModel,
fallbackModel: DEFAULT_VIDEO_MODEL,
tier: resolution,
fallbackTier: '480p',
}) * durationSeconds
);
}
export function calculateEditorImageModelPrice(
model: string | null | undefined,
imageSize: string | null | undefined = '1K',
) {
const normalizedModel = normalizeEditorImageModel(model);
return (
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image
);
const normalizedSize = normalizeImagePriceSize(normalizedModel, imageSize);
return readModelTierPrice({
model: normalizedModel,
fallbackModel: DEFAULT_IMAGE_MODEL,
tier: normalizedSize,
fallbackTier: '1K',
});
}
export function calculateEditorSpecGenerationPrice(
model: string | null | undefined = SPEC_GENERATION_MODEL,
) {
const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim();
return (
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]
);
return calculateEditorImageModelPrice(model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE);
}
export function calculateEditorImageGenerationPrice({
kind,
model,
imageSize,
}: {
kind?: string | null;
model?: string | null;
imageSize?: string | null;
} = {}) {
const normalizedKind = kind?.trim();
if (normalizedKind === 'spec') {
return calculateEditorSpecGenerationPrice(model);
}
return calculateEditorImageModelPrice(model);
return calculateEditorImageModelPrice(model, imageSize);
}
export function calculateEditorIconSpritesheetPrice(
model: string | null | undefined,
imageSize: string | null | undefined = '1K',
) {
return calculateEditorImageModelPrice(model);
return calculateEditorImageModelPrice(model, imageSize);
}
export function calculateEditorUiDesignPrice(
model: string | null | undefined,
imageSize: string | null | undefined = '1K',
) {
return calculateEditorImageModelPrice(model);
return calculateEditorImageModelPrice(model, imageSize);
}
export function calculateEditorSoundEffectPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
return (
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL]
);
return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL);
}
export function calculateEditorBackgroundMusicPrice(
model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL,
) {
const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim();
return (
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG
] ??
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
DEFAULT_BACKGROUND_MUSIC_MODEL
]
return readModelFlatPrice(normalizedModel, DEFAULT_BACKGROUND_MUSIC_MODEL);
}
export function applyEditorGenerationPricingConfig(
pricing: EditorGenerationPricingConfig,
) {
runtimeEditorGenerationPricingConfig = {
models: cloneModelPricing(pricing.models),
};
}
function cloneModelPricing<T extends EditorGenerationPricingConfig['models']>(
value: T,
): EditorGenerationPricingConfig['models'] {
return Object.fromEntries(
Object.entries(value).map(([model, pricing]) => [
model,
{
unit: pricing.unit,
...(typeof pricing.price === 'number' ? { price: pricing.price } : {}),
...(pricing.prices ? { prices: { ...pricing.prices } } : {}),
},
]),
);
}
@@ -209,6 +209,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
title: '图标规范 2',
},
});
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
'referenceImageSrcs',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain(
'参考图生成规范',
);
});
it('builds character plans with references and remembered model', () => {
@@ -536,7 +542,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'gpt-image-2',
aspectRatio: '3:2',
imageSize: '2K',
priceMudPoints: calculateEditorIconSpritesheetPrice('gpt-image-2'),
priceMudPoints: calculateEditorIconSpritesheetPrice(
'gpt-image-2',
'2K',
),
},
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
@@ -516,7 +516,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel),
priceMudPoints: calculateEditorIconSpritesheetPrice(
rememberImageModel,
dialog.imageSize ?? '1K',
),
},
generationInputs: buildIconGenerationInputs(
iconDescriptions,
@@ -200,7 +200,10 @@ export function ImageCanvasIconSpritesheetComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={calculateEditorIconSpritesheetPrice(dialog.imageModel)}
cost={calculateEditorIconSpritesheetPrice(
dialog.imageModel,
dialog.imageSize,
)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -80,16 +80,25 @@ describe('ImageCanvasPanelDockView', () => {
it('renders zoom and background settings with callback wiring', () => {
const props = renderPanelDock({
viewport: { x: 0, y: 0, scale: 0.5 },
isZoomMenuOpen: true,
isBackgroundSettingsOpen: true,
});
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至200%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' }));
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.25);
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.5);
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1);
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(4);
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
@@ -15,9 +15,10 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
import {
canvasDisplayScaleToViewportScale,
CANVAS_BACKGROUND_OPTIONS,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatPercent,
formatCanvasDisplayScalePercent,
} from './ImageCanvasEditorModel';
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
@@ -103,12 +104,12 @@ export function ImageCanvasPanelDockView({
<div className="image-canvas-editor__zoom-menu-wrap">
<PlatformInlineOptionButton
className="image-canvas-editor__zoom-trigger"
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
aria-label={`当前缩放比例 ${formatCanvasDisplayScalePercent(viewport.scale)}`}
aria-haspopup="menu"
aria-expanded={isZoomMenuOpen}
onClick={onToggleZoomMenu}
>
{formatPercent(viewport.scale)}
{formatCanvasDisplayScalePercent(viewport.scale)}
</PlatformInlineOptionButton>
{isZoomMenuOpen ? (
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
@@ -139,16 +140,18 @@ export function ImageCanvasPanelDockView({
>
显示画布所有元素
</PlatformFloatingMenuItem>
{[0.5, 1, 2].map((scale) => (
{[0.5, 1, 2].map((displayScale) => (
<PlatformFloatingMenuItem
key={scale}
key={displayScale}
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
onUpdateScaleFromCenter(scale);
onUpdateScaleFromCenter(
canvasDisplayScaleToViewportScale(displayScale),
);
onCloseZoomMenu();
}}
>
缩放至{Math.round(scale * 100)}%
缩放至{Math.round(displayScale * 100)}%
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>
@@ -303,7 +303,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(dialog.imageModel)}
cost={calculateEditorImageModelPrice(dialog.imageModel, workflow.imageSize)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -448,7 +448,7 @@ export function ImageCanvasQuickEditPanelView({
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{calculateEditorImageModelPrice(panel.model)}泥点
{calculateEditorImageModelPrice(panel.model, selectedImageSize)}泥点
</span>
</>
)}
@@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
),
).toBe(true);
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成5泥点');
expect(submitButton.textContent).toBe('生成40泥点');
const dimensionsButton = screen.getByRole('button', {
name: '生成图片尺寸 16:9·2K',
}) as HTMLButtonElement;
@@ -232,7 +232,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成5泥点');
expect(submitButton.textContent).toBe('生成40泥点');
});
it('disables controls while generating and renders failure state', () => {
@@ -359,7 +359,7 @@ export function ImageCanvasSpecGenerationPanelView({
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
includeDimensions
onRememberImageModel={onRememberImageModel}
cost={calculateEditorUiDesignPrice(dialog.imageModel)}
cost={calculateEditorUiDesignPrice(dialog.imageModel, dialog.imageSize)}
submitLabel="生成"
submitAriaLabel="生成UI设计图"
optionLabelPrefix="生成图片"
@@ -693,6 +693,7 @@ export function ImageCanvasWorldView({
return (
<div
className="image-canvas-editor__world"
data-testid="image-canvas-world"
style={{
width: CANVAS_WORLD_SIZE,
height: CANVAS_WORLD_SIZE,
@@ -937,6 +937,73 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits icon spec objects without requiring an icon spec reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,icon-spec',
width: 2048,
height: 1152,
prompt: '图标规范提示词',
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon-spec',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'icon',
specValues: {
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
placeholder: {
x: 120,
y: 140,
width: 640,
height: 360,
originalWidth: 2048,
originalHeight: 1152,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
assetKind: 'icon-spec',
assetLabel: '图标规范 1',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
}),
);
});
const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {};
expect(payload).not.toHaveProperty('referenceImageSrcs');
expect(payload.prompt).toContain('背包整理玩法');
expect(payload.prompt).not.toContain('参考图生成规范');
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:图标规范 1:-:icon-spec',
);
});
expect(screen.getByTestId('dialog').textContent).not.toContain(
'请选择图标规范',
);
});
it('validates icon submission before calling the API', async () => {
render(
<SubmissionWorkflowHarness
@@ -996,6 +996,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.input.kind === 'publication-material'
? await normalizePublicationReferenceImages(submissionPlan.input)
: submissionPlan.input;
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await generateEditorImage({
...imageGenerationInput,
projectId,
@@ -1003,12 +1005,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(canvasDialog && projectId
...(canvasDialog && projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title: submissionPlan.result.title,
placeholder: getGeneratingDialogPlaceholder(dialog),
title:
submissionPlan.result.title ??
imageGenerationInput.prompt ??
'AI 生成图片',
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
@@ -1,11 +1,6 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -186,6 +181,15 @@ function GenerationWorkflowHarness({
?.map((reference) => reference.label)
.join('|') ?? '-'}
</span>
<span data-testid="character-spec-reference">
{activeDialog?.characterSpecReference?.label ?? '-'}
</span>
<span data-testid="icon-spec-reference">
{activeDialog?.iconSpecReference?.label ?? '-'}
</span>
<span data-testid="ui-design-spec-reference">
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
</span>
<span data-testid="quick-edit">
{workflow.quickEditPanel
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
@@ -210,9 +214,18 @@ function GenerationWorkflowHarness({
<button type="button" onClick={workflow.openGenerateDialog}>
打开生成
</button>
<button type="button" onClick={workflow.openCharacterGenerationDialog}>
打开角色生成
</button>
<button type="button" onClick={workflow.openIconGenerationDialog}>
打开图标生成
</button>
<button type="button" onClick={() => workflow.openSpecDialog('ui')}>
打开UI规范
</button>
<button type="button" onClick={workflow.openUiDesignGenerationDialog}>
打开UI设计生成
</button>
<button type="button" onClick={workflow.openVideoGenerationDialog}>
打开视频生成
</button>
@@ -264,7 +277,10 @@ function GenerationWorkflowHarness({
>
打开快速编辑
</button>
<button type="button" onClick={() => workflow.openRedrawPanel(layers[0]!)}>
<button
type="button"
onClick={() => workflow.openRedrawPanel(layers[0]!)}
>
打开图片改造
</button>
<button
@@ -358,6 +374,94 @@ function GenerationWorkflowHarness({
>
选择画布参考图
</button>
<button
type="button"
onClick={() =>
workflow.pickCharacterSpecFromLayer(
createLayer({
id: 'layer-character-spec',
resourceId: 'resource-character-spec',
title: '上一张角色规范',
src: 'data:image/png;base64,character-spec',
assetKind: 'spec',
}),
)
}
>
选择角色规范
</button>
<button
type="button"
onClick={() =>
workflow.pickIconSpecFromLayer(
createLayer({
id: 'layer-icon-spec',
resourceId: 'resource-icon-spec',
title: '上一张图标规范',
src: 'data:image/png;base64,icon-spec',
assetKind: 'icon-spec',
}),
)
}
>
选择图标规范
</button>
<button
type="button"
onClick={() =>
workflow.pickUiDesignSpecFromLayer(
createLayer({
id: 'layer-ui-icon-spec',
resourceId: 'resource-ui-icon-spec',
title: '上一张UI图标规范',
src: 'data:image/png;base64,ui-icon-spec',
assetKind: 'icon-spec',
}),
)
}
>
选择UI图标规范
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...currentDialog,
characterSpecReference: {
id: 'upload-character-spec',
label: '上传角色规范',
src: 'data:image/png;base64,upload-character-spec',
resourceId: 'resource-upload-character-spec',
},
}
: currentDialog,
)
}
>
模拟上传角色规范
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...currentDialog,
iconSpecReference: {
id: 'upload-icon-spec',
label: '上传图标规范',
src: 'data:image/png;base64,upload-icon-spec',
resourceId: 'resource-upload-icon-spec',
},
}
: currentDialog,
)
}
>
模拟上传图标规范
</button>
<button
type="button"
onClick={() =>
@@ -426,6 +530,7 @@ function GenerationWorkflowHarness({
describe('useImageCanvasGenerationWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
});
it('opens a movable canvas generation placeholder and keeps toolbar state active', () => {
@@ -672,6 +777,146 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('reuses the last picked character spec when opening the next character material dialog', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'-',
);
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上一张角色规范',
);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:-:placeholder',
);
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上一张角色规范',
);
});
it('restores the last picked character spec from local cache after remounting the editor', () => {
const firstRender = render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上一张角色规范',
);
firstRender.unmount();
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上一张角色规范',
);
});
it('reuses uploaded character and icon specs when opening matching material dialogs again', async () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '模拟上传角色规范' }));
await waitFor(() => {
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上传角色规范',
);
});
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
'上传角色规范',
);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
fireEvent.click(screen.getByRole('button', { name: '模拟上传图标规范' }));
await waitFor(() => {
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上传图标规范',
);
});
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'icon:idle:open:-:placeholder',
);
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上传图标规范',
);
});
it('reuses the last picked icon spec when opening the next icon material dialog', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张图标规范',
);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'icon:idle:open:-:placeholder',
);
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张图标规范',
);
});
it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => {
const firstRender = render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张图标规范',
);
firstRender.unmount();
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张图标规范',
);
fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' }));
expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe(
'上一张图标规范',
);
});
it('shares the cached icon spec between icon material and UI design dialogs', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'ui-design:idle:open:-:placeholder',
);
expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe(
'上一张图标规范',
);
fireEvent.click(screen.getByRole('button', { name: '选择UI图标规范' }));
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
'上一张UI图标规范',
);
});
it('submits spec generation with reference image and reference prompt semantics', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: 'UI规范图' }),

Some files were not shown because too many files have changed in this diff Show More