Initial AI manga platform
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user