This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -8,6 +8,7 @@ import {
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
readAssetBytes,
resolveAssetReadUrl,
} from './assetReadUrlService';
@@ -301,4 +302,30 @@ describe('assetReadUrlService', () => {
expect(getStoredAccessToken()).toBe('test-access-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
);
const response = await readAssetBytes(
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
{ expireSeconds: 300 },
);
const bytes = new Uint8Array(await response.arrayBuffer());
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
expect(response.headers.get('content-type')).toBe('image/png');
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-bytes?',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
);
});
});

View File

@@ -1,7 +1,9 @@
import { parseApiErrorMessage } from '../../packages/shared/src/http';
import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
type ApiRequestOptions,
fetchWithApiAuth,
requestJson,
} from './apiClient';
@@ -23,6 +25,11 @@ type AssetReadUrlResolveOptions = {
refreshKey?: string | number | null;
};
type AssetReadBytesOptions = {
signal?: AbortSignal;
expireSeconds?: number;
};
export type AssetReadUrlResponse = {
read?: {
objectKey?: string;
@@ -44,6 +51,7 @@ type CachedReadUrlFailureEntry = {
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const ASSET_READ_BYTES_API_PATH = '/api/assets/read-bytes';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const ASSET_READ_URL_BACKGROUND_OPTIONS =
@@ -72,6 +80,27 @@ function buildCacheKey(request: AssetReadUrlRequest) {
return '';
}
function buildAssetReadSearchParams(request: AssetReadUrlRequest) {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
return searchParams;
}
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
const read = response.read ?? response;
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
@@ -146,23 +175,7 @@ export async function getSignedAssetReadUrl(
}
const requestPromise = (async () => {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const searchParams = buildAssetReadSearchParams(request);
try {
const response = await requestJson<AssetReadUrlResponse>(
@@ -289,6 +302,49 @@ export async function resolveAssetReadUrl(
return appendCacheBustParam(value, options.refreshKey);
}
export async function readAssetBytes(
source: string | null | undefined,
options: AssetReadBytesOptions = {},
) {
const value = source?.trim() ?? '';
if (!value) {
throw new Error('资源路径不能为空');
}
if (!isGeneratedLegacyPath(value)) {
const response = await fetch(value, { signal: options.signal });
if (!response.ok) {
throw new Error('读取资源内容失败');
}
return response;
}
// 中文注释:这里要拿图片字节转 Data URL不能直接 fetch OSS 签名 URL否则浏览器会受 bucket CORS 限制。
const searchParams = buildAssetReadSearchParams({
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
});
const response = await fetchWithApiAuth(
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal: options.signal,
},
{
...ASSET_READ_URL_BACKGROUND_OPTIONS,
omitEnvelopeHeader: true,
},
);
if (!response.ok) {
const message = await response
.text()
.then((text) => parseApiErrorMessage(text, '读取资源内容失败'))
.catch(() => '');
throw new Error(message || '读取资源内容失败');
}
return response;
}
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
signedReadUrlFailureCache.clear();

View File

@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import {
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
@@ -150,6 +152,47 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation exposes item sheet and image asset steps', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 17_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-item-names',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
expect(progress?.estimatedRemainingMs).toBe(103_000);
});
test('match3d generation anchors show theme and fixed three items', () => {
const entries = buildMatch3DGenerationAnchorEntries(null, {
themeText: '水果',
clearCount: 20,
difficulty: 8,
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'match3d-theme',
label: '题材',
value: '水果',
},
{
id: 'match3d-items',
label: '物品数量',
value: '3 件',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',

View File

@@ -3,6 +3,10 @@ import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/match3dAgent';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
@@ -10,7 +14,11 @@ import type {
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish' | 'square-hole';
export type MiniGameDraftGenerationKind =
| 'puzzle'
| 'big-fish'
| 'square-hole'
| 'match3d';
export type MiniGameDraftGenerationPhase =
| 'idle'
@@ -22,6 +30,11 @@ export type MiniGameDraftGenerationPhase =
| 'square-hole-cover'
| 'square-hole-shapes'
| 'square-hole-ready'
| 'match3d-item-names'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-ready'
| 'puzzle-images'
| 'puzzle-select-image'
| 'ready'
@@ -126,6 +139,33 @@ const SQUARE_HOLE_STEPS = [
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_STEPS = [
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据题材生成本局的 3 个物品名称。',
weight: 16,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
detail: '生成一张 1:1 的网格素材图。',
weight: 30,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成独立物品参考图。',
weight: 14,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入切割图片并准备进入草稿页。',
weight: 40,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -137,6 +177,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
}
if (kind === 'match3d') {
return MATCH3D_STEPS;
}
return BIG_FISH_STEPS;
}
@@ -190,7 +233,9 @@ export function createMiniGameDraftGenerationState(
? 'big-fish-draft'
: kind === 'square-hole'
? 'square-hole-draft'
: 'compile',
: kind === 'match3d'
? 'match3d-item-names'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
totalAssetCount: 0,
@@ -222,6 +267,21 @@ function resolveSquareHolePhaseByElapsedMs(
return 'square-hole-draft';
}
function resolveMatch3DPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 72_000) {
return 'match3d-upload-images';
}
if (elapsedMs >= 58_000) {
return 'match3d-slice-images';
}
if (elapsedMs >= 16_000) {
return 'match3d-material-sheet';
}
return 'match3d-item-names';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
@@ -282,6 +342,13 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
}
: state.kind === 'match3d' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs),
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
@@ -307,6 +374,8 @@ export function buildMiniGameDraftGenerationProgress(
? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: normalizedState.kind === 'match3d'
? 0.5
: 0;
const overallProgress =
normalizedState.phase === 'failed'
@@ -334,7 +403,9 @@ export function buildMiniGameDraftGenerationProgress(
(normalizedState.phase === 'ready'
? normalizedState.kind === 'big-fish'
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: normalizedState.kind === 'match3d'
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(cappedOverallProgress),
@@ -350,7 +421,9 @@ export function buildMiniGameDraftGenerationProgress(
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: null,
: normalizedState.kind === 'match3d'
? Math.max(0, 120_000 - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
steps,
@@ -439,6 +512,43 @@ export function buildBigFishGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
export function buildMatch3DGenerationAnchorEntries(
session: Match3DAgentSessionSnapshot | null | undefined,
formPayload: CreateMatch3DSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
if (!session && !formPayload) {
return [];
}
const config = session?.config;
const itemCount = 3;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
label: '题材',
value:
formPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value ||
'',
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount}`,
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {

View File

@@ -710,6 +710,7 @@ function buildLocalLeaderboardEntries(
rank: 1,
nickname,
elapsedMs,
visibleTags: [],
isCurrentPlayer: true,
},
];

View File

@@ -1,4 +1,4 @@
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1536;
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
@@ -114,7 +114,7 @@ async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
return dataUrl;
}
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1536 内给 JSON body 留余量
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1024 内更容易稳定进入 VectorEngine 参考图分支
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.fillStyle = '#ffffff';