合并编辑器素材库能力
合并 codex/editor-asset-library 到宣发素材演示分支 保留素材库、生成器、音频、UI设计图和持久化相关能力 补回宣发素材入口与 5 泥点计费契约
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorAsset,
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
deleteEditorAssetFolder,
|
||||
deleteEditorProject,
|
||||
editEditorImage,
|
||||
generateEditorBackgroundMusic,
|
||||
generateEditorCharacterAnimation,
|
||||
generateEditorIconSpritesheet,
|
||||
generateEditorImage,
|
||||
generateEditorSoundEffect,
|
||||
generateEditorVideo,
|
||||
listEditorProjects,
|
||||
loadEditorAssetLibrary,
|
||||
loadEditorProject,
|
||||
@@ -588,11 +591,13 @@ 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');
|
||||
@@ -605,6 +610,7 @@ describe('editorProjectClient', () => {
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
priceMudPoints: 12,
|
||||
}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
@@ -626,6 +632,7 @@ describe('editorProjectClient', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-spritesheet-task-2',
|
||||
priceMudPoints: 12,
|
||||
});
|
||||
|
||||
await generateEditorIconSpritesheet({
|
||||
@@ -634,6 +641,7 @@ describe('editorProjectClient', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 12,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
@@ -647,6 +655,7 @@ describe('editorProjectClient', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 12,
|
||||
}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
@@ -742,7 +751,7 @@ describe('editorProjectClient', () => {
|
||||
it('generates editor character animations through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
taskId: 'character-animation-1',
|
||||
model: 'seedance2.0',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '生成游戏角色动画\n动作描述:\n待机',
|
||||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||||
frames: [
|
||||
@@ -770,7 +779,7 @@ describe('editorProjectClient', () => {
|
||||
frameCount: 32,
|
||||
durationSeconds: 4,
|
||||
priceMudPoints: 40,
|
||||
model: 'seedance2.0',
|
||||
model: 'seedance2.0-fast',
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('character-animation-1');
|
||||
@@ -790,7 +799,7 @@ describe('editorProjectClient', () => {
|
||||
frameCount: 32,
|
||||
durationSeconds: 4,
|
||||
priceMudPoints: 40,
|
||||
model: 'seedance2.0',
|
||||
model: 'seedance2.0-fast',
|
||||
}),
|
||||
}),
|
||||
'生成角色动画失败',
|
||||
@@ -801,6 +810,242 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor videos through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
videoSrc: '/generated-editor-videos/video-task-1/preview.mp4',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceType: 'generated',
|
||||
prompt: '让角色向镜头挥手',
|
||||
actualPrompt: '让角色向镜头挥手',
|
||||
model: 'kling3.0-omni',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'video-task-1',
|
||||
durationSeconds: 5,
|
||||
resolution: '480p',
|
||||
priceMudPoints: 50,
|
||||
});
|
||||
|
||||
const result = await generateEditorVideo({
|
||||
prompt: '让角色向镜头挥手',
|
||||
model: 'kling3.0-omni',
|
||||
aspectRatio: '16:9',
|
||||
durationSeconds: 5,
|
||||
resolution: '480p',
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
priceMudPoints: 50,
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('video-task-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/videos/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: '让角色向镜头挥手',
|
||||
model: 'kling3.0-omni',
|
||||
aspectRatio: '16:9',
|
||||
durationSeconds: 5,
|
||||
resolution: '480p',
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
priceMudPoints: 50,
|
||||
}),
|
||||
}),
|
||||
'生成视频失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('passes seedance video multimodal references through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
videoSrc: '/generated-editor-videos/video-task-2/preview.mp4',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceType: 'generated',
|
||||
prompt: '参考素材生成视频',
|
||||
actualPrompt: '参考素材生成视频',
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'video-task-2',
|
||||
durationSeconds: 5,
|
||||
resolution: '720p',
|
||||
priceMudPoints: 100,
|
||||
});
|
||||
|
||||
await generateEditorVideo({
|
||||
prompt: '参考素材生成视频',
|
||||
model: 'seedance2.0-fast',
|
||||
aspectRatio: '16:9',
|
||||
durationSeconds: 5,
|
||||
resolution: '720p',
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
priceMudPoints: 100,
|
||||
referenceImageSrcs: ['data:image/png;base64,image'],
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/seedance-references/video/video.mp4',
|
||||
],
|
||||
referenceAudioSrcs: [
|
||||
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
|
||||
],
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/videos/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: '参考素材生成视频',
|
||||
model: 'seedance2.0-fast',
|
||||
aspectRatio: '16:9',
|
||||
durationSeconds: 5,
|
||||
resolution: '720p',
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
priceMudPoints: 100,
|
||||
referenceImageSrcs: ['data:image/png;base64,image'],
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/seedance-references/video/video.mp4',
|
||||
],
|
||||
referenceAudioSrcs: [
|
||||
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
'生成视频失败',
|
||||
expect.objectContaining({ timeoutMs: 1_200_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor sound effects through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
prompt: '金币掉落叮当声',
|
||||
actualPrompt: '金币掉落叮当声',
|
||||
model: 'audio1.0',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'sound-task-1',
|
||||
priceMudPoints: 10,
|
||||
audioKind: 'sound-effect',
|
||||
});
|
||||
|
||||
const result = await generateEditorSoundEffect({
|
||||
sound: '金币掉落叮当声',
|
||||
type: 'one-shot',
|
||||
tempo: 120,
|
||||
priceMudPoints: 10,
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('sound-task-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/audios/sound-effects/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sound: '金币掉落叮当声',
|
||||
type: 'one-shot',
|
||||
tempo: 120,
|
||||
priceMudPoints: 10,
|
||||
}),
|
||||
}),
|
||||
'生成游戏音效失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps editor sound effect tempo as null when BPM is not selected', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
prompt: '按钮确认短促音',
|
||||
actualPrompt: '按钮确认短促音',
|
||||
model: 'audio1.0',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'sound-task-null',
|
||||
priceMudPoints: 10,
|
||||
audioKind: 'sound-effect',
|
||||
});
|
||||
|
||||
await generateEditorSoundEffect({
|
||||
sound: '按钮确认短促音',
|
||||
type: 'one-shot',
|
||||
tempo: null,
|
||||
priceMudPoints: 10,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/audios/sound-effects/generations',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
sound: '按钮确认短促音',
|
||||
type: 'one-shot',
|
||||
tempo: null,
|
||||
priceMudPoints: 10,
|
||||
}),
|
||||
}),
|
||||
'生成游戏音效失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates editor background music through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
prompt: '森林冒险背景音乐',
|
||||
actualPrompt: '森林冒险背景音乐',
|
||||
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');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/audios/background-music/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gptDescriptionPrompt: '森林冒险背景音乐',
|
||||
makeInstrumental: true,
|
||||
priceMudPoints: 5,
|
||||
}),
|
||||
}),
|
||||
'生成游戏背景音乐失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits editor images through the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,edited',
|
||||
|
||||
@@ -8,6 +8,11 @@ const EDITOR_ICON_SPRITESHEET_GENERATION_API =
|
||||
'/api/editor/icon-spritesheets/generations';
|
||||
const EDITOR_CHARACTER_ANIMATION_GENERATION_API =
|
||||
'/api/editor/character-animations/generations';
|
||||
const EDITOR_VIDEO_GENERATION_API = '/api/editor/videos/generations';
|
||||
const EDITOR_SOUND_EFFECT_GENERATION_API =
|
||||
'/api/editor/audios/sound-effects/generations';
|
||||
const EDITOR_BACKGROUND_MUSIC_GENERATION_API =
|
||||
'/api/editor/audios/background-music/generations';
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||
|
||||
@@ -83,11 +88,15 @@ export type EditorAssetLibrarySnapshot = {
|
||||
export type EditorImageGenerationInput = {
|
||||
prompt: string;
|
||||
size?: string;
|
||||
kind?: 'spec' | 'character' | 'quick-edit' | 'publication-material';
|
||||
kind?:
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'quick-edit'
|
||||
| 'ui-design'
|
||||
| 'publication-material';
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
candidateCount?: number;
|
||||
referenceImageSrcs?: string[];
|
||||
};
|
||||
|
||||
@@ -97,6 +106,7 @@ export type EditorIconSpritesheetGenerationInput = {
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
priceMudPoints: number;
|
||||
};
|
||||
|
||||
export type EditorImageEditInput = {
|
||||
@@ -106,7 +116,7 @@ export type EditorImageEditInput = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationItemResult = {
|
||||
export type EditorImageGenerationResult = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
@@ -120,10 +130,6 @@ export type EditorImageGenerationItemResult = {
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = EditorImageGenerationItemResult & {
|
||||
images?: EditorImageGenerationItemResult[];
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetIconResult = {
|
||||
name: string;
|
||||
imageSrc: string;
|
||||
@@ -141,6 +147,7 @@ export type EditorIconSpritesheetGenerationResult = {
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
priceMudPoints: number;
|
||||
};
|
||||
|
||||
export type EditorCharacterAnimationResolution = '480p' | '720p';
|
||||
@@ -168,7 +175,7 @@ export type EditorCharacterAnimationGenerationInput = {
|
||||
frameCount: EditorCharacterAnimationFrameCount;
|
||||
durationSeconds: EditorCharacterAnimationDurationSeconds;
|
||||
priceMudPoints: number;
|
||||
model: 'seedance2.0';
|
||||
model: 'seedance2.0-fast';
|
||||
};
|
||||
|
||||
export type EditorCharacterAnimationFrameResult = {
|
||||
@@ -180,7 +187,7 @@ export type EditorCharacterAnimationFrameResult = {
|
||||
|
||||
export type EditorCharacterAnimationGenerationResult = {
|
||||
taskId: string;
|
||||
model: 'seedance2.0';
|
||||
model: 'seedance2.0-fast';
|
||||
prompt: string;
|
||||
previewVideoPath: string;
|
||||
frames: EditorCharacterAnimationFrameResult[];
|
||||
@@ -190,6 +197,68 @@ export type EditorCharacterAnimationGenerationResult = {
|
||||
priceMudPoints: number;
|
||||
};
|
||||
|
||||
export type EditorVideoModel =
|
||||
| 'seedance2.0'
|
||||
| 'seedance2.0-fast'
|
||||
| 'kling3.0'
|
||||
| 'kling3.0-omni'
|
||||
| 'veo3.1'
|
||||
| 'veo3.1-fast';
|
||||
|
||||
export type EditorVideoGenerationInput = {
|
||||
prompt: string;
|
||||
model: EditorVideoModel;
|
||||
aspectRatio: '16:9';
|
||||
durationSeconds: 4 | 5;
|
||||
resolution: '480p' | '720p';
|
||||
mode: 'std';
|
||||
sound: 'off';
|
||||
priceMudPoints: number;
|
||||
referenceImageSrcs?: string[];
|
||||
referenceVideoSrcs?: string[];
|
||||
referenceAudioSrcs?: string[];
|
||||
};
|
||||
|
||||
export type EditorVideoGenerationResult = {
|
||||
videoSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
prompt: string;
|
||||
actualPrompt?: string | null;
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
priceMudPoints?: number;
|
||||
};
|
||||
|
||||
export type EditorSoundEffectGenerationInput = {
|
||||
sound: string;
|
||||
type: 'one-shot' | 'loop';
|
||||
tempo: number | null;
|
||||
priceMudPoints: number;
|
||||
};
|
||||
|
||||
export type EditorBackgroundMusicGenerationInput = {
|
||||
gptDescriptionPrompt: string;
|
||||
makeInstrumental: true;
|
||||
priceMudPoints: number;
|
||||
};
|
||||
|
||||
export type EditorAudioGenerationResult = {
|
||||
audioSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
prompt: string;
|
||||
actualPrompt?: string | null;
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
priceMudPoints: number;
|
||||
audioKind: 'sound-effect' | 'background-music';
|
||||
};
|
||||
|
||||
export type EditorProjectSnapshot = {
|
||||
projectId: string;
|
||||
title: string;
|
||||
@@ -286,6 +355,8 @@ type EditorAssetResponse = {
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
type EditorIconSpritesheetGenerationResponse =
|
||||
EditorIconSpritesheetGenerationResult;
|
||||
type EditorVideoGenerationResponse = EditorVideoGenerationResult;
|
||||
type EditorAudioGenerationResponse = EditorAudioGenerationResult;
|
||||
|
||||
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -469,7 +540,6 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
...(input.candidateCount ? { candidateCount: input.candidateCount } : {}),
|
||||
...(input.referenceImageSrcs?.length
|
||||
? { referenceImageSrcs: input.referenceImageSrcs }
|
||||
: {}),
|
||||
@@ -495,6 +565,7 @@ 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,
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
{
|
||||
@@ -540,3 +611,76 @@ export async function generateEditorCharacterAnimation(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorVideo(input: EditorVideoGenerationInput) {
|
||||
return requestJson<EditorVideoGenerationResponse>(
|
||||
EDITOR_VIDEO_GENERATION_API,
|
||||
jsonRequest('POST', {
|
||||
prompt: input.prompt,
|
||||
model: input.model,
|
||||
aspectRatio: input.aspectRatio,
|
||||
durationSeconds: input.durationSeconds,
|
||||
resolution: input.resolution,
|
||||
mode: input.mode,
|
||||
sound: input.sound,
|
||||
priceMudPoints: input.priceMudPoints,
|
||||
...(input.referenceImageSrcs?.length
|
||||
? { referenceImageSrcs: input.referenceImageSrcs }
|
||||
: {}),
|
||||
...(input.referenceVideoSrcs?.length
|
||||
? { referenceVideoSrcs: input.referenceVideoSrcs }
|
||||
: {}),
|
||||
...(input.referenceAudioSrcs?.length
|
||||
? { referenceAudioSrcs: input.referenceAudioSrcs }
|
||||
: {}),
|
||||
}),
|
||||
'生成视频失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorSoundEffect(
|
||||
input: EditorSoundEffectGenerationInput,
|
||||
) {
|
||||
return requestJson<EditorAudioGenerationResponse>(
|
||||
EDITOR_SOUND_EFFECT_GENERATION_API,
|
||||
jsonRequest('POST', {
|
||||
sound: input.sound,
|
||||
type: input.type,
|
||||
tempo: input.tempo,
|
||||
priceMudPoints: input.priceMudPoints,
|
||||
}),
|
||||
'生成游戏音效失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorBackgroundMusic(
|
||||
input: EditorBackgroundMusicGenerationInput,
|
||||
) {
|
||||
return requestJson<EditorAudioGenerationResponse>(
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_API,
|
||||
jsonRequest('POST', {
|
||||
gptDescriptionPrompt: input.gptDescriptionPrompt,
|
||||
makeInstrumental: input.makeInstrumental,
|
||||
priceMudPoints: input.priceMudPoints,
|
||||
}),
|
||||
'生成游戏背景音乐失败',
|
||||
{
|
||||
timeoutMs: 1_200_000,
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { requestJson } from '../apiClient';
|
||||
import { getSignedAssetReadUrl } from '../assetReadUrlService';
|
||||
import { uploadEditorSeedanceReferenceFile } from './editorReferenceUploadClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../assetReadUrlService', () => ({
|
||||
getSignedAssetReadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestJsonMock = vi.mocked(requestJson);
|
||||
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
|
||||
|
||||
describe('editorReferenceUploadClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1771400000000);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 200 })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uploads Seedance reference videos through direct OSS upload and returns object metadata', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
bucket: 'bucket',
|
||||
host: 'https://oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
policy: 'policy',
|
||||
'x-oss-signature': 'signature',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObject: {
|
||||
assetObjectId: 'assetobj-video',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
assetKind: 'editor_seedance_reference_video',
|
||||
},
|
||||
});
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/ref.mp4',
|
||||
);
|
||||
|
||||
const result = await uploadEditorSeedanceReferenceFile(
|
||||
new File(['video'], 'ref.mp4', { type: 'video/mp4' }),
|
||||
'video',
|
||||
);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/assets/direct-upload-tickets',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'创建参考素材上传凭证失败',
|
||||
);
|
||||
const ticketBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'seedance-references',
|
||||
'video',
|
||||
'1771400000000',
|
||||
],
|
||||
fileName: 'ref.mp4',
|
||||
contentType: 'video/mp4',
|
||||
access: 'private',
|
||||
maxSizeBytes: 5,
|
||||
successActionStatus: 200,
|
||||
metadata: {
|
||||
asset_kind: 'editor_seedance_reference_video',
|
||||
editor_seedance_reference_type: 'video',
|
||||
},
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://oss.example.com',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/assets/objects/confirm',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'确认参考素材资产失败',
|
||||
);
|
||||
const confirmBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
bucket: 'bucket',
|
||||
objectKey: 'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
contentType: 'video/mp4',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_seedance_reference_video',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'editor-seedance-reference-video',
|
||||
});
|
||||
expect(getSignedAssetReadUrlMock).toHaveBeenCalledWith(
|
||||
{
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
expireSeconds: 3600,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
src: 'https://signed.example.com/ref.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
assetObjectId: 'assetobj-video',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { requestJson } from '../apiClient';
|
||||
import { getSignedAssetReadUrl } from '../assetReadUrlService';
|
||||
|
||||
type EditorSeedanceReferenceMediaType = 'video' | 'audio';
|
||||
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadedEditorSeedanceReference = {
|
||||
src: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
legacyPublicPath: string;
|
||||
};
|
||||
|
||||
const EDITOR_SEEDANCE_REFERENCE_READ_EXPIRE_SECONDS = 60 * 60;
|
||||
|
||||
function resolveReferenceAssetKind(mediaType: EditorSeedanceReferenceMediaType) {
|
||||
return mediaType === 'video'
|
||||
? 'editor_seedance_reference_video'
|
||||
: 'editor_seedance_reference_audio';
|
||||
}
|
||||
|
||||
function resolveReferenceEntityId(mediaType: EditorSeedanceReferenceMediaType) {
|
||||
return `editor-seedance-reference-${mediaType}`;
|
||||
}
|
||||
|
||||
function sanitizeReferenceFileName(fileName: string, mediaType: EditorSeedanceReferenceMediaType) {
|
||||
const trimmedName = fileName.trim();
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
return mediaType === 'video' ? 'seedance-reference.mp4' : 'seedance-reference.mp3';
|
||||
}
|
||||
|
||||
async function postDirectUploadFile(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
formData.append('file', file, file.name);
|
||||
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传参考素材失败');
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadEditorSeedanceReferenceFile(
|
||||
file: File,
|
||||
mediaType: EditorSeedanceReferenceMediaType,
|
||||
): Promise<UploadedEditorSeedanceReference> {
|
||||
const assetKind = resolveReferenceAssetKind(mediaType);
|
||||
const fileName = sanitizeReferenceFileName(file.name, mediaType);
|
||||
const contentType = file.type.trim() || 'application/octet-stream';
|
||||
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['editor', 'seedance-references', mediaType, `${Date.now()}`],
|
||||
fileName,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: file.size,
|
||||
successActionStatus: 200,
|
||||
metadata: {
|
||||
asset_kind: assetKind,
|
||||
editor_seedance_reference_type: mediaType,
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建参考素材上传凭证失败',
|
||||
);
|
||||
|
||||
await postDirectUploadFile(ticket.upload, file);
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: file.size,
|
||||
assetKind,
|
||||
accessPolicy: 'private',
|
||||
entityId: resolveReferenceEntityId(mediaType),
|
||||
}),
|
||||
},
|
||||
'确认参考素材资产失败',
|
||||
);
|
||||
|
||||
// 中文注释:返回短期 signed URL 供前端立即预览;生成请求最终优先提交 objectKey,由后端统一重新签名。
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
expireSeconds: EDITOR_SEEDANCE_REFERENCE_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
|
||||
return {
|
||||
src: signedUrl,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
legacyPublicPath: ticket.upload.legacyPublicPath,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user