This commit is contained in:
2026-05-11 20:27:41 +08:00
parent e30b733b17
commit 481a27fc53
60 changed files with 6357 additions and 1100 deletions

View File

@@ -5,6 +5,7 @@ import {
ImagePlus,
Loader2,
MessageSquareText,
Music,
Play,
Plus,
Sparkles,
@@ -15,12 +16,18 @@ import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
createBackgroundMusicTask,
publishBackgroundMusicAsset,
waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -51,7 +58,7 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work';
type PuzzleResultTab = 'levels' | 'work' | 'music';
type DraftEditState = {
workTitle: string;
@@ -65,6 +72,8 @@ const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
@@ -164,6 +173,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
coverAssetId: level.coverAssetId ?? null,
backgroundMusic: level.backgroundMusic ?? null,
generationStatus: level.generationStatus || 'idle',
}));
}
@@ -267,6 +277,7 @@ function createBlankPuzzleLevel(
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
backgroundMusic: null,
generationStatus: 'idle',
};
}
@@ -370,10 +381,11 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
<button
key={tab.id}
@@ -1315,6 +1327,189 @@ function PuzzleWorkInfoTab({
);
}
function PuzzleMusicTab({
editState,
profileId,
sessionId,
isBusy,
onChange,
}: {
editState: DraftEditState;
profileId: string | null;
sessionId: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
const [prompt, setPrompt] = useState(() =>
[
editState.workTitle.trim(),
editState.workDescription.trim(),
editState.themeTags.join(''),
'轻快、适合拼图游戏循环播放的背景音乐',
]
.filter(Boolean)
.join(''),
);
const [title, setTitle] = useState(() =>
`${editState.workTitle.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 writeMusic = (music: CreationAudioAsset) => {
const firstLevel = editState.levels[0];
if (!firstLevel) {
return;
}
onChange({
...editState,
levels: [
{ ...firstLevel, backgroundMusic: music },
...editState.levels.slice(1),
],
});
};
const generateMusic = async () => {
if (!canGenerate || isGenerating || !editState.levels[0]) {
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: 'puzzle_work',
entityId: profileId ?? sessionId,
slot: PUZZLE_BACKGROUND_MUSIC_SLOT,
assetKind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
profileId,
storagePrefix: 'puzzle_assets',
}),
);
if (!asset.audioSrc) {
throw new Error('音频生成完成但缺少播放地址。');
}
writeMusic({
taskId: asset.taskId,
provider: asset.provider,
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? PUZZLE_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={isBusy || 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={isBusy || 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={isBusy || 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 || isBusy || isGenerating}
onClick={() => void generateMusic()}
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || isBusy || 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>
);
}
function PuzzleResultActionBar({
actionError,
editState,
@@ -1686,7 +1881,8 @@ export function PuzzleResultView({
}}
onOpenLevel={setActiveLevelId}
/>
) : (
) : null}
{activeTab === 'work' ? (
<PuzzleWorkInfoTab
editState={editState}
tagGenerationError={tagGenerationError}
@@ -1712,7 +1908,16 @@ export function PuzzleResultView({
});
}}
/>
)}
) : null}
{activeTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId ?? null}
sessionId={session.sessionId}
isBusy={isBusy}
onChange={setEditState}
/>
) : null}
</div>
{error ? (