Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type {
Character,
Episode,
NovelChapter,
PlotMemory,
PlotThread,
Prisma,
Project,
StoryBible
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto';
import { EPISODE_STATUSES, toSafeEpisode, type EpisodeStatus } from './episode.types';
const MIN_EPISODES = 1;
const MAX_EPISODES = 100;
const MIN_DURATION = 15;
const MAX_DURATION = 600;
interface EpisodeDraft {
episode_no: number;
source_chapter_ids: Prisma.InputJsonValue;
title: string;
summary: string;
opening_hook: string;
middle_conflict: string;
ending_hook: string;
target_duration: number;
status: EpisodeStatus;
}
interface EpisodePlanContext {
storyBible: StoryBible;
characters: Character[];
chapters: NovelChapter[];
plotMemories: PlotMemory[];
plotThreads: PlotThread[];
}
@Injectable()
export class EpisodesService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async generatePlan(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) {
const project = await this.findProjectForUser(projectId, user);
const count = this.resolveEpisodeCount(project, dto.target_episode_count);
const context = await this.loadPlanContext(project.id);
const existingConfirmed = await this.prisma.episode.count({
where: {
project_id: project.id,
status: 'confirmed'
}
});
if (existingConfirmed > 0) {
throw new BadRequestException('Confirmed episodes cannot be regenerated');
}
const drafts = this.buildEpisodeDrafts(project, count, context);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'episode_planning' }
});
const episodes = await this.prisma.$transaction(async (tx) => {
await tx.episode.deleteMany({
where: { project_id: project.id }
});
await tx.episode.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
...draft
}))
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_episode_confirm' }
});
return saved;
});
return {
episodes: episodes.map(toSafeEpisode),
memory_context: {
story_bible_id: context.storyBible.id.toString(),
locked_character_count: context.characters.length,
active_plot_memory_count: context.plotMemories.length,
open_thread_count: context.plotThreads.length
},
next_step: 'episode_confirm'
};
}
async listEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
return episodes.map(toSafeEpisode);
}
async updateEpisode(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeDto) {
const episode = await this.findEpisodeForUser(episodeId, user);
if (episode.status === 'confirmed') {
throw new BadRequestException('Confirmed episodes cannot be edited');
}
const data = await this.createUpdateData(episode, dto);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No episode fields to update');
}
const updated = await this.prisma.episode.update({
where: { id: episode.id },
data
});
await this.prisma.project.update({
where: { id: episode.project_id },
data: { status: 'waiting_episode_confirm' }
});
return toSafeEpisode(updated);
}
async confirmEpisodes(user: AuthRequestUser, projectId: string) {
const project = await this.findProjectForUser(projectId, user);
const episodes = await this.prisma.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
this.assertEpisodesReadyForConfirmation(episodes);
const confirmed = await this.prisma.$transaction(async (tx) => {
await tx.episode.updateMany({
where: {
project_id: project.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
const saved = await tx.episode.findMany({
where: { project_id: project.id },
orderBy: { episode_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'episode_confirmed' }
});
return saved;
});
return {
episodes: confirmed.map(toSafeEpisode),
next_step: 'script_generate'
};
}
private async loadPlanContext(projectId: bigint): Promise<EpisodePlanContext> {
const [storyBible, characters, chapters, plotMemories, plotThreads] = await Promise.all([
this.prisma.storyBible.findFirst({
where: {
project_id: projectId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
}),
this.prisma.character.findMany({
where: {
project_id: projectId,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.novelChapter.findMany({
where: { project_id: projectId },
orderBy: { chapter_no: 'asc' }
}),
this.prisma.plotMemory.findMany({
where: {
project_id: projectId,
status: 'active'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.plotThread.findMany({
where: {
project_id: projectId,
status: { in: ['open', 'progressing', 'paused'] }
},
orderBy: [{ status: 'asc' }, { id: 'asc' }]
})
]);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before episode planning');
}
if (characters.length === 0) {
throw new BadRequestException('Locked characters are required before episode planning');
}
if (chapters.length === 0) {
throw new BadRequestException('Novel chapters are required before episode planning');
}
if (plotMemories.length === 0) {
throw new BadRequestException('Long-form plot memories are required before episode planning');
}
return {
storyBible,
characters,
chapters,
plotMemories,
plotThreads
};
}
private buildEpisodeDrafts(
project: Project,
count: number,
context: EpisodePlanContext
): EpisodeDraft[] {
const protagonist =
context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
context.characters[0];
const antagonist = context.characters.find((character) => character.role_type === 'antagonist');
const importantForeshadowing = context.plotMemories.find(
(memory) => memory.memory_type === 'foreshadowing'
);
const unresolvedConflict = context.plotMemories.find(
(memory) => memory.memory_type === 'unresolved_conflict'
);
const mainThread =
context.plotThreads.find((thread) => thread.thread_type === 'main_plot') ??
context.plotThreads[0];
const duration = this.validateDuration(project.episode_duration ?? 60);
return Array.from({ length: count }, (_, index) => {
const episodeNo = index + 1;
const chapterGroup = this.pickChapterGroup(context.chapters, index, count);
const firstChapter = chapterGroup[0] ?? context.chapters[0];
const lastChapter = chapterGroup.at(-1) ?? firstChapter;
const chapterSummary = chapterGroup
.map((chapter) => chapter.summary || this.compact(chapter.content).slice(0, 70))
.join('');
const sourceChapterIds = chapterGroup.map((chapter) => chapter.id.toString());
const threadText = mainThread?.description || context.storyBible.main_plot || '主线目标持续推进';
return {
episode_no: episodeNo,
source_chapter_ids: sourceChapterIds,
title: this.buildEpisodeTitle(episodeNo, firstChapter, count),
summary: [
`${protagonist.name}围绕${this.compact(threadText).slice(0, 80)}推进第${episodeNo}集。`,
chapterSummary,
episodeNo === count
? context.storyBible.ending_direction || '阶段性回收关键伏笔,并保留下一阶段入口。'
: '本集保留短视频节奏,结尾留下可承接悬念。'
].filter(Boolean).join(' '),
opening_hook:
episodeNo === 1
? `${protagonist.name}在高压场景中发现关键转机,观众第一秒进入冲突。`
: `承接上一集悬念,${protagonist.name}立刻面对新的选择和压力。`,
middle_conflict:
unresolvedConflict?.content ||
`${antagonist?.name ?? '主要对手'}围绕核心利益继续施压,${protagonist.name}必须用证据或行动反击。`,
ending_hook: this.buildEndingHook(
episodeNo,
count,
protagonist.name,
lastChapter,
importantForeshadowing,
context.storyBible
),
target_duration: duration,
status: 'generated'
};
});
}
private buildEpisodeTitle(episodeNo: number, chapter: NovelChapter, count: number) {
const cleaned = chapter.title
?.replace(/^?[0-9]+[.\s-]*/u, '')
.trim();
const fallback = episodeNo === count ? '真相逼近' : episodeNo === 1 ? '开局反击' : '冲突升级';
return `${episodeNo}${cleaned || fallback}`;
}
private buildEndingHook(
episodeNo: number,
count: number,
protagonistName: string,
chapter: NovelChapter,
foreshadowing: PlotMemory | undefined,
storyBible: StoryBible
) {
if (episodeNo === count) {
return storyBible.ending_direction || `${protagonistName}阶段性赢下对抗,但幕后真相仍未完全揭开。`;
}
const source = foreshadowing?.content || chapter.summary || chapter.title || '关键线索';
return `${protagonistName}发现${this.compact(source).slice(0, 42)},下一集必须继续追查。`;
}
private pickChapterGroup(chapters: NovelChapter[], index: number, count: number) {
const start = Math.floor((index * chapters.length) / count);
const end = Math.max(start + 1, Math.floor(((index + 1) * chapters.length) / count));
return chapters.slice(start, Math.min(end, chapters.length));
}
private async createUpdateData(
episode: Episode,
dto: UpdateEpisodeDto
): Promise<Prisma.EpisodeUncheckedUpdateInput> {
const data: Prisma.EpisodeUncheckedUpdateInput = {};
if ('episode_no' in dto) {
data.episode_no = await this.validateEpisodeNoForUpdate(episode, dto.episode_no);
}
if ('source_chapter_ids' in dto) {
data.source_chapter_ids = await this.validateSourceChapterIds(
episode.project_id,
dto.source_chapter_ids
);
}
if ('title' in dto) data.title = this.optionalText(dto.title);
if ('summary' in dto) data.summary = this.optionalText(dto.summary);
if ('opening_hook' in dto) data.opening_hook = this.optionalText(dto.opening_hook);
if ('middle_conflict' in dto) data.middle_conflict = this.optionalText(dto.middle_conflict);
if ('ending_hook' in dto) data.ending_hook = this.optionalText(dto.ending_hook);
if ('target_duration' in dto) {
data.target_duration = this.validateDuration(dto.target_duration);
}
if ('status' in dto) data.status = this.validateStatus(dto.status);
if (Object.keys(data).length > 0 && data.status !== 'confirmed') {
data.status = data.status ?? 'edited';
}
return data;
}
private async validateEpisodeNoForUpdate(episode: Episode, value: number | undefined) {
const episodeNo = this.validatePositiveInt(value, 'episode_no', MIN_EPISODES, MAX_EPISODES);
if (episodeNo === episode.episode_no) {
return episodeNo;
}
const existing = await this.prisma.episode.findFirst({
where: {
project_id: episode.project_id,
episode_no: episodeNo,
id: { not: episode.id }
}
});
if (existing) {
throw new BadRequestException('episode_no already exists in this project');
}
return episodeNo;
}
private async validateSourceChapterIds(projectId: bigint, value: string[] | undefined) {
if (!Array.isArray(value) || value.length === 0) {
throw new BadRequestException('source_chapter_ids must be a non-empty array');
}
const ids = value.map((item) => this.parseId(String(item), 'Invalid source chapter id'));
const count = await this.prisma.novelChapter.count({
where: {
project_id: projectId,
id: { in: ids }
}
});
if (count !== ids.length) {
throw new BadRequestException('source_chapter_ids contain chapters outside this project');
}
return ids.map((id) => id.toString());
}
private assertEpisodesReadyForConfirmation(episodes: Episode[]) {
if (episodes.length === 0) {
throw new BadRequestException('Episode plan is required before confirmation');
}
for (const [index, episode] of episodes.entries()) {
if (episode.episode_no !== index + 1) {
throw new BadRequestException('Episode numbers must be continuous from 1');
}
if (
!episode.title ||
!episode.summary ||
!episode.opening_hook ||
!episode.middle_conflict ||
!episode.ending_hook ||
!episode.target_duration
) {
throw new BadRequestException('All episodes must include title, hooks, conflict, summary, and duration');
}
}
}
private async findProjectForUser(projectId: string, user: AuthRequestUser) {
const project = await this.prisma.project.findUnique({
where: { id: this.parseId(projectId, 'Invalid project id') }
});
if (!project) {
throw new NotFoundException('Project not found');
}
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project is private');
}
return project;
}
private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) {
const episode = await this.prisma.episode.findUnique({
where: { id: this.parseId(episodeId, 'Invalid episode id') }
});
if (!episode) {
throw new NotFoundException('Episode not found');
}
await this.findProjectForUser(episode.project_id.toString(), user);
return episode;
}
private resolveEpisodeCount(project: Project, value: number | undefined) {
return this.validatePositiveInt(
value ?? project.target_episode_count ?? (project.input_mode === 'ai_original' ? 3 : 1),
'target_episode_count',
MIN_EPISODES,
MAX_EPISODES
);
}
private validateDuration(value: number | undefined) {
return this.validatePositiveInt(value, 'target_duration', MIN_DURATION, MAX_DURATION);
}
private validateStatus(value: string | undefined): EpisodeStatus {
if (!value || !EPISODE_STATUSES.includes(value as never)) {
throw new BadRequestException('episode status is invalid');
}
return value as EpisodeStatus;
}
private validatePositiveInt(value: unknown, field: string, min: number, max: number) {
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) {
throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`);
}
return numberValue;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private compact(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}