完成 SFX V2 T5 正式生成链路
接通 Luna 英文化、ElevenLabs 单次生成、MP3 校验、OSS 与权威元数据写回 演进站内和 External v1 契约、幂等参数、详情重绘与 Agent 音效模型 新增 ElevenLabs 动态定价键及历史定价快照兼容 同步 OpenAPI、外部 Agent Skill、共享计划与决策记录
This commit is contained in:
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user