Files
Genarrative/src/services/bark-battle-creation/barkBattleCreationClient.ts
2026-05-22 05:00:07 +08:00

452 lines
13 KiB
TypeScript

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<BarkBattleAssetSlot, BarkBattleGeneratedImageAsset>
>;
export type BarkBattleImageGenerationFailures = Partial<
Record<BarkBattleAssetSlot, string>
>;
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<string, string | null | undefined>;
};
};
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<string, string> = {
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<T>(
promise: Promise<T>,
slot: BarkBattleAssetSlot,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, 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<BarkBattleDraftConfig>(
`${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<BarkBattleDraftConfig>(
`${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<BarkBattlePublishedConfig>(
`${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<BarkBattleWorksResponse>(
`${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<BarkBattleWorksResponse>(
`${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<BarkBattleUploadedAsset> {
const contentType = validateBarkBattleUploadFile(payload.slot, payload.file);
const config = SLOT_UPLOAD_CONFIG[payload.slot];
const ticket = await requestJson<DirectUploadTicketResponse>(
'/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<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: 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<BarkBattleGeneratedImageAsset> {
const request: BarkBattleImageAssetGenerateRequest = {
slot: payload.slot,
draftId: payload.draftId ?? null,
config: payload.config,
};
return requestJson<BarkBattleGeneratedImageAsset>(
`${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<BarkBattleImageGenerationBatchResult> {
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,
};