完成 SFX V2 T5 正式生成链路
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Native shell tests (pull_request) Successful in 13m43s

接通 Luna 英文化、ElevenLabs 单次生成、MP3 校验、OSS 与权威元数据写回
演进站内和 External v1 契约、幂等参数、详情重绘与 Agent 音效模型
新增 ElevenLabs 动态定价键及历史定价快照兼容
同步 OpenAPI、外部 Agent Skill、共享计划与决策记录
This commit is contained in:
2026-08-07 06:13:34 +00:00
parent 44ac7f1252
commit 392741215c
37 changed files with 958 additions and 320 deletions
@@ -376,6 +376,46 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores authoritative SFX V2 prompt, duration mode, and loop on redraw', () => {
const draft = createAudioRedrawGenerationDialogDraft(
createLayer({
id: 'layer-sfx-v2',
mediaType: 'audio',
assetKind: 'sound-effect',
prompt: '不应覆盖权威用户描述',
generationInputs: {
fields: [
{ title: '用户描述', value: '金币落地' },
{ title: '实际英文提示词', value: 'A bright coin landing chime' },
{ title: '时长', value: '7.42秒' },
{ title: 'Loop', value: '开启' },
],
references: [],
soundEffect: {
schemaVersion: 2,
userPrompt: '金币落地',
actualPrompt: 'A bright coin landing chime',
model: 'eleven_text_to_sound_v2',
durationMode: 'auto',
requestedDurationSeconds: null,
actualDurationSeconds: 7.42,
loop: true,
},
},
}),
);
expect(draft).toMatchObject({
mode: 'audio-sound-effect',
sourceLayerId: 'layer-sfx-v2',
prompt: '金币落地',
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 5,
soundLoop: true,
});
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。',
@@ -472,6 +472,7 @@ const USER_PROMPT_INPUT_TITLES = new Set(
'快速编辑提示词',
'重绘提示词',
'动作描述',
'用户描述',
].map((title) => title.toLowerCase()),
);
@@ -508,6 +509,14 @@ function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) {
}
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
const soundEffect = sourceLayer.generationInputs?.soundEffect;
if (
sourceLayer.assetKind === 'sound-effect' &&
soundEffect?.schemaVersion === 2 &&
soundEffect.userPrompt.trim()
) {
return soundEffect.userPrompt;
}
return resolveUserGenerationPromptSnapshot(sourceLayer);
}
@@ -534,6 +543,17 @@ function resolveAudioRedrawSoundModel(
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
const soundEffect = sourceLayer.generationInputs?.soundEffect;
if (soundEffect?.schemaVersion === 2) {
if (
soundEffect.durationMode === 'manual' &&
typeof soundEffect.requestedDurationSeconds === 'number' &&
Number.isFinite(soundEffect.requestedDurationSeconds)
) {
return soundEffect.requestedDurationSeconds;
}
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'时长',
'duration',
@@ -582,9 +602,16 @@ export function createAudioRedrawGenerationDialogDraft(
return {
...baseDraft,
soundModel: resolveAudioRedrawSoundModel(sourceLayer),
soundDurationMode: 'manual',
soundDurationMode:
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2 &&
sourceLayer.generationInputs.soundEffect.durationMode === 'auto'
? 'auto'
: 'manual',
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
soundLoop: false,
soundLoop:
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2
? sourceLayer.generationInputs.soundEffect.loop
: false,
};
}
@@ -512,7 +512,8 @@ export function createAudioResultLayer({
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
sourceAssetId: asset?.assetId,
generationInputs: resource?.generationInputs ?? generationInputs,
generationInputs:
resource?.generationInputs ?? asset?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset ?? undefined,
};
}
@@ -541,12 +541,18 @@ describe('ImageCanvasGenerationModel', () => {
'Quick Edit Generator',
);
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
buildSoundEffectGenerationInputs(
'金币掉落叮当声',
'eleven_text_to_sound_v2',
7,
true,
),
).toEqual({
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: '时长', value: '7秒' },
{ title: '用户描述', value: '金币掉落叮当声' },
{ title: 'model', value: 'eleven_text_to_sound_v2' },
{ title: '请求时长', value: '7秒' },
{ title: 'Loop', value: '开启' },
],
references: [],
});
@@ -965,9 +965,7 @@ export function calculateEditorSoundEffectPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
// T4 只切前端固定模型展示;新模型的动态定价键由 T5 同批接入。中间态继续以
// 历史 Vidu 的 5 泥点前端兜底展示,服务端价格始终是正式真相。
return readModelFlatPrice(normalizedModel, SOUND_EFFECT_MODEL_VIDU);
return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL);
}
export function calculateEditorBackgroundMusicPrice(
@@ -1160,13 +1158,18 @@ export function buildVideoGenerationInputs(
export function buildSoundEffectGenerationInputs(
prompt: string,
model: string,
duration: number,
duration: number | null,
loop: boolean,
): CanvasGenerationInputs {
return {
fields: [
...createGenerationInputField('prompt', prompt),
...createGenerationInputField('用户描述', prompt),
...createGenerationInputField('model', model),
...createGenerationInputField('时长', `${duration}秒`),
...createGenerationInputField(
'请求时长',
duration === null ? '自动' : `${duration}秒`,
),
...createGenerationInputField('Loop', loop ? '开启' : '关闭'),
],
references: [],
};
@@ -1015,6 +1015,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 金币掉落叮当声 ',
status: 'idle',
soundDurationSeconds: 7,
soundDurationMode: 'manual',
},
layers: [],
nextGeneratedIndex: 4,
@@ -1034,9 +1035,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
title: '游戏音效 4',
generationInputs: {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: '用户描述', value: '金币掉落叮当声' },
{ title: 'model', value: 'eleven_text_to_sound_v2' },
{ title: '时长', value: '7秒' },
{ title: '请求时长', value: '7秒' },
{ title: 'Loop', value: '关闭' },
],
references: [],
},
@@ -1044,6 +1046,48 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('preserves frozen auto duration, loop, and manual decimal precision', () => {
const automatic = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '环境循环音',
status: 'idle',
soundDurationMode: 'auto',
soundDurationSeconds: 1.23456789,
soundLoop: true,
},
layers: [],
nextGeneratedIndex: 1,
});
expect(automatic).toMatchObject({
input: { duration: null, loop: true },
result: {
generationInputs: {
fields: expect.arrayContaining([
{ title: '请求时长', value: '自动' },
{ title: 'Loop', value: '开启' },
]),
},
},
});
const manual = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '精确时长音效',
status: 'idle',
soundDurationMode: 'manual',
soundDurationSeconds: 1.23456789,
soundLoop: false,
},
layers: [],
nextGeneratedIndex: 2,
});
expect(manual).toMatchObject({
input: { duration: 1.23456789, loop: false },
});
});
it('rejects an all-whitespace sound effect prompt without a fallback', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
@@ -545,9 +545,12 @@ export function buildImageGenerationSubmissionPlan({
if (dialog.mode === 'audio-sound-effect') {
const soundModel = EDITOR_SOUND_EFFECT_MODEL;
const durationSeconds =
typeof dialog.soundDurationSeconds === 'number'
? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds)))
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
dialog.soundDurationMode === 'auto'
? null
: typeof dialog.soundDurationSeconds === 'number'
? dialog.soundDurationSeconds
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
const loop = dialog.soundLoop === true;
return {
kind: 'audio',
audioKind: 'sound-effect',
@@ -556,7 +559,7 @@ export function buildImageGenerationSubmissionPlan({
prompt: normalizedPrompt,
model: soundModel,
duration: durationSeconds,
loop: false,
loop,
},
result: {
title: resolveGenerationAssetLabel(
@@ -567,6 +570,7 @@ export function buildImageGenerationSubmissionPlan({
normalizedPrompt,
soundModel,
durationSeconds,
loop,
),
},
};
@@ -260,6 +260,61 @@ describe('ImageCanvasMetadataModalView', () => {
expect(within(dialog).queryByText('420 x 120 px')).toBeNull();
});
it('renders SFX V2 authoritative prompts, actual duration, loop, model, and full task id', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '金币音效',
src: '/generated-character-drafts/editor-audios/coin.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
originalWidth: 420,
originalHeight: 120,
model: 'eleven_text_to_sound_v2',
taskId: 'task-sfx-v2-1234-abcd',
generationInputs: {
fields: [
{ title: '用户描述', value: '金币落地' },
{
title: '实际英文提示词',
value: 'A bright coin landing chime',
},
{ title: '时长', value: '7.42秒' },
{ title: 'Loop', value: '开启' },
],
references: [],
soundEffect: {
schemaVersion: 2,
userPrompt: '金币落地',
actualPrompt: 'A bright coin landing chime',
model: 'eleven_text_to_sound_v2',
durationMode: 'auto',
requestedDurationSeconds: null,
actualDurationSeconds: 7.42,
loop: true,
},
},
})}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '音频信息' });
for (const text of [
'用户描述',
'金币落地',
'实际英文提示词',
'A bright coin landing chime',
'7.42秒',
'Loop',
'开启',
'eleven_text_to_sound_v2',
'task-sfx-v2-1234-abcd',
]) {
expect(within(dialog).getByText(text)).toBeTruthy();
}
});
it('does not render a dialog when no layer is selected', () => {
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
@@ -105,7 +105,11 @@ export function ImageCanvasMetadataModalView({
</>
) : null}
<dt>Task</dt>
<dd>{formatTaskIdForDisplay(layer.taskId)}</dd>
<dd>
{layer.generationInputs?.soundEffect?.schemaVersion === 2
? layer.taskId || '-'
: formatTaskIdForDisplay(layer.taskId)}
</dd>
</dl>
) : null}
</UnifiedModal>
@@ -1655,12 +1655,13 @@ describe('useImageCanvasGenerationWorkflow', () => {
height: 120,
sourceType: 'generated',
prompt: '金币跳出音',
actualPrompt: '金币跳出音',
model: 'audio1.0',
provider: 'VectorEngine',
actualPrompt: 'A bright coin pop sound',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'task-sound',
audioKind: 'sound-effect',
durationSeconds: 4,
durationSeconds: 7.42,
loop: false,
});
render(<GenerationWorkflowHarness />);
@@ -1673,11 +1674,13 @@ describe('useImageCanvasGenerationWorkflow', () => {
prompt: '金币跳出音',
model: 'eleven_text_to_sound_v2',
duration: 8,
loop: false,
generationInputs: {
fields: [
{ title: 'prompt', value: '金币跳出音' },
{ title: '用户描述', value: '金币跳出音' },
{ title: 'model', value: 'eleven_text_to_sound_v2' },
{ title: '时长', value: '8秒' },
{ title: '请求时长', value: '8秒' },
{ title: 'Loop', value: '关闭' },
],
references: [],
},
@@ -106,6 +106,7 @@ describe('editorProjectClient', () => {
prices: { '480p': 13, '720p': 26, '1080p': 52 },
},
'audio1.0': { unit: 'perGeneration', price: 15 },
eleven_text_to_sound_v2: { unit: 'perGeneration', price: 16 },
'chirp-v5': { unit: 'perGeneration', price: 9 },
},
});
@@ -113,6 +114,7 @@ describe('editorProjectClient', () => {
const pricing = await loadEditorGenerationPricing();
expect(pricing.models['gpt-image-2']?.prices?.['1K']).toBe(29);
expect(pricing.models.eleven_text_to_sound_v2?.price).toBe(16);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/generation-pricing',
{ method: 'GET' },
@@ -1797,10 +1799,7 @@ describe('editorProjectClient', () => {
}),
}),
'生成游戏音效失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: editorRetryOptionsExpectation,
}),
{ timeoutMs: 1_200_000 },
);
});
@@ -1396,7 +1396,6 @@ export async function generateEditorSoundEffect(
'生成游戏音效失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS,
},
);
}