codex/editor-publication-materials-demo #66

Merged
kdletters merged 7 commits from codex/editor-publication-materials-demo into codex/editor-asset-library 2026-06-18 22:52:00 +08:00
38 changed files with 1614 additions and 45 deletions
+1 -1
View File
@@ -4,12 +4,12 @@
- 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员和各自本地 Agent 之间同步长期项目知识。
- [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。
- 开始复杂开发任务前,除阅读本文件外,还应优先读取:
- [`.hermes/README.md`](.hermes/README.md)
- [`docs/project-memory/README.md`](docs/project-memory/README.md)
- [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md)
- [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)
- [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)
- 与任务相关的 [`docs/project-memory/shared-memory/decision-log.md`](docs/project-memory/shared-memory/decision-log.md) 和 [`docs/project-memory/shared-memory/pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)
- 仅在需要使用仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md);长期项目记忆不要从 `.hermes/` 读取。
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件。
- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
-`docs/project-memory/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
@@ -0,0 +1,77 @@
# 宣发素材工具入口设计
## 目标
在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,自动扣 5 泥点,生成成功后把成品图片放回画布。
Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 UI 中默认暴露;真实生图请求会把结构化游戏输入和对应工作流约束合并为内部 Prompt。
三个工作流的输入框默认提示统一为:游戏名 `马戏团午夜惊魂`,游戏分类 `非对称对抗`,一句话描述游戏 `神奇数字马戏团里,帕姆尼失控了,找到钥匙,开门逃离马戏团,千万别被抓住了`。提示只作为占位文案,用户未填写时不进入生图 Prompt。
## 入口
- 页面:`/editor/canvas`
- 位置:底部 AI 画布工具栏
- 工具:`宣发素材`
- 交互:点击工具后弹出独立浮层菜单,菜单项包括 `游戏首图``详情五图``运营海报`;点击菜单项后在画布中心创建对应工作流的生成占位卡片,卡片可拖拽,设置面板锚定在占位卡片下方。
- 标题:画布占位卡片左上角使用英文功能名,例如 `Home Image Generator``Detail Gallery Generator``Promo Poster Generator`,与其它生成卡片保持一致。
## 三条独立工作流
1. `publication-cover-image`
- 名称:游戏首图
- 输出:单张主视觉
- 尺寸:`720 x 540`
- 适用:推荐流、作品卡和首屏传播位
2. `publication-detail-gallery`
- 名称:详情五图
- 输出:单张详情图;需要五图时由用户连续生成五次,保持同一参考摘要和输入信息
- 尺寸:`720 x 1280``9:16` 竖版
- 适用:作品详情、应用介绍和运营分发图组
3. `publication-promo-poster`
- 名称:运营海报
- 输出:单张活动海报
- 尺寸:`1280 x 720``16:9` 横版
- 版式:右下角保留白色二维码留白方块,并在方块内部上方加入黑色小字提示“此处换上你的游戏二维码”
- 适用:社群、活动、渠道投放和运营节日位
## 职责边界
本功能边界只负责图片编辑器里的宣发素材生成:
- 可以维护:工作流框架、Prompt 约束、参考图解析约束、图片后处理验收约束。
- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、5 泥点扣费、成品回画布。
- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次扣 5 泥点;需要五图时连续生成并复用同一游戏输入和参考图。
- 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。
- 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。
- 不修改:SpacetimeDB schema、前端发布页、作品发布系统。
## 生成与计费
- 前端生成请求统一通过 `/api/editor/images/generations`
- 宣发素材请求携带 `kind: publication-material`,后端仅对这个 kind 扣 5 泥点。
- `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 作为兼容兜底。
- 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。
- 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。
## 代码位置
- 工具入口:`src/components/image-editor/ImageCanvasBottomToolbarView.tsx`
- 菜单和生成卡片挂载:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx`
- 工作流状态:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`
- 工作流内容模型:`src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts`
- 画布内生成卡片设置面板:`src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx`
## 验证命令
```bash
npm run test -- src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts
cargo test -p platform-image vector_engine --manifest-path server-rs/Cargo.toml
cargo test -p api-server editor_image_generation_accepts_reference_body_above_default_limit --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
npm run check:encoding
```
@@ -110,4 +110,3 @@ POST /api/editor/audios/background-music/generations
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放。
- 刷新后 layout 能恢复音频生成器和音频图层。
@@ -122,7 +122,7 @@ server-rs + Axum + SpacetimeDB
- Issue tracker 是自托管 Gitea。可用 Gitea UI/API 或 `tea` CLI;不要用 GitHub `gh` 或 GitLab `glab`
- 默认 triage labels`needs-triage``needs-info``ready-for-agent``ready-for-human``wontfix`
-`CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。
- `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。
- `.hermes/` 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。
- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`
## 当前文档策略
+44
View File
@@ -1516,6 +1516,50 @@ mod tests {
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn editor_image_generation_accepts_reference_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body");
let app = build_router(state);
let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
let request_body = serde_json::json!({
"prompt": "生成宣发素材",
"kind": "publication-material",
"size": "720x540",
"referenceImageSrcs": [reference_image_src],
})
.to_string();
assert!(request_body.len() > 2 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("VECTOR_ENGINE_BASE_URL"),
"handler should parse the oversized editor reference before checking VectorEngine config: {body_text}"
);
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn password_entry_rejects_unknown_phone_without_registration() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -7,6 +7,7 @@ const EDITOR_SPEC_GENERATION_MUD_POINTS: u32 = 5;
const EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_UI_DESIGN_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS: u32 = 5;
pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
@@ -20,6 +21,7 @@ pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 {
Some("character") => EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS,
Some("icon") => EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS,
Some("ui-design") => EDITOR_UI_DESIGN_GENERATION_MUD_POINTS,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS,
_ => EDITOR_IMAGE_GENERATION_MUD_POINTS,
}
}
@@ -68,5 +70,9 @@ mod tests {
assert_eq!(editor_image_generation_mud_points(Some("character")), 12);
assert_eq!(editor_image_generation_mud_points(Some("icon")), 12);
assert_eq!(editor_image_generation_mud_points(Some("ui-design")), 12);
assert_eq!(
editor_image_generation_mud_points(Some("publication-material")),
5
);
}
}
@@ -32,6 +32,7 @@ use spacetime_client::{
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -63,6 +64,7 @@ const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -775,10 +777,12 @@ pub async fn generate_editor_image(
legacy_size
};
let normalized_kind = payload.kind.as_deref().map(str::trim);
let _configured_price_mud_points =
let configured_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(normalized_kind);
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 submitted_prompt = if is_character_generation {
build_editor_character_image_prompt(role_setting.as_str())
} else if is_ui_design_generation {
@@ -799,6 +803,7 @@ pub async fn generate_editor_image(
Some("spec") => "图片画布生成规范",
Some("quick-edit") => "图片画布快速编辑图片",
Some("ui-design") => "图片画布生成UI设计图",
Some("publication-material") => "图片画布生成宣发素材",
_ => "图片画布生成图片",
};
let reference_sources = payload
@@ -838,7 +843,8 @@ pub async fn generate_editor_image(
} else {
image_size
};
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
let generate_operation = async {
if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
@@ -850,8 +856,8 @@ pub async fn generate_editor_image(
reference_images.as_slice(),
failure_context,
)
.await?
} else if reference_images.is_empty() {
.await
} else if reference_images.is_empty() {
create_openai_image_generation_with_model(
&http_client,
&settings,
@@ -863,8 +869,8 @@ pub async fn generate_editor_image(
&[],
failure_context,
)
.await?
} else {
.await
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
@@ -876,7 +882,21 @@ pub async fn generate_editor_image(
reference_images.as_slice(),
failure_context,
)
.await
}
};
let generated = if is_publication_material_generation {
execute_billable_asset_operation_with_cost(
&state,
authenticated.claims().user_id(),
EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
request_context.request_id(),
u64::from(configured_price_mud_points),
generate_operation,
)
.await?
} else {
generate_operation.await?
};
let mut image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
@@ -1,5 +1,7 @@
use axum::{
Router, middleware,
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
};
@@ -16,6 +18,8 @@ use crate::{
state::AppState,
};
const EDITOR_IMAGE_GENERATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
@@ -99,10 +103,15 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/images/generations",
post(generate_editor_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(generate_editor_image)
.layer(DefaultBodyLimit::max(
// 中文注释:图片画布生图仍兼容参考图 Data URL 入参,宣发素材提交前会压缩,后端保留有限兜底。
EDITOR_IMAGE_GENERATION_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/images/edits",
@@ -578,7 +578,7 @@ mod tests {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
"1280*720",
"16:9",
2,
&["data:image/png;base64,abcd".to_string()],
);
@@ -91,17 +91,28 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str {
}
pub fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2k" => {
"1536x1024"
}
"1920*1080" | "1920x1080" | "2048*1152" | "2048x1152" | "2k-16:9" => "2048x1152",
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
value if !value.is_empty() => value,
_ => "1024x1024",
let size = size.trim();
match size {
"1:1" => "1024x1024".to_string(),
"16:9" | "2k" => "1536x1024".to_string(),
"2k-16:9" => "2048x1152".to_string(),
"9:16" => "1024x1536".to_string(),
value if is_explicit_pixel_size(value) => normalize_explicit_pixel_size(value),
value if !value.is_empty() => value.to_string(),
_ => "1024x1024".to_string(),
}
.to_string()
}
fn is_explicit_pixel_size(value: &str) -> bool {
let separator = if value.contains('x') { 'x' } else { '*' };
let Some((width, height)) = value.split_once(separator) else {
return false;
};
width.parse::<u32>().is_ok() && height.parse::<u32>().is_ok()
}
fn normalize_explicit_pixel_size(value: &str) -> String {
value.replace('*', "x")
}
fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str {
@@ -46,6 +46,20 @@ fn vector_engine_module_exposes_provider_protocol_helpers() {
);
}
#[test]
fn vector_engine_keeps_explicit_publication_material_pixel_sizes() {
let cover =
build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]);
let detail =
build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]);
let poster =
build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]);
assert_eq!(cover["size"], "720x540");
assert_eq!(detail["size"], "720x1280");
assert_eq!(poster["size"], "1280x720");
}
#[test]
fn vector_engine_normalizes_2k_landscape_spec_size() {
let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]);
@@ -11,10 +11,12 @@ describe('ImageCanvasBottomToolbarView', () => {
const switchTool = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="generate"
onSwitchTool={switchTool}
onOpenToolOptions={vi.fn()}
@@ -45,6 +47,7 @@ describe('ImageCanvasBottomToolbarView', () => {
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成图标素材' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
@@ -52,17 +55,20 @@ describe('ImageCanvasBottomToolbarView', () => {
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
});
it('only keeps the select and hand tools visibly pressed', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
const renderToolbar = (effectiveTool: Parameters<
typeof ImageCanvasBottomToolbarView
>[0]['effectiveTool']) => (
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
@@ -96,6 +102,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['character', '生成角色形象'],
['icon', '生成图标素材'],
['ui-design', '生成UI设计图'],
['publication', '宣发素材'],
] as const) {
rerender(renderToolbar(tool[0]));
@@ -112,10 +119,12 @@ describe('ImageCanvasBottomToolbarView', () => {
const closeToolOptions = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={openToolOptions}
@@ -127,10 +136,14 @@ describe('ImageCanvasBottomToolbarView', () => {
fireEvent.pointerLeave(specToolWrapRef.current!);
fireEvent.pointerEnter(musicToolWrapRef.current!);
fireEvent.pointerLeave(musicToolWrapRef.current!);
fireEvent.pointerEnter(publicationToolWrapRef.current!);
fireEvent.pointerLeave(publicationToolWrapRef.current!);
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
});
});
@@ -6,6 +6,7 @@ import {
Hand,
ImageIcon,
ImagePlus,
Megaphone,
MousePointer2,
Music,
UserRound,
@@ -15,11 +16,12 @@ import type { RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec'>;
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
type ImageCanvasBottomToolbarViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
@@ -43,6 +45,7 @@ const canvasTools: Array<{
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{ id: 'music', label: '生成音乐', icon: Music },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
{
id: 'spec',
label: '生成规范',
@@ -55,7 +58,7 @@ const canvasTools: Array<{
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
return tool === 'music' || tool === 'spec';
return tool === 'music' || tool === 'spec' || tool === 'publication';
}
function hasPersistentPressedState(tool: CanvasTool) {
@@ -65,6 +68,7 @@ function hasPersistentPressedState(tool: CanvasTool) {
export function ImageCanvasBottomToolbarView({
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
effectiveTool,
onSwitchTool,
onOpenToolOptions,
@@ -88,10 +92,19 @@ export function ImageCanvasBottomToolbarView({
/>
) : null}
<span
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
ref={
id === 'spec'
? specToolWrapRef
: id === 'music'
? musicToolWrapRef
: publicationToolWrapRef
}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
id === 'publication'
? 'image-canvas-editor__publication-tool-wrap'
: '',
]
.filter(Boolean)
.join(' ')}
@@ -114,6 +114,7 @@ function createStageProps(): ImageCanvasStageViewProps {
canvasViewportRef: createRef<HTMLDivElement>(),
specToolWrapRef: createRef<HTMLSpanElement>(),
musicToolWrapRef: createRef<HTMLSpanElement>(),
publicationToolWrapRef: createRef<HTMLSpanElement>(),
isPanning: false,
effectiveTool: 'select',
canvasBackgroundColor: '#f8fafc',
@@ -13,6 +13,7 @@ export type CanvasAssetKind =
| 'character'
| 'icon'
| 'icon-spec'
| 'publication-material'
| 'ui-design'
| 'video'
| 'sound-effect'
@@ -107,6 +108,7 @@ export type CanvasTool =
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'ui-design';
export type SidebarPanel = 'assets' | 'layers';
@@ -141,6 +143,17 @@ export type CharacterReferenceImage = {
assetObjectId?: string | null;
};
export type PublicationMaterialsGameInfo = {
gameName: string;
gameCategories: string;
gameDescription: string;
};
export type PublicationMaterialsWorkflowId =
| 'publication-cover-image'
| 'publication-detail-gallery'
| 'publication-promo-poster';
export type GenerateDialogState = {
id?: string;
mode:
@@ -149,6 +162,7 @@ export type GenerateDialogState = {
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'ui-design'
| 'video'
| 'audio-sound-effect'
@@ -166,6 +180,9 @@ export type GenerateDialogState = {
characterReferences?: CharacterReferenceImage[];
iconSpecReference?: CharacterReferenceImage | null;
iconDescriptions?: string[];
publicationWorkflowId?: PublicationMaterialsWorkflowId;
publicationGameInfo?: PublicationMaterialsGameInfo;
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
videoModel?: EditorVideoModel;
@@ -280,7 +297,8 @@ export type UploadTarget =
| 'character-spec'
| 'character-reference'
| 'icon-spec'
| 'ui-design-icon-spec';
| 'ui-design-icon-spec'
| 'publication-reference';
export type SnapGuide = {
vertical?: number;
@@ -56,9 +56,11 @@ export function ImageCanvasEditorView() {
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
@@ -501,9 +503,11 @@ export function ImageCanvasEditorView() {
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
publicationReferenceButtonRef,
iconSpecButtonRef,
generateDialog,
setGenerateDialog,
@@ -535,6 +539,8 @@ export function ImageCanvasEditorView() {
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -552,6 +558,7 @@ export function ImageCanvasEditorView() {
removeSelectedLayerBackground,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickPublicationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -646,12 +653,14 @@ export function ImageCanvasEditorView() {
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickPublicationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -884,6 +893,7 @@ export function ImageCanvasEditorView() {
canvasViewportRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -30,17 +30,20 @@ function createComposerProps(
characterReferenceButtonRef: createRef(),
generationReferenceButtonRef: createRef(),
iconSpecButtonRef: createRef(),
publicationReferenceButtonRef: createRef(),
isSpecMenuOpen: false,
isCharacterSpecMenuOpen: false,
isCharacterReferenceMenuOpen: false,
isGenerationReferenceMenuOpen: false,
isIconSpecMenuOpen: false,
isUiDesignSpecMenuOpen: false,
isPublicationReferenceMenuOpen: false,
isPickingCharacterSpecFromCanvas: false,
isPickingCharacterReferenceFromCanvas: false,
isPickingGenerationReferenceFromCanvas: false,
isPickingIconSpecFromCanvas: false,
isPickingUiDesignSpecFromCanvas: false,
isPickingPublicationReferenceFromCanvas: false,
generateDialog,
generationComposerStyle: { left: 320, top: 240 },
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
@@ -80,11 +83,13 @@ function createComposerProps(
setIsGenerationReferenceMenuOpen: mockStateSetter<boolean>(),
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
setIsPublicationReferenceMenuOpen: mockStateSetter<boolean>(),
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
setIsPickingPublicationReferenceFromCanvas: mockStateSetter<boolean>(),
onOpenSpecDialog: vi.fn(),
onRequestUpload: vi.fn(),
onSubmitImageGeneration: vi.fn(),
@@ -24,6 +24,7 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import type {
CanvasLayer,
CharacterAnimationPanelState,
@@ -42,6 +43,7 @@ import {
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
@@ -49,17 +51,20 @@ import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOpt
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
isSpecMenuOpen: boolean;
isGenerationReferenceMenuOpen: boolean;
isPublicationReferenceMenuOpen: boolean;
isCharacterSpecMenuOpen: boolean;
isCharacterReferenceMenuOpen: boolean;
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
isPickingPublicationReferenceFromCanvas: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
@@ -86,11 +91,15 @@ type ImageCanvasGenerationComposerViewProps = {
SetStateAction<CharacterAnimationPanelState | null>
>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<
SetStateAction<boolean>
>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
@@ -841,17 +850,20 @@ function ImageCanvasAudioGenerationComposerView({
export function ImageCanvasGenerationComposerView({
specToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
isSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPublicationReferenceMenuOpen,
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
@@ -876,11 +888,13 @@ export function ImageCanvasGenerationComposerView({
setCropExpandPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
@@ -974,6 +988,31 @@ export function ImageCanvasGenerationComposerView({
/>
) : null}
{generateDialog?.mode === 'publication' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasPublicationMaterialsDemoPanelView
dialog={generateDialog}
workflow={getPublicationMaterialsWorkflow(
generateDialog.publicationWorkflowId ?? 'publication-cover-image',
)}
style={generationComposerStyle}
publicationReferenceButtonRef={publicationReferenceButtonRef}
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsPublicationReferenceMenuOpen={
setIsPublicationReferenceMenuOpen
}
setIsPickingPublicationReferenceFromCanvas={
setIsPickingPublicationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'ui-design' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
@@ -1093,6 +1132,11 @@ export function ImageCanvasGenerationComposerView({
Esc 退
</div>
) : null}
{isPickingPublicationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
@@ -5,6 +5,7 @@ import type {
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
PublicationMaterialsWorkflowId,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
@@ -18,12 +19,15 @@ import {
createCanvasLayerReference,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
PUBLICATION_FRAME_DISPLAY_SIZE,
PUBLICATION_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
UI_DESIGN_FRAME_DISPLAY_SIZE,
@@ -233,6 +237,37 @@ export function createIconGenerationDialogDraft({
};
}
export function createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
workflowId: PublicationMaterialsWorkflowId;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const frameDisplaySize = PUBLICATION_FRAME_DISPLAY_SIZE[workflowId];
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
return {
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: workflowId,
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
publicationReferences: [],
placeholder: {
x: worldCenter.x - frameDisplaySize.width / 2,
y: worldCenter.y - frameDisplaySize.height / 2,
width: frameDisplaySize.width,
height: frameDisplaySize.height,
originalWidth: frameOriginalSize.width,
originalHeight: frameOriginalSize.height,
},
};
}
export function createVideoGenerationDialogDraft({
canvasSize,
viewport,
@@ -477,6 +512,22 @@ export function appendGenerationReference(
: dialog;
}
export function appendPublicationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'publication' && getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
@@ -567,6 +618,7 @@ export function hideGeneratedLayerComposerAfterBlur(
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'publication' ||
dialog?.mode === 'audio-sound-effect' ||
dialog?.mode === 'audio-background-music') &&
dialog.status !== 'generating'
@@ -581,6 +633,7 @@ export function closeGenerateComposerDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return dialog?.mode === 'generate' ||
dialog?.mode === 'publication' ||
dialog?.mode === 'audio-sound-effect' ||
dialog?.mode === 'audio-background-music'
? {
@@ -11,9 +11,15 @@ import type {
CanvasLayer,
CharacterReferenceImage,
GenerateDialogState,
PublicationMaterialsGameInfo,
PublicationMaterialsWorkflowId,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
getPublicationMaterialsWorkflow,
type PublicationMaterialsWorkflow,
} from './ImageCanvasPublicationMaterialsModel';
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
@@ -43,6 +49,22 @@ export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const PUBLICATION_FRAME_ORIGINAL_SIZE: Record<
PublicationMaterialsWorkflowId,
{ width: number; height: number }
> = {
'publication-cover-image': { width: 720, height: 540 },
'publication-detail-gallery': { width: 720, height: 1280 },
'publication-promo-poster': { width: 1280, height: 720 },
};
export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
PublicationMaterialsWorkflowId,
{ width: number; height: number }
> = {
'publication-cover-image': { width: 480, height: 360 },
'publication-detail-gallery': { width: 360, height: 640 },
'publication-promo-poster': { width: 560, height: 315 },
};
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;
@@ -50,6 +72,11 @@ export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
gameName: '',
gameCategories: '',
gameDescription: '',
};
export const DEFAULT_ICON_DESCRIPTIONS = [
'返回按钮',
'设置按钮',
@@ -257,6 +284,9 @@ export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return '图标规范';
}
if (layer.assetKind === 'publication-material') {
return '宣发素材';
}
if (layer.assetKind === 'ui-design') {
return 'UI设计';
}
@@ -288,6 +318,9 @@ export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return '图标素材规范图片';
}
if (layer.assetKind === 'publication-material') {
return '宣发素材图片';
}
if (layer.assetKind === 'ui-design') {
return 'UI设计图';
}
@@ -502,6 +535,71 @@ export function buildUiDesignGenerationInputs(
};
}
function normalizePublicationGameInfo(
gameInfo: PublicationMaterialsGameInfo | null | undefined,
): PublicationMaterialsGameInfo {
return {
gameName: gameInfo?.gameName?.trim() ?? '',
gameCategories: gameInfo?.gameCategories?.trim() ?? '',
gameDescription: gameInfo?.gameDescription?.trim() ?? '',
};
}
export function buildPublicationMaterialsPrompt(
gameInfo: PublicationMaterialsGameInfo | null | undefined,
) {
const normalizedGameInfo = normalizePublicationGameInfo(gameInfo);
return [
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
...createGenerationInputField(
'一句话描述游戏',
normalizedGameInfo.gameDescription,
),
]
.map((field) => `${field.title}${field.value}`)
.join('\n');
}
export function buildPublicationMaterialsGenerationPrompt({
gameInfo,
workflow,
}: {
gameInfo: PublicationMaterialsGameInfo | null | undefined;
workflow: PublicationMaterialsWorkflow;
}) {
const inputPrompt = buildPublicationMaterialsPrompt(gameInfo);
return [
`【宣发素材类型】${workflow.promptLabel ?? workflow.label}`,
inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。',
`【生图约束】\n${workflow.promptConstraints.join('\n')}`,
`【参考图约束】\n${workflow.referenceConstraints.join('\n')}`,
`【输出检查】\n${workflow.postProcessConstraints.join('\n')}`,
].join('\n\n');
}
export function buildPublicationMaterialsGenerationInputs(
gameInfo: PublicationMaterialsGameInfo | null | undefined,
references: CharacterReferenceImage[] | undefined,
): CanvasGenerationInputs {
const normalizedGameInfo = normalizePublicationGameInfo(gameInfo);
return {
fields: [
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
...createGenerationInputField(
'一句话描述游戏',
normalizedGameInfo.gameDescription,
),
],
references: (references ?? []).map((reference, index) => ({
title: `宣发参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
};
}
export function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
@@ -549,6 +647,7 @@ export function isCanvasGenerationDialog(
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
@@ -568,6 +667,9 @@ export function getGenerationFrameAriaLabel(
if (dialog.mode === 'icon') {
return '图标素材生成占位图';
}
if (dialog.mode === 'publication') {
return '宣发素材生成占位图';
}
if (dialog.mode === 'ui-design') {
return 'UI设计图生成占位图';
}
@@ -593,6 +695,11 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
if (dialog.mode === 'icon') {
return 'Icon Generator';
}
if (dialog.mode === 'publication') {
return `${getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
).englishName} Generator`;
}
if (dialog.mode === 'ui-design') {
return 'UI Design Generator';
}
@@ -206,6 +206,144 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds publication material plans with structured game fields', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationGameInfo: {
gameName: ' 重庆洪崖洞火锅 ',
gameCategories: '抓大鹅、休闲、治愈、手绘风',
gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '参考图',
src: 'data:image/png;base64,ref',
},
],
},
layers: [],
nextGeneratedIndex: 10,
});
expect(plan).toEqual({
kind: 'image',
normalizedPrompt: [
'游戏名:重庆洪崖洞火锅',
'游戏分类:抓大鹅、休闲、治愈、手绘风',
'一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除',
].join('\n'),
input: {
prompt: expect.stringContaining('【宣发素材类型】游戏首图'),
size: '720x540',
kind: 'publication-material',
referenceImageSrcs: ['data:image/png;base64,ref'],
},
result: {
title: '10 宣发素材',
generationInputs: {
fields: [
{ title: '游戏名', value: '重庆洪崖洞火锅' },
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
{
title: '一句话描述游戏',
value: '在一堆物品里点击,找到三个一样的物品将其消除',
},
],
references: [
{
title: '宣发参考图 1',
label: '参考图',
src: 'data:image/png;base64,ref',
},
],
},
},
});
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
expect(prompt).toContain('游戏名:重庆洪崖洞火锅');
expect(prompt).toContain('标题文字必须直接进入画面');
expect(prompt).toContain('输出检查:720 x 5404:3');
});
it('builds detail gallery publication plans as one 720p portrait image', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '',
gameDescription: '',
},
},
layers: [],
nextGeneratedIndex: 11,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
prompt: expect.stringContaining('【宣发素材类型】详情页单图'),
size: '720x1280',
kind: 'publication-material',
},
result: {
title: '11 宣发素材',
},
});
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'本次只生成一张 720 x 1280 竖版详情图',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'不是五图合集或拼贴',
);
});
it('builds promo poster publication plans as 720p landscape images', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '火锅节活动',
gameCategories: '',
gameDescription: '',
},
},
layers: [],
nextGeneratedIndex: 12,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
prompt: expect.stringContaining('【宣发素材类型】运营海报'),
size: '1280x720',
kind: 'publication-material',
},
result: {
title: '12 宣发素材',
},
});
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'16:9 横版 720p,建议 1280 x 720',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'此处换上你的游戏二维码',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'方块内部上方用黑色小字',
);
});
it('throws when edit source layer is missing', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
@@ -18,6 +18,9 @@ import {
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildPublicationMaterialsGenerationInputs,
buildPublicationMaterialsGenerationPrompt,
buildPublicationMaterialsPrompt,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
@@ -40,6 +43,7 @@ import {
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
@@ -292,6 +296,42 @@ export function buildImageGenerationSubmissionPlan({
};
}
if (dialog.mode === 'publication') {
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const publicationPrompt =
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
normalizedPrompt;
const generationPrompt = buildPublicationMaterialsGenerationPrompt({
gameInfo: dialog.publicationGameInfo,
workflow,
});
return {
kind: 'image',
normalizedPrompt: publicationPrompt,
input: {
prompt: generationPrompt,
size: workflow.outputSize,
kind: 'publication-material',
...(dialog.publicationReferences?.length
? {
referenceImageSrcs: dialog.publicationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
title: `${nextGeneratedIndex} 宣发素材`,
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
),
},
};
}
if (dialog.mode === 'video') {
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
@@ -0,0 +1,305 @@
import { ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import type {
CharacterReferenceImage,
GenerateDialogState,
PublicationMaterialsGameInfo,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
buildPublicationMaterialsPrompt,
DEFAULT_PUBLICATION_GAME_INFO,
} from './ImageCanvasGenerationModel';
import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
dialog: GenerateDialogState;
workflow: PublicationMaterialsWorkflow;
style: CSSProperties;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
isPublicationReferenceMenuOpen: boolean;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onSubmit: (dialog: GenerateDialogState) => void;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function updatePublicationGameInfoField(
currentDialog: GenerateDialogState | null,
key: keyof PublicationMaterialsGameInfo,
value: string,
): GenerateDialogState | null {
if (currentDialog?.mode !== 'publication') {
return currentDialog;
}
const publicationGameInfo = {
...DEFAULT_PUBLICATION_GAME_INFO,
...currentDialog.publicationGameInfo,
[key]: value,
};
return {
...resetFailedDialogStatus(currentDialog),
publicationGameInfo,
prompt: buildPublicationMaterialsPrompt(publicationGameInfo),
};
}
function ReferenceThumb({
reference,
index,
}: {
reference: CharacterReferenceImage;
index: number;
}) {
return (
<span
className="image-canvas-editor__publication-ref-thumb"
title={reference.label}
>
<img src={reference.src} alt={reference.label} />
<span>{index + 1}</span>
</span>
);
}
export function ImageCanvasPublicationMaterialsDemoPanelView({
dialog,
workflow,
style,
publicationReferenceButtonRef,
isPublicationReferenceMenuOpen,
setGenerateDialog,
setIsPublicationReferenceMenuOpen,
setIsPickingPublicationReferenceFromCanvas,
renderEditorPortal,
buildPortalMenuStyle,
onRequestUpload,
onSubmit,
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
const references = dialog.publicationReferences ?? [];
const publicationGameInfo = {
...DEFAULT_PUBLICATION_GAME_INFO,
...dialog.publicationGameInfo,
};
return (
<form
className="image-canvas-editor__publication-composer"
style={style}
role="dialog"
aria-label={`${workflow.label}生成卡片`}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
onSubmit(dialog);
}}
>
<header className="image-canvas-editor__publication-composer-header">
<div>
<span>{workflow.outputLabel}</span>
<h2>{workflow.englishName}</h2>
</div>
<strong>{workflow.sizeLabel}</strong>
</header>
<div className="image-canvas-editor__publication-composer-fields">
<label>
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label={`${workflow.label}游戏名`}
value={publicationGameInfo.gameName}
disabled={dialog.status === 'generating'}
placeholder="马戏团午夜惊魂"
size="sm"
density="compact"
className="image-canvas-editor__publication-input"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameName',
event.target.value,
),
)
}
/>
</label>
<label>
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
aria-label={`${workflow.label}游戏分类`}
value={publicationGameInfo.gameCategories}
disabled={dialog.status === 'generating'}
placeholder="非对称对抗"
size="sm"
density="compact"
className="image-canvas-editor__publication-input"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameCategories',
event.target.value,
),
)
}
/>
</label>
<label>
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label={`${workflow.label}一句话描述游戏`}
value={publicationGameInfo.gameDescription}
disabled={dialog.status === 'generating'}
placeholder="神奇数字马戏团里,帕姆尼失控了,找到钥匙,开门逃离马戏团,千万别被抓住了"
size="sm"
density="compact"
className="image-canvas-editor__publication-description"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameDescription',
event.target.value,
),
)
}
/>
</label>
<div>
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<div className="image-canvas-editor__publication-reference-list">
{references.map((reference, index) => (
<ReferenceThumb
key={reference.id}
reference={reference}
index={index}
/>
))}
<button
ref={publicationReferenceButtonRef}
type="button"
className="image-canvas-editor__publication-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
disabled={dialog.status === 'generating'}
onClick={() => setIsPublicationReferenceMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__reference-tile-visual">
<ImagePlus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-tile-copy">
</span>
</button>
</div>
{isPublicationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__publication-reference-menu image-canvas-editor__portal-menu"
label="宣发素材参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
publicationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingPublicationReferenceFromCanvas(true);
setIsPublicationReferenceMenuOpen(false);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPublicationReferenceMenuOpen(false);
onRequestUpload('publication-reference');
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<footer className="image-canvas-editor__publication-composer-footer">
<span>{workflow.scenario}</span>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={dialog.status === 'generating'}
>
{dialog.status === 'generating' ? '生成中' : '消耗5泥点 · 生成'}
</PlatformActionButton>
</footer>
</form>
);
}
@@ -0,0 +1,128 @@
import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes';
export type PublicationMaterialsWorkflow = {
id: PublicationMaterialsWorkflowId;
label: string;
promptLabel?: string;
englishName: string;
outputLabel: string;
sizeLabel: string;
outputSize: string;
scenario: string;
fields: string[];
framework: string[];
promptConstraints: string[];
referenceConstraints: string[];
postProcessConstraints: string[];
};
export const PUBLICATION_MATERIALS_WORKFLOWS: PublicationMaterialsWorkflow[] = [
{
id: 'publication-cover-image',
label: '游戏首图',
englishName: 'Home Image',
outputLabel: '单张主视觉',
sizeLabel: '720 x 540',
outputSize: '720x540',
scenario: '推荐流、作品卡和首屏传播位',
fields: ['游戏名称', '玩法分类标签', '一句话玩法', '主视觉关键词', '必要文字'],
framework: [
'读取游戏基础信息和现有参考图',
'抽取核心玩法动作、主体物和城市/题材符号',
'生成一张带标题文字的横版主视觉',
'按首图尺寸、文字可读性和主体完整度验收',
],
promptConstraints: [
'标题文字必须直接进入画面,优先使用游戏名称;标题应为清晰中文大字,少字、醒目、可读',
'画面只表达一个强主题,不拼贴多张小图,不做详情页合集,不做截图说明页',
'主体、玩法道具和背景层级必须清楚;核心主体适合小尺寸推荐卡,缩小后仍能一眼识别',
'标题与主体不能互相遮挡;主体不裁切,标题不贴边,四周保留安全边距',
'避免暗黑、脏污、低清、廉价拼贴、文字乱码、伪文字、英文标题、非中文标题、错误游戏名',
],
referenceConstraints: [
'参考图只用于提取视觉约束:主体、玩法元素、色彩气质、画风、构图方向;不得照搬水印、边框、无关 UI、平台外壳或隐私信息',
],
postProcessConstraints: [
'输出检查:720 x 540,4:3;主体完整不裁切;标题清晰可读;画面只有一个主视觉重点',
'输出 metadata 记录完整提示词、尺寸和参考图摘要',
'不做真实发布、不写作品数据,只作为画布素材候选',
],
},
{
id: 'publication-detail-gallery',
label: '详情五图',
promptLabel: '详情页单图',
englishName: 'Detail Gallery',
outputLabel: '单张详情图',
sizeLabel: '720 x 1280',
outputSize: '720x1280',
scenario: '作品详情、应用介绍和运营分发图组',
fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'],
framework: [
'选择当前这张详情图要表达的单一卖点',
'生成一张带短标题的竖版详情页素材',
'让主体、玩法反馈和背景符号保持同一套游戏物料气质',
'按单图尺寸、标题可读性、主体完整度和非合集画面验收',
],
promptConstraints: [
'本次只生成一张 720 x 1280 竖版详情图;不要把五张图合成一张长图、拼图、多分镜合集或截图说明页',
'每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句',
'画面保持与同一游戏后续详情图可组成一套:同一游戏名、同一主体物件体系、同一画风、同一色彩气质',
'构图要服务当前单一卖点,主体、玩法道具、反馈效果和背景层级清楚;核心信息缩小后仍能识别',
'短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、UI 挡主体',
'连续生成多张详情图时,可以独立生成并允许顺序差异;同一批次复用同一份参考摘要,主动拉开角度、主体姿势和背景变化',
'避免五联图、分栏、多格漫画、长图合集、重复小图拼贴、文字乱码、伪文字、英文标题、非中文标题',
],
referenceConstraints: [
'从参考图解析并统一:画风、主体物件、玩法道具、背景符号、地域/场景元素、色板和光影氛围',
'参考图只作为视觉理解来源,不照搬截图 UI、水印、边框、按钮、状态栏、弹窗或无关文字',
],
postProcessConstraints: [
'输出检查:单张 720 x 1280 竖版;只表达一个卖点;标题不截断、不乱码;不是五图合集或拼贴',
'metadata 记录本张图的卖点、提示词和参考摘要',
'仅生成画布候选物料,不触发投放、分享或发布',
],
},
{
id: 'publication-promo-poster',
label: '运营海报',
englishName: 'Promo Poster',
outputLabel: '单张活动海报',
sizeLabel: '1280 x 720',
outputSize: '1280x720',
scenario: '社群、活动、渠道投放和运营节日位',
fields: ['游戏名称', '活动主题', '玩法标签', '主文案', '行动指令'],
framework: [
'确认运营主题和投放场景',
'抽取游戏核心视觉符号和活动情绪',
'生成带主标题、卖点和行动指令的海报',
'按传播可读性、品牌一致性和尺寸验收',
],
promptConstraints: [
'主标题、游戏名和行动指令必须清晰入画;三者要有明确层级,不能互相抢占',
'运营氛围可以更强,包括活动感、福利感、节日感、更新感,但不能脱离游戏玩法、主体物件和美术资产',
'版式必须预留二维码或平台标识位置,但不得生成假二维码、假平台 Logo、假按钮或不可识别图标',
'右下角必须保留一个干净的白色二维码留白方块,并在方块内部上方用黑色小字写“此处换上你的游戏二维码”;留白内不要生成假二维码',
'活动主题优先级高于普通详情介绍,但不得覆盖游戏识别;用户仍需一眼知道这是哪款游戏、什么玩法',
'避免夸张承诺、敏感营销词、虚假福利、价格折扣、具体日期、不可读小字、文字乱码、伪文字',
],
referenceConstraints: [
'参考图用于锁定角色/主体物件、玩法道具、地域/场景符号、色彩气质和画风,不照搬参考图中的 UI、水印、边框或无关文字',
],
postProcessConstraints: [
'输出检查:16:9 横版 720p,建议 1280 x 720;主标题最大,游戏名清楚,行动指令可读;右下角白色二维码留白方块干净,方块内部上方带有黑色小字“此处换上你的游戏二维码”',
'metadata 记录活动主题、文案、提示词和预留位说明',
'仅生成画布候选物料,不触发投放、分享或发布',
],
},
];
export function getPublicationMaterialsWorkflow(
workflowId: PublicationMaterialsWorkflowId,
): PublicationMaterialsWorkflow {
return (
PUBLICATION_MATERIALS_WORKFLOWS.find(
(workflow) => workflow.id === workflowId,
) ?? PUBLICATION_MATERIALS_WORKFLOWS[0]!
);
}
@@ -32,6 +32,7 @@ export type ImageCanvasStageViewProps = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
isPanning: boolean;
effectiveTool: CanvasTool;
canvasBackgroundColor: string;
@@ -124,14 +125,19 @@ export type ImageCanvasStageViewProps = {
onToggleMinimap: () => void;
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
onCloseToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
onOpenToolOptions: (
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
) => void;
onCloseToolOptions: (
tool: Extract<CanvasTool, 'music' | 'spec' | 'publication'>,
) => void;
};
export function ImageCanvasStageView({
canvasViewportRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -329,6 +335,7 @@ export function ImageCanvasStageView({
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={onSwitchTool}
onOpenToolOptions={onOpenToolOptions}

Some files were not shown because too many files have changed in this diff Show More