Image editor: Seedance2.0 refs and audio changes

Adds Seedance 2.0 reference-media constraints and forbids video data URLs; updates docs/decision-log and pitfalls to record the new rules. Introduces typed audio params (type: one-shot|loop, tempo: number|null), generator-specific placeholder styles/avoidance and UI changes for generation panels. Implements related front-end changes (new crop/ raster edit components, many image-editor models, tests and workflow hooks) and server-side contract/handler updates across api-server, shared-contracts, platform-audio, platform-image and vector_engine_audio_generation to support the new submission/asset flow and validations.
This commit is contained in:
2026-06-18 17:31:35 +08:00
parent 6f1ae6caef
commit 9c7b3ad16d
68 changed files with 3262 additions and 1294 deletions
@@ -16,6 +16,14 @@
---
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
- 背景:`/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 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。
@@ -2350,3 +2358,10 @@
- 影响范围:图片画布 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,6 +47,14 @@
- 验证:`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`
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
- 现象:画板生成视频选择 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
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
@@ -71,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` 无法访问。
@@ -12,13 +12,13 @@
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片右上角素材类型标签、查看信息按钮和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;查看信息按钮使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `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` 路径;其中生成规范类图片固定 `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`基于当前生成图的修改走 `/api/editor/images/edits``生成视频``/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 成功图。
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 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 不变。
@@ -65,7 +65,7 @@
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
- `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``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/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`
@@ -86,15 +86,16 @@
- 左下角小地图可展示当前图层分布和视口范围,开关按钮可隐藏 / 恢复小地图,背景色菜单可切换白色、浅灰、暖灰和冷蓝工作区底色。
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成图片点击后显示画布内 `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`
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
- 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
@@ -109,4 +110,4 @@
- 接入图片生成 / 修改计费、队列进度状态、OSS 落盘和更完整失败审计。
- 资产库接入:素材栏从用户资产、历史生成图或上传结果读取。
- 图层模型:引入稳定 layer id、z-index、锁定、隐藏和多选。
- 图像编辑:将占位工具替换为裁剪、抠图、局部修改、蒙版和导出等真实能力。
- 图像编辑:继续扩展更精细的局部修改、蒙版、智能抠图与导出能力。
@@ -33,7 +33,7 @@
- 生成角色:`你希望角色如何设计?`
- 生成 UI`你希望这个 UI 长什么样?`
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范图标素材多描述
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框
9. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
## 参数交互
@@ -45,7 +45,7 @@
```
- 面板内不显示 `画面比例``大小尺寸``模型` 等字段标题,只显示当前选择值。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 type 和 BPM 合并成一个无标题左下角选项框
- 模型选项框和生成按钮位于右下角。
- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。
- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。
@@ -54,7 +54,7 @@
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
- 比例选项卡片内展示对应比例的线框。
- 父级面板、底部选项框、弹出子面板字号保持一致。
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p``单次·120BPM`
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范``生成音乐`
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
@@ -67,6 +67,7 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点``生成40泥点`;不使用泥点图标替代文字。
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。
## 画布占位落点
@@ -78,12 +79,14 @@
- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
## 画布悬浮信息
- 待生成占位图只有聚焦或生成中才显示高亮边框、左上角生成器名称和右上角原始尺寸;失焦后保留灰色占位块本体,不显示这些辅助信息。
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
- 视频 / 角色 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
## 画布保存
@@ -103,6 +106,8 @@
生成UI设计图:12 泥点
生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
生成音效:10 泥点
生成背景音乐:5 泥点
```
## 生成视频模型与接口
@@ -119,13 +124,25 @@
- 角色动画生成固定使用 `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` 暴露,点击选项后保持打开,可连续修改多个字段。
- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。
@@ -133,5 +150,8 @@
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 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` 字段。
@@ -21,15 +21,14 @@
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标素材规范图片;其它图片点击无效。
- `新建图标素材规范` 复用生成规范表单,规格类型为 `图标素材规范`,生成成功后图层标记为 `icon-spec`
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
2. 第二模块为素材描述列表
- 个文本输入个素材描述
- 默认填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
- 可以继续添加新的素材描述框,最多 `100`
- 生成时过滤空文本,按面板从上到下顺序作为 prompt 的素材清单。
2. 第二模块为素材描述文本框
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
- 生成时按换行、逗号、顿号、分号、斜杠或竖线切分,过滤空文本后最多保留 `100` 个素材描述,并按文本顺序作为 prompt 的素材清单
## 面板外观
- 图标素材面板不使用内部纵向滑动列表素材描述项按横向卡片铺开,新增一项就让面板整体更宽,保持列表一眼可扫
- 图标素材面板不使用列表素材描述框,也不再按描述项横向扩宽;素材描述区改为与角色形象生成面板一致的单个文本输入框
- 图标素材规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。
- 规范卡的 `从画布中选择 / 新建图标素材规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。
@@ -42,6 +41,7 @@
- `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`
@@ -73,7 +73,8 @@
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
- `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。
- 默认 6 个素材描述会进入 prompt;新增描述最多到 100 个。
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
- 生成成功后画布出现按描述命名的多个透明图标素材图层,图层之间不重叠。
- 生成图标素材提交体包含 `priceMudPoints: 12`;若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
@@ -21,15 +21,17 @@
### 生成游戏音效
- `sound`:用户输入的音效提示词。
- `type`:合成选项,第一版以前端下拉 / 子面板保存字符串
- `tempo`合成选项,第一版以前端下拉 / 子面板保存字符串
- `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 游戏背景音乐接口字段:`gpt_description_prompt``make_instrumental: true`
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv``gpt_description_prompt``make_instrumental: true``mv` 后端固定使用默认 Suno 模型,不在 UI 中展示。
- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。
## 画布数据
@@ -52,8 +54,8 @@
POST /api/editor/audios/sound-effects/generations
{
sound: string,
type: string,
tempo: string,
type: "one-shot" | "loop",
tempo: number | null,
priceMudPoints: 10
}
```
@@ -90,8 +92,9 @@ POST /api/editor/audios/background-music/generations
-`shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。
-`platform-audio` 增加编辑器专用 body builder 和 submit 函数:
- 背景音乐 body 使用 `gpt_description_prompt``make_instrumental`
- 音效 body 使用 `sound``type``tempo`
- 背景音乐 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`
@@ -101,7 +104,8 @@ POST /api/editor/audios/background-music/generations
- 底部工具栏显示 `生成音乐`
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
- 点击 `生成游戏音效` 后出现音效面板,字段为 `sound``type``tempo`
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `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>` 播放。
@@ -569,8 +569,8 @@ pub async fn generate_editor_character_animation(
}
.await;
let (generated, frames) = result
.map_err(|error| character_animation_error_response(&request_context, error))?;
let (generated, frames) =
result.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
@@ -606,13 +606,10 @@ pub async fn generate_editor_video(
)
})?;
let normalized =
normalize_editor_video_request(payload).map_err(|error| {
editor_video_error_response(&request_context, error)
})?;
let settings = require_editor_video_settings(&state, normalized.model.as_str()).map_err(
|error| editor_video_error_response(&request_context, error),
)?;
let normalized = normalize_editor_video_request(payload)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let settings = require_editor_video_settings(&state, normalized.model.as_str())
.map_err(|error| editor_video_error_response(&request_context, error))?;
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let task_id = generate_ai_task_id(current_utc_micros());
@@ -1423,9 +1420,12 @@ async fn request_editor_character_animation_preview(
let video_url =
wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str())
.await?;
let preview_payload =
download_generated_video(http_client, video_url.as_str(), "下载画板角色动画视频失败。")
.await?;
let preview_payload = download_generated_video(
http_client,
video_url.as_str(),
"下载画板角色动画视频失败。",
)
.await?;
let preview_video_path = put_generated_preview_video(
state,
owner_user_id,
@@ -1476,23 +1476,18 @@ async fn create_editor_text_to_video_task(
settings: &EditorVideoSettings,
request: &NormalizedEditorVideoRequest,
) -> Result<String, AppError> {
let content = build_editor_video_ark_content(state, request)?;
let request_body = build_editor_video_ark_request_body(state, request)?;
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.ark.base_url))
.post(format!(
"{}/contents/generations/tasks",
settings.ark.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.ark.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": request.provider_model,
"content": content,
"resolution": request.resolution,
"ratio": request.aspect_ratio,
"duration": request.duration_seconds,
"generate_audio": false,
"watermark": false,
}))
.json(&request_body)
.send()
.await
.map_err(|error| {
@@ -1556,7 +1551,10 @@ async fn create_editor_ark_image_to_video_task(
source_frame_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.ark.base_url))
.post(format!(
"{}/contents/generations/tasks",
settings.ark.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.ark.api_key),
@@ -2300,7 +2298,12 @@ fn normalize_editor_character_animation_request(
}
let source_image_src = trim_optional_text(Some(payload.source_image_src.as_str()))
.ok_or_else(|| editor_character_animation_bad_request("sourceImageSrc 不能为空。"))?;
let prompt_text = payload.prompt_text.trim().chars().take(4000).collect::<String>();
let prompt_text = payload
.prompt_text
.trim()
.chars()
.take(4000)
.collect::<String>();
if prompt_text.is_empty() {
return Err(editor_character_animation_bad_request("动画描述不能为空。"));
}
@@ -2443,6 +2446,23 @@ fn normalize_editor_video_request(
})
}
fn build_editor_video_ark_request_body(
state: &AppState,
request: &NormalizedEditorVideoRequest,
) -> Result<Value, AppError> {
let content = build_editor_video_ark_content(state, request)?;
Ok(json!({
"model": request.provider_model,
"content": content,
"resolution": request.resolution,
"ratio": request.aspect_ratio,
"duration": request.duration_seconds,
// 中文注释:Seedance 2.0 文档里声音生成默认为开启;画板当前视频入口固定无声,必须显式关闭。
"generate_audio": false,
"watermark": false,
}))
}
fn build_editor_video_ark_content(
state: &AppState,
request: &NormalizedEditorVideoRequest,
@@ -2458,16 +2478,19 @@ fn build_editor_video_ark_content(
state,
request.reference_image_srcs.as_slice(),
"参考图片",
true,
)?;
let reference_video_urls = resolve_editor_video_reference_urls(
state,
request.reference_video_srcs.as_slice(),
"参考视频",
false,
)?;
let reference_audio_urls = resolve_editor_video_reference_urls(
state,
request.reference_audio_srcs.as_slice(),
"参考音频",
true,
)?;
content.extend(reference_image_urls.iter().map(|url| {
json!({
@@ -2497,10 +2520,13 @@ fn resolve_editor_video_reference_urls(
state: &AppState,
sources: &[String],
label: &str,
allow_data_url: bool,
) -> Result<Vec<String>, AppError> {
sources
.iter()
.map(|source| resolve_editor_video_reference_url(state, source.as_str(), label))
.map(|source| {
resolve_editor_video_reference_url(state, source.as_str(), label, allow_data_url)
})
.collect()
}
@@ -2508,9 +2534,18 @@ fn resolve_editor_video_reference_url(
state: &AppState,
source: &str,
label: &str,
allow_data_url: bool,
) -> Result<String, AppError> {
let value = source.trim();
if value.starts_with("data:") || value.starts_with("asset://") {
if value.starts_with("data:") {
if allow_data_url {
return Ok(value.to_string());
}
return Err(editor_video_bad_request(format!(
"{label}格式不支持,请使用官方支持的 URL、素材 ID 或画板资源路径。"
)));
}
if value.starts_with("asset://") {
return Ok(value.to_string());
}
if value.starts_with("http://") || value.starts_with("https://") {
@@ -2519,8 +2554,13 @@ fn resolve_editor_video_reference_url(
let object_key = value.trim_start_matches('/');
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
let format_hint = if allow_data_url {
"公网 URL、素材 ID、data URL 或画板资源路径"
} else {
"公网 URL、素材 ID 或画板资源路径"
};
return Err(editor_video_bad_request(format!(
"{label}必须是官方支持的公网 URL、素材 ID、data URL 或画板资源路径"
"{label}必须是官方支持的{format_hint}"
)));
}
let oss_client = state.oss_client().ok_or_else(|| {
@@ -2865,12 +2905,12 @@ fn resolve_editor_character_animation_frame_size(
_ => (1, 1),
};
if ratio_width >= ratio_height {
let height = ((long_edge as f32 * ratio_height as f32 / ratio_width as f32).round() as u32)
.max(1);
let height =
((long_edge as f32 * ratio_height as f32 / ratio_width as f32).round() as u32).max(1);
(long_edge, height)
} else {
let width = ((long_edge as f32 * ratio_width as f32 / ratio_height as f32).round() as u32)
.max(1);
let width =
((long_edge as f32 * ratio_width as f32 / ratio_height as f32).round() as u32).max(1);
(width, long_edge)
}
}
@@ -4691,11 +4731,7 @@ mod tests {
})
.expect_err("invalid frame/duration pair should fail");
assert!(
error
.body_text()
.contains("durationSeconds 必须与帧数组合")
);
assert!(error.body_text().contains("durationSeconds 必须与帧数组合"));
}
#[test]
@@ -4789,7 +4825,6 @@ mod tests {
assert_ne!(normalized.provider_model, CHARACTER_ANIMATION_MODEL);
}
#[test]
fn editor_video_normalizes_seedance_multimodal_references() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
@@ -4809,12 +4844,18 @@ mod tests {
})
.expect("seedance multimodal request should normalize");
assert_eq!(normalized.reference_image_srcs, vec!["data:image/png;base64,image"]);
assert_eq!(
normalized.reference_image_srcs,
vec!["data:image/png;base64,image"]
);
assert_eq!(
normalized.reference_video_srcs,
vec!["generated-character-drafts/editor/seedance-references/video/demo.mp4"]
);
assert_eq!(normalized.reference_audio_srcs, vec!["data:audio/mpeg;base64,audio"]);
assert_eq!(
normalized.reference_audio_srcs,
vec!["data:audio/mpeg;base64,audio"]
);
}
#[test]
@@ -4837,6 +4878,83 @@ mod tests {
assert!(error.body_text().contains("参考视频格式不支持"));
}
#[test]
fn editor_video_ark_body_sets_generate_audio_false_without_mode() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考素材生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 40,
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()],
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
})
.expect("seedance multimodal request should normalize");
let body =
build_editor_video_ark_request_body(&state, &request).expect("ark body should build");
assert_eq!(body["generate_audio"], json!(false));
assert!(body.get("mode").is_none());
assert_eq!(body["content"][1]["role"], json!("reference_image"));
assert_eq!(body["content"][2]["role"], json!("reference_video"));
assert_eq!(body["content"][3]["role"], json!("reference_audio"));
assert_eq!(
body["content"][2]["video_url"]["url"],
json!("https://assets.example.com/reference.mp4")
);
}
#[test]
fn editor_video_ark_content_signs_generated_object_keys() {
let state = AppState::new(AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
})
.expect("state should build with OSS");
let request = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考视频生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 40,
reference_image_srcs: Vec::new(),
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
],
reference_audio_srcs: Vec::new(),
})
.expect("seedance objectKey request should normalize");
let content = build_editor_video_ark_content(&state, &request)
.expect("ark content should sign object keys");
let signed_url = content[1]["video_url"]["url"]
.as_str()
.expect("video url should be string");
assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/"));
assert!(
signed_url
.contains("generated-character-drafts/editor/seedance-references/video/demo.mp4")
);
assert!(signed_url.contains("x-oss-signature="));
assert_ne!(
signed_url,
"generated-character-drafts/editor/seedance-references/video/demo.mp4"
);
}
#[test]
fn editor_video_rejects_audio_only_seedance_references() {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
@@ -4854,7 +4972,11 @@ mod tests {
})
.expect_err("audio-only references should fail");
assert!(error.body_text().contains("参考音频必须搭配参考图片或参考视频"));
assert!(
error
.body_text()
.contains("参考音频必须搭配参考图片或参考视频")
);
}
#[test]
@@ -174,6 +174,7 @@ pub struct EditorIconSpritesheetGenerationRequest {
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
price_mud_points: u32,
}
#[derive(Debug, Serialize)]
@@ -267,6 +268,7 @@ pub struct EditorIconSpritesheetGenerationResponse {
model: String,
provider: &'static str,
task_id: String,
price_mud_points: u32,
}
#[derive(Debug, Serialize)]
@@ -785,7 +787,9 @@ pub async fn generate_editor_image(
payload
.reference_image_srcs
.as_ref()
.is_some_and(|references| references.iter().any(|source| !source.trim().is_empty())),
.is_some_and(|references| {
references.iter().any(|source| !source.trim().is_empty())
}),
)
} else {
role_setting.clone()
@@ -1116,6 +1120,8 @@ pub async fn generate_editor_icon_spritesheet(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let expected_price_mud_points =
validate_editor_icon_spritesheet_price(payload.price_mud_points)?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str())
.map_err(|error| {
@@ -1209,6 +1215,7 @@ pub async fn generate_editor_icon_spritesheet(
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
},
))
}
@@ -1403,6 +1410,24 @@ fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>,
Ok(normalized)
}
fn editor_icon_spritesheet_bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": message.into(),
}))
}
fn validate_editor_icon_spritesheet_price(price_mud_points: u32) -> Result<u32, AppError> {
let expected_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"));
if price_mud_points != expected_price_mud_points {
return Err(editor_icon_spritesheet_bad_request(format!(
"priceMudPoints 与素材生成计费配置不一致,应为 {expected_price_mud_points}"
)));
}
Ok(expected_price_mud_points)
}
fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String {
format!(
"参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet\n\n{}",
@@ -1883,4 +1908,34 @@ mod tests {
.collect::<Vec<_>>();
assert!(normalize_icon_descriptions(too_many).is_err());
}
#[test]
fn editor_icon_spritesheet_request_price_uses_generation_config() {
assert_eq!(validate_editor_icon_spritesheet_price(12).unwrap(), 12);
let error = validate_editor_icon_spritesheet_price(5)
.expect_err("wrong icon spritesheet price should fail");
assert!(error.body_text().contains("priceMudPoints"));
let response = EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: vec![EditorIconSpritesheetIconResponse {
name: "返回按钮".to_string(),
image_src: "data:image/png;base64,back".to_string(),
width: 128,
height: 128,
}],
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine",
task_id: "task-icons".to_string(),
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
),
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
}
}
@@ -8,10 +8,10 @@ use crate::{
editor_project::{
create_editor_asset, create_editor_asset_folder, create_editor_project,
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
delete_editor_project, edit_editor_image, generate_editor_image,
generate_editor_icon_spritesheet, get_editor_asset_library, get_editor_project,
list_editor_projects, load_recent_editor_project, rename_editor_project,
save_editor_project_layout, update_editor_asset, update_editor_asset_folder,
delete_editor_project, edit_editor_image, generate_editor_icon_spritesheet,
generate_editor_image, get_editor_asset_library, get_editor_project, list_editor_projects,
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
update_editor_asset, update_editor_asset_folder,
},
state::AppState,
};
@@ -34,7 +34,7 @@ const EDITOR_AUDIO_HEIGHT: u32 = 120;
pub(super) struct NormalizedEditorSoundEffectRequest {
pub(super) sound: String,
pub(super) sound_type: String,
pub(super) tempo: String,
pub(super) tempo: Option<u16>,
pub(super) price_mud_points: u32,
}
@@ -58,14 +58,31 @@ pub(super) fn normalize_editor_sound_effect_request(
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?,
sound_type: platform_audio::normalize_limited_text(&payload.sound_type, "type", 80)
.map_err(map_platform_audio_error)?,
tempo: platform_audio::normalize_limited_text(&payload.tempo, "tempo", 80)
.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> {
@@ -76,7 +93,7 @@ pub(super) fn normalize_editor_background_music_request(
gpt_description_prompt: platform_audio::normalize_limited_text(
&payload.gpt_description_prompt,
"gpt_description_prompt",
platform_audio::SUNO_PROMPT_MAX_CHARS,
platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?,
make_instrumental: true,
@@ -104,7 +121,7 @@ pub async fn generate_editor_sound_effect(
platform_audio::EditorSoundEffectTaskRequest {
sound: normalized.sound.clone(),
sound_type: normalized.sound_type.clone(),
tempo: normalized.tempo.clone(),
tempo: normalized.tempo,
},
)
.await
@@ -163,6 +180,7 @@ pub async fn generate_editor_background_music(
platform_audio::EditorBackgroundMusicTaskRequest {
gpt_description_prompt: normalized.gpt_description_prompt.clone(),
make_instrumental: normalized.make_instrumental,
model: None,
},
)
.await
@@ -86,18 +86,58 @@ 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: " ui ".to_string(),
tempo: " fast ".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, "ui");
assert_eq!(normalized.tempo, "fast");
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 =
@@ -113,12 +153,25 @@ fn editor_background_music_request_forces_instrumental_and_price() {
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: "ui".to_string(),
tempo: "medium".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(120),
price_mud_points: 5,
})
.expect_err("wrong sound effect price should fail");
+67 -12
View File
@@ -201,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),
@@ -214,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
}
@@ -225,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),
@@ -240,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,
@@ -311,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"
);
}
}
+4 -3
View File
@@ -28,7 +28,8 @@ pub use types::{
AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, EditorBackgroundMusicTaskRequest,
EditorSoundEffectTaskRequest, 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,
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,
};
+16 -5
View File
@@ -2,8 +2,9 @@ use serde_json::{Map, Value, json};
use crate::{
AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS,
SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
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(
@@ -59,9 +60,12 @@ pub fn build_editor_background_music_task_body(
let prompt = normalize_limited_text(
&request.gpt_description_prompt,
"gpt_description_prompt",
SUNO_PROMPT_MAX_CHARS,
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,
}))
@@ -72,11 +76,18 @@ pub fn build_editor_sound_effect_task_body(
) -> 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)?;
let tempo = normalize_limited_text(&request.tempo, "tempo", 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": tempo,
"tempo": request.tempo,
}))
}
+4 -2
View File
@@ -56,13 +56,14 @@ pub struct SoundEffectTaskRequest {
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: String,
pub tempo: Option<u16>,
}
#[derive(Clone, Debug)]
@@ -90,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;
@@ -87,29 +87,90 @@ fn sound_effect_request_rejects_overlong_prompt() {
#[test]
fn editor_background_music_request_body_uses_gpt_description_prompt_and_instrumental() {
let body =
build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest {
gpt_description_prompt: " 轻快森林冒险背景音乐 ".to_string(),
make_instrumental: true,
})
.expect("editor background music body should be valid");
let body = build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest {
gpt_description_prompt: " 轻快森林冒险背景音乐 ".to_string(),
make_instrumental: true,
model: None,
})
.expect("editor background music body should be valid");
assert_eq!(body["mv"], SUNO_DEFAULT_MODEL);
assert_eq!(body["gpt_description_prompt"], "轻快森林冒险背景音乐");
assert_eq!(body["make_instrumental"], true);
assert!(body.get("prompt").is_none());
}
#[test]
fn editor_background_music_request_body_normalizes_explicit_model() {
let body = build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest {
gpt_description_prompt: " 轻快森林冒险背景音乐 ".to_string(),
make_instrumental: true,
model: Some(" chirp-v5-5 ".to_string()),
})
.expect("editor background music body should be valid");
assert_eq!(body["mv"], "chirp-v5-5");
assert_eq!(body["gpt_description_prompt"], "轻快森林冒险背景音乐");
assert_eq!(body["make_instrumental"], true);
}
#[test]
fn editor_background_music_request_body_rejects_overlong_gpt_description_prompt() {
let error = build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest {
gpt_description_prompt: "".repeat(201),
make_instrumental: true,
model: None,
})
.expect_err("Apifox limits gpt_description_prompt to 200 chars");
assert!(error.message().contains("gpt_description_prompt 超过"));
}
#[test]
fn editor_sound_effect_request_body_uses_sound_type_and_tempo() {
let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: " 金币掉落叮当声 ".to_string(),
sound_type: " ui ".to_string(),
tempo: " fast ".to_string(),
sound_type: " one-shot ".to_string(),
tempo: Some(120),
})
.expect("editor sound effect body should be valid");
assert_eq!(body["sound"], "金币掉落叮当声");
assert_eq!(body["type"], "ui");
assert_eq!(body["tempo"], "fast");
assert_eq!(body["type"], "one-shot");
assert_eq!(body["tempo"], 120);
assert!(body.get("prompt").is_none());
}
#[test]
fn editor_sound_effect_request_body_keeps_null_tempo() {
let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "loop".to_string(),
tempo: None,
})
.expect("unset BPM should serialize as null");
assert_eq!(body["type"], "loop");
assert_eq!(body["tempo"], serde_json::Value::Null);
}
#[test]
fn editor_sound_effect_request_body_rejects_invalid_type_and_tempo() {
let invalid_type = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "ambient".to_string(),
tempo: Some(120),
})
.expect_err("platform layer should reject unsupported sound type");
assert!(invalid_type.message().contains("one-shot"));
for tempo in [0, 301] {
let invalid_tempo = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(tempo),
})
.expect_err("platform layer should reject BPM outside 1-300");
assert!(invalid_tempo.message().contains("1-300"));
}
}
@@ -253,15 +253,28 @@ fn remove_generated_asset_sheet_green_screen_background(
let x = pixel_index % width;
let y = pixel_index / width;
if x == 0 || x == width.saturating_sub(1) || y == 0 || y == height.saturating_sub(1) {
if x == 0 || x == width.saturating_sub(1) || y == 0 || y == height.saturating_sub(1)
{
touches_border = true;
}
let neighbors = [
if x > 0 { Some(pixel_index - 1) } else { None },
if x + 1 < width { Some(pixel_index + 1) } else { None },
if y > 0 { Some(pixel_index - width) } else { None },
if y + 1 < height { Some(pixel_index + width) } else { None },
if x + 1 < width {
Some(pixel_index + 1)
} else {
None
},
if y > 0 {
Some(pixel_index - width)
} else {
None
},
if y + 1 < height {
Some(pixel_index + width)
} else {
None
},
];
for next in neighbors.into_iter().flatten() {
@@ -213,14 +213,9 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
x1: bounds.x1.saturating_add(pad_x).min(width),
y1: bounds.y1.saturating_add(pad_y).min(height),
};
let cropped = image::imageops::crop_imm(
&image,
crop.x0,
crop.y0,
crop.width(),
crop.height(),
)
.to_image();
let cropped =
image::imageops::crop_imm(&image, crop.x0, crop.y0, crop.width(), crop.height())
.to_image();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cropped)
.write_to(&mut cursor, ImageFormat::Png)
@@ -304,8 +299,7 @@ mod tests {
#[test]
fn slices_icon_spritesheet_by_connected_components_in_reading_order() {
let mut sheet: image::RgbaImage =
ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255]));
for y in 10..24 {
for x in 12..28 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
@@ -338,8 +332,7 @@ mod tests {
#[test]
fn rejects_when_connected_components_are_fewer_than_icon_names() {
let mut sheet: image::RgbaImage =
ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255]));
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255]));
for y in 12..24 {
for x in 12..24 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
@@ -7,11 +7,8 @@ use curl::{
use serde_json::Value;
use super::{
audit::build_failure_audit,
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
request::build_prompt_with_negative,
types::ReferenceImage,
audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
request::build_prompt_with_negative, types::ReferenceImage,
};
#[derive(Debug)]
@@ -242,9 +239,7 @@ fn send_multipart_edit_request_with_curl_blocking(
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let mut form = Form::new();
form.part("model")
.contents(model.as_bytes())
.add()?;
form.part("model").contents(model.as_bytes()).add()?;
form.part("prompt")
.contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes())
.add()?;
+108 -7
View File
@@ -384,13 +384,52 @@ pub struct EditorVideoGenerateResponse {
pub price_mud_points: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerateRequest {
pub reference_image_src: String,
pub icon_descriptions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
pub price_mud_points: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetIconPayload {
pub name: String,
pub image_src: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerateResponse {
pub spritesheet_image_src: String,
pub spritesheet_width: u32,
pub spritesheet_height: u32,
pub icon_image_srcs: Vec<EditorIconSpritesheetIconPayload>,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub price_mud_points: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerateRequest {
pub sound: String,
#[serde(rename = "type")]
pub sound_type: String,
pub tempo: String,
pub tempo: Option<u16>,
pub price_mud_points: u32,
}
@@ -1026,7 +1065,9 @@ mod tests {
sound: "off".to_string(),
price_mud_points: 100,
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
reference_video_srcs: vec!["data:video/mp4;base64,video".to_string()],
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
],
reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()],
})
.expect("request should serialize");
@@ -1037,7 +1078,7 @@ mod tests {
);
assert_eq!(
payload["referenceVideoSrcs"],
json!(["data:video/mp4;base64,video"])
json!(["generated-character-drafts/editor/seedance-references/video/demo.mp4"])
);
assert_eq!(
payload["referenceAudioSrcs"],
@@ -1072,20 +1113,80 @@ mod tests {
assert_eq!(payload["durationSeconds"], json!(5));
}
#[test]
fn editor_icon_spritesheet_request_and_response_include_price() {
let request_payload = serde_json::to_value(EditorIconSpritesheetGenerateRequest {
reference_image_src: "data:image/png;base64,spec".to_string(),
icon_descriptions: vec!["返回按钮".to_string(), "设置按钮".to_string()],
model: Some("gemini-3.1-flash-image-preview".to_string()),
aspect_ratio: Some("1:1".to_string()),
image_size: Some("1K".to_string()),
price_mud_points: 12,
})
.expect("request should serialize");
assert_eq!(
request_payload["referenceImageSrc"],
json!("data:image/png;base64,spec")
);
assert_eq!(request_payload["priceMudPoints"], json!(12));
let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: vec![EditorIconSpritesheetIconPayload {
name: "返回按钮".to_string(),
image_src: "data:image/png;base64,back".to_string(),
width: 128,
height: 128,
}],
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine".to_string(),
task_id: "task-icons".to_string(),
price_mud_points: 12,
})
.expect("response should serialize");
assert_eq!(response_payload["priceMudPoints"], json!(12));
assert_eq!(
response_payload["iconImageSrcs"][0]["name"],
json!("返回按钮")
);
}
#[test]
fn editor_audio_requests_and_response_use_canvas_audio_shape() {
let sound_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
sound: "金币掉落叮当声".to_string(),
sound_type: "ui".to_string(),
tempo: "fast".to_string(),
sound_type: "one-shot".to_string(),
tempo: Some(120),
price_mud_points: 10,
})
.expect("sound request should serialize");
assert_eq!(sound_payload["sound"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["type"], json!("ui"));
assert_eq!(sound_payload["tempo"], json!("fast"));
assert_eq!(sound_payload["type"], json!("one-shot"));
assert_eq!(sound_payload["tempo"], json!(120));
assert_eq!(sound_payload["priceMudPoints"], json!(10));
let unset_tempo_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
sound: "按钮确认短促音".to_string(),
sound_type: "loop".to_string(),
tempo: None,
price_mud_points: 10,
})
.expect("sound request with unset tempo should serialize");
assert_eq!(unset_tempo_payload["type"], json!("loop"));
assert_eq!(unset_tempo_payload["tempo"], json!(null));
let parsed_unset_tempo: EditorSoundEffectGenerateRequest =
serde_json::from_value(unset_tempo_payload)
.expect("sound request with null tempo should deserialize");
assert_eq!(parsed_unset_tempo.sound_type, "loop");
assert_eq!(parsed_unset_tempo.tempo, None);
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "森林冒险背景音乐".to_string(),
make_instrumental: true,
@@ -57,14 +57,15 @@ impl SpacetimeClient {
self.call_after_connect(
"get_editor_project_and_return",
move |connection, sender| {
connection
.procedures()
.get_editor_project_and_return_then(procedure_input, move |_, result| {
connection.procedures().get_editor_project_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
});
},
);
},
)
.await
@@ -79,15 +80,14 @@ impl SpacetimeClient {
self.call_after_connect(
"list_editor_projects_and_return",
move |connection, sender| {
connection.procedures().list_editor_projects_and_return_then(
procedure_input,
move |_, result| {
connection
.procedures()
.list_editor_projects_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_list_procedure_result);
send_once(&sender, mapped);
},
);
});
},
)
.await
@@ -102,15 +102,14 @@ impl SpacetimeClient {
self.call_after_connect(
"rename_editor_project_and_return",
move |connection, sender| {
connection.procedures().rename_editor_project_and_return_then(
procedure_input,
move |_, result| {
connection
.procedures()
.rename_editor_project_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
},
);
});
},
)
.await
@@ -125,15 +124,14 @@ impl SpacetimeClient {
self.call_after_connect(
"delete_editor_project_and_return",
move |connection, sender| {
connection.procedures().delete_editor_project_and_return_then(
procedure_input,
move |_, result| {
connection
.procedures()
.delete_editor_project_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_delete_procedure_result);
send_once(&sender, mapped);
},
);
});
},
)
.await
@@ -204,15 +202,12 @@ impl SpacetimeClient {
move |connection, sender| {
connection
.procedures()
.get_editor_asset_library_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_library_procedure_result);
send_once(&sender, mapped);
},
);
.get_editor_asset_library_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_library_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
@@ -302,14 +297,15 @@ impl SpacetimeClient {
self.call_after_connect(
"create_editor_asset_and_return",
move |connection, sender| {
connection
.procedures()
.create_editor_asset_and_return_then(procedure_input, move |_, result| {
connection.procedures().create_editor_asset_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_procedure_result);
send_once(&sender, mapped);
});
},
);
},
)
.await
@@ -324,14 +320,15 @@ impl SpacetimeClient {
self.call_after_connect(
"update_editor_asset_and_return",
move |connection, sender| {
connection
.procedures()
.update_editor_asset_and_return_then(procedure_input, move |_, result| {
connection.procedures().update_editor_asset_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_procedure_result);
send_once(&sender, mapped);
});
},
);
},
)
.await
@@ -346,14 +343,15 @@ impl SpacetimeClient {
self.call_after_connect(
"delete_editor_asset_and_return",
move |connection, sender| {
connection
.procedures()
.delete_editor_asset_and_return_then(procedure_input, move |_, result| {
connection.procedures().delete_editor_asset_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_asset_procedure_result);
send_once(&sender, mapped);
});
},
);
},
)
.await
+1 -3
View File
@@ -37,8 +37,7 @@ pub use mapper::{
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectResourceRecord,
ExternalGenerationJobClaimRecordInput,
EditorProjectResourceRecord, ExternalGenerationJobClaimRecordInput,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
@@ -48,7 +47,6 @@ pub use mapper::{
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
+14 -14
View File
@@ -42,15 +42,6 @@ pub use self::combat::{
BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord,
ResolveCombatActionRecord,
};
pub use self::editor_project::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput,
EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord,
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
};
pub use self::common::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
@@ -80,6 +71,15 @@ pub use self::common::{
VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput,
VisualNovelWorkCompileRecordInput,
};
pub use self::editor_project::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord,
EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
};
pub use self::external_generation::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
@@ -197,11 +197,11 @@ pub(crate) use self::custom_world::{
parse_rpg_agent_stage_record,
};
pub(crate) use self::editor_project::{
map_editor_asset_folder_library_procedure_result,
map_editor_asset_folder_procedure_result, map_editor_asset_library_procedure_result,
map_editor_asset_procedure_result, map_editor_project_delete_procedure_result,
map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result,
map_editor_project_required_procedure_result, map_editor_project_resource_procedure_result,
map_editor_asset_folder_library_procedure_result, map_editor_asset_folder_procedure_result,
map_editor_asset_library_procedure_result, map_editor_asset_procedure_result,
map_editor_project_delete_procedure_result, map_editor_project_list_procedure_result,
map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result,
map_editor_project_resource_procedure_result,
};
pub(crate) use self::external_generation::{
map_external_generation_job_claim_result, map_external_generation_job_procedure_result,
@@ -486,8 +486,10 @@ fn map_editor_project_snapshot(
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let layers: serde_json::Value =
serde_json::from_str(&snapshot.canvas.layers_json).map_err(|error| {
SpacetimeClientError::validation_failed(format!("图片画布图层布局 JSON 无法解析:{error}"))
})?;
SpacetimeClientError::validation_failed(format!(
"图片画布图层布局 JSON 无法解析:{error}"
))
})?;
let viewport = EditorCanvasViewportRecord {
x: snapshot.canvas.viewport.x,
y: snapshot.canvas.viewport.y,
@@ -601,13 +601,7 @@ fn create_editor_project(
created_at: now,
updated_at: now,
});
ensure_default_canvas(
ctx,
project_id.as_str(),
owner_user_id.as_str(),
None,
now,
)?;
ensure_default_canvas(ctx, project_id.as_str(), owner_user_id.as_str(), None, now)?;
build_project_snapshot(ctx, project_id.as_str())
}
@@ -786,10 +780,8 @@ fn create_editor_project_resource(
ctx: &ReducerContext,
input: EditorProjectResourceCreateInput,
) -> Result<EditorProjectResourceSnapshot, String> {
let resource_id = normalize_required(
&input.resource_id,
"editor_project_resource.resource_id",
)?;
let resource_id =
normalize_required(&input.resource_id, "editor_project_resource.resource_id")?;
let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
@@ -797,10 +789,8 @@ fn create_editor_project_resource(
)?;
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
let image_src = normalize_required(&input.image_src, "editor_project_resource.image_src")?;
let source_type = normalize_required(
&input.source_type,
"editor_project_resource.source_type",
)?;
let source_type =
normalize_required(&input.source_type, "editor_project_resource.source_type")?;
if !EDITOR_PROJECT_SOURCE_TYPES.contains(&source_type.as_str()) {
return Err("画布资源来源类型只支持 uploaded、generated 或 mock_generated".to_string());
}
@@ -863,10 +853,8 @@ fn create_editor_asset_folder(
input: EditorAssetFolderCreateInput,
) -> Result<EditorAssetFolderSnapshot, String> {
let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
"editor_asset_folder.owner_user_id",
)?;
let owner_user_id =
normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?;
if ctx
.db
.editor_asset_folder()
@@ -901,10 +889,8 @@ fn update_editor_asset_folder(
input: EditorAssetFolderUpdateInput,
) -> Result<EditorAssetFolderSnapshot, String> {
let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
"editor_asset_folder.owner_user_id",
)?;
let owner_user_id =
normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?;
let folder = require_owned_asset_folder(ctx, folder_id.as_str(), owner_user_id.as_str())?;
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db.editor_asset_folder().folder_id().delete(&folder_id);
@@ -934,10 +920,8 @@ fn delete_editor_asset_folder(
input: EditorAssetFolderDeleteInput,
) -> Result<EditorAssetLibrarySnapshot, String> {
let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
"editor_asset_folder.owner_user_id",
)?;
let owner_user_id =
normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?;
let folder = require_owned_asset_folder(ctx, folder_id.as_str(), owner_user_id.as_str())?;
if folder.system_default {
return Err("默认素材文件夹不能删除".to_string());

Some files were not shown because too many files have changed in this diff Show More