From 0ebf6242bbd0322d0932daa84c2f394039ab2df4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 00:05:25 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=AE=A3=E5=8F=91=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=94=9F=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 } : {}), From cd7fca4a4f3ca85512a2972da41daed680472ee4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 15:17:06 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=AE=B0=E5=BF=86=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md 将复杂任务记忆入口固定到 docs/project-memory 项目基线与踩坑记录同步声明 .hermes 仅保存 Hermes 工具资源 决策记录新增 2026-06-18 长期记忆路径口径并标注旧 .hermes 记录为历史 --- AGENTS.md | 2 +- docs/project-memory/shared-memory/decision-log.md | 14 +++++++++++--- docs/project-memory/shared-memory/pitfalls.md | 4 ++-- docs/【项目基线】当前产品与工程约束-2026-05-15.md | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 653626033..5699de822 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,12 @@ - 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员和各自本地 Agent 之间同步长期项目知识。 - [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。 - 开始复杂开发任务前,除阅读本文件外,还应优先读取: - - [`.hermes/README.md`](.hermes/README.md) - [`docs/project-memory/README.md`](docs/project-memory/README.md) - [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md) - [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md) - [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md) - 与任务相关的 [`docs/project-memory/shared-memory/decision-log.md`](docs/project-memory/shared-memory/decision-log.md) 和 [`docs/project-memory/shared-memory/pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md) +- 仅在需要使用仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md);长期项目记忆不要从 `.hermes/` 读取。 - 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件。 - 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 - 若 `docs/project-memory/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1424f58e5..d37951164 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -21,9 +21,17 @@ - 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。 - 决策:`.codex/skills/` 下只保留 `spacetimedb-cli`、`spacetimedb-concepts`、`spacetimedb-rust` 三个本地 SpacetimeDB skill;删除 `spacetimedb-typescript`、`spacetimedb-csharp`、`spacetimedb-unity`。前端 / Node 侧如需处理 SpacetimeDB 订阅或绑定,按当前生成绑定、项目代码和官方文档核对,不再依赖仓库内单独 TypeScript skill。 - 影响范围:`AGENTS.md` 的 SpacetimeDB skill 清单、`.codex/skills/` 本地 skill 维护范围、后续 SpacetimeDB 设计 / CLI / Rust module 开发协作口径。 -- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md .hermes/shared-memory/decision-log.md` 和 `npm run check:encoding`。 +- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md docs/project-memory/shared-memory/decision-log.md` 和 `npm run check:encoding`。 - 关联文档:`AGENTS.md`、`.codex/skills/spacetimedb-cli/SKILL.md`、`.codex/skills/spacetimedb-concepts/SKILL.md`、`.codex/skills/spacetimedb-rust/SKILL.md`。 +## 2026-06-18 长期项目记忆固定读取 `docs/project-memory/` + +- 背景:项目记忆已从仓库 `.hermes/` 迁移到 `docs/project-memory/`,但部分协作说明仍把 `.hermes/` 写成共享记忆或计划容器,容易让 Agent 和开发者回到旧路径。 +- 决策:长期项目记忆、计划和 TODO 只从 `docs/project-memory/` 读取和维护;`.hermes/` 仅保存 Hermes 专用的仓库级 skills、plugins 和启用说明。`AGENTS.md` 的复杂任务阅读清单不再把 `.hermes/README.md` 放在项目记忆入口中,只有使用 Hermes 工具资源时才读取它。 +- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/README.md`、`docs/project-memory/shared-memory/`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 验证方式:扫描 `AGENTS.md`、当前 `docs/` 与 `.hermes/README.md`,确认当前口径不再把 `.hermes/` 描述为长期项目记忆路径。 +- 关联文档:`AGENTS.md`、`docs/project-memory/README.md`、`.hermes/README.md`。 + ## 2026-06-13 图片大图预览统一为黑底全屏查看器 - 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。 @@ -1785,10 +1793,10 @@ - 验证方式:VN 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`cargo test -p api-server visual_novel`、`cargo test -p api-server creation_agent_document_input`。 - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 -## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆 +## 2026-05-04 历史:曾在仓库 `.hermes/` 中建立团队共享记忆 - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 -- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。 +- 决策:不共享个人 `~/.hermes`,当时先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills;该路径已被 2026-06-18 的 `docs/project-memory/` 口径取代,当前不再作为长期项目记忆入口。 - 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/shared-memory/`。 - 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `docs/project-memory/shared-memory/` 文件。 - 关联文档:`.hermes/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0b94909cf..983c77463 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -797,11 +797,11 @@ - 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`;`npm run typecheck`。 - 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 -## `.hermes` 只放共享内容,不放个人 Hermes 配置 +## `.hermes` 只放 Hermes 工具资源,不放项目记忆或个人配置 - 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。 - 原因:仓库 `.hermes/` 与个人 `~/.hermes/` 名称相似。 -- 处理:仓库 `.hermes/` 只放 Markdown 共享记忆、计划和可公开 skills;不提交 `.env`、`config.yaml`、`sessions/`、`auth.json`。 +- 处理:仓库 `.hermes/` 只放 Hermes 专用 skills、plugins 和启用说明;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`;不提交 `.env`、`config.yaml`、`sessions/`、`auth.json`。 - 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。 - 关联:`.hermes/README.md`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 310adb1da..cfdb5b030 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -122,7 +122,7 @@ server-rs + Axum + SpacetimeDB - Issue tracker 是自托管 Gitea。可用 Gitea UI/API 或 `tea` CLI;不要用 GitHub `gh` 或 GitLab `glab`。 - 默认 triage labels:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。 - 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。 -- `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。 +- `.hermes/` 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。 - 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`。 ## 当前文档策略 From 638dc9f6f625066569527dafd32e9f49a3123652 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 15:49:20 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=AE=A3=E5=8F=91=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整详情五图工作流为单张竖版详情图生成 修正宣发素材明确像素尺寸透传和运营海报尺寸映射 压缩宣发参考图提交并提高后端请求体限制 补充前后端回归测试、文档和 Hermes 踩坑记录 --- .hermes/shared-memory/pitfalls.md | 16 +++ ...辑器】宣发素材工具演示入口设计-2026-06-17.md | 10 +- server-rs/crates/api-server/src/app.rs | 44 ++++++++ .../crates/api-server/src/editor_project.rs | 12 +++ .../api-server/src/modules/editor_project.rs | 27 +++-- .../api-server/src/openai_image_generation.rs | 2 +- .../src/vector_engine/request.rs | 31 ++++-- .../platform-image/tests/vector_engine.rs | 14 +++ .../ImageCanvasGenerationModel.ts | 2 +- ...ageCanvasGenerationSubmissionModel.test.ts | 10 +- .../ImageCanvasGenerationSubmissionModel.ts | 3 - .../ImageCanvasPublicationMaterialsModel.ts | 28 ++--- ...anvasGenerationSubmissionWorkflow.test.tsx | 102 ++++++++++++++---- ...ImageCanvasGenerationSubmissionWorkflow.ts | 29 ++++- .../image-editor/editorImageReference.test.ts | 17 ++- .../image-editor/editorImageReference.ts | 90 +++++++++++++++- .../image-editor/editorProjectClient.test.ts | 2 - 17 files changed, 365 insertions(+), 74 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0be1a9739..cebdfb200 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -47,6 +47,22 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +## 图片编辑器宣发素材参考图不要按原图 Data URL 提交 + +- 现象:宣发素材卡片里参考图缩略图已经出现,但点击生成后后端返回 `Failed to buffer the request body: length limit exceeded` 或前端只看到 `Failed to fetch`。 +- 原因:上传的宣发参考图曾以完整 Data URL 存入 `publicationReferences`,生成提交时直接放进 `/api/editor/images/generations` 的 `referenceImageSrcs` JSON;大图会超过 Axum 默认 `2MB` body limit,请求还没进入 handler 就被拦下。 +- 处理:宣发素材提交前统一用 `resolveEditorImageReferenceDataUrlForGeneration(...)` 把参考图解析并压缩到生成理解可用的 Data URL;`/api/editor/images/generations` 路由设置 `12MB` `DefaultBodyLimit` 作为兼容兜底。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts`;`cargo test -p api-server editor_image_generation_accepts_reference_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/modules/editor_project.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`。 + +## 图片编辑器宣发素材明确像素尺寸不要映射成比例预设 + +- 现象:宣发素材运营海报约束写的是 `1280 x 720`,但 VectorEngine 请求实际尺寸可能变成 `1536x1024`,用户看到贴片比例和约束不一致。 +- 原因:`platform-image` 的 VectorEngine 适配层曾把 `1280x720`、`1600x900` 这类明确像素值和 `16:9` 比例别名放在同一个归一化分支里,统一映射成旧的 provider 预设 `1536x1024`;前端和 api-server 传入的 `720x540`、`720x1280` 本身不是根因。 +- 处理:明确像素值统一保留并把 `*` 写法规范成 `x` 写法;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名继续映射 provider 预设。宣发素材入口使用 `720x540`、`720x1280`、`1280x720`。 +- 验证:`cargo test -p platform-image vector_engine --manifest-path server-rs/Cargo.toml`;`cargo test -p api-server editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/platform-image/src/vector_engine/request.rs`、`server-rs/crates/platform-image/tests/vector_engine.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`。 + ## 图片编辑器生成类菜单要挂到页面级 portal - 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。 diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md index 5bb9e36bd..a67348eb3 100644 --- a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -24,7 +24,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U 2. `publication-detail-gallery` - 名称:详情五图 - - 输出:五张详情图 + - 输出:单张详情图;需要五图时由用户连续生成五次,保持同一参考摘要和输入信息 - 尺寸:`720 x 1280`,`9:16` 竖版 - 适用:作品详情、应用介绍和运营分发图组 @@ -40,7 +40,7 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U - 可以维护:工作流框架、Prompt 约束、参考图解析约束、图片后处理验收约束。 - 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、5 泥点扣费、成品回画布。 -- 详情五图:点击一次生成 5 张 `720 x 1280` 竖版详情图,横向排布到画布;同一次操作只扣 5 泥点。 +- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次扣 5 泥点;需要五图时连续生成并复用同一游戏输入和参考图。 - 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。 - 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。 - 不修改:SpacetimeDB schema、前端发布页、作品发布系统。 @@ -49,7 +49,9 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U - 前端生成请求统一通过 `/api/editor/images/generations`。 - 宣发素材请求携带 `kind: publication-material`,后端仅对这个 kind 扣 5 泥点。 -- `publication-detail-gallery` 额外携带 `candidateCount: 5`;后端在同一次扣费操作内顺序生成 5 张图,并通过响应 `images` 列表返回。 +- `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。 +- 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。 +- 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。 - 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。 - 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。 @@ -65,6 +67,8 @@ Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 U ```bash npm run test -- src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts +cargo test -p platform-image vector_engine --manifest-path server-rs/Cargo.toml +cargo test -p api-server editor_image_generation_accepts_reference_body_above_default_limit --manifest-path server-rs/Cargo.toml cargo check -p api-server --manifest-path server-rs/Cargo.toml npm run check:encoding ``` diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index b70568e92..fc77cea69 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1516,6 +1516,50 @@ mod tests { assert!(!body_text.contains("length limit exceeded")); } + #[tokio::test] + async fn editor_image_generation_accepts_reference_body_above_default_limit() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body"); + let app = build_router(state); + let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); + let request_body = serde_json::json!({ + "prompt": "生成宣发素材", + "kind": "publication-material", + "size": "720x540", + "referenceImageSrcs": [reference_image_src], + }) + .to_string(); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/generations") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("VECTOR_ENGINE_BASE_URL"), + "handler should parse the oversized editor reference before checking VectorEngine config: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a4b422ea7..038cb40a9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1678,6 +1678,18 @@ mod tests { normalize_editor_image_generation_size(Some("2048x1152")), "2048x1152" ); + assert_eq!( + normalize_editor_image_generation_size(Some("720x540")), + "720x540" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("720x1280")), + "720x1280" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("1280x720")), + "1280x720" + ); assert_eq!( normalize_editor_image_generation_size(Some("640x640")), "640x640" diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index dd23407fc..f81510837 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -1,5 +1,7 @@ use axum::{ - Router, middleware, + Router, + extract::DefaultBodyLimit, + middleware, routing::{get, patch, post}, }; @@ -8,14 +10,16 @@ use crate::{ editor_project::{ create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, - delete_editor_project, edit_editor_image, generate_editor_image, - generate_editor_icon_spritesheet, get_editor_asset_library, get_editor_project, - list_editor_projects, load_recent_editor_project, rename_editor_project, - save_editor_project_layout, update_editor_asset, update_editor_asset_folder, + delete_editor_project, edit_editor_image, generate_editor_icon_spritesheet, + generate_editor_image, get_editor_asset_library, get_editor_project, list_editor_projects, + load_recent_editor_project, rename_editor_project, save_editor_project_layout, + update_editor_asset, update_editor_asset_folder, }, state::AppState, }; +const EDITOR_IMAGE_GENERATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; + pub fn router(state: AppState) -> Router { Router::new() .route( @@ -99,10 +103,15 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/editor/images/generations", - post(generate_editor_image).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_image) + .layer(DefaultBodyLimit::max( + // 中文注释:图片画布生图仍兼容参考图 Data URL 入参,宣发素材提交前会压缩,后端保留有限兜底。 + EDITOR_IMAGE_GENERATION_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/images/edits", diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 987896fb8..2003c576a 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -488,7 +488,7 @@ mod tests { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), - "1280*720", + "16:9", 2, &["data:image/png;base64,abcd".to_string()], ); diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index 26219012c..e641083d1 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -58,17 +58,28 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str { } pub fn normalize_image_size(size: &str) -> String { - match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2k" => { - "1536x1024" - } - "1920*1080" | "1920x1080" | "2048*1152" | "2048x1152" | "2k-16:9" => "2048x1152", - "1024*1536" | "1024x1536" | "9:16" => "1024x1536", - value if !value.is_empty() => value, - _ => "1024x1024", + let size = size.trim(); + match size { + "1:1" => "1024x1024".to_string(), + "16:9" | "2k" => "1536x1024".to_string(), + "2k-16:9" => "2048x1152".to_string(), + "9:16" => "1024x1536".to_string(), + value if is_explicit_pixel_size(value) => normalize_explicit_pixel_size(value), + value if !value.is_empty() => value.to_string(), + _ => "1024x1024".to_string(), } - .to_string() +} + +fn is_explicit_pixel_size(value: &str) -> bool { + let separator = if value.contains('x') { 'x' } else { '*' }; + let Some((width, height)) = value.split_once(separator) else { + return false; + }; + width.parse::().is_ok() && height.parse::().is_ok() +} + +fn normalize_explicit_pixel_size(value: &str) -> String { + value.replace('*', "x") } pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index 208a3b9b2..49f06b177 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -44,6 +44,20 @@ fn vector_engine_module_exposes_provider_protocol_helpers() { ); } +#[test] +fn vector_engine_keeps_explicit_publication_material_pixel_sizes() { + let cover = + build_vector_engine_image_request_body("宣发首图", None, "720x540", 1, &[]); + let detail = + build_vector_engine_image_request_body("详情单图", None, "720x1280", 1, &[]); + let poster = + build_vector_engine_image_request_body("运营海报", None, "1280x720", 1, &[]); + + assert_eq!(cover["size"], "720x540"); + assert_eq!(detail["size"], "720x1280"); + assert_eq!(poster["size"], "1280x720"); +} + #[test] fn vector_engine_normalizes_2k_landscape_spec_size() { let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 90181b5ee..323e91c19 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -409,7 +409,7 @@ export function buildPublicationMaterialsGenerationPrompt({ }) { const inputPrompt = buildPublicationMaterialsPrompt(gameInfo); return [ - `【宣发素材类型】${workflow.label}`, + `【宣发素材类型】${workflow.promptLabel ?? workflow.label}`, inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。', `【生图约束】\n${workflow.promptConstraints.join('\n')}`, `【参考图约束】\n${workflow.referenceConstraints.join('\n')}`, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 0a74811d3..7b43ae1f6 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -262,7 +262,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(prompt).toContain('输出检查:720 x 540,4:3'); }); - it('builds detail gallery publication plans as 720p portrait images', () => { + it('builds detail gallery publication plans as one 720p portrait image', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'publication', @@ -282,17 +282,19 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan).toMatchObject({ kind: 'image', input: { - prompt: expect.stringContaining('【宣发素材类型】详情五图'), + prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', - candidateCount: 5, }, result: { title: '11 宣发素材', }, }); expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( - '五张均为 720 x 1280 竖版', + '本次只生成一张 720 x 1280 竖版详情图', + ); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '不是五图合集或拼贴', ); }); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 456ebcb6b..9bed3c4a2 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -178,9 +178,6 @@ export function buildImageGenerationSubmissionPlan({ prompt: generationPrompt, size: workflow.outputSize, kind: 'publication-material', - ...(workflow.id === 'publication-detail-gallery' - ? { candidateCount: 5 } - : {}), ...(dialog.publicationReferences?.length ? { referenceImageSrcs: dialog.publicationReferences.map( diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts b/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts index 30bc51006..3887e34fe 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts @@ -3,6 +3,7 @@ import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes'; export type PublicationMaterialsWorkflow = { id: PublicationMaterialsWorkflowId; label: string; + promptLabel?: string; englishName: string; outputLabel: string; sizeLabel: string; @@ -50,33 +51,36 @@ export const PUBLICATION_MATERIALS_WORKFLOWS: PublicationMaterialsWorkflow[] = [ { id: 'publication-detail-gallery', label: '详情五图', + promptLabel: '详情页单图', englishName: 'Detail Gallery', - outputLabel: '五张详情图', - sizeLabel: '720p / 9:16', + outputLabel: '单张详情图', + sizeLabel: '720 x 1280', outputSize: '720x1280', scenario: '作品详情、应用介绍和运营分发图组', fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'], framework: [ - '将玩法拆成 5 个可视化卖点', - '每张图独立生成,统一标题样式和美术风格', - '覆盖玩法说明、操作反馈、关卡目标和情绪价值', - '逐张验收文字、构图、主体和图组一致性', + '选择当前这张详情图要表达的单一卖点', + '生成一张带短标题的竖版详情页素材', + '让主体、玩法反馈和背景符号保持同一套游戏物料气质', + '按单图尺寸、标题可读性、主体完整度和非合集画面验收', ], promptConstraints: [ + '本次只生成一张 720 x 1280 竖版详情图;不要把五张图合成一张长图、拼图、多分镜合集或截图说明页', '每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句', - '五张图保持同一游戏名、同一主体物件体系、同一画风、同一色彩气质,像同一套游戏详情物料', - '五张图可以独立生成,允许顺序差异,不强依赖首图作为上游输入;但同一批次必须复用同一份参考摘要,保证一致性', - '每张图构图要有差异:避免五张重复同一角度、同一主体姿势、同一背景', + '画面保持与同一游戏后续详情图可组成一套:同一游戏名、同一主体物件体系、同一画风、同一色彩气质', + '构图要服务当前单一卖点,主体、玩法道具、反馈效果和背景层级清楚;核心信息缩小后仍能识别', '短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、UI 挡主体', + '连续生成多张详情图时,可以独立生成并允许顺序差异;同一批次复用同一份参考摘要,主动拉开角度、主体姿势和背景变化', + '避免五联图、分栏、多格漫画、长图合集、重复小图拼贴、文字乱码、伪文字、英文标题、非中文标题', ], referenceConstraints: [ '从参考图解析并统一:画风、主体物件、玩法道具、背景符号、地域/场景元素、色板和光影氛围', '参考图只作为视觉理解来源,不照搬截图 UI、水印、边框、按钮、状态栏、弹窗或无关文字', ], postProcessConstraints: [ - '输出检查:五张均为 720 x 1280 竖版;每张一个卖点;标题不截断、不乱码;五张整体风格一致', - 'metadata 分别记录每张图的卖点、提示词和序号', - '标记低一致性图片,供后续局部重生成或替换', + '输出检查:单张 720 x 1280 竖版;只表达一个卖点;标题不截断、不乱码;不是五图合集或拼贴', + 'metadata 记录本张图的卖点、提示词和参考摘要', + '仅生成画布候选物料,不触发投放、分享或发布', ], }, { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 870b60588..22632a23c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -18,6 +24,9 @@ import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel'; import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow'; const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); +const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => + vi.fn(), +); const editEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); @@ -25,6 +34,8 @@ const generateEditorImageMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, + resolveEditorImageReferenceDataUrlForGeneration: + resolveEditorImageReferenceDataUrlForGenerationMock, })); vi.mock('../../services/image-editor/editorProjectClient', async () => { @@ -216,10 +227,7 @@ function SubmissionWorkflowHarness({ > 填写快速编辑 -