Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -1,6 +1,7 @@
export {
deleteMatch3DWork,
generateMatch3DBackgroundImage,
generateMatch3DContainerImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,

View File

@@ -1,6 +1,8 @@
import type {
GenerateMatch3DBackgroundImageRequest,
GenerateMatch3DBackgroundImageResponse,
GenerateMatch3DContainerImageRequest,
GenerateMatch3DContainerImageResponse,
GenerateMatch3DCoverImageRequest,
GenerateMatch3DCoverImageResponse,
GenerateMatch3DItemAssetsRequest,
@@ -177,6 +179,28 @@ export function generateMatch3DBackgroundImage(
);
}
/**
* 按画面描述重新生成并保存抓大鹅局内容器形象。
*/
export function generateMatch3DContainerImage(
profileId: string,
payload: GenerateMatch3DContainerImageRequest,
) {
return requestJson<GenerateMatch3DContainerImageResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/container-image`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成抓大鹅容器形象失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
},
);
}
/**
* 按名称批量生成抓大鹅 2D 五视角物品图片。
*/
@@ -244,6 +268,7 @@ export function deleteMatch3DWork(profileId: string) {
export const match3dWorksClient = {
delete: deleteMatch3DWork,
generateBackgroundImage: generateMatch3DBackgroundImage,
generateContainerImage: generateMatch3DContainerImage,
generateCoverImage: generateMatch3DCoverImage,
generateItemAssets: generateMatch3DItemAssets,
generateTags: generateMatch3DWorkTags,

View File

@@ -26,7 +26,6 @@ describe('miniGameDraftGenerationProgress', () => {
'编译首关草稿',
'生成关卡名称',
'生成首关画面',
'生成背景音乐',
'生成UI背景',
'写入正式草稿',
]);
@@ -34,7 +33,7 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps[0]?.detail).toBe(
'读取画面描述,建立可编辑草稿与首关结构。',
);
expect(progress?.estimatedRemainingMs).toBe(178_500);
expect(progress?.estimatedRemainingMs).toBe(130_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
@@ -50,22 +49,19 @@ describe('miniGameDraftGenerationProgress', () => {
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 126_000);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(155_000);
expect(imageProgress?.estimatedRemainingMs).toBe(107_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(musicProgress?.phaseId).toBe('puzzle-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
expect(writeBackProgress?.estimatedRemainingMs).toBe(7_000);
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
expect(writeBackProgress?.steps[4]?.status).toBe('active');
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
@@ -83,7 +79,7 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[4]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {
@@ -177,13 +173,12 @@ describe('miniGameDraftGenerationProgress', () => {
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-views',
'match3d-background-music',
'match3d-background-image',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('分批生成素材图');
expect(progress?.estimatedRemainingMs).toBe(570_000);
expect(progress?.estimatedRemainingMs).toBe(480_000);
});
test('match3d draft generation starts from title generation', () => {
@@ -215,29 +210,23 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps[6]?.detail).toContain('音效提示词');
expect(progress?.steps[6]?.detail).toContain('五视角图片');
expect(progress?.steps[6]?.completed).toBe(1);
expect(progress?.steps[6]?.total).toBe(3);
});
test('match3d draft generation reaches music, background image and writeback phases', () => {
test('match3d draft generation reaches background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const musicProgress = buildMiniGameDraftGenerationProgress(
const backgroundProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 400_000,
);
const backgroundProgress = buildMiniGameDraftGenerationProgress(
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 500_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 550_000,
);
expect(musicProgress?.phaseId).toBe('match3d-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');

View File

@@ -43,7 +43,6 @@ export type MiniGameDraftGenerationPhase =
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-music'
| 'match3d-background-image'
| 'match3d-write-draft'
| 'match3d-ready'
@@ -51,7 +50,6 @@ export type MiniGameDraftGenerationPhase =
| 'baby-object-images'
| 'baby-object-ready'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
| 'ready'
@@ -98,27 +96,21 @@ const PUZZLE_STEPS = [
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 42,
},
{
id: 'puzzle-background-music',
label: '生成背景音乐',
detail: '用作品题目生成纯音乐并转存音频资产。',
weight: 18,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 14,
weight: 32,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '写入首图、音乐、UI背景和首关数据。',
detail: '写入首图、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 180_000;
const PUZZLE_ESTIMATED_WAIT_MS = 132_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
@@ -126,7 +118,6 @@ const PUZZLE_PHASE_TIMELINE: Array<{
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
>;
@@ -135,7 +126,6 @@ const PUZZLE_PHASE_TIMELINE: Array<{
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-background-music', durationMs: 48_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
@@ -192,7 +182,7 @@ const MATCH3D_STEPS = [
{
id: 'match3d-item-names',
label: '生成作品计划',
detail: '生成游戏名称、物品名称、音乐名称与标签。',
detail: '生成游戏名称、物品名称与标签。',
weight: 10,
},
{
@@ -205,31 +195,25 @@ const MATCH3D_STEPS = [
id: 'match3d-material-sheet',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 22,
weight: 24,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 10,
weight: 12,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '上传每个物品的 2D 五视角素材。',
weight: 12,
weight: 14,
},
{
id: 'match3d-generate-views',
label: '校验素材结构',
detail: '确认物品顺序五视角图片和音效提示词。',
weight: 6,
},
{
id: 'match3d-background-music',
label: '生成背景音乐',
detail: '用音乐名称生成纯音乐并转存音频资产。',
weight: 14,
detail: '确认物品顺序五视角图片。',
weight: 8,
},
{
id: 'match3d-background-image',
@@ -240,11 +224,13 @@ const MATCH3D_STEPS = [
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、音乐、背景、容器和作品草稿。',
detail: '保存素材、背景、容器和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
const MATCH3D_PHASE_ORDER: Partial<
Record<MiniGameDraftGenerationPhase, number>
> = {
@@ -255,9 +241,8 @@ const MATCH3D_PHASE_ORDER: Partial<
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-music': 7,
'match3d-background-image': 8,
'match3d-write-draft': 9,
'match3d-background-image': 7,
'match3d-write-draft': 8,
};
const BABY_OBJECT_MATCH_STEPS = [
@@ -392,25 +377,23 @@ function resolveMatch3DPhaseByElapsedMs(
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 540_000
elapsedMs >= 492_000
? 'match3d-write-draft'
: elapsedMs >= 460_000
: elapsedMs >= 370_000
? 'match3d-background-image'
: elapsedMs >= 370_000
? 'match3d-background-music'
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
@@ -579,7 +562,7 @@ export function buildMiniGameDraftGenerationProgress(
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: normalizedState.kind === 'match3d'
? Math.max(0, 10 * 60_000 - elapsedMs)
? Math.max(0, MATCH3D_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'baby-object-match'
? Math.max(0, 60_000 - elapsedMs)
: null,

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
@@ -62,7 +63,7 @@ function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
for (let index = 0; index < 24; index += 1) {
const currentLevel = nextRun.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return nextRun;
@@ -574,6 +575,34 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background-object-key.png',
backgroundMusic: null,
generationStatus: 'ready',
} as PuzzleDraftLevel,
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background-object-key.png',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -12,6 +12,7 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource';
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
@@ -803,7 +804,10 @@ function buildFallbackLocalLevel(
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc;
const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel)
? nextLevel?.uiBackgroundImageObjectKey?.trim() || null
: currentLevel.uiBackgroundImageObjectKey ?? null;
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -835,6 +839,7 @@ function buildFallbackLocalLevel(
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -860,7 +865,9 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel);
const firstUiBackgroundImageObjectKey =
firstLevel?.uiBackgroundImageObjectKey?.trim() || null;
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
@@ -882,6 +889,7 @@ export function startLocalPuzzleRun(
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',

View File

@@ -0,0 +1,20 @@
type PuzzleUiBackgroundFields = {
uiBackgroundImageSrc?: string | null;
uiBackgroundImageObjectKey?: string | null;
};
export function resolvePuzzleUiBackgroundSource(
level: PuzzleUiBackgroundFields | null | undefined,
) {
const imageSrc = level?.uiBackgroundImageSrc?.trim();
if (imageSrc) {
return imageSrc;
}
const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, '');
if (!objectKey) {
return null;
}
return `/${objectKey}`;
}