1
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -710,6 +710,7 @@ function buildLocalLeaderboardEntries(
|
||||
rank: 1,
|
||||
nickname,
|
||||
elapsedMs,
|
||||
visibleTags: [],
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user