fix: polish bark battle creation flow
This commit is contained in:
@@ -1,21 +1,28 @@
|
||||
import type {
|
||||
BarkBattleAssetSlot,
|
||||
BarkBattleConfigEditorPayload,
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattleDraftConfigUpdateRequest,
|
||||
BarkBattleDraftCreateRequest,
|
||||
BarkBattleGeneratedImageAsset,
|
||||
BarkBattleImageAssetGenerateRequest,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleWorkPublishRequest,
|
||||
BarkBattleWorksResponse,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { CustomWorldSceneImageResult } from '../aiTypes';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
|
||||
|
||||
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_ASSET_UPLOAD_MAX_AUDIO_BYTES = 20 * 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,
|
||||
@@ -32,12 +39,6 @@ export type BarkBattleCreationRequestOptions = Pick<
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
export type BarkBattleAssetSlot =
|
||||
| 'player-character'
|
||||
| 'opponent-character'
|
||||
| 'ui-background'
|
||||
| 'bark-sound';
|
||||
|
||||
export type BarkBattleUploadedAsset = {
|
||||
assetObjectId: string;
|
||||
assetKind: string;
|
||||
@@ -45,6 +46,23 @@ export type BarkBattleUploadedAsset = {
|
||||
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;
|
||||
@@ -82,16 +100,10 @@ const SLOT_UPLOAD_CONFIG = {
|
||||
legacyPrefix: 'generated-bark-battle-assets',
|
||||
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
|
||||
},
|
||||
'bark-sound': {
|
||||
acceptKind: 'audio',
|
||||
assetKind: 'bark_battle_bark_sound',
|
||||
legacyPrefix: 'generated-bark-battle-assets',
|
||||
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES,
|
||||
},
|
||||
} satisfies Record<
|
||||
BarkBattleAssetSlot,
|
||||
{
|
||||
acceptKind: 'image' | 'audio';
|
||||
acceptKind: 'image';
|
||||
assetKind: string;
|
||||
legacyPrefix: string;
|
||||
maxSizeBytes: number;
|
||||
@@ -101,11 +113,7 @@ const SLOT_UPLOAD_CONFIG = {
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
png: 'image/png',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
@@ -129,9 +137,6 @@ function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
|
||||
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
|
||||
throw new Error('请选择图片素材。');
|
||||
}
|
||||
if (config.acceptKind === 'audio' && !contentType.startsWith('audio/')) {
|
||||
throw new Error('请选择音频素材。');
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@@ -174,27 +179,29 @@ async function postDirectUploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
function buildBarkBattleImagePrompt(
|
||||
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>,
|
||||
payload: BarkBattleConfigEditorPayload,
|
||||
) {
|
||||
const slotPrompt = {
|
||||
'player-character': `玩家角色形象:${payload.playerDogSkinPreset}`,
|
||||
'opponent-character': `对手角色形象:${payload.opponentDogSkinPreset}`,
|
||||
'ui-background': `游戏 UI 背景:${payload.themePreset}`,
|
||||
} satisfies Record<Exclude<BarkBattleAssetSlot, 'bark-sound'>, string>;
|
||||
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 [
|
||||
`汪汪声浪大作战《${payload.title}》`,
|
||||
payload.description ?? '',
|
||||
slotPrompt[slot],
|
||||
slot === 'ui-background'
|
||||
? '竖屏移动端游戏背景,无文字,无按钮,无角色遮挡'
|
||||
: '游戏角色立绘,完整主体,透明感背景,无文字,无 UI',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
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(
|
||||
@@ -219,6 +226,31 @@ export function createBarkBattleDraft(
|
||||
);
|
||||
}
|
||||
|
||||
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 = {},
|
||||
@@ -241,6 +273,34 @@ export function publishBarkBattleWork(
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -302,45 +362,90 @@ export async function uploadBarkBattleAsset(payload: {
|
||||
}
|
||||
|
||||
export function regenerateBarkBattleImageAsset(payload: {
|
||||
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>;
|
||||
slot: BarkBattleAssetSlot;
|
||||
config: BarkBattleConfigEditorPayload;
|
||||
draftId?: string | null;
|
||||
}): Promise<CustomWorldSceneImageResult> {
|
||||
return generateRpgWorldSceneImage({
|
||||
profile: {
|
||||
id: payload.draftId?.trim() || 'bark-battle-draft',
|
||||
name: payload.config.title.trim() || '汪汪声浪大作战',
|
||||
subtitle: '汪汪声浪',
|
||||
summary: payload.config.description?.trim() || payload.config.themePreset,
|
||||
tone: payload.config.themePreset,
|
||||
playerGoal: '用声浪压过对手',
|
||||
settingText: [
|
||||
payload.config.themePreset,
|
||||
payload.config.playerDogSkinPreset,
|
||||
payload.config.opponentDogSkinPreset,
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
}): 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),
|
||||
},
|
||||
landmark: {
|
||||
id: payload.slot,
|
||||
name:
|
||||
payload.slot === 'ui-background'
|
||||
? '声浪竞技 UI 背景'
|
||||
: payload.slot === 'player-character'
|
||||
? payload.config.playerDogSkinPreset || '玩家角色'
|
||||
: payload.config.opponentDogSkinPreset || '对手角色',
|
||||
description: buildBarkBattleImagePrompt(payload.slot, payload.config),
|
||||
'生成汪汪声浪素材失败',
|
||||
{
|
||||
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
|
||||
timeoutMs: BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS,
|
||||
},
|
||||
userPrompt: buildBarkBattleImagePrompt(payload.slot, payload.config),
|
||||
size: payload.slot === 'ui-background' ? '1024*1792' : '1024*1024',
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user