diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 863bcb53b..5cb81863d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-23 编辑器宣发素材固定 gpt-image-2 + +- 背景:画板宣发素材的游戏首图、详情五图和运营海报只应使用稳定的宣发图生成链路,不能被图片模型上次选择或旧请求切到 `nanobanana2`。 +- 决策:三个宣发素材工作流前端面板只显示禁用态 `gpt-image-2` 模型胶囊,生成提交固定携带 `gpt-image-2` 且不写入图片模型记忆;后端 `/api/editor/images/generations` 对 `kind = "publication-material"` 强制归一为 `gpt-image-2` 后再生成和按运行时模型定价扣费。`生成角色形象` 的默认图片模型继续使用 `nanobanana2`。 +- 影响范围:图片画布宣发素材面板、图片生成提交模型、编辑器图片 BFF、宣发素材设计文档和 Lovart 生成面板方案。 +- 验证方式:运行宣发素材提交模型 / 面板测试、`api-server` 宣发素材模型锁定测试、前端类型检查、编码检查和 `git diff --check`。 +- 关联文档:`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-06-22 编辑器生成模型默认定价调整 - 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 347df3aeb..77528b436 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,7 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 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 成功图。 +- 图片生成 / 修改统一经 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`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `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`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图`、`生成角色动作` 和快速编辑提交后创建的 `Quick Edit Generator`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;快速编辑提交时同样必须把避让后的 placeholder 写入独立生成占位,生成结果落在该占位位置。 @@ -68,7 +68,7 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md index 300e045dd..4f2aa5d21 100644 --- a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -2,7 +2,7 @@ ## 目标 -在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,按当前图片模型计费方案自动扣泥点,生成成功后把成品图片放回画布。 +在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,三个宣发工作流固定使用 `gpt-image-2` 计费并自动扣泥点,生成成功后把成品图片放回画布。 Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 UI 中默认暴露;真实生图请求会把结构化游戏输入和对应工作流约束合并为内部 Prompt。 @@ -14,7 +14,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U - 位置:底部 AI 画布工具栏 - 工具:`宣发素材`,位于底部工具栏 `生成UI设计图` 按钮右侧。 - 交互:点击工具后弹出独立浮层菜单,菜单项包括 `游戏首图`、`详情五图`、`运营海报`;点击菜单项后在画布中心创建对应工作流的生成占位卡片,卡片可拖拽,设置面板锚定在占位卡片下方。 -- 设置面板:三个工作流统一对齐生成角色面板结构,顶部为参考图横向槽,主体为游戏名、游戏分类、一句话描述游戏输入区,底部复用生成类面板的模型选择与生成按钮;生成按钮内显示当前模型对应泥点。 +- 设置面板:三个工作流统一对齐生成角色面板结构,顶部为参考图横向槽,主体为游戏名、游戏分类、一句话描述游戏输入区,底部复用生成类面板的禁用态模型胶囊与生成按钮;模型固定显示 `gpt-image-2`,不提供 `nanobanana2` 入口,生成按钮内显示 `gpt-image-2` 对应泥点。 - 标题:画布占位卡片左上角使用英文功能名,例如 `Home Image Generator`、`Detail Gallery Generator`、`Promo Poster Generator`,与其它生成卡片保持一致。 ## 三条独立工作流 @@ -43,8 +43,8 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U 本功能边界只负责图片编辑器里的宣发素材生成: - 可以维护:工作流框架、Prompt 约束、参考图解析约束、图片后处理验收约束。 -- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、按图片模型计费、成品回画布。 -- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次按当前图片模型计费;需要五图时连续生成并复用同一游戏输入和参考图。 +- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、固定 `gpt-image-2` 计费、成品回画布。 +- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次按 `gpt-image-2` 计费;需要五图时连续生成并复用同一游戏输入和参考图。 - 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。 - 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。 - 不修改:SpacetimeDB schema、前端发布页、作品发布系统。 @@ -52,7 +52,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U ## 生成与计费 - 前端生成请求统一通过 `/api/editor/images/generations`。 -- 宣发素材请求携带 `kind: publication-material` 与当前图片模型,后端对这个 kind 按归一后的图片模型计费方案扣泥点,不再使用固定价格。 +- 宣发素材请求携带 `kind: publication-material`,前端提交和后端 handler 都固定归一为 `gpt-image-2`;即使旧前端或外部请求传入 `nanobanana2`,后端也按 `gpt-image-2` 生成和计费,但价格仍来自运行时模型定价配置,不写死数值。 - `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。 - 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。 - 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index fbef055d6..d658c012c 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -67,6 +67,7 @@ - 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。 - 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。 - 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。 +- 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。 - 快速编辑默认从原图分辨率和模型初始化比例、尺寸和模型,但底部胶囊必须可点击修改;左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。 - 不再在底部常驻展开全部可选项。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6c7e306ed..5326c9610 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -920,15 +920,21 @@ pub(crate) async fn generate_editor_image_for_owner( ); } + let normalized_kind = payload.kind.as_deref().map(str::trim); + let is_character_generation = matches!(normalized_kind, Some("character")); + let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); + let is_publication_material_generation = + matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( - payload.model.as_deref(), + if is_ui_design_generation || is_publication_material_generation { + Some(GPT_IMAGE_2_MODEL) + } else { + payload.model.as_deref() + }, payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); - let normalized_kind = payload.kind.as_deref().map(str::trim); - let is_character_generation = matches!(normalized_kind, Some("character")); - let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let image_size = resolve_editor_image_request_size( normalized_kind, payload.size.as_deref(), @@ -989,15 +995,6 @@ pub(crate) async fn generate_editor_image_for_owner( .map(|source| parse_editor_reference_image(source.as_str())) .collect::, _>>()? }; - let generation_options = if is_ui_design_generation { - normalize_editor_generation_options( - Some(GPT_IMAGE_2_MODEL), - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ) - } else { - generation_options - }; let image_size = if is_ui_design_generation { Cow::Owned(generation_options.size.clone()) } else { @@ -2804,6 +2801,21 @@ mod tests { ); } + #[test] + fn publication_material_generation_is_locked_to_gpt_image_2() { + let source = include_str!("editor_project.rs"); + + assert_function_contains( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "let configured_price_mud_points", + &[ + "is_publication_material_generation", + "Some(GPT_IMAGE_2_MODEL)", + ], + ); + } + #[test] fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() { let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index bef1636d4..070daac06 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -31,6 +31,7 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, ICON_DESCRIPTION_LIMIT, + IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, normalizeEditorImageModel, @@ -39,6 +40,7 @@ import { resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; +import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; type CanvasSize = { width: number; height: number }; const VIDEO_REFERENCE_LIMITS = { @@ -298,6 +300,7 @@ export function createPublicationGenerationDialogDraft({ }): Omit { const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId]; + const workflow = getPublicationMaterialsWorkflow(workflowId); return { mode: 'publication', prompt: '', @@ -306,6 +309,9 @@ export function createPublicationGenerationDialogDraft({ publicationWorkflowId: workflowId, publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO }, publicationReferences: [], + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: workflow.aspectRatio, + imageSize: workflow.imageSize, placeholder: { x: worldCenter.x - frameOriginalSize.width / 2, y: worldCenter.y - frameOriginalSize.height / 2, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 13cf71936..3237de2a7 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -354,7 +354,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: expect.stringContaining('【宣发素材类型】游戏首图'), size: '720x540', kind: 'publication-material', - model: 'gemini-3.1-flash-image-preview', + model: 'gpt-image-2', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,ref'], @@ -380,7 +380,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }, }, - rememberImageModel: 'gemini-3.1-flash-image-preview', }); const prompt = plan.kind === 'image' ? plan.input.prompt : ''; expect(prompt).toContain('游戏名:重庆洪崖洞火锅'); @@ -467,7 +466,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ); }); - it('builds publication material plans with selected image model', () => { + it('locks publication material plans to gpt-image-2 and does not remember the fixed model', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', @@ -480,7 +479,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { gameDescription: '', }, publicationReferences: [], - imageModel: 'gpt-image-2', + imageModel: 'nanobanana2', }, layers: [], nextGeneratedIndex: 4, @@ -499,7 +498,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { imageSize: '1K', }), ); - expect(plan.rememberImageModel).toBe('gpt-image-2'); + expect(plan.rememberImageModel).toBeUndefined(); }); it('throws when edit source layer is missing', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index e51ff3a68..6de2a6425 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -322,7 +322,7 @@ export function buildImageGenerationSubmissionPlan({ const workflow = getPublicationMaterialsWorkflow( dialog.publicationWorkflowId ?? 'publication-cover-image', ); - const imageModel = normalizeEditorImageModel(dialog.imageModel); + const imageModel = IMAGE_MODEL_GPT_IMAGE_2; const publicationPrompt = buildPublicationMaterialsPrompt(dialog.publicationGameInfo) || normalizedPrompt; @@ -355,7 +355,6 @@ export function buildImageGenerationSubmissionPlan({ dialog.publicationReferences, ), }, - rememberImageModel: imageModel, }; } diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx new file mode 100644 index 000000000..acd252a8c --- /dev/null +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.test.tsx @@ -0,0 +1,71 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { + IMAGE_MODEL_NANOBANANA2, +} from './ImageCanvasGenerationModel'; +import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView'; +import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; + +function PublicationPanelHarness({ + onRememberImageModel = vi.fn(), +}: { + onRememberImageModel?: (model: string) => void; +}) { + const [dialog, setDialog] = useState({ + mode: 'publication', + prompt: '', + status: 'idle', + imageModel: IMAGE_MODEL_NANOBANANA2, + publicationWorkflowId: 'publication-cover-image', + publicationGameInfo: { + gameName: '测试游戏', + gameCategories: '休闲', + gameDescription: '点击消除三个一样的物品', + }, + publicationReferences: [], + }); + const workflow = getPublicationMaterialsWorkflow('publication-cover-image'); + + return dialog ? ( + undefined} + setIsPickingPublicationReferenceFromCanvas={() => undefined} + renderEditorPortal={(node) => node} + buildPortalMenuStyle={() => ({})} + onRequestUpload={() => undefined} + onRememberImageModel={onRememberImageModel} + onSubmit={() => undefined} + /> + ) : null; +} + +describe('ImageCanvasPublicationMaterialsDemoPanelView', () => { + it('locks publication material model selection to gpt-image-2', () => { + const rememberImageModel = vi.fn(); + render(); + + const modelButton = screen.getByRole('button', { + name: '宣发素材模型 gpt-image-2', + }) as HTMLButtonElement; + + expect(modelButton.disabled).toBe(true); + expect(modelButton.textContent).not.toContain('nanobanana2'); + fireEvent.click(modelButton); + expect(screen.queryByRole('menu', { name: '宣发素材模型选项' })).toBeNull(); + expect(rememberImageModel).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( + '生成3泥点', + ); + }); +}); diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 42dd4581f..35777e936 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -26,6 +26,7 @@ import { buildPublicationMaterialsPrompt, calculateEditorImageModelPrice, DEFAULT_PUBLICATION_GAME_INFO, + IMAGE_MODEL_GPT_IMAGE_2, } from './ImageCanvasGenerationModel'; import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; @@ -302,8 +303,12 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ setGenerateDialog={setGenerateDialog} includeDimensions={false} onRememberImageModel={onRememberImageModel} + lockedModel={IMAGE_MODEL_GPT_IMAGE_2} optionLabelPrefix="宣发素材" - cost={calculateEditorImageModelPrice(dialog.imageModel, workflow.imageSize)} + cost={calculateEditorImageModelPrice( + IMAGE_MODEL_GPT_IMAGE_2, + workflow.imageSize, + )} submitLabel="生成" submitAriaLabel="生成" renderEditorPortal={renderEditorPortal}