452 lines
13 KiB
TypeScript
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,
|
|
};
|