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

@@ -474,6 +474,114 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
fireEvent.load(screen.getByTestId('match3d-container-image'));
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'rounded-full',
);
});
test('容器图换签失败时保留默认圆形容器兜底', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/failing-task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('read-url failed'));
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalled();
});
expect(screen.queryByTestId('match3d-container-image')).toBeNull();
expect(screen.getByTestId('match3d-board').className).toContain(
'rounded-full',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'bg-transparent',
);
});
test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
const run = startLocalMatch3DRun(3);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
const signedUrl = url.includes('ui-container')
? 'https://oss.example.com/match3d-container.png'
: 'https://oss.example.com/match3d-background.png';
return Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
});
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={[]}
generatedBackgroundAsset={{
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/task/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
}}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId('match3d-background-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-background.png');
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
fireEvent.load(screen.getByTestId('match3d-container-image'));
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {

View File

@@ -22,7 +22,10 @@ import type {
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
@@ -58,6 +61,7 @@ import {
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
backgroundImageSrc?: string | null;
isBusy?: boolean;
error?: string | null;
@@ -459,6 +463,7 @@ function Match3DSettlement({
export function Match3DRuntimeShell({
run,
generatedItemAssets = [],
generatedBackgroundAsset = null,
backgroundImageSrc = null,
isBusy = false,
error = null,
@@ -564,6 +569,8 @@ export function Match3DRuntimeShell({
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
@@ -574,6 +581,8 @@ export function Match3DRuntimeShell({
.find(Boolean) ||
'';
const containerAssetSrc =
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
@@ -606,6 +615,10 @@ export function Match3DRuntimeShell({
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
const hasRenderedContainerAsset = Boolean(
resolvedContainerImageSrc && isContainerImageLoaded,
);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -715,11 +728,14 @@ export function Match3DRuntimeShell({
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -727,11 +743,13 @@ export function Match3DRuntimeShell({
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
setIsContainerImageLoaded(false);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
}
});
@@ -875,6 +893,7 @@ export function Match3DRuntimeShell({
src={resolvedBackgroundImageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-background-image"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
@@ -921,7 +940,11 @@ export function Match3DRuntimeShell({
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
className={`relative aspect-square max-w-full ${
hasRenderedContainerAsset
? 'overflow-visible bg-transparent'
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
}`}
style={{
width: 'min(92vw, 58dvh, 100%)',
}}
@@ -933,8 +956,15 @@ export function Match3DRuntimeShell({
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
className={`pointer-events-none absolute inset-[-8%] z-0 h-[116%] w-[116%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
}`}
data-testid="match3d-container-image"
onLoad={() => setIsContainerImageLoaded(true)}
onError={() => {
setIsContainerImageLoaded(false);
setResolvedContainerImageSrc('');
}}
/>
) : (
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />