diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7fb0ad280..29cad5f10 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置 + +- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。 +- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动画、图标素材、音效和背景音乐也使用同一函数。后端用 `server-rs/crates/api-server/src/editor_generation_config.rs` 的同名语义配置重新计算并校验 / 扣费。当前正式模型均已覆盖定价;`nanobanana2`、`nano-banana` 只作为 UI 显示名或历史别名,不作为正式模型 ID 写入定价表。 +- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config` 和 Lovart 生成类面板文档。 +- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 @@ -35,7 +43,7 @@ ## 2026-06-18 图片画布生成音乐入口作为音频图层接入 - 背景:图片画布底部生成工具需要补齐游戏音效和游戏背景音乐生成,既要复用现有 Lovart 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。 -- 决策:`/editor/canvas` 新增底部 `生成音乐` 入口,点击后先弹出“生成游戏音效 / 生成游戏背景音乐”选项框,再分别创建 `audio-sound-effect` 或 `audio-background-music` 生成器;生成结果作为 `mediaType="audio"` 的画布音频卡保存,`assetKind` 分别为 `sound-effect` / `background-music`。音效请求字段固定映射 `sound/type/tempo`,背景音乐请求字段固定映射 `gpt_description_prompt` 且 `make_instrumental=true`。 +- 决策:`/editor/canvas` 新增底部 `生成音乐` 入口,点击后先弹出“生成游戏音效 / 生成游戏背景音乐”选项框,再分别创建 `audio-sound-effect` 或 `audio-background-music` 生成器;生成结果作为 `mediaType="audio"` 的画布音频卡保存,`assetKind` 分别为 `sound-effect` / `background-music`。音效请求字段固定映射 Vidu `prompt/model/duration`,模型固定 `audio1.0`、时长严格 `2-10` 秒;背景音乐请求字段固定映射 `gpt_description_prompt` 且 `make_instrumental=true`。 - 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`platform-audio`、`api-server` 编辑器音频 BFF、图片画布技术方案和音乐生成入口设计文档。 - 验证方式:运行编辑器生成入口 / 提交 / 音频图层相关前端测试,`platform-audio` 请求体测试,`shared-contracts` editor audio 序列化测试,`api-server` editor audio 归一化测试,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index a7a88bf1b..d2000cfe7 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -71,8 +71,13 @@ - 本次消耗泥点必须显示在生成按钮内部。 - 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。 -- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。 +- 画板内所有会提交外部生成任务的生成按钮,价格都必须从模型定价配置函数推导,不允许在按钮文案或提交 payload 中散落固定泥点数字;修改 `ImageCanvasGenerationModel.ts` 与 `api-server/src/editor_generation_config.rs` 的同名定价配置后,按钮展示、前端提交的 `priceMudPoints` 和后端校验 / 扣费应同步变化。 +- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动画走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。 +- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。当前不是运行时动态配置接口,若要后台改价实时影响前端,需要新增配置下发能力。 - 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。 +- 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。 +- `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。 +- 历史 / 测试中出现的 `nanobanana2`、`nano-banana` 只是 UI 显示名或旧别名,不是正式上游模型 ID;正式图片模型 ID 以 `gemini-3.1-flash-image-preview` 和 `gpt-image-2` 为准。历史别名若继续进入计算函数,只能回落到默认图片模型价格,不能作为新增正式模型绕过定价表。 ## 画布占位落点 @@ -121,6 +126,17 @@ 生成背景音乐:5 泥点 ``` +当前必须显式覆盖的正式模型定价配置: + +- 图片类:`gemini-3.1-flash-image-preview`、`gpt-image-2`。 +- 规范:`gpt-image-2`。 +- 视频:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`;兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留定价配置,但前端模型菜单不展示。 +- 角色动画:`seedance2.0-fast`。 +- 音效:`audio1.0`。 +- 背景音乐:`chirp-v5`。 + +本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、前端提交计算、后端校验配置和对应测试,再出现在任一模型选择框中。 + ## 生成视频模型与接口 - 底部 `生成视频` 面板提交到 `POST /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 4d8d999ad..e0cb77c12 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 @@ -87,6 +87,11 @@ fn normalize_editor_sound_duration(value: u8) -> Result { Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间")) } +pub(super) fn normalize_creation_sound_effect_duration(value: Option) -> Result { + let duration = value.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS); + normalize_editor_sound_duration(duration) +} + pub(super) fn normalize_editor_background_music_request( payload: assets::EditorBackgroundMusicGenerateRequest, ) -> Result { diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs index 8fd56e60d..5d3ee628b 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs @@ -14,6 +14,7 @@ use crate::{ use super::{ errors::{map_platform_audio_error, parse_json_payload}, + generation::normalize_creation_sound_effect_duration, publish::publish_generated_audio_asset, settings::require_vector_engine_audio_settings, targets::{ @@ -82,10 +83,8 @@ pub async fn create_visual_novel_sound_effect_task( &settings, SoundEffectTaskRequest { prompt: payload.prompt, - duration: payload - .duration - .unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS) - .clamp(2, 10), + duration: normalize_creation_sound_effect_duration(payload.duration) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?, seed: payload.seed, }, ) diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs index fec797c0c..8a17b5466 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs @@ -3,7 +3,10 @@ use shared_contracts::creation_audio; use crate::{http_error::AppError, state::AppState}; -use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings}; +use super::{ + errors::map_platform_audio_error, generation::normalize_creation_sound_effect_duration, + settings::require_vector_engine_audio_settings, +}; pub(super) async fn create_sound_effect_task_response( state: &AppState, @@ -19,9 +22,7 @@ pub(super) async fn create_sound_effect_task_response( &settings, SoundEffectTaskRequest { prompt, - duration: duration - .unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS) - .clamp(2, 10), + duration: normalize_creation_sound_effect_duration(duration)?, seed, }, ) 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 4270ec6b6..d27925d14 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 @@ -4,7 +4,8 @@ use shared_contracts::{assets, creation_audio}; use super::{ generation::{ - normalize_editor_background_music_request, normalize_editor_sound_effect_request, + normalize_creation_sound_effect_duration, normalize_editor_background_music_request, + normalize_editor_sound_effect_request, }, publish::resolve_creation_audio_points_cost, targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target}, @@ -138,6 +139,28 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() { } } +#[test] +fn creation_sound_effect_duration_rejects_outside_vidu_range() { + assert_eq!( + normalize_creation_sound_effect_duration(None).expect("default duration should pass"), + platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS + ); + assert_eq!( + normalize_creation_sound_effect_duration(Some(2)).expect("2 seconds should pass"), + 2 + ); + assert_eq!( + normalize_creation_sound_effect_duration(Some(10)).expect("10 seconds should pass"), + 10 + ); + + for duration in [Some(1), Some(11)] { + let error = normalize_creation_sound_effect_duration(duration) + .expect_err("duration outside Vidu 2-10 seconds should fail"); + assert!(error.to_string().contains("2-10")); + } +} + #[test] fn editor_background_music_request_forces_instrumental_and_price() { let normalized = diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index b31dd2cd6..ad2d36059 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -1,6 +1,6 @@ use platform_audio::{ - AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, - EditorSoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, + EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, audio_mime_to_extension, build_background_music_task_body, build_editor_background_music_task_body, build_editor_sound_effect_task_body, build_sound_effect_task_body, extract_audio_urls, is_failed_task_status, @@ -135,7 +135,10 @@ fn vidu_sound_effect_request_body_uses_text2audio_contract() { }) .expect("Vidu sound effect body should be valid"); - assert_eq!(AudioTaskKind::SoundEffect.submit_path(), "/ent/v2/text2audio"); + assert_eq!( + AudioTaskKind::SoundEffect.submit_path(), + "/ent/v2/text2audio" + ); assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu"); assert_eq!(body["model"], VIDU_AUDIO_MODEL); assert_eq!(body["prompt"], "金币掉落叮当声"); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 17f488673..75d87fbe6 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -610,6 +610,65 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); + it('opens the matching audio generation panel when remodeling an existing sound effect layer', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-audio-remodel', + title: '音频改造画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-sound-effect', + resourceId: 'resource-sound-effect', + title: '游戏音效', + src: '/generated-character-drafts/editor-audios/sound.wav', + mediaType: 'audio', + assetKind: 'sound-effect', + x: 120, + y: 140, + width: 420, + height: 120, + originalWidth: 420, + originalHeight: 120, + zIndex: 2, + sourceType: 'generated', + prompt: '金币跳出音', + model: 'audio1.0', + generationInputs: { + fields: [ + { title: '音效提示词', value: '金币跳出音' }, + { title: 'model', value: 'audio1.0' }, + { title: 'duration', value: '8秒' }, + ], + references: [ + { label: '历史参考图', src: '/creation-type-references/puzzle.webp' }, + ], + }, + }, + ], + resources: [], + updatedAt: '2026-06-19T00:00:00.000Z', + }); + render(); + + const audioLayer = await screen.findByRole('button', { + name: '选择游戏音效', + }); + fireEvent.pointerDown(audioLayer, { + button: 0, + pointerId: 54, + clientX: 160, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '改造' })); + + const panel = screen.getByRole('dialog', { name: '生成游戏音效' }); + expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '音效时长 8秒' }), + ).toBeTruthy(); + expect(panel.querySelector('.image-canvas-editor__reference-strip')).toBeNull(); + }); + it('deletes the selected audio layer with Backspace from the audio control focus', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-audio-delete', diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 815d114e7..f2631d588 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -136,6 +136,25 @@ describe('ImageCanvasQuickEditPanelView', () => { expect(screen.getByLabelText('面板状态').textContent).toBe('closed'); }); + it('shows configured mud point price inside redraw submit button', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( + '生成12泥点', + ); + }); + it('renders extra references before the source image and removes references from the slot', () => { render( {isGenerating ? ( '生成中' - ) : isRedraw ? ( - '生成' ) : ( <> 生成 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 0cbbf6e74..7406583cc 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -191,6 +191,9 @@ function GenerationWorkflowHarness({ ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` : '-'} + + {workflow.characterAnimationPrice} + {fitLayersMockRef.current.mock.calls.length} @@ -424,6 +427,19 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('calculates character animation button price from the model pricing config', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); + + expect(screen.getByTestId('character-animation').textContent).toBe( + 'layer-character:idle', + ); + expect(screen.getByTestId('character-animation-price').textContent).toBe( + '40', + ); + }); + it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => { render(