import type { BarkBattleAssetSlot, BarkBattleConfigEditorPayload, BarkBattleDraftConfig, BarkBattleDraftConfigUpdateRequest, BarkBattleDraftCreateRequest, BarkBattleGeneratedImageAsset, BarkBattleImageAssetGenerateRequest, BarkBattlePublishedConfig, BarkBattleWorkPublishRequest, BarkBattleWorksResponse, } from '../../../packages/shared/src/contracts/barkBattle'; import { type ApiRequestOptions, type ApiRetryOptions, requestJson, } from '../apiClient'; export type { BarkBattleAssetSlot } from '../../../packages/shared/src/contracts/barkBattle'; const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle'; const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle'; const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS = 180_000; const BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS = 30_000; const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, maxDelayMs: 360, retryUnsafeMethods: true, }; export type BarkBattleCreationRequestOptions = Pick< ApiRequestOptions, | 'authImpact' | 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' >; export type BarkBattleUploadedAsset = { assetObjectId: string; assetKind: string; objectKey: string; assetSrc: string; }; export type BarkBattleGeneratedImageAssets = Partial< Record >; export type BarkBattleImageGenerationFailures = Partial< Record >; export type BarkBattleSlotGenerationResult = | { status: 'fulfilled'; asset: BarkBattleGeneratedImageAsset } | { status: 'rejected'; message: string }; export type BarkBattleImageGenerationBatchResult = { assets: BarkBattleGeneratedImageAssets; failures: BarkBattleImageGenerationFailures; }; type DirectUploadTicketResponse = { upload: { bucket: string; host: string; objectKey: string; legacyPublicPath: string; formFields: Record; }; }; type ConfirmAssetObjectResponse = { assetObject: { assetObjectId: string; objectKey: string; assetKind: string; }; }; const SLOT_UPLOAD_CONFIG = { 'player-character': { acceptKind: 'image', assetKind: 'bark_battle_player_character_image', legacyPrefix: 'generated-bark-battle-assets', maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES, }, 'opponent-character': { acceptKind: 'image', assetKind: 'bark_battle_opponent_character_image', legacyPrefix: 'generated-bark-battle-assets', maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES, }, 'ui-background': { acceptKind: 'image', assetKind: 'bark_battle_ui_background_image', legacyPrefix: 'generated-bark-battle-assets', maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES, }, } satisfies Record< BarkBattleAssetSlot, { acceptKind: 'image'; assetKind: string; legacyPrefix: string; maxSizeBytes: number; } >; const MIME_BY_EXTENSION: Record = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', }; function resolveUploadContentType(file: File) { if (file.type.trim()) { return file.type.trim(); } const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? ''; return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; } function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) { const config = SLOT_UPLOAD_CONFIG[slot]; const contentType = resolveUploadContentType(file); if (file.size <= 0) { throw new Error('素材文件为空,请重新选择。'); } if (file.size > config.maxSizeBytes) { throw new Error('素材文件过大,请压缩后再上传。'); } if (config.acceptKind === 'image' && !contentType.startsWith('image/')) { throw new Error('请选择图片素材。'); } return contentType; } function normalizeAssetPathSegment(value: string) { return value .trim() .replace(/[^a-zA-Z0-9_-]+/gu, '-') .replace(/^-+|-+$/gu, '') .slice(0, 72); } function buildUploadPathSegments(slot: BarkBattleAssetSlot, draftId?: string) { return [ 'bark-battle', normalizeAssetPathSegment(draftId || 'draft') || 'draft', slot, String(Date.now()), ]; } 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); const response = await fetch(upload.host, { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('上传平台资产失败。'); } } function withBarkBattleGenerationTimeout( promise: Promise, slot: BarkBattleAssetSlot, ): Promise { let timeoutId: ReturnType | null = null; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`${slot} 生成超时`)); }, BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS); }); return Promise.race([promise, timeout]).finally(() => { if (timeoutId) { clearTimeout(timeoutId); } }); } function resolveBarkBattleGenerationFailureMessage(error: unknown) { if (error instanceof Error && error.message.trim()) { return error.message.trim(); } return '汪汪声浪素材生成失败。'; } export function createBarkBattleDraft( payload: BarkBattleDraftCreateRequest, options: BarkBattleCreationRequestOptions = {}, ) { return requestJson( `${BARK_BATTLE_CREATION_API_BASE}/drafts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '创建汪汪声浪大作战草稿失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); } export function updateBarkBattleDraftConfig( payload: BarkBattleDraftConfigUpdateRequest, options: BarkBattleCreationRequestOptions = {}, ) { return requestJson( `${BARK_BATTLE_CREATION_API_BASE}/drafts/${encodeURIComponent( payload.draftId, )}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '保存汪汪声浪草稿素材失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, timeoutMs: BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS, authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); } export function publishBarkBattleWork( payload: BarkBattleWorkPublishRequest, options: BarkBattleCreationRequestOptions = {}, ) { return requestJson( `${BARK_BATTLE_CREATION_API_BASE}/works/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '发布汪汪声浪大作战作品失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); } export function listBarkBattleWorks( options: BarkBattleCreationRequestOptions = {}, ) { return requestJson( `${BARK_BATTLE_RUNTIME_API_BASE}/works`, { method: 'GET' }, '读取汪汪声浪作品架失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); } export function listBarkBattleGallery() { return requestJson( `${BARK_BATTLE_RUNTIME_API_BASE}/gallery`, { method: 'GET' }, '读取汪汪声浪公开广场失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, }, ); } export async function uploadBarkBattleAsset(payload: { slot: BarkBattleAssetSlot; file: File; draftId?: string | null; }): Promise { const contentType = validateBarkBattleUploadFile(payload.slot, payload.file); const config = SLOT_UPLOAD_CONFIG[payload.slot]; const ticket = await requestJson( '/api/assets/direct-upload-tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legacyPrefix: config.legacyPrefix, pathSegments: buildUploadPathSegments( payload.slot, payload.draftId ?? undefined, ), fileName: payload.file.name, contentType, access: 'private', maxSizeBytes: config.maxSizeBytes, metadata: { asset_kind: config.assetKind, bark_battle_slot: payload.slot, }, }), }, '创建汪汪声浪素材上传凭证失败', ); await postDirectUploadFile(ticket.upload, payload.file); const confirmed = await requestJson( '/api/assets/objects/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bucket: ticket.upload.bucket, objectKey: ticket.upload.objectKey, contentType, contentLength: payload.file.size, assetKind: config.assetKind, accessPolicy: 'private', profileId: payload.draftId?.trim() || null, entityId: payload.slot, }), }, '确认汪汪声浪素材失败', ); return { assetObjectId: confirmed.assetObject.assetObjectId, assetKind: confirmed.assetObject.assetKind, objectKey: confirmed.assetObject.objectKey, assetSrc: ticket.upload.legacyPublicPath, }; } export function regenerateBarkBattleImageAsset(payload: { slot: BarkBattleAssetSlot; config: BarkBattleConfigEditorPayload; draftId?: string | null; }): Promise { const request: BarkBattleImageAssetGenerateRequest = { slot: payload.slot, draftId: payload.draftId ?? null, config: payload.config, }; return requestJson( `${BARK_BATTLE_CREATION_API_BASE}/images/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }, '生成汪汪声浪素材失败', { retry: BARK_BATTLE_CREATION_WRITE_RETRY, timeoutMs: BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS, }, ); } export async function generateAllBarkBattleImageAssets(payload: { config: BarkBattleConfigEditorPayload; draftId?: string | null; onSlotComplete?: ( slot: BarkBattleAssetSlot, result: BarkBattleSlotGenerationResult, ) => void; }): Promise { const slots = [ 'player-character', 'opponent-character', 'ui-background', ] as const; const results = await Promise.allSettled( slots.map(async (slot) => [ slot, await withBarkBattleGenerationTimeout( regenerateBarkBattleImageAsset({ slot, config: payload.config, draftId: payload.draftId, }), slot, ) .then((asset) => { payload.onSlotComplete?.(slot, { status: 'fulfilled', asset }); return asset; }) .catch((error) => { const message = resolveBarkBattleGenerationFailureMessage(error); payload.onSlotComplete?.(slot, { status: 'rejected', message }); throw new Error(message); }), ] as const), ); const assets: BarkBattleGeneratedImageAssets = {}; const failures: BarkBattleImageGenerationFailures = {}; results.forEach((result, index) => { const slot = slots[index]; if (!slot) { return; } if (result.status === 'fulfilled') { assets[slot] = result.value[1]; return; } failures[slot] = resolveBarkBattleGenerationFailureMessage(result.reason); }); return { assets, failures }; } export const barkBattleCreationClient = { createDraft: createBarkBattleDraft, generateAllImageAssets: generateAllBarkBattleImageAssets, listGallery: listBarkBattleGallery, listWorks: listBarkBattleWorks, regenerateImageAsset: regenerateBarkBattleImageAsset, publish: publishBarkBattleWork, updateDraftConfig: updateBarkBattleDraftConfig, uploadAsset: uploadBarkBattleAsset, };