Lock publication materials to gpt-image-2
Enforce gpt-image-2 for publication-material workflows to ensure stable, predictable promotional asset generation. Updates include: docs clarifying the decision and UI behaviour; backend api-server normalize/force model to GPT_IMAGE_2 for kind = "publication-material"; frontend sets publication dialogs to use and display a disabled gpt-image-2 model capsule and computes cost using that model; submission flow no longer remembers or submits nanobanana2 for publication items. Added/updated unit tests and a demo panel test to validate the locked model. Key files changed: docs/* (decision log and editor guides), server-rs/crates/api-server/src/editor_project.rs, several src/components/image-editor/* files and tests.
This commit is contained in:
@@ -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 编辑器生成模型默认定价调整
|
||||
|
||||
- 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 作为兼容兜底。
|
||||
|
||||
@@ -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`,右下角模型胶囊紧贴生成按钮。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
|
||||
@@ -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::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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<CanvasGenerationDialogState, 'id'> {
|
||||
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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GenerateDialogState | null>({
|
||||
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 ? (
|
||||
<ImageCanvasPublicationMaterialsDemoPanelView
|
||||
dialog={dialog}
|
||||
workflow={workflow}
|
||||
style={{}}
|
||||
publicationReferenceButtonRef={{ current: null }}
|
||||
isPublicationReferenceMenuOpen={false}
|
||||
setGenerateDialog={setDialog}
|
||||
setIsPublicationReferenceMenuOpen={() => 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(<PublicationPanelHarness onRememberImageModel={rememberImageModel} />);
|
||||
|
||||
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泥点',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user