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
+25
View File
@@ -0,0 +1,25 @@
import type { ScriptStatus, StoryboardStatus } from './script.types';
export class UpdateEpisodeScriptDto {
script_text?: string;
narration_text?: string;
dialogue_json?: unknown;
status?: ScriptStatus;
}
export class UpdateStoryboardShotDto {
shot_no?: number;
scene_name?: string;
location_desc?: string;
characters_json?: unknown;
visual_desc?: string;
action_desc?: string;
dialogue_text?: string;
narration_text?: string;
camera_motion?: string;
effect_type?: string;
duration?: number;
prompt_text?: string;
negative_prompt?: string;
status?: StoryboardStatus;
}
+81
View File
@@ -0,0 +1,81 @@
import type { EpisodeScript, Prisma, StoryboardShot } from '@prisma/client';
export const SCRIPT_STATUSES = ['draft', 'generated', 'edited', 'confirmed', 'superseded'] as const;
export const STORYBOARD_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const;
export type ScriptStatus = (typeof SCRIPT_STATUSES)[number];
export type StoryboardStatus = (typeof STORYBOARD_STATUSES)[number];
export interface SafeEpisodeScript {
id: string;
project_id: string;
episode_id: string;
script_text: string | null;
narration_text: string | null;
dialogue_json: Prisma.JsonValue | null;
version: number;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeStoryboardShot {
id: string;
project_id: string;
episode_id: string;
shot_no: number;
scene_name: string | null;
location_desc: string | null;
characters_json: Prisma.JsonValue | null;
visual_desc: string | null;
action_desc: string | null;
dialogue_text: string | null;
narration_text: string | null;
camera_motion: string | null;
effect_type: string | null;
duration: number | null;
prompt_text: string | null;
negative_prompt: string | null;
status: string;
created_at: string;
updated_at: string;
}
export function toSafeEpisodeScript(script: EpisodeScript): SafeEpisodeScript {
return {
id: script.id.toString(),
project_id: script.project_id.toString(),
episode_id: script.episode_id.toString(),
script_text: script.script_text,
narration_text: script.narration_text,
dialogue_json: script.dialogue_json,
version: script.version,
status: script.status,
created_at: script.created_at.toISOString(),
updated_at: script.updated_at.toISOString()
};
}
export function toSafeStoryboardShot(shot: StoryboardShot): SafeStoryboardShot {
return {
id: shot.id.toString(),
project_id: shot.project_id.toString(),
episode_id: shot.episode_id.toString(),
shot_no: shot.shot_no,
scene_name: shot.scene_name,
location_desc: shot.location_desc,
characters_json: shot.characters_json,
visual_desc: shot.visual_desc,
action_desc: shot.action_desc,
dialogue_text: shot.dialogue_text,
narration_text: shot.narration_text,
camera_motion: shot.camera_motion,
effect_type: shot.effect_type,
duration: shot.duration ? Number(shot.duration.toString()) : null,
prompt_text: shot.prompt_text,
negative_prompt: shot.negative_prompt,
status: shot.status,
created_at: shot.created_at.toISOString(),
updated_at: shot.updated_at.toISOString()
};
}
+104
View File
@@ -0,0 +1,104 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
Patch,
Post,
UseGuards
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto';
import { ScriptsService } from './scripts.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class ScriptsController {
constructor(@Inject(ScriptsService) private readonly scriptsService: ScriptsService) {}
@Post('episodes/:episodeId/script/generate')
generateScript(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.generateScript(user, episodeId);
}
@Get('episodes/:episodeId/script')
getScript(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.getScript(user, episodeId);
}
@Patch('episodes/:episodeId/script')
updateScript(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: UpdateEpisodeScriptDto
) {
return this.scriptsService.updateScript(user, episodeId, dto);
}
@Post('episodes/:episodeId/script/confirm')
confirmScript(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.confirmScript(user, episodeId);
}
@Post('episodes/:episodeId/storyboard/generate')
generateStoryboard(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.generateStoryboard(user, episodeId);
}
@Get('episodes/:episodeId/storyboard')
getStoryboard(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.getStoryboard(user, episodeId);
}
@Patch('storyboard-shots/:shotId')
updateStoryboardShot(
@CurrentUser() user: AuthRequestUser,
@Param('shotId') shotId: string,
@Body() dto: UpdateStoryboardShotDto
) {
return this.scriptsService.updateStoryboardShot(user, shotId, dto);
}
@Delete('storyboard-shots/:shotId')
deleteStoryboardShot(
@CurrentUser() user: AuthRequestUser,
@Param('shotId') shotId: string
) {
return this.scriptsService.deleteStoryboardShot(user, shotId);
}
@Post('episodes/:episodeId/storyboard/confirm')
confirmStoryboard(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.scriptsService.confirmStoryboard(user, episodeId);
}
@Post('storyboard-shots/:shotId/regenerate-prompt')
regenerateShotPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('shotId') shotId: string
) {
return this.scriptsService.regenerateShotPrompt(user, shotId);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ScriptsController } from './scripts.controller';
import { ScriptsService } from './scripts.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [ScriptsController],
providers: [ScriptsService],
exports: [ScriptsService]
})
export class ScriptsModule {}
+474
View File
@@ -0,0 +1,474 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
Character,
Episode,
EpisodeScript,
PlotMemory,
Project,
StoryBible,
StoryboardShot
} from '@prisma/client';
import { Prisma } from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import type { PrismaService } from '../prisma/prisma.service';
import { ScriptsService } from './scripts.service';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
const now = new Date('2026-05-31T00:00:00.000Z');
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: 10n,
user_id: 1n,
title: '重生归来,我只搞事业',
input_mode: 'ai_original',
genre: 'urban_rebirth',
style_code: 'korean_comic',
output_type: 'short_video',
output_mode: 'image_manga',
visual_mode: 'korean_manga',
video_generation_level: 'standard',
target_episode_count: 3,
episode_duration: 60,
status: 'episode_confirmed',
copyright_status: 'ai_original',
payment_status: 'unpaid',
quality_level: 'mvp',
is_long_series: false,
created_at: now,
updated_at: now,
completed_at: null,
...overrides
};
}
function createEpisode(overrides: Partial<Episode> = {}): Episode {
return {
id: 20n,
project_id: 10n,
episode_no: 1,
source_chapter_ids: ['30'],
title: '第1集 暴雨开局',
summary: '林晚用录音证据逼近真相。',
opening_hook: '林晚睁眼时,会议室大屏已经开始播放她的录音。',
middle_conflict: '周启试图转移责任。',
ending_hook: '幕后投资人的车停在楼下。',
target_duration: 60,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
return {
id: 40n,
project_id: 10n,
title: '重生归来,我只搞事业',
logline: '林晚重回命运转折点,用证据夺回项目。',
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
core_conflict: '林晚必须在资本压力中守住原创项目。',
selling_points: '重生归来\n证据反杀',
tone: '克制、锋利、连续反转',
world_summary: '现代都市内容公司',
ending_direction: '幕后真相继续推进。',
taboo_rules: '不得改变主角姓名。',
version: 1,
status: 'confirmed',
created_at: now,
updated_at: now,
...overrides
};
}
function createCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 50n,
project_id: 10n,
global_character_id: null,
name: '林晚',
alias_names: [],
role_type: 'protagonist',
gender_label: '女',
age_group: '青年',
identity_desc: '故事主角',
appearance_desc: '眼神坚定',
face_desc: '精致鹅蛋脸',
hair_desc: '深色中长发',
eye_desc: '深色眼睛',
body_desc: '身形修长',
costume_rules: '现代都市通勤装',
special_props: '手机、录音证据',
personality_desc: '冷静克制',
speech_style: '短句明确',
relationship_desc: '与周启围绕项目控制权对抗',
character_arc: '从被动到主动',
negative_rules: '不得改名',
anchor_asset_id: null,
wardrobe_variant: null,
voice_provider_code: null,
voice_model: null,
voice_id: null,
voice_style: null,
performance_style: null,
importance_level: 100,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
return {
id: 60n,
project_id: 10n,
episode_id: null,
chapter_id: 30n,
memory_type: 'foreshadowing',
content: '录音证据会在后续揭开幕后真相。',
importance_level: 90,
status: 'active',
created_at: now,
...overrides
};
}
function createScript(overrides: Partial<EpisodeScript> = {}): EpisodeScript {
return {
id: 70n,
project_id: 10n,
episode_id: 20n,
script_text: '【第1集】林晚在会议室反击。',
narration_text: '林晚压住情绪,拿出录音证据。',
dialogue_json: [
{ speaker: '林晚', line: '这一回,我不会再退。' },
{ speaker: '周启', line: '你以为这样就够了吗?' }
],
version: 1,
status: 'generated',
created_at: now,
updated_at: now,
...overrides
};
}
function createShot(overrides: Partial<StoryboardShot> = {}): StoryboardShot {
return {
id: 80n,
project_id: 10n,
episode_id: 20n,
shot_no: 1,
scene_name: '开局压迫',
location_desc: '会议室,竖屏构图',
characters_json: [{ id: '50', name: '林晚' }],
visual_desc: '林晚处于画面中心,眼神冷静。',
action_desc: '林晚抬眼看向镜头。',
dialogue_text: '这一回,我不会再退。',
narration_text: '会议室大屏开始播放录音。',
camera_motion: '近景缓慢推进',
effect_type: 'subtle_zoom',
duration: new Prisma.Decimal(4),
scene_type: null,
importance_score: null,
emotion_score: null,
action_score: null,
route_tier: null,
prompt_text: '高质量韩漫风,林晚会议室反击。',
negative_prompt: '低清晰度,多人混脸。',
live_action_desc: null,
actor_action: null,
camera_instruction: null,
performance_instruction: null,
video_prompt: null,
keyframe_asset_id: null,
video_clip_asset_id: null,
video_status: null,
status: 'generated',
created_at: now,
updated_at: now,
...overrides
};
}
function createCreativePattern(overrides: Record<string, unknown> = {}) {
return {
id: 100n,
source_case_id: null,
pattern_type: 'opening_hook',
title: '退婚开场钩子',
genre: 'urban_rebirth',
language: 'zh-CN',
description: '前 8 秒建立关系破裂和证据反击。',
structure_json: {},
prompt_template: '写一个退婚现场开场钩子。',
negative_prompt: '拖慢铺垫',
tags_json: ['退婚', '打脸'],
usage_count: 0,
effectiveness_score: null,
status: 'active',
created_by_user_id: 1n,
created_at: now,
updated_at: now,
...overrides
};
}
function createProjectCreativePattern(overrides: Record<string, unknown> = {}) {
return {
id: 101n,
project_id: 10n,
creative_pattern_id: 100n,
source: 'user_selected',
snapshot_json: {},
sort_order: 1,
created_at: now,
...overrides
};
}
describe('ScriptsService', () => {
let prisma: any;
let tx: any;
let service: ScriptsService;
beforeEach(() => {
tx = {
episodeScript: {
create: vi.fn().mockResolvedValue(createScript()),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(createScript({ status: 'confirmed' }))
},
storyboardShot: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
findMany: vi.fn().mockResolvedValue([
createShot(),
createShot({ id: 81n, shot_no: 2, scene_name: '证据出现' })
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 })
},
project: {
update: vi.fn().mockResolvedValue(createProject())
}
};
prisma = {
project: {
findUnique: vi.fn().mockResolvedValue(createProject()),
update: vi.fn().mockResolvedValue(createProject())
},
episode: {
findUnique: vi.fn().mockResolvedValue(createEpisode())
},
storyBible: {
findFirst: vi.fn().mockResolvedValue(createStoryBible())
},
character: {
findMany: vi.fn().mockResolvedValue([
createCharacter(),
createCharacter({
id: 51n,
name: '周启',
role_type: 'antagonist',
importance_level: 80
})
])
},
plotMemory: {
findMany: vi.fn().mockResolvedValue([createPlotMemory()])
},
projectCreativePattern: {
findMany: vi.fn().mockResolvedValue([])
},
creativePattern: {
findMany: vi.fn().mockResolvedValue([])
},
episodeScript: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([createScript()]),
update: vi.fn().mockResolvedValue(createScript({ status: 'edited' })),
updateMany: vi.fn()
},
storyboardShot: {
count: vi.fn().mockResolvedValue(0),
findMany: vi.fn().mockResolvedValue([createShot()]),
findUnique: vi.fn().mockResolvedValue(createShot()),
findFirst: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(createShot({ status: 'edited' })),
delete: vi.fn().mockResolvedValue(createShot())
},
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
};
service = new ScriptsService(prisma as PrismaService);
});
it('generates a script for a confirmed episode', async () => {
const result = await service.generateScript(user, '20');
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'script_generating' }
});
expect(tx.episodeScript.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
episode_id: 20n,
version: 1,
status: 'generated'
})
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'waiting_script_confirm' }
});
expect(result.next_step).toBe('script_confirm');
});
it('injects selected creative patterns into script and storyboard prompts', async () => {
prisma.projectCreativePattern.findMany.mockResolvedValue([
createProjectCreativePattern(),
createProjectCreativePattern({ id: 103n, creative_pattern_id: 102n, sort_order: 2 })
]);
prisma.creativePattern.findMany.mockResolvedValue([
createCreativePattern(),
createCreativePattern({
id: 102n,
pattern_type: 'visual_prompt',
title: '会议室权力构图',
prompt_template: '竖版会议室强对峙,证据特写后切人物反应。',
negative_prompt: '站桩闲聊'
})
]);
prisma.episodeScript.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(createScript({ status: 'confirmed' }));
await service.generateScript(user, '20');
await service.generateStoryboard(user, '20');
expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('【题材套路库】');
expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('退婚开场钩子');
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].prompt_text).toContain('题材套路/视觉Prompt参考');
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].negative_prompt).toContain('站桩闲聊');
});
it('requires confirmed episode before script generation', async () => {
prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'generated' }));
await expect(service.generateScript(user, '20')).rejects.toBeInstanceOf(BadRequestException);
});
it('updates and confirms a script', async () => {
prisma.episodeScript.findFirst.mockResolvedValue(createScript());
const updated = await service.updateScript(user, '20', {
narration_text: '新的旁白。'
});
const confirmed = await service.confirmScript(user, '20');
expect(prisma.episodeScript.update).toHaveBeenCalledWith({
where: { id: 70n },
data: expect.objectContaining({
narration_text: '新的旁白。',
status: 'edited'
})
});
expect(tx.episodeScript.update).toHaveBeenCalledWith({
where: { id: 70n },
data: { status: 'confirmed' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'script_confirmed' }
});
expect(updated.status).toBe('edited');
expect(confirmed.next_step).toBe('storyboard_generate');
});
it('generates storyboard shots from a confirmed script', async () => {
prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' }));
const result = await service.generateStoryboard(user, '20');
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'storyboard_generating' }
});
expect(tx.storyboardShot.createMany.mock.calls[0][0].data).toHaveLength(10);
expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0]).toEqual(
expect.objectContaining({
project_id: 10n,
episode_id: 20n,
shot_no: 1,
status: 'generated'
})
);
expect(result.next_step).toBe('storyboard_confirm');
});
it('updates, regenerates prompt, and deletes an unconfirmed shot', async () => {
const updated = await service.updateStoryboardShot(user, '80', {
visual_desc: '林晚站在会议桌前,表情更坚定。',
duration: 5
});
const regenerated = await service.regenerateShotPrompt(user, '80');
const deleted = await service.deleteStoryboardShot(user, '80');
expect(prisma.storyboardShot.update).toHaveBeenCalledWith({
where: { id: 80n },
data: expect.objectContaining({
visual_desc: '林晚站在会议桌前,表情更坚定。',
duration: 5,
status: 'edited'
})
});
expect(regenerated.prompt_text).toContain('高质量韩漫风');
expect(deleted.id).toBe('80');
expect(updated.status).toBe('edited');
});
it('confirms storyboard shots', async () => {
prisma.storyboardShot.findMany.mockResolvedValue([
createShot(),
createShot({ id: 81n, shot_no: 2 })
]);
const result = await service.confirmStoryboard(user, '20');
expect(tx.storyboardShot.updateMany).toHaveBeenCalledWith({
where: {
episode_id: 20n,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
expect(tx.project.update).toHaveBeenCalledWith({
where: { id: 10n },
data: { status: 'storyboard_confirmed' }
});
expect(result.next_step).toBe('image_generation');
});
it('blocks editing confirmed scripts and shots', async () => {
prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' }));
prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'confirmed' }));
await expect(service.updateScript(user, '20', { script_text: '改不了' })).rejects.toBeInstanceOf(
BadRequestException
);
await expect(
service.updateStoryboardShot(user, '80', { visual_desc: '改不了' })
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects access to another user project', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
await expect(service.getScript(user, '20')).rejects.toBeInstanceOf(ForbiddenException);
});
});
File diff suppressed because it is too large Load Diff