1
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ImagePlus,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type {
|
||||
Hyper3dDownloadFilePayload,
|
||||
Hyper3dGenerationMode,
|
||||
@@ -33,12 +35,23 @@ import {
|
||||
getHyper3dTaskStatus,
|
||||
submitHyper3dImageToModel,
|
||||
} from '../../services/hyper3dModelGenerationService';
|
||||
import {
|
||||
createBackgroundMusicTask,
|
||||
createSoundEffectTask,
|
||||
publishBackgroundMusicAsset,
|
||||
publishSoundEffectAsset,
|
||||
waitForGeneratedAudioAsset,
|
||||
} from '../../services/creation-audio';
|
||||
import {
|
||||
publishMatch3DWork,
|
||||
generateMatch3DWorkTags,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
updateMatch3DWork,
|
||||
} from '../../services/match3d-works';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
readAssetBytes,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { Match3DModelPreview } from './Match3DModelPreview';
|
||||
|
||||
@@ -54,7 +67,7 @@ type Match3DResultViewProps = {
|
||||
};
|
||||
|
||||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Match3DResultTab = 'work' | 'config' | 'assets';
|
||||
type Match3DResultTab = 'work' | 'config' | 'assets' | 'music';
|
||||
type Match3DAssetGenerationMode = Hyper3dGenerationMode;
|
||||
type Match3DAssetTaskStatus =
|
||||
| 'idle'
|
||||
@@ -78,6 +91,8 @@ type Match3DRodinAssetDraft = {
|
||||
status: Match3DAssetTaskStatus;
|
||||
progress: number | null;
|
||||
downloads: Hyper3dDownloadFilePayload[];
|
||||
backgroundMusic: CreationAudioAsset | null;
|
||||
clickSound: CreationAudioAsset | null;
|
||||
error: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -96,11 +111,14 @@ 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_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
|
||||
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
|
||||
|
||||
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
|
||||
{ id: 'work', label: '作品信息' },
|
||||
{ id: 'config', label: '玩法配置' },
|
||||
{ id: 'assets', label: '3D素材' },
|
||||
{ id: 'music', label: '音乐' },
|
||||
];
|
||||
|
||||
function normalizeTags(value: string) {
|
||||
@@ -156,14 +174,58 @@ function normalizeMatch3DTagListText(value: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
|
||||
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
|
||||
}
|
||||
|
||||
function mergeMatch3DGeneratedItemAsset(
|
||||
base: Match3DGeneratedItemAsset,
|
||||
override: Match3DGeneratedItemAsset,
|
||||
): Match3DGeneratedItemAsset {
|
||||
const overrideHasModel = hasMatch3DGeneratedModelSource(override);
|
||||
return {
|
||||
...base,
|
||||
itemName: override.itemName.trim() || base.itemName,
|
||||
imageSrc: override.imageSrc?.trim()
|
||||
? override.imageSrc
|
||||
: base.imageSrc ?? null,
|
||||
imageObjectKey: override.imageObjectKey?.trim()
|
||||
? override.imageObjectKey
|
||||
: base.imageObjectKey ?? null,
|
||||
modelSrc: override.modelSrc?.trim()
|
||||
? override.modelSrc
|
||||
: base.modelSrc ?? null,
|
||||
modelObjectKey: override.modelObjectKey?.trim()
|
||||
? override.modelObjectKey
|
||||
: base.modelObjectKey ?? null,
|
||||
modelFileName: override.modelFileName?.trim()
|
||||
? override.modelFileName
|
||||
: base.modelFileName ?? null,
|
||||
taskUuid: override.taskUuid?.trim()
|
||||
? override.taskUuid
|
||||
: base.taskUuid ?? null,
|
||||
subscriptionKey: override.subscriptionKey?.trim()
|
||||
? override.subscriptionKey
|
||||
: base.subscriptionKey ?? null,
|
||||
backgroundMusic: override.backgroundMusic ?? base.backgroundMusic ?? null,
|
||||
clickSound: override.clickSound ?? base.clickSound ?? null,
|
||||
// 中文注释:草稿 response 可能只有图片;profile 里若已有模型,结果页和试玩不能被旧草稿快照覆盖回 image_ready。
|
||||
status:
|
||||
overrideHasModel && base.status !== 'model_ready'
|
||||
? 'model_ready'
|
||||
: base.status,
|
||||
error: override.error ?? base.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createMatch3DAssetDrafts(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null = null,
|
||||
): Match3DRodinAssetDraft[] {
|
||||
const generatedAssets =
|
||||
draft?.generatedItemAssets?.length
|
||||
? draft.generatedItemAssets
|
||||
: profile.generatedItemAssets;
|
||||
const generatedAssets = resolveMatch3DResultGeneratedItemAssets(
|
||||
profile,
|
||||
draft,
|
||||
);
|
||||
if (generatedAssets?.length) {
|
||||
return generatedAssets.map((asset) =>
|
||||
createMatch3DAssetDraftFromGeneratedAsset(profile, asset),
|
||||
@@ -214,6 +276,8 @@ function createMatch3DAssetDrafts(
|
||||
status: 'idle',
|
||||
progress: null,
|
||||
downloads: [],
|
||||
backgroundMusic: null,
|
||||
clickSound: null,
|
||||
error: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
@@ -223,11 +287,13 @@ function createMatch3DAssetDraftFromGeneratedAsset(
|
||||
profile: Match3DWorkProfile,
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
): Match3DRodinAssetDraft {
|
||||
const downloads = asset.modelSrc
|
||||
const modelSource =
|
||||
asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
|
||||
const downloads = modelSource
|
||||
? [
|
||||
{
|
||||
name: asset.modelFileName ?? `${asset.itemName}.glb`,
|
||||
url: asset.modelSrc,
|
||||
url: modelSource,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -248,11 +314,50 @@ function createMatch3DAssetDraftFromGeneratedAsset(
|
||||
: normalizeMatch3DAssetStatus(asset.status),
|
||||
progress: asset.status === 'model_ready' ? 1 : null,
|
||||
downloads,
|
||||
backgroundMusic: asset.backgroundMusic ?? null,
|
||||
clickSound: asset.clickSound ?? null,
|
||||
error: asset.error ?? null,
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createGeneratedAssetsFromDrafts(
|
||||
assetDrafts: Match3DRodinAssetDraft[],
|
||||
existingAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
): Match3DGeneratedItemAsset[] {
|
||||
const existingById = new Map(existingAssets.map((asset) => [asset.itemId, asset]));
|
||||
return assetDrafts.map((asset) => {
|
||||
const existing = existingById.get(asset.id);
|
||||
const modelFile = asset.downloads.find((file) => file.url.trim()) ?? null;
|
||||
const modelSource =
|
||||
modelFile?.url.trim() ||
|
||||
existing?.modelSrc?.trim() ||
|
||||
existing?.modelObjectKey?.trim() ||
|
||||
null;
|
||||
const modelObjectKey =
|
||||
modelFile?.url && isGeneratedLegacyPath(modelFile.url)
|
||||
? modelFile.url.trim().replace(/^\/+/u, '')
|
||||
: modelFile
|
||||
? null
|
||||
: existing?.modelObjectKey ?? null;
|
||||
return {
|
||||
itemId: asset.id,
|
||||
itemName: asset.name,
|
||||
imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null),
|
||||
imageObjectKey: existing?.imageObjectKey ?? null,
|
||||
modelSrc: modelSource,
|
||||
modelObjectKey,
|
||||
modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null,
|
||||
taskUuid: asset.taskUuid,
|
||||
subscriptionKey: asset.subscriptionKey,
|
||||
backgroundMusic: asset.backgroundMusic,
|
||||
clickSound: asset.clickSound,
|
||||
status: asset.status === 'done' ? 'model_ready' : asset.status,
|
||||
error: asset.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (
|
||||
@@ -467,13 +572,14 @@ async function resolveRodinReferenceImageDataUrl(source: string) {
|
||||
function buildPlayableProfile(
|
||||
profile: Match3DWorkProfile,
|
||||
editState: Match3DResultEditState,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return profile;
|
||||
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
|
||||
}
|
||||
|
||||
return {
|
||||
return attachMatch3DGeneratedItemAssets({
|
||||
...profile,
|
||||
gameName: payload.gameName,
|
||||
themeText: payload.themeText ?? profile.themeText,
|
||||
@@ -482,6 +588,51 @@ function buildPlayableProfile(
|
||||
coverImageSrc: payload.coverImageSrc,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
}, generatedItemAssets);
|
||||
}
|
||||
|
||||
function resolveMatch3DResultGeneratedItemAssets(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
) {
|
||||
const profileAssets = profile.generatedItemAssets ?? [];
|
||||
const draftAssets = draft?.generatedItemAssets ?? [];
|
||||
if (draftAssets.length <= 0) {
|
||||
return profileAssets;
|
||||
}
|
||||
if (profileAssets.length <= 0) {
|
||||
return draftAssets;
|
||||
}
|
||||
|
||||
const profileAssetsById = new Map(
|
||||
profileAssets.map((asset) => [asset.itemId, asset]),
|
||||
);
|
||||
const mergedAssets = draftAssets.map((draftAsset) => {
|
||||
const profileAsset = profileAssetsById.get(draftAsset.itemId);
|
||||
return profileAsset
|
||||
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
|
||||
: draftAsset;
|
||||
});
|
||||
for (const profileAsset of profileAssets) {
|
||||
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
|
||||
mergedAssets.push(profileAsset);
|
||||
}
|
||||
}
|
||||
return mergedAssets;
|
||||
}
|
||||
|
||||
function attachMatch3DGeneratedItemAssets(
|
||||
profile: Match3DWorkProfile,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (generatedItemAssets.length <= 0) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
|
||||
return {
|
||||
...profile,
|
||||
generatedItemAssets: [...generatedItemAssets],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,7 +686,7 @@ function Match3DResultTabs({
|
||||
onChange: (tab: Match3DResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{MATCH3D_RESULT_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -918,12 +1069,16 @@ function Match3DRodinAssetListCard({
|
||||
function Match3DRodinAssetDetail({
|
||||
asset,
|
||||
busy,
|
||||
soundBusy,
|
||||
onChange,
|
||||
onGenerateClickSound,
|
||||
onSubmit,
|
||||
}: {
|
||||
asset: Match3DRodinAssetDraft;
|
||||
busy: boolean;
|
||||
soundBusy: boolean;
|
||||
onChange: (asset: Match3DRodinAssetDraft) => void;
|
||||
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const canSubmit = Boolean(asset.referenceImageSrc.trim());
|
||||
@@ -963,6 +1118,35 @@ function Match3DRodinAssetDetail({
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
点击音效
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || soundBusy}
|
||||
onClick={() => onGenerateClickSound(asset)}
|
||||
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="生成点击音效"
|
||||
title="生成点击音效"
|
||||
>
|
||||
{soundBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{asset.clickSound?.audioSrc ? (
|
||||
<audio className="w-full" controls src={asset.clickSound.audioSrc} />
|
||||
) : (
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无音效
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -973,15 +1157,19 @@ function Match3DAssetsTab({
|
||||
activeAssetId,
|
||||
assets,
|
||||
busyAssetId,
|
||||
soundBusyAssetId,
|
||||
onActiveAssetChange,
|
||||
onAssetChange,
|
||||
onGenerateClickSound,
|
||||
onSubmitAsset,
|
||||
}: {
|
||||
activeAssetId: string | null;
|
||||
assets: Match3DRodinAssetDraft[];
|
||||
busyAssetId: string | null;
|
||||
soundBusyAssetId: string | null;
|
||||
onActiveAssetChange: (assetId: string | null) => void;
|
||||
onAssetChange: (asset: Match3DRodinAssetDraft) => void;
|
||||
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
|
||||
onSubmitAsset: (assetId: string) => void;
|
||||
}) {
|
||||
const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? null;
|
||||
@@ -1006,7 +1194,9 @@ function Match3DAssetsTab({
|
||||
<Match3DRodinAssetDetail
|
||||
asset={activeAsset}
|
||||
busy={busyAssetId === activeAsset.id}
|
||||
soundBusy={soundBusyAssetId === activeAsset.id}
|
||||
onChange={onAssetChange}
|
||||
onGenerateClickSound={onGenerateClickSound}
|
||||
onSubmit={() => onSubmitAsset(activeAsset.id)}
|
||||
/>
|
||||
) : (
|
||||
@@ -1021,6 +1211,171 @@ function Match3DAssetsTab({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DMusicTab({
|
||||
assetDrafts,
|
||||
editState,
|
||||
profileId,
|
||||
busy,
|
||||
onMusicGenerated,
|
||||
}: {
|
||||
assetDrafts: Match3DRodinAssetDraft[];
|
||||
editState: Match3DResultEditState;
|
||||
profileId: string;
|
||||
busy: boolean;
|
||||
onMusicGenerated: (music: CreationAudioAsset) => void;
|
||||
}) {
|
||||
const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
[
|
||||
editState.gameName.trim(),
|
||||
editState.themeText.trim(),
|
||||
editState.summary.trim(),
|
||||
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
`${editState.gameName.trim() || '抓大鹅'}背景音乐`.slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('轻快, 休闲, 消除, instrumental');
|
||||
const [statusText, setStatusText] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
|
||||
|
||||
const generateMusic = async () => {
|
||||
if (!canGenerate || isGenerating) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setStatusText('生成中');
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishBackgroundMusicAsset(task.taskId, {
|
||||
entityKind: 'match3d_work',
|
||||
entityId: profileId,
|
||||
slot: 'background_music',
|
||||
assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
profileId,
|
||||
storagePrefix: 'match3d_assets',
|
||||
}),
|
||||
);
|
||||
if (!asset.audioSrc) {
|
||||
throw new Error('音频生成完成但缺少播放地址。');
|
||||
}
|
||||
onMusicGenerated({
|
||||
taskId: asset.taskId,
|
||||
provider: asset.provider,
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
assetKind: asset.assetKind ?? MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
audioSrc: asset.audioSrc,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
setStatusText('已生成');
|
||||
} catch (caughtError) {
|
||||
setErrorText(
|
||||
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
|
||||
);
|
||||
setStatusText(null);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
背景音乐
|
||||
</div>
|
||||
{statusText ? (
|
||||
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
|
||||
{statusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{currentMusic?.audioSrc ? (
|
||||
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
|
||||
) : (
|
||||
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
<Music className="h-4 w-4" />
|
||||
暂无音乐
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
曲名
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
disabled={busy || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="抓大鹅背景音乐曲名"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={busy || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="抓大鹅背景音乐风格"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={busy || isGenerating}
|
||||
rows={5}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate || busy || isGenerating}
|
||||
onClick={() => void generateMusic()}
|
||||
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
生成音乐
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{errorText ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DResultView({
|
||||
profile,
|
||||
draft = null,
|
||||
@@ -1038,6 +1393,7 @@ export function Match3DResultView({
|
||||
);
|
||||
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
|
||||
const [busyAssetId, setBusyAssetId] = useState<string | null>(null);
|
||||
const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<Match3DAutoSaveState>('idle');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -1051,6 +1407,10 @@ export function Match3DResultView({
|
||||
);
|
||||
const canStartTestRun = testRunBlockers.length === 0;
|
||||
const canSubmit = blockers.length === 0;
|
||||
const generatedItemAssets = useMemo(
|
||||
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
|
||||
[draft?.generatedItemAssets, profile],
|
||||
);
|
||||
const totalItemCount =
|
||||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||||
3;
|
||||
@@ -1065,6 +1425,7 @@ export function Match3DResultView({
|
||||
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
|
||||
setActiveAssetId(null);
|
||||
setBusyAssetId(null);
|
||||
setSoundBusyAssetId(null);
|
||||
}, [draft?.generatedItemAssets, profile.generatedItemAssets, profile.profileId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1128,9 +1489,13 @@ export function Match3DResultView({
|
||||
setAutoSaveState('saving');
|
||||
setLocalError(null);
|
||||
const { item } = await updateMatch3DWork(profile.profileId, payload);
|
||||
const playableItem = attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
generatedItemAssets,
|
||||
);
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(item);
|
||||
return item;
|
||||
onSaved?.(playableItem);
|
||||
return playableItem;
|
||||
};
|
||||
|
||||
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -1211,6 +1576,102 @@ export function Match3DResultView({
|
||||
const getRodinAsset = (assetId: string) =>
|
||||
assetDrafts.find((asset) => asset.id === assetId) ?? null;
|
||||
|
||||
const persistAudioAssetDrafts = async (
|
||||
nextDrafts: Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
return persistGeneratedAssetDrafts(nextDrafts);
|
||||
};
|
||||
|
||||
const persistGeneratedAssetDrafts = async (
|
||||
nextDrafts: Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, {
|
||||
generatedItemAssets: createGeneratedAssetsFromDrafts(
|
||||
nextDrafts,
|
||||
profile.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
onSaved?.(item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const patchAndPersistAudioAssetDrafts = async (
|
||||
patcher: (drafts: Match3DRodinAssetDraft[]) => Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
const nextDrafts = patcher(assetDrafts);
|
||||
setAssetDrafts(nextDrafts);
|
||||
await persistAudioAssetDrafts(nextDrafts);
|
||||
};
|
||||
|
||||
const handleBackgroundMusicGenerated = async (music: CreationAudioAsset) => {
|
||||
try {
|
||||
await patchAndPersistAudioAssetDrafts((drafts) => {
|
||||
if (drafts.length <= 0) {
|
||||
return drafts;
|
||||
}
|
||||
return drafts.map((asset, index) =>
|
||||
index === 0 ? { ...asset, backgroundMusic: music } : asset,
|
||||
);
|
||||
});
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '保存背景音乐失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateClickSound = async (asset: Match3DRodinAssetDraft) => {
|
||||
if (soundBusyAssetId) {
|
||||
return;
|
||||
}
|
||||
setSoundBusyAssetId(asset.id);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const prompt = `${editState.themeText.trim() || '抓大鹅'}物体${asset.name.trim()}被点击消除时的短促反馈音效,清脆、可爱、适合移动端休闲游戏。`;
|
||||
const task = await createSoundEffectTask({
|
||||
prompt,
|
||||
duration: 3,
|
||||
});
|
||||
const generated = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishSoundEffectAsset(task.taskId, {
|
||||
entityKind: 'match3d_item',
|
||||
entityId: asset.id,
|
||||
slot: 'click_sound',
|
||||
assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||||
profileId: profile.profileId,
|
||||
storagePrefix: 'match3d_assets',
|
||||
}),
|
||||
);
|
||||
if (!generated.audioSrc) {
|
||||
throw new Error('音效生成完成但缺少播放地址。');
|
||||
}
|
||||
const clickSound: CreationAudioAsset = {
|
||||
taskId: generated.taskId,
|
||||
provider: generated.provider,
|
||||
assetObjectId: generated.assetObjectId ?? null,
|
||||
assetKind: generated.assetKind ?? MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||||
audioSrc: generated.audioSrc,
|
||||
prompt,
|
||||
title: `${asset.name}点击音效`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await patchAndPersistAudioAssetDrafts((drafts) =>
|
||||
drafts.map((draftAsset) =>
|
||||
draftAsset.id === asset.id
|
||||
? { ...draftAsset, clickSound, updatedAt: new Date().toISOString() }
|
||||
: draftAsset,
|
||||
),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '点击音效生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setSoundBusyAssetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitRodinAsset = async (assetId: string) => {
|
||||
const asset = getRodinAsset(assetId);
|
||||
if (!asset || busyAssetId) {
|
||||
@@ -1302,11 +1763,24 @@ export function Match3DResultView({
|
||||
if (!modelFile) {
|
||||
throw new Error('Hyper3D 已完成但未返回可下载模型文件。');
|
||||
}
|
||||
patchRodinAsset(assetId, {
|
||||
status: 'done',
|
||||
downloads: [modelFile],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const updatedAt = new Date().toISOString();
|
||||
const nextDrafts = assetDrafts.map((draftAsset) =>
|
||||
draftAsset.id === assetId
|
||||
? {
|
||||
...draftAsset,
|
||||
mode: 'image-to-model' as const,
|
||||
taskUuid: response.taskUuid,
|
||||
subscriptionKey: response.subscriptionKey,
|
||||
status: 'done' as const,
|
||||
progress: 1,
|
||||
downloads: [modelFile],
|
||||
error: null,
|
||||
updatedAt,
|
||||
}
|
||||
: draftAsset,
|
||||
);
|
||||
setAssetDrafts(nextDrafts);
|
||||
await persistGeneratedAssetDrafts(nextDrafts);
|
||||
} catch (caughtError) {
|
||||
patchRodinAsset(assetId, {
|
||||
status: 'failed',
|
||||
@@ -1330,7 +1804,10 @@ export function Match3DResultView({
|
||||
setIsStartingTestRun(true);
|
||||
try {
|
||||
const savedProfile = await saveNow();
|
||||
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
|
||||
onStartTestRun(
|
||||
savedProfile ??
|
||||
buildPlayableProfile(profile, editState, generatedItemAssets),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
|
||||
@@ -1363,7 +1840,12 @@ export function Match3DResultView({
|
||||
}
|
||||
};
|
||||
|
||||
const busy = isBusy || isPublishing || isStartingTestRun || Boolean(busyAssetId);
|
||||
const busy =
|
||||
isBusy ||
|
||||
isPublishing ||
|
||||
isStartingTestRun ||
|
||||
Boolean(busyAssetId) ||
|
||||
Boolean(soundBusyAssetId);
|
||||
const workBusy = busy || isGeneratingTags;
|
||||
const displayError = error ?? localError;
|
||||
|
||||
@@ -1402,13 +1884,28 @@ export function Match3DResultView({
|
||||
activeAssetId={activeAssetId}
|
||||
assets={assetDrafts}
|
||||
busyAssetId={busyAssetId}
|
||||
soundBusyAssetId={soundBusyAssetId}
|
||||
onActiveAssetChange={setActiveAssetId}
|
||||
onAssetChange={updateRodinAsset}
|
||||
onGenerateClickSound={(asset) => {
|
||||
void handleGenerateClickSound(asset);
|
||||
}}
|
||||
onSubmitAsset={(assetId) => {
|
||||
void handleSubmitRodinAsset(assetId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'music' ? (
|
||||
<Match3DMusicTab
|
||||
assetDrafts={assetDrafts}
|
||||
editState={editState}
|
||||
profileId={profile.profileId}
|
||||
busy={busy}
|
||||
onMusicGenerated={(music) => {
|
||||
void handleBackgroundMusicGenerated(music);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{displayError ? (
|
||||
|
||||
Reference in New Issue
Block a user