import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../database/prisma.service'; import { GeminiService } from '../../gemini/gemini.service'; import { CreateSegmentDto, UpdateSegmentDto } from '../dto'; import { AnalysisService } from './analysis.service'; import { VersionsService } from './versions.service'; import { buildScriptOutlinePrompt, buildChapterSegmentPrompt, buildSegmentRewritePrompt, buildSegmentImagePrompt, calculateTargetWordCount, calculateEstimatedChapters, } from '../prompts'; /** * ScriptsService * * Service for managing script segments and AI-powered script generation. * * TR: Script segmentlerini yönetmek ve AI destekli script oluşturmak için servis. * EN: Service for managing script segments and AI-powered script generation. */ @Injectable() export class ScriptsService { private readonly logger = new Logger(ScriptsService.name); constructor( private readonly prisma: PrismaService, private readonly gemini: GeminiService, private readonly analysisService: AnalysisService, private readonly versionsService: VersionsService, ) {} /** * Create a new segment */ async createSegment(data: CreateSegmentDto) { // Get highest sortOrder for this project const lastSegment = await this.prisma.scriptSegment.findFirst({ where: { projectId: data.projectId }, orderBy: { sortOrder: 'desc' }, }); const sortOrder = data.sortOrder ?? (lastSegment?.sortOrder ?? 0) + 1; return this.prisma.scriptSegment.create({ data: { ...data, sortOrder, citationIndexes: [], }, }); } /** * Update a segment */ async updateSegment(id: string, data: UpdateSegmentDto) { const segment = await this.prisma.scriptSegment.findUnique({ where: { id }, }); if (!segment) { throw new NotFoundException(`Segment with ID ${id} not found`); } return this.prisma.scriptSegment.update({ where: { id }, data, }); } /** * Delete a segment */ async deleteSegment(id: string) { const segment = await this.prisma.scriptSegment.findUnique({ where: { id }, }); if (!segment) { throw new NotFoundException(`Segment with ID ${id} not found`); } return this.prisma.scriptSegment.delete({ where: { id } }); } /** * Reorder segments * * @param projectId - Project ID * @param segmentIds - Array of segment IDs in new order */ async reorderSegments(projectId: string, segmentIds: string[]) { const updates = segmentIds.map((id, index) => this.prisma.scriptSegment.update({ where: { id }, data: { sortOrder: index }, }), ); await this.prisma.$transaction(updates); return this.prisma.scriptSegment.findMany({ where: { projectId }, orderBy: { sortOrder: 'asc' }, }); } /** * Generate a full script for a project * * @param projectId - Project ID * @returns Generated segments */ async generateScript(projectId: string) { this.logger.log(`Generating script for project: ${projectId}`); // Auto-snapshot current state before regeneration await this.versionsService.createSnapshot( projectId, 'AUTO_SAVE', undefined, 'Auto-save before script generation', ).catch(() => { /* ignore if no segments yet */ }); // Update status await this.prisma.scriptProject.update({ where: { id: projectId }, data: { status: 'SCRIPTING' }, }); const project = await this.prisma.scriptProject.findUnique({ where: { id: projectId }, include: { sources: { where: { selected: true } }, briefItems: { orderBy: { sortOrder: 'asc' } }, characters: true, }, }); if (!project) { throw new NotFoundException(`Project with ID ${projectId} not found`); } // Build context from project data const sourceContext = project.sources .slice(0, 5) .map((s, i) => `[Source ${i + 1}] (${s.type}): ${s.title} - ${s.snippet}`) .join('\n'); const briefContext = project.briefItems .map((b) => `Q: ${b.question}\nA: ${b.answer}`) .join('\n'); const characterContext = project.characters .map( (c) => `${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}] Mannerisms[${c.mannerisms}]`, ) .join('\n'); // Calculate target metrics const targetWordCount = calculateTargetWordCount(project.targetDuration); const estimatedChapters = calculateEstimatedChapters(targetWordCount); // PHASE 1: Generate Outline using prompt builder const outlinePromptData = buildScriptOutlinePrompt({ topic: project.topic, logline: project.logline || '', characterContext, speechStyles: project.speechStyle, targetAudience: project.targetAudience, contentType: project.contentType, targetDuration: project.targetDuration, targetWordCount, estimatedChapters, sourceContext, briefContext, }); const outlineResp = await this.gemini.generateJSON<{ title: string; seoDescription: string; tags: string[]; thumbnailIdeas: string[]; chapters: { title: string; focus: string; type: string }[]; }>(outlinePromptData.prompt, outlinePromptData.schema, { temperature: outlinePromptData.temperature, }); const outlineData = outlineResp.data; // Update project with SEO data await this.prisma.scriptProject.update({ where: { id: projectId }, data: { seoTitle: outlineData.title, seoDescription: outlineData.seoDescription, seoTags: outlineData.tags, thumbnailIdeas: outlineData.thumbnailIdeas, }, }); // PHASE 2: Generate each chapter using prompt builder const generatedSegments: any[] = []; let timeOffset = 0; for (let i = 0; i < outlineData.chapters.length; i++) { const chapter = outlineData.chapters[i]; const chapterPromptData = buildChapterSegmentPrompt({ chapterIndex: i, totalChapters: outlineData.chapters.length, chapterTitle: chapter.title, chapterFocus: chapter.focus, chapterType: chapter.type, speechStyles: project.speechStyle, targetAudience: project.targetAudience, characterContext, language: project.language, }); try { const segmentResp = await this.gemini.generateJSON( chapterPromptData.prompt, chapterPromptData.schema, { temperature: chapterPromptData.temperature }, ); for (const seg of segmentResp.data) { const words = seg.narratorScript ? seg.narratorScript.split(' ').length : 0; const dur = Math.max(5, Math.ceil(words / (140 / 60))); const start = this.formatTime(timeOffset); timeOffset += dur; generatedSegments.push({ projectId, segmentType: seg.segmentType || 'Body', timeStart: start, duration: `${dur}s`, narratorScript: seg.narratorScript || '', visualDescription: seg.visualDescription || 'Background', videoPrompt: seg.videoPrompt || `Cinematic shot of ${seg.stockQuery}`, imagePrompt: seg.imagePrompt || `High quality image of ${seg.stockQuery}`, onScreenText: seg.onScreenText || '', editorNotes: '', generalNotes: '', audioCues: seg.audioCues || '', stockQuery: seg.stockQuery || 'background', sortOrder: generatedSegments.length, citationIndexes: [], }); } } catch (error) { this.logger.warn(`Failed to generate chapter ${i + 1}: ${error}`); } } // Clear existing segments and insert new ones await this.prisma.scriptSegment.deleteMany({ where: { projectId } }); if (generatedSegments.length > 0) { await this.prisma.scriptSegment.createMany({ data: generatedSegments, }); } return this.prisma.scriptSegment.findMany({ where: { projectId }, orderBy: { sortOrder: 'asc' }, }); } /** * Rewrite a segment with a new style * * @param segmentId - Segment ID * @param newStyle - New speech style * @returns Updated segment */ async rewriteSegment( segmentId: string, newStyle: string, // SpeechStyle or 'Make it Longer' | 'Make it Shorter' ) { const segment = await this.prisma.scriptSegment.findUnique({ where: { id: segmentId }, include: { project: true }, }); if (!segment) { throw new NotFoundException(`Segment with ID ${segmentId} not found`); } const promptData = buildSegmentRewritePrompt({ currentScript: segment.narratorScript || '', newStyle, topic: segment.project.topic, language: segment.project.language, }); const rewriteResp = await this.gemini.generateJSON<{ narratorScript: string; visualDescription: string; onScreenText: string; audioCues: string; }>(promptData.prompt, promptData.schema, { temperature: promptData.temperature, }); const data = rewriteResp.data; const words = data.narratorScript ? data.narratorScript.split(' ').length : 0; const dur = Math.max(5, Math.ceil(words / (140 / 60))); return this.prisma.scriptSegment.update({ where: { id: segmentId }, data: { narratorScript: data.narratorScript, visualDescription: data.visualDescription, onScreenText: data.onScreenText, audioCues: data.audioCues, duration: `${dur}s`, }, }); } /** * Format seconds to MM:SS */ private formatTime(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } /** * Generate an image for a specific segment */ async generateSegmentImage(segmentId: string) { const segment = await this.prisma.scriptSegment.findUnique({ where: { id: segmentId }, include: { project: true }, }); if (!segment) { throw new NotFoundException(`Segment with ID ${segmentId} not found`); } // 1. Generate/Refine Image Prompt using LLM const promptData = buildSegmentImagePrompt({ topic: segment.project.topic, narratorScript: segment.narratorScript || '', visualDescription: segment.visualDescription || '', }); const prompts = await this.gemini.generateJSON<{ imagePrompt: string; videoPrompt: string; }>(promptData.prompt, promptData.schema, { temperature: promptData.temperature, }); // 2. Use the new image prompt for generation const imageUrl = await this.analysisService.generateThumbnailImage( prompts.data.imagePrompt, ); // 3. Update segment with new prompts AND generated image URL return this.prisma.scriptSegment.update({ where: { id: segmentId }, data: { imagePrompt: prompts.data.imagePrompt, videoPrompt: prompts.data.videoPrompt, generatedImageUrl: imageUrl, }, }); } // ========== REGENERATE (Tek segment / Partial) ========== /** * Regenerate a single segment with AI. * Auto-snapshots current state before regeneration. * * @param segmentId - Segment ID to regenerate * @returns Updated segment */ async regenerateSegment(segmentId: string) { const segment = await this.prisma.scriptSegment.findUnique({ where: { id: segmentId }, include: { project: { include: { characters: true } } }, }); if (!segment) { throw new NotFoundException(`Segment with ID ${segmentId} not found`); } // Auto-snapshot before regeneration await this.versionsService.createSnapshot( segment.projectId, 'AUTO_SAVE', undefined, `Auto-save before regenerating segment #${segment.sortOrder + 1}`, ).catch(() => {}); const characterContext = segment.project.characters .map((c) => `${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}]`) .join('\n'); const chapterPromptData = buildChapterSegmentPrompt({ chapterIndex: segment.sortOrder, totalChapters: 1, chapterTitle: segment.segmentType, chapterFocus: segment.visualDescription || segment.segmentType, chapterType: segment.segmentType, speechStyles: segment.project.speechStyle, targetAudience: segment.project.targetAudience, characterContext, language: segment.project.language, }); const resp = await this.gemini.generateJSON( chapterPromptData.prompt, chapterPromptData.schema, { temperature: chapterPromptData.temperature }, ); const newSeg = resp.data[0]; if (!newSeg) return segment; const words = newSeg.narratorScript ? newSeg.narratorScript.split(' ').length : 0; const dur = Math.max(5, Math.ceil(words / (140 / 60))); return this.prisma.scriptSegment.update({ where: { id: segmentId }, data: { narratorScript: newSeg.narratorScript, visualDescription: newSeg.visualDescription, videoPrompt: newSeg.videoPrompt, imagePrompt: newSeg.imagePrompt, onScreenText: newSeg.onScreenText, audioCues: newSeg.audioCues, stockQuery: newSeg.stockQuery, duration: `${dur}s`, }, }); } /** * Regenerate multiple selected segments. * * @param projectId - Project ID * @param segmentIds - Array of segment IDs to regenerate * @returns Updated segments */ async regeneratePartial(projectId: string, segmentIds: string[]) { // Auto-snapshot await this.versionsService.createSnapshot( projectId, 'AUTO_SAVE', undefined, `Auto-save before partial regeneration (${segmentIds.length} segments)`, ).catch(() => {}); const results: any[] = []; for (const segId of segmentIds) { try { const result = await this.regenerateSegment(segId); results.push(result); } catch (error) { this.logger.warn(`Failed to regenerate segment ${segId}: ${error}`); } } return results; } }