Files
Genarrative/src/services/puzzle-works/puzzleAssetClient.ts
2026-05-24 19:00:21 +08:00

188 lines
5.3 KiB
TypeScript

import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
import {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageFile,
} from '../puzzleReferenceImage';
export type PuzzleHistoryAsset = {
assetObjectId: string;
assetKind: 'puzzle_cover_image';
imageSrc: string;
ownerUserId?: string | null;
ownerLabel: string;
profileId?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
};
export type PuzzleReferenceAsset = PuzzleHistoryAsset & {
objectKey: string;
};
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: 'puzzle_cover_image';
ownerUserId?: string | null;
profileId?: string | null;
entityId?: string | null;
createdAt?: string;
updatedAt?: string;
};
};
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
};
function resolvePuzzleImageContentType(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 validatePuzzleReferenceImageUploadFile(file: File) {
const contentType = resolvePuzzleImageContentType(file);
validatePuzzleReferenceImageFile(file);
if (!contentType.startsWith('image/')) {
throw new Error('参考图必须是图片文件。');
}
}
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('上传拼图参考图失败。');
}
}
export async function uploadPuzzleReferenceImage(payload: {
file: File;
}): Promise<PuzzleReferenceAsset> {
validatePuzzleReferenceImageUploadFile(payload.file);
const contentType = resolvePuzzleImageContentType(payload.file);
const uploadedAt = Date.now();
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-puzzle-assets',
pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`],
fileName: payload.file.name,
contentType,
access: 'private',
maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
metadata: {
asset_kind: 'puzzle_cover_image',
puzzle_slot: 'reference_image',
},
}),
},
'创建拼图参考图上传凭证失败',
);
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: 'puzzle_cover_image',
accessPolicy: 'private',
}),
},
'确认拼图参考图失败',
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
assetKind: confirmed.assetObject.assetKind,
objectKey: confirmed.assetObject.objectKey,
imageSrc: ticket.upload.legacyPublicPath,
ownerUserId: confirmed.assetObject.ownerUserId,
ownerLabel: confirmed.assetObject.ownerUserId
? `账号 ${confirmed.assetObject.ownerUserId}`
: '当前账号',
profileId: confirmed.assetObject.profileId,
entityId: confirmed.assetObject.entityId,
createdAt: confirmed.assetObject.createdAt ?? '',
updatedAt: confirmed.assetObject.updatedAt ?? '',
};
}
export const puzzleReferenceAssetTestUtils = {
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validateFile: validatePuzzleReferenceImageUploadFile,
};
export {
PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile,
};
/**
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
*/
export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
const params = new URLSearchParams({ kind: 'puzzle_cover_image' });
if (payload.limit) {
params.set('limit', String(payload.limit));
}
const response = await requestJson<{ assets: PuzzleHistoryAsset[] }>(
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
{ method: 'GET' },
'读取历史拼图素材失败',
);
return response.assets;
}
export const puzzleAssetClient = {
listHistoryAssets: listPuzzleHistoryAssets,
uploadReferenceImage: uploadPuzzleReferenceImage,
};