generated from fahricansecer/boilerplate-be
483 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|