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:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -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);