From 0ebf6242bbd0322d0932daa84c2f394039ab2df4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 00:05:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=9B=BE=E7=89=87=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AE=A3=E5=8F=91=E7=B4=A0=E6=9D=90=E7=94=9F?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增宣发素材入口、工作流模型、画布生成卡片和参考图选择能力。 接入宣发素材真实生图请求,隐藏 Prompt 摘要和约束预览,并将约束作为内部 Prompt 使用。 支持游戏首图、详情五图、运营海报尺寸,其中详情五图一次生成五张 720x1280 竖图。 后端仅对 publication-material 生图扣 5 泥点,并沿用现有失败退款逻辑。 补充前后端测试和宣发素材设计文档。 --- ...辑器】宣发素材工具演示入口设计-2026-06-17.md | 70 ++++ .../crates/api-server/src/editor_project.rs | 262 +++++++++++---- .../ImageCanvasBottomToolbarView.test.tsx | 5 +- .../ImageCanvasBottomToolbarView.tsx | 18 ++ .../ImageCanvasEditorShellView.test.tsx | 1 + .../image-editor/ImageCanvasEditorTypes.ts | 20 +- .../image-editor/ImageCanvasEditorView.tsx | 15 + .../ImageCanvasGenerationComposerView.tsx | 81 +++++ .../ImageCanvasGenerationDialogModel.test.ts | 55 ++++ .../ImageCanvasGenerationDialogModel.ts | 56 +++- .../ImageCanvasGenerationLayerModel.ts | 3 +- .../ImageCanvasGenerationModel.test.ts | 43 +++ .../ImageCanvasGenerationModel.ts | 103 +++++- ...ageCanvasGenerationSubmissionModel.test.ts | 130 ++++++++ .../ImageCanvasGenerationSubmissionModel.ts | 43 +++ ...CanvasIconSpritesheetComposerView.test.tsx | 4 +- .../image-editor/ImageCanvasOverlayModel.ts | 3 +- ...anvasPublicationMaterialsDemoPanelView.tsx | 305 ++++++++++++++++++ .../ImageCanvasPublicationMaterialsModel.ts | 123 +++++++ .../image-editor/ImageCanvasStageView.tsx | 3 + .../image-editor/ImageCanvasWorldView.tsx | 8 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 82 +++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 97 +++++- .../useImageCanvasGenerationSurface.test.tsx | 40 ++- .../useImageCanvasGenerationSurface.tsx | 36 +++ .../useImageCanvasGenerationWorkflow.ts | 77 +++++ .../useImageCanvasKeyboardShortcuts.test.tsx | 33 ++ .../useImageCanvasKeyboardShortcuts.ts | 20 +- .../useImageCanvasStageInteractions.test.tsx | 2 + .../useImageCanvasStageInteractions.ts | 14 + .../useImageCanvasUploadWorkflow.ts | 33 ++ src/index.css | 131 +++++++- .../image-editor/editorProjectClient.test.ts | 43 +++ .../image-editor/editorProjectClient.ts | 10 +- 34 files changed, 1883 insertions(+), 86 deletions(-) create mode 100644 docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md create mode 100644 src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx create mode 100644 src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md new file mode 100644 index 000000000..5bb9e36bd --- /dev/null +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -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 +``` diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5e002c9ac..a4b422ea7 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, kind: Option, model: Option, + candidate_count: Option, reference_image_srcs: Option>, } @@ -241,6 +245,23 @@ pub struct EditorImageGenerationResponse { model: &'static str, provider: &'static str, task_id: String, + images: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorImageGenerationImageResponse { + image_src: String, + object_key: Option, + asset_object_id: Option, + width: u32, + height: u32, + source_type: &'static str, + prompt: String, + actual_prompt: Option, + 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, + 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 { + 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::, _>>()?; 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, 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![], }, )) } diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index a67baa288..240988a36 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -12,6 +12,7 @@ describe('ImageCanvasBottomToolbarView', () => { render( ()} + publicationToolWrapRef={createRef()} 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'); }); }); diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx index dd36b4cce..a277e0a91 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -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; + publicationToolWrapRef: RefObject; 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)} /> + ) : id === 'publication' ? ( + + onSwitchTool(id)} + /> + ) : ( (), specToolWrapRef: createRef(), + publicationToolWrapRef: createRef(), isPanning: false, effectiveTool: 'select', canvasBackgroundColor: '#f8fafc', diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 30bc68204..ecb998056 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -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; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index a3621262b..2201ad273 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -47,6 +47,8 @@ export function ImageCanvasEditorView() { const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); + const publicationToolWrapRef = useRef(null); + const publicationReferenceButtonRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(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, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 9e646bda4..9b3a4e301 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -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; + publicationToolWrapRef: RefObject; + publicationReferenceButtonRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; @@ -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>; setIsCharacterReferenceMenuOpen: Dispatch>; setIsIconSpecMenuOpen: Dispatch>; + setIsPublicationMenuOpen: Dispatch>; + setIsPublicationReferenceMenuOpen: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; + setIsPickingPublicationReferenceFromCanvas: Dispatch< + SetStateAction + >; onOpenSpecDialog: (specType: SpecGenerationType) => void; + onOpenPublicationWorkflow: ( + workflowId: NonNullable, + ) => 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( + + {PUBLICATION_MATERIALS_WORKFLOWS.map((workflow) => ( + onOpenPublicationWorkflow(workflow.id)} + > + {workflow.label} + + ))} + , + ) + : null} + {generateDialog?.mode === 'generate' && generateDialog.composerOpen !== false && generationComposerStyle ? ( @@ -198,6 +249,31 @@ export function ImageCanvasGenerationComposerView({ /> ) : null} + {generateDialog?.mode === 'publication' && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + {generateDialog?.mode === 'spec' && generateDialog.composerOpen !== false && generationComposerStyle ? ( @@ -273,6 +349,11 @@ export function ImageCanvasGenerationComposerView({ 请选择画布中的图标素材规范,按 Esc 退出 ) : null} + {isPickingPublicationReferenceFromCanvas ? ( +
+ 请选择画布中的图片作为宣发素材参考图,按 Esc 退出 +
+ ) : null} {quickEditPanel && quickEditPanel.status !== 'generating' && diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 218048eeb..b1e17d26d 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -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: '原图提示', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index c03421283..a303170e0 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -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 { + 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, diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 6ad7874a2..319004d78 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -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; diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index b6c565ad4..82e6856e0 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -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({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 7a119efc2..90181b5ee 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -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'; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 56b4d1f17..0a74811d3 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -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({ diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index c92bc16ff..456ebcb6b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -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, diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index a0b025d92..24f49aca6 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -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; diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index 59492c8b5..02bb1f131 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -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' ); } diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx new file mode 100644 index 000000000..750d0fe31 --- /dev/null +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -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; + isPublicationReferenceMenuOpen: boolean; + setGenerateDialog: Dispatch>; + setIsPublicationReferenceMenuOpen: Dispatch>; + setIsPickingPublicationReferenceFromCanvas: Dispatch>; + 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 ( + + {reference.label} + {index + 1} + + ); +} + +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 ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + onSubmit(dialog); + }} + > +
+
+ {workflow.outputLabel} +

{workflow.englishName}

+
+ {workflow.sizeLabel} +
+ +
+ + + +
+ + 参考图 + +
+ {references.map((reference, index) => ( + + ))} + +
+ {isPublicationReferenceMenuOpen + ? renderEditorPortal( + + { + setIsPickingPublicationReferenceFromCanvas(true); + setIsPublicationReferenceMenuOpen(false); + }} + > + 从画布中选择 + + { + setIsPublicationReferenceMenuOpen(false); + onRequestUpload('publication-reference'); + }} + > + 上传图片 + + , + ) + : null} +
+
+ + {dialog.status === 'failed' ? ( + + {dialog.errorMessage} + + ) : null} + +
+ {workflow.scenario} + + {dialog.status === 'generating' ? '生成中' : '消耗5泥点 · 生成'} + +
+
+ ); +} diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts b/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts new file mode 100644 index 000000000..30bc51006 --- /dev/null +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts @@ -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]! + ); +} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 1e04f7533..8727b7e1a 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -31,6 +31,7 @@ import type { export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; + publicationToolWrapRef: RefObject; 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({ diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 69b8a999e..a01e2ade9 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -260,6 +260,11 @@ export function ImageCanvasWorldView({ 图标 ) : null} + {dialog.mode === 'publication' ? ( + + 宣发 + + ) : null} {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 ? ( { ); }); + 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( + , + ); + + 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( [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[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, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index b379ff00c..8f46176f5 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -8,7 +8,6 @@ import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, - GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; @@ -60,6 +59,8 @@ function GenerationSurfaceHarness() { const [, setImageContextMenu] = useState(null); const layerCounterRef = useRef(0); const specToolWrapRef = useRef(null); + const publicationToolWrapRef = useRef(null); + const publicationReferenceButtonRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(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 (
规范入口 + 宣发素材入口 {activeTool} {activeSidebarPanel ?? '-'} @@ -132,6 +136,12 @@ function GenerationSurfaceHarness() { + @@ -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(); + + 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(); + }); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 14f7204af..d6bc87b85 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -36,6 +36,8 @@ type ImageCanvasGenerationSurfaceOptions = { viewport: CanvasViewport; layerCounterRef: MutableRefObject; specToolWrapRef: RefObject; + publicationToolWrapRef: RefObject; + publicationReferenceButtonRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; @@ -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 = ( void generationWorkflow.submitImageGeneration(dialog) diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 07633ff49..22f5956f1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -15,6 +15,7 @@ import type { CharacterAnimationPanelState, GenerateDialogState, ImageContextMenuState, + PublicationMaterialsWorkflowId, QuickEditPanelState, SidebarPanel, SpecFormValues, @@ -23,6 +24,7 @@ import type { import { appendCharacterReference, appendIconDescriptionToDialog, + appendPublicationReference, assignCharacterSpecReference, assignIconSpecReference, closeGenerateComposerDialog, @@ -31,6 +33,7 @@ import { createEditDialogDraft, createGenerateDialogDraft, createIconGenerationDialogDraft, + createPublicationGenerationDialogDraft, createQuickEditPanelDraft, createSpecDialogDraft, hideGeneratedLayerComposerAfterBlur, @@ -116,6 +119,13 @@ export function useImageCanvasGenerationWorkflow({ const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(false); + const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(false); + const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] = + useState(false); + const [ + isPickingPublicationReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, + ] = useState(false); const [quickEditPanel, setQuickEditPanel] = useState(null); const [characterAnimationPanel, setCharacterAnimationPanel] = @@ -153,6 +163,9 @@ export function useImageCanvasGenerationWorkflow({ : DEFAULT_ICON_DESCRIPTIONS; const openGenerateDialog = useCallback(() => { + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingPublicationReferenceFromCanvas(false); openCanvasGenerationDialog( createGenerateDialogDraft({ canvasSize, viewport }), ); @@ -169,6 +182,9 @@ export function useImageCanvasGenerationWorkflow({ const openSpecDialog = useCallback( (specType: SpecGenerationType) => { + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingPublicationReferenceFromCanvas(false); openCanvasGenerationDialog( createSpecDialogDraft({ canvasSize, viewport, specType }), ); @@ -202,6 +218,9 @@ export function useImageCanvasGenerationWorkflow({ const openCharacterGenerationDialog = useCallback(() => { setIsSpecMenuOpen(false); + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingPublicationReferenceFromCanvas(false); setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); @@ -226,6 +245,9 @@ export function useImageCanvasGenerationWorkflow({ const openIconGenerationDialog = useCallback(() => { setIsSpecMenuOpen(false); + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingPublicationReferenceFromCanvas(false); setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); @@ -250,6 +272,37 @@ export function useImageCanvasGenerationWorkflow({ viewport, ]); + const openPublicationGenerationDialog = useCallback( + (workflowId: PublicationMaterialsWorkflowId) => { + setIsSpecMenuOpen(false); + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); + setIsPickingPublicationReferenceFromCanvas(false); + setIsCharacterReferenceMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsPickingCharacterReferenceFromCanvas(false); + setIsPickingIconSpecFromCanvas(false); + openCanvasGenerationDialog( + createPublicationGenerationDialogDraft({ + canvasSize, + viewport, + workflowId, + }), + ); + setActiveTool('publication'); + selectSingleLayer(null); + setQuickEditPanel(null); + setCharacterAnimationPanel(null); + }, + [ + canvasSize, + openCanvasGenerationDialog, + selectSingleLayer, + setActiveTool, + viewport, + ], + ); + const openEditDialog = useCallback( (sourceLayer: CanvasLayer) => { setMetadataLayer(null); @@ -318,6 +371,17 @@ export function useImageCanvasGenerationWorkflow({ [setGenerateDialog, setImageContextMenu], ); + const pickPublicationReferenceFromLayer = useCallback( + (layer: CanvasLayer) => { + setGenerateDialog((currentDialog) => + appendPublicationReference(currentDialog, layer), + ); + setIsPickingPublicationReferenceFromCanvas(false); + setImageContextMenu(null); + }, + [setGenerateDialog, setImageContextMenu], + ); + const updateIconDescription = useCallback( (index: number, value: string) => { setGenerateDialog((currentDialog) => @@ -422,6 +486,12 @@ export function useImageCanvasGenerationWorkflow({ iconDescriptionValues, isSpecMenuOpen, setIsSpecMenuOpen, + isPublicationMenuOpen, + setIsPublicationMenuOpen, + isPublicationReferenceMenuOpen, + setIsPublicationReferenceMenuOpen, + isPickingPublicationReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen, isCharacterReferenceMenuOpen, @@ -439,11 +509,13 @@ export function useImageCanvasGenerationWorkflow({ openCharacterAnimationPanel, openCharacterGenerationDialog, openIconGenerationDialog, + openPublicationGenerationDialog, openEditDialog, openQuickEditPanel, pickCharacterSpecFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, + pickPublicationReferenceFromLayer, submitIconSpritesheetGeneration, submitQuickEdit, submitImageGeneration, @@ -469,20 +541,25 @@ export function useImageCanvasGenerationWorkflow({ isCharacterReferenceMenuOpen, isCharacterSpecMenuOpen, isIconSpecMenuOpen, + isPublicationMenuOpen, + isPublicationReferenceMenuOpen, isPickingCharacterReferenceFromCanvas, isPickingCharacterSpecFromCanvas, isPickingIconSpecFromCanvas, + isPickingPublicationReferenceFromCanvas, isSpecMenuOpen, openCharacterAnimationPanel, openCharacterGenerationDialog, openEditDialog, openGenerateDialog, openIconGenerationDialog, + openPublicationGenerationDialog, openQuickEditPanel, openSpecDialog, pickCharacterReferenceFromLayer, pickCharacterSpecFromLayer, pickIconSpecFromLayer, + pickPublicationReferenceFromLayer, quickEditModelOptions, quickEditPanel, quickEditSizeOptions, diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 1f44b871c..01956baa1 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -88,8 +88,15 @@ function KeyboardShortcutsHarness({ setIsPickingCharacterReferenceFromCanvas, ] = useState(true); const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true); + const [isPublicationMenuOpen, setIsPublicationMenuOpen] = useState(true); + const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] = + useState(true); const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = useState(true); + const [ + isPickingPublicationReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, + ] = useState(true); const [isSpacePanning, setIsSpacePanning] = useState(false); const [shiftPressed, setShiftPressed] = useState(false); const generateDialogRef = useRef(generateDialog); @@ -121,7 +128,10 @@ function KeyboardShortcutsHarness({ setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, + setIsPublicationMenuOpen, + setIsPublicationReferenceMenuOpen, setIsPickingIconSpecFromCanvas, + setIsPickingPublicationReferenceFromCanvas, setIsSpacePanning, setShiftPressed, }); @@ -156,9 +166,18 @@ function KeyboardShortcutsHarness({ {String(isPickingCharacterReferenceFromCanvas)} {String(isIconSpecMenuOpen)} + + {String(isPublicationMenuOpen)} + + + {String(isPublicationReferenceMenuOpen)} + {String(isPickingIconSpecFromCanvas)} + + {String(isPickingPublicationReferenceFromCanvas)} + {String(isSpacePanning)} {String(shiftPressed)}
@@ -234,7 +253,14 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(screen.getByTestId('character-menu').textContent).toBe('false'); expect(screen.getByTestId('character-picking').textContent).toBe('false'); expect(screen.getByTestId('icon-menu').textContent).toBe('false'); + expect(screen.getByTestId('publication-menu').textContent).toBe('false'); + expect(screen.getByTestId('publication-reference-menu').textContent).toBe( + 'false', + ); expect(screen.getByTestId('icon-picking').textContent).toBe('false'); + expect( + screen.getByTestId('publication-reference-picking').textContent, + ).toBe('false'); }); it('closes transient editor panels with Escape and collapses idle composers', () => { @@ -267,6 +293,13 @@ describe('useImageCanvasKeyboardShortcuts', () => { expect(screen.getByTestId('spec-menu').textContent).toBe('false'); expect(screen.getByTestId('character-menu').textContent).toBe('false'); expect(screen.getByTestId('icon-menu').textContent).toBe('false'); + expect(screen.getByTestId('publication-menu').textContent).toBe('false'); + expect(screen.getByTestId('publication-reference-menu').textContent).toBe( + 'false', + ); + expect( + screen.getByTestId('publication-reference-picking').textContent, + ).toBe('false'); }); it('keeps generating panels open when Escape closes transient chrome', () => { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index ed2fd4795..f14ea6154 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -35,7 +35,10 @@ type UseImageCanvasKeyboardShortcutsOptions = { setIsPickingCharacterSpecFromCanvas: (picking: boolean) => void; setIsPickingCharacterReferenceFromCanvas: (picking: boolean) => void; setIsIconSpecMenuOpen: (open: boolean) => void; + setIsPublicationMenuOpen: (open: boolean) => void; + setIsPublicationReferenceMenuOpen: (open: boolean) => void; setIsPickingIconSpecFromCanvas: (picking: boolean) => void; + setIsPickingPublicationReferenceFromCanvas: (picking: boolean) => void; setIsSpacePanning: (panning: boolean) => void; setShiftPressed: (pressed: boolean) => void; }; @@ -61,7 +64,8 @@ function isCanvasGenerationPlaceholderDialog( (dialog?.mode === 'generate' || dialog?.mode === 'spec' || dialog?.mode === 'character' || - dialog?.mode === 'icon') + dialog?.mode === 'icon' || + dialog?.mode === 'publication') ); } @@ -83,7 +87,10 @@ export function useImageCanvasKeyboardShortcuts({ setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsIconSpecMenuOpen, + setIsPublicationMenuOpen, + setIsPublicationReferenceMenuOpen, setIsPickingIconSpecFromCanvas, + setIsPickingPublicationReferenceFromCanvas, setIsSpacePanning, setShiftPressed, }: UseImageCanvasKeyboardShortcutsOptions) { @@ -101,7 +108,10 @@ export function useImageCanvasKeyboardShortcuts({ setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); setIsIconSpecMenuOpen(false); + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); setIsPickingIconSpecFromCanvas(false); + setIsPickingPublicationReferenceFromCanvas(false); setGenerateDialog((currentDialog) => { if (!currentDialog || currentDialog.status === 'generating') { return currentDialog; @@ -115,7 +125,7 @@ export function useImageCanvasKeyboardShortcuts({ if (currentDialog.mode === 'character') { return currentDialog; } - if (currentDialog.mode === 'icon') { + if (currentDialog.mode === 'icon' || currentDialog.mode === 'publication') { return currentDialog; } return null; @@ -159,7 +169,10 @@ export function useImageCanvasKeyboardShortcuts({ setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); setIsIconSpecMenuOpen(false); + setIsPublicationMenuOpen(false); + setIsPublicationReferenceMenuOpen(false); setIsPickingIconSpecFromCanvas(false); + setIsPickingPublicationReferenceFromCanvas(false); return; } } @@ -203,9 +216,12 @@ export function useImageCanvasKeyboardShortcuts({ setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, + setIsPublicationMenuOpen, + setIsPublicationReferenceMenuOpen, setIsPickingCharacterReferenceFromCanvas, setIsPickingCharacterSpecFromCanvas, setIsPickingIconSpecFromCanvas, + setIsPickingPublicationReferenceFromCanvas, setIsSpacePanning, setIsSpecMenuOpen, setQuickEditPanel, diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index df67f5055..a1aafd6c3 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -177,6 +177,7 @@ function StageInteractionsHarness({ isPickingCharacterSpecFromCanvas: false, isPickingCharacterReferenceFromCanvas: false, isPickingIconSpecFromCanvas: false, + isPickingPublicationReferenceFromCanvas: false, clearCanvasFocus: () => { setSelectedLayerId(null); setSelectedLayerIds([]); @@ -185,6 +186,7 @@ function StageInteractionsHarness({ pickCharacterSpecFromLayer, pickCharacterReferenceFromLayer: vi.fn(), pickIconSpecFromLayer, + pickPublicationReferenceFromLayer: vi.fn(), activateCanvasGenerationDialog, updateCanvasGenerationDialogById: (dialogId, updater) => { setGenerateDialog((currentDialog) => { diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 865a27cb3..20ee64e6e 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -56,10 +56,12 @@ type UseImageCanvasStageInteractionsOptions = { isPickingCharacterSpecFromCanvas: boolean; isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; + isPickingPublicationReferenceFromCanvas: boolean; clearCanvasFocus: () => void; pickCharacterSpecFromLayer: (layer: CanvasLayer) => void; pickCharacterReferenceFromLayer: (layer: CanvasLayer) => void; pickIconSpecFromLayer: (layer: CanvasLayer) => void; + pickPublicationReferenceFromLayer: (layer: CanvasLayer) => void; activateCanvasGenerationDialog: ( dialog: CanvasGenerationDialogState, ) => void; @@ -94,10 +96,12 @@ export function useImageCanvasStageInteractions({ isPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, + isPickingPublicationReferenceFromCanvas, clearCanvasFocus, pickCharacterSpecFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, + pickPublicationReferenceFromLayer, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, moveViewportFromMinimapPointer, @@ -217,6 +221,16 @@ export function useImageCanvasStageInteractions({ pickIconSpecFromLayer(layer); return; } + if ( + isPickingPublicationReferenceFromCanvas && + generateDialog?.mode === 'publication' + ) { + event.preventDefault(); + event.stopPropagation(); + suppressNextLayerClickRef.current = true; + pickPublicationReferenceFromLayer(layer); + return; + } event.preventDefault(); event.stopPropagation(); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 220fa2fa9..411ea9d79 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -208,6 +208,36 @@ export function useImageCanvasUploadWorkflow({ [setGenerateDialog], ); + const addPublicationReferenceFiles = useCallback( + async (files: FileList | File[]) => { + const imageFiles = Array.from(files).filter(isImageFile); + if (!imageFiles.length) { + window.alert('请选择图片文件'); + return; + } + + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + id: `upload-publication-reference-${Date.now()}-${index}`, + label: file.name || `宣发参考图${index + 1}`, + src: await readImageFileAsDataUrl(file), + })), + ); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'publication' + ? { + ...setFailedGenerationIdle(currentDialog), + publicationReferences: [ + ...(currentDialog.publicationReferences ?? []), + ...references, + ], + } + : currentDialog, + ); + }, + [setGenerateDialog], + ); + const uploadAssetFile = useCallback( async (file: File, options: UploadAssetFileOptions) => { if (!isImageFile(file)) { @@ -477,6 +507,8 @@ export function useImageCanvasUploadWorkflow({ void addCharacterReferenceFiles(files); } else if (currentUploadTarget === 'icon-spec') { void addIconSpecReferenceFiles(files); + } else if (currentUploadTarget === 'publication-reference') { + void addPublicationReferenceFiles(files); } else { addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); } @@ -490,6 +522,7 @@ export function useImageCanvasUploadWorkflow({ addCharacterSpecReferenceFiles, addSpecReferenceFiles, addIconSpecReferenceFiles, + addPublicationReferenceFiles, addUploadedFiles, setUploadTarget, ], diff --git a/src/index.css b/src/index.css index 12106d6e9..7a519eea5 100644 --- a/src/index.css +++ b/src/index.css @@ -4721,7 +4721,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { color: #0369a1; } -.image-canvas-editor__spec-tool-wrap { +.image-canvas-editor__spec-tool-wrap, +.image-canvas-editor__publication-tool-wrap { position: relative; display: inline-flex; } @@ -4730,15 +4731,127 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-width: 8.8rem; } +.image-canvas-editor__publication-menu { + min-width: 8.4rem; +} + .image-canvas-editor__portal-menu { z-index: 70; } -.image-canvas-editor__spec-menu-item { +.image-canvas-editor__spec-menu-item, +.image-canvas-editor__publication-menu-item { font-weight: 850; white-space: nowrap; } +.image-canvas-editor__publication-composer { + position: absolute; + z-index: 13; + display: grid; + width: min(36rem, calc(100% - 1.5rem)); + max-height: min(34rem, calc(100vh - 7rem)); + gap: 0.6rem; + overflow: auto; + border: 1px solid #cfd6df; + border-radius: 0.5rem; + background: #ffffff; + padding: 0.72rem; + color: #1f2937; + box-shadow: 0 18px 46px rgba(15, 23, 42, 0.16); + transform: translateX(-50%); +} + +.image-canvas-editor__publication-composer-header, +.image-canvas-editor__publication-composer-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.image-canvas-editor__publication-composer-header span, +.image-canvas-editor__publication-composer-footer span { + color: #64748b; + font-size: 0.72rem; + font-weight: 820; + line-height: 1.35; +} + +.image-canvas-editor__publication-composer-header h2 { + margin: 0.08rem 0 0; + color: #0f172a; + font-size: 0.98rem; + font-weight: 900; +} + +.image-canvas-editor__publication-composer-header strong { + flex: 0 0 auto; + border: 1px solid #dbe3ee; + border-radius: 0.45rem; + background: #f8fafc; + padding: 0.3rem 0.5rem; + color: #475569; + font-size: 0.72rem; + font-weight: 850; +} + +.image-canvas-editor__publication-composer-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(11rem, 0.62fr); + gap: 0.6rem; +} + +.image-canvas-editor__publication-input { + min-height: 2.45rem; +} + +.image-canvas-editor__publication-description { + min-height: 4.4rem; +} + +.image-canvas-editor__publication-reference-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.3rem; +} + +.image-canvas-editor__publication-ref-thumb { + position: relative; + display: inline-flex; + width: 3.2rem; + height: 3.2rem; + overflow: hidden; + border: 1px solid #d7dfe9; + border-radius: 0.5rem; + background: #f8fafc; +} + +.image-canvas-editor__publication-ref-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-canvas-editor__publication-ref-thumb span { + position: absolute; + right: 0.18rem; + bottom: 0.18rem; + min-width: 1rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.76); + color: #ffffff; + font-size: 0.62rem; + font-weight: 850; + text-align: center; +} + +.image-canvas-editor__publication-reference-add { + min-width: 7.8rem; + height: 3.2rem; +} + .image-canvas-editor__generation-composer { position: absolute; z-index: 13; @@ -5830,6 +5943,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { transform: none; } + .image-canvas-editor__publication-composer { + left: 0.75rem !important; + right: auto; + top: auto !important; + bottom: 3.85rem; + width: calc(100vw - 1.5rem); + max-height: calc(100vh - 7rem); + transform: none !important; + } + + .image-canvas-editor__publication-composer-fields { + grid-template-columns: minmax(0, 1fr); + } + .image-canvas-editor__icon-spec-row { grid-template-columns: minmax(14rem, 1fr) auto; } diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 9e56d01d2..e688064c3 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -698,6 +698,49 @@ describe('editorProjectClient', () => { ); }); + it('passes publication material generation kind and size to the backend BFF', async () => { + requestJsonMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,publication', + width: 720, + height: 1280, + sourceType: 'generated', + prompt: '宣发素材 prompt', + actualPrompt: '宣发素材 prompt', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'vector-publication-1', + }); + + const result = await generateEditorImage({ + prompt: '宣发素材 prompt', + size: '720x1280', + kind: 'publication-material', + candidateCount: 5, + referenceImageSrcs: ['data:image/png;base64,ref'], + }); + + expect(result.height).toBe(1280); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/images/generations', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: '宣发素材 prompt', + size: '720x1280', + kind: 'publication-material', + candidateCount: 5, + referenceImageSrcs: ['data:image/png;base64,ref'], + }), + }), + '生成图片失败', + expect.objectContaining({ + timeoutMs: 1_200_000, + retry: { maxRetries: 0 }, + }), + ); + }); + it('generates editor character animations through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ taskId: 'character-animation-1', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 5a7fc6c7d..f2306f533 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -83,10 +83,11 @@ export type EditorAssetLibrarySnapshot = { export type EditorImageGenerationInput = { prompt: string; size?: string; - kind?: 'spec' | 'character' | 'quick-edit'; + kind?: 'spec' | 'character' | 'quick-edit' | 'publication-material'; model?: string; aspectRatio?: string; imageSize?: string; + candidateCount?: number; referenceImageSrcs?: string[]; }; @@ -105,7 +106,7 @@ export type EditorImageEditInput = { model?: string; }; -export type EditorImageGenerationResult = { +export type EditorImageGenerationItemResult = { imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; @@ -119,6 +120,10 @@ export type EditorImageGenerationResult = { taskId: string; }; +export type EditorImageGenerationResult = EditorImageGenerationItemResult & { + images?: EditorImageGenerationItemResult[]; +}; + export type EditorIconSpritesheetIconResult = { name: string; imageSrc: string; @@ -464,6 +469,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { ...(input.model ? { model: input.model } : {}), ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), + ...(input.candidateCount ? { candidateCount: input.candidateCount } : {}), ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}),