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/TRACKING.md b/TRACKING.md index 366d27a80..9d577ebd0 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -144,6 +144,7 @@ - 2026-06-17 前端拆分第十九执行批次:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。 - 2026-06-17 前端拆分第二十执行批次:新增 `ImageCanvasMetadataModalView`,把图片信息弹窗从主视图抽出,承载图片类型、生成输入、参考图、模型、分辨率、Provider、Task 和 Object 信息渲染;主视图只保留 `metadataLayer` 状态和关闭回调。同步修复未登录进入编辑器时项目 / 素材接口抢跑 401、`重置画布视图` 点击事件误传给适合视图函数的问题。新增组件单测覆盖生成图 metadata、上传图 fallback 和关闭回调,新增 hook / 主视图测试覆盖未登录不请求受保护素材 / 工程数据和重置按钮回归;主视图从 1405 行降至 1337 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后直接弹出 `账号入口`,且未登录状态下没有发起 `/api/editor/*` 请求;登录临时开发账号后 `重置画布视图` 无控制台错误,`画布背景设置` 保持 Lovart 式白色浮层,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`,上传素材可加入画布,右上角图片信息按钮可打开不透明白底元数据弹窗,关闭后 `AI画布工具栏` 仍可见。 - 2026-06-17 前端拆分第二十一执行批次:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。 +- 2026-06-18 生成中占位图删除确认:画布生成占位框增加删除按钮,删除 `generating` 状态占位图时统一弹出 `删除生成中的占位图` 二次确认,并提示 `已消耗的泥点不会返还`;空闲占位仍直接删除。键盘 Backspace / Delete 删除生成占位也改走同一请求入口,避免绕过提示;确认删除后清理选中态、右键菜单并切回选择工具,已删除的生成任务即使后续返回结果也不会落图。验证命令:`npm run test -- src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:本地 Vite + Chrome CDP 打开 `/editor/canvas`,点击 `生成图片` 创建生成中占位,删除按钮弹出确认框,取消后占位保留,确认后占位移除且提示包含泥点不返还。 - 2026-06-17 前端拆分第二十二执行批次:新增 `useImageCanvasAssetPointerDragBridge`,把素材卡片 pointer 拖拽到画布 / 文件夹的全局监听、拖拽激活、画布 drop 提示、文件夹高亮、移动素材、加入画布和点击抑制从主视图抽出;主视图继续负责素材库事实、画布建层、历史和工程资源持久化。同步恢复 `账号入口` 认证弹窗 portal 渲染,避免全屏画布遮住登录弹窗;`画布背景设置` 调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 显示当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;上传图片进入 `项目素材`,真实 pointer 拖拽素材到画布后图层数从 0 到 1 且 `AI画布工具栏` 保持可见;新建 `SmokeFolder` 后真实 pointer 拖拽素材到文件夹,`项目素材` 变 0、`SmokeFolder` 变 1,素材执行移动而非拷贝。控制台仅有未登录 refresh 401,登录后编辑器 API 均为 200。 - 2026-06-17 前端拆分第二十三执行批次:新增 `ImageCanvasOverlayModel`,把生成输入框锚定、图标素材生成面板宽度、选中图片工具栏边界、快速编辑面板和角色动画面板定位从主视图抽出为纯模型;主视图继续保留生成 / quick edit / 角色动画状态机和舞台编排。新增模型单测覆盖锚定优先级、生成中隐藏、icon 宽度、工具栏 clamp、quick edit / 角色动画边界和生成 dialog 模式识别;主视图从 1182 行降至 1133 行。验证命令:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开显示 `账号入口`,登录后 `画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;素材库已有素材真实 pointer 拖入画布后图层数从 0 到 1,工具栏保持可见,截图留存于 `output/playwright/editor-overlay-model-regression-20260617.png`。 - 2026-06-17 前端拆分第二十四执行批次:新增 `useImageCanvasAssetCanvasBridge`,把删除素材清理画布图层、素材加入画布、素材 pointer 拖入画布 / 文件夹、画布 drag/drop 分流和文件拖入画布参数组装从主视图抽成素材到画布桥接 hook;主视图继续保留素材库事实、上传读取、工程资源持久化和历史触发。新增 hook 单测覆盖素材建层、pointer drop 入画布和删除素材清理关联图层 / 选中态;主视图从 1133 行降至 1086 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 清空会话后未登录直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 是完整设置面板,包含当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,HEX 输入 `#ffffff` 后变为白色,按 Escape 关闭面板;登录后上传图片请求 `/api/editor/assets` 返回 200,素材库出现上传素材,`AI画布工具栏` 保持可见。 diff --git a/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md new file mode 100644 index 000000000..3b9088964 --- /dev/null +++ b/docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md @@ -0,0 +1,77 @@ +# 宣发素材工具入口设计 + +## 目标 + +在 Web 图片画布编辑器底部工具栏新增 `宣发素材` 分组入口,用于生成游戏发布物料。点击具体工作流后,不打开说明弹窗,而是在画布内创建一个可拖拽的宣发素材生成卡片;卡片包含游戏名、游戏分类、一句话描述游戏三个输入框、参考图控件、尺寸信息和生成按钮。点击生成后走图片画布现有生图 BFF,自动扣 5 泥点,生成成功后把成品图片放回画布。 + +Prompt 输入摘要与 Prompt 约束只作为内部生成契约维护,不在 UI 中默认暴露;真实生图请求会把结构化游戏输入和对应工作流约束合并为内部 Prompt。 + +三个工作流的输入框默认提示统一为:游戏名 `马戏团午夜惊魂`,游戏分类 `非对称对抗`,一句话描述游戏 `神奇数字马戏团里,帕姆尼失控了,找到钥匙,开门逃离马戏团,千万别被抓住了`。提示只作为占位文案,用户未填写时不进入生图 Prompt。 + +## 入口 + +- 页面:`/editor/canvas` +- 位置:底部 AI 画布工具栏 +- 工具:`宣发素材` +- 交互:点击工具后弹出独立浮层菜单,菜单项包括 `游戏首图`、`详情五图`、`运营海报`;点击菜单项后在画布中心创建对应工作流的生成占位卡片,卡片可拖拽,设置面板锚定在占位卡片下方。 +- 标题:画布占位卡片左上角使用英文功能名,例如 `Home Image Generator`、`Detail Gallery Generator`、`Promo Poster Generator`,与其它生成卡片保持一致。 + +## 三条独立工作流 + +1. `publication-cover-image` + - 名称:游戏首图 + - 输出:单张主视觉 + - 尺寸:`720 x 540` + - 适用:推荐流、作品卡和首屏传播位 + +2. `publication-detail-gallery` + - 名称:详情五图 + - 输出:单张详情图;需要五图时由用户连续生成五次,保持同一参考摘要和输入信息 + - 尺寸:`720 x 1280`,`9:16` 竖版 + - 适用:作品详情、应用介绍和运营分发图组 + +3. `publication-promo-poster` + - 名称:运营海报 + - 输出:单张活动海报 + - 尺寸:`1280 x 720`,`16:9` 横版 + - 版式:右下角保留白色二维码留白方块,并在方块内部上方加入黑色小字提示“此处换上你的游戏二维码” + - 适用:社群、活动、渠道投放和运营节日位 + +## 职责边界 + +本功能边界只负责图片编辑器里的宣发素材生成: + +- 可以维护:工作流框架、Prompt 约束、参考图解析约束、图片后处理验收约束。 +- 已落地:画布内生成占位卡片、游戏名 / 游戏分类 / 一句话描述游戏结构化输入、参考图上传 / 从画布选择、尺寸标识、真实生图、5 泥点扣费、成品回画布。 +- 详情五图:当前 UI 只有一个生成卡片,一次生成 1 张 `720 x 1280` 竖版详情图,单次扣 5 泥点;需要五图时连续生成并复用同一游戏输入和参考图。 +- 不在 UI 暴露:Prompt 输入摘要、Prompt 约束预览、内部提示词拼接过程。 +- 暂不实现:作品发布状态、审核、分享渠道、远程 Genarrative 接口、作品架或广场能力。 +- 不修改:SpacetimeDB schema、前端发布页、作品发布系统。 + +## 生成与计费 + +- 前端生成请求统一通过 `/api/editor/images/generations`。 +- 宣发素材请求携带 `kind: publication-material`,后端仅对这个 kind 扣 5 泥点。 +- `publication-detail-gallery` 不再携带 `candidateCount: 5`;后端仍兼容多候选请求,但当前宣发素材入口不主动批量生成。 +- 尺寸请求必须使用明确像素值:游戏首图 `720x540`、详情图 `720x1280`、运营海报 `1280x720`。VectorEngine 适配层对明确像素值保持原样透传;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名才走 provider 预设映射。 +- 参考图在提交 `/api/editor/images/generations` 前由前端压缩成适合生成理解的图片 Data URL,避免原图 Data URL 撑爆 JSON 请求体;后端该路由保留 `12MB` body limit 作为兼容兜底。 +- 扣费通过现有钱包资产操作封装执行;上游生成失败或未返回图片时按现有补偿逻辑退款。 +- 生成成功后,成品作为图片画布生成图层加入画布,并保留游戏输入和参考图摘要供图层信息使用。 + +## 代码位置 + +- 工具入口:`src/components/image-editor/ImageCanvasBottomToolbarView.tsx` +- 菜单和生成卡片挂载:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx` +- 工作流状态:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts` +- 工作流内容模型:`src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts` +- 画布内生成卡片设置面板:`src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx` + +## 验证命令 + +```bash +npm run test -- src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts +cargo test -p platform-image vector_engine --manifest-path server-rs/Cargo.toml +cargo test -p api-server editor_image_generation_accepts_reference_body_above_default_limit --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run check:encoding +``` diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index 39df1af6d..5997df3b1 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -121,4 +121,3 @@ POST /api/editor/audios/background-music/generations - 成功后的音频卡展示提示词;卡片、悬停角标、信息弹窗和待生成占位均显示时长。 - 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。 - 刷新后 layout 能恢复音频生成器和音频图层。 - 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/`。 ## 当前文档策略 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c7d44d14e..b4a404abb 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1559,6 +1559,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_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 1fb6ad586..e16c7deb2 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -7,6 +7,7 @@ const EDITOR_SPEC_GENERATION_MUD_POINTS: u32 = 5; const EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS: u32 = 12; const EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS: u32 = 12; const EDITOR_UI_DESIGN_GENERATION_MUD_POINTS: u32 = 12; +const EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS: u32 = 5; pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10; pub(crate) const EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND: u32 = 20; @@ -20,6 +21,7 @@ pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 { Some("character") => EDITOR_CHARACTER_IMAGE_GENERATION_MUD_POINTS, Some("icon") => EDITOR_ICON_SPRITESHEET_GENERATION_MUD_POINTS, Some("ui-design") => EDITOR_UI_DESIGN_GENERATION_MUD_POINTS, + Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_GENERATION_MUD_POINTS, _ => EDITOR_IMAGE_GENERATION_MUD_POINTS, } } @@ -68,5 +70,9 @@ mod tests { assert_eq!(editor_image_generation_mud_points(Some("character")), 12); assert_eq!(editor_image_generation_mud_points(Some("icon")), 12); assert_eq!(editor_image_generation_mud_points(Some("ui-design")), 12); + assert_eq!( + editor_image_generation_mud_points(Some("publication-material")), + 5 + ); } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7f69ef14a..0c1309a02 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -34,6 +34,7 @@ use spacetime_client::{ use crate::{ api_response::json_success_body, + asset_billing::execute_billable_asset_operation_with_cost, auth::AuthenticatedAccessToken, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -66,6 +67,7 @@ const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "提取画面中的所有 const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; +const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -784,10 +786,12 @@ pub async fn generate_editor_image( legacy_size }; let normalized_kind = payload.kind.as_deref().map(str::trim); - let _configured_price_mud_points = + let configured_price_mud_points = crate::editor_generation_config::editor_image_generation_mud_points(normalized_kind); let is_character_generation = matches!(normalized_kind, Some("character")); let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); + let is_publication_material_generation = + matches!(normalized_kind, Some("publication-material")); let submitted_prompt = if is_character_generation { build_editor_character_image_prompt(role_setting.as_str()) } else if is_ui_design_generation { @@ -808,6 +812,7 @@ pub async fn generate_editor_image( Some("spec") => "图片画布生成规范", Some("quick-edit") => "图片画布快速编辑图片", Some("ui-design") => "图片画布生成UI设计图", + Some("publication-material") => "图片画布生成宣发素材", _ => "图片画布生成图片", }; let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { @@ -852,45 +857,60 @@ pub async fn generate_editor_image( } else { image_size }; - let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - create_openai_nanobanana_generate_content( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), - negative_prompt, - generation_options.aspect_ratio, - generation_options.provider_image_size, - reference_images.as_slice(), - failure_context, - ) - .await? - } else if reference_images.is_empty() { - create_openai_image_generation_with_model( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), - negative_prompt, - image_size.as_ref(), - 1, - &[], - failure_context, + let generate_operation = async { + if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + &http_client, + &settings, + generation_options.model, + submitted_prompt.as_str(), + negative_prompt, + generation_options.aspect_ratio, + generation_options.provider_image_size, + reference_images.as_slice(), + failure_context, + ) + .await + } else if reference_images.is_empty() { + create_openai_image_generation_with_model( + &http_client, + &settings, + generation_options.model, + submitted_prompt.as_str(), + negative_prompt, + image_size.as_ref(), + 1, + &[], + failure_context, + ) + .await + } else { + create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + generation_options.model, + submitted_prompt.as_str(), + negative_prompt, + image_size.as_ref(), + 1, + reference_images.as_slice(), + failure_context, + ) + .await + } + }; + let generated = if is_publication_material_generation { + execute_billable_asset_operation_with_cost( + &state, + authenticated.claims().user_id(), + EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, + request_context.request_id(), + u64::from(configured_price_mud_points), + generate_operation, ) .await? } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), - negative_prompt, - image_size.as_ref(), - 1, - reference_images.as_slice(), - failure_context, - ) - .await? + generate_operation.await? }; let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ 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 58a447149..08204463c 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -104,10 +104,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_REFERENCE_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 3742350c7..6e461dfd0 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -578,7 +578,7 @@ mod tests { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), - "1280*720", + "16:9", 2, &["data:image/png;base64,abcd".to_string()], ); 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 930dc159c..869f4e374 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -91,17 +91,28 @@ pub fn normalize_vector_engine_image_model(model: &str) -> &str { } pub fn normalize_image_size(size: &str) -> String { - match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2k" => { - "1536x1024" - } - "1920*1080" | "1920x1080" | "2048*1152" | "2048x1152" | "2k-16:9" => "2048x1152", - "1024*1536" | "1024x1536" | "9:16" => "1024x1536", - value if !value.is_empty() => value, - _ => "1024x1024", + let size = size.trim(); + match size { + "1:1" => "1024x1024".to_string(), + "16:9" | "2k" => "1536x1024".to_string(), + "2k-16:9" => "2048x1152".to_string(), + "9:16" => "1024x1536".to_string(), + value if is_explicit_pixel_size(value) => normalize_explicit_pixel_size(value), + value if !value.is_empty() => value.to_string(), + _ => "1024x1024".to_string(), } - .to_string() +} + +fn is_explicit_pixel_size(value: &str) -> bool { + let separator = if value.contains('x') { 'x' } else { '*' }; + let Some((width, height)) = value.split_once(separator) else { + return false; + }; + width.parse::().is_ok() && height.parse::().is_ok() +} + +fn normalize_explicit_pixel_size(value: &str) -> String { + value.replace('*', "x") } fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str { diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index ec757c685..66c6dc0b0 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -46,6 +46,17 @@ 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/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index de6dc85c9..984653d16 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -11,10 +11,12 @@ describe('ImageCanvasBottomToolbarView', () => { const switchTool = vi.fn(); const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); + const publicationToolWrapRef = createRef(); render( { fireEvent.click( within(toolbar).getByRole('button', { name: '生成图标素材' }), ); + fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' })); expect(switchTool).toHaveBeenNthCalledWith(1, 'hand'); expect(switchTool).toHaveBeenNthCalledWith(2, 'video'); @@ -52,17 +55,20 @@ describe('ImageCanvasBottomToolbarView', () => { expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design'); expect(switchTool).toHaveBeenNthCalledWith(5, 'music'); expect(switchTool).toHaveBeenNthCalledWith(6, 'icon'); + expect(switchTool).toHaveBeenNthCalledWith(7, 'publication'); }); it('only keeps the select and hand tools visibly pressed', () => { const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); + const publicationToolWrapRef = createRef(); const renderToolbar = (effectiveTool: Parameters< typeof ImageCanvasBottomToolbarView >[0]['effectiveTool']) => ( { ['character', '生成角色形象'], ['icon', '生成图标素材'], ['ui-design', '生成UI设计图'], + ['publication', '宣发素材'], ] as const) { rerender(renderToolbar(tool[0])); @@ -112,10 +119,12 @@ describe('ImageCanvasBottomToolbarView', () => { const closeToolOptions = vi.fn(); const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); + const publicationToolWrapRef = createRef(); render( { fireEvent.pointerLeave(specToolWrapRef.current!); fireEvent.pointerEnter(musicToolWrapRef.current!); fireEvent.pointerLeave(musicToolWrapRef.current!); + fireEvent.pointerEnter(publicationToolWrapRef.current!); + fireEvent.pointerLeave(publicationToolWrapRef.current!); expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec'); expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec'); expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music'); expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music'); + expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication'); + expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication'); }); }); diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx index 06bec8a92..6dcb19b24 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -6,6 +6,7 @@ import { Hand, ImageIcon, ImagePlus, + Megaphone, MousePointer2, Music, UserRound, @@ -15,11 +16,12 @@ import type { RefObject } from 'react'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasTool } from './ImageCanvasEditorTypes'; -type ToolbarOptionTool = Extract; +type ToolbarOptionTool = Extract; type ImageCanvasBottomToolbarViewProps = { specToolWrapRef: RefObject; musicToolWrapRef: RefObject; + publicationToolWrapRef: RefObject; effectiveTool: CanvasTool; onSwitchTool: (tool: CanvasTool) => void; onOpenToolOptions: (tool: ToolbarOptionTool) => void; @@ -43,6 +45,7 @@ const canvasTools: Array<{ }, { id: 'video', label: '生成视频', icon: Clapperboard }, { id: 'music', label: '生成音乐', icon: Music }, + { id: 'publication', label: '宣发素材', icon: Megaphone }, { id: 'spec', label: '生成规范', @@ -55,7 +58,7 @@ const canvasTools: Array<{ ]; function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool { - return tool === 'music' || tool === 'spec'; + return tool === 'music' || tool === 'spec' || tool === 'publication'; } function hasPersistentPressedState(tool: CanvasTool) { @@ -65,6 +68,7 @@ function hasPersistentPressedState(tool: CanvasTool) { export function ImageCanvasBottomToolbarView({ specToolWrapRef, musicToolWrapRef, + publicationToolWrapRef, effectiveTool, onSwitchTool, onOpenToolOptions, @@ -88,10 +92,19 @@ export function ImageCanvasBottomToolbarView({ /> ) : null} { ); }); + it('confirms before deleting a generating placeholder because mud points are not refunded', async () => { + let resolveGeneration!: (value: unknown) => void; + generateEditorImageMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGeneration = resolve; + }), + ); + render(); + + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图片' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '准备删除的生成中图片' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + const frame = screen.getByLabelText('图像生成占位图'); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--generating', + ); + + fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); + + const confirmDialog = screen.getByRole('dialog', { + name: '删除生成中的占位图', + }); + expect( + within(confirmDialog).getByText( + '删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。', + ), + ).toBeTruthy(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + + fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' })); + expect(screen.queryByRole('dialog', { name: '删除生成中的占位图' })).toBeNull(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + + fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); + fireEvent.click(screen.getByRole('button', { name: '确认删除' })); + + expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); + + await act(async () => { + resolveGeneration({ + imageSrc: 'data:image/png;base64,ZGVsZXRlZC1wbGFjZWhvbGRlcg==', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '准备删除的生成中图片', + actualPrompt: '准备删除的生成中图片', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-deleted-placeholder-1', + }); + }); + + expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); + }); + it('hides the generation composer when selecting another image but keeps the placeholder', () => { render(); @@ -2933,6 +2995,12 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); }); + const confirmDialog = screen.getByRole('dialog', { + name: '删除生成中的占位图', + }); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认删除' })); + expect(screen.queryByLabelText('快速编辑生成占位图')).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 772c72b74..fde9ef05a 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -114,6 +114,7 @@ function createStageProps(): ImageCanvasStageViewProps { canvasViewportRef: createRef(), specToolWrapRef: createRef(), musicToolWrapRef: 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 4246137b6..81b7e8238 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -15,6 +15,7 @@ export type CanvasAssetKind = | 'icon' | 'icon-spritesheet' | 'icon-spec' + | 'publication-material' | 'ui-design' | 'video' | 'sound-effect' @@ -111,6 +112,7 @@ export type CanvasTool = | 'spec' | 'character' | 'icon' + | 'publication' | 'ui-design'; export type SidebarPanel = 'assets' | 'layers'; @@ -145,6 +147,17 @@ export type CharacterReferenceImage = { assetObjectId?: string | null; }; +export type PublicationMaterialsGameInfo = { + gameName: string; + gameCategories: string; + gameDescription: string; +}; + +export type PublicationMaterialsWorkflowId = + | 'publication-cover-image' + | 'publication-detail-gallery' + | 'publication-promo-poster'; + export type GenerateDialogState = { id?: string; mode: @@ -153,6 +166,7 @@ export type GenerateDialogState = { | 'spec' | 'character' | 'icon' + | 'publication' | 'ui-design' | 'quick-edit' | 'character-animation' @@ -172,6 +186,9 @@ export type GenerateDialogState = { characterReferences?: CharacterReferenceImage[]; iconSpecReference?: CharacterReferenceImage | null; iconDescriptions?: string[]; + publicationWorkflowId?: PublicationMaterialsWorkflowId; + publicationGameInfo?: PublicationMaterialsGameInfo; + publicationReferences?: CharacterReferenceImage[]; uiDesignSpecReference?: CharacterReferenceImage | null; imageModel?: string; videoModel?: EditorVideoModel; @@ -313,7 +330,8 @@ export type UploadTarget = | 'character-spec' | 'character-reference' | 'icon-spec' - | 'ui-design-icon-spec'; + | 'ui-design-icon-spec' + | 'publication-reference'; export type SnapGuide = { vertical?: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index a7672f180..f44139371 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -13,6 +13,7 @@ import { createEditorProjectResource, } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { @@ -64,9 +65,11 @@ export function ImageCanvasEditorView() { const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); const musicToolWrapRef = useRef(null); + const publicationToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); + const publicationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); @@ -82,6 +85,8 @@ export function ImageCanvasEditorView() { const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [hoveredLayerId, setHoveredLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); + const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] = + useState(null); const [imageContextMenu, setImageContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState( @@ -511,9 +516,11 @@ export function ImageCanvasEditorView() { layerCounterRef, specToolWrapRef, musicToolWrapRef, + publicationToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, + publicationReferenceButtonRef, iconSpecButtonRef, generateDialog, setGenerateDialog, @@ -546,6 +553,8 @@ export function ImageCanvasEditorView() { setIsPickingGenerationReferenceFromCanvas, isPickingQuickEditReferenceFromCanvas, setIsPickingQuickEditReferenceFromCanvas, + isPickingPublicationReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, @@ -565,6 +574,7 @@ export function ImageCanvasEditorView() { pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, pickQuickEditReferenceFromLayer, + pickPublicationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, pickUiDesignSpecFromLayer, @@ -661,6 +671,7 @@ export function ImageCanvasEditorView() { isPickingCharacterSpecFromCanvas, isPickingGenerationReferenceFromCanvas, isPickingQuickEditReferenceFromCanvas, + isPickingPublicationReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, isPickingUiDesignSpecFromCanvas, @@ -668,6 +679,7 @@ export function ImageCanvasEditorView() { pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, pickQuickEditReferenceFromLayer, + pickPublicationReferenceFromLayer, pickCharacterReferenceFromLayer, pickIconSpecFromLayer, pickUiDesignSpecFromLayer, @@ -683,6 +695,45 @@ export function ImageCanvasEditorView() { (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); + const removeCanvasGenerationDialog = useCallback( + (dialogId: string) => { + captureCanvasHistory(); + removeCanvasGenerationDialogById(dialogId); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + setActiveTool('select'); + }, + [ + captureCanvasHistory, + removeCanvasGenerationDialogById, + setActiveTool, + setContextMenu, + setImageContextMenu, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + const requestRemoveCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if (dialog.status === 'generating') { + activateCanvasGenerationDialog(dialog); + setPendingGenerationDeleteDialog(dialog); + return; + } + removeCanvasGenerationDialog(dialog.id); + }, + [activateCanvasGenerationDialog, removeCanvasGenerationDialog], + ); + const confirmRemoveGeneratingDialog = useCallback(() => { + const dialog = pendingGenerationDeleteDialog; + if (!dialog) { + return; + } + setPendingGenerationDeleteDialog(null); + removeCanvasGenerationDialog(dialog.id); + }, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]); useImageCanvasKeyboardShortcuts({ generateDialogRef, @@ -690,11 +741,7 @@ export function ImageCanvasEditorView() { redoCanvasChange, undoCanvasChange, deleteLayerById: deleteLayerByIdFromShortcut, - removeCanvasGenerationDialogById: (dialogId: string) => { - captureCanvasHistory(); - removeCanvasGenerationDialogById(dialogId); - setActiveTool('select'); - }, + requestRemoveCanvasGenerationDialog, setActiveTool, setGenerateDialog, setImageContextMenu, @@ -703,8 +750,11 @@ export function ImageCanvasEditorView() { closeEditorChromePanels, setIsSpecMenuOpen, setIsGenerationReferenceMenuOpen, + setIsPublicationReferenceMenuOpen: + generationSurface.setIsPublicationReferenceMenuOpen, setIsPickingGenerationReferenceFromCanvas, setIsPickingQuickEditReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsPickingCharacterSpecFromCanvas, @@ -866,6 +916,7 @@ export function ImageCanvasEditorView() { canvasViewportRef, specToolWrapRef, musicToolWrapRef, + publicationToolWrapRef, isPanning, effectiveTool, canvasBackgroundColor, @@ -960,20 +1011,31 @@ export function ImageCanvasEditorView() { }; return ( - setMetadataLayer(null), - }} - /> + <> + setMetadataLayer(null), + }} + /> + setPendingGenerationDeleteDialog(null)} + onConfirm={confirmRemoveGeneratingDialog} + > + 删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。 + + ); } diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 82d0d4213..f4ece5ae5 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -30,18 +30,21 @@ function createComposerProps( characterReferenceButtonRef: createRef(), generationReferenceButtonRef: createRef(), iconSpecButtonRef: createRef(), + publicationReferenceButtonRef: createRef(), isSpecMenuOpen: false, isCharacterSpecMenuOpen: false, isCharacterReferenceMenuOpen: false, isGenerationReferenceMenuOpen: false, isIconSpecMenuOpen: false, isUiDesignSpecMenuOpen: false, + isPublicationReferenceMenuOpen: false, isPickingCharacterSpecFromCanvas: false, isPickingCharacterReferenceFromCanvas: false, isPickingGenerationReferenceFromCanvas: false, isPickingQuickEditReferenceFromCanvas: false, isPickingIconSpecFromCanvas: false, isPickingUiDesignSpecFromCanvas: false, + isPickingPublicationReferenceFromCanvas: false, generateDialog, generationComposerStyle: { left: 320, top: 240 }, iconComposerStyle: { left: 320, top: 240, width: '32rem' }, @@ -80,11 +83,13 @@ function createComposerProps( setIsPickingQuickEditReferenceFromCanvas: mockStateSetter(), setIsIconSpecMenuOpen: mockStateSetter(), setIsUiDesignSpecMenuOpen: mockStateSetter(), + setIsPublicationReferenceMenuOpen: mockStateSetter(), setIsPickingCharacterSpecFromCanvas: mockStateSetter(), setIsPickingCharacterReferenceFromCanvas: mockStateSetter(), setIsPickingGenerationReferenceFromCanvas: mockStateSetter(), setIsPickingIconSpecFromCanvas: mockStateSetter(), setIsPickingUiDesignSpecFromCanvas: mockStateSetter(), + setIsPickingPublicationReferenceFromCanvas: mockStateSetter(), onOpenSpecDialog: vi.fn(), onRequestUpload: vi.fn(), onSubmitImageGeneration: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index e645a677d..1b0760b99 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -24,6 +24,7 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView'; import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; +import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView'; import type { CanvasLayer, CharacterAnimationPanelState, @@ -42,6 +43,7 @@ import { SPEC_TYPE_LABEL, } from './ImageCanvasGenerationModel'; import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; +import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; @@ -49,18 +51,21 @@ import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOpt type ImageCanvasGenerationComposerViewProps = { specToolWrapRef: RefObject; + publicationReferenceButtonRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; generationReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; isSpecMenuOpen: boolean; isGenerationReferenceMenuOpen: boolean; + isPublicationReferenceMenuOpen: boolean; isCharacterSpecMenuOpen: boolean; isCharacterReferenceMenuOpen: boolean; isIconSpecMenuOpen: boolean; isUiDesignSpecMenuOpen: boolean; isPickingGenerationReferenceFromCanvas: boolean; isPickingQuickEditReferenceFromCanvas: boolean; + isPickingPublicationReferenceFromCanvas: boolean; isPickingCharacterSpecFromCanvas: boolean; isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; @@ -85,12 +90,16 @@ type ImageCanvasGenerationComposerViewProps = { SetStateAction >; setIsGenerationReferenceMenuOpen: Dispatch>; + setIsPublicationReferenceMenuOpen: Dispatch>; setIsCharacterSpecMenuOpen: Dispatch>; setIsCharacterReferenceMenuOpen: Dispatch>; setIsIconSpecMenuOpen: Dispatch>; setIsUiDesignSpecMenuOpen: Dispatch>; setIsPickingGenerationReferenceFromCanvas: Dispatch>; setIsPickingQuickEditReferenceFromCanvas: Dispatch>; + setIsPickingPublicationReferenceFromCanvas: Dispatch< + SetStateAction + >; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; @@ -841,18 +850,21 @@ function ImageCanvasAudioGenerationComposerView({ export function ImageCanvasGenerationComposerView({ specToolWrapRef, + publicationReferenceButtonRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, iconSpecButtonRef, isSpecMenuOpen, isGenerationReferenceMenuOpen, + isPublicationReferenceMenuOpen, isCharacterSpecMenuOpen, isCharacterReferenceMenuOpen, isIconSpecMenuOpen, isUiDesignSpecMenuOpen, isPickingGenerationReferenceFromCanvas, isPickingQuickEditReferenceFromCanvas, + isPickingPublicationReferenceFromCanvas, isPickingCharacterSpecFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, @@ -875,12 +887,14 @@ export function ImageCanvasGenerationComposerView({ setCropExpandPanel, setCharacterAnimationPanel, setIsGenerationReferenceMenuOpen, + setIsPublicationReferenceMenuOpen, setIsCharacterSpecMenuOpen, setIsCharacterReferenceMenuOpen, setIsIconSpecMenuOpen, setIsUiDesignSpecMenuOpen, setIsPickingGenerationReferenceFromCanvas, setIsPickingQuickEditReferenceFromCanvas, + setIsPickingPublicationReferenceFromCanvas, setIsPickingCharacterSpecFromCanvas, setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, @@ -974,6 +988,31 @@ export function ImageCanvasGenerationComposerView({ /> ) : null} + {generateDialog?.mode === 'publication' && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + {generateDialog?.mode === 'ui-design' && generateDialog.composerOpen !== false && generationComposerStyle ? ( @@ -1098,6 +1137,11 @@ export function ImageCanvasGenerationComposerView({ 请选择画布中的图片作为参考图,按 Esc 退出 ) : null} + {isPickingPublicationReferenceFromCanvas ? ( +
+ 请选择画布中的图片作为宣发素材参考图,按 Esc 退出 +
+ ) : null} {isPickingIconSpecFromCanvas ? (
请选择画布中的图标规范,按 Esc 退出 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index d6f3d0424..856ee36f3 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, @@ -21,12 +22,15 @@ import { createCanvasLayerReference, DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, + DEFAULT_PUBLICATION_GAME_INFO, DEFAULT_SPEC_FORM_VALUES, DEFAULT_VIDEO_MODEL, EDITOR_IMAGE_DIMENSION_OPTIONS, ICON_DESCRIPTION_LIMIT, ICON_FRAME_DISPLAY_SIZE, ICON_FRAME_ORIGINAL_SIZE, + PUBLICATION_FRAME_DISPLAY_SIZE, + PUBLICATION_FRAME_ORIGINAL_SIZE, SPEC_FRAME_DISPLAY_SIZE, SPEC_FRAME_ORIGINAL_SIZE, UI_DESIGN_FRAME_DISPLAY_SIZE, @@ -262,6 +266,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 createVideoGenerationDialogDraft({ canvasSize, viewport, @@ -575,6 +610,22 @@ export function appendGenerationReference( : dialog; } +export function appendPublicationReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + return dialog?.mode === 'publication' && getReferenceMediaType(layer) === 'image' + ? { + ...resetFailedGenerationDialog(dialog), + publicationReferences: [ + ...(dialog.publicationReferences ?? []), + createCanvasLayerReference(layer), + ], + composerOpen: true, + } + : dialog; +} + export function assignIconSpecReference( dialog: GenerateDialogState | null, layer: CanvasLayer, @@ -685,6 +736,7 @@ export function hideGeneratedLayerComposerAfterBlur( dialog?.mode === 'quick-edit' || dialog?.mode === 'character-animation' || dialog?.mode === 'video' || + dialog?.mode === 'publication' || dialog?.mode === 'audio-sound-effect' || dialog?.mode === 'audio-background-music') && dialog.status !== 'generating' @@ -706,6 +758,7 @@ export function closeGenerateComposerDialog( dialog?.mode === 'quick-edit' || dialog?.mode === 'character-animation' || dialog?.mode === 'video' || + dialog?.mode === 'publication' || dialog?.mode === 'audio-sound-effect' || dialog?.mode === 'audio-background-music' ? { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 8c84eb16f..b250540d0 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -11,9 +11,15 @@ import type { CanvasLayer, CharacterReferenceImage, GenerateDialogState, + PublicationMaterialsGameInfo, + PublicationMaterialsWorkflowId, SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; +import { + getPublicationMaterialsWorkflow, + type PublicationMaterialsWorkflow, +} from './ImageCanvasPublicationMaterialsModel'; // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。 export const EDITOR_GENERATION_MUD_POINT_CONFIG = { @@ -51,6 +57,22 @@ export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; +export const PUBLICATION_FRAME_ORIGINAL_SIZE: Record< + PublicationMaterialsWorkflowId, + { width: number; height: number } +> = { + 'publication-cover-image': { width: 720, height: 540 }, + 'publication-detail-gallery': { width: 720, height: 1280 }, + 'publication-promo-poster': { width: 1280, height: 720 }, +}; +export const PUBLICATION_FRAME_DISPLAY_SIZE: Record< + PublicationMaterialsWorkflowId, + { width: number; height: number } +> = { + 'publication-cover-image': { width: 480, height: 360 }, + 'publication-detail-gallery': { width: 360, height: 640 }, + 'publication-promo-poster': { width: 560, height: 315 }, +}; export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; @@ -58,6 +80,11 @@ export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const ICON_DESCRIPTION_LIMIT = 100; +export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = { + gameName: '', + gameCategories: '', + gameDescription: '', +}; export const DEFAULT_ICON_DESCRIPTIONS = [ '返回按钮', '设置按钮', @@ -274,6 +301,9 @@ export function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'icon-spec') { return '图标规范'; } + if (layer.assetKind === 'publication-material') { + return '宣发素材'; + } if (layer.assetKind === 'ui-design') { return 'UI设计'; } @@ -327,6 +357,9 @@ export function formatLayerImageType(layer: CanvasLayer) { if (layer.assetKind === 'icon-spec') { return '图标规范图片'; } + if (layer.assetKind === 'publication-material') { + return '宣发素材图片'; + } if (layer.assetKind === 'ui-design') { return 'UI设计图'; } @@ -565,6 +598,71 @@ export function buildUiDesignGenerationInputs( }; } +function normalizePublicationGameInfo( + gameInfo: PublicationMaterialsGameInfo | null | undefined, +): PublicationMaterialsGameInfo { + return { + gameName: gameInfo?.gameName?.trim() ?? '', + gameCategories: gameInfo?.gameCategories?.trim() ?? '', + gameDescription: gameInfo?.gameDescription?.trim() ?? '', + }; +} + +export function buildPublicationMaterialsPrompt( + gameInfo: PublicationMaterialsGameInfo | null | undefined, +) { + const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); + return [ + ...createGenerationInputField('游戏名', normalizedGameInfo.gameName), + ...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories), + ...createGenerationInputField( + '一句话描述游戏', + normalizedGameInfo.gameDescription, + ), + ] + .map((field) => `${field.title}:${field.value}`) + .join('\n'); +} + +export function buildPublicationMaterialsGenerationPrompt({ + gameInfo, + workflow, +}: { + gameInfo: PublicationMaterialsGameInfo | null | undefined; + workflow: PublicationMaterialsWorkflow; +}) { + const inputPrompt = buildPublicationMaterialsPrompt(gameInfo); + return [ + `【宣发素材类型】${workflow.promptLabel ?? workflow.label}`, + inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。', + `【生图约束】\n${workflow.promptConstraints.join('\n')}`, + `【参考图约束】\n${workflow.referenceConstraints.join('\n')}`, + `【输出检查】\n${workflow.postProcessConstraints.join('\n')}`, + ].join('\n\n'); +} + +export function buildPublicationMaterialsGenerationInputs( + gameInfo: PublicationMaterialsGameInfo | null | undefined, + references: CharacterReferenceImage[] | undefined, +): CanvasGenerationInputs { + const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); + return { + fields: [ + ...createGenerationInputField('游戏名', normalizedGameInfo.gameName), + ...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories), + ...createGenerationInputField( + '一句话描述游戏', + normalizedGameInfo.gameDescription, + ), + ], + references: (references ?? []).map((reference, index) => ({ + title: `宣发参考图 ${index + 1}`, + label: reference.label, + src: reference.src, + })), + }; +} + export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, @@ -657,6 +755,7 @@ export function isCanvasGenerationDialog( dialog.mode === 'spec' || dialog.mode === 'character' || dialog.mode === 'icon' || + dialog.mode === 'publication' || dialog.mode === 'ui-design' || dialog.mode === 'quick-edit' || dialog.mode === 'character-animation' || @@ -678,6 +777,9 @@ export function getGenerationFrameAriaLabel( if (dialog.mode === 'icon') { return '图标素材生成占位图'; } + if (dialog.mode === 'publication') { + return '宣发素材生成占位图'; + } if (dialog.mode === 'ui-design') { return 'UI设计图生成占位图'; } @@ -709,6 +811,11 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { if (dialog.mode === 'icon') { return 'Icon Generator'; } + if (dialog.mode === 'publication') { + return `${getPublicationMaterialsWorkflow( + dialog.publicationWorkflowId ?? 'publication-cover-image', + ).englishName} Generator`; + } if (dialog.mode === 'ui-design') { return 'UI Design Generator'; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index e9b9e70a1..e3c962155 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -234,6 +234,144 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('builds publication material plans with structured game fields', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'publication', + prompt: '', + status: 'idle', + publicationGameInfo: { + gameName: ' 重庆洪崖洞火锅 ', + gameCategories: '抓大鹅、休闲、治愈、手绘风', + gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除', + }, + publicationReferences: [ + { + id: 'ref-1', + label: '参考图', + src: 'data:image/png;base64,ref', + }, + ], + }, + layers: [], + nextGeneratedIndex: 10, + }); + + expect(plan).toEqual({ + kind: 'image', + normalizedPrompt: [ + '游戏名:重庆洪崖洞火锅', + '游戏分类:抓大鹅、休闲、治愈、手绘风', + '一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除', + ].join('\n'), + input: { + prompt: expect.stringContaining('【宣发素材类型】游戏首图'), + size: '720x540', + kind: 'publication-material', + referenceImageSrcs: ['data:image/png;base64,ref'], + }, + result: { + title: '10 宣发素材', + generationInputs: { + fields: [ + { title: '游戏名', value: '重庆洪崖洞火锅' }, + { title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' }, + { + title: '一句话描述游戏', + value: '在一堆物品里点击,找到三个一样的物品将其消除', + }, + ], + references: [ + { + title: '宣发参考图 1', + label: '参考图', + src: 'data:image/png;base64,ref', + }, + ], + }, + }, + }); + const prompt = plan.kind === 'image' ? plan.input.prompt : ''; + expect(prompt).toContain('游戏名:重庆洪崖洞火锅'); + expect(prompt).toContain('标题文字必须直接进入画面'); + expect(prompt).toContain('输出检查:720 x 540,4:3'); + }); + + it('builds detail gallery publication plans as one 720p portrait image', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'publication', + prompt: '', + status: 'idle', + publicationWorkflowId: 'publication-detail-gallery', + publicationGameInfo: { + gameName: '云朵消消乐', + gameCategories: '', + gameDescription: '', + }, + }, + layers: [], + nextGeneratedIndex: 11, + }); + + expect(plan).toMatchObject({ + kind: 'image', + input: { + prompt: expect.stringContaining('【宣发素材类型】详情页单图'), + size: '720x1280', + kind: 'publication-material', + }, + result: { + title: '11 宣发素材', + }, + }); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '本次只生成一张 720 x 1280 竖版详情图', + ); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '不是五图合集或拼贴', + ); + }); + + it('builds promo poster publication plans as 720p landscape images', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'publication', + prompt: '', + status: 'idle', + publicationWorkflowId: 'publication-promo-poster', + publicationGameInfo: { + gameName: '火锅节活动', + gameCategories: '', + gameDescription: '', + }, + }, + layers: [], + nextGeneratedIndex: 12, + }); + + expect(plan).toMatchObject({ + kind: 'image', + input: { + prompt: expect.stringContaining('【宣发素材类型】运营海报'), + size: '1280x720', + kind: 'publication-material', + }, + result: { + title: '12 宣发素材', + }, + }); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '16:9 横版 720p,建议 1280 x 720', + ); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '此处换上你的游戏二维码', + ); + expect(plan.kind === 'image' ? plan.input.prompt : '').toContain( + '方块内部上方用黑色小字', + ); + }); + it('throws when edit source layer is missing', () => { expect(() => buildImageGenerationSubmissionPlan({ diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 782c09bc6..436141dc5 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -18,6 +18,9 @@ import { buildEditGenerationInputs, buildIconGenerationInputs, buildImageGenerationInputs, + buildPublicationMaterialsGenerationInputs, + buildPublicationMaterialsGenerationPrompt, + buildPublicationMaterialsPrompt, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, @@ -40,6 +43,7 @@ import { SPEC_GENERATION_SIZE, SPEC_TYPE_LABEL, } from './ImageCanvasGenerationModel'; +import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; type ImageGenerationSubmissionOptions = { dialog: GenerateDialogState; @@ -294,6 +298,42 @@ export function buildImageGenerationSubmissionPlan({ }; } + if (dialog.mode === 'publication') { + const workflow = getPublicationMaterialsWorkflow( + dialog.publicationWorkflowId ?? 'publication-cover-image', + ); + const publicationPrompt = + buildPublicationMaterialsPrompt(dialog.publicationGameInfo) || + normalizedPrompt; + const generationPrompt = buildPublicationMaterialsGenerationPrompt({ + gameInfo: dialog.publicationGameInfo, + workflow, + }); + return { + kind: 'image', + normalizedPrompt: publicationPrompt, + input: { + prompt: generationPrompt, + size: workflow.outputSize, + kind: 'publication-material', + ...(dialog.publicationReferences?.length + ? { + referenceImageSrcs: dialog.publicationReferences.map( + (reference) => reference.src, + ), + } + : {}), + }, + result: { + title: `${nextGeneratedIndex} 宣发素材`, + generationInputs: buildPublicationMaterialsGenerationInputs( + dialog.publicationGameInfo, + dialog.publicationReferences, + ), + }, + }; + } + if (dialog.mode === 'video') { const resolution = dialog.videoResolution ?? '480p'; const durationSeconds = dialog.videoDurationSeconds ?? 4; diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts index 385ddd89f..d3a5955c1 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.test.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -173,6 +173,9 @@ describe('ImageCanvasOverlayModel', () => { expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe( true, ); + expect( + isCanvasGenerationComposerVisible(createDialog({ mode: 'publication' })), + ).toBe(true); expect( isCanvasGenerationComposerVisible({ mode: 'edit', diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index 050a67485..92c58c541 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -203,6 +203,7 @@ export function isCanvasGenerationComposerVisible( dialog?.mode === 'icon' || dialog?.mode === 'ui-design' || dialog?.mode === 'quick-edit' || + dialog?.mode === 'publication' || dialog?.mode === 'character-animation' || dialog?.mode === 'video' || dialog?.mode === 'audio-sound-effect' || diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx new file mode 100644 index 000000000..37b1970f4 --- /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..c757f19c8 --- /dev/null +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsModel.ts @@ -0,0 +1,128 @@ +import type { PublicationMaterialsWorkflowId } from './ImageCanvasEditorTypes'; + +export type PublicationMaterialsWorkflow = { + id: PublicationMaterialsWorkflowId; + label: string; + promptLabel?: string; + englishName: string; + outputLabel: string; + sizeLabel: string; + outputSize: string; + scenario: string; + fields: string[]; + framework: string[]; + promptConstraints: string[]; + referenceConstraints: string[]; + postProcessConstraints: string[]; +}; + +export const PUBLICATION_MATERIALS_WORKFLOWS: PublicationMaterialsWorkflow[] = [ + { + id: 'publication-cover-image', + label: '游戏首图', + englishName: 'Home Image', + outputLabel: '单张主视觉', + sizeLabel: '720 x 540', + outputSize: '720x540', + scenario: '推荐流、作品卡和首屏传播位', + fields: ['游戏名称', '玩法分类标签', '一句话玩法', '主视觉关键词', '必要文字'], + framework: [ + '读取游戏基础信息和现有参考图', + '抽取核心玩法动作、主体物和城市/题材符号', + '生成一张带标题文字的横版主视觉', + '按首图尺寸、文字可读性和主体完整度验收', + ], + promptConstraints: [ + '标题文字必须直接进入画面,优先使用游戏名称;标题应为清晰中文大字,少字、醒目、可读', + '画面只表达一个强主题,不拼贴多张小图,不做详情页合集,不做截图说明页', + '主体、玩法道具和背景层级必须清楚;核心主体适合小尺寸推荐卡,缩小后仍能一眼识别', + '标题与主体不能互相遮挡;主体不裁切,标题不贴边,四周保留安全边距', + '避免暗黑、脏污、低清、廉价拼贴、文字乱码、伪文字、英文标题、非中文标题、错误游戏名', + ], + referenceConstraints: [ + '参考图只用于提取视觉约束:主体、玩法元素、色彩气质、画风、构图方向;不得照搬水印、边框、无关 UI、平台外壳或隐私信息', + ], + postProcessConstraints: [ + '输出检查:720 x 540,4:3;主体完整不裁切;标题清晰可读;画面只有一个主视觉重点', + '输出 metadata 记录完整提示词、尺寸和参考图摘要', + '不做真实发布、不写作品数据,只作为画布素材候选', + ], + }, + { + id: 'publication-detail-gallery', + label: '详情五图', + promptLabel: '详情页单图', + englishName: 'Detail Gallery', + outputLabel: '单张详情图', + sizeLabel: '720 x 1280', + outputSize: '720x1280', + scenario: '作品详情、应用介绍和运营分发图组', + fields: ['游戏名称', '玩法分类标签', '卖点拆解', '图组顺序', '必要文字'], + framework: [ + '选择当前这张详情图要表达的单一卖点', + '生成一张带短标题的竖版详情页素材', + '让主体、玩法反馈和背景符号保持同一套游戏物料气质', + '按单图尺寸、标题可读性、主体完整度和非合集画面验收', + ], + promptConstraints: [ + '本次只生成一张 720 x 1280 竖版详情图;不要把五张图合成一张长图、拼图、多分镜合集或截图说明页', + '每张图只承载一个卖点,短标题必须直接出现在画面中;标题用简短中文,不堆长句', + '画面保持与同一游戏后续详情图可组成一套:同一游戏名、同一主体物件体系、同一画风、同一色彩气质', + '构图要服务当前单一卖点,主体、玩法道具、反馈效果和背景层级清楚;核心信息缩小后仍能识别', + '短标题不能遮挡核心主体和玩法反馈;避免文字过密、小字不可读、UI 挡主体', + '连续生成多张详情图时,可以独立生成并允许顺序差异;同一批次复用同一份参考摘要,主动拉开角度、主体姿势和背景变化', + '避免五联图、分栏、多格漫画、长图合集、重复小图拼贴、文字乱码、伪文字、英文标题、非中文标题', + ], + referenceConstraints: [ + '从参考图解析并统一:画风、主体物件、玩法道具、背景符号、地域/场景元素、色板和光影氛围', + '参考图只作为视觉理解来源,不照搬截图 UI、水印、边框、按钮、状态栏、弹窗或无关文字', + ], + postProcessConstraints: [ + '输出检查:单张 720 x 1280 竖版;只表达一个卖点;标题不截断、不乱码;不是五图合集或拼贴', + 'metadata 记录本张图的卖点、提示词和参考摘要', + '仅生成画布候选物料,不触发投放、分享或发布', + ], + }, + { + id: 'publication-promo-poster', + label: '运营海报', + englishName: 'Promo Poster', + outputLabel: '单张活动海报', + sizeLabel: '1280 x 720', + outputSize: '1280x720', + scenario: '社群、活动、渠道投放和运营节日位', + fields: ['游戏名称', '活动主题', '玩法标签', '主文案', '行动指令'], + framework: [ + '确认运营主题和投放场景', + '抽取游戏核心视觉符号和活动情绪', + '生成带主标题、卖点和行动指令的海报', + '按传播可读性、品牌一致性和尺寸验收', + ], + promptConstraints: [ + '主标题、游戏名和行动指令必须清晰入画;三者要有明确层级,不能互相抢占', + '运营氛围可以更强,包括活动感、福利感、节日感、更新感,但不能脱离游戏玩法、主体物件和美术资产', + '版式必须预留二维码或平台标识位置,但不得生成假二维码、假平台 Logo、假按钮或不可识别图标', + '右下角必须保留一个干净的白色二维码留白方块,并在方块内部上方用黑色小字写“此处换上你的游戏二维码”;留白内不要生成假二维码', + '活动主题优先级高于普通详情介绍,但不得覆盖游戏识别;用户仍需一眼知道这是哪款游戏、什么玩法', + '避免夸张承诺、敏感营销词、虚假福利、价格折扣、具体日期、不可读小字、文字乱码、伪文字', + ], + referenceConstraints: [ + '参考图用于锁定角色/主体物件、玩法道具、地域/场景符号、色彩气质和画风,不照搬参考图中的 UI、水印、边框或无关文字', + ], + postProcessConstraints: [ + '输出检查:16:9 横版 720p,建议 1280 x 720;主标题最大,游戏名清楚,行动指令可读;右下角白色二维码留白方块干净,方块内部上方带有黑色小字“此处换上你的游戏二维码”', + 'metadata 记录活动主题、文案、提示词和预留位说明', + '仅生成画布候选物料,不触发投放、分享或发布', + ], + }, +]; + +export function getPublicationMaterialsWorkflow( + workflowId: PublicationMaterialsWorkflowId, +): PublicationMaterialsWorkflow { + return ( + PUBLICATION_MATERIALS_WORKFLOWS.find( + (workflow) => workflow.id === workflowId, + ) ?? PUBLICATION_MATERIALS_WORKFLOWS[0]! + ); +} diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 9bd1ea880..7512bd0b3 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -33,6 +33,7 @@ export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; musicToolWrapRef: RefObject; + publicationToolWrapRef: RefObject; isPanning: boolean; effectiveTool: CanvasTool; canvasBackgroundColor: string; @@ -130,14 +131,19 @@ export type ImageCanvasStageViewProps = { onToggleMinimap: () => void; onMinimapPointerDown: (event: ReactPointerEvent) => void; onSwitchTool: (tool: CanvasTool) => void; - onOpenToolOptions: (tool: Extract) => void; - onCloseToolOptions: (tool: Extract) => void; + onOpenToolOptions: ( + tool: Extract, + ) => void; + onCloseToolOptions: ( + tool: Extract, + ) => void; }; export function ImageCanvasStageView({ canvasViewportRef, specToolWrapRef, musicToolWrapRef, + publicationToolWrapRef, isPanning, effectiveTool, canvasBackgroundColor, @@ -339,6 +345,7 @@ export function ImageCanvasStageView({ { dialog, ); expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog); + expect( + within(frame).queryByRole('button', { name: '删除图标素材生成占位图' }), + ).toBeNull(); expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); @@ -295,6 +298,14 @@ describe('ImageCanvasWorldView', () => { 'image-canvas-editor__generation-frame--character-animation', iconClassName: 'lucide-person-standing', }, + { + mode: 'publication', + ariaLabel: '宣发素材生成占位图', + generatorLabel: 'Home Image Generator', + badgeLabel: '宣发', + frameClassName: 'image-canvas-editor__generation-frame--publication', + iconClassName: 'lucide-image', + }, { mode: 'video', ariaLabel: '视频生成占位图', @@ -365,6 +376,12 @@ describe('ImageCanvasWorldView', () => { generatorLabel: 'Action Generator', badgeLabel: '动作', }, + { + mode: 'publication', + ariaLabel: '宣发素材生成占位图', + generatorLabel: 'Home Image Generator', + badgeLabel: '宣发', + }, { mode: 'video', ariaLabel: '视频生成占位图', @@ -433,6 +450,12 @@ describe('ImageCanvasWorldView', () => { generatorLabel: 'Action Generator', badgeLabel: '动作', }, + { + mode: 'publication', + ariaLabel: '宣发素材生成占位图', + generatorLabel: 'Home Image Generator', + badgeLabel: '宣发', + }, { mode: 'video', ariaLabel: '视频生成占位图', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 0a490f69c..d85dd37d5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -88,6 +88,14 @@ function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) { icon: , labelIcon: , }; + case 'publication': + return { + frameClassName: 'image-canvas-editor__generation-frame--publication', + badgeClassName: 'publication', + badgeLabel: '宣发', + icon: , + labelIcon: , + }; case 'video': return { frameClassName: 'image-canvas-editor__generation-frame--video', @@ -141,7 +149,6 @@ function buildInverseScaleStyle( '--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`, '--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`, '--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`, - '--image-canvas-editor-frame-action-offset': `calc(-0.75rem * ${inverseScale})`, }; } @@ -579,6 +586,7 @@ export function ImageCanvasWorldView({ generateDialog?.mode === 'icon' || generateDialog?.mode === 'ui-design' || generateDialog?.mode === 'quick-edit' || + generateDialog?.mode === 'publication' || generateDialog?.mode === 'character-animation' || generateDialog?.mode === 'video' || generateDialog?.mode === 'audio-sound-effect' || diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 4a1c31e8c..75e95a0af 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 { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow'; const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); +const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => + vi.fn(), +); const editEditorImageMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); @@ -26,6 +35,8 @@ const generateEditorImageMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, + resolveEditorImageReferenceDataUrlForGeneration: + resolveEditorImageReferenceDataUrlForGenerationMock, })); vi.mock('../../services/image-editor/editorProjectClient', async () => { @@ -219,10 +230,7 @@ function SubmissionWorkflowHarness({ > 填写快速编辑 - +