Server-side billing for editor generation

Move editor generation pricing to backend: remove priceMudPoints from public request schemas and docs, require backend to compute model prices and perform pre-charge via execute_billable_asset_operation_with_cost. Update character animation and video handlers to use authenticated owner IDs and wrap generation requests in billing; remove client-side price validation. Adjust OpenAPI, project-memory docs, pricing management docs, and related tests to reflect that front-end only displays prices and must not submit priceMudPoints. Also document new user registration reward and wallet refund outbox behavior. Code and test changes applied across api-server handlers, shared contracts, and image-editor client/tests.
This commit is contained in:
2026-06-22 23:11:23 +08:00
parent 6caf349992
commit 7830d201c4
33 changed files with 556 additions and 531 deletions
@@ -2773,8 +2773,7 @@
"type": "object",
"required": [
"referenceImageSrc",
"iconDescriptions",
"priceMudPoints"
"iconDescriptions"
],
"properties": {
"referenceImageSrc": {
@@ -2813,10 +2812,6 @@
],
"default": "1K"
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
},
"projectId": {
"type": [
"string",
@@ -2840,8 +2835,7 @@
"required": [
"sourceImageSrc",
"aspectRatio",
"imageSize",
"priceMudPoints"
"imageSize"
],
"properties": {
"sourceImageSrc": {
@@ -2861,10 +2855,6 @@
],
"description": "框选数量少时使用 1K,超过阈值时使用 2K。"
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
},
"projectId": {
"type": [
"string",
@@ -3021,7 +3011,6 @@
"ratio",
"frameCount",
"durationSeconds",
"priceMudPoints",
"model"
],
"properties": {
@@ -3080,10 +3069,6 @@
6
]
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
},
"model": {
"type": "string",
"const": "seedance2.0-fast"
@@ -3190,8 +3175,7 @@
"durationSeconds",
"resolution",
"mode",
"sound",
"priceMudPoints"
"sound"
],
"properties": {
"prompt": {
@@ -3236,10 +3220,6 @@
"type": "string",
"const": "off"
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
},
"referenceImageSrcs": {
"type": "array",
"items": {
@@ -3349,8 +3329,7 @@
"type": "object",
"required": [
"prompt",
"duration",
"priceMudPoints"
"duration"
],
"properties": {
"prompt": {
@@ -3368,10 +3347,6 @@
"type": "integer",
"minimum": 2,
"maximum": 10
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
@@ -3380,8 +3355,7 @@
"type": "object",
"required": [
"gptDescriptionPrompt",
"makeInstrumental",
"priceMudPoints"
"makeInstrumental"
],
"properties": {
"gptDescriptionPrompt": {
@@ -3391,10 +3365,6 @@
"makeInstrumental": {
"type": "boolean",
"const": true
},
"priceMudPoints": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
@@ -18,12 +18,20 @@
## 2026-06-22 编辑器生成模型默认定价调整
- 背景:图片画布生成按钮、后端 `priceMudPoints` 校验和后台定价页需要统一使用新的模型默认泥点。
- 背景:图片画布生成按钮、后端模型定价扣费和后台定价页需要统一使用新的模型默认泥点。
- 决策:`audio1.0` 默认按次 `5` 泥点,`chirp-v5` 默认按次 `12` 泥点,`gpt-image-2` 默认 `1K=3``2K=5` 泥点;运行态仍允许后台 override 覆盖,前端兜底必须与后端默认 JSON 保持一致。
- 影响范围:`editor-generation-pricing.default.json``ImageCanvasGenerationModel.ts`、后台定价页 fixture、后端价格校验和编辑器定价文档。
- 影响范围:`editor-generation-pricing.default.json``ImageCanvasGenerationModel.ts`、后台定价页 fixture、后端价格计算和编辑器定价文档。
- 验证方式:运行 `editor_generation_config`、公开定价路由、图标素材价格校验、图片画布定价模型和后台定价页相关测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 2026-06-22 编辑器生成扣费与新用户赠送收口
- 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。
- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。新用户注册赠送固定为 `100` 泥点。
- 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。
- 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-22 AGENTS.md 收敛为入口导航
- 背景:`AGENTS.md` 同时承载项目记忆、RAG、Issue、UI、Git、后端、SpacetimeDB 和文档图谱等细则,入口过重,复杂任务启动成本高。
@@ -74,8 +82,8 @@
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动作、图标素材、音效和背景音乐也使用同一函数。后端默认配置独立放在 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,运行时 override 默认写入 `.app/editor-generation-pricing.override.json`,可用 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写路径;后台“模型定价”通过 `/admin/api/editor-generation-pricing` 读取和保存完整 `models` 配置,主站通过 `/api/editor/generation-pricing` 动态下发。后端提交校验 / 扣费`AppState` 当前运行时配置为准,前端内置定价只作为接口失败兜底。模型定价不再按图片 / 规范、视频 / 动作用途拆分,只按模型区分:图片模型按尺寸单次计价,`gemini-3.1-flash-image-preview` 必须配置 `0.5K / 1K / 2K``gpt-image-2` 必须配置 `1K / 2K`,规范固定读取 `gpt-image-2``2K`;视频和角色动作共用视频模型分辨率每秒价格,角色动作仍固定 `seedance2.0-fast`。后台管理页必须显示定价单位“按次 / 按秒”。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端请求和后端扣费各自写固定数值,后续调整模型价格会出现展示价和扣费价不一致。
- 决策:所有画板生成按钮展示价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算,但生成请求不提交 `priceMudPoints`后端默认配置独立放在 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,运行时 override 默认写入 `.app/editor-generation-pricing.override.json`,可用 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写路径;后台“模型定价”通过 `/admin/api/editor-generation-pricing` 读取和保存完整 `models` 配置,主站通过 `/api/editor/generation-pricing` 动态下发。后端扣费以 `AppState` 当前运行时配置为准,前端内置定价只作为接口失败兜底展示。模型定价不再按图片 / 规范、视频 / 动作用途拆分,只按模型区分:图片模型按尺寸单次计价,`gemini-3.1-flash-image-preview` 必须配置 `0.5K / 1K / 2K``gpt-image-2` 必须配置 `1K / 2K`,规范固定读取 `gpt-image-2``2K`;视频和角色动作共用视频模型分辨率每秒价格,角色动作仍固定 `seedance2.0-fast`。后台管理页必须显示定价单位“按次 / 按秒”。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。
- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config`、后台管理端和 Lovart 生成类面板文档。
- 验证方式:运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_generation_config::tests editor_generation_pricing_route -- --nocapture``npx vitest run src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose``npm run admin-web:typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】模型定价配置管理方案-2026-06-22.md`
@@ -2512,7 +2520,7 @@
## 2026-06-15 图片画布角色图层新增动画生成入口
- 背景:图片画布已有角色形象图层标记 `assetKind="character"`,需要只对角色图片开放动画生成,不让普通素材误触发角色动画链路。
- 决策:角色动画入口只由画布图层 `assetKind="character"` 控制,在图片上方浮动工具条和右键菜单显示 `生成动画`;非角色图层不展示入口。点击后打开独立 `角色动画生成面板`,桌面端锚定到图片右侧,移动端按底部面板承接。前端固定提交 `seedance2.0-fast`、分辨率 / 比例 / 帧数 / 时长 / 价格字段;后端经 `/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`
@@ -2598,7 +2606,7 @@
## 2026-06-19 编辑器游戏音效默认改用 Vidu 文生音频
- 背景:VectorEngine Apifox `创建文生音频任务` 文档明确 Vidu `/ent/v2/text2audio` 请求体使用 `model: "audio1.0"``prompt``duration` 和可选 `seed`;编辑器此前把游戏音效提交到 Suno `task: "sound"`,与当前游戏音效默认模型要求不一致。
- 决策:`/editor/canvas``生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt``model: "audio1.0"``duration``priceMudPoints`,面板只显示 `Vidu``2-10` 秒时长选项,默认 `5` 秒;不再展示 `type``tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交到 VectorEngine 时对内 `prompt` 同步映射为上游 body 的 `prompt``sound`,兼容 Apifox 文档和线上网关实际 `missing field sound` 校验;提交和轮询改走 Vidu `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music``/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。
- 决策:`/editor/canvas``生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt``model: "audio1.0"``duration`,面板只显示 `Vidu``2-10` 秒时长选项,默认 `5` 秒;不再展示 `type``tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`,并按模型定价计算扣费;提交到 VectorEngine 时对内 `prompt` 同步映射为上游 body 的 `prompt``sound`,兼容 Apifox 文档和线上网关实际 `missing field sound` 校验;提交和轮询改走 Vidu `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music``/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。
- 影响范围:`server-rs/crates/platform-audio``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``server-rs/crates/shared-contracts/src/assets.rs``src/services/image-editor/editorProjectClient.ts``src/components/image-editor/ImageCanvasGeneration*`
- 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_sound_effect``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_audio_requests_and_response_use_canvas_audio_shape``npx vitest run src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationModel.test.ts --reporter=dot`
@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
- 原因:前端展示价和后端价格计算只证明价格能被展示 / 解析;如果 handler 没有包进 `execute_billable_asset_operation_with_cost`,或异步音频发布目标没有携带本次模型价格,外部 provider 仍会被调用但不会真实扣费。
- 处理:新增或改造编辑器外部生成入口时,确认前端请求不携带 `priceMudPoints`,后端按运行时模型定价重新计算价格,并用该价格进入资产扣费 wrapper。音频提交 / 发布分离时,把后端计算出的价格写入 `AudioAssetBindingTarget.billing_points_cost`
- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;音频测试覆盖 `resolve_creation_audio_points_cost` 优先读取 editor target 的 `billing_points_cost`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs``server-rs/crates/api-server/src/vector_engine_audio_generation/``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`
## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败
- 现象:`npm run dev` 启动 SpacetimeDB 时可能先打印 `static max level is off``Skipping tokio metrics`,或 SpacetimeDB CLI 提示存在新版本 / 当前版本较旧,看起来像启动异常。
@@ -196,6 +196,13 @@ npm run check:server-rs-ddd
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED``GENARRATIVE_WALLET_REFUND_OUTBOX_DIR``GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE``GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS``GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`
7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。
## 用户钱包与编辑器生成扣费契约
1. 新用户账号完成注册并成功同步正式认证表后,注册赠送固定为 `100` 泥点,流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。
2. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
## 外部服务与资产
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
@@ -2,7 +2,7 @@
## 背景
图片画布的生成图片、生成规范、生成角色、生成素材、生成 UI、生成视频、角色动作、音效和背景音乐都需要在生成按钮展示泥点消耗,并在后端提交时校验 `priceMudPoints`。定价不能散落在前端组件和具体 handler 中,也不能按“图片 / 规范”“视频 / 动作”等用途拆出不同价格事实源。
图片画布的生成图片、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、图片修改、生成视频、角色动作、UI 设计图提取素材、音效和背景音乐都需要在按钮展示泥点消耗,但生成请求不携带 `priceMudPoints`实际扣费金额由后端按运行时模型定价配置统一计算。定价不能散落在前端组件和具体 handler 中,也不能按“图片 / 规范”“视频 / 动作”等用途拆出不同价格事实源。
## 配置来源
@@ -55,7 +55,7 @@
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 override 文件。
后端 `AppState` 启动时加载默认配置和 override。提交视频、角色动作、图标素材、音效和背景音乐时继续以运行时配置重新计算价格,前端传入的 `priceMudPoints` 只作为一致性校验值。普通图片生成和宣发素材扣费也读取同一运行时模型配置
后端 `AppState` 启动时加载默认配置和 override。所有会调用外部生成 provider 的编辑器生成请求都必须由后端以运行时配置重新计算价格,前端请求不提交价格字段;计算完成后统一进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`
## 管理端
@@ -74,12 +74,12 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点``生成40泥点`;不使用泥点图标替代文字。
- 画板内所有会提交外部生成任务的生成按钮,价格都必须从模型定价配置函数推导,不允许在按钮文案或提交 payload 中散落固定泥点数字;修改 `ImageCanvasGenerationModel.ts``api-server/src/editor_generation_config.rs` 的同名定价配置后,按钮展示、前端提交 `priceMudPoints` 和后端校验 / 扣费应同步变化。
- 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。
- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost``/api/editor/images/generations``/api/editor/images/edits``/api/editor/icon-spritesheets/generations``/api/editor/ui-designs/assets/extractions``/api/editor/videos/generations``/api/editor/character-animations/generations``/api/editor/audios/sound-effects/generations``/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2``2K` 定价。
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成
- 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验
- `提取素材` 点击后进入 UI 素材提取态:右侧框选工具对齐底部工具栏按钮风格,素材下方显示与生成新素材一致宽度的提取面板。提取面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格 `1:1·1K/2K``提取 · N泥点` 按钮;前后端提交 `aspectRatio / imageSize / priceMudPoints`,后端按 `editor_generation_config` 校验,不能写死。
- 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费
- `提取素材` 点击后进入 UI 素材提取态:右侧框选工具对齐底部工具栏按钮风格,素材下方显示与生成新素材一致宽度的提取面板。提取面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格 `1:1·1K/2K``提取 · N泥点` 按钮;前端提交 `aspectRatio / imageSize` 等生成参数,后端按 `editor_generation_config` 计算扣费,不能写死
- 画板 UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`;历史输入或旧布局中的 `nano-banana` 必须先归一为 `nanobanana2` 对应的真实模型 ID 后再提交和计费。定价表仍以真实模型 ID `gemini-3.1-flash-image-preview``gpt-image-2` 为准,别名不能作为新增正式模型绕过定价表。
## 画布占位落点
@@ -132,7 +132,7 @@
- 音效:`audio1.0` 按次定价。
- 背景音乐:`chirp-v5` 按次定价。
本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、前端提交计算、后端校验配置和对应测试,再出现在任一模型选择框中。
本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、后端扣费计算配置和对应测试,再出现在任一模型选择框中。
## 生成视频模型与接口
@@ -182,7 +182,7 @@
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
+37 -41
View File
@@ -1472,7 +1472,10 @@ mod tests {
#[tokio::test]
async fn editor_character_animation_accepts_character_image_body_above_default_limit() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body");
let app = build_router(state);
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
let request_body = serde_json::json!({
"sourceLayerId": "layer-character-large",
@@ -1484,7 +1487,6 @@ mod tests {
"ratio": "same",
"frameCount": 32,
"durationSeconds": 4,
"priceMudPoints": 40,
"model": "seedance2.0-fast"
})
.to_string();
@@ -1495,6 +1497,7 @@ mod tests {
Request::builder()
.method("POST")
.uri("/api/editor/character-animations/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
@@ -1517,6 +1520,38 @@ mod tests {
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn editor_character_animation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sourceLayerId": "layer-character",
"sourceImageSrc": "data:image/png;base64,AAAA",
"sourceWidth": 1024,
"sourceHeight": 1024,
"promptText": "待机呼吸循环。",
"resolution": "480p",
"ratio": "same",
"frameCount": 32,
"durationSeconds": 4,
"model": "seedance2.0-fast"
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/character-animations/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_ui_design_asset_extraction_accepts_image_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -1528,7 +1563,6 @@ mod tests {
"sourceImageSrc": source_image_src,
"aspectRatio": "1:1",
"imageSize": "1K",
"priceMudPoints": 3,
})
.to_string();
assert!(request_body.len() > 2 * 1024 * 1024);
@@ -1563,44 +1597,6 @@ mod tests {
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn editor_ui_design_asset_extraction_rejects_wrong_price_before_image_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138189", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_price");
let app = build_router(state);
let request_body = serde_json::json!({
"sourceImageSrc": "data:image/png;base64,AAAA",
"aspectRatio": "1:1",
"imageSize": "2K",
"priceMudPoints": 3,
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/ui-designs/assets/extractions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(body_text.contains("priceMudPoints"));
}
#[tokio::test]
async fn editor_image_generation_accepts_reference_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -1,4 +1,4 @@
use std::{
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
@@ -49,6 +49,8 @@ use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
custom_world_asset_prompts::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
@@ -520,12 +522,13 @@ pub async fn generate_character_animation(
pub async fn generate_editor_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_character_animation_for_owner(
state,
request_context,
"editor-character-animation".to_string(),
authenticated.claims().user_id().to_string(),
payload,
)
.await
@@ -565,39 +568,47 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let task_id = generate_ai_task_id(current_utc_micros());
let source_data_url = resolve_media_source_as_data_url(
&state,
&http_client,
normalized.source_image_src.as_str(),
"sourceImageSrc",
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let result = async {
let source_data_url = resolve_media_source_as_data_url(
&state,
&http_client,
normalized.source_image_src.as_str(),
"sourceImageSrc",
)
.await?;
let generated = request_editor_character_animation_preview(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
normalized.source_layer_id.as_str(),
task_id.as_str(),
normalized.prompt.as_str(),
source_data_url.as_str(),
)
.await?;
let frames = extract_and_persist_editor_character_animation_frames(
&state,
owner_user_id.as_str(),
normalized.source_layer_id.as_str(),
task_id.as_str(),
generated.preview_video_path.as_str(),
&normalized,
&extraction_settings,
)
.await?;
let result = execute_billable_asset_operation_with_cost(
&state,
owner_user_id.as_str(),
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
task_id.as_str(),
u64::from(normalized.price_mud_points),
async {
let generated = request_editor_character_animation_preview(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
normalized.source_layer_id.as_str(),
task_id.as_str(),
normalized.prompt.as_str(),
source_data_url.as_str(),
)
.await?;
let frames = extract_and_persist_editor_character_animation_frames(
&state,
owner_user_id.as_str(),
normalized.source_layer_id.as_str(),
task_id.as_str(),
generated.preview_video_path.as_str(),
&normalized,
&extraction_settings,
)
.await?;
Ok::<_, AppError>((generated, frames))
}
Ok::<_, AppError>((generated, frames))
},
)
.await;
let (generated, frames) =
@@ -625,10 +636,16 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
pub async fn generate_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_video_for_owner(state, request_context, "editor-video".to_string(), payload)
.await
generate_editor_video_for_owner(
state,
request_context,
authenticated.claims().user_id().to_string(),
payload,
)
.await
}
pub(crate) async fn generate_editor_video_for_owner(
@@ -664,13 +681,20 @@ pub(crate) async fn generate_editor_video_for_owner(
.map_err(|error| editor_video_error_response(&request_context, error))?;
let task_id = generate_ai_task_id(current_utc_micros());
let generated = request_editor_video_preview(
let generated = execute_billable_asset_operation_with_cost(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
EDITOR_VIDEO_ASSET_KIND,
task_id.as_str(),
&normalized,
u64::from(normalized.price_mud_points),
request_editor_video_preview(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
task_id.as_str(),
&normalized,
),
)
.await
.map_err(|error| editor_video_error_response(&request_context, error))?;
@@ -2439,11 +2463,6 @@ fn normalize_editor_character_animation_request_with_pricing(
resolution,
duration_seconds,
);
if payload.price_mud_points != expected_price {
return Err(editor_character_animation_bad_request(format!(
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}"
)));
}
if payload.model.trim() != EDITOR_CHARACTER_ANIMATION_MODEL {
return Err(editor_character_animation_bad_request(
"model 必须固定为 seedance2.0-fast。",
@@ -2550,11 +2569,6 @@ fn normalize_editor_video_request_with_pricing(
));
}
let expected_price = pricing.video_model_mud_points(Some(model), resolution, duration_seconds);
if payload.price_mud_points != expected_price {
return Err(editor_video_bad_request(format!(
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}"
)));
}
let references = normalize_editor_video_references(
model,
payload.reference_image_srcs,
@@ -4894,7 +4908,6 @@ mod tests {
ratio: "same".to_string(),
frame_count: 48,
duration_seconds: 6,
price_mud_points: 120,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
})
.expect("editor request should normalize");
@@ -4929,7 +4942,6 @@ mod tests {
ratio: "same".to_string(),
frame_count: 32,
duration_seconds: 4,
price_mud_points: 40,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
})
.expect("editor request should normalize");
@@ -4953,7 +4965,6 @@ mod tests {
ratio: "1:1".to_string(),
frame_count: 48,
duration_seconds: 4,
price_mud_points: 40,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
})
.expect_err("invalid frame/duration pair should fail");
@@ -4991,6 +5002,31 @@ mod tests {
);
}
#[test]
fn editor_character_animation_and_video_generation_are_billable() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"execute_billable_asset_operation_with_cost",
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"normalized.price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_video_for_owner",
"pub async fn get_character_animation_job",
&[
"execute_billable_asset_operation_with_cost",
"EDITOR_VIDEO_ASSET_KIND",
"normalized.price_mud_points",
],
);
}
#[test]
fn editor_character_animation_samples_last_frame_before_decode_tail() {
let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false);
@@ -5082,7 +5118,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 100,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5108,7 +5143,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 80,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5131,7 +5165,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 96,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5154,7 +5187,6 @@ mod tests {
mode: "std".to_string(),
sound: "on".to_string(),
web_search_enabled: true,
price_mud_points: 720,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5178,7 +5210,6 @@ mod tests {
mode: "std".to_string(),
sound: "on".to_string(),
web_search_enabled: true,
price_mud_points: 160,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5199,7 +5230,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 100,
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
@@ -5233,7 +5263,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 40,
reference_image_srcs: Vec::new(),
reference_video_srcs: vec!["data:video/mp4;base64,video".to_string()],
reference_audio_srcs: Vec::new(),
@@ -5255,7 +5284,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
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()],
@@ -5295,7 +5323,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 40,
reference_image_srcs: Vec::new(),
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
@@ -5333,7 +5360,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 48,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: vec!["data:audio/wav;base64,audio".to_string()],
@@ -5378,24 +5404,21 @@ mod tests {
);
}
#[test]
fn editor_video_rejects_price_mismatch() {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "生成镜头。".to_string(),
model: EDITOR_VIDEO_MODEL_VEO_3_1.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 40,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
})
.expect_err("wrong price should fail");
assert!(error.body_text().contains("priceMudPoints"));
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
body.contains(snippet),
"function body starting with {start} should contain {snippet}"
);
}
}
}
+201 -112
View File
@@ -68,7 +68,8 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "仅提取被红色框框选的素材并整理成spritesheet";
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str =
"仅提取被红色框框选的素材并整理成spritesheet";
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
@@ -220,7 +221,6 @@ pub struct EditorIconSpritesheetGenerationRequest {
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) price_mud_points: u32,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
@@ -232,7 +232,6 @@ pub struct EditorUiDesignAssetExtractionRequest {
pub(crate) source_image_src: String,
pub(crate) aspect_ratio: String,
pub(crate) image_size: String,
pub(crate) price_mud_points: u32,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
@@ -930,8 +929,6 @@ pub(crate) async fn generate_editor_image_for_owner(
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let image_size = resolve_editor_image_request_size(
normalized_kind,
payload.size.as_deref(),
@@ -1061,19 +1058,15 @@ pub(crate) async fn generate_editor_image_for_owner(
.await
}
};
let generated = if is_publication_material_generation {
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
request_context.request_id(),
u64::from(configured_price_mud_points),
generate_operation,
)
.await?
} else {
generate_operation.await?
};
let generated = execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
editor_image_generation_billing_asset_kind(normalized_kind),
request_context.request_id(),
u64::from(configured_price_mud_points),
generate_operation,
)
.await?;
let mut image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
@@ -1168,6 +1161,30 @@ pub(crate) async fn generate_editor_image_for_owner(
))
}
fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str {
match normalized_kind {
Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND,
Some("spec") => "editor_spec_image",
Some("quick-edit") => "editor_quick_edit_image",
Some("ui-design") => "editor_ui_design",
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
_ => "editor_image_generation",
}
}
fn resolve_editor_image_edit_price(state: &AppState) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("quick-edit"), Some(GPT_IMAGE_2_MODEL), Some("1K"));
Ok(expected_price_mud_points)
}
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
match size.map(str::trim).filter(|value| !value.is_empty()) {
Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"),
@@ -1340,6 +1357,7 @@ pub(crate) async fn edit_editor_image_for_owner(
);
}
let reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?;
let expected_price_mud_points = resolve_editor_image_edit_price(state)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
@@ -1350,15 +1368,22 @@ pub(crate) async fn edit_editor_image_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_edit_with_references(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
EDITOR_IMAGE_GENERATION_SIZE,
1,
&[reference_image],
"图片画布修改图片",
let generated = execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
EDITOR_IMAGE_GENERATION_SIZE,
1,
&[reference_image],
"图片画布修改图片",
),
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
@@ -1456,9 +1481,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let expected_price_mud_points = validate_editor_icon_spritesheet_price(
let expected_price_mud_points = resolve_editor_icon_spritesheet_price(
state,
payload.price_mud_points,
Some(generation_options.model),
Some(generation_options.image_size),
)?;
@@ -1474,33 +1498,43 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
generation_options.aspect_ratio,
generation_options.provider_image_size,
&[reference_image],
"图片画布生成图标素材 spritesheet",
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
size,
1,
&[reference_image],
"图片画布生成图标素材 spritesheet",
)
.await?
};
let generated = execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_icon_spritesheet",
request_context.request_id(),
u64::from(expected_price_mud_points),
async {
if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
generation_options.aspect_ratio,
generation_options.provider_image_size,
&[reference_image],
"图片画布生成图标素材 spritesheet",
)
.await
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
size,
1,
&[reference_image],
"图片画布生成图标素材 spritesheet",
)
.await
}
},
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
@@ -1637,9 +1671,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
payload.aspect_ratio.as_str(),
payload.image_size.as_str(),
)?;
let expected_price_mud_points = validate_editor_ui_design_asset_extraction_price(
let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price(
state,
payload.price_mud_points,
Some(generation_options.image_size),
)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
@@ -1651,16 +1684,23 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
None,
generation_options.size.as_str(),
1,
&[reference_image],
"图片画布提取UI设计图素材 spritesheet",
let generated = execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_ui_design_asset_extraction",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
None,
generation_options.size.as_str(),
1,
&[reference_image],
"图片画布提取UI设计图素材 spritesheet",
),
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
@@ -2281,16 +2321,8 @@ 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(
fn resolve_editor_icon_spritesheet_price(
state: &AppState,
price_mud_points: u32,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
@@ -2303,11 +2335,6 @@ fn validate_editor_icon_spritesheet_price(
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
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)
}
@@ -2343,9 +2370,8 @@ fn normalize_editor_ui_design_asset_extraction_options(
))
}
fn validate_editor_ui_design_asset_extraction_price(
fn resolve_editor_ui_design_asset_extraction_price(
state: &AppState,
price_mud_points: u32,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points = state
@@ -2357,11 +2383,6 @@ fn validate_editor_ui_design_asset_extraction_price(
}))
})?
.image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), image_size);
if price_mud_points != expected_price_mud_points {
return Err(editor_ui_design_asset_extraction_bad_request(format!(
"priceMudPoints 与 UI 素材提取计费配置不一致,应为 {expected_price_mud_points}"
)));
}
Ok(expected_price_mud_points)
}
@@ -3293,12 +3314,11 @@ mod tests {
}
#[test]
fn editor_icon_spritesheet_request_price_uses_generation_config() {
fn editor_icon_spritesheet_price_is_resolved_from_generation_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
validate_editor_icon_spritesheet_price(
resolve_editor_icon_spritesheet_price(
&state,
12,
Some("gemini-3.1-flash-image-preview"),
Some("1K")
)
@@ -3306,12 +3326,10 @@ mod tests {
12
);
assert_eq!(
validate_editor_icon_spritesheet_price(&state, 3, Some("gpt-image-2"), Some("1K")).unwrap(),
resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K"))
.unwrap(),
3
);
let error = validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K"))
.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,
@@ -3348,10 +3366,9 @@ mod tests {
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
"sourceImageSrc": "data:image/png;base64,AAAA",
"aspectRatio": "1:1",
"imageSize": "2K",
"priceMudPoints": 5
"imageSize": "2K"
}))
.expect("UI extraction request should deserialize dimensions and price");
.expect("UI extraction request should deserialize dimensions");
let generation_options = normalize_editor_ui_design_asset_extraction_options(
request.aspect_ratio.as_str(),
request.image_size.as_str(),
@@ -3361,30 +3378,85 @@ mod tests {
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
assert_eq!(generation_options.size, "2048x2048");
assert_eq!(
validate_editor_ui_design_asset_extraction_price(
&state,
request.price_mud_points,
Some(generation_options.image_size)
)
resolve_editor_ui_design_asset_extraction_price(&state, Some(generation_options.image_size))
.unwrap(),
5
);
let ratio_error = match normalize_editor_ui_design_asset_extraction_options("16:9", "2K")
{
let ratio_error = match normalize_editor_ui_design_asset_extraction_options("16:9", "2K") {
Ok(_) => panic!("UI extraction ratio should stay square"),
Err(error) => error,
};
assert!(ratio_error.body_text().contains("aspectRatio"));
let size_error = match normalize_editor_ui_design_asset_extraction_options("1:1", "0.5K")
{
let size_error = match normalize_editor_ui_design_asset_extraction_options("1:1", "0.5K") {
Ok(_) => panic!("UI extraction size should be 1K or 2K"),
Err(error) => error,
};
assert!(size_error.body_text().contains("imageSize"));
let error =
validate_editor_ui_design_asset_extraction_price(&state, 3, Some("2K"))
.expect_err("wrong UI extraction price should fail");
assert!(error.body_text().contains("priceMudPoints"));
}
#[test]
fn editor_image_generation_request_omits_price_mud_points() {
let request: EditorImageGenerationRequest = serde_json::from_value(json!({
"prompt": "生成图片",
"model": "gpt-image-2",
"aspectRatio": "1:1",
"imageSize": "1K"
}))
.expect("image generation request should deserialize without price");
assert_eq!(request.prompt, "生成图片");
}
#[test]
fn editor_image_edit_request_omits_price_mud_points() {
let request: EditorImageEditRequest = serde_json::from_value(json!({
"prompt": "修改图片",
"sourceImageSrc": "data:image/png;base64,AAAA"
}))
.expect("image edit request should deserialize without price");
assert_eq!(request.prompt, "修改图片");
}
#[test]
fn editor_image_and_spritesheet_generation_are_billable() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"execute_billable_asset_operation_with_cost",
"configured_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn generate_editor_icon_spritesheet",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
}
#[test]
@@ -3410,4 +3482,21 @@ mod tests {
);
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
}
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
body.contains(snippet),
"function body starting with {start} should contain {snippet}"
);
}
}
}
@@ -833,11 +833,31 @@ mod tests {
.get("/api/external/v1/editor/images/generations")
.is_some()
);
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/edits")
.is_some()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
@@ -850,7 +870,10 @@ mod tests {
);
let ui_extraction_schema =
&parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"];
assert_eq!(ui_extraction_schema["properties"]["aspectRatio"]["const"], "1:1");
assert_eq!(
ui_extraction_schema["properties"]["aspectRatio"]["const"],
"1:1"
);
assert_eq!(
ui_extraction_schema["properties"]["imageSize"]["enum"],
json!(["1K", "2K"])
@@ -858,7 +881,7 @@ mod tests {
assert!(
ui_extraction_schema["properties"]
.get("priceMudPoints")
.is_some()
.is_none()
);
assert!(
parsed["paths"]
@@ -460,10 +460,15 @@ fn play_flow_support_router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/character-animations/generations",
post(generate_editor_character_animation).layer(DefaultBodyLimit::max(
// 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
)),
post(generate_editor_character_animation)
.layer(DefaultBodyLimit::max(
// 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。
EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/videos/generations",
@@ -59,11 +59,6 @@ pub(super) fn normalize_editor_sound_effect_request_with_pricing(
) -> Result<NormalizedEditorSoundEffectRequest, AppError> {
let model = normalize_editor_sound_model(payload.model.as_deref())?;
let expected_price_mud_points = pricing.sound_effect_model_mud_points(Some(model.as_str()));
if payload.price_mud_points != expected_price_mud_points {
return Err(editor_audio_bad_request(format!(
"音效生成泥点消耗不匹配,应为 {expected_price_mud_points}"
)));
}
Ok(NormalizedEditorSoundEffectRequest {
prompt: platform_audio::normalize_limited_text(
&payload.prompt,
@@ -115,11 +110,6 @@ pub(super) fn normalize_editor_background_music_request_with_pricing(
) -> Result<NormalizedEditorBackgroundMusicRequest, AppError> {
let expected_price_mud_points =
pricing.background_music_model_mud_points(Some(platform_audio::SUNO_DEFAULT_MODEL));
if payload.price_mud_points != expected_price_mud_points {
return Err(editor_audio_bad_request(format!(
"背景音乐生成泥点消耗不匹配,应为 {expected_price_mud_points}"
)));
}
Ok(NormalizedEditorBackgroundMusicRequest {
gpt_description_prompt: platform_audio::normalize_limited_text(
&payload.gpt_description_prompt,
@@ -189,7 +179,12 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
task.kind,
build_editor_audio_target(&task.task_id, "sound_effect", "editor_sound_effect"),
build_editor_audio_target(
&task.task_id,
"sound_effect",
"editor_sound_effect",
Some(u64::from(normalized.price_mud_points)),
),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
@@ -274,7 +269,12 @@ pub(crate) async fn generate_editor_background_music_for_owner(
owner_user_id.as_str(),
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
build_editor_audio_target(&task.task_id, "background_music", "editor_background_music"),
build_editor_audio_target(
&task.task_id,
"background_music",
"editor_background_music",
Some(u64::from(normalized.price_mud_points)),
),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
@@ -308,6 +308,7 @@ fn build_editor_audio_target(
task_id: &str,
slot: &'static str,
asset_kind: &'static str,
billing_points_cost: Option<u64>,
) -> AudioAssetBindingTarget {
AudioAssetBindingTarget {
entity_kind: "editor_audio".to_string(),
@@ -317,6 +318,7 @@ fn build_editor_audio_target(
profile_id: None,
storage_prefix: LegacyAssetPrefix::CharacterDrafts,
storage_scope: "editor_audio".to_string(),
billing_points_cost,
}
}
@@ -364,6 +366,7 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
billing_points_cost: None,
};
let generated = wait_for_generated_audio_asset(
state,
@@ -200,8 +200,11 @@ pub(super) fn build_audio_billing_asset_id(
pub(super) fn resolve_creation_audio_points_cost(
slot: AudioAssetSlot,
_target: &AudioAssetBindingTarget,
target: &AudioAssetBindingTarget,
) -> u64 {
if let Some(points_cost) = target.billing_points_cost {
return points_cost;
}
match slot {
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
@@ -23,6 +23,7 @@ pub(super) fn build_visual_novel_audio_target(
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
storage_prefix: LegacyAssetPrefix::CustomWorldScenes,
storage_scope: "visual-novel".to_string(),
billing_points_cost: None,
})
}
@@ -22,6 +22,7 @@ fn creation_audio_billing_uses_lower_cost_for_background_music() {
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
storage_scope: "puzzle_work".to_string(),
billing_points_cost: None,
};
assert_eq!(
@@ -34,6 +35,25 @@ fn creation_audio_billing_uses_lower_cost_for_background_music() {
);
}
#[test]
fn editor_audio_billing_uses_model_price_from_generation_request() {
let target = AudioAssetBindingTarget {
entity_kind: "editor_audio".to_string(),
entity_id: "task-editor-audio".to_string(),
slot: "background_music".to_string(),
asset_kind: "editor_background_music".to_string(),
profile_id: None,
storage_prefix: LegacyAssetPrefix::CharacterDrafts,
storage_scope: "editor_audio".to_string(),
billing_points_cost: Some(12),
};
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
12
);
}
#[test]
fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() {
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
@@ -83,20 +103,19 @@ fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effec
}
#[test]
fn editor_sound_effect_request_normalizes_prompt_duration_and_price() {
fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() {
let normalized =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
prompt: " 金币掉落叮当声 ".to_string(),
model: None,
duration: 7,
price_mud_points: 10,
})
.expect("editor sound effect request should normalize");
assert_eq!(normalized.prompt, "金币掉落叮当声");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
assert_eq!(normalized.duration, 7);
assert_eq!(normalized.price_mud_points, 10);
assert_eq!(normalized.price_mud_points, 5);
}
#[test]
@@ -106,7 +125,6 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
prompt: "金币掉落叮当声".to_string(),
model: Some(" audio1.0 ".to_string()),
duration: 5,
price_mud_points: 10,
})
.expect("Vidu audio model should be accepted");
@@ -116,7 +134,6 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
prompt: "金币掉落叮当声".to_string(),
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
duration: 5,
price_mud_points: 10,
})
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
@@ -131,7 +148,6 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
prompt: "按钮点击".to_string(),
model: None,
duration,
price_mud_points: 10,
})
.expect_err("duration outside 2-10 seconds should fail");
@@ -162,18 +178,17 @@ fn creation_sound_effect_duration_rejects_outside_vidu_range() {
}
#[test]
fn editor_background_music_request_forces_instrumental_and_price() {
fn editor_background_music_request_forces_instrumental_and_resolves_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);
assert_eq!(normalized.price_mud_points, 12);
}
#[test]
@@ -182,22 +197,8 @@ fn editor_background_music_request_rejects_prompt_over_documented_limit() {
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 {
prompt: "点击按钮".to_string(),
model: None,
duration: 5,
price_mud_points: 5,
})
.expect_err("wrong sound effect price should fail");
assert!(error.to_string().contains("泥点"));
}
@@ -20,6 +20,7 @@ pub(super) struct AudioAssetBindingTarget {
pub(super) profile_id: Option<String>,
pub(super) storage_prefix: LegacyAssetPrefix,
pub(super) storage_scope: String,
pub(super) billing_points_cost: Option<u64>,
}
#[derive(Clone, Debug)]
@@ -14,7 +14,7 @@ pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::L
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
pub const PROFILE_NEW_USER_INITIAL_WALLET_POINTS: u64 = 10;
pub const PROFILE_NEW_USER_INITIAL_WALLET_POINTS: u64 = 100;
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
+3 -3
View File
@@ -836,15 +836,15 @@ mod tests {
}
#[test]
fn new_user_registration_wallet_reward_starts_with_ten_points() {
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 10);
fn new_user_registration_wallet_reward_starts_with_one_hundred_points() {
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 100);
assert_eq!(
calculate_runtime_profile_wallet_balance(
0,
PROFILE_NEW_USER_INITIAL_WALLET_POINTS as i64,
)
.expect("new user registration reward should fit wallet balance"),
10
100
);
}
@@ -316,7 +316,6 @@ pub struct EditorCharacterAnimationGenerateRequest {
pub ratio: String,
pub frame_count: u32,
pub duration_seconds: u32,
pub price_mud_points: u32,
pub model: String,
}
@@ -358,7 +357,6 @@ pub struct EditorVideoGenerateRequest {
pub sound: String,
#[serde(default = "default_editor_video_web_search_enabled")]
pub web_search_enabled: bool,
pub price_mud_points: u32,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
@@ -405,7 +403,6 @@ pub struct EditorIconSpritesheetGenerateRequest {
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)]
@@ -440,7 +437,6 @@ pub struct EditorSoundEffectGenerateRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub duration: u8,
pub price_mud_points: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -448,7 +444,6 @@ pub struct EditorSoundEffectGenerateRequest {
pub struct EditorBackgroundMusicGenerateRequest {
pub gpt_description_prompt: String,
pub make_instrumental: bool,
pub price_mud_points: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -996,7 +991,6 @@ mod tests {
ratio: "same".to_string(),
frame_count: 48,
duration_seconds: 6,
price_mud_points: 120,
model: "seedance2.0-fast".to_string(),
})
.expect("request should serialize");
@@ -1006,7 +1000,7 @@ mod tests {
payload["sourceImageSrc"],
json!("/generated-characters/hero/master.png")
);
assert_eq!(payload["priceMudPoints"], json!(120));
assert!(payload.get("priceMudPoints").is_none());
assert_eq!(payload["model"], json!("seedance2.0-fast"));
}
@@ -1055,7 +1049,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 100,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -1064,7 +1057,7 @@ mod tests {
assert_eq!(payload["aspectRatio"], json!("16:9"));
assert_eq!(payload["durationSeconds"], json!(5));
assert_eq!(payload["priceMudPoints"], json!(100));
assert!(payload.get("priceMudPoints").is_none());
assert_eq!(payload["model"], json!("kling3.0-omni"));
assert_eq!(payload["webSearchEnabled"], json!(true));
}
@@ -1080,7 +1073,6 @@ mod tests {
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
price_mud_points: 100,
reference_image_srcs: vec!["data:image/png;base64,image".to_string()],
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
@@ -1138,14 +1130,13 @@ mod tests {
}
#[test]
fn editor_icon_spritesheet_request_and_response_include_price() {
fn editor_icon_spritesheet_request_omits_price_and_response_includes_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");
@@ -1153,7 +1144,7 @@ mod tests {
request_payload["referenceImageSrc"],
json!("data:image/png;base64,spec")
);
assert_eq!(request_payload["priceMudPoints"], json!(12));
assert!(request_payload.get("priceMudPoints").is_none());
let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
@@ -1187,13 +1178,12 @@ mod tests {
prompt: "金币掉落叮当声".to_string(),
model: Some("audio1.0".to_string()),
duration: 7,
price_mud_points: 10,
})
.expect("sound request should serialize");
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["model"], json!("audio1.0"));
assert_eq!(sound_payload["duration"], json!(7));
assert_eq!(sound_payload["priceMudPoints"], json!(10));
assert!(sound_payload.get("priceMudPoints").is_none());
assert!(sound_payload.get("sound").is_none());
assert!(sound_payload.get("type").is_none());
assert!(sound_payload.get("tempo").is_none());
@@ -1208,7 +1198,6 @@ mod tests {
prompt: "按钮确认短促音".to_string(),
model: None,
duration: 5,
price_mud_points: 10,
})
.expect("sound request with unset model should serialize");
assert!(unset_model_payload.get("model").is_none());
@@ -1217,7 +1206,6 @@ mod tests {
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "森林冒险背景音乐".to_string(),
make_instrumental: true,
price_mud_points: 5,
})
.expect("background music request should serialize");
assert_eq!(
@@ -180,7 +180,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
const submitButton = within(panel).getByRole('button', { name: '生成' });
expect(submitButton.textContent).toBe('生成20泥点');
expect(submitButton.textContent).toBe('生成5泥点');
});
it('clicks the parent generation panel to collapse an opened option panel', () => {
@@ -160,7 +160,7 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成20泥点',
'生成3泥点',
);
});
@@ -51,7 +51,7 @@ describe('ImageCanvasEditGenerationModalView', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '新的修改提示' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
fireEvent.click(screen.getByRole('button', { name: '修改3泥点' }));
expect(screen.getByLabelText('当前提示词').textContent).toBe(
'新的修改提示',
@@ -4,6 +4,10 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import {
calculateEditorImageGenerationPrice,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
type ImageCanvasEditGenerationModalViewProps = {
@@ -18,6 +22,11 @@ export function ImageCanvasEditGenerationModalView({
onSubmit,
}: ImageCanvasEditGenerationModalViewProps) {
const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating';
const editPrice = calculateEditorImageGenerationPrice({
kind: 'quick-edit',
model: IMAGE_MODEL_GPT_IMAGE_2,
imageSize: '1K',
});
return (
<UnifiedModal
@@ -89,7 +98,9 @@ export function ImageCanvasEditGenerationModalView({
className="image-canvas-editor__generate-submit"
disabled={dialog.status === 'generating'}
>
{dialog.status === 'generating' ? '修改中' : '修改'}
{dialog.status === 'generating'
? '修改中'
: `修改${editPrice}泥点`}
</PlatformActionButton>
</div>
</form>
@@ -1501,7 +1501,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'icon-gpt-model-1',
priceMudPoints: 3,
});
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
@@ -1567,7 +1566,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 3,
}),
);
});
@@ -2305,7 +2303,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-1',
priceMudPoints: 12,
});
render(<ImageCanvasEditorView />);
@@ -2381,7 +2378,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 12,
projectId: 'editor-project-icons',
assetFolderId: 'project',
generationInputs: expect.objectContaining({
@@ -2448,7 +2444,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
provider: 'VectorEngine',
taskId: 'icon-task-renamed-spec-1',
priceMudPoints: 12,
});
render(<ImageCanvasEditorView />);
@@ -2521,7 +2516,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 12,
projectId: 'editor-project-default',
assetFolderId: 'project',
}),
@@ -2608,7 +2602,6 @@ describe('ImageCanvasEditorView generation integration', () => {
frameCount: 48,
durationSeconds: 6,
fps: 8,
priceMudPoints: 120,
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const requestUrl =
@@ -2747,7 +2740,6 @@ describe('ImageCanvasEditorView generation integration', () => {
ratio: '16:9',
frameCount: 48,
durationSeconds: 6,
priceMudPoints: 120,
model: 'seedance2.0-fast',
}),
);
@@ -2816,7 +2808,6 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-extract-task-1',
priceMudPoints: 3,
});
render(<ImageCanvasEditorView />);
@@ -2908,7 +2899,6 @@ describe('ImageCanvasEditorView generation integration', () => {
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 3,
projectId: 'editor-project-ui-extract',
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
@@ -3198,8 +3188,8 @@ describe('ImageCanvasEditorView generation integration', () => {
const generatedLayer = screen
.getByAltText('画布图片:魔法森林 快速编辑')
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
expect(Number.parseFloat(generatedLayer.style.left)).toBe(376);
expect(Number.parseFloat(generatedLayer.style.top)).toBe(1196);
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(683);
await waitFor(() => {
@@ -3214,8 +3204,8 @@ describe('ImageCanvasEditorView generation integration', () => {
height: 683,
originalWidth: 1024,
originalHeight: 683,
x: 1688,
y: 140,
x: 376,
y: 1196,
}),
]),
}),
@@ -273,7 +273,7 @@ describe('ImageCanvasGenerationComposerView', () => {
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
'生成20泥点',
'生成3泥点',
);
});
@@ -624,7 +624,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(within(panel).queryByText('循环')).toBeNull();
expect(within(panel).queryByText(/BPM/u)).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
'生成10泥点',
'生成5泥点',
);
fireEvent.click(
@@ -689,7 +689,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(within(panel).getByText('Suno')).toBeTruthy();
expect(within(panel).queryByText('make_instrumental')).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
'生成5泥点',
'生成12泥点',
);
});
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {

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