Initial commit

This commit is contained in:
Harun CAN
2026-03-23 01:59:17 +03:00
commit 458127ce76
136 changed files with 26214 additions and 0 deletions

View 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,
},
});
}
}