Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -55,6 +55,9 @@ import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
const match3dSpritesheetParser = vi.hoisted(() => ({
|
||||
loadMatch3DSpritesheetAssetRegions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -65,6 +68,16 @@ vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
|
||||
return {
|
||||
...actual,
|
||||
loadMatch3DSpritesheetAssetRegions:
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -96,6 +109,7 @@ afterEach(() => {
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1493,6 +1507,194 @@ test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({
|
||||
label,
|
||||
x: index * 10,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
sheetWidth: 64,
|
||||
sheetHeight: 64,
|
||||
imageSrc: `data:image/png;base64,${label}`,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,返回');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,设置');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,移出');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,凑齐');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,打乱');
|
||||
});
|
||||
expect(
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
|
||||
maxRegions: 6,
|
||||
source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
containerPrompt: '兼容UI素材',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-container-image').getAttribute('src'),
|
||||
).toBe('/match3d-background-references/pot-fused-reference.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation(
|
||||
async ({ source }: { source: string }) => {
|
||||
if (source.includes('item-spritesheet')) {
|
||||
return Array.from({ length: 100 }, (_, index) => ({
|
||||
label: `素材${index + 1}`,
|
||||
x: index * 2,
|
||||
y: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
sheetWidth: 200,
|
||||
sheetHeight: 2,
|
||||
imageSrc: `data:image/png;base64,item-${index + 1}`,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
generatedItemAssets={[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
itemSpritesheetPrompt: '果园物品',
|
||||
itemSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
itemSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByTestId('match3d-tray-image') as HTMLImageElement).src,
|
||||
).toBe('data:image/png;base64,item-1');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
const playSpy = vi
|
||||
@@ -1570,10 +1772,10 @@ test('本地试玩按难度档位生成类型并兼容历史硬核消除数', ()
|
||||
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
|
||||
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
|
||||
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
|
||||
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
|
||||
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(20);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20);
|
||||
expect(countTypes(smallRun)).toBe(9);
|
||||
expect(countTypes(hardRun)).toBe(21);
|
||||
expect(countTypes(hardRun)).toBe(20);
|
||||
expect(hardRun.clearCount).toBe(21);
|
||||
expect(hardRun.items).toHaveLength(63);
|
||||
});
|
||||
@@ -1593,9 +1795,9 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(21);
|
||||
expect(visualKeys.size).toBe(21);
|
||||
expect(signatures.size).toBe(21);
|
||||
expect(firstItemByType.size).toBe(20);
|
||||
expect(visualKeys.size).toBe(20);
|
||||
expect(signatures.size).toBe(20);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
@@ -1690,7 +1892,7 @@ test('硬核档位按五档体积比例生成尺寸', () => {
|
||||
}
|
||||
|
||||
expect(tierCounts.get('XL')).toBe(4);
|
||||
expect(tierCounts.get('L')).toBe(7);
|
||||
expect(tierCounts.get('L')).toBe(6);
|
||||
expect(tierCounts.get('M')).toBe(6);
|
||||
expect(tierCounts.get('XS')).toBe(3);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
@@ -1706,7 +1908,7 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect(radiiByVisualKey.size).toBe(20);
|
||||
expect(
|
||||
[...radiiByVisualKey.values()].every((radii) => radii.size === 1),
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user