合并画板素材生成与宣发入口改动
新增 UI 设计图提取素材入口并复用 gpt-image-2 spritesheet 拆分流程。 保留图标生成与 UI 提取的 spritesheet 原图并同步放置拆分素材到画布。 补齐宣发素材演示入口、占位图样式、引用上传与生成提交链路。 调整编辑器生成接口 body 限制和 VectorEngine 请求兼容逻辑。 更新画板音乐生成、项目基线、宣发素材与图片画布相关文档。
This commit is contained in:
@@ -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/` 为准,并同步修正过期共享记忆。
|
||||
|
||||
@@ -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画布工具栏` 保持可见。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -121,4 +121,3 @@ POST /api/editor/audios/background-music/generations
|
||||
- 成功后的音频卡展示提示词;卡片、悬停角标、信息弹窗和待生成占位均显示时长。
|
||||
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
|
||||
- 刷新后 layout 能恢复音频生成器和音频图层。
|
||||
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
## 当前文档策略
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -104,10 +104,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.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",
|
||||
|
||||
@@ -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()],
|
||||
);
|
||||
|
||||
@@ -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::<u32>().is_ok() && height.parse::<u32>().is_ok()
|
||||
}
|
||||
|
||||
fn normalize_explicit_pixel_size(value: &str) -> String {
|
||||
value.replace('*', "x")
|
||||
}
|
||||
|
||||
fn normalize_nanobanana_aspect_ratio(aspect_ratio: &str) -> &str {
|
||||
|
||||
@@ -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, &[]);
|
||||
|
||||
@@ -11,10 +11,12 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const switchTool = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="generate"
|
||||
onSwitchTool={switchTool}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
@@ -45,6 +47,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
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<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const renderToolbar = (effectiveTool: Parameters<
|
||||
typeof ImageCanvasBottomToolbarView
|
||||
>[0]['effectiveTool']) => (
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
@@ -96,6 +102,7 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
['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<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={openToolOptions}
|
||||
@@ -127,10 +136,14 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CanvasTool, 'music' | 'spec'>;
|
||||
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
|
||||
|
||||
type ImageCanvasBottomToolbarViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
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}
|
||||
<span
|
||||
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
|
||||
ref={
|
||||
id === 'spec'
|
||||
? specToolWrapRef
|
||||
: id === 'music'
|
||||
? musicToolWrapRef
|
||||
: publicationToolWrapRef
|
||||
}
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar-option-wrap',
|
||||
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
|
||||
id === 'publication'
|
||||
? 'image-canvas-editor__publication-tool-wrap'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
|
||||
@@ -500,6 +500,68 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(<ImageCanvasEditorView />);
|
||||
|
||||
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(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
canvasViewportRef: createRef<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
musicToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
publicationToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
isPanning: false,
|
||||
effectiveTool: 'select',
|
||||
canvasBackgroundColor: '#f8fafc',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLSpanElement | null>(null);
|
||||
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
@@ -82,6 +85,8 @@ export function ImageCanvasEditorView() {
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
|
||||
useState<CanvasGenerationDialogState | null>(null);
|
||||
const [imageContextMenu, setImageContextMenu] =
|
||||
useState<ImageContextMenuState | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||
@@ -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 (
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={editorRootRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
uploadAccept={getEditorUploadAccept(uploadTarget)}
|
||||
onUploadInputChange={handleUploadInputChange}
|
||||
assetDragPreview={assetDragPreview}
|
||||
sidebarProps={sidebarProps}
|
||||
topbarProps={topbarProps}
|
||||
stageProps={stageProps}
|
||||
metadataProps={{
|
||||
layer: metadataLayer,
|
||||
onClose: () => setMetadataLayer(null),
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ImageCanvasEditorShellView
|
||||
editorRootRef={editorRootRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
uploadAccept={getEditorUploadAccept(uploadTarget)}
|
||||
onUploadInputChange={handleUploadInputChange}
|
||||
assetDragPreview={assetDragPreview}
|
||||
sidebarProps={sidebarProps}
|
||||
topbarProps={topbarProps}
|
||||
stageProps={stageProps}
|
||||
metadataProps={{
|
||||
layer: metadataLayer,
|
||||
onClose: () => setMetadataLayer(null),
|
||||
}}
|
||||
/>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={Boolean(pendingGenerationDeleteDialog)}
|
||||
title="删除生成中的占位图"
|
||||
confirmLabel="确认删除"
|
||||
onClose={() => setPendingGenerationDeleteDialog(null)}
|
||||
onConfirm={confirmRemoveGeneratingDialog}
|
||||
>
|
||||
删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。
|
||||
</PlatformDangerConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>(),
|
||||
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsPublicationReferenceMenuOpen: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
|
||||
setIsPickingPublicationReferenceFromCanvas: mockStateSetter<boolean>(),
|
||||
onOpenSpecDialog: vi.fn(),
|
||||
onRequestUpload: vi.fn(),
|
||||
onSubmitImageGeneration: vi.fn(),
|
||||
|
||||
@@ -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<HTMLSpanElement | null>;
|
||||
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
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<CharacterAnimationPanelState | null>
|
||||
>;
|
||||
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<
|
||||
SetStateAction<boolean>
|
||||
>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -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 ? (
|
||||
<ImageCanvasPublicationMaterialsDemoPanelView
|
||||
dialog={generateDialog}
|
||||
workflow={getPublicationMaterialsWorkflow(
|
||||
generateDialog.publicationWorkflowId ?? 'publication-cover-image',
|
||||
)}
|
||||
style={generationComposerStyle}
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{generateDialog?.mode === 'ui-design' &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -1098,6 +1137,11 @@ export function ImageCanvasGenerationComposerView({
|
||||
请选择画布中的图片作为参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingPublicationReferenceFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图片作为宣发素材参考图,按 Esc 退出
|
||||
</div>
|
||||
) : null}
|
||||
{isPickingIconSpecFromCanvas ? (
|
||||
<div className="image-canvas-editor__canvas-pick-hint">
|
||||
请选择画布中的图标规范,按 Esc 退出
|
||||
|
||||
@@ -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<CanvasGenerationDialogState, 'id'> {
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const frameDisplaySize = PUBLICATION_FRAME_DISPLAY_SIZE[workflowId];
|
||||
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
|
||||
return {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
|
||||
publicationReferences: [],
|
||||
placeholder: {
|
||||
x: worldCenter.x - frameDisplaySize.width / 2,
|
||||
y: worldCenter.y - frameDisplaySize.height / 2,
|
||||
width: frameDisplaySize.width,
|
||||
height: frameDisplaySize.height,
|
||||
originalWidth: frameOriginalSize.width,
|
||||
originalHeight: frameOriginalSize.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function 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'
|
||||
? {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -173,6 +173,9 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isCanvasGenerationComposerVisible(createDialog({ mode: 'publication' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCanvasGenerationComposerVisible({
|
||||
mode: 'edit',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user