generated from fahricansecer/boilerplate-be
Initial commit
This commit is contained in:
397
src/modules/skriptai/services/scripts.service.ts
Normal file
397
src/modules/skriptai/services/scripts.service.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
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';
|
||||
|
||||
// AI_CONFIG is only used for model selection reference
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
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 word count based on duration
|
||||
let targetWordCount = 840;
|
||||
if (project.targetDuration.includes('Short')) targetWordCount = 140;
|
||||
else if (project.targetDuration.includes('Standard')) targetWordCount = 840;
|
||||
else if (project.targetDuration.includes('Long')) targetWordCount = 1680;
|
||||
else if (project.targetDuration.includes('Deep Dive'))
|
||||
targetWordCount = 2800;
|
||||
|
||||
const estimatedChapters = Math.ceil(targetWordCount / 200);
|
||||
|
||||
// PHASE 1: Generate Outline
|
||||
const outlinePrompt = `
|
||||
Create a CONTENT OUTLINE.
|
||||
Topic: "${project.topic}"
|
||||
Logline: "${project.logline || ''}"
|
||||
Characters: ${characterContext}
|
||||
Styles: ${project.speechStyle.join(', ')}. Audience: ${project.targetAudience.join(', ')}.
|
||||
Format: ${project.contentType}. Target Duration: ${project.targetDuration}. Target Total Word Count: ${targetWordCount}.
|
||||
Generate exactly ${estimatedChapters} chapters.
|
||||
Material: ${sourceContext.substring(0, 15000)}
|
||||
Brief: ${briefContext}
|
||||
|
||||
Return JSON: {
|
||||
"title": "Title", "seoDescription": "Desc", "tags": ["tag1"],
|
||||
"thumbnailIdeas": ["Idea 1"],
|
||||
"chapters": [{ "title": "Chap 1", "focus": "Summary", "type": "Intro" }]
|
||||
}
|
||||
`;
|
||||
|
||||
const outlineResp = await this.gemini.generateJSON<{
|
||||
title: string;
|
||||
seoDescription: string;
|
||||
tags: string[];
|
||||
thumbnailIdeas: string[];
|
||||
chapters: { title: string; focus: string; type: string }[];
|
||||
}>(
|
||||
outlinePrompt,
|
||||
'{ title, seoDescription, tags, thumbnailIdeas, chapters }',
|
||||
);
|
||||
|
||||
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
|
||||
const generatedSegments: any[] = [];
|
||||
let timeOffset = 0;
|
||||
|
||||
for (let i = 0; i < outlineData.chapters.length; i++) {
|
||||
const chapter = outlineData.chapters[i];
|
||||
|
||||
const chapterPrompt = `
|
||||
Write Script Segment ${i + 1}/${outlineData.chapters.length}.
|
||||
Chapter: "${chapter.title}". Focus: ${chapter.focus}.
|
||||
Style: ${project.speechStyle.join(', ')}.
|
||||
Audience: ${project.targetAudience.join(', ')}.
|
||||
Characters: ${characterContext}.
|
||||
Target Length: ~200 words.
|
||||
Language: ${project.language}.
|
||||
|
||||
Return JSON Array: [{
|
||||
"segmentType": "${chapter.type || 'Body'}",
|
||||
"narratorScript": "Full text...",
|
||||
"visualDescription": "Detailed visual explanation...",
|
||||
"videoPrompt": "Cinematic shot of [subject], 4k...",
|
||||
"imagePrompt": "Hyper-realistic photo of [subject]...",
|
||||
"onScreenText": "Overlay text...",
|
||||
"stockQuery": "Pexels keyword",
|
||||
"audioCues": "SFX..."
|
||||
}]
|
||||
`;
|
||||
|
||||
try {
|
||||
const segmentResp = await this.gemini.generateJSON<any[]>(
|
||||
chapterPrompt,
|
||||
'[{ segmentType, narratorScript, visualDescription, videoPrompt, imagePrompt, onScreenText, stockQuery, audioCues }]',
|
||||
);
|
||||
|
||||
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 prompt = `
|
||||
Rewrite this script segment.
|
||||
Current Text: "${segment.narratorScript}"
|
||||
Goal: Change style to "${newStyle}".
|
||||
Context: Topic is "${segment.project.topic}". Language: ${segment.project.language}.
|
||||
Principles: Show Don't Tell, Subtext.
|
||||
|
||||
Return JSON: {
|
||||
"narratorScript": "New text...",
|
||||
"visualDescription": "Updated visual...",
|
||||
"onScreenText": "Updated overlay...",
|
||||
"audioCues": "Updated audio..."
|
||||
}
|
||||
`;
|
||||
|
||||
const rewriteResp = await this.gemini.generateJSON<{
|
||||
narratorScript: string;
|
||||
visualDescription: string;
|
||||
onScreenText: string;
|
||||
audioCues: string;
|
||||
}>(
|
||||
prompt,
|
||||
'{ narratorScript, visualDescription, onScreenText, audioCues }',
|
||||
);
|
||||
|
||||
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 promptGenPrompt = `
|
||||
Create a detailed AI Image Generation Prompt and a Video Generation Prompt for this script segment.
|
||||
Topic: "${segment.project.topic}"
|
||||
Segment Content: "${segment.narratorScript}"
|
||||
Visual Context: "${segment.visualDescription}"
|
||||
|
||||
Goal: Create a highly detailed, cinematic, and artistic prompt optimized for tools like Midjourney, Flux, or Runway.
|
||||
Style: Cinematic, highly detailed, 8k, professional lighting.
|
||||
|
||||
Return JSON: {
|
||||
"imagePrompt": "Full detailed image prompt...",
|
||||
"videoPrompt": "Full detailed video prompt..."
|
||||
}
|
||||
`;
|
||||
|
||||
const prompts = await this.gemini.generateJSON<{
|
||||
imagePrompt: string;
|
||||
videoPrompt: string;
|
||||
}>(promptGenPrompt, '{ imagePrompt, videoPrompt }');
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user