Files
Genarrative/src/components/visual-novel-creation/visualNovelEntryGeneration.ts

216 lines
6.1 KiB
TypeScript

import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CreateVisualNovelSessionRequest } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
type VisualNovelStyleOptionId =
| 'cinematic-anime'
| 'watercolor'
| 'pixel-noir'
| 'ink-fantasy'
| 'soft-pastel'
| 'dark-gothic';
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
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: 10,
durationMs: 3_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: 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 === 'generating' ? activeStep.id : phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}