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:
@@ -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 () => {
|
||||
|
||||
@@ -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%)]" />
|
||||
|
||||
Reference in New Issue
Block a user