diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 6f9dfa1d..5c882f3a 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -83,7 +83,7 @@ - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 - 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 -- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。 +- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。 - 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。 diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index c043bf49..30bc6820 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -132,10 +132,14 @@ export type GenerateDialogState = { generatedLayerId?: string; specType?: SpecGenerationType; specValues?: SpecFormValues; + specReference?: CharacterReferenceImage | null; characterSpecReference?: CharacterReferenceImage | null; characterReferences?: CharacterReferenceImage[]; iconSpecReference?: CharacterReferenceImage | null; iconDescriptions?: string[]; + imageModel?: string; + aspectRatio?: string; + imageSize?: string; errorMessage?: string; placeholder?: { x: number; @@ -215,6 +219,7 @@ export type CharacterAnimationPanelState = { export type UploadTarget = | 'asset' + | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec'; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index d47f77fd..82e98f07 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -2914,7 +2914,7 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', - model: 'gpt-image-2', + model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: expect.stringContaining('玩法设计:平台跳跃玩法'), }); @@ -3009,23 +3009,29 @@ describe('ImageCanvasEditorView', () => { name: '生成角色形象', }); expect(within(characterPanel).getByText('画面比例')).toBeTruthy(); + expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy(); expect(within(characterPanel).getByText('模型')).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '1:1' }), ).toBeTruthy(); expect( - within(characterPanel).getByRole('button', { name: 'GPT Image' }), + within(characterPanel).getByRole('button', { name: '1K' }), + ).toBeTruthy(); + expect( + within(characterPanel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(within(iconPanel).getByText('画面比例')).toBeTruthy(); + expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy(); expect(within(iconPanel).getByText('模型')).toBeTruthy(); expect( within(iconPanel).getByRole('button', { name: 'nanobanana2' }), ).toBeTruthy(); }); - it('submits character generation without legacy dimension options', async () => { + it('submits character generation with default model and dimension options', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character-model-options', width: 1024, @@ -3056,15 +3062,104 @@ describe('ImageCanvasEditorView', () => { expect.objectContaining({ kind: 'character', prompt: '高个子游侠', + model: 'gemini-3.1-flash-image-preview', + aspectRatio: '1:1', + imageSize: '1K', }), ); }); - expect(generateEditorImageMock.mock.calls[0]?.[0]).not.toEqual( - expect.objectContaining({ - aspectRatio: expect.any(String), - imageSize: expect.any(String), - }), + }); + + it('remembers the last selected image model for character and icon generation', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,character-gpt-model', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '蓝衣剑士', + actualPrompt: '蓝衣剑士', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'character-gpt-model-1', + }); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model', + spritesheetWidth: 1024, + spritesheetHeight: 1024, + iconImageSrcs: [ + { + name: '返回按钮', + imageSrc: 'data:image/png;base64,back', + width: 128, + height: 128, + }, + ], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'icon-gpt-model-1', + }); + render(); + await screen.findByAltText('画布图片:拼图素材'); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { + name: '生成角色形象', + }); + fireEvent.click( + within(characterPanel).getByRole('button', { name: 'gpt-image-2' }), ); + fireEvent.click(within(characterPanel).getByRole('button', { name: '2:3' })); + fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' })); + fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { + target: { value: '蓝衣剑士' }, + }); + fireEvent.click( + within(characterPanel).getByRole('button', { name: '生成' }), + ); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'character', + prompt: '蓝衣剑士', + model: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '2K', + }), + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect( + within(iconPanel).getByRole('button', { name: 'gpt-image-2' }), + ).toBeTruthy(); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标素材规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); + await userEvent.upload( + screen.getByLabelText('上传图片文件'), + new File(['icon-spec'], '图标规范.png', { type: 'image/png' }), + ); + fireEvent.click( + within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( + 'button', + { name: '生成' }, + ), + ); + + await waitFor(() => { + expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gpt-image-2', + aspectRatio: '1:1', + imageSize: '1K', + }), + ); + }); }); it('keeps the bottom AI toolbar visible while generation panels are open', () => { @@ -3239,6 +3334,18 @@ describe('ImageCanvasEditorView', () => { const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' }); expect(referenceRow?.contains(sourceMenu)).toBe(false); + expect(sourceMenu.className).toContain('platform-floating-menu--top-start'); + + fireEvent.click( + within(characterPanel).getByRole('button', { name: '上传常规参考图' }), + ); + const regularReferenceMenu = screen.getByRole('menu', { + name: '常规参考图来源', + }); + expect(referenceRow?.contains(regularReferenceMenu)).toBe(false); + expect(regularReferenceMenu.className).toContain( + 'platform-floating-menu--top-start', + ); }); it('uses Lovart-style reference tiles in the character generation panel', () => { @@ -3395,7 +3502,7 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', - model: 'gpt-image-2', + model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), }); @@ -3442,7 +3549,7 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'spec', - model: 'gpt-image-2', + model: 'gemini-3.1-flash-image-preview', size: '2048x1152', prompt: '生成一张武器图标规范展板', }); @@ -3504,15 +3611,51 @@ describe('ImageCanvasEditorView', () => { screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), ).toBeNull(); + const canvasReferenceLayer = screen + .getByAltText('画布图片:大鱼素材') + .closest('button')!; + expect(canvasReferenceLayer.className).not.toContain( + 'image-canvas-editor__layer--selected', + ); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); + const regularReferenceMenu = screen.getByRole('menu', { + name: '常规参考图来源', + }); + fireEvent.click( + within(regularReferenceMenu).getByRole('menuitem', { + name: '从画布中选择', + }), + ); + expect( + screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), + ).toBeTruthy(); + fireEvent.pointerDown(canvasReferenceLayer, { + button: 0, + pointerId: 171, + clientX: 180, + clientY: 120, + }); + expect( + screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), + ).toBeNull(); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + expect(canvasReferenceLayer.className).not.toContain( + 'image-canvas-editor__layer--selected', + ); + expect(within(characterPanel).getByText('1')).toBeTruthy(); + + fireEvent.click( + within(characterPanel).getByRole('button', { name: '上传常规参考图' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); await userEvent.upload( screen.getByLabelText('上传图片文件'), new File(['reference'], '常规参考.png', { type: 'image/png' }), ); await waitFor(() => { - expect(within(characterPanel).getByText('1')).toBeTruthy(); + expect(within(characterPanel).getByText('2')).toBeTruthy(); }); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { @@ -3525,8 +3668,12 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ kind: 'character', prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + model: 'gemini-3.1-flash-image-preview', + aspectRatio: '1:1', + imageSize: '1K', referenceImageSrcs: [ '/creation-type-references/puzzle.webp', + '/creation-type-references/big-fish.webp', expect.stringMatching(/^data:image\/png;base64,/u), ], }); @@ -3554,6 +3701,8 @@ describe('ImageCanvasEditorView', () => { expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy(); expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy(); expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy(); expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy(); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( @@ -3752,6 +3901,9 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({ referenceImageSrc: 'data:image/png;base64,icon-spec', iconDescriptions: ['返回按钮', '设置按钮'], + model: 'gemini-3.1-flash-image-preview', + aspectRatio: '1:1', + imageSize: '1K', }); await waitFor(() => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index b534a315..9519a4e4 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -82,6 +82,7 @@ export function ImageCanvasEditorView() { const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); + const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); @@ -466,8 +467,12 @@ export function ImageCanvasEditorView() { setIsSpecMenuOpen, isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen, + isCharacterReferenceMenuOpen, + setIsCharacterReferenceMenuOpen, isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, + setIsPickingCharacterReferenceFromCanvas, isIconSpecMenuOpen, setIsIconSpecMenuOpen, isPickingIconSpecFromCanvas, @@ -480,6 +485,7 @@ export function ImageCanvasEditorView() { openEditDialog, openQuickEditPanel, pickCharacterSpecFromLayer, + pickCharacterReferenceFromLayer, pickIconSpecFromLayer, submitIconSpritesheetGeneration, submitQuickEdit, @@ -488,6 +494,7 @@ export function ImageCanvasEditorView() { updateIconDescription, addIconDescription, updateCharacterAnimationDuration, + rememberImageModel, submitCharacterAnimation, hideGeneratedLayerPanelAfterBlur, closeGenerateComposer, @@ -605,9 +612,11 @@ export function ImageCanvasEditorView() { generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, + pickCharacterReferenceFromLayer, pickIconSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, @@ -636,7 +645,9 @@ export function ImageCanvasEditorView() { closeEditorChromePanels, setIsSpecMenuOpen, setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, + setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsSpacePanning, @@ -1064,11 +1075,16 @@ export function ImageCanvasEditorView() { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 444c363f..a4f57e2a 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -35,7 +35,10 @@ import { CHARACTER_ANIMATION_RATIO_OPTIONS, CHARACTER_SPEC_VIEW_OPTIONS, DEFAULT_ICON_DESCRIPTIONS, + EDITOR_IMAGE_DIMENSION_OPTIONS, + EDITOR_IMAGE_MODEL_OPTIONS, ICON_DESCRIPTION_LIMIT, + IMAGE_MODEL_NANOBANANA2, SPEC_GENERATION_COST, SPEC_TYPE_LABEL, } from './ImageCanvasGenerationModel'; @@ -52,11 +55,14 @@ import type { type ImageCanvasGenerationComposerViewProps = { specToolWrapRef: RefObject; characterSpecButtonRef: RefObject; + characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; isSpecMenuOpen: boolean; isCharacterSpecMenuOpen: boolean; + isCharacterReferenceMenuOpen: boolean; isIconSpecMenuOpen: boolean; isPickingCharacterSpecFromCanvas: boolean; + isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; generateDialog: GenerateDialogState | null; generationComposerStyle: CSSProperties | null; @@ -76,8 +82,10 @@ type ImageCanvasGenerationComposerViewProps = { SetStateAction >; setIsCharacterSpecMenuOpen: Dispatch>; + setIsCharacterReferenceMenuOpen: Dispatch>; setIsIconSpecMenuOpen: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; + setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; @@ -90,6 +98,7 @@ type ImageCanvasGenerationComposerViewProps = { onUpdateIconDescription: (index: number, value: string) => void; onAddIconDescription: () => void; onUpdateCharacterAnimationDuration: (frameCountValue: string) => void; + onRememberImageModel: (model: string) => void; }; function triggerPlaceholderAction(label: string) { @@ -152,14 +161,161 @@ function resetFailedPanelStatus size === '1K') ?? options.imageSizes[0]), + options, + }; +} + +function renderImageOptionButtons({ + dialog, + setGenerateDialog, + includeDimensions, + onRememberImageModel, +}: { + dialog: GenerateDialogState; + setGenerateDialog: Dispatch>; + includeDimensions: boolean; + onRememberImageModel: (model: string) => void; +}) { + const selection = normalizeImageDialogSelection(dialog); + const updateDialog = (patch: Partial) => { + setGenerateDialog((currentDialog) => + currentDialog && currentDialog.mode === dialog.mode + ? { + ...resetFailedDialogStatus(currentDialog), + ...patch, + } + : currentDialog, + ); + }; + + return ( + <> + {includeDimensions ? ( + <> +
+ + 画面比例 + +
+ {selection.options.aspectRatios.map((aspectRatio) => ( + updateDialog({ aspectRatio })} + > + {aspectRatio} + + ))} +
+
+
+ + 大小尺寸 + +
+ {selection.options.imageSizes.map((imageSize) => ( + updateDialog({ imageSize })} + > + {imageSize} + + ))} +
+
+ + ) : null} +
+ + 模型 + +
+ {EDITOR_IMAGE_MODEL_OPTIONS.map((option) => { + const nextOptions = getImageDimensionOptions(option.value); + const nextAspectRatios = nextOptions.aspectRatios as readonly string[]; + const nextImageSizes = nextOptions.imageSizes as readonly string[]; + return ( + { + onRememberImageModel(option.value); + updateDialog({ + imageModel: option.value, + aspectRatio: + dialog.aspectRatio && + nextAspectRatios.includes(dialog.aspectRatio) + ? dialog.aspectRatio + : nextOptions.aspectRatios[0], + imageSize: + dialog.imageSize && + nextImageSizes.includes(dialog.imageSize) + ? dialog.imageSize + : (nextOptions.imageSizes.find((size) => size === '1K') ?? + nextOptions.imageSizes[0]), + }); + }} + > + {option.label} + + ); + })} +
+
+ + ); +} + export function ImageCanvasGenerationComposerView({ specToolWrapRef, characterSpecButtonRef, + characterReferenceButtonRef, iconSpecButtonRef, isSpecMenuOpen, isCharacterSpecMenuOpen, + isCharacterReferenceMenuOpen, isIconSpecMenuOpen, isPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, generateDialog, generationComposerStyle, @@ -177,8 +333,10 @@ export function ImageCanvasGenerationComposerView({ setQuickEditPanel, setCharacterAnimationPanel, setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, setIsPickingCharacterSpecFromCanvas, + setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, onOpenSpecDialog, onRequestUpload, @@ -191,6 +349,7 @@ export function ImageCanvasGenerationComposerView({ onUpdateIconDescription, onAddIconDescription, onUpdateCharacterAnimationDuration, + onRememberImageModel, }: ImageCanvasGenerationComposerViewProps) { return ( <> @@ -544,10 +703,10 @@ export function ImageCanvasGenerationComposerView({ onRequestUpload('character-reference')} + onClick={() => + setIsCharacterReferenceMenuOpen((open) => !open) + } > + {isCharacterReferenceMenuOpen + ? renderEditorPortal( + + { + setIsPickingCharacterReferenceFromCanvas(true); + setIsCharacterReferenceMenuOpen(false); + }} + > + 从画布中选择 + + { + setIsCharacterReferenceMenuOpen(false); + onRequestUpload('character-reference'); + }} + > + 上传图片 + + , + ) + : null} @@ -657,36 +851,12 @@ export function ImageCanvasGenerationComposerView({ ) : null}
-
- - 画面比例 - - triggerPlaceholderAction('角色比例')} - > - 1:1 - -
-
- - 模型 - - triggerPlaceholderAction('角色模型')} - > - GPT Image - -
+ {renderImageOptionButtons({ + dialog: generateDialog, + setGenerateDialog, + includeDimensions: true, + onRememberImageModel, + })} 添加素材描述 -
- - 模型 - - triggerPlaceholderAction('图标模型')} - > - nanobanana2 - -
+ {renderImageOptionButtons({ + dialog: generateDialog, + setGenerateDialog, + includeDimensions: true, + onRememberImageModel, + })} ) : null} + {isPickingCharacterReferenceFromCanvas ? ( +
+ 请选择画布中的图片作为常规参考图,按 Esc 退出 +
+ ) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标素材规范,按 Esc 退出 diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 59286b2d..b6c565ad 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -104,10 +104,46 @@ describe('ImageCanvasGenerationModel', () => { .toBe('自定义'); expect(buildQuickEditModelOptions('nano-banana')).toEqual([ { label: 'nano-banana', value: 'nano-banana' }, - { label: 'GPT Image', value: DEFAULT_IMAGE_MODEL }, + { label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL }, + { label: 'gpt-image-2', value: 'gpt-image-2' }, ]); }); + it('adds reference image semantics and snapshots for spec generation references', () => { + const prompt = buildSpecPrompt( + 'ui', + { + playSetting: '消除玩法', + artStyle: '清爽卡通', + bodyRatio: '3', + characterView: '', + customPrompt: '', + }, + true, + ); + + expect(prompt).toContain('参考图生成规范'); + expect(prompt).toContain('参考图1'); + expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板'); + expect( + buildSpecPrompt( + 'custom', + { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, + true, + ), + ).toContain('生成一张怪兽规范图'); + expect( + buildSpecGenerationInputs( + 'custom', + { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, + { id: 'spec-ref', label: '参考.png', src: '/ref.png' }, + ), + ).toEqual({ + fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }], + references: [{ title: '参考图', label: '参考.png', src: '/ref.png' }], + }); + }); + it('uses objectKey for character animation references before falling back to src', () => { expect( resolveCharacterAnimationSourceImageSrc({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 88ee13b4..7a119efc 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -23,7 +23,9 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; -export const DEFAULT_IMAGE_MODEL = 'gpt-image-2'; +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; export const ICON_DESCRIPTION_LIMIT = 100; // 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。 export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4; @@ -44,8 +46,20 @@ export const QUICK_EDIT_SIZE_PRESETS = [ '1024x1536', ] as const; export const QUICK_EDIT_MODEL_OPTIONS = [ - { label: 'GPT Image', value: DEFAULT_IMAGE_MODEL }, + { label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 }, + { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, ] as const; +export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS; +export const EDITOR_IMAGE_DIMENSION_OPTIONS = { + [IMAGE_MODEL_NANOBANANA2]: { + aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'], + imageSizes: ['0.5K', '1K', '2K'], + }, + [IMAGE_MODEL_GPT_IMAGE_2]: { + aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'], + imageSizes: ['1K', '2K'], + }, +} as const; export const CHARACTER_ANIMATION_MODEL = 'seedance2.0'; export const CHARACTER_ANIMATION_ACTION_PROMPTS = [ { label: '待机', text: '待机动作,轻微呼吸起伏。' }, @@ -169,17 +183,23 @@ export function buildIconSpecPrompt(values: SpecFormValues) { export function buildSpecPrompt( type: SpecGenerationType, values: SpecFormValues, + hasReferenceImage = false, ) { - if (type === 'character') { - return buildCharacterSpecPrompt(values); + const prompt = + type === 'character' + ? buildCharacterSpecPrompt(values) + : type === 'ui' + ? buildUiSpecPrompt(values) + : type === 'icon' + ? buildIconSpecPrompt(values) + : values.customPrompt.trim(); + if (!hasReferenceImage) { + return prompt; } - if (type === 'ui') { - return buildUiSpecPrompt(values); - } - if (type === 'icon') { - return buildIconSpecPrompt(values); - } - return values.customPrompt.trim(); + return [ + '参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。', + prompt, + ].join('\n'); } export function getLayerKindLabel(layer: CanvasLayer) { @@ -254,11 +274,15 @@ export function buildImageGenerationInputs(prompt: string): CanvasGenerationInpu export function buildSpecGenerationInputs( specType: SpecGenerationType, values: SpecFormValues, + reference?: CharacterReferenceImage | null, ): CanvasGenerationInputs { + const references = reference + ? [{ title: '参考图', label: reference.label, src: reference.src }] + : []; if (specType === 'custom') { return { fields: createGenerationInputField('自定义规范提示词', values.customPrompt), - references: [], + references, }; } @@ -274,7 +298,7 @@ export function buildSpecGenerationInputs( } return { fields: baseFields, - references: [], + references, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index f321d1d2..c36313ac 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -30,6 +30,7 @@ import { DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, DEFAULT_SPEC_FORM_VALUES, + EDITOR_IMAGE_DIMENSION_OPTIONS, ICON_DESCRIPTION_LIMIT, ICON_FRAME_DISPLAY_SIZE, ICON_FRAME_ORIGINAL_SIZE, @@ -154,10 +155,16 @@ export function useImageCanvasGenerationWorkflow({ const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); + const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] = + useState(false); const [ isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, ] = useState(false); + const [ + isPickingCharacterReferenceFromCanvas, + setIsPickingCharacterReferenceFromCanvas, + ] = useState(false); const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(false); @@ -165,6 +172,7 @@ export function useImageCanvasGenerationWorkflow({ useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); + const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL); const quickEditSourceLayer = quickEditPanel ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? @@ -278,7 +286,13 @@ export function useImageCanvasGenerationWorkflow({ const openCharacterGenerationDialog = useCallback(() => { const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); setIsSpecMenuOpen(false); + setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); + const dimensionOptions = + EDITOR_IMAGE_DIMENSION_OPTIONS[ + lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; openCanvasGenerationDialog({ mode: 'character', prompt: '', @@ -286,6 +300,11 @@ export function useImageCanvasGenerationWorkflow({ composerOpen: true, characterSpecReference: null, characterReferences: [], + imageModel: lastImageModel, + aspectRatio: dimensionOptions.aspectRatios[0], + imageSize: + dimensionOptions.imageSizes.find((size) => size === '1K') ?? + dimensionOptions.imageSizes[0], placeholder: { x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2, @@ -300,6 +319,7 @@ export function useImageCanvasGenerationWorkflow({ setQuickEditPanel(null); }, [ canvasSize, + lastImageModel, openCanvasGenerationDialog, selectSingleLayer, setActiveTool, @@ -309,8 +329,14 @@ export function useImageCanvasGenerationWorkflow({ const openIconGenerationDialog = useCallback(() => { const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); setIsSpecMenuOpen(false); + setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); setIsPickingIconSpecFromCanvas(false); + const dimensionOptions = + EDITOR_IMAGE_DIMENSION_OPTIONS[ + lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; openCanvasGenerationDialog({ mode: 'icon', prompt: '', @@ -318,6 +344,11 @@ export function useImageCanvasGenerationWorkflow({ composerOpen: true, iconSpecReference: null, iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], + imageModel: lastImageModel, + aspectRatio: dimensionOptions.aspectRatios[0], + imageSize: + dimensionOptions.imageSizes.find((size) => size === '1K') ?? + dimensionOptions.imageSizes[0], placeholder: { x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2, y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2, @@ -333,6 +364,7 @@ export function useImageCanvasGenerationWorkflow({ setCharacterAnimationPanel(null); }, [ canvasSize, + lastImageModel, openCanvasGenerationDialog, selectSingleLayer, setActiveTool, @@ -543,6 +575,26 @@ export function useImageCanvasGenerationWorkflow({ [setGenerateDialog, setImageContextMenu], ); + const pickCharacterReferenceFromLayer = useCallback( + (layer: CanvasLayer) => { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...setFailedCharacterGenerationIdle(currentDialog), + characterReferences: [ + ...(currentDialog.characterReferences ?? []), + createCanvasLayerReference(layer), + ], + composerOpen: true, + } + : currentDialog, + ); + setIsPickingCharacterReferenceFromCanvas(false); + setImageContextMenu(null); + }, + [setGenerateDialog, setImageContextMenu], + ); + const pickIconSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (layer.assetKind !== 'icon-spec') { @@ -654,7 +706,11 @@ export function useImageCanvasGenerationWorkflow({ const generated = await generateEditorIconSpritesheet({ referenceImageSrc: dialog.iconSpecReference.src, iconDescriptions, + model: dialog.imageModel ?? DEFAULT_IMAGE_MODEL, + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', }); + setLastImageModel(dialog.imageModel ?? DEFAULT_IMAGE_MODEL); addIconSpritesheetResultLayers( generated, generated.iconImageSrcs, @@ -795,8 +851,12 @@ export function useImageCanvasGenerationWorkflow({ const generated = await generateEditorImage({ prompt: normalizedPrompt, kind: 'character', + model: dialog.imageModel ?? DEFAULT_IMAGE_MODEL, + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }); + setLastImageModel(dialog.imageModel ?? DEFAULT_IMAGE_MODEL); addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), assetKind: 'character', @@ -1021,8 +1081,12 @@ export function useImageCanvasGenerationWorkflow({ setIsSpecMenuOpen, isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen, + isCharacterReferenceMenuOpen, + setIsCharacterReferenceMenuOpen, isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, + setIsPickingCharacterReferenceFromCanvas, isIconSpecMenuOpen, setIsIconSpecMenuOpen, isPickingIconSpecFromCanvas, @@ -1035,6 +1099,7 @@ export function useImageCanvasGenerationWorkflow({ openEditDialog, openQuickEditPanel, pickCharacterSpecFromLayer, + pickCharacterReferenceFromLayer, pickIconSpecFromLayer, submitIconSpritesheetGeneration, submitQuickEdit, @@ -1043,6 +1108,7 @@ export function useImageCanvasGenerationWorkflow({ updateIconDescription, addIconDescription, updateCharacterAnimationDuration, + rememberImageModel: setLastImageModel, submitCharacterAnimation, hideGeneratedLayerPanelAfterBlur, closeGenerateComposer, @@ -1057,8 +1123,10 @@ export function useImageCanvasGenerationWorkflow({ closeGenerateComposer, hideGeneratedLayerPanelAfterBlur, iconDescriptionValues, + isCharacterReferenceMenuOpen, isCharacterSpecMenuOpen, isIconSpecMenuOpen, + isPickingCharacterReferenceFromCanvas, isPickingCharacterSpecFromCanvas, isPickingIconSpecFromCanvas, isSpecMenuOpen, @@ -1069,6 +1137,7 @@ export function useImageCanvasGenerationWorkflow({ openIconGenerationDialog, openQuickEditPanel, openSpecDialog, + pickCharacterReferenceFromLayer, pickCharacterSpecFromLayer, pickIconSpecFromLayer, quickEditModelOptions, diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 5071864f..1f44b871 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -79,8 +79,14 @@ function KeyboardShortcutsHarness({ const [contextMenuOpen, setContextMenuOpen] = useState(true); const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true); + const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] = + useState(true); const [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] = useState(true); + const [ + isPickingCharacterReferenceFromCanvas, + setIsPickingCharacterReferenceFromCanvas, + ] = useState(true); const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(true); @@ -111,7 +117,9 @@ function KeyboardShortcutsHarness({ closeEditorChromePanels, setIsSpecMenuOpen, setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, + setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsSpacePanning, @@ -138,9 +146,15 @@ function KeyboardShortcutsHarness({ {String(isCharacterSpecMenuOpen)} + + {String(isCharacterReferenceMenuOpen)} + {String(isPickingCharacterSpecFromCanvas)} + + {String(isPickingCharacterReferenceFromCanvas)} + {String(isIconSpecMenuOpen)} {String(isPickingIconSpecFromCanvas)} diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index eca38901..ed2fd479 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -31,7 +31,9 @@ type UseImageCanvasKeyboardShortcutsOptions = { closeEditorChromePanels: () => void; setIsSpecMenuOpen: (open: boolean) => void; setIsCharacterSpecMenuOpen: (open: boolean) => void; + setIsCharacterReferenceMenuOpen: (open: boolean) => void; setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void; + setIsPickingCharacterReferenceFromCanvas: (picking: boolean) => void; setIsIconSpecMenuOpen: (open: boolean) => void; setIsPickingIconSpecFromCanvas: (picking: boolean) => void; setIsSpacePanning: (panning: boolean) => void; @@ -77,7 +79,9 @@ export function useImageCanvasKeyboardShortcuts({ closeEditorChromePanels, setIsSpecMenuOpen, setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, + setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, setIsPickingIconSpecFromCanvas, setIsSpacePanning, @@ -93,7 +97,9 @@ export function useImageCanvasKeyboardShortcuts({ currentPanel?.status === 'generating' ? currentPanel : null, ); setIsCharacterSpecMenuOpen(false); + setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); setIsIconSpecMenuOpen(false); setIsPickingIconSpecFromCanvas(false); setGenerateDialog((currentDialog) => { @@ -149,7 +155,9 @@ export function useImageCanvasKeyboardShortcuts({ setGenerateDialog(null); setActiveTool('select'); setIsCharacterSpecMenuOpen(false); + setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); setIsIconSpecMenuOpen(false); setIsPickingIconSpecFromCanvas(false); return; @@ -193,7 +201,9 @@ export function useImageCanvasKeyboardShortcuts({ setGenerateDialog, setImageContextMenu, setIsCharacterSpecMenuOpen, + setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, + setIsPickingCharacterReferenceFromCanvas, setIsPickingCharacterSpecFromCanvas, setIsPickingIconSpecFromCanvas, setIsSpacePanning, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 2d89bcfc..df67f505 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -175,6 +175,7 @@ function StageInteractionsHarness({ generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas: false, + isPickingCharacterReferenceFromCanvas: false, isPickingIconSpecFromCanvas: false, clearCanvasFocus: () => { setSelectedLayerId(null); @@ -182,6 +183,7 @@ function StageInteractionsHarness({ setClearCount((currentCount) => currentCount + 1); }, pickCharacterSpecFromLayer, + pickCharacterReferenceFromLayer: vi.fn(), pickIconSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById: (dialogId, updater) => { diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 2507f19f..46dbf0fd 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -45,9 +45,11 @@ type UseImageCanvasStageInteractionsOptions = { generateDialog: GenerateDialogState | null; setGenerateDialog: Dispatch>; isPickingCharacterSpecFromCanvas: boolean; + isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; clearCanvasFocus: () => void; pickCharacterSpecFromLayer: (layer: CanvasLayer) => void; + pickCharacterReferenceFromLayer: (layer: CanvasLayer) => void; pickIconSpecFromLayer: (layer: CanvasLayer) => void; activateCanvasGenerationDialog: ( dialog: CanvasGenerationDialogState, @@ -126,9 +128,11 @@ export function useImageCanvasStageInteractions({ generateDialog, setGenerateDialog, isPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, + pickCharacterReferenceFromLayer, pickIconSpecFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, @@ -139,6 +143,7 @@ export function useImageCanvasStageInteractions({ }: UseImageCanvasStageInteractionsOptions) { const dragStateRef = useRef(null); const isShiftPressedRef = useRef(false); + const suppressNextLayerClickRef = useRef(false); const [canvasMarquee, setCanvasMarquee] = useState( null, ); @@ -230,12 +235,24 @@ export function useImageCanvasStageInteractions({ ) { event.preventDefault(); event.stopPropagation(); + suppressNextLayerClickRef.current = true; pickCharacterSpecFromLayer(layer); return; } + if ( + isPickingCharacterReferenceFromCanvas && + generateDialog?.mode === 'character' + ) { + event.preventDefault(); + event.stopPropagation(); + suppressNextLayerClickRef.current = true; + pickCharacterReferenceFromLayer(layer); + return; + } if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') { event.preventDefault(); event.stopPropagation(); + suppressNextLayerClickRef.current = true; pickIconSpecFromLayer(layer); return; } @@ -302,8 +319,10 @@ export function useImageCanvasStageInteractions({ effectiveTool, generateDialog?.mode, isPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, layers, + pickCharacterReferenceFromLayer, pickCharacterSpecFromLayer, pickIconSpecFromLayer, selectedLayerIds, @@ -320,9 +339,16 @@ export function useImageCanvasStageInteractions({ // 测试环境和辅助技术可能只触发 click; // 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。 event.stopPropagation(); + if (suppressNextLayerClickRef.current) { + suppressNextLayerClickRef.current = false; + return; + } if (isPickingCharacterSpecFromCanvas) { return; } + if (isPickingCharacterReferenceFromCanvas) { + return; + } if (isPickingIconSpecFromCanvas) { return; } @@ -346,6 +372,7 @@ export function useImageCanvasStageInteractions({ }, [ isPickingCharacterSpecFromCanvas, + isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, onCloseImageContextMenu, setGenerateDialog,