完成图片编辑器宣发素材生成

新增宣发素材入口、工作流模型、画布生成卡片和参考图选择能力。

接入宣发素材真实生图请求,隐藏 Prompt 摘要和约束预览,并将约束作为内部 Prompt 使用。

支持游戏首图、详情五图、运营海报尺寸,其中详情五图一次生成五张 720x1280 竖图。

后端仅对 publication-material 生图扣 5 泥点,并沿用现有失败退款逻辑。

补充前后端测试和宣发素材设计文档。
This commit is contained in:
Codex
2026-06-18 00:05:25 +08:00
parent 7dec8b7a66
commit 0ebf6242bb
34 changed files with 1883 additions and 86 deletions
@@ -0,0 +1,70 @@
# 宣发素材工具入口设计
## 目标
在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,自动扣 5 泥点,生成成功后把成品图片放回画布。
Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 UI 中默认暴露;真实生图请求会把结构化游戏输入和对应工作流约束合并为内部 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 泥点扣费、成品回画布。
- 详情五图:点击一次生成 5 张 `720 x 1280` 竖版详情图,横向排布到画布;同一次操作只扣 5 泥点。
- 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。
- 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。
- 不修改:SpacetimeDB schema、前端发布页、作品发布系统。
## 生成与计费
- 前端生成请求统一通过 `/api/editor/images/generations`
- 宣发素材请求携带 `kind: publication-material`,后端仅对这个 kind 扣 5 泥点。
- `publication-detail-gallery` 额外携带 `candidateCount: 5`;后端在同一次扣费操作内顺序生成 5 张图,并通过响应 `images` 列表返回。
- 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。
- 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。
## 代码位置
- 工具入口:`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 check -p api-server --manifest-path server-rs/Cargo.toml
npm run check:encoding
```
+201 -61
View File
@@ -1,24 +1,24 @@
use std::{borrow::Cow, collections::BTreeMap};
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
Json,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
generate_asset_object_id,
build_asset_object_upsert_input, generate_asset_object_id, AssetObjectAccessPolicy,
AssetObjectFieldError,
};
use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon, slice_generated_icon_spritesheet_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components, GeneratedAssetSheetConnectedIcon,
},
DownloadedImage,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
@@ -32,18 +32,19 @@ 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,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
normalize_generated_image_asset_mime,
normalize_generated_image_asset_mime, GeneratedImageAssetAdapter,
GeneratedImageAssetDataUrl,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
build_openai_image_http_client, create_openai_image_edit_with_references,
create_openai_image_edit_with_references_and_model, create_openai_image_generation,
require_openai_image_settings,
require_openai_image_settings, DownloadedOpenAiImage, OpenAiReferenceImage,
GPT_IMAGE_2_MODEL,
},
platform_errors::map_oss_error,
request_context::RequestContext,
@@ -64,6 +65,8 @@ 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_POINTS_COST: u64 = 5;
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -155,6 +158,7 @@ pub struct EditorImageGenerationRequest {
size: Option<String>,
kind: Option<String>,
model: Option<String>,
candidate_count: Option<u32>,
reference_image_srcs: Option<Vec<String>>,
}
@@ -241,6 +245,23 @@ pub struct EditorImageGenerationResponse {
model: &'static str,
provider: &'static str,
task_id: String,
images: Vec<EditorImageGenerationImageResponse>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationImageResponse {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: &'static str,
prompt: String,
actual_prompt: Option<String>,
model: &'static str,
provider: &'static str,
task_id: String,
}
#[derive(Debug, Serialize)]
@@ -761,6 +782,13 @@ pub async fn generate_editor_image(
let _requested_model = payload.model.as_deref();
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let candidate_count = if is_publication_material_generation {
payload.candidate_count.unwrap_or(1).clamp(1, 5)
} else {
1
};
let submitted_prompt = if is_character_generation {
build_editor_character_image_prompt(role_setting.as_str())
} else {
@@ -768,6 +796,7 @@ pub async fn generate_editor_image(
};
let failure_context = match normalized_kind {
Some("character") => "图片画布生成角色形象",
Some("publication-material") => "图片画布生成宣发素材",
Some("spec") => "图片画布生成规范",
Some("quick-edit") => "图片画布快速编辑图片",
_ => "图片画布生成图片",
@@ -787,13 +816,127 @@ pub async fn generate_editor_image(
);
let http_client = build_openai_image_http_client(&settings)?;
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
let generated = if reference_sources.is_empty() {
create_openai_image_generation(
let billing_asset_id = request_context.request_id().to_string();
let generated_images = if is_publication_material_generation {
execute_billable_asset_operation_with_cost(
&state,
authenticated.claims().user_id(),
EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
billing_asset_id.as_str(),
EDITOR_PUBLICATION_MATERIAL_POINTS_COST,
generate_editor_image_candidates(
&http_client,
&settings,
submitted_prompt.as_str(),
negative_prompt,
image_size.as_ref(),
reference_sources.as_slice(),
failure_context,
candidate_count,
),
)
.await?
} else {
generate_editor_image_candidates(
&http_client,
&settings,
submitted_prompt.as_str(),
negative_prompt,
image_size.as_ref(),
reference_sources.as_slice(),
failure_context,
1,
)
.await?
};
let mut image_responses = Vec::with_capacity(generated_images.len());
for generated_image in generated_images {
let mut image = generated_image.image;
if is_character_generation {
image = prepare_editor_character_image_for_response(image);
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let persisted = if is_character_generation {
Some(
persist_editor_character_image(
&state,
authenticated.claims().user_id(),
generated_image.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated_image.actual_prompt.as_deref(),
)
.await?,
)
} else {
None
};
image_responses.push(EditorImageGenerationImageResponse {
image_src: data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()),
object_key: persisted.as_ref().map(|asset| asset.object_key.clone()),
asset_object_id: persisted
.as_ref()
.map(|asset| asset.asset_object_id.clone()),
width,
height,
source_type: "generated",
prompt: role_setting.clone(),
actual_prompt: generated_image.actual_prompt,
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: generated_image.task_id,
});
}
let first_image = image_responses.first().cloned().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src: first_image.image_src.clone(),
object_key: first_image.object_key.clone(),
asset_object_id: first_image.asset_object_id.clone(),
width: first_image.width,
height: first_image.height,
source_type: "generated",
prompt: role_setting,
actual_prompt: first_image.actual_prompt.clone(),
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: first_image.task_id.clone(),
images: image_responses,
},
))
}
struct EditorGeneratedImageCandidate {
image: DownloadedOpenAiImage,
actual_prompt: Option<String>,
task_id: String,
}
async fn generate_editor_image_candidate(
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
submitted_prompt: &str,
negative_prompt: Option<&str>,
image_size: &str,
reference_sources: &[String],
failure_context: &str,
) -> Result<EditorGeneratedImageCandidate, AppError> {
let generated = if reference_sources.is_empty() {
create_openai_image_generation(
http_client,
settings,
submitted_prompt,
negative_prompt,
image_size,
1,
&[],
failure_context,
@@ -805,69 +948,65 @@ pub async fn generate_editor_image(
.map(|source| parse_editor_reference_image(source.as_str()))
.collect::<Result<Vec<_>, _>>()?;
create_openai_image_edit_with_references(
&http_client,
&settings,
submitted_prompt.as_str(),
http_client,
settings,
submitted_prompt,
negative_prompt,
image_size.as_ref(),
image_size,
1,
reference_images.as_slice(),
failure_context,
)
.await?
};
let mut image = generated.images.into_iter().next().ok_or_else(|| {
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
if is_character_generation {
image = prepare_editor_character_image_for_response(image);
}
Ok(EditorGeneratedImageCandidate {
image,
actual_prompt: generated.actual_prompt,
task_id: generated.task_id,
})
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let persisted = if is_character_generation {
Some(
persist_editor_character_image(
&state,
authenticated.claims().user_id(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
async fn generate_editor_image_candidates(
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
submitted_prompt: &str,
negative_prompt: Option<&str>,
image_size: &str,
reference_sources: &[String],
failure_context: &str,
candidate_count: u32,
) -> Result<Vec<EditorGeneratedImageCandidate>, AppError> {
let mut candidates = Vec::with_capacity(candidate_count as usize);
for index in 0..candidate_count {
let prompt = if candidate_count > 1 {
format!(
"{submitted_prompt}\n\n【当前详情图序号】第 {} 张,共 {} 张。保持同一套游戏详情物料风格,但构图、主体姿势和背景角度需要与其他详情图明显不同。",
index + 1,
candidate_count,
)
} else {
submitted_prompt.to_string()
};
candidates.push(
generate_editor_image_candidate(
http_client,
settings,
prompt.as_str(),
negative_prompt,
image_size,
reference_sources,
failure_context,
)
.await?,
)
} else {
None
};
let image_src = format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes.as_slice())
);
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: persisted.as_ref().map(|asset| asset.object_key.clone()),
asset_object_id: persisted
.as_ref()
.map(|asset| asset.asset_object_id.clone()),
width,
height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: generated.task_id,
},
))
);
}
Ok(candidates)
}
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
@@ -958,6 +1097,7 @@ pub async fn edit_editor_image(
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: generated.task_id,
images: vec![],
},
))
}
@@ -12,6 +12,7 @@ describe('ImageCanvasBottomToolbarView', () => {
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={createRef<HTMLSpanElement>()}
publicationToolWrapRef={createRef<HTMLSpanElement>()}
effectiveTool="generate"
onSwitchTool={switchTool}
/>,
@@ -32,10 +33,12 @@ describe('ImageCanvasBottomToolbarView', () => {
fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '文字工具' }));
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(2, 'spec');
expect(switchTool).toHaveBeenNthCalledWith(3, 'text');
expect(switchTool).toHaveBeenNthCalledWith(3, 'publication');
expect(switchTool).toHaveBeenNthCalledWith(4, 'text');
});
});
@@ -4,6 +4,7 @@ import {
Hand,
ImageIcon,
ImagePlus,
Megaphone,
MousePointer2,
Shapes,
Sparkles,
@@ -17,6 +18,7 @@ import type { CanvasTool } from './ImageCanvasEditorTypes';
type ImageCanvasBottomToolbarViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
onSwitchTool: (tool: CanvasTool) => void;
};
@@ -33,6 +35,7 @@ const canvasTools: Array<{
{ id: 'spec', label: '生成规范', icon: ClipboardList },
{ id: 'character', label: '生成角色形象', icon: Sparkles },
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
{ id: 'text', label: '文字工具', icon: Type },
{ id: 'shape', label: '形状标注工具', icon: Shapes },
{ id: 'export', label: '导出工具', icon: Download },
@@ -40,6 +43,7 @@ const canvasTools: Array<{
export function ImageCanvasBottomToolbarView({
specToolWrapRef,
publicationToolWrapRef,
effectiveTool,
onSwitchTool,
}: ImageCanvasBottomToolbarViewProps) {
@@ -65,6 +69,20 @@ export function ImageCanvasBottomToolbarView({
onClick={() => onSwitchTool(id)}
/>
</span>
) : id === 'publication' ? (
<span
key={id}
ref={publicationToolWrapRef}
className="image-canvas-editor__publication-tool-wrap"
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
) : (
<EditorIconButton
key={id}
@@ -115,6 +115,7 @@ function createStageProps(): ImageCanvasStageViewProps {
return {
canvasViewportRef: createRef<HTMLDivElement>(),
specToolWrapRef: createRef<HTMLSpanElement>(),
publicationToolWrapRef: createRef<HTMLSpanElement>(),
isPanning: false,
effectiveTool: 'select',
canvasBackgroundColor: '#f8fafc',
@@ -92,6 +92,7 @@ export type CanvasTool =
| 'spec'
| 'character'
| 'icon'
| 'publication'
| 'text'
| 'shape'
| 'export';
@@ -122,9 +123,20 @@ export type CharacterReferenceImage = {
src: string;
};
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: 'generate' | 'edit' | 'spec' | 'character' | 'icon';
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon' | 'publication';
prompt: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
@@ -137,6 +149,9 @@ export type GenerateDialogState = {
characterReferences?: CharacterReferenceImage[];
iconSpecReference?: CharacterReferenceImage | null;
iconDescriptions?: string[];
publicationWorkflowId?: PublicationMaterialsWorkflowId;
publicationGameInfo?: PublicationMaterialsGameInfo;
publicationReferences?: CharacterReferenceImage[];
imageModel?: string;
aspectRatio?: string;
imageSize?: string;
@@ -222,7 +237,8 @@ export type UploadTarget =
| 'spec-reference'
| 'character-spec'
| 'character-reference'
| 'icon-spec';
| 'icon-spec'
| 'publication-reference';
export type SnapGuide = {
vertical?: number;
@@ -47,6 +47,8 @@ export function ImageCanvasEditorView() {
const captureCanvasHistoryRef = useRef<() => void>(() => {});
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -352,6 +354,8 @@ export function ImageCanvasEditorView() {
viewport,
layerCounterRef,
specToolWrapRef,
publicationToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
@@ -378,6 +382,8 @@ export function ImageCanvasEditorView() {
setIsSpecMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPublicationMenuOpen,
setIsPublicationReferenceMenuOpen,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -385,12 +391,15 @@ export function ImageCanvasEditorView() {
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
openCharacterAnimationPanel,
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
hideGeneratedLayerPanelAfterBlur,
clearDeletedLayerGenerationState,
generationComposerStyle,
@@ -484,10 +493,12 @@ export function ImageCanvasEditorView() {
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingPublicationReferenceFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
moveViewportFromMinimapPointer,
@@ -516,10 +527,13 @@ export function ImageCanvasEditorView() {
setIsSpecMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPublicationMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
setIsSpacePanning,
setShiftPressed,
});
@@ -672,6 +686,7 @@ export function ImageCanvasEditorView() {
const stageProps = {
canvasViewportRef,
specToolWrapRef,
publicationToolWrapRef,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -16,6 +16,11 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import {
getPublicationMaterialsWorkflow,
PUBLICATION_MATERIALS_WORKFLOWS,
} from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { SPEC_TYPE_LABEL } from './ImageCanvasGenerationModel';
@@ -31,6 +36,8 @@ import type {
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
@@ -38,9 +45,12 @@ type ImageCanvasGenerationComposerViewProps = {
isCharacterSpecMenuOpen: boolean;
isCharacterReferenceMenuOpen: boolean;
isIconSpecMenuOpen: boolean;
isPublicationMenuOpen: boolean;
isPublicationReferenceMenuOpen: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
isPickingPublicationReferenceFromCanvas: boolean;
generateDialog: GenerateDialogState | null;
generationComposerStyle: CSSProperties | null;
iconComposerStyle: CSSProperties | null;
@@ -61,10 +71,18 @@ type ImageCanvasGenerationComposerViewProps = {
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPublicationMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<
SetStateAction<boolean>
>;
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onOpenPublicationWorkflow: (
workflowId: NonNullable<GenerateDialogState['publicationWorkflowId']>,
) => void;
onRequestUpload: (target: UploadTarget) => void;
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
@@ -118,6 +136,8 @@ function renderEditorPortal(node: ReactNode) {
export function ImageCanvasGenerationComposerView({
specToolWrapRef,
publicationToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
@@ -125,9 +145,12 @@ export function ImageCanvasGenerationComposerView({
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isPublicationMenuOpen,
isPublicationReferenceMenuOpen,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingPublicationReferenceFromCanvas,
generateDialog,
generationComposerStyle,
iconComposerStyle,
@@ -146,10 +169,14 @@ export function ImageCanvasGenerationComposerView({
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsPublicationMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
onOpenSpecDialog,
onOpenPublicationWorkflow,
onRequestUpload,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
@@ -185,6 +212,30 @@ export function ImageCanvasGenerationComposerView({
)
: null}
{isPublicationMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__publication-menu image-canvas-editor__portal-menu"
label="宣发素材类型"
placement="top-start"
style={buildPortalMenuStyle(
publicationToolWrapRef.current,
'above',
)}
>
{PUBLICATION_MATERIALS_WORKFLOWS.map((workflow) => (
<PlatformFloatingMenuItem
key={workflow.id}
className="image-canvas-editor__publication-menu-item"
onClick={() => onOpenPublicationWorkflow(workflow.id)}
>
{workflow.label}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{generateDialog?.mode === 'generate' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
@@ -198,6 +249,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 === 'spec' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
@@ -273,6 +349,11 @@ export function ImageCanvasGenerationComposerView({
Esc 退
</div>
) : null}
{isPickingPublicationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{quickEditPanel &&
quickEditPanel.status !== 'generating' &&
@@ -16,6 +16,7 @@ import {
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditPanelDraft,
createSpecDialogDraft,
hideGeneratedLayerComposerAfterBlur,
@@ -132,6 +133,60 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates detail gallery publication drafts in 720p portrait ratio', () => {
const canvasSize = { width: 1000, height: 900 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-detail-gallery',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
placeholder: {
x: 320,
y: 130,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
},
});
});
it('creates promo poster publication drafts in 720p landscape ratio', () => {
const canvasSize = { width: 1000, height: 900 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-promo-poster',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-promo-poster',
placeholder: {
x: 220,
y: 292.5,
width: 560,
height: 315,
originalWidth: 1280,
originalHeight: 720,
},
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '原图提示',
@@ -5,6 +5,7 @@ import type {
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
PublicationMaterialsWorkflowId,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
@@ -16,11 +17,14 @@ import {
createCanvasLayerReference,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SPEC_FORM_VALUES,
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,
} from './ImageCanvasGenerationModel';
@@ -180,6 +184,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 createEditDialogDraft(
sourceLayer: CanvasLayer,
): GenerateDialogState {
@@ -255,6 +290,22 @@ export function appendCharacterReference(
: dialog;
}
export function appendPublicationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'publication'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
@@ -348,7 +399,8 @@ export function hideGeneratedLayerComposerAfterBlur(
return (dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon') &&
dialog?.mode === 'icon' ||
dialog?.mode === 'publication') &&
dialog.status !== 'generating'
? {
...dialog,
@@ -360,7 +412,7 @@ export function hideGeneratedLayerComposerAfterBlur(
export function closeGenerateComposerDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return dialog?.mode === 'generate'
return dialog?.mode === 'generate' || dialog?.mode === 'publication'
? {
...dialog,
composerOpen: false,
@@ -1,4 +1,5 @@
import type {
EditorImageGenerationItemResult,
EditorIconSpritesheetGenerationResult,
EditorIconSpritesheetIconResult,
EditorImageGenerationResult,
@@ -15,7 +16,7 @@ import type {
type CanvasSize = { width: number; height: number };
type GeneratedResultLayerOptions = {
generated: EditorImageGenerationResult;
generated: EditorImageGenerationItemResult;
generatedIndex: number;
canvasSize: CanvasSize;
viewport: CanvasViewport;
@@ -7,6 +7,8 @@ import {
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildPublicationMaterialsGenerationInputs,
buildPublicationMaterialsPrompt,
buildQuickEditModelOptions,
buildSpecGenerationInputs,
buildSpecPrompt,
@@ -156,6 +158,39 @@ describe('ImageCanvasGenerationModel', () => {
);
});
it('builds publication material prompts from structured game fields', () => {
const gameInfo = {
gameName: ' 重庆洪崖洞火锅 ',
gameCategories: '抓大鹅、休闲、治愈、手绘风',
gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除',
};
expect(buildPublicationMaterialsPrompt(gameInfo)).toBe(
[
'游戏名:重庆洪崖洞火锅',
'游戏分类:抓大鹅、休闲、治愈、手绘风',
'一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除',
].join('\n'),
);
expect(
buildPublicationMaterialsGenerationInputs(gameInfo, [
{ id: 'ref-a', label: '参考图', src: '/ref.png' },
]),
).toEqual({
fields: [
{ title: '游戏名', value: '重庆洪崖洞火锅' },
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
{
title: '一句话描述游戏',
value: '在一堆物品里点击,找到三个一样的物品将其消除',
},
],
references: [
{ title: '宣发参考图 1', label: '参考图', src: '/ref.png' },
],
});
});
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
const iconDialog: CanvasGenerationDialogState = {
id: 'dialog-icon',
@@ -166,6 +201,14 @@ describe('ImageCanvasGenerationModel', () => {
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
getGenerationFrameLabel({
...iconDialog,
id: 'dialog-publication',
mode: 'publication',
publicationWorkflowId: 'publication-cover-image',
}),
).toBe('Home Image Generator');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
@@ -10,10 +10,16 @@ import type {
CanvasLayer,
CharacterReferenceImage,
GenerateDialogState,
PublicationMaterialsGameInfo,
PublicationMaterialsWorkflowId,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
import {
getPublicationMaterialsWorkflow,
type PublicationMaterialsWorkflow,
} from './ImageCanvasPublicationMaterialsModel';
export const SPEC_GENERATION_COST = 5;
export const SPEC_GENERATION_SIZE = '2048x1152';
@@ -23,6 +29,22 @@ export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
export const 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;
@@ -31,6 +53,11 @@ export const ICON_DESCRIPTION_LIMIT = 100;
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
export const ICON_COMPOSER_MIN_WIDTH_REM = 28;
export const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4;
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
gameName: '',
gameCategories: '',
gameDescription: '',
};
export const DEFAULT_ICON_DESCRIPTIONS = [
'返回按钮',
'设置按钮',
@@ -347,6 +374,71 @@ export function buildIconGenerationInputs(
};
}
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.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 buildEditGenerationInputs(
title: '修改要求' | '快速编辑提示词',
prompt: string,
@@ -372,7 +464,8 @@ export function isCanvasGenerationDialog(
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon'),
dialog.mode === 'icon' ||
dialog.mode === 'publication'),
);
}
@@ -388,6 +481,9 @@ export function getGenerationFrameAriaLabel(
if (dialog.mode === 'icon') {
return '图标素材生成占位图';
}
if (dialog.mode === 'publication') {
return '宣发素材生成占位图';
}
return '图像生成占位图';
}
@@ -401,6 +497,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`;
}
return 'Image Generator';
}
@@ -199,6 +199,136 @@ 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 720p portrait images', () => {
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',
candidateCount: 5,
},
result: {
title: '11 宣发素材',
},
});
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'五张均为 720 x 1280 竖版',
);
});
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',
);
});
it('throws when edit source layer is missing', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
@@ -20,11 +20,15 @@ import {
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildImageGenerationInputs,
buildPublicationMaterialsGenerationInputs,
buildPublicationMaterialsGenerationPrompt,
buildPublicationMaterialsPrompt,
buildSpecGenerationInputs,
buildSpecPrompt,
calculateCharacterAnimationPrice,
resolveCharacterAnimationSourceImageSrc,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
@@ -156,6 +160,45 @@ 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',
...(workflow.id === 'publication-detail-gallery'
? { candidateCount: 5 }
: {}),
...(dialog.publicationReferences?.length
? {
referenceImageSrcs: dialog.publicationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
title: `${nextGeneratedIndex} 宣发素材`,
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
),
},
};
}
return {
kind: 'image',
normalizedPrompt,
@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import type { GenerateDialogState, UploadTarget } from './ImageCanvasEditorTypes';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
function createIconDialog(
@@ -35,7 +35,7 @@ function IconComposerHarness({
initialDialog: GenerateDialogState;
initialMenuOpen?: boolean;
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
onRequestUpload?: (target: 'asset' | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec') => void;
onRequestUpload?: (target: UploadTarget) => void;
onUpdateIconDescription?: (index: number, value: string) => void;
onAddIconDescription?: () => void;
onRememberImageModel?: (model: string) => void;
@@ -183,6 +183,7 @@ export function isCanvasGenerationComposerVisible(
dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon'
dialog?.mode === 'icon' ||
dialog?.mode === 'publication'
);
}
@@ -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,123 @@
import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes';
export type PublicationMaterialsWorkflow = {
id: PublicationMaterialsWorkflowId;
label: 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: '详情五图',
englishName: 'Detail Gallery',
outputLabel: '五张详情图',
sizeLabel: '720p / 9:16',
outputSize: '720x1280',
scenario: '作品详情、应用介绍和运营分发图组',
fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'],
framework: [
'将玩法拆成 5 个可视化卖点',
'每张图独立生成,统一标题样式和美术风格',
'覆盖玩法说明、操作反馈、关卡目标和情绪价值',
'逐张验收文字、构图、主体和图组一致性',
],
promptConstraints: [
'每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句',
'五张图保持同一游戏名、同一主体物件体系、同一画风、同一色彩气质,像同一套游戏详情物料',
'五张图可以独立生成,允许顺序差异,不强依赖首图作为上游输入;但同一批次必须复用同一份参考摘要,保证一致性',
'每张图构图要有差异:避免五张重复同一角度、同一主体姿势、同一背景',
'短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、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]!
);
}
@@ -31,6 +31,7 @@ import type {
export type ImageCanvasStageViewProps = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
isPanning: boolean;
effectiveTool: CanvasTool;
canvasBackgroundColor: string;
@@ -125,6 +126,7 @@ export type ImageCanvasStageViewProps = {
export function ImageCanvasStageView({
canvasViewportRef,
specToolWrapRef,
publicationToolWrapRef,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -314,6 +316,7 @@ export function ImageCanvasStageView({
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={onSwitchTool}
/>
@@ -260,6 +260,11 @@ export function ImageCanvasWorldView({
</span>
) : null}
{dialog.mode === 'publication' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--publication">
</span>
) : null}
<span className="image-canvas-editor__generation-frame-size">
{dialog.placeholder.originalWidth} x{' '}
{dialog.placeholder.originalHeight}
@@ -281,7 +286,8 @@ export function ImageCanvasWorldView({
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' ||
generateDialog?.mode === 'icon') &&
generateDialog?.mode === 'icon' ||
generateDialog?.mode === 'publication') &&
generateDialog.status === 'generating' &&
generationComposerStyle ? (
<PlatformStatusMessage
@@ -416,6 +416,88 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('submits publication materials as billable editor images and appends the result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,publication',
width: 720,
height: 1280,
prompt: '游戏名:云朵消消乐',
images: Array.from({ length: 5 }, (_, index) =>
createGenerated({
imageSrc: `data:image/png;base64,publication-${index + 1}`,
width: 720,
height: 1280,
prompt: '游戏名:云朵消消乐',
taskId: `task-publication-${index + 1}`,
}),
),
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-publication',
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '休闲消除',
gameDescription: '点击同类云朵完成消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,ref',
},
],
placeholder: {
x: 180,
y: 120,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'publication:generating:closed:-:placeholder:-',
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: expect.stringContaining('【宣发素材类型】详情五图'),
size: '720x1280',
kind: 'publication-material',
candidateCount: 5,
referenceImageSrcs: ['data:image/png;base64,ref'],
});
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('游戏名:云朵消消乐');
expect(prompt).toContain('五张均为 720 x 1280 竖版');
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:1 宣发素材 1:-:-',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-5:1 宣发素材 5:-:-',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-generated-1',
);
});
it('validates icon submission before calling the API', async () => {
render(
<SubmissionWorkflowHarness
@@ -162,6 +162,75 @@ export function useImageCanvasGenerationSubmissionWorkflow({
],
);
const addGeneratedResultLayers = useCallback(
(
generatedItems: Parameters<
typeof createGeneratedResultLayer
>[0]['generated'][],
options: {
frame?: GenerateDialogState['placeholder'];
assetKind?: CanvasLayer['assetKind'];
title?: string;
dialogId?: string;
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
if (!generatedItems.length) {
return;
}
const nextLayers = generatedItems.map((generated, index) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const layer = createGeneratedResultLayer({
generated,
generatedIndex,
canvasSize,
viewport,
frame: options.frame,
assetKind: options.assetKind,
title:
generatedItems.length > 1 && options.title
? `${options.title} ${index + 1}`
: options.title,
generationInputs: options.generationInputs,
});
return {
...layer,
x: layer.x + index * (layer.width + 32),
zIndex: generatedIndex + 10,
};
});
appendCanvasLayersWithResources(nextLayers);
const firstLayer = nextLayers[0]!;
selectSingleLayer(firstLayer.id);
setActiveSidebarPanel('layers');
if (options.dialogId) {
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
? null
: {
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: firstLayer.id,
placeholder: undefined,
errorMessage: undefined,
},
);
}
},
[
appendCanvasLayersWithResources,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
updateCanvasGenerationDialogById,
viewport,
],
);
const addQuickEditResultLayer = useCallback(
(
generated: Parameters<typeof createQuickEditResultLayer>[0]['generated'],
@@ -402,13 +471,26 @@ export function useImageCanvasGenerationSubmissionWorkflow({
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
const generatedItems = generated.images?.length
? generated.images
: [generated];
if (generatedItems.length > 1) {
addGeneratedResultLayers(generatedItems, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
} else {
addGeneratedResultLayer(generatedItems[0] ?? generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
}
}
} catch (error) {
if (canvasDialog) {
@@ -432,6 +514,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
},
[
addGeneratedResultLayer,
addGeneratedResultLayers,
getGeneratingDialogPlaceholder,
layerCounterRef,
layers,
@@ -8,7 +8,6 @@ import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
@@ -60,6 +59,8 @@ function GenerationSurfaceHarness() {
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
const layerCounterRef = useRef(0);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -75,6 +76,8 @@ function GenerationSurfaceHarness() {
viewport: { x: 10, y: 20, scale: 2 },
layerCounterRef,
specToolWrapRef,
publicationToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
@@ -101,6 +104,7 @@ function GenerationSurfaceHarness() {
return (
<div>
<span ref={specToolWrapRef}></span>
<span ref={publicationToolWrapRef}></span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="dialog">
@@ -132,6 +136,12 @@ function GenerationSurfaceHarness() {
<button type="button" onClick={() => surface.switchGenerationTool('icon')}>
</button>
<button
type="button"
onClick={() => surface.switchGenerationTool('publication')}
>
</button>
<button type="button" onClick={() => surface.switchGenerationTool('text')}>
</button>
@@ -178,4 +188,32 @@ describe('useImageCanvasGenerationSurface', () => {
const menu = screen.getByRole('menu', { name: '生成规范类型' });
expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy();
});
it('creates a canvas publication generation card from the workflow menu', () => {
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换宣发素材' }));
expect(screen.getByTestId('tool').textContent).toBe('publication');
const menu = screen.getByRole('menu', { name: '宣发素材类型' });
fireEvent.click(within(menu).getByRole('menuitem', { name: '游戏首图' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'publication:open:placeholder',
);
expect(
screen.getByRole('dialog', { name: '游戏首图生成卡片' }),
).toBeTruthy();
const gameNameInput = screen.getByLabelText('游戏首图游戏名');
expect(gameNameInput).toBeTruthy();
expect(screen.getByLabelText('游戏首图游戏分类')).toBeTruthy();
expect(screen.getByLabelText('游戏首图一句话描述游戏')).toBeTruthy();
fireEvent.change(gameNameInput, { target: { value: '重庆洪崖洞火锅' } });
expect(screen.getByRole('button', { name: '添加参考图' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '消耗5泥点 · 生成' }),
).toBeTruthy();
expect(screen.getByText('720 x 540')).toBeTruthy();
expect(screen.queryByLabelText('游戏首图Prompt 输入摘要')).toBeNull();
expect(screen.queryByText('Prompt 约束')).toBeNull();
});
});
@@ -36,6 +36,8 @@ type ImageCanvasGenerationSurfaceOptions = {
viewport: CanvasViewport;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
@@ -70,6 +72,8 @@ export function useImageCanvasGenerationSurface({
viewport,
layerCounterRef,
specToolWrapRef,
publicationToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
@@ -163,6 +167,19 @@ export function useImageCanvasGenerationSurface({
generationWorkflow.openIconGenerationDialog();
return true;
}
if (tool === 'publication') {
generationWorkflow.setIsSpecMenuOpen(false);
generationWorkflow.setIsCharacterSpecMenuOpen(false);
generationWorkflow.setIsCharacterReferenceMenuOpen(false);
generationWorkflow.setIsIconSpecMenuOpen(false);
generationWorkflow.setIsPickingCharacterSpecFromCanvas(false);
generationWorkflow.setIsPickingCharacterReferenceFromCanvas(false);
generationWorkflow.setIsPickingIconSpecFromCanvas(false);
generationWorkflow.setIsPickingPublicationReferenceFromCanvas(false);
generationWorkflow.setIsPublicationMenuOpen((open) => !open);
setActiveTool('publication');
return true;
}
return false;
},
[generationWorkflow, setActiveTool],
@@ -171,6 +188,8 @@ export function useImageCanvasGenerationSurface({
const generationComposerNode = (
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
publicationReferenceButtonRef={publicationReferenceButtonRef}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
@@ -180,6 +199,10 @@ export function useImageCanvasGenerationSurface({
generationWorkflow.isCharacterReferenceMenuOpen
}
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
isPublicationMenuOpen={generationWorkflow.isPublicationMenuOpen}
isPublicationReferenceMenuOpen={
generationWorkflow.isPublicationReferenceMenuOpen
}
isPickingCharacterSpecFromCanvas={
generationWorkflow.isPickingCharacterSpecFromCanvas
}
@@ -189,6 +212,9 @@ export function useImageCanvasGenerationSurface({
isPickingIconSpecFromCanvas={
generationWorkflow.isPickingIconSpecFromCanvas
}
isPickingPublicationReferenceFromCanvas={
generationWorkflow.isPickingPublicationReferenceFromCanvas
}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
@@ -215,6 +241,10 @@ export function useImageCanvasGenerationSurface({
generationWorkflow.setIsCharacterReferenceMenuOpen
}
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
setIsPublicationMenuOpen={generationWorkflow.setIsPublicationMenuOpen}
setIsPublicationReferenceMenuOpen={
generationWorkflow.setIsPublicationReferenceMenuOpen
}
setIsPickingCharacterSpecFromCanvas={
generationWorkflow.setIsPickingCharacterSpecFromCanvas
}
@@ -224,7 +254,13 @@ export function useImageCanvasGenerationSurface({
setIsPickingIconSpecFromCanvas={
generationWorkflow.setIsPickingIconSpecFromCanvas
}
setIsPickingPublicationReferenceFromCanvas={
generationWorkflow.setIsPickingPublicationReferenceFromCanvas
}
onOpenSpecDialog={generationWorkflow.openSpecDialog}
onOpenPublicationWorkflow={
generationWorkflow.openPublicationGenerationDialog
}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void generationWorkflow.submitImageGeneration(dialog)

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