feat: add visual novel AI image entry points
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-13 21:14:13 +08:00
parent 2a75a19ece
commit c1131e6f55
4 changed files with 311 additions and 6 deletions

View File

@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
createVisualNovelBackgroundMusicTask: vi.fn(),
createVisualNovelSoundEffectTask: vi.fn(),
generateVisualNovelImageAsset: vi.fn(),
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
publishVisualNovelBackgroundMusicAsset: vi.fn(),
publishVisualNovelSoundEffectAsset: vi.fn(),
@@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/');
});
test('visual novel result generates scene background from asset picker', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import('../../services/visual-novel-creation');
const generateImageMock = vi.mocked(
visualNovelCreation.generateVisualNovelImageAsset,
);
generateImageMock.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp',
assetId: 'asset-scene-ai',
model: 'test-image-model',
size: '1280*720',
taskId: 'task-scene-ai',
prompt: '默认图片提示词',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const editorDialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(
within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!,
);
await user.click(
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
name: 'AI生成',
}),
);
await user.click(within(editorDialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(generateImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/vn-profile/scene-ai.webp',
);
});

View File

@@ -4,16 +4,16 @@ import {
ImagePlus,
Images,
Loader2,
type LucideIcon,
Music,
Save,
PenLine,
Play,
Save,
Settings,
Sparkles,
Upload,
Waves,
X,
type LucideIcon,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -27,9 +27,12 @@ import type {
VisualNovelStoryPhaseDraft,
VisualNovelValidationIssue,
} from '../../../packages/shared/src/contracts/visualNovel';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import {
buildVisualNovelImageGenerationPrompt,
createVisualNovelBackgroundMusicTask,
createVisualNovelSoundEffectTask,
generateVisualNovelImageAsset,
listVisualNovelHistoryAssets,
publishVisualNovelBackgroundMusicAsset,
publishVisualNovelSoundEffectAsset,
@@ -38,7 +41,6 @@ import {
type VisualNovelHistoryAssetKind,
type VisualNovelUploadAssetKind,
} from '../../services/visual-novel-creation';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
profileId?: string | null;
entityId?: string | null;
previewTone: 'image' | 'audio';
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
};
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
type VisualNovelImageGeneratorKind =
| 'cover'
| 'scene_background'
| 'character_standee';
type VisualNovelImageGeneratorConfig = {
kind: VisualNovelImageGeneratorKind;
draft: VisualNovelResultDraft;
scene?: VisualNovelSceneDraft | null;
character?: VisualNovelCharacterDraft | null;
};
type VisualNovelAudioGeneratorConfig = {
kind: VisualNovelAudioGeneratorKind;
scene: VisualNovelSceneDraft;
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
Boolean(config.historyKind),
);
const [isUploading, setIsUploading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
};
}, [config.historyKind]);
const handleGenerateImage = async () => {
if (!config.imageGeneratorConfig || config.previewTone !== 'image') {
return;
}
setIsGeneratingImage(true);
setError(null);
try {
const result = await generateVisualNovelImageAsset({
...config.imageGeneratorConfig,
prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig),
});
onSelect({
assetObjectId: result.assetId || result.taskId,
assetKind:
config.uploadKind === 'character_standee'
? 'character_visual'
: config.uploadKind === 'cover'
? 'visual_novel_cover_image'
: 'scene_image',
objectKey: '',
imageSrc: result.imageSrc,
profileId: config.profileId ?? null,
entityId: config.entityId ?? null,
});
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: 'AI 图片生成失败。',
);
} finally {
setIsGeneratingImage(false);
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
<div className="mb-4 flex flex-wrap gap-2">
<button
type="button"
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
>
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
)}
</button>
{config.imageGeneratorConfig && config.previewTone === 'image' ? (
<button
type="button"
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => {
void handleGenerateImage();
}}
className="platform-button platform-button--primary min-h-10 px-4 py-2 text-sm"
>
{isGeneratingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
AI生成
</button>
) : null}
<input
ref={fileInputRef}
type="file"
accept={config.accept}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onChange={(event) => {
void handleUpload(event);
}}
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
entityId,
historyKind,
icon: Icon,
imageGeneratorConfig,
label,
onSelect,
previewTone,
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
entityId?: string | null;
historyKind?: VisualNovelHistoryAssetKind;
icon: LucideIcon;
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
label: string;
onSelect: (asset: VisualNovelAssetReference) => void;
previewTone: 'image' | 'audio';
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
profileId,
entityId,
previewTone,
imageGeneratorConfig,
}}
disabled={disabled}
onClose={() => setIsPickerOpen(false)}
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
accept="image/png,image/jpeg,image/webp"
profileId={draft.profileId}
previewTone="image"
imageGeneratorConfig={{ kind: 'cover', draft }}
onSelect={(asset) =>
onChange({ ...draft, coverImageSrc: asset.imageSrc })
}
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
function VisualNovelCharacterEditor({
item,
disabled,
draft,
onChange,
}: {
item: VisualNovelCharacterDraft;
disabled: boolean;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelCharacterDraft) => void;
}) {
return (
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
profileId={null}
entityId={item.characterId}
previewTone="image"
imageGeneratorConfig={{
kind: 'character_standee',
draft,
character: item,
}}
onSelect={(asset) =>
onChange({
...item,
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
item,
disabled,
profileId,
draft,
onChange,
}: {
item: VisualNovelSceneDraft;
disabled: boolean;
profileId?: string | null;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelSceneDraft) => void;
}) {
return (
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="image"
imageGeneratorConfig={{
kind: 'scene_background',
draft,
scene: item,
}}
onSelect={(asset) =>
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
}
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
<VisualNovelCharacterEditor
item={target.item}
disabled={disabled}
draft={draft}
onChange={updateCharacter}
/>
) : null}
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
item={target.item}
disabled={disabled}
profileId={draft.profileId}
draft={draft}
onChange={updateScene}
/>
) : null}