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

@@ -7,7 +7,6 @@ import {
LayoutTemplate,
Loader2,
MessageSquareText,
Music,
Play,
Plus,
Sparkles,
@@ -18,7 +17,6 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
@@ -26,14 +24,9 @@ import type {
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
createBackgroundMusicTask,
publishBackgroundMusicAsset,
waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -63,7 +56,7 @@ type PuzzleResultViewProps = {
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type PuzzleAssetConfigTabId = 'ui';
type DraftEditState = {
workTitle: string;
@@ -76,10 +69,8 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_PUBLISH_POINT_COST = 1;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
@@ -94,7 +85,6 @@ const PUZZLE_ASSET_CONFIG_TABS: Array<{
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
@@ -1099,8 +1089,13 @@ function PuzzlePublishDialog({
{actionError}
</div>
) : publishReady ? (
<div className="platform-banner platform-banner--success text-sm leading-6">
<div className="space-y-2">
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
{PUZZLE_PUBLISH_POINT_COST}
</div>
</div>
) : (
<div className="space-y-2">
@@ -1151,7 +1146,9 @@ function PuzzlePublishDialog({
disabled={!publishReady || isBusy}
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? '发布中...' : '发布到广场'}
{isBusy
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</button>
</div>
</div>
@@ -1426,11 +1423,13 @@ function PuzzleUiAssetsTab({
editState,
firstLevel,
);
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
const backgroundPreviewSrc =
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
onChange({
@@ -1495,11 +1494,7 @@ function PuzzleUiAssetsTab({
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
onGenerate(normalizedPrompt);
setIsCostConfirmOpen(true);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
@@ -1508,7 +1503,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1524,6 +1519,53 @@ function PuzzleUiAssetsTab({
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{isCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-ui-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-ui-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!firstLevel || !normalizedPrompt || isBusy}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -1648,187 +1690,11 @@ function PuzzleUiRuntimePreviewPanel({
);
}
function PuzzleMusicTab({
editState,
profileId,
sessionId,
isBusy,
onChange,
}: {
editState: DraftEditState;
profileId: string | null;
sessionId: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
const [title, setTitle] = useState(() =>
(
currentMusic?.title?.trim() ||
editState.levels[0]?.levelName.trim() ||
editState.workTitle.trim() ||
'拼图'
).slice(0, 40),
);
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
const firstLevel = editState.levels[0];
if (!firstLevel) {
return;
}
onChange({
...editState,
levels: [
{ ...firstLevel, backgroundMusic: music },
...editState.levels.slice(1),
],
});
};
const generateMusic = async () => {
if (!canGenerate || isGenerating || !editState.levels[0]) {
return;
}
setIsGenerating(true);
setStatusText('生成中');
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: '',
title: title.trim(),
tags: tags.trim() || null,
});
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
publishBackgroundMusicAsset(task.taskId, {
entityKind: 'puzzle_work',
entityId: profileId ?? sessionId,
slot: PUZZLE_BACKGROUND_MUSIC_SLOT,
assetKind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
profileId,
storagePrefix: 'puzzle_assets',
}),
);
if (!asset.audioSrc) {
throw new Error('音频生成完成但缺少播放地址。');
}
writeMusic({
taskId: asset.taskId,
provider: asset.provider,
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
audioSrc: asset.audioSrc,
prompt: '',
title: title.trim(),
updatedAt: new Date().toISOString(),
});
setStatusText('已生成');
} catch (caughtError) {
setErrorText(
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
);
setStatusText(null);
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{statusText ? (
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
{statusText}
</span>
) : null}
</div>
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
</div>
)}
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={title}
disabled={isBusy || isGenerating}
onChange={(event) => setTitle(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="背景音乐曲名"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={tags}
disabled={isBusy || isGenerating}
onChange={(event) => setTags(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="背景音乐风格"
/>
</label>
<button
type="button"
disabled={!canGenerate || isBusy || isGenerating}
onClick={() => void generateMusic()}
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || isBusy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
{errorText ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{errorText}
</div>
) : null}
</div>
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
@@ -1837,8 +1703,6 @@ function PuzzleAssetConfigTab({
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
@@ -1858,15 +1722,6 @@ function PuzzleAssetConfigTab({
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
@@ -2300,8 +2155,6 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {