合并编辑器素材库能力
合并 codex/editor-asset-library 到宣发素材演示分支 保留素材库、生成器、音频、UI设计图和持久化相关能力 补回宣发素材入口与 5 泥点计费契约
This commit is contained in:
@@ -38,6 +38,9 @@ temp*build*/
|
||||
/target/
|
||||
/logs
|
||||
/.codegraph/
|
||||
/.playwright-cli/
|
||||
**/.playwright-cli/
|
||||
/output/playwright/
|
||||
/server-rs/crates/*/logs/
|
||||
.worktrees/
|
||||
.rag/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,30 +16,38 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-18 长期项目记忆固定读取 `docs/project-memory/`
|
||||
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
|
||||
|
||||
- 背景:项目记忆已从仓库 `.hermes/` 迁移到 `docs/project-memory/`,但部分协作说明仍把 `.hermes/` 写成共享记忆或计划容器,容易让 Agent 和开发者回到旧路径。
|
||||
- 决策:长期项目记忆、计划和 TODO 只从 `docs/project-memory/` 读取和维护;`.hermes/` 仅保存 Hermes 专用的仓库级 skills、plugins 和启用说明。`AGENTS.md` 的复杂任务阅读清单不再把 `.hermes/README.md` 放在项目记忆入口中,只有使用 Hermes 工具资源时才读取它。
|
||||
- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/README.md`、`docs/project-memory/shared-memory/`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
- 验证方式:扫描 `AGENTS.md`、当前 `docs/` 与 `.hermes/README.md`,确认当前口径不再把 `.hermes/` 描述为长期项目记忆路径。
|
||||
- 关联文档:`AGENTS.md`、`docs/project-memory/README.md`、`.hermes/README.md`。
|
||||
- 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。
|
||||
- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset_object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference_*` role 构造,并显式发送 `generate_audio:false`。
|
||||
- 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。
|
||||
- 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。
|
||||
|
||||
## 2026-06-18 图片画布生成音乐入口作为音频图层接入
|
||||
|
||||
- 背景:图片画布底部生成工具需要补齐游戏音效和游戏背景音乐生成,既要复用现有 Lovart 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。
|
||||
- 决策:`/editor/canvas` 新增底部 `生成音乐` 入口,点击后先弹出“生成游戏音效 / 生成游戏背景音乐”选项框,再分别创建 `audio-sound-effect` 或 `audio-background-music` 生成器;生成结果作为 `mediaType="audio"` 的画布音频卡保存,`assetKind` 分别为 `sound-effect` / `background-music`。音效请求字段固定映射 `sound/type/tempo`,背景音乐请求字段固定映射 `gpt_description_prompt` 且 `make_instrumental=true`。
|
||||
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`platform-audio`、`api-server` 编辑器音频 BFF、图片画布技术方案和音乐生成入口设计文档。
|
||||
- 验证方式:运行编辑器生成入口 / 提交 / 音频图层相关前端测试,`platform-audio` 请求体测试,`shared-contracts` editor audio 序列化测试,`api-server` editor audio 归一化测试,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 2026-06-17 图片画布生成占位统一避让落点
|
||||
|
||||
- 背景:图片画布的普通图片、规范、角色、图标、视频和 UI 设计图生成入口都会先在画布中新建“即将生成”的占位图;若各入口直接使用当前视口中心,容易压住已有图片或已有生成占位,Lovart 式连续创作体验不稳定。
|
||||
- 决策:所有会新建画布生成占位的入口统一经过 `ImageCanvasGenerationPlacementModel` 计算落点。模型以当前视口世界中心为目标,避让所有未隐藏画布图层和 active / inactive generation dialog placeholder,按 32px 画布世界坐标间距外扩阻挡矩形,选择距离当前屏幕中心对应画板位置最近且不重叠的位置。选定后立即调用 `centerViewportOnPlacement(...)`,保持当前缩放比例不变,只平移画布 viewport,让屏幕中心移动到新占位中心。
|
||||
- 影响范围:`/editor/canvas` 图片画布生成入口、`useImageCanvasGenerationWorkflow`、`ImageCanvasGenerationPlacementModel`、图片画布技术方案和 Lovart 生成类面板文档。
|
||||
- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
||||
|
||||
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
||||
- 决策:`.codex/skills/` 下只保留 `spacetimedb-cli`、`spacetimedb-concepts`、`spacetimedb-rust` 三个本地 SpacetimeDB skill;删除 `spacetimedb-typescript`、`spacetimedb-csharp`、`spacetimedb-unity`。前端 / Node 侧如需处理 SpacetimeDB 订阅或绑定,按当前生成绑定、项目代码和官方文档核对,不再依赖仓库内单独 TypeScript skill。
|
||||
- 影响范围:`AGENTS.md` 的 SpacetimeDB skill 清单、`.codex/skills/` 本地 skill 维护范围、后续 SpacetimeDB 设计 / CLI / Rust module 开发协作口径。
|
||||
- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md docs/project-memory/shared-memory/decision-log.md` 和 `npm run check:encoding`。
|
||||
- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md .hermes/shared-memory/decision-log.md` 和 `npm run check:encoding`。
|
||||
- 关联文档:`AGENTS.md`、`.codex/skills/spacetimedb-cli/SKILL.md`、`.codex/skills/spacetimedb-concepts/SKILL.md`、`.codex/skills/spacetimedb-rust/SKILL.md`。
|
||||
|
||||
## 2026-06-13 `/editor/agent` AI Web 工程编辑器采用静态沙箱预览 MVP
|
||||
|
||||
- 背景:`/editor/agent` 需要承载浏览器内类似 IDE 的 AI Web 工程编辑和实时预览能力,但 AI 生成工程的构建和运行不能进入 Genarrative 主站 JS 上下文、当前仓库源码目录或 api-server 进程。
|
||||
- 决策:第一版采用“平台编辑器壳 `/editor/agent` + api-server 控制面 + 独立 `web-project-runner` worker + 独立 preview origin”的四层结构。MVP 只支持固定 React / Vite / TypeScript 静态模板、虚拟文件系统、结构化 AI patch、平台固定构建命令、独立 runner 静态构建和独立域 iframe 预览;明确不做 HMR、终端 shell、后端服务、任意端口代理、任意 npm 安装、AI 自定义 shell script 或主站同源预览。
|
||||
- 影响范围:`/editor/agent` 前端入口、api-server Web project 控制面、Web project runtime job、runner 部署、preview gateway、artifact store、安全验收和后续作品化发布链路。
|
||||
- 验证方式:Phase 0 必须先完成技术方案、威胁模型和验收清单;Phase 1 只能在路径校验、runner 资源限制、网络隔离、preview token、iframe/CSP、失败保留上一版预览和刷新恢复验收口径明确后进入编码。
|
||||
- 关联文档:`docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md`、`docs/technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md`、`docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md`。
|
||||
|
||||
## 2026-06-13 图片大图预览统一为黑底全屏查看器
|
||||
|
||||
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
|
||||
@@ -56,6 +64,14 @@
|
||||
- 验证方式:生成页不出现“生成队列”区域;登录用户进入“我的”页且队列有 pending/running 或当前 job 为 queued/running/failed 时显示队列卡;退出登录或切换账号时不保留旧账号队列概览。前端验证运行 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
||||
|
||||
## 2026-06-13 `/editor/agent` AI Web 工程编辑器采用静态沙箱预览 MVP
|
||||
|
||||
- 背景:`/editor/agent` 需要承载浏览器内类似 IDE 的 AI Web 工程编辑和实时预览能力,但 AI 生成工程的构建和运行不能进入 Genarrative 主站 JS 上下文、当前仓库源码目录或 api-server 进程。
|
||||
- 决策:第一版采用“平台编辑器壳 `/editor/agent` + api-server 控制面 + 独立 `web-project-runner` worker + 独立 preview origin”的四层结构。MVP 只支持固定 React / Vite / TypeScript 静态模板、虚拟文件系统、结构化 AI patch、平台固定构建命令、独立 runner 静态构建和独立域 iframe 预览;明确不做 HMR、终端 shell、后端服务、任意端口代理、任意 npm 安装、AI 自定义 shell script 或主站同源预览。
|
||||
- 影响范围:`/editor/agent` 前端入口、api-server Web project 控制面、Web project runtime job、runner 部署、preview gateway、artifact store、安全验收和后续作品化发布链路。
|
||||
- 验证方式:Phase 0 必须先完成技术方案、威胁模型和验收清单;Phase 1 只能在路径校验、runner 资源限制、网络隔离、preview token、iframe/CSP、失败保留上一版预览和刷新恢复验收口径明确后进入编码。
|
||||
- 关联文档:`docs/technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md`、`docs/technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md`、`docs/technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md`。
|
||||
|
||||
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
|
||||
|
||||
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
|
||||
@@ -1801,10 +1817,10 @@
|
||||
- 验证方式:VN 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`cargo test -p api-server visual_novel`、`cargo test -p api-server creation_agent_document_input`。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-04 历史:曾在仓库 `.hermes/` 中建立团队共享记忆
|
||||
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
|
||||
|
||||
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
||||
- 决策:不共享个人 `~/.hermes`,当时先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills;该路径已被 2026-06-18 的 `docs/project-memory/` 口径取代,当前不再作为长期项目记忆入口。
|
||||
- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。
|
||||
- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/shared-memory/`。
|
||||
- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `docs/project-memory/shared-memory/` 文件。
|
||||
- 关联文档:`.hermes/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。
|
||||
@@ -2282,7 +2298,7 @@
|
||||
## 2026-06-15 图片画布角色图层新增动画生成入口
|
||||
|
||||
- 背景:图片画布已有角色形象图层标记 `assetKind="character"`,需要只对角色图片开放动画生成,不让普通素材误触发角色动画链路。
|
||||
- 决策:角色动画入口只由画布图层 `assetKind="character"` 控制,在图片上方浮动工具条和右键菜单显示 `生成动画`;非角色图层不展示入口。点击后打开独立 `角色动画生成面板`,桌面端锚定到图片右侧,移动端按底部面板承接。前端固定提交 `seedance2.0`、分辨率 / 比例 / 帧数 / 时长 / 价格字段;后端经 `/api/editor/character-animations/generations` 使用角色图作为首帧和尾帧生成视频,并立即抽取 32 / 40 / 48 帧、绿幕去背后写入 OSS。
|
||||
- 决策:角色动画入口只由画布图层 `assetKind="character"` 控制,在图片上方浮动工具条和右键菜单显示 `生成动画`;非角色图层不展示入口。点击后打开独立 `角色动画生成面板`,桌面端锚定到图片右侧,移动端按底部面板承接。前端固定提交 `seedance2.0-fast`、分辨率 / 比例 / 帧数 / 时长 / 价格字段;后端经 `/api/editor/character-animations/generations` 使用角色图作为首帧和尾帧生成视频,并立即抽取 32 / 40 / 48 帧、绿幕去背后写入 OSS。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/shared-contracts/src/assets.rs`、图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
@@ -2295,13 +2311,13 @@
|
||||
- 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标素材规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。
|
||||
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-16 图片画布图标素材采用 nanobanana2 spritesheet + 后端连通域拆分
|
||||
## 2026-06-16 图片画布图标素材与角色生成支持双图片模型
|
||||
|
||||
- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布。
|
||||
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。前端提交 `/api/editor/icon-spritesheets/generations`,后端固定使用 VectorEngine `gemini-3.1-flash-image-preview`,`<=25` 个描述用 `512x512`,`>25` 个描述用 `1024x1024`,先生成绿幕 1:1 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`cargo test --manifest-path server-rs/Cargo.toml -p platform-image generated_asset_sheets::sheet::tests -- --nocapture`、`cargo test --manifest-path server-rs/Cargo.toml -p api-server editor_project -- --nocapture`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布;角色形象生成也需要和图标素材共用同一套图片模型选择、比例和大小口径。
|
||||
- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。`生成角色形象` 与 `生成图标素材` 均支持 VectorEngine `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`,用户在两类面板中切换过模型后下一次打开继续沿用上次模型。前端提交 `model`、`aspectRatio`、`imageSize`;后端不再按图标数量分 `512x512/1024x1024`,而是按模型归一尺寸:`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,把参考图写成 `inline_data`,并在 `generationConfig.imageConfig` 写入比例和大小,`0.5K` 传 `"512"`;`gpt-image-2` 无参考图走 generations,有参考图走 edits,按文档支持的 `size` 字符串映射。图标素材仍先生成绿幕 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`,角色图层写入 `assetKind="character"`。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/*`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p api-server editor_generation_dimensions_follow_model_options --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image nanobanana_generate_content --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-16 图片画布生成面板与浮层层级收口
|
||||
|
||||
@@ -2326,3 +2342,26 @@
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout hydrate、新建 / 上传 / 生成 / 快速编辑 / 图标素材生成结果铺回画布逻辑,以及图片画布技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "hydrates canvas images from Resolution instead of saved Size|opens generated image info from the corner button and creates a real right-side edit result|shows image resolution on hover"`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-17 图片画布底部生成视频接入 Lovart 面板
|
||||
|
||||
- 背景:编辑器画板底部工具栏需要新增 `生成视频`,并和现有 Lovart 式生成类面板、泥点展示、占位图和画布结果图层保持一致。
|
||||
- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期前端仅开放 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,不展示 Veo 模型入口,默认 `seedance2.0-fast`。后端必须严格区分 Seedance 2.0 Fast 与标准版:`seedance2.0-fast` 映射 `doubao-seedance-2-0-fast-260128`,`seedance2.0` 映射 `doubao-seedance-2-0-260128`,不得混用;固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。
|
||||
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"`、`npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx`、`npm run test -- src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-17 图片画布生成器快照纳入画布布局
|
||||
|
||||
- 背景:生成占位图和生成器对话框里包含用户输入、参数、参考图、占位框位置和生成结果绑定,刷新后丢失会让已生成图片无法回到 Lovart 式跟随编辑状态。
|
||||
- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类生成结果同步写入账号级素材库;视频结果当前只作为画布视频资源保存。
|
||||
- 影响范围:图片画布 layout 序列化 / hydrate、生成工作流、生成器渲染、项目自动保存、素材库回填和编辑器技术方案。
|
||||
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`、浏览器刷新 smoke。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-18 编辑器画板音效参数与待生成占位类型化
|
||||
|
||||
- 背景:`/editor/canvas` 新建视频、角色形象、音效和背景音乐待生成对象时沿用图片占位 icon,音效面板仍把 `type` 与 `tempo` 分成两个旧字符串选项,不符合 Lovart 式简洁参数按钮和 BPM 输入需求。
|
||||
- 决策:画布待生成占位按生成器模式渲染专属空白样式、icon 与右上角标签:视频、角色、音效、背景音乐不再统一使用图片 icon;角标继续按 viewport 反向缩放。编辑器音效请求契约改为 `type: "one-shot" | "loop"` 与 `tempo: number | null`,BPM 范围 `1-300`,默认 `null`;前端底部只保留一个无标题音效参数按钮,组合显示如 `单次·120BPM`。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasWorldView.tsx`、`ImageCanvasGenerationComposerView.tsx`、`ImageCanvasEditorTypes.ts`、`ImageCanvasGenerationSubmissionModel.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/shared-contracts/src/assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`server-rs/crates/platform-audio/src/request.rs`。
|
||||
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts --reporter verbose`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_sound_effect --manifest-path server-rs/Cargo.toml`。
|
||||
|
||||
@@ -47,29 +47,29 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片编辑器宣发素材参考图不要按原图 Data URL 提交
|
||||
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
|
||||
|
||||
- 现象:宣发素材卡片里参考图缩略图已经出现,但点击生成后后端返回 `Failed to buffer the request body: length limit exceeded` 或前端只看到 `Failed to fetch`。
|
||||
- 原因:上传的宣发参考图曾以完整 Data URL 存入 `publicationReferences`,生成提交时直接放进 `/api/editor/images/generations` 的 `referenceImageSrcs` JSON;大图会超过 Axum 默认 `2MB` body limit,请求还没进入 handler 就被拦下。
|
||||
- 处理:宣发素材提交前统一用 `resolveEditorImageReferenceDataUrlForGeneration(...)` 把参考图解析并压缩到生成理解可用的 Data URL;`/api/editor/images/generations` 路由设置 `12MB` `DefaultBodyLimit` 作为兼容兜底。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts`;`cargo test -p api-server editor_image_generation_accepts_reference_body_above_default_limit --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/modules/editor_project.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`。
|
||||
|
||||
## 图片编辑器宣发素材明确像素尺寸不要映射成比例预设
|
||||
|
||||
- 现象:宣发素材运营海报约束写的是 `1280 x 720`,但 VectorEngine 请求实际尺寸可能变成 `1536x1024`,用户看到贴片比例和约束不一致。
|
||||
- 原因:`platform-image` 的 VectorEngine 适配层曾把 `1280x720`、`1600x900` 这类明确像素值和 `16:9` 比例别名放在同一个归一化分支里,统一映射成旧的 provider 预设 `1536x1024`;前端和 api-server 传入的 `720x540`、`720x1280` 本身不是根因。
|
||||
- 处理:明确像素值统一保留并把 `*` 写法规范成 `x` 写法;只有 `16:9`、`9:16`、`2k` 等比例 / 档位别名继续映射 provider 预设。宣发素材入口使用 `720x540`、`720x1280`、`1280x720`。
|
||||
- 验证:`cargo test -p platform-image vector_engine --manifest-path server-rs/Cargo.toml`;`cargo test -p api-server editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/platform-image/src/vector_engine/request.rs`、`server-rs/crates/platform-image/tests/vector_engine.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】宣发素材工具演示入口设计-2026-06-17.md`。
|
||||
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
|
||||
- 原因:火山 Seedance 2.0 参考视频只支持公网 URL 或 `asset://` 素材 ID,项目内画板资源应以 `objectKey` 由后端换签;视频不支持 Base64 / `data:video`,且 50MB 视频转 Base64 后会逼近或超过 64MB 请求体上限。参考音频虽然支持 Base64,但也不能单独输入,且大文件同样不应塞进 JSON。
|
||||
- 处理:参考视频 / 音频上传先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时优先使用 `objectKey`。后端归一化必须拒绝 `data:video/*`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`。
|
||||
- 验证:`npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`;`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`;`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`src/services/image-editor/editorReferenceUploadClient.ts`、`src/components/image-editor/useImageCanvasUploadWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片编辑器生成类菜单要挂到页面级 portal
|
||||
|
||||
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
|
||||
- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界。
|
||||
- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
|
||||
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。
|
||||
- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界;即便挂到 portal,如果菜单根节点的 `pointerdown` 继续冒泡到画布视口,也会先触发画布失焦并卸载面板,导致菜单项 `click` 前消失。
|
||||
- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;`PlatformFloatingMenu` 根节点必须阻止 `pointerdown` 冒泡,避免画布清空当前生成面板;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
|
||||
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在;规范参考图来源菜单应能通过 portal 点击“从画布中选择 / 上传图片”并写回规范参考图。
|
||||
- 关联:`src/components/common/PlatformFloatingMenu.tsx`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。
|
||||
|
||||
## 图片编辑器规范图片面板不要脱离统一生成 shell
|
||||
|
||||
- 现象:生成 UI 设计图或新建图标素材规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标素材规范甚至可能缺少首行参考图入口。
|
||||
- 原因:规范、UI 设计图等面板虽然都属于生成类入口,但 JSX 和 CSS 曾各自维护 `spec-footer`、局部 field wrapper 或缺省参考区,导致后续改造只覆盖普通图片 / 角色 / 视频,规范图片类面板结构漂移。
|
||||
- 处理:生成规范下的角色形象规范、UI 素材规范、图标素材规范、自定义规范,以及生成 UI 设计图,都必须复用 `image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image` 外层 shell;首行统一 `image-canvas-editor__generation-ref`,底部统一 `image-canvas-editor__generation-composer-footer` + `image-canvas-editor__generation-submit`。多字段内容只在中央字段区保持紧凑,不单独发明 footer 或省略参考区。
|
||||
- 验证:`npm test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -t "生成UI设计图|生成规范|visible titles|图标素材规范|character spec"`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx`、`src/index.css`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片编辑器生成占位图在生成中也要使用最新拖拽位置
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
- 验证:`npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps the generation placeholder draggable while the image is generating"`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片画布 Lovart 新生成占位必须避让已有图层和占位
|
||||
|
||||
- 现象:用户在画布中心已有图片时继续点击“生成图片 / 生成视频 / 生成规范”等入口,新建的待生成占位压在已有图片或其它待生成占位上;生成完成后看起来像图片被覆盖或丢失。
|
||||
- 原因:入口直接把 placeholder 放在当前视口中心,没有把已有图层、隐藏状态和 inactive generation dialog 的占位统一纳入避让计算,也没有在落点确定后把 viewport 平移到新占位中心。
|
||||
- 处理:所有会创建 generation dialog 的入口都必须走 `ImageCanvasGenerationPlacementModel`,避让所有 `hidden !== true` 的图层和 active / inactive placeholder;按 32px 世界坐标间距外扩阻挡矩形,在候选点中选择距离当前屏幕中心对应画板位置最近且不重叠的位置,再调用 `centerViewportOnPlacement(...)` 保持缩放只平移。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc
|
||||
|
||||
- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。
|
||||
@@ -869,11 +877,11 @@
|
||||
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`;`npm run typecheck`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## `.hermes` 只放 Hermes 工具资源,不放项目记忆或个人配置
|
||||
## `.hermes` 只放共享内容,不放个人 Hermes 配置
|
||||
|
||||
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
|
||||
- 原因:仓库 `.hermes/` 与个人 `~/.hermes/` 名称相似。
|
||||
- 处理:仓库 `.hermes/` 只放 Hermes 专用 skills、plugins 和启用说明;团队共享记忆、计划和 TODO 统一放在 `docs/project-memory/`;不提交 `.env`、`config.yaml`、`sessions/`、`auth.json`。
|
||||
- 处理:仓库 `.hermes/` 只放 Markdown 共享记忆、计划和可公开 skills;不提交 `.env`、`config.yaml`、`sessions/`、`auth.json`。
|
||||
- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。
|
||||
- 关联:`.hermes/README.md`。
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在图片画布编辑器中,仅对角色图片提供角色动画生成入口,并通过后端 seedance2.0 链路生成视频、抽帧、去绿幕并持久化到 OSS。
|
||||
**Goal:** 在图片画布编辑器中,仅对角色图片提供角色动画生成入口,并通过后端 seedance2.0-fast 链路生成视频、抽帧、去绿幕并持久化到 OSS。
|
||||
|
||||
**Architecture:** 前端在 `ImageCanvasEditorView` 中基于图层 `assetKind === "character"` 控制悬浮按钮和右键菜单,打开锚定到图片右侧的独立动画生成面板。前端 service 调用新增编辑器角色动画 API,后端复用 `character_animation_assets.rs` 中现有视频生成、抽帧、绿幕去背、OSS 写入能力,避免新建平行资产系统。
|
||||
|
||||
**Tech Stack:** React + TypeScript + Vitest;Rust Axum `api-server`;现有 `shared-contracts` 资产 DTO;Aliyun OSS 资产持久化;VectorEngine/Ark seedance2.0 角色动画链路。
|
||||
**Tech Stack:** React + TypeScript + Vitest;Rust Axum `api-server`;现有 `shared-contracts` 资产 DTO;Aliyun OSS 资产持久化;VectorEngine/Ark seedance2.0-fast 角色动画链路。
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
- [ ] **Step 1: 新增 service 类型和请求函数**
|
||||
- `generateEditorCharacterAnimation(input)` 调用 `/api/editor/character-animations/generations`。
|
||||
- 限定 model 固定为 `seedance2.0` 的回包展示字段。
|
||||
- 限定 model 固定为 `seedance2.0-fast` 的回包展示字段。
|
||||
|
||||
- [ ] **Step 2: 扩展图层 assetKind**
|
||||
- `CanvasLayer.assetKind` 支持 `'character' | 'spec' | null`。
|
||||
@@ -71,7 +71,7 @@
|
||||
- Modify: `C:/Genarrative/server-rs/crates/api-server/src/modules/play_flow.rs` 或现有 editor router 文件(按现有路由事实选择)
|
||||
|
||||
- [ ] **Step 1: 写 DTO / prompt / plan 单测**
|
||||
- 验证请求 480p/720p、32/40/48 帧、比例枚举、模型固定 seedance2.0。
|
||||
- 验证请求 480p/720p、32/40/48 帧、比例枚举、模型固定 seedance2.0-fast。
|
||||
- 验证构造 prompt 包含用户给定固定骨架与动作描述。
|
||||
- 验证价格计算:480p 每秒 10,720p 每秒 20。
|
||||
|
||||
|
||||
@@ -12,14 +12,15 @@
|
||||
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
|
||||
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
|
||||
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成、局部修改 / 蒙版、文字、形状 / 标注、导出。
|
||||
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
|
||||
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
|
||||
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
|
||||
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
|
||||
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
|
||||
- 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。
|
||||
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图落在最新占位框位置,输入框继续跟随新生成图;点击所有图片生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
|
||||
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色形象规范 / 常规参考图 / 图标素材规范 / 编辑参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
|
||||
- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。
|
||||
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范、快速编辑和重绘保留既有 `gpt-image-2` 路径;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板只展示只读参数,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后作为 `quick-edit` 参考图走同一图片生成 BFF,并在原图右侧生成一张新图。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和重绘在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
|
||||
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
|
||||
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频` 和 `生成UI设计图`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。
|
||||
|
||||
## 交互规则
|
||||
|
||||
@@ -41,7 +42,8 @@
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
|
||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。
|
||||
- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
@@ -62,8 +64,10 @@
|
||||
- `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。
|
||||
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片;携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标素材规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize` 和 `priceMudPoints`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`,后端把 UI 尺寸归一为文档支持的 `1024x1024`、`1024x1536`、`1536x1024`、`2048x2048`、`2048x1152` 等 `size` 字符串。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
|
||||
所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。
|
||||
|
||||
@@ -82,12 +86,16 @@
|
||||
- 左下角小地图可展示当前图层分布和视口范围,开关按钮可隐藏 / 恢复小地图,背景色菜单可切换白色、浅灰、暖灰和冷蓝工作区底色。
|
||||
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
|
||||
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
|
||||
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
|
||||
- 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
||||
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。
|
||||
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
|
||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色形象规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
|
||||
- 生成规范类图片面板底部必须显示只读 `16:9`、`2K`、`gpt-image-2`,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
|
||||
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
|
||||
- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、角色图专属生成动画、分割线、重绘、下载按钮。裁扩支持输入四边像素,负数裁剪、正数扩大,扩大区域保持透明;去除背景使用前端轻量去背并回写为新的工程资源快照。
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
|
||||
- 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。
|
||||
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
@@ -102,4 +110,4 @@
|
||||
- 接入图片生成 / 修改计费、队列进度状态、OSS 落盘和更完整失败审计。
|
||||
- 资产库接入:素材栏从用户资产、历史生成图或上传结果读取。
|
||||
- 图层模型:引入稳定 layer id、z-index、锁定、隐藏和多选。
|
||||
- 图像编辑:将占位工具替换为裁剪、抠图、局部修改、蒙版和导出等真实能力。
|
||||
- 图像编辑:继续扩展更精细的局部修改、蒙版、智能抠图与导出能力。
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 生成类面板 Lovart 统一改造方案
|
||||
|
||||
日期:`2026-06-17`
|
||||
|
||||
## 范围
|
||||
|
||||
图片画布编辑器内以下生成类面板统一对齐 Lovart 式极简创作工具风格:
|
||||
|
||||
- `生成图片`
|
||||
- `生成规范`
|
||||
- `生成角色形象`
|
||||
- `生成图标素材`
|
||||
- `生成UI设计图`
|
||||
- `生成视频`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
|
||||
## 统一布局
|
||||
|
||||
1. 参考图区域永远位于面板第一行。
|
||||
2. 参考图使用统一方形参考图图标组件,不再使用横向长卡:
|
||||
- 普通参考图:灰蓝色图片图标。
|
||||
- 规范参考图:紫色规范 logo。
|
||||
- 图标素材规范:绿色图标图标。
|
||||
- UI 图标规范:琥珀色图标图标。
|
||||
- 视频参考图:深色视频图标。
|
||||
3. 角色形象规范参考图组件只展示一个方形参考图图标;图标内上方是规范 logo,下方固定短标 `角色规范`,不再展示绑定状态、来源说明或长标题。已有绑定图片的原始标题只作为可访问名称、悬浮 title 和图片信息回看使用。
|
||||
4. 参考图图标尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。
|
||||
5. 已有参考图在鼠标悬停或键盘聚焦到对应参考图槽位时,右上角显示一个 `×` 删除按钮;鼠标不在槽位上时不显示。点击 `×` 只移除该参考图绑定,不触发来源菜单、不删除画布图片。
|
||||
6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。
|
||||
7. 单文本输入面板不显示文本框标题,用问题式 placeholder:
|
||||
- 生成图片:`今天想生成什么画面?`
|
||||
- 生成角色:`你希望角色如何设计?`
|
||||
- 生成 UI:`你希望这个 UI 长什么样?`
|
||||
- 生成视频:`你希望生成什么视频?`
|
||||
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
|
||||
9. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
|
||||
## 参数交互
|
||||
|
||||
画面比例、大小尺寸、模型、分辨率、时长等生成参数统一为 Lovart 式底部胶囊按钮:
|
||||
|
||||
```text
|
||||
左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成12泥点]
|
||||
```
|
||||
|
||||
- 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。
|
||||
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 type 和 BPM 合并成一个无标题左下角选项框。
|
||||
- 模型选项框和生成按钮位于右下角。
|
||||
- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。
|
||||
- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。
|
||||
- 选项面板打开后,点击父级生成面板内任意非选项框、非触发按钮区域必须收起;点击选项面板自身仍保持打开,便于连续修改。
|
||||
- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,方便连续修改。
|
||||
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
|
||||
- 比例选项卡片内展示对应比例的线框。
|
||||
- 父级面板、底部选项框、弹出子面板字号保持一致。
|
||||
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`、`单次·120BPM`。
|
||||
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
|
||||
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范` 和 `生成音乐`。
|
||||
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
|
||||
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
|
||||
- 生成规范类图片固定使用 `16:9 · 2K · gpt-image-2`。这三个参数在面板底部以只读短胶囊展示,不提供比例、尺寸或模型修改入口。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
## 泥点显示
|
||||
|
||||
- 本次消耗泥点必须显示在生成按钮内部。
|
||||
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。
|
||||
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
|
||||
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。
|
||||
|
||||
## 画布占位落点
|
||||
|
||||
- 任何会创建“即将生成”画布占位的 Lovart 面板入口,都必须先生成对应 draft,再通过统一 placement 模型改写 placeholder,不能直接使用 draft 初始中心点。
|
||||
- 统一入口范围:生成图片、生成规范、生成角色形象、生成图标素材、生成视频、生成 UI 设计图。
|
||||
- 避让对象:所有 `hidden !== true` 的画布图层,以及当前 active / inactive generation dialogs 中已有的 placeholder。
|
||||
- 避让间距:每个避让矩形向外扩 32px,保证新占位和已有图层 / 占位之间保留可见空隙。
|
||||
- 距离目标:以当前视口世界中心为目标点,在候选落点中选择距离最近且不重叠的位置;中心被占用时按相邻边、四角和外圈候选继续寻找。
|
||||
- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。
|
||||
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
|
||||
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
|
||||
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
|
||||
|
||||
## 画布悬浮信息
|
||||
|
||||
- 待生成占位图只有聚焦或生成中才显示高亮边框、左上角生成器名称和右上角原始尺寸;失焦后保留灰色占位块本体,不显示这些辅助信息。
|
||||
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
|
||||
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
|
||||
- 视频 / 角色 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
|
||||
|
||||
## 画布保存
|
||||
|
||||
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
|
||||
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。
|
||||
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
|
||||
- 图片类生成结果还要写入账号级素材库;视频结果先只作为画布资源和视频图层保存。
|
||||
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
|
||||
|
||||
## 第一版计费配置
|
||||
|
||||
```text
|
||||
生成图片:12 泥点
|
||||
生成规范:5 泥点
|
||||
生成角色形象:12 泥点
|
||||
生成图标素材:12 泥点
|
||||
生成UI设计图:12 泥点
|
||||
生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点
|
||||
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
|
||||
生成音效:10 泥点
|
||||
生成背景音乐:5 泥点
|
||||
```
|
||||
|
||||
## 生成视频模型与接口
|
||||
|
||||
- 底部 `生成视频` 面板提交到 `POST /api/editor/videos/generations`。
|
||||
- 固定 `16:9`、静音、标准模式;结果作为 `mediaType="video"`、`assetKind="video"` 的视频图层加入画布。
|
||||
- 支持模型:
|
||||
- `seedance2.0-fast`
|
||||
- `seedance2.0`
|
||||
- `kling3.0`
|
||||
- `kling3.0-omni`
|
||||
- 前端不展示 Veo 模型入口。
|
||||
- `seedance2.0-fast` 是生成视频默认项,并继续映射到 `doubao-seedance-2-0-fast-260128`;`seedance2.0` 是标准版可选项,必须独立映射到 `doubao-seedance-2-0-260128`,不得与 Fast 混用。
|
||||
- 角色动画生成固定使用 `seedance2.0-fast`,不继承生成视频标准版选择。
|
||||
- 后端复用现有 Ark / VectorEngine content generation task 轮询链路,并把生成视频持久化到 OSS;缺少 `ARK_CHARACTER_VIDEO_BASE_URL` 或 `ARK_CHARACTER_VIDEO_API_KEY` 时 fail-closed 返回配置错误。
|
||||
|
||||
### Seedance 2.0 参考媒体与提交约束
|
||||
|
||||
- 参考媒体只对 `seedance2.0-fast` / `seedance2.0` 开放;切换到 Kling 等非 Seedance 模型时,前端不提交 `referenceImageSrcs` / `referenceVideoSrcs` / `referenceAudioSrcs`,后端收到非 Seedance 参考字段必须拒绝。
|
||||
- 多模态参考按火山 Seedance 2.0 文档限制:参考图片 0~9 张,参考视频 0~3 个,参考音频 0~3 段;音频不可单独输入,必须至少搭配 1 张参考图片或 1 个参考视频。
|
||||
- 参考图片支持 URL / Base64 / `asset://` / 画板资源路径;格式为 jpeg、png、webp、bmp、tiff、gif、heic、heif,单张小于 30MB,请求体总大小不超过 64MB。
|
||||
- 参考视频只支持 URL / `asset://` / 画板资源路径,不支持 Base64 / `data:video/*`;格式为 mp4、mov,单个文件不超过 50MB,单个时长 [2, 15] 秒,最多 3 个且总时长不超过 15 秒。
|
||||
- 参考音频支持 URL / Base64 / `asset://` / 画板资源路径;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒。
|
||||
- 前端上传参考视频 / 参考音频必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时优先使用 `objectKey`。
|
||||
- 后端接收画板资源 `objectKey` 后统一重新签名为 Ark 可读 URL,再按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。
|
||||
- 画板生成视频当前固定无声,因此 Ark 请求必须显式带 `generate_audio:false`,不能只依赖前端 `sound=off` 或上游默认值。
|
||||
- Seedance 2.0 文档提示不支持直接上传含真人人脸的参考图 / 视频;当前画板尚未做真人脸授权证明、来源声明或服务端人脸拦截,后续开放真人素材前必须补授权/来源确认链路。
|
||||
|
||||
## 验收
|
||||
|
||||
- 所有生成类面板首行都是参考图区域。
|
||||
- 参考图槽位统一是方形图标;角色形象规范槽位显示规范 logo 和 `角色规范` 四字,悬停已有参考图时才显示右上角删除 `×`。
|
||||
- 比例 / 尺寸 / 模型不再平铺全部选项;比例与尺寸合并为左下角当前值按钮,模型与生成按钮位于右下角。
|
||||
- 单文本输入面板不显示字段标题,placeholder 是问题式文案。
|
||||
- 多文本输入面板字段标题和边界仍清晰;图标素材面板应只有一个 `素材描述` 文本输入框,不出现列表式 `素材描述 N` 卡片或添加描述按钮。
|
||||
- 生成按钮内能看到 `N泥点` 文案。
|
||||
- 弹出选项面板通过 `role=menu` 暴露,点击选项后保持打开,可连续修改多个字段。
|
||||
- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示只读 `16:9`、`2K`、`gpt-image-2`,提交参数也固定为这三项,不出现可编辑的比例 / 尺寸 / 模型选项。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
- 生成视频 / 角色形象 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮;type 只支持 `one-shot` / `loop`,BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
|
||||
- Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 含 `generate_audio:false` 且不含未确认的 `mode` 字段。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 【编辑器】画板UI设计图生成入口设计
|
||||
|
||||
日期:2026-06-17
|
||||
|
||||
## 入口与画布状态
|
||||
|
||||
- 底部 AI 画布工具栏新增 `生成UI设计图`。
|
||||
- 点击后立即在画布中新建 `UI设计图生成占位图`,不复用普通新建图片的空白样式。
|
||||
- 占位图默认 16:9 展示,生成成功后替换为 `assetKind: "ui-design"` 的画布图层。
|
||||
|
||||
## 生成面板
|
||||
|
||||
- 占位图下方打开独立生成面板,标题为 `生成UI设计图`。
|
||||
- 面板复用普通 `生成图片` / `生成角色形象` / `生成视频` 的生成类 shell:首行参考图区域、中央单文本输入、底部参数与生成按钮区。
|
||||
- 面板第一个模块为 `图标素材规范`,并放在首行参考图区域。
|
||||
- 点击图标素材规范卡片后,在卡片旁弹出来源菜单:
|
||||
- `从画布中选择`
|
||||
- `新建图标素材规范`
|
||||
- `上传图片`
|
||||
- `从画布中选择` 只接受 `assetKind: "icon-spec"` 的图层;普通图片、其他类别图层和不携带标签的图片不绑定。
|
||||
- `新建图标素材规范` 复用现有图标素材规范生成表单。
|
||||
- `上传图片` 仅绑定到当前 UI 设计面板的图标素材规范参考,不自动添加为画布图层。
|
||||
|
||||
## 生成参数
|
||||
|
||||
- 支持自定义画面比例和大小尺寸。
|
||||
- 模型固定为 `gpt-image-2`,面板不提供切换到其他模型的能力。
|
||||
- 默认画面比例为 `16:9`,默认大小为 `1K`。
|
||||
|
||||
## 提示词契约
|
||||
|
||||
后端收到 `kind: "ui-design"` 时固定拼接:
|
||||
|
||||
```text
|
||||
生成玩法UI原型图
|
||||
【用户输入】<用户输入>
|
||||
```
|
||||
|
||||
如果用户设置了图标素材规范参考图,则追加:
|
||||
|
||||
```text
|
||||
参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。
|
||||
```
|
||||
|
||||
生成请求固定使用 `gpt-image-2`。有参考图时走图片编辑请求;无参考图时走图片生成请求。
|
||||
|
||||
## 验收点
|
||||
|
||||
- 点击 `生成UI设计图` 后出现 UI 设计占位图和独立生成面板。
|
||||
- 面板第一模块为图标素材规范,来源菜单包含三个动作。
|
||||
- 从画布选择时只能绑定图标素材规范图片。
|
||||
- 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。
|
||||
- 生成图层信息面板展示 `用户输入` 与 `图标素材规范`。
|
||||
@@ -21,15 +21,14 @@
|
||||
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标素材规范图片;其它图片点击无效。
|
||||
- `新建图标素材规范` 复用生成规范表单,规格类型为 `图标素材规范`,生成成功后图层标记为 `icon-spec`。
|
||||
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
|
||||
2. 第二模块为素材描述列表。
|
||||
- 每个文本框输入一个素材描述。
|
||||
- 默认填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。
|
||||
- 可以继续添加新的素材描述框,最多 `100` 个。
|
||||
- 生成时过滤空文本,按面板从上到下顺序作为 prompt 的素材清单。
|
||||
2. 第二模块为素材描述文本框。
|
||||
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
|
||||
- 默认按换行填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。
|
||||
- 生成时按换行、逗号、顿号、分号、斜杠或竖线切分,过滤空文本后最多保留 `100` 个素材描述,并按文本顺序作为 prompt 的素材清单。
|
||||
|
||||
## 面板外观
|
||||
|
||||
- 图标素材面板不使用内部纵向滑动列表;素材描述项按横向卡片铺开,新增一项就让面板整体更宽,保持列表一眼可扫。
|
||||
- 图标素材面板不再使用列表式素材描述框,也不再按描述项横向扩宽;素材描述区改为与角色形象生成面板一致的单个文本输入框。
|
||||
- 图标素材规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。
|
||||
- 规范卡的 `从画布中选择 / 新建图标素材规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。
|
||||
|
||||
@@ -39,11 +38,14 @@
|
||||
- 请求字段:
|
||||
- `referenceImageSrc`:图标素材规范 Data URL。
|
||||
- `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`。
|
||||
- `model`:固定 `gemini-3.1-flash-image-preview`。
|
||||
- 后端根据图标数量选择尺寸:
|
||||
- `<=25` 个:`512x512`,即 0.5K 1:1。
|
||||
- `>25` 个:`1024x1024`,即 1K 1:1。
|
||||
- 后端使用 VectorEngine 图片编辑接口,把 `referenceImageSrc` 作为参考图 1,模型固定传 `gemini-3.1-flash-image-preview`。
|
||||
- `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
|
||||
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
|
||||
- `priceMudPoints`:固定取编辑器生成计费配置中的 `icon` 档位,首版为 `12`;前端只提交该配置值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死其它价格。
|
||||
- 模型与尺寸选项:
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标素材规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。
|
||||
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标素材规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- Prompt 固定为:
|
||||
|
||||
```text
|
||||
@@ -71,7 +73,8 @@
|
||||
|
||||
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
|
||||
- `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。
|
||||
- 默认 6 个素材描述会进入 prompt;新增描述最多到 100 个。
|
||||
- `<=25` 个描述提交时后端请求尺寸为 `512x512`;`>25` 个描述提交时后端请求尺寸为 `1024x1024`。
|
||||
- VectorEngine 请求体的 `model` 为 `gemini-3.1-flash-image-preview`。
|
||||
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。
|
||||
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 生成成功后画布出现按描述命名的多个透明图标素材图层,图层之间不重叠。
|
||||
- 生成图标素材提交体包含 `priceMudPoints: 12`;若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
4. 左下角展示画面比例和大小选择按钮。
|
||||
5. 右下角展示模型选择和生成按钮。
|
||||
|
||||
## 普通生成面板视觉口径
|
||||
|
||||
普通 `生成图片`、`生成角色形象`、`生成UI设计图` 等锚定在图片下方的生成面板统一采用 Lovart 式极简创作工具风格:
|
||||
|
||||
- PC 端优先保持面板锚定在占位图或目标图片下方,宽度允许比旧版略宽,避免比例、尺寸、模型和生成按钮挤成一行难以阅读。
|
||||
- 面板只展示创作必需输入:参考图、提示词、比例、大小、模型和生成按钮;不在 UI 内铺说明性规则文案。
|
||||
- 视觉以白色半透明面板、轻边框、低阴影、低饱和选项按钮和清晰黑色主按钮为主,减少游戏式厚重装饰,贴近 Lovart 的极简画布工具感。
|
||||
- 普通 `生成图片` 面板的比例、大小和模型必须使用真实选项按钮,不再使用占位式参数按钮或弹出“建设中”提示。
|
||||
- 移动端仍可固定在底部工具栏上方并允许内部滚动,保证不遮挡底部 AI 工具栏和画布操作。
|
||||
|
||||
## 生成与参考图契约
|
||||
|
||||
- 前端提交角色生成时,使用 `POST /api/editor/images/generations`。
|
||||
@@ -39,8 +49,14 @@
|
||||
- 角色形象规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
|
||||
1. 角色形象规范图。
|
||||
2. 常规参考图列表。
|
||||
- 当前请求尺寸沿用编辑器普通生成默认值;比例和大小按钮先复用现有占位交互。
|
||||
- 后端如果收到参考图,则走带多参考图的图片编辑/参考图生成链路;没有参考图时走纯文本生成链路。
|
||||
- 请求同时提交 `model`、`aspectRatio` 和 `imageSize`:
|
||||
- `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。
|
||||
- 尺寸选项来源以 VectorEngine 接入文档为准:
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。
|
||||
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations` 或 `/v1/images/edits`,按文档尺寸映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。
|
||||
- 后端如果收到参考图,`nanobanana2` 把参考图作为 `inline_data` 传入原生 `generateContent`;`gpt-image-2` 走带多参考图的图片编辑链路。没有参考图时按所选模型走纯文本生成链路。
|
||||
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
|
||||
|
||||
```text
|
||||
@@ -52,11 +68,25 @@
|
||||
|
||||
## 生成规范参考图
|
||||
|
||||
- `生成规范 -> 角色形象规范`、`UI素材规范`、`自定义规范` 的设定面板支持上传 1 张参考图;`图标素材规范` 继续使用后续图标素材生成面板里的专用规范图链路,不在这里重复新增入口。
|
||||
- `生成规范 -> 角色形象规范`、`UI素材规范`、`图标素材规范`、`自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。
|
||||
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
|
||||
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
|
||||
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
|
||||
|
||||
## 新建生成图落点避让
|
||||
|
||||
- 普通生成、生成规范、生成角色形象和生成图标素材在创建画布占位图前,必须先检测占位图矩形是否与画布中已有可见图片图层或已有生成占位图重叠。
|
||||
- 若当前屏幕中心对应的画板位置可用,则占位图仍创建在该中心;若重叠,则以当前屏幕中心对应画板位置为原点,按距离由近到远查找不重叠候选位置。
|
||||
- 候选占位图与相邻图片或占位图之间保留 `32px` 画板间距;隐藏图层不阻挡新建落点。
|
||||
- 选中落点后保持当前缩放比例不变,将视口中心移动到新占位图中心,确保用户创建后立即看到新图和生成面板。
|
||||
|
||||
## 图片信息展示
|
||||
|
||||
- 图片信息弹窗标题固定为 `图片信息`,不拼接图片 / 图层名称。
|
||||
- 图片信息弹窗不展示 Provider 行,也不在 Task 中暴露 Provider 字符串。
|
||||
- Task 只展示任务标识里的数字部分;若任务标识没有数字,则显示 `-`。
|
||||
- 图片 / 图层名称只用于画布内部选择、图层列表和素材管理,不进入图片信息弹窗。
|
||||
|
||||
## 可访问性与状态
|
||||
|
||||
- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`。
|
||||
@@ -71,13 +101,16 @@
|
||||
- `角色形象规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
|
||||
- `从画布中选择` 后点击已有画布图片可绑定为角色形象规范,`Esc` 可退出点选状态。
|
||||
- 上传常规参考图后缩略图右下角显示序号。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt 和参考图数组。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`aspectRatio` 和 `imageSize`。
|
||||
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
|
||||
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
|
||||
|
||||
## 当前落地记录
|
||||
|
||||
- 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。
|
||||
- 画布生成类入口已统一接入新建占位图落点避让:优先使用当前屏幕中心对应画板位置,重叠时自动选择最近的不重叠位置,并将视口中心移动到新占位图。
|
||||
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色形象规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。
|
||||
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕去背、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
|
||||
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
|
||||
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
|
||||
@@ -103,7 +136,7 @@
|
||||
- 分辨率:`480p`、`720p`。
|
||||
- 画面比例:默认 `与角色图片保持同尺寸`,可选 `1:1`、`4:3`、`16:9`、`9:16`、`3:4`。
|
||||
- 时长:`32帧·4秒`、`40帧·5秒`、`48帧·6秒`。
|
||||
4. 模型固定使用 `seedance2.0`,前端不提供模型切换。
|
||||
4. 模型固定使用 `seedance2.0-fast`,前端不提供模型切换。
|
||||
5. 生成按钮上方显示本次生成文本摘要和生成价格:
|
||||
- `480p` 每秒 `10` 泥点。
|
||||
- `720p` 每秒 `20` 泥点。
|
||||
@@ -112,7 +145,7 @@
|
||||
|
||||
- 前端提交到 `POST /api/editor/character-animations/generations`。
|
||||
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。
|
||||
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 seedance2.0 对应后端模型。
|
||||
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。
|
||||
- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。
|
||||
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# 画板音乐生成入口设计
|
||||
|
||||
日期:`2026-06-18`
|
||||
|
||||
## 范围
|
||||
|
||||
本次只在 `/editor/canvas` 图片画布编辑器内新增底部 `生成音乐` 入口,用于生成完整游戏音效或游戏背景音乐。该入口属于画板生成类工具,不新增平台玩法入口、不进入作品发布链路,也不修改现有视觉小说音频生成开关。
|
||||
|
||||
## 入口与交互
|
||||
|
||||
1. 底部 AI 画布工具栏新增 `生成音乐`。
|
||||
2. 点击 `生成音乐` 后先弹出页面级 fixed 选项框,选项为:
|
||||
- `生成游戏音效`
|
||||
- `生成游戏背景音乐`
|
||||
3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。
|
||||
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 生成按钮区,不写规则说明类文案。
|
||||
5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。
|
||||
|
||||
## 面板字段
|
||||
|
||||
### 生成游戏音效
|
||||
|
||||
- `sound`:用户输入的音效提示词。
|
||||
- `type`:合成选项,只支持 `one-shot` / `loop`;UI 显示为 `单次` / `循环`,不展示字段标题。
|
||||
- `tempo`:可空 BPM 数值;默认不填为 `null`,用户可通过拖拉条或数字输入设置,范围 `1-300`。
|
||||
- `type` 与 `tempo` 合并为一个 Lovart 式底部参数按钮;无 BPM 时按钮显示 `单次` / `循环`,有 BPM 时显示 `单次·120BPM` 这类组合值。
|
||||
- 提交时映射到 VectorEngine 游戏音效接口字段:`sound`、`type`、`tempo`。
|
||||
|
||||
### 生成游戏背景音乐
|
||||
|
||||
- `gpt_description_prompt`:用户输入的背景音乐提示词。
|
||||
- `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。
|
||||
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,不在 UI 中展示。
|
||||
- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。
|
||||
|
||||
## 画布数据
|
||||
|
||||
- 生成器继续保存到画布 layout JSON 的 `itemType: "generation-dialog"`,不新增表。
|
||||
- 新增生成器模式:
|
||||
- `audio-sound-effect`
|
||||
- `audio-background-music`
|
||||
- 新增结果图层媒体类型 `mediaType="audio"`。
|
||||
- 新增素材类型:
|
||||
- `assetKind="sound-effect"`
|
||||
- `assetKind="background-music"`
|
||||
- 音频结果以小型音频卡加入画布,卡片内使用 `<audio controls>` 播放;音频结果当前只保存为画布资源,不进入图片素材库。
|
||||
- 元数据弹窗按音频显示 `音频信息` / `音频类型`,生成输入快照只展示用户面板字段。
|
||||
|
||||
## 前端提交契约
|
||||
|
||||
前端新增两个 BFF client:
|
||||
|
||||
```ts
|
||||
POST /api/editor/audios/sound-effects/generations
|
||||
{
|
||||
sound: string,
|
||||
type: "one-shot" | "loop",
|
||||
tempo: number | null,
|
||||
priceMudPoints: 10
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
POST /api/editor/audios/background-music/generations
|
||||
{
|
||||
gptDescriptionPrompt: string,
|
||||
makeInstrumental: true,
|
||||
priceMudPoints: 5
|
||||
}
|
||||
```
|
||||
|
||||
统一响应:
|
||||
|
||||
```ts
|
||||
{
|
||||
ok: true,
|
||||
audioSrc: string,
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: "generated",
|
||||
prompt: string,
|
||||
actualPrompt?: string | null,
|
||||
model: string,
|
||||
provider: string,
|
||||
taskId: string,
|
||||
priceMudPoints: number,
|
||||
audioKind: "sound-effect" | "background-music"
|
||||
}
|
||||
```
|
||||
|
||||
## 后端实现
|
||||
|
||||
- 在 `shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。
|
||||
- 在 `platform-audio` 增加编辑器专用 body builder 和 submit 函数:
|
||||
- 背景音乐 body 使用 `mv`、`gpt_description_prompt`、`make_instrumental`。
|
||||
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
|
||||
- 音效 body 使用 `sound`、`type`、`tempo`,其中 `tempo` 是 `1-300` 的 BPM 或 `null`。
|
||||
- 在 `api-server` 增加编辑器音频 BFF:
|
||||
- `/api/editor/audios/sound-effects/generations`
|
||||
- `/api/editor/audios/background-music/generations`
|
||||
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。
|
||||
|
||||
## 验收
|
||||
|
||||
- 底部工具栏显示 `生成音乐`。
|
||||
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
|
||||
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `sound`;底部只有一个无标题音效参数按钮,内部合并 `type` 和 BPM。
|
||||
- 音效参数只提供 `单次(one-shot)`、`循环(loop)`,BPM 支持拖拉条和数字输入,范围 `1-300`,不填时提交 `tempo: null`。
|
||||
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,不展示 `make_instrumental`。
|
||||
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`。
|
||||
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放。
|
||||
- 刷新后 layout 能恢复音频生成器和音频图层。
|
||||
@@ -1484,7 +1484,7 @@ mod tests {
|
||||
"frameCount": 32,
|
||||
"durationSeconds": 4,
|
||||
"priceMudPoints": 40,
|
||||
"model": "seedance2.0"
|
||||
"model": "seedance2.0-fast"
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/// 图片画布编辑器生成类能力的泥点配置。
|
||||
///
|
||||
/// 中文注释:先用 api-server 静态配置收口价格事实源,避免继续把价格散落在
|
||||
/// 前端常量和具体 handler 内;后续若接后台配置,可只替换本模块读取来源。
|
||||
const EDITOR_IMAGE_GENERATION_MUD_POINTS: u32 = 12;
|
||||
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;
|
||||
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
|
||||
pub(crate) const EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND: u32 = 20;
|
||||
|
||||
pub(crate) fn editor_image_generation_mud_points(kind: Option<&str>) -> u32 {
|
||||
match kind.map(str::trim) {
|
||||
Some("spec") => EDITOR_SPEC_GENERATION_MUD_POINTS,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editor_video_generation_mud_points(resolution: &str, duration_seconds: u32) -> u32 {
|
||||
let per_second = if resolution == "720p" {
|
||||
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND
|
||||
} else {
|
||||
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND
|
||||
};
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
pub(crate) fn editor_character_animation_mud_points(
|
||||
resolution: &str,
|
||||
duration_seconds: u32,
|
||||
) -> u32 {
|
||||
let per_second = if resolution == "720p" {
|
||||
EDITOR_CHARACTER_ANIMATION_720P_MUD_POINTS_PER_SECOND
|
||||
} else {
|
||||
EDITOR_CHARACTER_ANIMATION_480P_MUD_POINTS_PER_SECOND
|
||||
};
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_character_animation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_character_animation_mud_points("720p", 6), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_video_generation_price_uses_configured_resolution_rates() {
|
||||
assert_eq!(editor_video_generation_mud_points("480p", 4), 40);
|
||||
assert_eq!(editor_video_generation_mud_points("720p", 5), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_generation_price_uses_configured_kind_rates() {
|
||||
assert_eq!(editor_image_generation_mud_points(None), 12);
|
||||
assert_eq!(editor_image_generation_mud_points(Some("spec")), 5);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ mod custom_world_asset_prompts;
|
||||
mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod editor_generation_config;
|
||||
mod editor_project;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
|
||||
@@ -16,9 +16,9 @@ use crate::{
|
||||
assets::get_asset_history,
|
||||
auth::require_bearer_auth,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, generate_editor_character_animation,
|
||||
get_character_animation_job, get_character_workflow_cache, import_character_animation_video,
|
||||
list_character_animation_templates,
|
||||
generate_character_animation, generate_editor_character_animation, generate_editor_video,
|
||||
get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
|
||||
save_character_workflow_cache,
|
||||
},
|
||||
@@ -46,10 +46,14 @@ use crate::{
|
||||
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
generate_editor_background_music, generate_editor_sound_effect,
|
||||
},
|
||||
};
|
||||
|
||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||
const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
const EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct PlayFlowDomainAdapter {
|
||||
@@ -461,6 +465,29 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
|
||||
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/videos/generations",
|
||||
post(generate_editor_video)
|
||||
.layer(DefaultBodyLimit::max(EDITOR_VIDEO_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/audios/sound-effects/generations",
|
||||
post(generate_editor_sound_effect).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/audios/background-music/generations",
|
||||
post(generate_editor_background_music).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
|
||||
@@ -3,7 +3,9 @@ use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use platform_image::{
|
||||
@@ -159,6 +161,94 @@ pub(crate) async fn create_openai_image_generation(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_image_generation_with_model(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[String],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"model": model,
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_generation_with_model(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_generation",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_nanobanana_generate_content(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
aspect_ratio: &str,
|
||||
image_size: &str,
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"model": model,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"imageSize": image_size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_nanobanana_generate_content(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"nanobanana_generate_content",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
|
||||
@@ -11,6 +11,7 @@ mod tasks;
|
||||
mod tests;
|
||||
mod types;
|
||||
|
||||
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
|
||||
pub use handlers::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
|
||||
|
||||
@@ -1,18 +1,247 @@
|
||||
use axum::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{State, rejection::JsonRejection},
|
||||
response::Response,
|
||||
};
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use shared_contracts::creation_audio;
|
||||
use shared_contracts::{assets, creation_audio};
|
||||
|
||||
use crate::{
|
||||
http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success,
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
|
||||
use super::{
|
||||
clock::{current_utc_iso_text, current_utc_micros},
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
errors::{map_platform_audio_error, parse_json_payload, vector_engine_bad_gateway},
|
||||
publish::wait_for_generated_audio_asset,
|
||||
settings::require_vector_engine_audio_settings,
|
||||
tasks::create_sound_effect_task_response,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
|
||||
};
|
||||
|
||||
const EDITOR_SOUND_EFFECT_POINTS_COST: u32 = 10;
|
||||
const EDITOR_BACKGROUND_MUSIC_POINTS_COST: u32 = 5;
|
||||
const EDITOR_AUDIO_WIDTH: u32 = 420;
|
||||
const EDITOR_AUDIO_HEIGHT: u32 = 120;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NormalizedEditorSoundEffectRequest {
|
||||
pub(super) sound: String,
|
||||
pub(super) sound_type: String,
|
||||
pub(super) tempo: Option<u16>,
|
||||
pub(super) price_mud_points: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NormalizedEditorBackgroundMusicRequest {
|
||||
pub(super) gpt_description_prompt: String,
|
||||
pub(super) make_instrumental: bool,
|
||||
pub(super) price_mud_points: u32,
|
||||
}
|
||||
|
||||
pub(super) fn normalize_editor_sound_effect_request(
|
||||
payload: assets::EditorSoundEffectGenerateRequest,
|
||||
) -> Result<NormalizedEditorSoundEffectRequest, AppError> {
|
||||
if payload.price_mud_points != EDITOR_SOUND_EFFECT_POINTS_COST {
|
||||
return Err(editor_audio_bad_request("音效生成泥点消耗不匹配"));
|
||||
}
|
||||
Ok(NormalizedEditorSoundEffectRequest {
|
||||
sound: platform_audio::normalize_limited_text(
|
||||
&payload.sound,
|
||||
"sound",
|
||||
platform_audio::VIDU_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?,
|
||||
sound_type: normalize_editor_sound_type(&payload.sound_type)?,
|
||||
tempo: normalize_editor_sound_tempo(payload.tempo)?,
|
||||
price_mud_points: payload.price_mud_points,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_editor_sound_type(value: &str) -> Result<String, AppError> {
|
||||
let sound_type = platform_audio::normalize_limited_text(value, "type", 80)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
if sound_type == "one-shot" || sound_type == "loop" {
|
||||
return Ok(sound_type);
|
||||
}
|
||||
Err(editor_audio_bad_request(
|
||||
"音效 type 只支持 one-shot 或 loop",
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_editor_sound_tempo(value: Option<u16>) -> Result<Option<u16>, AppError> {
|
||||
match value {
|
||||
Some(tempo) if (1..=300).contains(&tempo) => Ok(Some(tempo)),
|
||||
Some(_) => Err(editor_audio_bad_request("音效 tempo BPM 必须在 1-300 之间")),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_editor_background_music_request(
|
||||
payload: assets::EditorBackgroundMusicGenerateRequest,
|
||||
) -> Result<NormalizedEditorBackgroundMusicRequest, AppError> {
|
||||
if payload.price_mud_points != EDITOR_BACKGROUND_MUSIC_POINTS_COST {
|
||||
return Err(editor_audio_bad_request("背景音乐生成泥点消耗不匹配"));
|
||||
}
|
||||
Ok(NormalizedEditorBackgroundMusicRequest {
|
||||
gpt_description_prompt: platform_audio::normalize_limited_text(
|
||||
&payload.gpt_description_prompt,
|
||||
"gpt_description_prompt",
|
||||
platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?,
|
||||
make_instrumental: true,
|
||||
price_mud_points: payload.price_mud_points,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_editor_sound_effect(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let normalized = normalize_editor_sound_effect_request(payload)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let settings = require_vector_engine_audio_settings(&state)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let task = platform_audio::submit_editor_sound_effect_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
platform_audio::EditorSoundEffectTaskRequest {
|
||||
sound: normalized.sound.clone(),
|
||||
sound_type: normalized.sound_type.clone(),
|
||||
tempo: normalized.tempo,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::SoundEffect,
|
||||
build_editor_audio_target(&task.task_id, "sound_effect", "editor_sound_effect"),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
assets::EditorAudioGenerateResponse {
|
||||
ok: true,
|
||||
audio_src,
|
||||
width: EDITOR_AUDIO_WIDTH,
|
||||
height: EDITOR_AUDIO_HEIGHT,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: normalized.sound.clone(),
|
||||
actual_prompt: Some(normalized.sound),
|
||||
model: platform_audio::VIDU_AUDIO_MODEL.to_string(),
|
||||
provider: generated.provider,
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
audio_kind: "sound-effect".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_background_music(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let normalized = normalize_editor_background_music_request(payload)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let settings = require_vector_engine_audio_settings(&state)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let task = platform_audio::submit_editor_background_music_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
platform_audio::EditorBackgroundMusicTaskRequest {
|
||||
gpt_description_prompt: normalized.gpt_description_prompt.clone(),
|
||||
make_instrumental: normalized.make_instrumental,
|
||||
model: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
build_editor_audio_target(&task.task_id, "background_music", "editor_background_music"),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
assets::EditorAudioGenerateResponse {
|
||||
ok: true,
|
||||
audio_src,
|
||||
width: EDITOR_AUDIO_WIDTH,
|
||||
height: EDITOR_AUDIO_HEIGHT,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: normalized.gpt_description_prompt.clone(),
|
||||
actual_prompt: Some(normalized.gpt_description_prompt),
|
||||
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
|
||||
provider: generated.provider,
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
audio_kind: "background-music".to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_editor_audio_target(
|
||||
task_id: &str,
|
||||
slot: &'static str,
|
||||
asset_kind: &'static str,
|
||||
) -> AudioAssetBindingTarget {
|
||||
AudioAssetBindingTarget {
|
||||
entity_kind: "editor_audio".to_string(),
|
||||
entity_id: task_id.to_string(),
|
||||
slot: slot.to_string(),
|
||||
asset_kind: asset_kind.to_string(),
|
||||
profile_id: None,
|
||||
storage_prefix: LegacyAssetPrefix::CharacterDrafts,
|
||||
storage_scope: "editor_audio".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_audio_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": platform_audio::VECTOR_ENGINE_PROVIDER,
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use shared_contracts::creation_audio;
|
||||
use shared_contracts::{assets, creation_audio};
|
||||
|
||||
use super::{
|
||||
generation::{
|
||||
normalize_editor_background_music_request, normalize_editor_sound_effect_request,
|
||||
},
|
||||
publish::resolve_creation_audio_points_cost,
|
||||
targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target},
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot},
|
||||
@@ -77,3 +80,101 @@ fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effec
|
||||
.expect_err("wooden fish hit sound target should be disabled");
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_normalizes_sound_type_tempo_and_price() {
|
||||
let normalized =
|
||||
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
sound: " 金币掉落叮当声 ".to_string(),
|
||||
sound_type: " one-shot ".to_string(),
|
||||
tempo: Some(120),
|
||||
price_mud_points: 10,
|
||||
})
|
||||
.expect("editor sound effect request should normalize");
|
||||
|
||||
assert_eq!(normalized.sound, "金币掉落叮当声");
|
||||
assert_eq!(normalized.sound_type, "one-shot");
|
||||
assert_eq!(normalized.tempo, Some(120));
|
||||
assert_eq!(normalized.price_mud_points, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_accepts_null_tempo_and_rejects_invalid_type() {
|
||||
let normalized =
|
||||
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
sound: "按钮点击".to_string(),
|
||||
sound_type: "loop".to_string(),
|
||||
tempo: None,
|
||||
price_mud_points: 10,
|
||||
})
|
||||
.expect("null BPM should keep tempo unset");
|
||||
assert_eq!(normalized.sound_type, "loop");
|
||||
assert_eq!(normalized.tempo, None);
|
||||
|
||||
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
sound: "按钮点击".to_string(),
|
||||
sound_type: "ui".to_string(),
|
||||
tempo: Some(120),
|
||||
price_mud_points: 10,
|
||||
})
|
||||
.expect_err("unsupported sound type should fail");
|
||||
assert!(error.to_string().contains("one-shot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_rejects_bpm_outside_documented_range() {
|
||||
for tempo in [0, 301] {
|
||||
let error = normalize_editor_sound_effect_request(
|
||||
assets::EditorSoundEffectGenerateRequest {
|
||||
sound: "按钮点击".to_string(),
|
||||
sound_type: "one-shot".to_string(),
|
||||
tempo: Some(tempo),
|
||||
price_mud_points: 10,
|
||||
},
|
||||
)
|
||||
.expect_err("BPM outside 1-300 should fail");
|
||||
|
||||
assert!(error.to_string().contains("1-300"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_background_music_request_forces_instrumental_and_price() {
|
||||
let normalized =
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: " 森林冒险背景音乐 ".to_string(),
|
||||
make_instrumental: false,
|
||||
price_mud_points: 5,
|
||||
})
|
||||
.expect("editor background music request should normalize");
|
||||
|
||||
assert_eq!(normalized.gpt_description_prompt, "森林冒险背景音乐");
|
||||
assert!(normalized.make_instrumental);
|
||||
assert_eq!(normalized.price_mud_points, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_background_music_request_rejects_prompt_over_documented_limit() {
|
||||
let error =
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: "乐".repeat(201),
|
||||
make_instrumental: true,
|
||||
price_mud_points: 5,
|
||||
})
|
||||
.expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit");
|
||||
|
||||
assert!(error.to_string().contains("gpt_description_prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_audio_rejects_price_mismatch() {
|
||||
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
sound: "点击按钮".to_string(),
|
||||
sound_type: "one-shot".to_string(),
|
||||
tempo: Some(120),
|
||||
price_mud_points: 5,
|
||||
})
|
||||
.expect_err("wrong sound effect price should fail");
|
||||
|
||||
assert!(error.to_string().contains("泥点"));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::response::{
|
||||
};
|
||||
use crate::{
|
||||
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
|
||||
SoundEffectTaskRequest, VectorEngineAudioSettings, build_background_music_task_body,
|
||||
EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SoundEffectTaskRequest,
|
||||
VectorEngineAudioSettings, build_background_music_task_body,
|
||||
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
|
||||
build_sound_effect_task_body,
|
||||
};
|
||||
|
||||
@@ -83,6 +85,62 @@ pub async fn submit_sound_effect_task(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_editor_background_music_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
request: EditorBackgroundMusicTaskRequest,
|
||||
) -> Result<AudioTaskResponse, AudioError> {
|
||||
let body = build_editor_background_music_task_body(request)?;
|
||||
let response = post_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
AudioTaskKind::BackgroundMusic.submit_path(),
|
||||
body,
|
||||
"提交编辑器背景音乐任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = extract_string_by_path(&response, &["data"])
|
||||
.or_else(|| find_first_string_by_key(&response, "task_id"))
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| {
|
||||
AudioError::missing_audio("提交编辑器背景音乐任务失败:上游未返回任务 ID")
|
||||
})?;
|
||||
|
||||
Ok(AudioTaskResponse {
|
||||
kind: AudioTaskKind::BackgroundMusic,
|
||||
task_id,
|
||||
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
|
||||
status: "submitted".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_editor_sound_effect_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
request: EditorSoundEffectTaskRequest,
|
||||
) -> Result<AudioTaskResponse, AudioError> {
|
||||
let body = build_editor_sound_effect_task_body(request)?;
|
||||
let response = post_vector_engine_json(
|
||||
http_client,
|
||||
settings,
|
||||
AudioTaskKind::SoundEffect.submit_path(),
|
||||
body,
|
||||
"提交编辑器音效任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = find_first_string_by_key(&response, "task_id")
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| AudioError::missing_audio("提交编辑器音效任务失败:上游未返回任务 ID"))?;
|
||||
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
|
||||
|
||||
Ok(AudioTaskResponse {
|
||||
kind: AudioTaskKind::SoundEffect,
|
||||
task_id,
|
||||
provider: AudioTaskKind::SoundEffect.provider().to_string(),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_audio_task_payload(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &VectorEngineAudioSettings,
|
||||
@@ -143,12 +201,9 @@ async fn get_vector_engine_json(
|
||||
path: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
let endpoint = vector_engine_audio_endpoint_url(settings, path);
|
||||
let response = http_client
|
||||
.get(format!(
|
||||
"{}{}",
|
||||
settings.base_url.trim_end_matches('/'),
|
||||
path
|
||||
))
|
||||
.get(endpoint.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
@@ -156,7 +211,7 @@ async fn get_vector_engine_json(
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
|
||||
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
@@ -167,12 +222,9 @@ async fn post_vector_engine_json(
|
||||
body: Value,
|
||||
failure_context: &str,
|
||||
) -> Result<Value, AudioError> {
|
||||
let endpoint = vector_engine_audio_endpoint_url(settings, path);
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}{}",
|
||||
settings.base_url.trim_end_matches('/'),
|
||||
path
|
||||
))
|
||||
.post(endpoint.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
@@ -182,10 +234,33 @@ async fn post_vector_engine_json(
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_reqwest_error(failure_context, path, error))?;
|
||||
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
|
||||
parse_vector_engine_response(response, failure_context).await
|
||||
}
|
||||
|
||||
pub(crate) fn vector_engine_audio_endpoint_url(
|
||||
settings: &VectorEngineAudioSettings,
|
||||
path: &str,
|
||||
) -> String {
|
||||
let base_url = normalize_vector_engine_audio_base_url(settings.base_url.as_str());
|
||||
let path = path.trim();
|
||||
if path.starts_with('/') {
|
||||
format!("{base_url}{path}")
|
||||
} else {
|
||||
format!("{base_url}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_vector_engine_audio_base_url(base_url: &str) -> String {
|
||||
let normalized = base_url.trim().trim_end_matches('/');
|
||||
// 中文注释:Suno / Vidu 音频接口在 VectorEngine 根路径下,不能拼成 /v1/suno/*。
|
||||
normalized
|
||||
.strip_suffix("/v1")
|
||||
.unwrap_or(normalized)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn parse_vector_engine_response(
|
||||
response: reqwest::Response,
|
||||
failure_context: &str,
|
||||
@@ -253,3 +328,41 @@ fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Erro
|
||||
fn truncate_raw(raw_text: &str) -> String {
|
||||
raw_text.chars().take(800).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(base_url: &str) -> VectorEngineAudioSettings {
|
||||
VectorEngineAudioSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_endpoint_urls_strip_v1_for_root_level_suno_paths() {
|
||||
assert_eq!(
|
||||
vector_engine_audio_endpoint_url(
|
||||
&settings("https://api.vectorengine.cn"),
|
||||
"/suno/submit/music"
|
||||
),
|
||||
"https://api.vectorengine.cn/suno/submit/music"
|
||||
);
|
||||
assert_eq!(
|
||||
vector_engine_audio_endpoint_url(
|
||||
&settings("https://api.vectorengine.cn/v1"),
|
||||
"/suno/submit/music"
|
||||
),
|
||||
"https://api.vectorengine.cn/suno/submit/music"
|
||||
);
|
||||
assert_eq!(
|
||||
vector_engine_audio_endpoint_url(
|
||||
&settings(" https://api.vectorengine.cn/v1/ "),
|
||||
"suno/submit/music"
|
||||
),
|
||||
"https://api.vectorengine.cn/suno/submit/music"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ mod types;
|
||||
|
||||
pub use client::{
|
||||
build_vector_engine_audio_http_client, resolve_audio_task_download_urls,
|
||||
submit_background_music_task, submit_sound_effect_task,
|
||||
submit_background_music_task, submit_editor_background_music_task,
|
||||
submit_editor_sound_effect_task, submit_sound_effect_task,
|
||||
};
|
||||
pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type};
|
||||
pub use error::{AudioError, AudioStatusHint};
|
||||
@@ -16,7 +17,8 @@ pub use persist::{
|
||||
GeneratedAudioPersistInput, GeneratedAudioPersistTarget, prepare_generated_audio_put_request,
|
||||
};
|
||||
pub use request::{
|
||||
build_background_music_task_body, build_sound_effect_task_body, normalize_limited_text,
|
||||
build_background_music_task_body, build_editor_background_music_task_body,
|
||||
build_editor_sound_effect_task_body, build_sound_effect_task_body, normalize_limited_text,
|
||||
normalize_limited_text_allow_empty, normalize_optional_text,
|
||||
};
|
||||
pub use response::{
|
||||
@@ -24,9 +26,10 @@ pub use response::{
|
||||
};
|
||||
pub use types::{
|
||||
AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES,
|
||||
SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS,
|
||||
SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER,
|
||||
VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
VectorEngineAudioSettings,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, EditorBackgroundMusicTaskRequest,
|
||||
EditorSoundEffectTaskRequest, MAX_GENERATED_AUDIO_BYTES, SUNO_DEFAULT_MODEL,
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS,
|
||||
SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER,
|
||||
VECTOR_ENGINE_SUNO_PROVIDER, VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL,
|
||||
VIDU_PROMPT_MAX_CHARS, VectorEngineAudioSettings,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
AudioError, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS,
|
||||
SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL,
|
||||
VIDU_PROMPT_MAX_CHARS,
|
||||
AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
|
||||
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS,
|
||||
SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest,
|
||||
VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
};
|
||||
|
||||
pub fn build_background_music_task_body(
|
||||
@@ -53,6 +54,43 @@ pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<V
|
||||
Ok(Value::Object(body))
|
||||
}
|
||||
|
||||
pub fn build_editor_background_music_task_body(
|
||||
request: EditorBackgroundMusicTaskRequest,
|
||||
) -> Result<Value, AudioError> {
|
||||
let prompt = normalize_limited_text(
|
||||
&request.gpt_description_prompt,
|
||||
"gpt_description_prompt",
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS,
|
||||
)?;
|
||||
let model = normalize_optional_text(request.model.as_deref())
|
||||
.unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
|
||||
Ok(json!({
|
||||
"mv": model,
|
||||
"gpt_description_prompt": prompt,
|
||||
"make_instrumental": request.make_instrumental,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn build_editor_sound_effect_task_body(
|
||||
request: EditorSoundEffectTaskRequest,
|
||||
) -> Result<Value, AudioError> {
|
||||
let sound = normalize_limited_text(&request.sound, "sound", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let sound_type = normalize_limited_text(&request.sound_type, "type", 80)?;
|
||||
if sound_type != "one-shot" && sound_type != "loop" {
|
||||
return Err(AudioError::invalid_request("type 只支持 one-shot 或 loop"));
|
||||
}
|
||||
if let Some(tempo) = request.tempo {
|
||||
if !(1..=300).contains(&tempo) {
|
||||
return Err(AudioError::invalid_request("tempo BPM 必须在 1-300 之间"));
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"sound": sound,
|
||||
"type": sound_type,
|
||||
"tempo": request.tempo,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn normalize_limited_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
|
||||
@@ -52,6 +52,20 @@ pub struct SoundEffectTaskRequest {
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditorBackgroundMusicTaskRequest {
|
||||
pub gpt_description_prompt: String,
|
||||
pub make_instrumental: bool,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditorSoundEffectTaskRequest {
|
||||
pub sound: String,
|
||||
pub sound_type: String,
|
||||
pub tempo: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioTaskResponse {
|
||||
pub kind: AudioTaskKind,
|
||||
@@ -77,9 +91,10 @@ pub struct DownloadedAudio {
|
||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno";
|
||||
pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu";
|
||||
pub const SUNO_DEFAULT_MODEL: &str = "chirp-v4";
|
||||
pub const SUNO_DEFAULT_MODEL: &str = "chirp-v5";
|
||||
pub const VIDU_AUDIO_MODEL: &str = "audio1.0";
|
||||
pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000;
|
||||
pub const SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS: usize = 200;
|
||||
pub const SUNO_TITLE_MAX_CHARS: usize = 80;
|
||||
pub const SUNO_TAGS_MAX_CHARS: usize = 160;
|
||||
pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user