refactor(api-server): narrow puzzle state surface

This commit is contained in:
kdletters
2026-05-21 18:55:25 +08:00
parent cc23b6020d
commit 5834a99107
31 changed files with 1087 additions and 169 deletions

View File

@@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = {
id: string;
label: string;
imageSrc: string;
assetObjectId?: string | null;
};
export type CreativeImageInputPanelLabels = {

View File

@@ -1956,6 +1956,8 @@ function buildPuzzleCompileActionFromFormPayload(
...(pictureDescription ? { pictureDescription } : {}),
referenceImageSrc: payload?.referenceImageSrc || null,
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
imageModel: payload?.imageModel ?? null,
aiRedraw: payload?.aiRedraw ?? true,
candidateCount: 1,
@@ -1978,6 +1980,8 @@ function buildPuzzleFormPayloadFromSession(
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
};
@@ -2008,6 +2012,8 @@ function buildPuzzleFormPayloadFromAction(
? (payload.referenceImageSrc ?? null)
: (payload.referenceImageSrc ?? null),
referenceImageSrcs: payload.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
imageModel:
payload.action === 'compile_puzzle_draft'
? (payload.imageModel ?? null)
@@ -5542,6 +5548,10 @@ export function PlatformEntryFlowShellImpl({
pictureDescription: payload.pictureDescription ?? '',
referenceImageSrc: payload.referenceImageSrc ?? null,
referenceImageSrcs: payload.referenceImageSrcs ?? [],
referenceImageAssetObjectId:
payload.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds:
payload.referenceImageAssetObjectIds ?? [],
imageModel: payload.imageModel ?? null,
aiRedraw: payload.aiRedraw ?? true,
});

View File

@@ -29,6 +29,7 @@ vi.mock('../ResolvedAssetImage', () => ({
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
uploadReferenceImage: vi.fn(),
},
}));
@@ -90,6 +91,19 @@ beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation(
async ({ file }) => ({
assetObjectId: `asset-reference-${file.name}`,
assetKind: 'puzzle_cover_image',
objectKey: `generated-puzzle-assets/reference/${file.name}`,
imageSrc: `/generated-puzzle-assets/reference/${file.name}`,
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
}),
);
});
afterEach(() => {
@@ -190,6 +204,8 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -325,8 +341,10 @@ test('puzzle workspace selects a history image from the upload card', async () =
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -384,6 +402,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
@@ -484,6 +504,8 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -528,8 +550,10 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: uploadedDataUrl,
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
@@ -584,6 +608,8 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
pictureDescription: '历史素材 · image.png',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
@@ -593,6 +619,17 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
@@ -612,6 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
@@ -621,8 +661,101 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: uploadedDataUrl,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace uploads prompt references as asset object ids', async () => {
const onCreateFromForm = vi.fn();
const uploadedSources = [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
];
let readIndex = 0;
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? '';
readIndex += 1;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.mocked(puzzleAssetClient.uploadReferenceImage)
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-1.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-1.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
})
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-2',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-2.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-2.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: uploadedSources.map(
(_source, index) =>
new File(['x'], `reference-${index + 1}.png`, {
type: 'image/png',
}),
),
},
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: //u })).toHaveLength(
2,
);
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -705,7 +838,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: uploadedSources.slice(0, 5),
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});

View File

@@ -16,9 +16,11 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
puzzleReferenceImageDataUrlToFile,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
@@ -54,6 +56,7 @@ type PuzzleAgentWorkspaceProps = {
type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageAssetObjectId: string;
referenceImageLabel: string;
referenceImageSrcs: CreativeImageInputReferenceImage[];
imageModel: PuzzleImageModelId;
@@ -63,6 +66,7 @@ type PuzzleFormState = {
const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -74,6 +78,7 @@ const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
type PuzzleImageCropState = {
source: string;
label: string;
fileName: string;
imageSize: { width: number; height: number };
cropRect: SquareImageCropRect;
error: string | null;
@@ -97,11 +102,14 @@ function resolveInitialFormState(
return {
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload?.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload?.referenceImageSrcs,
initialFormPayload?.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
@@ -115,11 +123,14 @@ function resolveInitialFormState(
initialFormPayload.seedText ??
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload.referenceImageSrcs,
initialFormPayload.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
@@ -138,6 +149,7 @@ function resolveInitialFormState(
session.seedText ||
'',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -166,14 +178,46 @@ function normalizePuzzlePromptReferenceSources(
function createPuzzlePromptReferenceImagesFromSources(
sources: readonly string[] | null | undefined,
assetObjectIds: readonly string[] | null | undefined = [],
): CreativeImageInputReferenceImage[] {
return normalizePuzzlePromptReferenceSources(sources).map(
const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds);
const sourceImages = normalizePuzzlePromptReferenceSources(sources).map(
(imageSrc, index) => ({
id: `restored:${index}:${imageSrc}`,
label: `参考图 ${index + 1}`,
imageSrc,
assetObjectId: assetIds[index] ?? null,
}),
);
if (sourceImages.length > 0) {
return sourceImages;
}
return assetIds.map((assetObjectId, index) => ({
id: `restored-asset:${index}:${assetObjectId}`,
label: `参考图 ${index + 1}`,
imageSrc: '',
assetObjectId,
}));
}
function normalizePuzzleAssetObjectIds(
assetObjectIds: readonly (string | null | undefined)[] | null | undefined,
) {
const normalizedIds: string[] = [];
for (const assetObjectId of assetObjectIds ?? []) {
const normalized = assetObjectId?.trim() ?? '';
if (
normalized &&
!normalizedIds.some((current) => current === normalized)
) {
normalizedIds.push(normalized);
}
if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedIds;
}
function addPuzzlePromptReferenceImage(
@@ -256,6 +300,21 @@ export function PuzzleAgentWorkspace({
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const promptReferenceAssetObjectIds = useMemo(
() =>
formState.referenceImageSrc
? []
: normalizePuzzleAssetObjectIds(
formState.referenceImageSrcs.map((image) => image.assetObjectId),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const mainReferenceImageSrcForPayload =
formState.referenceImageAssetObjectId && formState.aiRedraw
? null
: formState.referenceImageSrc || null;
const promptReferenceImageSrcsForPayload =
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
@@ -263,16 +322,21 @@ export function PuzzleAgentWorkspace({
() => ({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
[
formState.aiRedraw,
formState.referenceImageSrc,
formState.referenceImageAssetObjectId,
formState.imageModel,
promptReferenceImageSrcs,
mainReferenceImageSrcForPayload,
promptReferenceAssetObjectIds,
promptReferenceImageSrcsForPayload,
pictureDescription,
],
);
@@ -280,6 +344,8 @@ export function PuzzleAgentWorkspace({
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.referenceImageSrcs,
autosavePayload.referenceImageAssetObjectId,
autosavePayload.referenceImageAssetObjectIds,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
@@ -333,6 +399,7 @@ export function PuzzleAgentWorkspace({
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
fileName: file.name.trim() || 'puzzle-reference.jpg',
imageSize,
cropRect: buildCenteredSquareImageCropRect(imageSize),
error: null,
@@ -342,9 +409,11 @@ export function PuzzleAgentWorkspace({
return;
}
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: uploadImage.dataUrl,
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
@@ -372,11 +441,18 @@ export function PuzzleAgentWorkspace({
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
})),
files.slice(0, remainingSlots).map(async (file, index) => {
const [imageSrc, asset] = await Promise.all([
readPuzzleReferenceImageAsDataUrl(file),
puzzleAssetClient.uploadReferenceImage({ file }),
]);
return {
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: asset.imageSrc || imageSrc,
assetObjectId: asset.assetObjectId,
};
}),
);
setFormState((current) => ({
...current,
@@ -439,9 +515,15 @@ export function PuzzleAgentWorkspace({
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
const file = puzzleReferenceImageDataUrlToFile(
dataUrl,
currentCropState.fileName,
);
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageSrc: asset.imageSrc || dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
@@ -482,8 +564,11 @@ export function PuzzleAgentWorkspace({
const payload = {
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
@@ -499,8 +584,11 @@ export function PuzzleAgentWorkspace({
action: 'compile_puzzle_draft',
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
@@ -510,6 +598,7 @@ export function PuzzleAgentWorkspace({
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
aiRedraw: true,
}));
@@ -645,6 +734,7 @@ export function PuzzleAgentWorkspace({
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
asset.imageSrc,
),

View File

@@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = {
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 PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
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 validatePuzzleReferenceImageFile(file: File) {
const contentType = resolvePuzzleImageContentType(file);
if (file.size <= 0) {
throw new Error('参考图文件为空,请重新选择。');
}
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
throw new Error('参考图过大,请压缩后再上传。');
}
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> {
validatePuzzleReferenceImageFile(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: validatePuzzleReferenceImageFile,
};
/**
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
@@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
export const puzzleAssetClient = {
listHistoryAssets: listPuzzleHistoryAssets,
uploadReferenceImage: uploadPuzzleReferenceImage,
};

View File

@@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({
),
);
}
export function puzzleReferenceImageDataUrlToFile(
dataUrl: string,
fileName = 'puzzle-reference.jpg',
) {
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
const mimeType =
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType });
}