diff --git a/src/components/visual-novel-result/VisualNovelResultView.test.tsx b/src/components/visual-novel-result/VisualNovelResultView.test.tsx index 949d70f6..dfea00eb 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.test.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.test.tsx @@ -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( + {}} + 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', + ); +}); diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 893188d2..90ad043e 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -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(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) => { const file = event.target.files?.[0]; event.currentTarget.value = ''; @@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
+ {config.imageGeneratorConfig && config.previewTone === 'image' ? ( + + ) : null} { 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({ ) : null} @@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({ item={target.item} disabled={disabled} profileId={draft.profileId} + draft={draft} onChange={updateScene} /> ) : null} diff --git a/src/services/visual-novel-creation/index.ts b/src/services/visual-novel-creation/index.ts index a8ab7acc..fd1bca9b 100644 --- a/src/services/visual-novel-creation/index.ts +++ b/src/services/visual-novel-creation/index.ts @@ -1,3 +1,4 @@ -export * from './visualNovelCreationClient'; export * from './visualNovelAssetClient'; export * from './visualNovelAudioGenerationClient'; +export * from './visualNovelCreationClient'; +export * from './visualNovelImageGenerationClient'; diff --git a/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts new file mode 100644 index 00000000..79323ed4 --- /dev/null +++ b/src/services/visual-novel-creation/visualNovelImageGenerationClient.ts @@ -0,0 +1,158 @@ +import type { + VisualNovelCharacterDraft, + VisualNovelResultDraft, + VisualNovelSceneDraft, +} from '../../../packages/shared/src/contracts/visualNovel'; +import type { + CustomWorldSceneImageRequest, + CustomWorldSceneImageResult, +} from '../aiTypes'; +import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient'; + +export type VisualNovelImageGenerationKind = + | 'cover' + | 'scene_background' + | 'character_standee'; + +export type VisualNovelImageGenerationRequest = { + kind: VisualNovelImageGenerationKind; + draft: VisualNovelResultDraft; + scene?: VisualNovelSceneDraft | null; + character?: VisualNovelCharacterDraft | null; + prompt?: string; + referenceImageSrc?: string; +}; + +function buildVisualNovelProfile( + draft: VisualNovelResultDraft, +): CustomWorldSceneImageRequest['profile'] { + return { + id: draft.profileId?.trim() || 'visual-novel-draft', + name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品', + subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说', + summary: draft.workDescription.trim() || draft.world.summary.trim(), + tone: + draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说', + playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择', + settingText: [ + draft.world.premise, + draft.world.background, + draft.world.literaryStyle, + ] + .map((part) => part.trim()) + .filter(Boolean) + .join('\n'), + }; +} + +function buildVisualNovelLandmark( + payload: VisualNovelImageGenerationRequest, +): CustomWorldSceneImageRequest['landmark'] { + if (payload.kind === 'scene_background' && payload.scene) { + return { + id: payload.scene.sceneId, + name: payload.scene.name.trim() || '视觉小说场景', + description: payload.scene.description.trim() || payload.draft.world.summary, + }; + } + + if (payload.kind === 'character_standee' && payload.character) { + return { + id: payload.character.characterId, + name: `${payload.character.name.trim() || '视觉小说角色'}立绘`, + description: [ + payload.character.appearance, + payload.character.personality, + payload.character.role, + payload.character.relationshipToPlayer, + ] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(';'), + }; + } + + return { + id: payload.draft.profileId?.trim() || 'visual-novel-cover', + name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`, + description: + payload.draft.workDescription.trim() || + payload.draft.world.summary.trim() || + payload.draft.world.premise.trim(), + }; +} + +function buildDefaultVisualNovelImagePrompt( + payload: VisualNovelImageGenerationRequest, +) { + const draft = payload.draft; + if (payload.kind === 'scene_background' && payload.scene) { + return [ + `视觉小说场景背景:${payload.scene.name}`, + payload.scene.description, + draft.world.defaultTone, + '16:9 横版背景图,无文字,无 UI,无人物特写', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + if (payload.kind === 'character_standee' && payload.character) { + return [ + `视觉小说角色立绘:${payload.character.name}`, + payload.character.appearance, + payload.character.personality, + payload.character.tone, + '透明感二次元全身或半身立绘,干净背景,无文字,无 UI', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + } + + return [ + `视觉小说作品封面:${draft.workTitle}`, + draft.workDescription, + draft.world.summary, + draft.world.defaultTone, + '精致视觉小说封面构图,无文字,无 UI,适合 4:3/16:9 裁切', + ] + .map((part) => part.trim()) + .filter(Boolean) + .join(','); +} + +function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) { + if (kind === 'character_standee') { + return '768*1024'; + } + return '1280*720'; +} + +export async function generateVisualNovelImageAsset( + payload: VisualNovelImageGenerationRequest, +): Promise { + const userPrompt = + payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload); + + if (!userPrompt.trim()) { + throw new Error('请先补充图片生成提示词。'); + } + + return generateRpgWorldSceneImage({ + profile: buildVisualNovelProfile(payload.draft), + landmark: buildVisualNovelLandmark(payload), + userPrompt, + size: resolveVisualNovelImageSize(payload.kind), + ...(payload.referenceImageSrc?.trim() + ? { referenceImageSrc: payload.referenceImageSrc.trim() } + : {}), + }); +} + +export function buildVisualNovelImageGenerationPrompt( + payload: VisualNovelImageGenerationRequest, +) { + return buildDefaultVisualNovelImagePrompt(payload); +}