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 { 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 { 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); } } }