锁定画布UI设计图模型选择

固定生成UI设计图模型展示为gpt-image-2

对齐角色规范面板的只读模型展示样式

提交层兜底强制UI设计图使用gpt-image-2

补充模型锁定与旧草稿兜底测试
This commit is contained in:
2026-06-22 21:51:59 +08:00
parent dfd567fe2c
commit 6caf349992
7 changed files with 467 additions and 333 deletions
@@ -25,7 +25,7 @@
## 生成参数
- 支持自定义画面比例和大小尺寸。
- 模型固定为 `gpt-image-2`面板不提供切换到其他模型的能力
- 模型固定为 `gpt-image-2`模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`
- 默认画面比例为 `16:9`,默认大小为 `1K`
## 提示词契约
@@ -16,10 +16,12 @@ function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
@@ -32,8 +34,9 @@ function ImageOptionsHarness({
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
model: lockedModel ?? dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
@@ -121,15 +124,11 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
expect(
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
).toBeTruthy();
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('当前模型'));
expect(
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
).toBeNull();
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
});
it('renders model choices as one model per row and uses a check mark for selection', () => {
@@ -153,7 +152,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
);
const panel = screen.getByRole('menu', { name: '生成图片模型选项' });
expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: 'nanobanana2' }),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy();
@@ -166,9 +167,40 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
expect(
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe(
'生成20泥点',
expect(
screen.getByRole('button', { name: '生成角色形象' }).textContent,
).toBe('生成3泥点');
});
it('locks the model trigger when a fixed model is provided', () => {
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'ui-design',
prompt: '',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '1K',
}}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
onRememberImageModel={rememberImageModel}
/>,
);
const modelButton = screen.getByRole('button', {
name: '生成图片模型 gpt-image-2',
}) as HTMLButtonElement;
expect(modelButton.disabled).toBe(true);
expect(modelButton.closest('div')?.className).toContain(
'image-canvas-editor__readonly-generation-option',
);
expect(modelButton.textContent).not.toContain('nanobanana2');
fireEvent.click(modelButton);
expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull();
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('renders option menus and shows mud point text in the submit button', () => {
@@ -189,9 +221,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
expect(
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
).toBeTruthy();
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
const submit = screen.getByRole('button', { name: '生成角色形象' });
expect(submit.textContent).toBe('生成12泥点');
});
@@ -219,9 +249,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
/>,
);
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
@@ -33,6 +33,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
cost?: number;
submitLabel?: string;
submitAriaLabel?: string;
lockedModel?: string;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
@@ -53,13 +54,16 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
function getImageDimensionOptions(model: string | null | undefined) {
return (
EDITOR_IMAGE_DIMENSION_OPTIONS[
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
(model ??
IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
);
}
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2);
const model = normalizeEditorImageModel(
dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2,
);
const options = getImageDimensionOptions(model);
const aspectRatios = options.aspectRatios as readonly string[];
const imageSizes = options.imageSizes as readonly string[];
@@ -113,19 +117,28 @@ export function ImageCanvasGenerationImageOptionsView({
cost,
submitLabel = '生成',
submitAriaLabel = '生成',
lockedModel,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
}: ImageCanvasGenerationImageOptionsViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const selection = normalizeImageDialogSelection(dialog);
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
: null;
const selection = normalizeImageDialogSelection(
normalizedLockedModel
? { ...dialog, imageModel: normalizedLockedModel }
: dialog,
);
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
const selectedSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: selection.aspectRatio,
imageSize: selection.imageSize,
});
const isGenerating = dialog.status === 'generating';
const isModelLocked = Boolean(normalizedLockedModel);
const labelPrefix = optionLabelPrefix ?? '生成图片';
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
@@ -257,24 +270,42 @@ export function ImageCanvasGenerationImageOptionsView({
: null}
</div>
) : null}
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
<div
className={[
'image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model',
isModelLocked
? 'image-canvas-editor__readonly-generation-option'
: '',
]
.filter(Boolean)
.join(' ')}
>
<PlatformInlineOptionButton
ref={modelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
aria-expanded={openPanel === 'model'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('model')}
aria-expanded={isModelLocked ? undefined : openPanel === 'model'}
disabled={isGenerating || isModelLocked}
trailingIcon={
isModelLocked ? undefined : <ChevronDown className="h-3 w-3" />
}
onClick={() => {
if (!isModelLocked) {
togglePanel('model');
}
}}
>
<span className="image-canvas-editor__model-trigger-label">
<span className="image-canvas-editor__model-icon" aria-hidden="true">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'model'
{!isModelLocked && openPanel === 'model'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
@@ -15,6 +15,7 @@ import {
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
@@ -167,20 +168,22 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
title: '图标规范 8',
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
{
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
);
});
it('treats the renamed UI spec entry as an icon spec asset', () => {
@@ -265,22 +268,62 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
rememberImageModel: 'gpt-image-2',
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
fields: [{ title: '角色设定', value: '白发骑士' }],
references: [
{
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-character-spec',
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
{
fields: [{ title: '角色设定', value: '白发骑士' }],
references: [
{
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-character-spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
},
);
});
it('locks UI design generation submissions to gpt-image-2', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'ui-design',
prompt: ' 设计移动端商城首页 ',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '0.5K',
uiDesignSpecReference: {
id: 'ui-spec',
label: 'UI图标规范',
src: 'data:image/png;base64,ui-spec',
resourceId: 'resource-ui-spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '设计移动端商城首页',
input: {
kind: 'ui-design',
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,ui-spec'],
},
result: {
assetKind: 'ui-design',
title: 'UI设计图 6',
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
});
@@ -607,7 +650,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds seedance video plans with multimodal references', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -788,7 +830,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: '角色绕场一周',
input: {
model: 'seedance2.0-fast',
priceMudPoints: calculateEditorVideoPrice('seedance2.0-fast', '480p', 4),
priceMudPoints: calculateEditorVideoPrice(
'seedance2.0-fast',
'480p',
4,
),
},
result: {
title: '生成视频 3',
@@ -846,7 +892,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: '金币掉落叮当声',
model: 'audio1.0',
duration: 7,
priceMudPoints: calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL),
priceMudPoints: calculateEditorSoundEffectPrice(
DEFAULT_SOUND_EFFECT_MODEL,
),
},
result: {
title: '游戏音效 4',
@@ -43,6 +43,7 @@ import {
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
@@ -68,6 +69,10 @@ export function isSeedanceEditorVideoModel(model: string) {
return isSeedanceVideoModel(model);
}
function normalizeGptImageSize(imageSize: string | null | undefined) {
return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K';
}
function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
// 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。
return references.reduce((sum, reference) => sum + reference.src.length, 0);
@@ -76,8 +81,10 @@ function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
function buildSeedanceVideoReferenceInput(
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
const resolveReferenceSource = (reference: { src: string; objectKey?: string | null }) =>
reference.objectKey?.trim() || reference.src;
const resolveReferenceSource = (reference: {
src: string;
objectKey?: string | null;
}) => reference.objectKey?.trim() || reference.src;
const imageReferences = references.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
@@ -97,7 +104,11 @@ function buildSeedanceVideoReferenceInput(
if (audioReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.audio) {
throw new Error('参考音频最多 3 个');
}
if (audioReferences.length && !imageReferences.length && !videoReferences.length) {
if (
audioReferences.length &&
!imageReferences.length &&
!videoReferences.length
) {
throw new Error('参考音频必须搭配参考图片或参考视频');
}
if (
@@ -202,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
if (dialog.mode === 'edit') {
const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId);
const sourceLayer = layers.find(
(layer) => layer.id === dialog.sourceLayerId,
);
if (!sourceLayer) {
throw new Error('未找到要修改的图片');
}
@@ -284,7 +297,7 @@ export function buildImageGenerationSubmissionPlan({
}
if (dialog.mode === 'ui-design') {
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter(
(src): src is string => Boolean(src),
);
@@ -296,7 +309,7 @@ export function buildImageGenerationSubmissionPlan({
kind: 'ui-design',
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
imageSize: normalizeGptImageSize(dialog.imageSize),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
@@ -448,7 +461,8 @@ export function buildImageGenerationSubmissionPlan({
},
result: {
title: `游戏背景音乐 ${nextGeneratedIndex}`,
generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt),
generationInputs:
buildBackgroundMusicGenerationInputs(normalizedPrompt),
},
};
}
@@ -484,9 +498,10 @@ export function buildImageGenerationSubmissionPlan({
export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
): IconSpritesheetGenerationSubmissionPlan {
const iconDescriptions = (dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,;/|]+/u)
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
const iconDescriptions = (
dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,;/|]+/u)
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
)
.map((description) => description.trim())
.filter(Boolean)
@@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
),
).toBe(true);
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成40泥点');
expect(submitButton.textContent).toBe('生成5泥点');
const dimensionsButton = screen.getByRole('button', {
name: '生成图片尺寸 16:9·2K',
}) as HTMLButtonElement;
@@ -135,19 +135,23 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
),
).toBe(true);
expect(panel.textContent).not.toContain('UI设计要求');
expect(prompt.getAttribute('placeholder')).toBe(
'你希望这个 UI 长什么样?',
);
expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?');
expect(prompt.className).toContain(
'image-canvas-editor__generation-prompt--borderless',
);
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
expect(submitButton.textContent).toBe('生成12泥点');
fireEvent.click(screen.getByRole('button', { name: '生成图片模型 nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByRole('button', { name: '生成UI设计图' }).textContent).toBe(
'生成20泥点',
const modelButton = screen.getByRole('button', {
name: '生成图片模型 gpt-image-2',
}) as HTMLButtonElement;
expect(submitButton.textContent).toBe('生成3泥点');
expect(modelButton.disabled).toBe(true);
expect(modelButton.closest('div')?.className).toContain(
'image-canvas-editor__readonly-generation-option',
);
fireEvent.click(modelButton);
expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull();
expect(screen.queryByText('nanobanana2')).toBeNull();
});
it('renders character spec fields and forwards updates', () => {
@@ -232,7 +236,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
expect(submitButton.textContent).toBe('生成40泥点');
expect(submitButton.textContent).toBe('生成5泥点');
});
it('disables controls while generating and renders failure state', () => {
@@ -249,9 +253,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).disabled).toBe(
true,
);
expect(
(screen.getByLabelText('玩法设定') as HTMLInputElement).disabled,
).toBe(true);
expect(submitSpec).not.toHaveBeenCalled();
rerender(
File diff suppressed because it is too large Load Diff