Files
ai/backend/src/scripts/scripts.service.ts
T
2026-06-15 17:45:28 +08:00

1010 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type {
Character,
CreativePattern,
Episode,
EpisodeScript,
PlotMemory,
Prisma,
Project,
StoryBible,
StoryboardShot
} from '@prisma/client';
import type { AuthRequestUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto';
import {
SCRIPT_STATUSES,
STORYBOARD_STATUSES,
toSafeEpisodeScript,
toSafeStoryboardShot,
type ScriptStatus,
type StoryboardStatus
} from './script.types';
const MIN_SHOT_DURATION = 2;
const MAX_SHOT_DURATION = 5;
const DEFAULT_SHOT_COUNT = 10;
interface ScriptContext {
episode: Episode;
project: Project;
storyBible: StoryBible;
characters: Character[];
plotMemories: PlotMemory[];
creativePatterns: CreativePattern[];
}
interface ScriptDraft {
script_text: string;
narration_text: string;
dialogue_json: Prisma.InputJsonValue;
}
interface StoryboardDraft {
shot_no: number;
scene_name: string;
location_desc: string;
characters_json: Prisma.InputJsonValue;
visual_desc: string;
action_desc: string;
dialogue_text: string | null;
narration_text: string | null;
camera_motion: string;
effect_type: string;
duration: number;
prompt_text: string;
negative_prompt: string;
status: StoryboardStatus;
}
@Injectable()
export class ScriptsService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async generateScript(user: AuthRequestUser, episodeId: string) {
const context = await this.loadScriptContext(episodeId, user);
if (context.episode.status !== 'confirmed') {
throw new BadRequestException('Confirmed episode is required before script generation');
}
const nextVersion = await this.nextScriptVersion(context.episode.id);
const draft = this.buildScriptDraft(context);
await this.prisma.project.update({
where: { id: context.project.id },
data: { status: 'script_generating' }
});
const script = await this.prisma.$transaction(async (tx) => {
const created = await tx.episodeScript.create({
data: {
project_id: context.project.id,
episode_id: context.episode.id,
...draft,
version: nextVersion,
status: 'generated'
}
});
await tx.project.update({
where: { id: context.project.id },
data: { status: 'waiting_script_confirm' }
});
return created;
});
return {
script: toSafeEpisodeScript(script),
next_step: 'script_confirm'
};
}
async getScript(user: AuthRequestUser, episodeId: string) {
const { episode } = await this.loadEpisodeForUser(episodeId, user);
const [latest, versions] = await Promise.all([
this.findLatestScript(episode.id),
this.prisma.episodeScript.findMany({
where: { episode_id: episode.id },
orderBy: { version: 'desc' }
})
]);
return {
script: latest ? toSafeEpisodeScript(latest) : null,
versions: versions.map((script) => ({
id: script.id.toString(),
version: script.version,
status: script.status,
updated_at: script.updated_at.toISOString()
}))
};
}
async updateScript(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeScriptDto) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const script = await this.findLatestScript(episode.id);
if (!script) {
throw new NotFoundException('Episode script not found');
}
if (script.status === 'confirmed') {
throw new BadRequestException('Confirmed scripts cannot be edited');
}
const data = this.createScriptUpdateData(dto);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No script fields to update');
}
if (data.status !== 'confirmed') {
data.status = data.status ?? 'edited';
}
const updated = await this.prisma.episodeScript.update({
where: { id: script.id },
data
});
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'waiting_script_confirm' }
});
return toSafeEpisodeScript(updated);
}
async confirmScript(user: AuthRequestUser, episodeId: string) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const script = await this.findLatestScript(episode.id);
if (!script) {
throw new NotFoundException('Episode script not found');
}
this.assertScriptReady(script);
const confirmed = await this.prisma.$transaction(async (tx) => {
await tx.episodeScript.updateMany({
where: {
episode_id: episode.id,
status: 'confirmed',
id: { not: script.id }
},
data: { status: 'superseded' }
});
const updated = await tx.episodeScript.update({
where: { id: script.id },
data: { status: 'confirmed' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'script_confirmed' }
});
return updated;
});
return {
script: toSafeEpisodeScript(confirmed),
next_step: 'storyboard_generate'
};
}
async generateStoryboard(user: AuthRequestUser, episodeId: string) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const script = await this.findConfirmedScript(episode.id);
if (!script) {
throw new BadRequestException('Confirmed episode script is required before storyboard generation');
}
const confirmedShotCount = await this.prisma.storyboardShot.count({
where: {
episode_id: episode.id,
status: 'confirmed'
}
});
if (confirmedShotCount > 0) {
throw new BadRequestException('Confirmed storyboard cannot be regenerated');
}
const characters = await this.prisma.character.findMany({
where: {
project_id: project.id,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
const creativePatterns = await this.loadProjectCreativePatterns(project.id);
if (characters.length === 0) {
throw new BadRequestException('Locked characters are required before storyboard generation');
}
const drafts = this.buildStoryboardDrafts(project, episode, script, characters, creativePatterns);
await this.prisma.project.update({
where: { id: project.id },
data: { status: 'storyboard_generating' }
});
const shots = await this.prisma.$transaction(async (tx) => {
await tx.storyboardShot.deleteMany({
where: { episode_id: episode.id }
});
await tx.storyboardShot.createMany({
data: drafts.map((draft) => ({
project_id: project.id,
episode_id: episode.id,
...draft
}))
});
const saved = await tx.storyboardShot.findMany({
where: { episode_id: episode.id },
orderBy: { shot_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'waiting_storyboard_confirm' }
});
return saved;
});
return {
shots: shots.map(toSafeStoryboardShot),
next_step: 'storyboard_confirm'
};
}
async getStoryboard(user: AuthRequestUser, episodeId: string) {
const { episode } = await this.loadEpisodeForUser(episodeId, user);
const shots = await this.prisma.storyboardShot.findMany({
where: { episode_id: episode.id },
orderBy: { shot_no: 'asc' }
});
return shots.map(toSafeStoryboardShot);
}
async updateStoryboardShot(
user: AuthRequestUser,
shotId: string,
dto: UpdateStoryboardShotDto
) {
const shot = await this.findShotForUser(shotId, user);
if (shot.status === 'confirmed') {
throw new BadRequestException('Confirmed storyboard shots cannot be edited');
}
const data = await this.createShotUpdateData(shot, dto);
if (Object.keys(data).length === 0) {
throw new BadRequestException('No storyboard shot fields to update');
}
if (data.status !== 'confirmed') {
data.status = data.status ?? 'edited';
}
const updated = await this.prisma.storyboardShot.update({
where: { id: shot.id },
data
});
await this.prisma.project.update({
where: { id: shot.project_id },
data: { status: 'waiting_storyboard_confirm' }
});
return toSafeStoryboardShot(updated);
}
async deleteStoryboardShot(user: AuthRequestUser, shotId: string) {
const shot = await this.findShotForUser(shotId, user);
if (shot.status === 'confirmed') {
throw new BadRequestException('Confirmed storyboard shots cannot be deleted');
}
const deleted = await this.prisma.storyboardShot.delete({
where: { id: shot.id }
});
await this.prisma.project.update({
where: { id: shot.project_id },
data: { status: 'waiting_storyboard_confirm' }
});
return toSafeStoryboardShot(deleted);
}
async regenerateShotPrompt(user: AuthRequestUser, shotId: string) {
const shot = await this.findShotForUser(shotId, user);
if (shot.status === 'confirmed') {
throw new BadRequestException('Confirmed storyboard shots cannot regenerate prompt');
}
const characters = await this.prisma.character.findMany({
where: {
project_id: shot.project_id,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
});
const creativePatterns = await this.loadProjectCreativePatterns(shot.project_id);
const promptContext = this.createStoryboardPatternPromptContext(creativePatterns);
const updated = await this.prisma.storyboardShot.update({
where: { id: shot.id },
data: {
prompt_text: this.buildPromptText(shot, characters, promptContext.promptSuffix),
negative_prompt: this.buildNegativePrompt(characters, promptContext.negativeSuffix),
status: 'edited'
}
});
return toSafeStoryboardShot(updated);
}
async confirmStoryboard(user: AuthRequestUser, episodeId: string) {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const shots = await this.prisma.storyboardShot.findMany({
where: { episode_id: episode.id },
orderBy: { shot_no: 'asc' }
});
this.assertStoryboardReady(shots);
const confirmed = await this.prisma.$transaction(async (tx) => {
await tx.storyboardShot.updateMany({
where: {
episode_id: episode.id,
status: { in: ['draft', 'generated', 'edited'] }
},
data: { status: 'confirmed' }
});
const saved = await tx.storyboardShot.findMany({
where: { episode_id: episode.id },
orderBy: { shot_no: 'asc' }
});
await tx.project.update({
where: { id: project.id },
data: { status: 'storyboard_confirmed' }
});
return saved;
});
return {
shots: confirmed.map(toSafeStoryboardShot),
next_step: 'image_generation'
};
}
private async loadScriptContext(episodeId: string, user: AuthRequestUser): Promise<ScriptContext> {
const { episode, project } = await this.loadEpisodeForUser(episodeId, user);
const [storyBible, characters, plotMemories, creativePatterns] = await Promise.all([
this.prisma.storyBible.findFirst({
where: {
project_id: project.id,
status: 'confirmed'
},
orderBy: { version: 'desc' }
}),
this.prisma.character.findMany({
where: {
project_id: project.id,
status: 'locked'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }]
}),
this.prisma.plotMemory.findMany({
where: {
project_id: project.id,
status: 'active'
},
orderBy: [{ importance_level: 'desc' }, { id: 'asc' }],
take: 20
}),
this.loadProjectCreativePatterns(project.id)
]);
if (!storyBible) {
throw new BadRequestException('Confirmed story bible is required before script generation');
}
if (characters.length === 0) {
throw new BadRequestException('Locked characters are required before script generation');
}
return {
episode,
project,
storyBible,
characters,
plotMemories,
creativePatterns
};
}
private async loadEpisodeForUser(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');
}
const project = await this.prisma.project.findUnique({
where: { id: episode.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 { episode, project };
}
private buildScriptDraft(context: ScriptContext): ScriptDraft {
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 helper = context.characters.find((character) => character.role_type === 'supporting');
const foreshadowing = context.plotMemories.find((memory) => memory.memory_type === 'foreshadowing');
const conflict = context.episode.middle_conflict || context.storyBible.core_conflict || '核心冲突升级。';
const patternGuide = this.createScriptPatternGuide(context.creativePatterns);
const dialogue = [
{
beat: 'opening',
speaker: protagonist.name,
line: '这一回,我不会再让你们拿走我的东西。'
},
{
beat: 'conflict',
speaker: antagonist?.name ?? '对手',
line: '你以为一份证据就能改变结果吗?'
},
{
beat: 'turning',
speaker: helper?.name ?? protagonist.name,
line: helper ? '备份还在,我已经找到了新的时间戳。' : '真正的证据,还没有公开。'
},
{
beat: 'ending',
speaker: protagonist.name,
line: '下一次见面,该轮到我提条件了。'
}
];
const narration = [
context.episode.opening_hook,
`${protagonist.name}把所有情绪压回眼底,只留下一个明确目标。`,
conflict,
patternGuide.narrationHint,
foreshadowing?.content,
context.episode.ending_hook
].filter(Boolean).join('\n');
const scriptText = [
`【第${context.episode.episode_no}集】${context.episode.title ?? '未命名分集'}`,
`【本集摘要】${context.episode.summary ?? '待补充分集摘要'}`,
`【开头钩子】${context.episode.opening_hook ?? '主角进入高压场景。'}`,
`【中段冲突】${conflict}`,
patternGuide.scriptBlock,
`【脚本】`,
`1. ${protagonist.name}进入画面中心,场景压力直接压到观众面前。`,
`2. ${antagonist?.name ?? '主要对手'}试图用规则和舆论压制局面。`,
`3. ${helper?.name ?? protagonist.name}抛出关键线索,推动局势反转。`,
`4. ${protagonist.name}用短句完成反击,保留下一集悬念。`,
`【结尾悬念】${context.episode.ending_hook ?? context.storyBible.ending_direction ?? '幕后真相继续推进。'}`
].join('\n');
return {
script_text: scriptText,
narration_text: narration,
dialogue_json: dialogue
};
}
private buildStoryboardDrafts(
project: Project,
episode: Episode,
script: EpisodeScript,
characters: Character[],
creativePatterns: CreativePattern[]
): StoryboardDraft[] {
const protagonist =
characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ??
characters[0];
const antagonist = characters.find((character) => character.role_type === 'antagonist');
const helper = characters.find((character) => character.role_type === 'supporting');
const duration = Math.min(
MAX_SHOT_DURATION,
Math.max(MIN_SHOT_DURATION, Math.floor((episode.target_duration ?? 50) / DEFAULT_SHOT_COUNT))
);
const patternPromptContext = this.createStoryboardPatternPromptContext(creativePatterns);
const rhythmHint = this.patternPromptForType(creativePatterns, 'episode_rhythm');
const openingHint = this.patternPromptForType(creativePatterns, 'opening_hook');
const shotSeeds = [
{
scene: '开局压迫',
location: '会议室或雨夜室内,竖屏构图',
chars: [protagonist],
visual: `${protagonist.name}处于画面中心,眼神冷静,背景压暗。${openingHint ? ` 套路参考:${openingHint}` : ''}`,
action: '主角抬眼看向镜头,情绪从压抑转为坚定。',
dialogue: this.pickDialogue(script, protagonist.name) ?? '这一回,我不会再退。',
narration: episode.opening_hook,
camera: '近景缓慢推进',
effect: 'subtle_zoom'
},
{
scene: '证据出现',
location: '桌面、手机屏幕或投屏前',
chars: [protagonist],
visual: '手机录音、合同或关键证据占据画面前景。',
action: `${protagonist.name}把证据推到众人面前。`,
dialogue: null,
narration: '关键证据第一次进入画面。',
camera: '俯拍定格',
effect: 'flash_cut'
},
{
scene: '反派压制',
location: '会议桌对面',
chars: [antagonist ?? protagonist],
visual: `${antagonist?.name ?? '主要对手'}面部特写,表情克制但带压迫感。`,
action: '对手身体前倾,用冷静语气施压。',
dialogue: this.pickDialogue(script, antagonist?.name ?? '对手') ?? '你以为这样就够了吗?',
narration: null,
camera: '面部特写',
effect: 'speed_line'
},
{
scene: '主角反击',
location: '会议室中心',
chars: [protagonist, antagonist ?? protagonist],
visual: `${protagonist.name}${antagonist?.name ?? '对手'}分立画面两侧,形成强对抗。`,
action: `${protagonist.name}说出关键台词,对手表情第一次动摇。`,
dialogue: this.pickDialogue(script, protagonist.name) ?? '真正的证据,还没有公开。',
narration: episode.middle_conflict,
camera: '过肩对峙镜头',
effect: 'comic_impact'
},
{
scene: '支援入场',
location: '门口或走廊',
chars: [helper ?? protagonist],
visual: `${helper?.name ?? protagonist.name}拿着资料袋或手机快步进入。`,
action: '支援角色带来新线索,打断现场僵局。',
dialogue: this.pickDialogue(script, helper?.name ?? protagonist.name) ?? '备份还在。',
narration: null,
camera: '中景横移',
effect: 'quick_pan'
},
{
scene: '线索放大',
location: '投屏画面前',
chars: [protagonist],
visual: '屏幕上的时间戳、照片或合同细节被放大。',
action: `${protagonist.name}指向关键细节,现场气氛冻结。`,
dialogue: null,
narration: '伏笔被推进,但真相还未完全揭开。',
camera: '特写切入',
effect: 'freeze_frame'
},
{
scene: '反派震惊',
location: '对手席位',
chars: [antagonist ?? protagonist],
visual: `${antagonist?.name ?? '对手'}眼睛睁大,背景速度线,表情失控一瞬。`,
action: '对手短暂停顿,暴露破绽。',
dialogue: null,
narration: '局势第一次倒向主角。',
camera: '极近特写',
effect: 'shock_line'
},
{
scene: '主角掌控',
location: '画面中心',
chars: [protagonist],
visual: `${protagonist.name}站稳,服装和发型保持角色圣经规则。`,
action: '主角收起证据,语气平静地提出条件。',
dialogue: this.pickDialogue(script, protagonist.name) ?? '现在,轮到我提条件。',
narration: null,
camera: '低角度中近景',
effect: 'hero_light'
},
{
scene: '悬念前奏',
location: '走廊阴影或窗边',
chars: [helper ?? protagonist],
visual: '新消息弹出,屏幕只露出半句关键内容。',
action: '角色低头看手机,表情突然凝住。',
dialogue: null,
narration: episode.ending_hook,
camera: '手机屏幕特写',
effect: 'suspense_blink'
},
{
scene: '结尾钩子',
location: '阴影中的门口或车内',
chars: [protagonist],
visual: '神秘人物只露出半张脸或一只手,画面留白强。',
action: `关键人物或道具在最后一秒出现。${rhythmHint ? ` 节奏参考:${rhythmHint}` : ''}`,
dialogue: null,
narration: episode.ending_hook ?? '幕后真相继续推进。',
camera: '远景定格',
effect: 'cliffhanger'
}
];
return shotSeeds.map((seed, index) => {
const characterPayload = this.toCharacterPayload(seed.chars.filter(Boolean) as Character[]);
const baseShot = {
id: 0n,
project_id: project.id,
episode_id: episode.id,
shot_no: index + 1,
scene_name: seed.scene,
location_desc: seed.location,
characters_json: characterPayload,
visual_desc: seed.visual,
action_desc: seed.action,
dialogue_text: seed.dialogue,
narration_text: seed.narration ?? null,
camera_motion: seed.camera,
effect_type: seed.effect,
duration: { toString: () => String(duration) },
prompt_text: null,
negative_prompt: null,
status: 'generated',
created_at: new Date(),
updated_at: new Date()
} as unknown as StoryboardShot;
return {
shot_no: index + 1,
scene_name: seed.scene,
location_desc: seed.location,
characters_json: characterPayload,
visual_desc: seed.visual,
action_desc: seed.action,
dialogue_text: seed.dialogue,
narration_text: seed.narration ?? null,
camera_motion: seed.camera,
effect_type: seed.effect,
duration,
prompt_text: this.buildPromptText(baseShot, characters, patternPromptContext.promptSuffix),
negative_prompt: this.buildNegativePrompt(characters, patternPromptContext.negativeSuffix),
status: 'generated'
};
});
}
private createScriptUpdateData(dto: UpdateEpisodeScriptDto): Prisma.EpisodeScriptUncheckedUpdateInput {
const data: Prisma.EpisodeScriptUncheckedUpdateInput = {};
if ('script_text' in dto) data.script_text = this.optionalText(dto.script_text);
if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text);
if ('dialogue_json' in dto) data.dialogue_json = this.normalizeJson(dto.dialogue_json);
if ('status' in dto) data.status = this.validateScriptStatus(dto.status);
return data;
}
private async createShotUpdateData(
shot: StoryboardShot,
dto: UpdateStoryboardShotDto
): Promise<Prisma.StoryboardShotUncheckedUpdateInput> {
const data: Prisma.StoryboardShotUncheckedUpdateInput = {};
if ('shot_no' in dto) data.shot_no = await this.validateShotNoForUpdate(shot, dto.shot_no);
if ('scene_name' in dto) data.scene_name = this.optionalText(dto.scene_name);
if ('location_desc' in dto) data.location_desc = this.optionalText(dto.location_desc);
if ('characters_json' in dto) data.characters_json = this.normalizeJson(dto.characters_json);
if ('visual_desc' in dto) data.visual_desc = this.optionalText(dto.visual_desc);
if ('action_desc' in dto) data.action_desc = this.optionalText(dto.action_desc);
if ('dialogue_text' in dto) data.dialogue_text = this.optionalText(dto.dialogue_text);
if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text);
if ('camera_motion' in dto) data.camera_motion = this.optionalText(dto.camera_motion);
if ('effect_type' in dto) data.effect_type = this.optionalText(dto.effect_type);
if ('duration' in dto) data.duration = this.validateShotDuration(dto.duration);
if ('prompt_text' in dto) data.prompt_text = this.optionalText(dto.prompt_text);
if ('negative_prompt' in dto) data.negative_prompt = this.optionalText(dto.negative_prompt);
if ('status' in dto) data.status = this.validateStoryboardStatus(dto.status);
return data;
}
private assertScriptReady(script: EpisodeScript) {
if (!script.script_text || !script.narration_text || !script.dialogue_json) {
throw new BadRequestException('Script text, narration, and dialogue are required before confirmation');
}
}
private assertStoryboardReady(shots: StoryboardShot[]) {
if (shots.length === 0) {
throw new BadRequestException('Storyboard shots are required before confirmation');
}
for (const shot of shots) {
if (
!shot.visual_desc ||
!shot.action_desc ||
!shot.duration ||
!shot.prompt_text ||
!shot.negative_prompt
) {
throw new BadRequestException('Each storyboard shot must include visual, action, duration, prompt, and negative prompt');
}
const duration = Number(shot.duration.toString());
if (duration < MIN_SHOT_DURATION || duration > MAX_SHOT_DURATION) {
throw new BadRequestException('Each storyboard shot duration must be between 2 and 5 seconds');
}
}
}
private buildPromptText(shot: StoryboardShot, characters: Character[], patternPromptSuffix = '') {
const shotCharacters = this.readShotCharacterNames(shot);
const fixedCharacterText = characters
.filter((character) => shotCharacters.length === 0 || shotCharacters.includes(character.name))
.slice(0, 3)
.map((character) =>
`${character.name}${character.global_character_id ? `全局角色#${character.global_character_id.toString()}` : ''}${character.age_group ?? '固定年龄段'}${character.face_desc ?? character.appearance_desc ?? '固定脸型'}${character.hair_desc ?? '固定发型'}${character.costume_rules ?? '固定服装范围'}${character.wardrobe_variant ? `,本项目服装=${character.wardrobe_variant}` : ''}${character.performance_style ? `,表演=${character.performance_style}` : ''}`
)
.join('');
return [
'高质量韩漫风,竖屏9:16,电影光影,人物五官精致,背景清晰',
shot.location_desc,
shot.visual_desc,
shot.action_desc,
shot.camera_motion ? `镜头:${shot.camera_motion}` : null,
shot.effect_type ? `效果:${shot.effect_type}` : null,
fixedCharacterText ? `角色固定设定:${fixedCharacterText}` : null,
patternPromptSuffix ? `题材套路/视觉Prompt参考:${patternPromptSuffix}` : null,
'一个画面中心,一个主要动作,一个清晰情绪点'
].filter(Boolean).join('');
}
private buildNegativePrompt(characters: Character[], patternNegativeSuffix = '') {
const names = characters.map((character) => character.name).join('、');
return [
'低清晰度,崩坏手指,五官扭曲,多余肢体,文字水印,画面模糊',
'多人混脸,年龄漂移,发色无原因变化,服装完全跑偏',
names ? `禁止把${names}混合成同一个角色` : null,
patternNegativeSuffix ? `题材套路禁区:${patternNegativeSuffix}` : null,
'一个镜头超过4个主要人物,同镜头同时打斗,复杂手部互动'
].filter(Boolean).join('');
}
private async loadProjectCreativePatterns(projectId: bigint) {
const bindings = await this.prisma.projectCreativePattern.findMany({
where: { project_id: projectId },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }]
});
if (bindings.length === 0) {
return [];
}
const patterns = await this.prisma.creativePattern.findMany({
where: {
id: { in: bindings.map((binding) => binding.creative_pattern_id) },
status: 'active'
}
});
const patternMap = new Map(patterns.map((pattern) => [pattern.id.toString(), pattern]));
return bindings
.map((binding) => patternMap.get(binding.creative_pattern_id.toString()) ?? null)
.filter((pattern): pattern is CreativePattern => Boolean(pattern));
}
private createScriptPatternGuide(patterns: CreativePattern[]) {
if (patterns.length === 0) {
return {
scriptBlock: '',
narrationHint: ''
};
}
const items = patterns
.slice(0, 5)
.map((pattern) => `- ${pattern.title}${pattern.pattern_type}):${pattern.prompt_template ?? pattern.description ?? '复用该模式。'}`)
.join('\n');
const narrationHint = patterns
.map((pattern) => pattern.description ?? pattern.prompt_template)
.filter((value): value is string => Boolean(value))
.slice(0, 2)
.join('\n');
return {
scriptBlock: `【题材套路库】\n${items}`,
narrationHint
};
}
private createStoryboardPatternPromptContext(patterns: CreativePattern[]) {
const promptSuffix = patterns
.filter((pattern) => ['visual_prompt', 'opening_hook', 'episode_rhythm'].includes(pattern.pattern_type))
.map((pattern) => pattern.prompt_template ?? pattern.description)
.filter((value): value is string => Boolean(value))
.slice(0, 3)
.join('');
const negativeSuffix = patterns
.map((pattern) => pattern.negative_prompt)
.filter((value): value is string => Boolean(value))
.slice(0, 3)
.join('');
return { promptSuffix, negativeSuffix };
}
private patternPromptForType(patterns: CreativePattern[], patternType: string) {
const pattern = patterns.find((item) => item.pattern_type === patternType);
return pattern?.prompt_template ?? pattern?.description ?? '';
}
private toCharacterPayload(characters: Character[]) {
return characters.slice(0, 3).map((character) => ({
id: character.id.toString(),
name: character.name,
role_type: character.role_type,
fixed_desc: character.appearance_desc,
costume_rules: character.costume_rules
}));
}
private pickDialogue(script: EpisodeScript, speaker: string) {
const dialogue = Array.isArray(script.dialogue_json) ? script.dialogue_json : [];
const item = dialogue.find((entry) =>
typeof entry === 'object' &&
entry !== null &&
'speaker' in entry &&
String(entry.speaker) === speaker &&
'line' in entry
);
return item && typeof item === 'object' && 'line' in item ? String(item.line) : null;
}
private readShotCharacterNames(shot: StoryboardShot) {
const value = shot.characters_json;
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) =>
typeof item === 'object' && item !== null && 'name' in item ? String(item.name) : ''
)
.filter(Boolean);
}
private async findShotForUser(shotId: string, user: AuthRequestUser) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: this.parseId(shotId, 'Invalid storyboard shot id') }
});
if (!shot) {
throw new NotFoundException('Storyboard shot not found');
}
await this.loadEpisodeForUser(shot.episode_id.toString(), user);
return shot;
}
private async findLatestScript(episodeId: bigint) {
return this.prisma.episodeScript.findFirst({
where: { episode_id: episodeId },
orderBy: { version: 'desc' }
});
}
private async findConfirmedScript(episodeId: bigint) {
return this.prisma.episodeScript.findFirst({
where: {
episode_id: episodeId,
status: 'confirmed'
},
orderBy: { version: 'desc' }
});
}
private async nextScriptVersion(episodeId: bigint) {
const latest = await this.findLatestScript(episodeId);
return (latest?.version ?? 0) + 1;
}
private async validateShotNoForUpdate(shot: StoryboardShot, value: number | undefined) {
const shotNo = this.validatePositiveInt(value, 'shot_no', 1, 500);
if (shotNo === shot.shot_no) {
return shotNo;
}
const existing = await this.prisma.storyboardShot.findFirst({
where: {
episode_id: shot.episode_id,
shot_no: shotNo,
id: { not: shot.id }
}
});
if (existing) {
throw new BadRequestException('shot_no already exists in this episode');
}
return shotNo;
}
private validateShotDuration(value: number | undefined) {
return this.validatePositiveInt(value, 'duration', MIN_SHOT_DURATION, MAX_SHOT_DURATION);
}
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 validateScriptStatus(value: string | undefined): ScriptStatus {
if (!value || !SCRIPT_STATUSES.includes(value as never)) {
throw new BadRequestException('script status is invalid');
}
return value as ScriptStatus;
}
private validateStoryboardStatus(value: string | undefined): StoryboardStatus {
if (!value || !STORYBOARD_STATUSES.includes(value as never)) {
throw new BadRequestException('storyboard status is invalid');
}
return value as StoryboardStatus;
}
private normalizeJson(value: unknown): Prisma.InputJsonValue {
if (value === undefined) {
throw new BadRequestException('json value is required');
}
return value as Prisma.InputJsonValue;
}
private optionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || null;
}
private parseId(id: string, message: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException(message);
}
}
}