完成图片编辑器宣发素材生成
新增宣发素材入口、工作流模型、画布生成卡片和参考图选择能力。 接入宣发素材真实生图请求,隐藏 Prompt 摘要和约束预览,并将约束作为内部 Prompt 使用。 支持游戏首图、详情五图、运营海报尺寸,其中详情五图一次生成五张 720x1280 竖图。 后端仅对 publication-material 生图扣 5 泥点,并沿用现有失败退款逻辑。 补充前后端测试和宣发素材设计文档。
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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 540,4: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
Reference in New Issue
Block a user