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

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