Merge pull request 'hermes/visual-novel-genarrative' (#18) from hermes/visual-novel-genarrative into master
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
type VisualNovelEntryFormPayload,
|
||||
} from './visualNovelEntryGeneration';
|
||||
|
||||
function createVisualNovelPayload(
|
||||
overrides: Partial<VisualNovelEntryFormPayload> = {},
|
||||
): VisualNovelEntryFormPayload {
|
||||
return {
|
||||
sourceMode: 'idea',
|
||||
seedText:
|
||||
'雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
|
||||
visualStyleId: 'cinematic-anime',
|
||||
visualStyleLabel: '映画动画',
|
||||
visualStylePrompt: '电影感动画视觉小说画风。',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('visualNovelEntryGeneration', () => {
|
||||
test('one-line visual novel generation exposes reference-flow stages', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
1_500,
|
||||
);
|
||||
|
||||
expect(progress.steps.map((step) => step.id)).toEqual([
|
||||
'visual-novel-intent',
|
||||
'visual-novel-world',
|
||||
'visual-novel-cast-scenes',
|
||||
'visual-novel-opening',
|
||||
'visual-novel-ready',
|
||||
]);
|
||||
expect(progress.phaseLabel).toBe('理解一句话创意');
|
||||
expect(progress.steps[0]?.detail).toBe(
|
||||
'提取核心题材、视觉画风、玩家身份和互动叙事目标。',
|
||||
);
|
||||
expect(progress.estimatedRemainingMs).toBe(44_500);
|
||||
expect(progress.overallProgress).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation advances to opening choices before ready', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
35_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('visual-novel-opening');
|
||||
expect(progress.phaseLabel).toBe('生成开场与选择');
|
||||
expect(progress.steps[2]?.status).toBe('completed');
|
||||
expect(progress.steps[3]?.status).toBe('active');
|
||||
expect(progress.overallProgress).toBeLessThan(99);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation ready copy points to editable draft', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'ready',
|
||||
46_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('ready');
|
||||
expect(progress.phaseLabel).toBe('生成完成');
|
||||
expect(progress.phaseDetail).toBe(
|
||||
'视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。',
|
||||
);
|
||||
expect(progress.overallProgress).toBe(100);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation anchors include source, style and target', () => {
|
||||
const entries = buildVisualNovelEntryGenerationAnchorEntries(
|
||||
createVisualNovelPayload(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'visual-novel-idea',
|
||||
label: '一句话',
|
||||
value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-style',
|
||||
label: '视觉画风',
|
||||
value: '映画动画',
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-target',
|
||||
label: '生成目标',
|
||||
value: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries(
|
||||
label: '视觉画风',
|
||||
value: payload.visualStyleLabel,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-target',
|
||||
label: '生成目标',
|
||||
value: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
@@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-session',
|
||||
label: '创建创作会话',
|
||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
||||
weight: 24,
|
||||
durationMs: 5_000,
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-draft',
|
||||
label: '生成故事底稿',
|
||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
||||
weight: 56,
|
||||
durationMs: 22_000,
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-intent',
|
||||
label: '理解一句话创意',
|
||||
detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。',
|
||||
weight: 16,
|
||||
durationMs: 6_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-world',
|
||||
label: '扩展世界观',
|
||||
detail: '生成世界背景、故事前提、文学风格和玩家角色。',
|
||||
weight: 22,
|
||||
durationMs: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-cast-scenes',
|
||||
label: '设计角色与场景',
|
||||
detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。',
|
||||
weight: 28,
|
||||
durationMs: 16_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-opening',
|
||||
label: '生成开场与选择',
|
||||
detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。',
|
||||
weight: 24,
|
||||
durationMs: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-ready',
|
||||
label: '准备草稿页',
|
||||
detail: '校验可编辑字段并进入草稿页。',
|
||||
weight: 20,
|
||||
durationMs: 4_000,
|
||||
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。',
|
||||
weight: 10,
|
||||
durationMs: 3_000,
|
||||
},
|
||||
];
|
||||
let elapsedBeforeStep = 0;
|
||||
@@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
: phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||
const estimatedTotalMs = timeline.reduce(
|
||||
(sum, step) => sum + step.durationMs,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
phaseId: phase,
|
||||
phaseId: phase === 'generating' ? activeStep.id : phase,
|
||||
phaseLabel:
|
||||
phase === 'ready'
|
||||
? '生成完成'
|
||||
@@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
phase === 'ready'
|
||||
? '视觉小说草稿已准备完成。'
|
||||
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
|
||||
: phase === 'failed'
|
||||
? '草稿生成失败,请返回入口页调整后重试。'
|
||||
: activeStep.detail,
|
||||
@@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
||||
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
|
||||
activeStepIndex: normalizedActiveStepIndex,
|
||||
steps: timeline.map((step, index) => {
|
||||
const isCompleted =
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user