feat: add visual novel AI image entry points
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
|
|||||||
vi.mock('../../services/visual-novel-creation', () => ({
|
vi.mock('../../services/visual-novel-creation', () => ({
|
||||||
createVisualNovelBackgroundMusicTask: vi.fn(),
|
createVisualNovelBackgroundMusicTask: vi.fn(),
|
||||||
createVisualNovelSoundEffectTask: vi.fn(),
|
createVisualNovelSoundEffectTask: vi.fn(),
|
||||||
|
generateVisualNovelImageAsset: vi.fn(),
|
||||||
|
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
|
||||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||||
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
||||||
publishVisualNovelSoundEffectAsset: 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,
|
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
|
||||||
).toContain('/generated-custom-world-scenes/');
|
).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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import {
|
|||||||
ImagePlus,
|
ImagePlus,
|
||||||
Images,
|
Images,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
type LucideIcon,
|
||||||
Music,
|
Music,
|
||||||
Save,
|
|
||||||
PenLine,
|
PenLine,
|
||||||
Play,
|
Play,
|
||||||
|
Save,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Upload,
|
Upload,
|
||||||
Waves,
|
Waves,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -27,9 +27,12 @@ import type {
|
|||||||
VisualNovelStoryPhaseDraft,
|
VisualNovelStoryPhaseDraft,
|
||||||
VisualNovelValidationIssue,
|
VisualNovelValidationIssue,
|
||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||||
import {
|
import {
|
||||||
|
buildVisualNovelImageGenerationPrompt,
|
||||||
createVisualNovelBackgroundMusicTask,
|
createVisualNovelBackgroundMusicTask,
|
||||||
createVisualNovelSoundEffectTask,
|
createVisualNovelSoundEffectTask,
|
||||||
|
generateVisualNovelImageAsset,
|
||||||
listVisualNovelHistoryAssets,
|
listVisualNovelHistoryAssets,
|
||||||
publishVisualNovelBackgroundMusicAsset,
|
publishVisualNovelBackgroundMusicAsset,
|
||||||
publishVisualNovelSoundEffectAsset,
|
publishVisualNovelSoundEffectAsset,
|
||||||
@@ -38,7 +41,6 @@ import {
|
|||||||
type VisualNovelHistoryAssetKind,
|
type VisualNovelHistoryAssetKind,
|
||||||
type VisualNovelUploadAssetKind,
|
type VisualNovelUploadAssetKind,
|
||||||
} from '../../services/visual-novel-creation';
|
} from '../../services/visual-novel-creation';
|
||||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
||||||
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
|
|||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
entityId?: string | null;
|
entityId?: string | null;
|
||||||
previewTone: 'image' | 'audio';
|
previewTone: 'image' | 'audio';
|
||||||
|
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
|
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 = {
|
type VisualNovelAudioGeneratorConfig = {
|
||||||
kind: VisualNovelAudioGeneratorKind;
|
kind: VisualNovelAudioGeneratorKind;
|
||||||
scene: VisualNovelSceneDraft;
|
scene: VisualNovelSceneDraft;
|
||||||
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
|
|||||||
Boolean(config.historyKind),
|
Boolean(config.historyKind),
|
||||||
);
|
);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
|
|||||||
};
|
};
|
||||||
}, [config.historyKind]);
|
}, [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 handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.currentTarget.value = '';
|
event.currentTarget.value = '';
|
||||||
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
|
|||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading || isGeneratingImage}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
|
|||||||
)}
|
)}
|
||||||
上传
|
上传
|
||||||
</button>
|
</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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={config.accept}
|
accept={config.accept}
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading || isGeneratingImage}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void handleUpload(event);
|
void handleUpload(event);
|
||||||
}}
|
}}
|
||||||
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
|
|||||||
entityId,
|
entityId,
|
||||||
historyKind,
|
historyKind,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
imageGeneratorConfig,
|
||||||
label,
|
label,
|
||||||
onSelect,
|
onSelect,
|
||||||
previewTone,
|
previewTone,
|
||||||
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
|
|||||||
entityId?: string | null;
|
entityId?: string | null;
|
||||||
historyKind?: VisualNovelHistoryAssetKind;
|
historyKind?: VisualNovelHistoryAssetKind;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||||
label: string;
|
label: string;
|
||||||
onSelect: (asset: VisualNovelAssetReference) => void;
|
onSelect: (asset: VisualNovelAssetReference) => void;
|
||||||
previewTone: 'image' | 'audio';
|
previewTone: 'image' | 'audio';
|
||||||
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
|
|||||||
profileId,
|
profileId,
|
||||||
entityId,
|
entityId,
|
||||||
previewTone,
|
previewTone,
|
||||||
|
imageGeneratorConfig,
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClose={() => setIsPickerOpen(false)}
|
onClose={() => setIsPickerOpen(false)}
|
||||||
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
|
|||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
profileId={draft.profileId}
|
profileId={draft.profileId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{ kind: 'cover', draft }}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({ ...draft, coverImageSrc: asset.imageSrc })
|
onChange({ ...draft, coverImageSrc: asset.imageSrc })
|
||||||
}
|
}
|
||||||
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
|
|||||||
function VisualNovelCharacterEditor({
|
function VisualNovelCharacterEditor({
|
||||||
item,
|
item,
|
||||||
disabled,
|
disabled,
|
||||||
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
item: VisualNovelCharacterDraft;
|
item: VisualNovelCharacterDraft;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
draft: VisualNovelResultDraft;
|
||||||
onChange: (item: VisualNovelCharacterDraft) => void;
|
onChange: (item: VisualNovelCharacterDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
|
|||||||
profileId={null}
|
profileId={null}
|
||||||
entityId={item.characterId}
|
entityId={item.characterId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{
|
||||||
|
kind: 'character_standee',
|
||||||
|
draft,
|
||||||
|
character: item,
|
||||||
|
}}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({
|
onChange({
|
||||||
...item,
|
...item,
|
||||||
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
|
|||||||
item,
|
item,
|
||||||
disabled,
|
disabled,
|
||||||
profileId,
|
profileId,
|
||||||
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
item: VisualNovelSceneDraft;
|
item: VisualNovelSceneDraft;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
|
draft: VisualNovelResultDraft;
|
||||||
onChange: (item: VisualNovelSceneDraft) => void;
|
onChange: (item: VisualNovelSceneDraft) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
|
|||||||
profileId={profileId ?? null}
|
profileId={profileId ?? null}
|
||||||
entityId={item.sceneId}
|
entityId={item.sceneId}
|
||||||
previewTone="image"
|
previewTone="image"
|
||||||
|
imageGeneratorConfig={{
|
||||||
|
kind: 'scene_background',
|
||||||
|
draft,
|
||||||
|
scene: item,
|
||||||
|
}}
|
||||||
onSelect={(asset) =>
|
onSelect={(asset) =>
|
||||||
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
|
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
|
||||||
}
|
}
|
||||||
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
|
|||||||
<VisualNovelCharacterEditor
|
<VisualNovelCharacterEditor
|
||||||
item={target.item}
|
item={target.item}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
draft={draft}
|
||||||
onChange={updateCharacter}
|
onChange={updateCharacter}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
|
|||||||
item={target.item}
|
item={target.item}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
profileId={draft.profileId}
|
profileId={draft.profileId}
|
||||||
|
draft={draft}
|
||||||
onChange={updateScene}
|
onChange={updateScene}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './visualNovelCreationClient';
|
|
||||||
export * from './visualNovelAssetClient';
|
export * from './visualNovelAssetClient';
|
||||||
export * from './visualNovelAudioGenerationClient';
|
export * from './visualNovelAudioGenerationClient';
|
||||||
|
export * from './visualNovelCreationClient';
|
||||||
|
export * from './visualNovelImageGenerationClient';
|
||||||
|
|||||||
@@ -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<CustomWorldSceneImageResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user