Files
SkriptAI-be/src/modules/skriptai/services/scripts.service.ts
Harun CAN e60b6ea526
Some checks failed
CI / build (push) Has been cancelled
main
2026-03-23 02:34:03 +03:00

483 lines
14 KiB
TypeScript

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<any[]>(
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<any[]>(
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;
}
}