diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 1e1441550..91471a614 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -17,7 +17,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 +- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 20f4757e4..5b2a8d711 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -22,6 +22,7 @@ import { createIconGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditPanelDraft, + createRedrawPanelDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, @@ -263,7 +264,7 @@ describe('ImageCanvasGenerationDialogModel', () => { title: '游戏音效', mediaType: 'audio', assetKind: 'sound-effect', - prompt: '金币收集音', + prompt: '【系统内置】完整音效提示词:金币收集音,自动混音,内部参数。', generationInputs: { fields: [ { title: 'prompt', value: '金币收集音' }, @@ -315,20 +316,19 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull(); }); - it('uses audio prompt templates when remodeling old audio layers without prompts', () => { + it('keeps audio remodel prompts blank when no user input snapshot exists', () => { expect( createAudioRedrawGenerationDialogDraft( createLayer({ mediaType: 'audio', assetKind: 'sound-effect', - prompt: '', + prompt: '【系统内置】完整音效提示词', generationInputs: { fields: [], references: [] }, }), ), ).toMatchObject({ mode: 'audio-sound-effect', - prompt: - '短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。', + prompt: '', }); expect( createAudioRedrawGenerationDialogDraft( @@ -336,26 +336,33 @@ describe('ImageCanvasGenerationDialogModel', () => { mediaType: 'audio', assetKind: 'background-music', title: '游戏背景音乐', - prompt: '', - generationInputs: { fields: [], references: [] }, + prompt: '【系统内置】完整背景音乐提示词', + generationInputs: { + fields: [{ title: 'duration', value: '10秒' }], + references: [], + }, }), ), ).toMatchObject({ mode: 'audio-background-music', - prompt: '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。', + prompt: '', }); }); it('creates edit, quick-edit, and character animation panel drafts', () => { const sourceLayer = createLayer({ - prompt: '原图提示', + prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。', model: 'gpt-image-2', assetKind: 'character', + generationInputs: { + fields: [{ title: '生成提示词', value: '原图提示' }], + references: [], + }, }); expect(createEditDialogDraft(sourceLayer)).toEqual({ mode: 'edit', - prompt: '原图提示,在保持主体结构的基础上优化画面细节', + prompt: '原图提示', status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', @@ -371,6 +378,7 @@ describe('ImageCanvasGenerationDialogModel', () => { quickEditReferences: [], status: 'idle', }); + expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示'); expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({ sourceLayerId: 'layer-source', promptText: '', @@ -383,6 +391,86 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull(); }); + it('restores remodel prompts from user-visible generation input snapshots only', () => { + const cases: Array<{ + fields: NonNullable['fields']; + expectedPrompt: string; + }> = [ + { + fields: [{ title: '视频描述', value: '用户的视频描述' }], + expectedPrompt: '用户的视频描述', + }, + { + fields: [{ title: '角色设定', value: '用户的角色设定' }], + expectedPrompt: '用户的角色设定', + }, + { + fields: [{ title: '用户输入', value: '用户的 UI 需求' }], + expectedPrompt: '用户的 UI 需求', + }, + { + fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], + expectedPrompt: '返回按钮\n设置按钮', + }, + { + fields: [{ title: '自定义规范提示词', value: '用户自定义规范' }], + expectedPrompt: '用户自定义规范', + }, + { + fields: [ + { title: '玩法设定', value: '平台跳跃' }, + { title: '美术风格', value: '像素风' }, + ], + expectedPrompt: '玩法设定:平台跳跃\n美术风格:像素风', + }, + { + fields: [ + { title: '游戏名', value: '星海远征' }, + { title: '游戏分类', value: '冒险' }, + { title: '一句话描述游戏', value: '驾驶飞船探索星云' }, + ], + expectedPrompt: + '游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云', + }, + { + fields: [{ title: '动作描述', value: '待机动作' }], + expectedPrompt: '待机动作', + }, + ]; + + for (const testCase of cases) { + expect( + createRedrawPanelDraft( + createLayer({ + prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。', + generationInputs: { + fields: testCase.fields, + references: [], + }, + }), + ).prompt, + ).toBe(testCase.expectedPrompt); + } + + expect( + createRedrawPanelDraft( + createLayer({ + prompt: '【系统内置】固定素材提取提示词', + generationInputs: { + fields: [ + { + title: '提取提示词', + value: '仅提取被红色框框选的素材并整理成spritesheet', + }, + { title: 'model', value: 'gpt-image-2' }, + ], + references: [], + }, + }), + ).prompt, + ).toBe(''); + }); + it('normalizes legacy nanobanana model aliases in quick edit drafts', () => { expect( createQuickEditPanelDraft( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b05b7f638..bef1636d4 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -416,37 +416,62 @@ function findGenerationInputFieldValue( ); } -function resolveDefaultAudioPrompt( - mode: Extract< - GenerateDialogState['mode'], - 'audio-sound-effect' | 'audio-background-music' - >, -) { - return mode === 'audio-background-music' - ? DEFAULT_BACKGROUND_MUSIC_PROMPT - : DEFAULT_SOUND_EFFECT_PROMPT; +const USER_PROMPT_INPUT_TITLES = new Set( + [ + '生成提示词', + '视频描述', + 'prompt', + 'sound', + 'gpt_description_prompt', + '音效提示词', + '背景音乐提示词', + '角色设定', + '用户输入', + '素材描述', + '自定义规范提示词', + '修改要求', + '快速编辑提示词', + '重绘提示词', + '动作描述', + ].map((title) => title.toLowerCase()), +); + +const STRUCTURED_USER_INPUT_TITLES = new Set( + [ + '玩法设定', + '美术风格', + '头身比', + '角色视角', + '游戏名', + '游戏分类', + '一句话描述游戏', + ].map((title) => title.toLowerCase()), +); + +function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) { + const fields = sourceLayer.generationInputs?.fields ?? []; + const promptField = fields.find((field) => + USER_PROMPT_INPUT_TITLES.has(field.title.trim().toLowerCase()), + ); + const promptValue = promptField?.value.trim(); + if (promptValue) { + return promptValue; + } + + const structuredLines = fields.flatMap((field) => { + if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) { + return []; + } + const value = field.value.trim(); + return value ? [`${field.title.trim()}:${value}`] : []; + }); + return structuredLines.join('\n'); } function resolveAudioRedrawPrompt( sourceLayer: CanvasLayer, - mode: Extract< - GenerateDialogState['mode'], - 'audio-sound-effect' | 'audio-background-music' - >, ) { - return ( - sourceLayer.prompt?.trim() || - findGenerationInputFieldValue(sourceLayer, [ - 'prompt', - 'sound', - 'gpt_description_prompt', - '生成提示词', - '音效提示词', - '背景音乐提示词', - ])?.trim() || - sourceLayer.generationInputs?.fields[0]?.value?.trim() || - resolveDefaultAudioPrompt(mode) - ); + return resolveUserGenerationPromptSnapshot(sourceLayer); } function resolveAudioRedrawMode(sourceLayer: CanvasLayer) { @@ -493,7 +518,7 @@ export function createAudioRedrawGenerationDialogDraft( const baseDraft = { mode, sourceLayerId: sourceLayer.id, - prompt: resolveAudioRedrawPrompt(sourceLayer, mode), + prompt: resolveAudioRedrawPrompt(sourceLayer), status: 'idle' as const, composerOpen: true, placeholder: { @@ -562,9 +587,7 @@ export function createEditDialogDraft( ): GenerateDialogState { return { mode: 'edit', - prompt: sourceLayer.prompt - ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` - : '', + prompt: resolveUserGenerationPromptSnapshot(sourceLayer), status: 'idle', composerOpen: true, sourceLayerId: sourceLayer.id, @@ -646,10 +669,7 @@ export function createRedrawPanelDraft( return { ...createQuickEditPanelDraft(sourceLayer), mode: 'redraw', - prompt: - sourceLayer.prompt?.trim() || - sourceLayer.generationInputs?.fields[0]?.value?.trim() || - '', + prompt: resolveUserGenerationPromptSnapshot(sourceLayer), }; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index db5acb35b..288df5b06 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -168,6 +168,7 @@ function GenerationWorkflowHarness({ ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} + {activeDialog?.prompt || '-'} {dialogs.canvasGenerationDialogs .map( @@ -256,7 +257,7 @@ function GenerationWorkflowHarness({ height: 120, originalWidth: 420, originalHeight: 120, - prompt: '金币跳出音', + prompt: '【系统内置】完整音效提示词:金币跳出音,内部混音参数。', generationInputs: { fields: [ { title: 'prompt', value: '金币跳出音' }, @@ -296,6 +297,23 @@ function GenerationWorkflowHarness({ > 打开图片改造 +