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

@@ -14,6 +14,10 @@ import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
const match3dSpritesheetParser = vi.hoisted(() => ({
loadMatch3DSpritesheetAssetRegions: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -45,9 +49,20 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
return {
...actual,
loadMatch3DSpritesheetAssetRegions:
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
};
});
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
vi.unstubAllGlobals();
});
@@ -687,8 +702,10 @@ describe('Match3DResultView', () => {
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI素材' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.getAllByRole('button', { name: /打开.+物品素材/u }))
.toHaveLength(20);
fireEvent.click(
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
@@ -1075,7 +1092,7 @@ describe('Match3DResultView', () => {
);
});
expect(screen.getByText('63 件')).toBeTruthy();
expect(screen.getAllByText('21 种').length).toBeGreaterThan(0);
expect(screen.getAllByText('20 种').length).toBeGreaterThan(0);
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
clearCount: 21,
@@ -1323,13 +1340,32 @@ describe('Match3DResultView', () => {
).toBeNull();
});
test('素材配置 UI 子 Tab 展示默认提示词并支持预览UI页面', () => {
test('素材配置 UI素材子 Tab 仅预览背景图和UI spritesheet', () => {
render(
<Match3DResultView
profile={createProfile({
backgroundPrompt: '果园主题抓大鹅竖屏背景',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
levelScenePrompt: '果园完整关卡画面',
levelSceneImageSrc:
'/generated-match3d-assets/session/profile/level-scene/scene.png',
levelSceneImageObjectKey: null,
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
@@ -1337,15 +1373,18 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/background/background.png',
);
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
'value',
'果园主题抓大鹅竖屏背景',
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(
screen.queryByLabelText('UI背景图画面描述提示词'),
).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
@@ -1359,7 +1398,7 @@ describe('Match3DResultView', () => {
'img[src="/match3d-background-references/pot-fused-reference.png"]',
);
expect(containerImage).toBeTruthy();
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
expect(containerImage?.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage?.className).toContain('-translate-x-1/2');
expect(
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
@@ -1371,7 +1410,7 @@ describe('Match3DResultView', () => {
).toBeTruthy();
});
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
test('素材配置 UI素材子 Tab 从物品挂载资产展示生成背景和UI spritesheet', async () => {
const onStartTestRun = vi.fn();
const profile = createProfile({
backgroundPrompt: null,
@@ -1388,11 +1427,14 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
uiSpritesheetPrompt: '果园UI素材',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
@@ -1417,15 +1459,16 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/background/background.png',
);
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
'value',
'果园背景',
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(
@@ -1435,14 +1478,14 @@ describe('Match3DResultView', () => {
).toBeTruthy();
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
'img[src="/generated-match3d-assets/session/profile/ui-spritesheet/ui.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
),
).toBeNull();
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1455,8 +1498,8 @@ describe('Match3DResultView', () => {
generatedBackgroundAsset: expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
}),
}),
{
@@ -1466,7 +1509,81 @@ describe('Match3DResultView', () => {
});
});
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {
test('素材配置 UI素材子 Tab 预览物品spritesheet解析结果', async () => {
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
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}`,
})),
);
render(
<Match3DResultView
profile={createProfile({
generatedItemAssets: [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
],
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
itemSpritesheetPrompt: '物品 spritesheet',
itemSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
itemSpritesheetImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('物品素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
);
await waitFor(() => {
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-0-0')
.getAttribute('src'),
).toBe('data:image/png;base64,item-1');
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-1-4')
.getAttribute('src'),
).toBe('data:image/png;base64,item-10');
});
expect(screen.getByText('草莓')).toBeTruthy();
expect(screen.getByText('苹果')).toBeTruthy();
expect(
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
).toHaveBeenCalledWith(
expect.objectContaining({
maxRegions: 100,
source:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
}),
);
});
test('素材配置 UI素材子 Tab 不提供背景或容器重新生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1500,267 +1617,24 @@ describe('Match3DResultView', () => {
error: null,
},
});
const nextProfile = createProfile({
...profile,
backgroundPrompt: '新背景提示词',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: {
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
},
],
generatedBackgroundAsset: {
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockResolvedValue({
item: nextProfile,
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
backgroundImageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
generatedBackgroundAsset: nextProfile.generatedBackgroundAsset!,
prompt: '新背景提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
expect(
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
confirmPointCost();
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新背景提示词',
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
});
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
const deferred =
createDeferred<
Awaited<
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
>
>();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockReturnValue(deferred.promise);
render(
<Match3DResultView
profile={createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
).toBeTruthy();
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
});
});
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
const profile = createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
backgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
},
],
});
const nextBackgroundAsset = {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
};
const nextProfile = createProfile({
...profile,
generatedBackgroundAsset: nextBackgroundAsset,
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: nextBackgroundAsset,
},
],
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DContainerImage,
).mockResolvedValue({
item: nextProfile,
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
generatedBackgroundAsset: nextBackgroundAsset,
prompt: '新容器提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
target: { value: '新容器提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DContainerImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新容器提示词',
});
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).not.toHaveBeenCalled();
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByLabelText('容器形象画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: '容器形象' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
expect(match3dWorksService.generateMatch3DBackgroundImage).not.toHaveBeenCalled();
expect(match3dWorksService.generateMatch3DContainerImage).not.toHaveBeenCalled();
});
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {

View File

@@ -32,8 +32,6 @@ import type {
} from '../../../packages/shared/src/contracts/match3dWorks';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
generateMatch3DBackgroundImage,
generateMatch3DContainerImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
@@ -48,6 +46,11 @@ import {
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DItemSpritesheetViewRegions,
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
@@ -83,7 +86,7 @@ type Match3DResultViewProps = {
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultTab = 'work' | 'config' | 'assets';
type Match3DAssetConfigTab = 'items' | 'ui' | 'container';
type Match3DAssetConfigTab = 'items' | 'ui';
type Match3DAssetTaskStatus =
| 'idle'
| 'submitting'
@@ -102,6 +105,12 @@ type Match3DBatchItemGenerationState = {
error: string | null;
};
type Match3DItemSpritesheetPreviewGroup = {
itemIndex: number;
itemName: string;
regions: Match3DDecodedSpritesheetRegion[];
};
type Match3DTimedGenerationProgress = {
startedAtMs: number;
nowMs: number;
@@ -158,11 +167,9 @@ type Match3DCoverReferenceDraft = {
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS = 90;
const MATCH3D_DEFAULT_ASSET_COUNT = 20;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 20;
const MATCH3D_COVER_REFERENCE_IMAGE_LIMIT = 6;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
@@ -176,8 +183,7 @@ const MATCH3D_ASSET_CONFIG_TABS: Array<{
label: string;
}> = [
{ id: 'items', label: '物品' },
{ id: 'ui', label: 'UI' },
{ id: 'container', label: '容器形象' },
{ id: 'ui', label: 'UI素材' },
];
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
@@ -202,7 +208,7 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
label: '硬核',
clearCount: 21,
difficulty: 8,
itemTypeCount: 21,
itemTypeCount: 20,
},
] as const;
@@ -385,6 +391,56 @@ function resolveMatch3DContainerPreviewSource(
);
}
function resolveMatch3DUiSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DItemSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DContainerPrompt(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
@@ -589,6 +645,12 @@ function hasPersistableMatch3DGeneratedItemAsset(
asset.subscriptionKey?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.levelSceneImageSrc?.trim() ||
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
asset.backgroundAsset?.prompt?.trim() ||
@@ -627,8 +689,17 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
asset.backgroundMusic?.taskId?.trim() ??
'',
asset.backgroundAsset?.prompt?.trim() ?? '',
asset.backgroundAsset?.levelScenePrompt?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageSrc?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
@@ -770,9 +841,22 @@ function createMatch3DAssetDrafts(
name: `${theme}场景小物`,
usage: '圆形空间周边装饰物',
},
].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);
];
const fallbackSeeds = Array.from(
{ length: MATCH3D_DEFAULT_ASSET_COUNT },
(_, index) => {
const seed = seeds[index];
return (
seed ?? {
id: `generated-item-${index + 1}`,
name: `${theme}物品${index + 1}`,
usage: '局内点击消除物件',
}
);
},
);
return seeds.map((seed) => ({
return fallbackSeeds.map((seed) => ({
...seed,
prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage),
referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '',
@@ -2675,7 +2759,7 @@ function Match3DAssetConfigTabs({
onChange: (tab: Match3DAssetConfigTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
@@ -2697,39 +2781,62 @@ function Match3DAssetConfigTabs({
function Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
error,
progressRuntime,
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
error: string | null;
progressRuntime: Match3DTimedGenerationProgress | null;
onGenerate: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(backgroundPrompt);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [itemSpritesheetGroups, setItemSpritesheetGroups] = useState<
Match3DItemSpritesheetPreviewGroup[]
>([]);
useEffect(() => {
setPrompt(backgroundPrompt);
}, [backgroundPrompt]);
if (!itemSpritesheetPreviewSrc) {
setItemSpritesheetGroups((current) =>
current.length > 0 ? [] : current,
);
return undefined;
}
const normalizedPrompt = prompt.trim();
const generationProgress =
resolveMatch3DTimedGenerationProgress(progressRuntime);
let cancelled = false;
const controller = new AbortController();
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetPreviewSrc,
maxRegions: 100,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setItemSpritesheetGroups(
buildMatch3DItemSpritesheetViewRegions(regions, itemNames),
);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetGroups([]);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [itemNames, itemSpritesheetPreviewSrc]);
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(14rem,0.72fr)_minmax(0,1fr)]">
<div className="grid gap-4 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
@@ -2744,66 +2851,67 @@ function Match3DUIAssetsTab({
<span className="sr-only">UI页面预览</span>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={busy || isGenerating}
rows={7}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="UI背景图画面描述提示词"
<div className="flex min-h-0 flex-col gap-3">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<ResolvedAssetImage
src={uiSpritesheetPreviewSrc}
alt="UI素材图"
className="h-full w-full object-contain"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI页面
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
{isGenerating ? (
<div
role="progressbar"
aria-label="UI背景图生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
) : null}
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI页面
</button>
</div>
</div>
</section>
{itemSpritesheetPreviewSrc ? (
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<ResolvedAssetImage
src={itemSpritesheetPreviewSrc}
alt="物品素材图"
className="h-full w-full object-contain"
/>
</div>
{itemSpritesheetGroups.length > 0 ? (
<div className="grid max-h-[24rem] content-start gap-3 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
{itemSpritesheetGroups.map((group) => (
<div
key={`${group.itemIndex}-${group.itemName}`}
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="mb-2 truncate text-xs font-black text-[var(--platform-text-strong)]">
{group.itemName}
</div>
<div className="grid grid-cols-5 gap-1.5">
{group.regions.map((region, regionIndex) => (
<img
key={`${group.itemIndex}-${regionIndex}-${region.imageSrc}`}
src={region.imageSrc}
alt=""
aria-hidden="true"
data-testid={`match3d-item-spritesheet-preview-${group.itemIndex}-${regionIndex}`}
className="aspect-square w-full rounded-[0.55rem] border border-white/70 bg-white/82 object-contain p-1"
draggable={false}
/>
))}
</div>
</div>
))}
</div>
) : null}
</div>
</section>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
@@ -2813,217 +2921,10 @@ function Match3DUIAssetsTab({
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
containerPreviewSrc={MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC}
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="match3d-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="match3d-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)]">
{MATCH3D_UI_BACKGROUND_POINTS_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={!normalizedPrompt || busy || isGenerating}
onClick={() => {
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function Match3DContainerAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
containerPrompt,
busy,
isGenerating,
error,
progressRuntime,
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
containerPrompt: string;
busy: boolean;
isGenerating: boolean;
error: string | null;
progressRuntime: Match3DTimedGenerationProgress | null;
onGenerate: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(containerPrompt);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const hasContainerPreview = Boolean(containerPreviewSrc.trim());
useEffect(() => {
setPrompt(containerPrompt);
}, [containerPrompt]);
const normalizedPrompt = prompt.trim();
const generationProgress =
resolveMatch3DTimedGenerationProgress(progressRuntime);
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(14rem,0.72fr)_minmax(0,1fr)]">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="mx-auto aspect-square w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 text-left shadow-sm"
aria-label="打开容器形象预览"
>
<ResolvedAssetImage
src={containerPreviewSrc}
alt="容器形象"
className="h-full w-full object-contain"
/>
<span className="sr-only"></span>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={busy || isGenerating}
rows={7}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="容器形象画面描述提示词"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
disabled={!normalizedPrompt || busy || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
{isGenerating ? (
<div
role="progressbar"
aria-label="容器形象生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
) : null}
</div>
</div>
</section>
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={hasContainerPreview ? containerPreviewSrc : ''}
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="match3d-container-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="match3d-container-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)]">
{MATCH3D_UI_BACKGROUND_POINTS_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={!normalizedPrompt || busy || isGenerating}
onClick={() => {
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -3115,49 +3016,33 @@ function Match3DAssetConfigTab({
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
containerPrompt,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
backgroundGenerationError,
containerGenerationError,
batchGenerationState,
busy,
backgroundGenerationProgress,
containerGenerationProgress,
isGeneratingBackground,
isGeneratingContainer,
onActiveAssetChange,
onAddBatch,
onRegenerateBatch,
onAssetChange,
onAssetConfigTabChange,
onDeleteAsset,
onGenerateBackground,
onGenerateContainer,
}: {
activeAssetConfigTab: Match3DAssetConfigTab;
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
containerPrompt: string;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
backgroundGenerationError: string | null;
containerGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
busy: boolean;
backgroundGenerationProgress: Match3DTimedGenerationProgress | null;
containerGenerationProgress: Match3DTimedGenerationProgress | null;
isGeneratingBackground: boolean;
isGeneratingContainer: boolean;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onRegenerateBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
onDeleteAsset: (assetId: string) => void;
onGenerateBackground: (prompt: string) => void;
onGenerateContainer: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
@@ -3180,25 +3065,10 @@ function Match3DAssetConfigTab({
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={itemNames}
error={backgroundGenerationError}
progressRuntime={backgroundGenerationProgress}
onGenerate={onGenerateBackground}
/>
) : null}
{activeAssetConfigTab === 'container' ? (
<Match3DContainerAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
containerPrompt={containerPrompt}
busy={busy}
isGenerating={isGeneratingContainer}
error={containerGenerationError}
progressRuntime={containerGenerationProgress}
onGenerate={onGenerateContainer}
/>
) : null}
</div>
@@ -3255,18 +3125,9 @@ export function Match3DResultView({
const [batchRegenerateError, setBatchRegenerateError] = useState<
string | null
>(null);
const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
string | null
>(null);
const [backgroundGenerationProgress, setBackgroundGenerationProgress] =
useState<Match3DTimedGenerationProgress | null>(null);
const [isGeneratingContainer, setIsGeneratingContainer] = useState(false);
const [containerGenerationError, setContainerGenerationError] = useState<
string | null
>(null);
const [containerGenerationProgress, setContainerGenerationProgress] =
useState<Match3DTimedGenerationProgress | null>(null);
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
@@ -3299,24 +3160,6 @@ export function Match3DResultView({
),
[draft, generatedItemAssets, promotedProfile],
);
const backgroundPrompt = useMemo(
() =>
resolveMatch3DBackgroundPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPrompt = useMemo(
() =>
resolveMatch3DContainerPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(
@@ -3327,6 +3170,28 @@ export function Match3DResultView({
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[draft, generatedItemAssets, promotedProfile],
);
const uiSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DUiSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const itemSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DItemSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const generatedItemNames = useMemo(
() => generatedItemAssets.map((asset) => asset.itemName),
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() =>
resolveMatch3DCoverSourceAssets(
@@ -3349,11 +3214,6 @@ export function Match3DResultView({
setCoverAiRedraw(false);
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
setBackgroundGenerationProgress(null);
setContainerGenerationError(null);
setIsGeneratingContainer(false);
setContainerGenerationProgress(null);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
@@ -3369,56 +3229,6 @@ export function Match3DResultView({
profile.profileId,
]);
useEffect(() => {
if (!isGeneratingBackground) {
return undefined;
}
const startedAtMs = Date.now();
setBackgroundGenerationProgress({
startedAtMs,
nowMs: startedAtMs,
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
const timer = window.setInterval(() => {
setBackgroundGenerationProgress((current) =>
current
? {
...current,
nowMs: Date.now(),
}
: current,
);
}, 1000);
return () => window.clearInterval(timer);
}, [isGeneratingBackground]);
useEffect(() => {
if (!isGeneratingContainer) {
return undefined;
}
const startedAtMs = Date.now();
setContainerGenerationProgress({
startedAtMs,
nowMs: startedAtMs,
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
const timer = window.setInterval(() => {
setContainerGenerationProgress((current) =>
current
? {
...current,
nowMs: Date.now(),
}
: current,
);
}, 1000);
return () => window.clearInterval(timer);
}, [isGeneratingContainer]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
@@ -3461,12 +3271,6 @@ export function Match3DResultView({
if (cancelled) {
return;
}
if (isGeneratingBackground) {
return;
}
if (isGeneratingContainer) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
@@ -3481,8 +3285,6 @@ export function Match3DResultView({
}, [
editState,
generatedItemAssets,
isGeneratingBackground,
isGeneratingContainer,
onSaved,
profile,
]);
@@ -3906,92 +3708,6 @@ export function Match3DResultView({
});
};
const handleGenerateBackground = async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt || isGeneratingBackground) {
setBackgroundGenerationError('请填写画面描述提示词。');
return;
}
setIsGeneratingBackground(true);
setBackgroundGenerationProgress({
startedAtMs: Date.now(),
nowMs: Date.now(),
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
setBackgroundGenerationError(null);
try {
const response = await generateMatch3DBackgroundImage(profile.profileId, {
prompt: normalizedPrompt,
});
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
response.item.generatedItemAssets?.length
? response.item.generatedItemAssets
: generatedItemAssets,
response.generatedBackgroundAsset,
);
const refreshedProfile = attachMatch3DGeneratedItemAssets(
response.item,
nextGeneratedAssets,
);
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
onSaved?.(refreshedProfile);
setLocalError(null);
} catch (caughtError) {
setBackgroundGenerationError(
caughtError instanceof Error
? caughtError.message
: 'UI背景图生成失败。',
);
} finally {
setIsGeneratingBackground(false);
setBackgroundGenerationProgress(null);
}
};
const handleGenerateContainer = async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt || isGeneratingContainer) {
setContainerGenerationError('请填写容器形象提示词。');
return;
}
setIsGeneratingContainer(true);
setContainerGenerationProgress({
startedAtMs: Date.now(),
nowMs: Date.now(),
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
});
setContainerGenerationError(null);
try {
const response = await generateMatch3DContainerImage(profile.profileId, {
prompt: normalizedPrompt,
});
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
response.item.generatedItemAssets?.length
? response.item.generatedItemAssets
: generatedItemAssets,
response.generatedBackgroundAsset,
);
const refreshedProfile = attachMatch3DGeneratedItemAssets(
response.item,
nextGeneratedAssets,
);
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
onSaved?.(refreshedProfile);
setLocalError(null);
} catch (caughtError) {
setContainerGenerationError(
caughtError instanceof Error
? caughtError.message
: '容器形象生成失败。',
);
} finally {
setIsGeneratingContainer(false);
setContainerGenerationProgress(null);
}
};
const handleStartTestRun = async () => {
if (!canStartTestRun || isStartingTestRun) {
setLocalError(testRunBlockers[0] ?? null);
@@ -4063,9 +3779,7 @@ export function Match3DResultView({
isBusy ||
isPublishing ||
isStartingTestRun ||
isGeneratingCover ||
isGeneratingBackground ||
isGeneratingContainer;
isGeneratingCover;
const workBusy = busy || isGeneratingTags;
const displayError = error ?? localError;
const dialogPublishError = hasAttemptedPublish ? error ?? localError : null;
@@ -4104,17 +3818,11 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
containerPrompt={containerPrompt}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={generatedItemNames}
backgroundGenerationError={backgroundGenerationError}
containerGenerationError={containerGenerationError}
batchGenerationState={batchGenerationState}
busy={busy}
backgroundGenerationProgress={backgroundGenerationProgress}
containerGenerationProgress={containerGenerationProgress}
isGeneratingBackground={isGeneratingBackground}
isGeneratingContainer={isGeneratingContainer}
onActiveAssetChange={setActiveAssetId}
onAddBatch={() => {
setBatchAddError(null);
@@ -4137,12 +3845,6 @@ export function Match3DResultView({
onDeleteAsset={(assetId) => {
void handleDeleteAssetDraft(assetId);
}}
onGenerateBackground={(prompt) => {
void handleGenerateBackground(prompt);
}}
onGenerateContainer={(prompt) => {
void handleGenerateContainer(prompt);
}}
/>
) : null}
</div>