This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -9,7 +9,11 @@ import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockDat
import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
createVisualNovelBackgroundMusicTask: vi.fn(),
createVisualNovelSoundEffectTask: vi.fn(),
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
publishVisualNovelBackgroundMusicAsset: vi.fn(),
publishVisualNovelSoundEffectAsset: vi.fn(),
uploadVisualNovelAsset: vi.fn(),
}));
@@ -91,8 +95,10 @@ test('visual novel result uploads scene and character assets into platform refer
uploadMock.mockResolvedValue({
assetObjectId: 'asset-scene-1',
assetKind: 'scene_image',
objectKey: 'generated-custom-world-scenes/vn-profile/scene-1/background.png',
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-1/background.png',
objectKey:
'generated-custom-world-scenes/vn-profile/scene-1/background.png',
imageSrc:
'/generated-custom-world-scenes/vn-profile/scene-1/background.png',
});
render(
@@ -112,9 +118,9 @@ test('visual novel result uploads scene and character assets into platform refer
});
await user.click(backgroundButtons[0]!);
const fileInput = within(screen.getByRole('dialog', { name: '背景图' })).getByLabelText(
'上传背景图文件',
) as HTMLInputElement;
const fileInput = within(
screen.getByRole('dialog', { name: '背景图' }),
).getByLabelText('上传背景图文件') as HTMLInputElement;
await user.upload(
fileInput,
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
@@ -124,7 +130,7 @@ test('visual novel result uploads scene and character assets into platform refer
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(onSaveDraft).toHaveBeenCalled();
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toContain(
'/generated-custom-world-scenes/',
);
expect(
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/');
});

View File

@@ -9,7 +9,9 @@ import {
PenLine,
Play,
Settings,
Sparkles,
Upload,
Waves,
X,
type LucideIcon,
} from 'lucide-react';
@@ -26,7 +28,11 @@ import type {
VisualNovelValidationIssue,
} from '../../../packages/shared/src/contracts/visualNovel';
import {
createVisualNovelBackgroundMusicTask,
createVisualNovelSoundEffectTask,
listVisualNovelHistoryAssets,
publishVisualNovelBackgroundMusicAsset,
publishVisualNovelSoundEffectAsset,
uploadVisualNovelAsset,
type VisualNovelAssetReference,
type VisualNovelHistoryAssetKind,
@@ -98,6 +104,17 @@ type VisualNovelAssetPickerConfig = {
previewTone: 'image' | 'audio';
};
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
type VisualNovelAudioGeneratorConfig = {
kind: VisualNovelAudioGeneratorKind;
scene: VisualNovelSceneDraft;
profileId?: string | null;
};
const AUDIO_POLL_INTERVAL_MS = 3600;
const AUDIO_POLL_MAX_ATTEMPTS = 36;
const RESULT_TABS: Array<{ id: VisualNovelResultTab; label: string }> = [
{ id: 'profile', label: '作品' },
{ id: 'world', label: '世界' },
@@ -537,7 +554,9 @@ function VisualNovelAssetPickerDialog({
</div>
) : null}
{!isLoadingHistory && config.historyKind && historyAssets.length <= 0 ? (
{!isLoadingHistory &&
config.historyKind &&
historyAssets.length <= 0 ? (
<div className="flex min-h-40 items-center justify-center rounded-[1.15rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/56 text-sm text-[var(--platform-text-base)]">
</div>
@@ -704,6 +723,299 @@ function VisualNovelAssetField({
);
}
async function waitForVisualNovelGeneratedAudioAsset(
config: VisualNovelAudioGeneratorConfig,
taskId: string,
) {
for (let attempt = 0; attempt < AUDIO_POLL_MAX_ATTEMPTS; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => {
window.setTimeout(resolve, AUDIO_POLL_INTERVAL_MS);
});
}
const payload = {
sceneId: config.scene.sceneId,
profileId: config.profileId ?? null,
};
const asset =
config.kind === 'background_music'
? await publishVisualNovelBackgroundMusicAsset(taskId, payload)
: await publishVisualNovelSoundEffectAsset(taskId, payload);
if (asset.audioSrc?.trim()) {
return asset;
}
}
throw new Error('音频生成仍在处理中,请稍后重试。');
}
function buildDefaultAudioPrompt(
kind: VisualNovelAudioGeneratorKind,
scene: VisualNovelSceneDraft,
) {
const name = scene.name.trim() || '当前场景';
const description = scene.description.trim();
if (kind === 'background_music') {
return [name, description, '适合作为视觉小说循环播放的无歌词背景音乐']
.filter(Boolean)
.join('');
}
return [name, description, '短促、清晰、适合场景切换时播放的环境音效']
.filter(Boolean)
.join('');
}
function VisualNovelAudioGeneratorDialog({
config,
disabled,
onClose,
onGenerated,
}: {
config: VisualNovelAudioGeneratorConfig;
disabled: boolean;
onClose: () => void;
onGenerated: (asset: VisualNovelAssetReference) => void;
}) {
const authUi = useAuthUi();
const platformTheme = authUi?.platformTheme ?? 'light';
const isBackgroundMusic = config.kind === 'background_music';
const [prompt, setPrompt] = useState(() =>
buildDefaultAudioPrompt(config.kind, config.scene),
);
const [title, setTitle] = useState(() =>
(config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40),
);
const [tags, setTags] = useState('cinematic, ambient, emotional');
const [duration, setDuration] = useState(5);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPrompt(buildDefaultAudioPrompt(config.kind, config.scene));
setTitle((config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40));
setTags('cinematic, ambient, emotional');
setDuration(5);
setError(null);
}, [config]);
const handleGenerate = async () => {
if (!prompt.trim()) {
setError('提示词不能为空。');
return;
}
if (isBackgroundMusic && !title.trim()) {
setError('标题不能为空。');
return;
}
setIsGenerating(true);
setError(null);
try {
const task = isBackgroundMusic
? await createVisualNovelBackgroundMusicTask({
prompt,
title,
tags: tags.trim() || null,
model: 'chirp-v4',
})
: await createVisualNovelSoundEffectTask({
prompt,
duration,
});
const asset = await waitForVisualNovelGeneratedAudioAsset(
config,
task.taskId,
);
onGenerated({
assetObjectId: asset.assetObjectId ?? task.taskId,
assetKind:
asset.assetKind ??
(isBackgroundMusic
? 'visual_novel_music'
: 'visual_novel_ambient_sound'),
objectKey: '',
imageSrc: asset.audioSrc ?? '',
profileId: config.profileId ?? null,
entityId: config.scene.sceneId,
});
onClose();
} catch (generateError) {
setError(
generateError instanceof Error
? generateError.message
: '音频生成失败。',
);
} finally {
setIsGenerating(false);
}
};
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[180] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget && !isGenerating) {
onClose();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label={isBackgroundMusic ? '生成音乐' : '生成音效'}
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,34rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
<h2 className="min-w-0 truncate text-base font-black text-[var(--platform-text-strong)]">
{isBackgroundMusic ? '生成音乐' : '生成音效'}
</h2>
<button
type="button"
className="platform-icon-button h-9 w-9"
onClick={onClose}
disabled={isGenerating}
aria-label="关闭"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
{isBackgroundMusic ? (
<>
<label className="block">
<FieldLabel></FieldLabel>
<input
value={title}
disabled={disabled || 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"
/>
</label>
<label className="block">
<FieldLabel></FieldLabel>
<input
value={tags}
disabled={disabled || 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"
/>
</label>
</>
) : (
<label className="block">
<FieldLabel></FieldLabel>
<input
type="range"
min={2}
max={10}
step={1}
value={duration}
disabled={disabled || isGenerating}
onChange={(event) => setDuration(Number(event.target.value))}
className="mt-3 w-full accent-[var(--platform-primary)]"
/>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{duration}
</div>
</label>
)}
<label className="block">
<FieldLabel></FieldLabel>
<textarea
value={prompt}
disabled={disabled || 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"
/>
</label>
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
<footer className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3">
<button
type="button"
disabled={isGenerating}
onClick={onClose}
className="platform-button platform-button--ghost min-h-10 px-4 text-sm"
>
</button>
<button
type="button"
disabled={disabled || isGenerating}
onClick={() => {
void handleGenerate();
}}
className="platform-button platform-button--primary min-h-10 px-4 text-sm"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</button>
</footer>
</section>
</div>,
document.body,
);
}
function VisualNovelAudioGenerateButton({
config,
disabled,
icon: Icon,
label,
onGenerated,
}: {
config: VisualNovelAudioGeneratorConfig;
disabled: boolean;
icon: LucideIcon;
label: string;
onGenerated: (asset: VisualNovelAssetReference) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3">
<div className="flex items-center justify-between gap-3">
<FieldLabel>{label}</FieldLabel>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className="platform-icon-button h-9 w-9"
aria-label={label}
title={label}
>
<Icon className="h-4 w-4" />
</button>
</div>
{isOpen ? (
<VisualNovelAudioGeneratorDialog
config={config}
disabled={disabled}
onClose={() => setIsOpen(false)}
onGenerated={onGenerated}
/>
) : null}
</div>
);
}
function VisualNovelProfileTab({
draft,
disabled,
@@ -777,7 +1089,10 @@ function VisualNovelProfileTab({
value={draft.workTags.join('')}
disabled={disabled}
onChange={(event) =>
onChange({ ...draft, workTags: normalizeTags(event.target.value) })
onChange({
...draft,
workTags: normalizeTags(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"
/>
@@ -870,7 +1185,9 @@ function VisualNovelOpeningTab({
<label>
<FieldLabel></FieldLabel>
<textarea
value={draft.opening.initialChoices.map((choice) => choice.text).join('\n')}
value={draft.opening.initialChoices
.map((choice) => choice.text)
.join('\n')}
disabled={disabled}
rows={4}
onChange={(event) =>
@@ -878,16 +1195,16 @@ function VisualNovelOpeningTab({
...draft,
opening: {
...draft.opening,
initialChoices: normalizeListInput(event.target.value).slice(0, 4).map(
(text, index) => ({
initialChoices: normalizeListInput(event.target.value)
.slice(0, 4)
.map((text, index) => ({
choiceId:
draft.opening.initialChoices[index]?.choiceId ??
`${draft.profileId ?? 'vn'}-opening-choice-${index + 1}`,
text,
actionHint:
draft.opening.initialChoices[index]?.actionHint ?? null,
}),
),
})),
},
})
}
@@ -984,7 +1301,8 @@ function VisualNovelRuntimeConfigTab({
...draft,
runtimeConfig: {
...draft.runtimeConfig,
attributePanelMode: event.target.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
attributePanelMode: event.target
.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
},
})
}
@@ -1113,10 +1431,12 @@ function VisualNovelCharacterEditor({
function VisualNovelSceneEditor({
item,
disabled,
profileId,
onChange,
}: {
item: VisualNovelSceneDraft;
disabled: boolean;
profileId?: string | null;
onChange: (item: VisualNovelSceneDraft) => void;
}) {
return (
@@ -1151,7 +1471,8 @@ function VisualNovelSceneEditor({
onChange={(event) =>
onChange({
...item,
availability: event.target.value as VisualNovelSceneAvailability,
availability: event.target
.value as VisualNovelSceneAvailability,
})
}
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"
@@ -1169,7 +1490,10 @@ function VisualNovelSceneEditor({
value={item.phaseIds.join('')}
disabled={disabled}
onChange={(event) =>
onChange({ ...item, phaseIds: normalizeListInput(event.target.value) })
onChange({
...item,
phaseIds: normalizeListInput(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"
/>
@@ -1183,7 +1507,7 @@ function VisualNovelSceneEditor({
uploadKind="scene_background"
accept="image/png,image/jpeg,image/webp"
historyKind="scene_image"
profileId={null}
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="image"
onSelect={(asset) =>
@@ -1197,11 +1521,49 @@ function VisualNovelSceneEditor({
disabled={disabled}
uploadKind="music"
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
profileId={null}
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="audio"
onSelect={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
/>
<VisualNovelAudioGenerateButton
label="生成音乐"
icon={Sparkles}
disabled={disabled}
config={{
kind: 'background_music',
scene: item,
profileId: profileId ?? null,
}}
onGenerated={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
/>
<VisualNovelAssetField
label="音效"
icon={Waves}
assetSrc={item.ambientSoundSrc}
disabled={disabled}
uploadKind="ambient_sound"
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="audio"
onSelect={(asset) =>
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
}
/>
<VisualNovelAudioGenerateButton
label="生成音效"
icon={Sparkles}
disabled={disabled}
config={{
kind: 'sound_effect',
scene: item,
profileId: profileId ?? null,
}}
onGenerated={(asset) =>
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
}
/>
</div>
);
}
@@ -1238,7 +1600,10 @@ function VisualNovelPhaseEditor({
value={item.sceneIds.join('')}
disabled={disabled}
onChange={(event) =>
onChange({ ...item, sceneIds: normalizeListInput(event.target.value) })
onChange({
...item,
sceneIds: normalizeListInput(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"
/>
@@ -1334,10 +1699,24 @@ function VisualNovelEntityGrid({
onCreate,
onOpen,
}: {
items: Array<VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft>;
items: Array<
| VisualNovelCharacterDraft
| VisualNovelSceneDraft
| VisualNovelStoryPhaseDraft
>;
emptyText: string;
getTitle: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
getMeta: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
getTitle: (
item:
| VisualNovelCharacterDraft
| VisualNovelSceneDraft
| VisualNovelStoryPhaseDraft,
) => string;
getMeta: (
item:
| VisualNovelCharacterDraft
| VisualNovelSceneDraft
| VisualNovelStoryPhaseDraft,
) => string;
kind: VisualNovelEditorKind;
onCreate: () => void;
onOpen: (target: VisualNovelEditorTarget) => void;
@@ -1371,7 +1750,10 @@ function VisualNovelEntityGrid({
} else if (kind === 'scene') {
onOpen({ kind, item: item as VisualNovelSceneDraft });
} else {
onOpen({ kind: 'phase', item: item as VisualNovelStoryPhaseDraft });
onOpen({
kind: 'phase',
item: item as VisualNovelStoryPhaseDraft,
});
}
}}
className="platform-subpanel min-h-32 rounded-[1.25rem] p-4 text-left transition hover:-translate-y-0.5"
@@ -1515,6 +1897,7 @@ function VisualNovelEditorDialog({
<VisualNovelSceneEditor
item={target.item}
disabled={disabled}
profileId={draft.profileId}
onChange={updateScene}
/>
) : null}
@@ -1561,11 +1944,12 @@ export function VisualNovelResultView({
onStartTestRun,
onPublish,
}: VisualNovelResultViewProps) {
const [editDraft, setEditDraft] = useState(() => cloneDraft(draft ?? mockVisualNovelDraft));
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
const [editorTarget, setEditorTarget] = useState<VisualNovelEditorTarget | null>(
null,
const [editDraft, setEditDraft] = useState(() =>
cloneDraft(draft ?? mockVisualNovelDraft),
);
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
const [editorTarget, setEditorTarget] =
useState<VisualNovelEditorTarget | null>(null);
const blockers = useMemo(() => buildPublishBlockers(editDraft), [editDraft]);
const canPublish = blockers.length === 0;
const publishIssues = useMemo(
@@ -1612,7 +1996,9 @@ export function VisualNovelResultView({
<button
type="button"
disabled={isBusy}
onClick={() => setEditorTarget({ kind: 'runtime', item: editDraft })}
onClick={() =>
setEditorTarget({ kind: 'runtime', item: editDraft })
}
className="platform-icon-button h-10 w-10"
aria-label="运行配置"
title="运行配置"
@@ -1675,7 +2061,10 @@ export function VisualNovelResultView({
onCreate={() =>
updateDraft({
...editDraft,
scenes: [...editDraft.scenes, createSceneDraft(editDraft.scenes.length)],
scenes: [
...editDraft.scenes,
createSceneDraft(editDraft.scenes.length),
],
})
}
onOpen={setEditorTarget}