修复画布音频改造面板
修复音效素材点击改造后生成面板被选中逻辑关闭的问题 补充音频改造集成回归测试,确保不显示参考图且恢复提示词和时长 统一重绘按钮泥点展示并补充价格配置约束文档 收紧 Vidu 音效生成时长校验,避免静默夹取非法时长
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -87,6 +87,11 @@ fn normalize_editor_sound_duration(value: u8) -> Result<u8, AppError> {
|
||||
Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间"))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_creation_sound_effect_duration(value: Option<u8>) -> Result<u8, AppError> {
|
||||
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<NormalizedEditorBackgroundMusicRequest, AppError> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"], "金币掉落叮当声");
|
||||
|
||||
@@ -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(<ImageCanvasEditorView />);
|
||||
|
||||
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',
|
||||
|
||||
@@ -136,6 +136,25 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
expect(screen.getByLabelText('面板状态').textContent).toBe('closed');
|
||||
});
|
||||
|
||||
it('shows configured mud point price inside redraw submit button', () => {
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
initialPanel={{
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '重绘提示',
|
||||
size: '1024x1024',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
mode: 'redraw',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders extra references before the source image and removes references from the slot', () => {
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
|
||||
@@ -225,8 +225,6 @@ export function ImageCanvasQuickEditPanelView({
|
||||
>
|
||||
{isGenerating ? (
|
||||
'生成中'
|
||||
) : isRedraw ? (
|
||||
'生成'
|
||||
) : (
|
||||
<>
|
||||
<span>生成</span>
|
||||
|
||||
@@ -191,6 +191,9 @@ function GenerationWorkflowHarness({
|
||||
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="character-animation-price">
|
||||
{workflow.characterAnimationPrice}
|
||||
</span>
|
||||
<span data-testid="fit-count">
|
||||
{fitLayersMockRef.current.mock.calls.length}
|
||||
</span>
|
||||
@@ -424,6 +427,19 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates character animation button price from the model pricing config', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
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(
|
||||
<GenerationWorkflowHarness
|
||||
|
||||
@@ -56,10 +56,12 @@ import {
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
calculateCharacterAnimationPrice,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
isCanvasGenerationDialog,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
@@ -282,7 +284,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
: null;
|
||||
const characterAnimationPrice = effectiveCharacterAnimationPanel
|
||||
? calculateCharacterAnimationPrice(
|
||||
'seedance2.0-fast',
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
effectiveCharacterAnimationPanel.resolution,
|
||||
effectiveCharacterAnimationPanel.durationSeconds,
|
||||
)
|
||||
@@ -485,7 +487,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
createUiDesignGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
imageModel: 'gpt-image-2',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
}),
|
||||
);
|
||||
activateCanvasGenerationEntry('ui-design');
|
||||
@@ -594,8 +596,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
return;
|
||||
}
|
||||
setQuickEditPanel(null);
|
||||
openCanvasGenerationDialog(audioDraft);
|
||||
selectSingleLayer(sourceLayer.id);
|
||||
openCanvasGenerationDialog(audioDraft);
|
||||
setActiveTool('music');
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user