From 7830d201c48f48b3e4fead55676b1029c0cade56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 22 Jun 2026 23:11:23 +0800 Subject: [PATCH] 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. --- .../genarrative-external-v1.openapi.json | 40 +-- .../shared-memory/decision-log.md | 20 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 7 + ...编辑器】模型定价é…ç½®ç®¡ç†æ–¹æ¡ˆ-2026-06-22.md | 4 +- ...™¨ã€‘生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md | 12 +- server-rs/crates/api-server/src/app.rs | 78 +++-- .../src/character_animation_assets.rs | 183 +++++----- .../crates/api-server/src/editor_project.rs | 313 +++++++++++------- .../api-server/src/external_editor_api.rs | 27 +- .../api-server/src/modules/play_flow.rs | 13 +- .../generation.rs | 27 +- .../vector_engine_audio_generation/publish.rs | 5 +- .../vector_engine_audio_generation/targets.rs | 1 + .../vector_engine_audio_generation/tests.rs | 47 +-- .../vector_engine_audio_generation/types.rs | 1 + server-rs/crates/module-runtime/src/domain.rs | 2 +- server-rs/crates/module-runtime/src/lib.rs | 6 +- .../crates/shared-contracts/src/assets.rs | 22 +- ...CanvasBasicGenerationComposerView.test.tsx | 2 +- ...asCharacterGenerationComposerView.test.tsx | 2 +- ...mageCanvasEditGenerationModalView.test.tsx | 2 +- .../ImageCanvasEditGenerationModalView.tsx | 13 +- ...CanvasEditorGenerationIntegration.test.tsx | 18 +- ...ImageCanvasGenerationComposerView.test.tsx | 6 +- ...ageCanvasGenerationSubmissionModel.test.ts | 29 -- .../ImageCanvasGenerationSubmissionModel.ts | 24 -- ...CanvasIconSpritesheetComposerView.test.tsx | 2 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 21 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 96 +++--- .../useImageCanvasGenerationWorkflow.test.tsx | 2 - .../image-editor/editorProjectClient.test.ts | 33 +- .../image-editor/editorProjectClient.ts | 21 +- 33 files changed, 556 insertions(+), 531 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 414f34fbc..9225908cf 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8b183e01f..863bcb53b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5bbb63fab..c8fbefec2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 æç¤ºå­˜åœ¨æ–°ç‰ˆæœ¬ / 当å‰ç‰ˆæœ¬è¾ƒæ—§ï¼Œçœ‹èµ·æ¥åƒå¯åŠ¨å¼‚å¸¸ã€‚ diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 8130008e1..529aa8dff 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -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` å¯ç”¨æ€§ã€‚ diff --git a/docs/ã€ç¼–辑器】模型定价é…ç½®ç®¡ç†æ–¹æ¡ˆ-2026-06-22.md b/docs/ã€ç¼–辑器】模型定价é…ç½®ç®¡ç†æ–¹æ¡ˆ-2026-06-22.md index 9b1645c15..3cbc833cf 100644 --- a/docs/ã€ç¼–辑器】模型定价é…ç½®ç®¡ç†æ–¹æ¡ˆ-2026-06-22.md +++ b/docs/ã€ç¼–辑器】模型定价é…ç½®ç®¡ç†æ–¹æ¡ˆ-2026-06-22.md @@ -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`。 ## 管ç†ç«¯ diff --git a/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md b/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md index 3918f7049..fbef055d6 100644 --- a/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md +++ b/docs/ã€ç¼–辑器】生æˆç±»é¢æ¿Lovart统一改造方案-2026-06-17.md @@ -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`。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c34abb18a..55e3d04d7 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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"); diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 664ae6f8d..3b552054d 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -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, Extension(request_context): Extension, + Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, + Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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}" + ); + } } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6d681134b..6c7e306ed 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, - pub(crate) price_mud_points: u32, pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, @@ -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, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, @@ -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 { + 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) -> Result, Ok(normalized) } -fn editor_icon_spritesheet_bad_request(message: impl Into) -> 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 { @@ -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 { 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}" + ); + } + } } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 6e22b4f70..6bb592e36 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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"] diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs index c90d06150..cb05b06a3 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -460,10 +460,15 @@ fn play_flow_support_router(state: AppState) -> Router { ) .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", diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 941deefc6..07eff0393 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -59,11 +59,6 @@ pub(super) fn normalize_editor_sound_effect_request_with_pricing( ) -> Result { 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 { 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, ) -> 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, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs index c64ec2539..41e8288c8 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs @@ -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, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs index 086d224b3..47e5233ae 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs @@ -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, }) } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs index d27925d14..50c3d7ac6 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs @@ -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("泥点")); -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs index 8a141b54b..6c50a69f1 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs @@ -20,6 +20,7 @@ pub(super) struct AudioAssetBindingTarget { pub(super) profile_id: Option, pub(super) storage_prefix: LegacyAssetPrefix, pub(super) storage_scope: String, + pub(super) billing_points_cost: Option, } #[derive(Clone, Debug)] diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 9ba272cd7..cc1e0fdac 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -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 = "{}"; diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index e4b727b18..bf8a5be23 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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 ); } diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index f140204c7..20b21ada0 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -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, #[serde(default)] @@ -405,7 +403,6 @@ pub struct EditorIconSpritesheetGenerateRequest { pub aspect_ratio: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub image_size: Option, - 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, 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!( diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index afa372f84..549fba65d 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -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', () => { diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index b2c162ad8..d98475f29 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -160,7 +160,7 @@ describe('ImageCanvasCharacterGenerationComposerView', () => { expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); expect(screen.getByRole('button', { name: '生æˆ' }).textContent).toBe( - '生æˆ20泥点', + '生æˆ3泥点', ); }); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx index 8b2d1db02..c4ca42259 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -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( '新的修改æç¤º', diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 2f7027b79..78b2ce32b 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -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 ( - {dialog.status === 'generating' ? '修改中' : '修改'} + {dialog.status === 'generating' + ? '修改中' + : `修改${editPrice}泥点`} diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 4c5cfac4c..b8bcdcc38 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1501,7 +1501,6 @@ describe('ImageCanvasEditorView generation integration', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-gpt-model-1', - priceMudPoints: 3, }); render(); 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(); @@ -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(); @@ -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(); @@ -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, }), ]), }), diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index a7e94a4fc..5b3112d99 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -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('生æˆè§„范å‚è€ƒå›¾ç‚¹å‡»å…ˆå¼¹æ¥æºèœå•,ä¸ç›´æŽ¥æ‰“开上传', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index c3e7ec5d9..13cf71936 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -7,14 +7,6 @@ import { buildImageGenerationSubmissionPlan, } from './ImageCanvasGenerationSubmissionModel'; import { - BACKGROUND_MUSIC_MODEL_SUNO, - calculateCharacterAnimationPrice, - calculateEditorBackgroundMusicPrice, - calculateEditorIconSpritesheetPrice, - calculateEditorSoundEffectPrice, - calculateEditorVideoPrice, - CHARACTER_ANIMATION_MODEL, - DEFAULT_SOUND_EFFECT_MODEL, IMAGE_MODEL_GPT_IMAGE_2, IMAGE_MODEL_NANOBANANA2, } from './ImageCanvasGenerationModel'; @@ -585,10 +577,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { model: 'gpt-image-2', aspectRatio: '3:2', imageSize: '2K', - priceMudPoints: calculateEditorIconSpritesheetPrice( - 'gpt-image-2', - '2K', - ), }, generationInputs: { fields: [{ title: 'ç´ ææè¿°', value: '返回按钮\n设置按钮' }], @@ -640,11 +628,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ratio: 'same', frameCount: 48, durationSeconds: 6, - priceMudPoints: calculateCharacterAnimationPrice( - CHARACTER_ANIMATION_MODEL, - '720p', - 6, - ), model: 'seedance2.0-fast', }, }); @@ -830,11 +813,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '角色绕场一周', input: { model: 'seedance2.0-fast', - priceMudPoints: calculateEditorVideoPrice( - 'seedance2.0-fast', - '480p', - 4, - ), }, result: { title: '生æˆè§†é¢‘ 3', @@ -864,7 +842,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { model: 'seedance2.0', resolution: '720p', durationSeconds: 5, - priceMudPoints: calculateEditorVideoPrice('seedance2.0', '720p', 5), }, result: { title: '生æˆè§†é¢‘ 7', @@ -892,9 +869,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: '金叿މè½å®å½“声', model: 'audio1.0', duration: 7, - priceMudPoints: calculateEditorSoundEffectPrice( - DEFAULT_SOUND_EFFECT_MODEL, - ), }, result: { title: '游æˆéŸ³æ•ˆ 4', @@ -959,9 +933,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { input: { gptDescriptionPrompt: '温暖轻快的森林冒险背景音ä¹', makeInstrumental: true, - priceMudPoints: calculateEditorBackgroundMusicPrice( - BACKGROUND_MUSIC_MODEL_SUNO, - ), }, result: { title: '游æˆèƒŒæ™¯éŸ³ä¹ 5', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 54c388074..e51ff3a68 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -26,13 +26,7 @@ import { buildSpecPrompt, buildUiDesignGenerationInputs, buildVideoGenerationInputs, - calculateCharacterAnimationPrice, - calculateEditorBackgroundMusicPrice, - calculateEditorIconSpritesheetPrice, - calculateEditorSoundEffectPrice, - calculateEditorVideoPrice, CHARACTER_ANIMATION_MODEL, - DEFAULT_BACKGROUND_MUSIC_MODEL, DEFAULT_ICON_DESCRIPTIONS, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DEFAULT_SOUND_EFFECT_MODEL, @@ -395,11 +389,6 @@ export function buildImageGenerationSubmissionPlan({ mode: 'std', sound, webSearchEnabled, - priceMudPoints: calculateEditorVideoPrice( - model, - resolution, - durationSeconds, - ), ...(seedanceReferences.referenceImageSrcs.length ? { referenceImageSrcs: seedanceReferences.referenceImageSrcs } : {}), @@ -434,7 +423,6 @@ export function buildImageGenerationSubmissionPlan({ prompt: normalizedPrompt, model: soundModel, duration: durationSeconds, - priceMudPoints: calculateEditorSoundEffectPrice(soundModel), }, result: { title: `游æˆéŸ³æ•ˆ ${nextGeneratedIndex}`, @@ -455,9 +443,6 @@ export function buildImageGenerationSubmissionPlan({ input: { gptDescriptionPrompt: normalizedPrompt, makeInstrumental: true, - priceMudPoints: calculateEditorBackgroundMusicPrice( - DEFAULT_BACKGROUND_MUSIC_MODEL, - ), }, result: { title: `游æˆèƒŒæ™¯éŸ³ä¹ ${nextGeneratedIndex}`, @@ -531,10 +516,6 @@ export function buildIconSpritesheetGenerationSubmissionPlan( model: rememberImageModel, aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', - priceMudPoints: calculateEditorIconSpritesheetPrice( - rememberImageModel, - dialog.imageSize ?? '1K', - ), }, generationInputs: buildIconGenerationInputs( iconDescriptions, @@ -564,11 +545,6 @@ export function buildCharacterAnimationSubmissionPlan({ ratio: panel.ratio, frameCount: panel.frameCount, durationSeconds: panel.durationSeconds, - priceMudPoints: calculateCharacterAnimationPrice( - CHARACTER_ANIMATION_MODEL, - panel.resolution, - panel.durationSeconds, - ), model: CHARACTER_ANIMATION_MODEL, }, }; diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index 3f3f7ab3d..df6449cf4 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -187,7 +187,7 @@ describe('ImageCanvasIconSpritesheetComposerView', () => { expect(screen.getByLabelText('当剿¨¡åž‹').textContent).toBe('gpt-image-2'); expect(screen.getByLabelText('当å‰å°ºå¯¸').textContent).toBe('1K'); expect(screen.getByRole('button', { name: '生æˆ' }).textContent).toBe( - '生æˆ20泥点', + '生æˆ3泥点', ); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 920ff5e70..084b6ac22 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -106,7 +106,6 @@ function createVideoGenerated(overrides = {}) { model: 'seedance2.0-fast', provider: 'ark', taskId: 'task-video-generated', - priceMudPoints: 40, ...overrides, }; } @@ -482,14 +481,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { const layerRects = screen.getByTestId('layer-rects').textContent ?? ''; const resultRect = parseLayerRect(layerRects, 'layer-quick-edit-1'); - expect( - rectOverlapsExpanded(resultRect, blockingLayer, 32), - ).toBe(false); + expect(rectOverlapsExpanded(resultRect, blockingLayer, 32)).toBe(false); }); it('submits quick edits with the numbered red selection image as the final source reference', async () => { generateEditorImageMock.mockResolvedValueOnce( - createGenerated({ prompt: '对1å·çº¢è‰²åœˆé€‰æ¡†é‡Œçš„内容åšä»¥ä¸‹ä¿®æ”¹ï¼šæ”¹æˆè“色' }), + createGenerated({ + prompt: '对1å·çº¢è‰²åœˆé€‰æ¡†é‡Œçš„内容åšä»¥ä¸‹ä¿®æ”¹ï¼šæ”¹æˆè“色', + }), ); render( { resolution: '480p', mode: 'std', sound: 'off', - priceMudPoints: 40, referenceImageSrcs: ['data:image/png;base64,style.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/videos/source.mp4', @@ -718,7 +716,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { frameCount: 32, durationSeconds: 4, fps: 8, - priceMudPoints: 40, }); render( { title: '角色动作', src: 'generated-character-drafts/editor/actions/frame.png', mediaType: 'image-sequence', - thumbnailSrc: - 'generated-character-drafts/editor/actions/frame.png', + thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png', imageSequenceFrames: [ { frameIndex: 1, @@ -769,7 +765,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ratio: 'same', frameCount: 32, durationSeconds: 4, - priceMudPoints: 40, model: 'seedance2.0-fast', }), ); @@ -1233,7 +1228,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', - priceMudPoints: 20, iconImageSrcs: [ createIconResult('返回按钮', 'data:image/png;base64,back'), createIconResult('设置按钮', 'data:image/png;base64,setting'), @@ -1275,7 +1269,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gpt-image-2', - priceMudPoints: 3, }), ); }); @@ -1311,7 +1304,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', - priceMudPoints: 20, iconImageSrcs: [ createIconResult('ç´ æ 1', 'data:image/png;base64,asset-1'), ], @@ -1365,7 +1357,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', - priceMudPoints: 20, iconImageSrcs: [ createIconResult('ç´ æ 1', 'data:image/png;base64,asset-1'), ], @@ -1409,7 +1400,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { sourceImageSrc: 'data:image/png;base64,marked-ui-design', aspectRatio: '1:1', imageSize: '2K', - priceMudPoints: 5, generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ @@ -1532,7 +1522,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { frameCount: 32, durationSeconds: 4, fps: 8, - priceMudPoints: 40, }); render( { - if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) { + if ( + options.dialogId && + !hasCanvasGenerationDialogById(options.dialogId) + ) { return; } layerCounterRef.current += 1; @@ -276,7 +275,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ dialogId?: string; } = {}, ) => { - if (options.dialogId && !hasCanvasGenerationDialogById(options.dialogId)) { + if ( + options.dialogId && + !hasCanvasGenerationDialogById(options.dialogId) + ) { return; } layerCounterRef.current += 1; @@ -308,13 +310,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setQuickEditPanel(null); setActiveTool('select'); if (options.dialogId) { - updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({ - ...currentDialog, - status: 'idle', - composerOpen: false, - generatedLayerId: nextLayer.id, - errorMessage: undefined, - })); + updateCanvasGenerationDialogById( + options.dialogId, + (currentDialog) => ({ + ...currentDialog, + status: 'idle', + composerOpen: false, + generatedLayerId: nextLayer.id, + errorMessage: undefined, + }), + ); } } fitLayers([sourceLayer, nextLayer]); @@ -584,10 +589,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const extractionPlan = resolveUiAssetExtractionGenerationPlan( options.marks?.length ?? 0, ); - const priceMudPoints = calculateEditorIconSpritesheetPrice( - IMAGE_MODEL_GPT_IMAGE_2, - extractionPlan.imageSize, - ); const generated = await extractEditorUiDesignAssets({ sourceImageSrc, projectId, @@ -596,7 +597,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ spritesheetLabel: `${sourceLayer.title} ç´ æå›¾é›†`, aspectRatio: extractionPlan.aspectRatio, imageSize: extractionPlan.imageSize, - priceMudPoints, }); const extractionFrame = { x: sourceLayer.x + sourceLayer.width + 32, @@ -814,9 +814,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); const generated = await generateEditorCharacterAnimation({ sourceLayerId: quickEditSourceLayer.id, - sourceImageSrc: resolveCharacterAnimationSourceImageSrc( - quickEditSourceLayer, - ), + sourceImageSrc: + resolveCharacterAnimationSourceImageSrc(quickEditSourceLayer), sourceWidth: quickEditSourceLayer.originalWidth, sourceHeight: quickEditSourceLayer.originalHeight, promptText: normalizedPrompt, @@ -824,11 +823,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ratio: 'same', frameCount: animationFrameCount, durationSeconds: animationDurationSeconds, - priceMudPoints: calculateCharacterAnimationPrice( - CHARACTER_ANIMATION_MODEL, - animationResolution, - animationDurationSeconds, - ), model: CHARACTER_ANIMATION_MODEL, }); addCharacterAnimationResultLayer( @@ -843,7 +837,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); return; } - if (panelMode === 'quick-edit' && isVideoQuickEditSource(quickEditSourceLayer)) { + if ( + panelMode === 'quick-edit' && + isVideoQuickEditSource(quickEditSourceLayer) + ) { const videoModel = DEFAULT_VIDEO_MODEL; const videoResolution = '480p' as const; const videoDurationSeconds = 4 as const; @@ -861,11 +858,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ resolution: videoResolution, mode: 'std', sound: 'off', - priceMudPoints: calculateEditorVideoPrice( - videoModel, - videoResolution, - videoDurationSeconds, - ), ...(extraReferenceImageSrcs.length ? { referenceImageSrcs: extraReferenceImageSrcs } : {}), @@ -914,7 +906,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ quickEditReferences, ); const isCharacterRedraw = - panelMode === 'redraw' && quickEditSourceLayer.assetKind === 'character'; + panelMode === 'redraw' && + quickEditSourceLayer.assetKind === 'character'; const generated = await generateEditorImage({ prompt: normalizedPrompt, size: quickEditSize, @@ -949,13 +942,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); } catch (error) { if (quickEditDialogId) { - updateCanvasGenerationDialogById(quickEditDialogId, (currentDialog) => ({ - ...currentDialog, - prompt: normalizedPrompt, - status: 'failed', - composerOpen: false, - errorMessage: resolveImageGenerationErrorMessage(error), - })); + updateCanvasGenerationDialogById( + quickEditDialogId, + (currentDialog) => ({ + ...currentDialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: false, + errorMessage: resolveImageGenerationErrorMessage(error), + }), + ); } setQuickEditPanel({ ...quickEditPanel, @@ -1099,15 +1095,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } if (canvasDialog && projectId) { - updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => - currentDialog.generatedLayerId - ? currentDialog - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - errorMessage: undefined, - }, + updateCanvasGenerationDialogById( + canvasDialog.id, + (currentDialog) => + currentDialog.generatedLayerId + ? currentDialog + : { + ...currentDialog, + status: 'idle', + composerOpen: true, + errorMessage: undefined, + }, ); return; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 389ebb65f..61b42a92f 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -759,7 +759,6 @@ describe('useImageCanvasGenerationWorkflow', () => { model: 'audio1.0', provider: 'VectorEngine', taskId: 'task-sound', - priceMudPoints: 5, audioKind: 'sound-effect', durationSeconds: 4, }); @@ -773,7 +772,6 @@ describe('useImageCanvasGenerationWorkflow', () => { prompt: '金å¸è·³å‡ºéŸ³', model: 'audio1.0', duration: 8, - priceMudPoints: 5, }); }); await waitFor(() => { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 04f1f3eac..3aa3b20e0 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -571,7 +571,9 @@ describe('editorProjectClient', () => { taskId: 'vector-task-1', }); - const result = await generateEditorImage({ prompt: '一张画布图片' }); + const result = await generateEditorImage({ + prompt: '一张画布图片', + }); expect(result.taskId).toBe('vector-task-1'); expect(requestJsonMock).toHaveBeenCalledWith( @@ -579,7 +581,6 @@ describe('editorProjectClient', () => { expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: '一张画布图片' }), }), '生æˆå›¾ç‰‡å¤±è´¥', expect.objectContaining({ @@ -730,13 +731,11 @@ describe('editorProjectClient', () => { model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-1', - priceMudPoints: 12, }); const result = await generateEditorIconSpritesheet({ referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], - priceMudPoints: 12, }); expect(result.taskId).toBe('icon-spritesheet-task-1'); @@ -749,7 +748,6 @@ describe('editorProjectClient', () => { referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', - priceMudPoints: 12, }), }), '生æˆå›¾æ ‡ç´ æå¤±è´¥', @@ -771,7 +769,6 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-2', - priceMudPoints: 20, }); await generateEditorIconSpritesheet({ @@ -780,7 +777,6 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '2K', - priceMudPoints: 20, projectId: 'editor-project-1', assetFolderId: 'project', generationInputs: { @@ -800,7 +796,6 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '2K', - priceMudPoints: 20, projectId: 'editor-project-1', generationInputs: { fields: [{ title: 'ç´ ææè¿°', value: '返回按钮' }], @@ -835,7 +830,6 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-asset-extraction-1', - priceMudPoints: 20, }); const result = await extractEditorUiDesignAssets({ @@ -845,7 +839,6 @@ describe('editorProjectClient', () => { spritesheetLabel: '战斗UI设计图 ç´ æå›¾é›†', aspectRatio: '1:1', imageSize: '2K', - priceMudPoints: 5, generationInputs: { fields: [{ title: 'æ¥æº', value: '战斗UI设计图' }], references: [], @@ -869,7 +862,6 @@ describe('editorProjectClient', () => { spritesheetLabel: '战斗UI设计图 ç´ æå›¾é›†', aspectRatio: '1:1', imageSize: '2K', - priceMudPoints: 5, }), }), 'æå–UI设计图素æå¤±è´¥', @@ -983,7 +975,6 @@ describe('editorProjectClient', () => { frameCount: 32, durationSeconds: 4, fps: 8, - priceMudPoints: 40, }); const result = await generateEditorCharacterAnimation({ @@ -996,7 +987,6 @@ describe('editorProjectClient', () => { ratio: 'same', frameCount: 32, durationSeconds: 4, - priceMudPoints: 40, model: 'seedance2.0-fast', }); @@ -1016,7 +1006,6 @@ describe('editorProjectClient', () => { ratio: 'same', frameCount: 32, durationSeconds: 4, - priceMudPoints: 40, model: 'seedance2.0-fast', }), }), @@ -1041,7 +1030,6 @@ describe('editorProjectClient', () => { taskId: 'video-task-1', durationSeconds: 5, resolution: '480p', - priceMudPoints: 100, }); const result = await generateEditorVideo({ @@ -1052,7 +1040,6 @@ describe('editorProjectClient', () => { resolution: '480p', mode: 'std', sound: 'off', - priceMudPoints: 100, }); expect(result.taskId).toBe('video-task-1'); @@ -1070,7 +1057,6 @@ describe('editorProjectClient', () => { mode: 'std', sound: 'off', webSearchEnabled: true, - priceMudPoints: 100, }), }), '生æˆè§†é¢‘失败', @@ -1081,7 +1067,6 @@ describe('editorProjectClient', () => { ); }); - it('passes seedance video multimodal references through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ videoSrc: '/generated-editor-videos/video-task-2/preview.mp4', @@ -1095,7 +1080,6 @@ describe('editorProjectClient', () => { taskId: 'video-task-2', durationSeconds: 5, resolution: '720p', - priceMudPoints: 100, }); await generateEditorVideo({ @@ -1106,7 +1090,6 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - priceMudPoints: 100, referenceImageSrcs: ['data:image/png;base64,image'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', @@ -1130,7 +1113,6 @@ describe('editorProjectClient', () => { mode: 'std', sound: 'off', webSearchEnabled: true, - priceMudPoints: 100, referenceImageSrcs: ['data:image/png;base64,image'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', @@ -1156,7 +1138,6 @@ describe('editorProjectClient', () => { model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-1', - priceMudPoints: 10, audioKind: 'sound-effect', }); @@ -1164,7 +1145,6 @@ describe('editorProjectClient', () => { prompt: '金叿މè½å®å½“声', model: 'audio1.0', duration: 7, - priceMudPoints: 10, }); expect(result.taskId).toBe('sound-task-1'); @@ -1177,7 +1157,6 @@ describe('editorProjectClient', () => { prompt: '金叿މè½å®å½“声', model: 'audio1.0', duration: 7, - priceMudPoints: 10, }), }), 'ç”Ÿæˆæ¸¸æˆéŸ³æ•ˆå¤±è´¥', @@ -1199,7 +1178,6 @@ describe('editorProjectClient', () => { model: 'audio1.0', provider: 'VectorEngine', taskId: 'sound-task-null', - priceMudPoints: 10, audioKind: 'sound-effect', }); @@ -1207,7 +1185,6 @@ describe('editorProjectClient', () => { prompt: '按钮确认短促音', model: 'audio1.0', duration: 5, - priceMudPoints: 10, }); expect(requestJsonMock).toHaveBeenCalledWith( @@ -1217,7 +1194,6 @@ describe('editorProjectClient', () => { prompt: '按钮确认短促音', model: 'audio1.0', duration: 5, - priceMudPoints: 10, }), }), 'ç”Ÿæˆæ¸¸æˆéŸ³æ•ˆå¤±è´¥', @@ -1236,14 +1212,12 @@ describe('editorProjectClient', () => { model: 'chirp-v4', provider: 'VectorEngine', taskId: 'music-task-1', - priceMudPoints: 5, audioKind: 'background-music', }); const result = await generateEditorBackgroundMusic({ gptDescriptionPrompt: '森林冒险背景音ä¹', makeInstrumental: true, - priceMudPoints: 5, }); expect(result.taskId).toBe('music-task-1'); @@ -1255,7 +1229,6 @@ describe('editorProjectClient', () => { body: JSON.stringify({ gptDescriptionPrompt: '森林冒险背景音ä¹', makeInstrumental: true, - priceMudPoints: 5, }), }), 'ç”Ÿæˆæ¸¸æˆèƒŒæ™¯éŸ³ä¹å¤±è´¥', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index f5eaccbd2..c7113d315 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -153,7 +153,6 @@ export type EditorIconSpritesheetGenerationInput = { model?: string; aspectRatio?: string; imageSize?: string; - priceMudPoints: number; projectId?: string | null; generationInputs?: EditorAssetGenerationInputs | null; assetFolderId?: string | null; @@ -163,7 +162,6 @@ export type EditorUiDesignAssetExtractionInput = { sourceImageSrc: string; aspectRatio?: string; imageSize?: string; - priceMudPoints?: number; projectId?: string | null; generationInputs?: EditorAssetGenerationInputs | null; assetFolderId?: string | null; @@ -248,7 +246,6 @@ export type EditorCharacterAnimationGenerationInput = { ratio: EditorCharacterAnimationRatio; frameCount: EditorCharacterAnimationFrameCount; durationSeconds: EditorCharacterAnimationDurationSeconds; - priceMudPoints: number; model: 'seedance2.0-fast'; }; @@ -281,7 +278,13 @@ export type EditorVideoModel = export type EditorVideoResolution = '480p' | '720p' | '1080p'; export type EditorVideoSoundMode = 'on' | 'off'; -export type EditorVideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9'; +export type EditorVideoAspectRatio = + | '16:9' + | '9:16' + | '1:1' + | '4:3' + | '3:4' + | '21:9'; export type EditorVideoGenerationInput = { prompt: string; @@ -292,7 +295,6 @@ export type EditorVideoGenerationInput = { mode: 'std'; sound: EditorVideoSoundMode; webSearchEnabled?: boolean; - priceMudPoints: number; referenceImageSrcs?: string[]; referenceVideoSrcs?: string[]; referenceAudioSrcs?: string[]; @@ -317,13 +319,11 @@ export type EditorSoundEffectGenerationInput = { prompt: string; model: 'audio1.0'; duration: number; - priceMudPoints: number; }; export type EditorBackgroundMusicGenerationInput = { gptDescriptionPrompt: string; makeInstrumental: true; - priceMudPoints: number; }; export type EditorAudioGenerationResult = { @@ -694,7 +694,6 @@ export async function generateEditorIconSpritesheet( model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2, ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), - priceMudPoints: input.priceMudPoints, ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.generationInputs ? { generationInputs: input.generationInputs } @@ -728,9 +727,6 @@ export async function extractEditorUiDesignAssets( : {}), ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), - ...(typeof input.priceMudPoints === 'number' - ? { priceMudPoints: input.priceMudPoints } - : {}), }), 'æå–UI设计图素æå¤±è´¥', { @@ -799,7 +795,6 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) { mode: input.mode, sound: input.sound, webSearchEnabled: input.webSearchEnabled ?? true, - priceMudPoints: input.priceMudPoints, ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}), @@ -829,7 +824,6 @@ export async function generateEditorSoundEffect( prompt: input.prompt, model: input.model, duration: input.duration, - priceMudPoints: input.priceMudPoints, }), 'ç”Ÿæˆæ¸¸æˆéŸ³æ•ˆå¤±è´¥', { @@ -849,7 +843,6 @@ export async function generateEditorBackgroundMusic( jsonRequest('POST', { gptDescriptionPrompt: input.gptDescriptionPrompt, makeInstrumental: input.makeInstrumental, - priceMudPoints: input.priceMudPoints, }), 'ç”Ÿæˆæ¸¸æˆèƒŒæ™¯éŸ³ä¹å¤±è´¥', {