合并编辑器素材库能力

合并 codex/editor-asset-library 到宣发素材演示分支

保留素材库、生成器、音频、UI设计图和持久化相关能力

补回宣发素材入口与 5 泥点计费契约
This commit is contained in:
2026-06-18 22:36:54 +08:00
120 changed files with 16619 additions and 3198 deletions
@@ -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',
+154 -10
View File
@@ -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,
};
}