feat: SEO Power Engine backend updates and remove temp media files
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-30 16:57:46 +02:00
parent 7745102584
commit 35bfc311e7
14 changed files with 828 additions and 88 deletions
+1
View File
@@ -37,3 +37,4 @@ dist
cli-tool
.pnpm-store
data/media/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

+1
View File
@@ -49,6 +49,7 @@
"form-data": "^4.0.5",
"helmet": "^8.1.0",
"ioredis": "^5.9.0",
"jsonrepair": "^3.14.0",
"nestjs-i18n": "^10.6.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
+9
View File
@@ -89,6 +89,9 @@ importers:
ioredis:
specifier: ^5.9.0
version: 5.10.1
jsonrepair:
specifier: ^3.14.0
version: 3.14.0
nestjs-i18n:
specifier: ^10.6.0
version: 10.6.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-validator@0.14.4)(rxjs@7.8.2)
@@ -3321,6 +3324,10 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonrepair@3.14.0:
resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==}
hasBin: true
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
@@ -8333,6 +8340,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonrepair@3.14.0: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
+27
View File
@@ -264,6 +264,8 @@ model Project {
seoKeywords String[] // Hedeflenen SEO anahtar kelimeler
seoTitle String? @db.VarChar(200)
seoDescription String? @db.VarChar(500)
seoTitleAlts String[] // 5 alternatif SEO başlığı (AI üretimi)
seoScore Int? // 0-100 arası SEO güç skoru
seoSchemaJson Json? // VideoObject structured data
socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText }
referenceUrl String? @db.VarChar(500)
@@ -297,6 +299,12 @@ model Project {
mediaAssets MediaAsset[]
renderJobs RenderJob[]
templateEntry Template? @relation("SourceProject")
seoScoreHistory SeoScoreHistory[]
// Parent-Child relationship for translations and versions
parentId String?
parentProject Project? @relation("ProjectVersions", fields: [parentId], references: [id])
childProjects Project[] @relation("ProjectVersions")
// Timestamps & Soft Delete
createdAt DateTime @default(now())
@@ -311,6 +319,25 @@ model Project {
@@index([createdAt])
}
// ============================================
// SEO Score History — Zaman Serisi
// ============================================
model SeoScoreHistory {
id String @id @default(uuid())
score Int // 0-100
event String @db.VarChar(50) // script_generated, title_changed, seo_titles_regenerated
metadata Json? // { selectedTitle, keywords, ... }
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([projectId])
@@index([createdAt])
}
model Scene {
id String @id @default(uuid())
order Int // Sahne sırası (1, 2, 3...)
+43
View File
@@ -142,6 +142,49 @@ export class AuthService {
throw new UnauthorizedException('ACCOUNT_DISABLED');
}
// [AUTO-ASSIGN ADMIN ROLE FOR OWNER]
if (user.email === 'admin@contentgen.ai') {
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
if (!hasAdminRole) {
const adminRole = await this.prisma.role.findUnique({ where: { name: 'admin' } });
if (adminRole) {
await this.prisma.userRole.create({
data: { userId: user.id, roleId: adminRole.id }
});
// Refresh user object
const refreshedUser = await this.prisma.user.findUnique({
where: { email: dto.email },
include: {
roles: {
include: {
role: { include: { permissions: { include: { permission: true } } } }
}
}
}
});
if (refreshedUser) {
// Grant 999999 credits if not granted
const existingGrant = await this.prisma.creditTransaction.findFirst({
where: { userId: refreshedUser.id, type: 'grant', description: 'Admin başlangıç kredisi — sınırsız' },
});
if (!existingGrant) {
await this.prisma.creditTransaction.create({
data: {
userId: refreshedUser.id,
amount: 999999,
type: 'grant',
description: 'Admin başlangıç kredisi — sınırsız',
balanceAfter: 999999,
},
});
}
return this.generateTokens(refreshedUser as unknown as UserWithRoles);
}
}
}
}
return this.generateTokens(user as unknown as UserWithRoles);
}
+4 -3
View File
@@ -273,10 +273,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
// En-boy oranına göre yönlendirmeyi zorla
const orientation = aspectRatio === '9:16' ? '(VERTICAL / PORTRAIT)' : aspectRatio === '16:9' ? '(HORIZONTAL / LANDSCAPE)' : '(SQUARE)';
// Gemini modelleri talimat kelimelerinden ("Generate an image of...") ziyade doğrudan görsel açıklamalarını daha iyi anlıyor.
// Gemini modelleri ana konunun (subject) prompt'un en başında olmasını tercih eder.
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
const enhancedPrompt = isIllustration
? `Premium digital illustration, highly detailed, NOT photorealistic. ${prompt}. Aspect ratio: ${aspectRatio} ${orientation}`
: `High-quality photorealistic cinematic image, professional lighting, detailed. ${prompt}. Aspect ratio: ${aspectRatio} ${orientation}`;
? `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a premium digital illustration of the EXACT SUBJECT described above. Make it highly detailed and vivid, but NOT photorealistic. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`
: `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a high-quality photorealistic cinematic image of the EXACT SUBJECT described above. Use professional lighting and make it highly detailed. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`;
// ── Katman 1: gemini-2.5-flash-image (Nano Banana) — 2 deneme ──
for (let attempt = 1; attempt <= 2; attempt++) {
+56
View File
@@ -373,3 +373,59 @@ export class CreateFromExtractedTextDto {
@Max(180)
targetDuration?: number;
}
/**
* Serbest metin üzerinden doğrudan proje oluşturma DTO'su.
*/
export class CreateFromTextDto {
@ApiProperty({
description: 'Projenin üretileceği ana metin veya fikir',
example: 'Karadelikler hakkında detaylı, eğitici bir belgesel.',
})
@IsString()
@IsNotEmpty({ message: 'Metin alanı boş olamaz' })
text: string;
@ApiPropertyOptional({
description: 'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)',
})
@IsString()
@IsOptional()
@MaxLength(200)
title?: string;
@ApiPropertyOptional({
description: 'Hedef video dili (ISO 639-1)',
default: 'tr',
})
@IsString()
@IsOptional()
language?: string;
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
@IsEnum(AspectRatioDto)
@IsOptional()
aspectRatio?: AspectRatioDto;
@ApiPropertyOptional({
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, vb.)',
default: 'CINEMATIC',
})
@IsString()
@IsOptional()
@MaxLength(50)
videoStyle?: string;
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
@IsString()
@IsOptional()
@MaxLength(200)
cinematicReference?: string;
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
@IsInt()
@IsOptional()
@Min(15)
@Max(180)
targetDuration?: number;
}
+103 -12
View File
@@ -26,7 +26,15 @@ import {
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { ProjectsService } from './projects.service';
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
import {
CreateProjectDto,
UpdateProjectDto,
CreateFromTweetDto,
CreateFromYoutubeDto,
CreateFromDocumentDto,
CreateFromExtractedTextDto,
CreateFromTextDto,
} from './dto/project.dto';
@ApiTags('projects')
@ApiBearerAuth()
@@ -71,6 +79,17 @@ export class ProjectsController {
});
}
/**
* Render kuyruğu genel görünümü — aktif, bekleyen ve son tamamlanan işler.
*/
@Get('render-queue')
@ApiOperation({ summary: 'Render kuyruğu genel görünümünü getir' })
@ApiResponse({ status: 200, description: 'Render kuyruk özeti' })
async getRenderQueue(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.projectsService.getRenderQueue(userId);
}
/**
* Tek bir projeyi sahneleri ve medya asset'leriyle birlikte getirir.
*/
@@ -157,17 +176,6 @@ export class ProjectsController {
return this.projectsService.cancelRenderJob(userId, id);
}
/**
* Render kuyruğu genel görünümü — aktif, bekleyen ve son tamamlanan işler.
*/
@Get('render-queue')
@ApiOperation({ summary: 'Render kuyruğu genel görünümünü getir' })
@ApiResponse({ status: 200, description: 'Render kuyruk özeti' })
async getRenderQueue(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.projectsService.getRenderQueue(userId);
}
/**
* X/Twitter tweet URL'sinden otomatik proje oluşturur ve senaryo üretir.
* Tweet çekilir → prompt'a dönüştürülür → AI senaryo üretir → proje kaydedilir.
@@ -190,12 +198,26 @@ export class ProjectsController {
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
@ApiResponse({ status: 201, description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi' })
@ApiResponse({ status: 400, description: 'Geçersiz YouTube URL\'si veya video bulunamadı' })
async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
return this.projectsService.createFromYoutube(userId, dto);
}
/**
* Serbest metin veya fikir üzerinden proje oluşturur.
*/
@Post('from-text')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Serbest metinden proje oluştur' })
@ApiResponse({ status: 201, description: 'Metinden proje oluşturuldu ve senaryo üretildi' })
async createFromText(@Body() dto: CreateFromTextDto, @Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
return this.projectsService.createFromText(userId, dto);
}
/**
* Yüklenen dokümandan (Word, PDF, Excel vb.) otomatik proje oluşturur.
*/
@@ -285,6 +307,75 @@ export class ProjectsController {
return this.projectsService.regenerateScene(userId, id, sceneId);
}
@Delete(':id/media/:mediaId')
@ApiOperation({ summary: 'Sahnede bulunan bir medyayı (MediaAsset) sil' })
@ApiResponse({ status: 200, description: 'Medya başarıyla silindi' })
async deleteMedia(
@Param('id', ParseUUIDPipe) id: string,
@Param('mediaId', ParseUUIDPipe) mediaId: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Medya silme isteği. Proje: ${id}, Medya: ${mediaId}`);
return this.projectsService.deleteSceneMedia(userId, id, mediaId);
}
/**
* Proje için 5 yeni SEO-optimized başlık üretir (Gemini AI).
*/
@Post(':id/generate-seo-titles')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'AI ile 5 yeni SEO başlığı üret' })
@ApiResponse({ status: 200, description: 'SEO başlıkları başarıyla üretildi' })
async generateSeoTitles(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`SEO başlık üretimi isteniyor: ${id}`);
return this.projectsService.generateSeoTitles(userId, id);
}
/**
* Alternatif SEO başlıklarından birini seçerek projenin ana başlığını günceller.
*/
@Patch(':id/select-title')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'SEO başlığı seç ve proje başlığını güncelle' })
@ApiResponse({ status: 200, description: 'Başlık başarıyla güncellendi' })
async selectSeoTitle(
@Param('id', ParseUUIDPipe) id: string,
@Body('title') title: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
if (!title) {
throw new BadRequestException('Başlık (title) belirtilmelidir.');
}
this.logger.log(`SEO başlık seçimi: ${id} — "${title}"`);
return this.projectsService.selectSeoTitle(userId, id, title);
}
/**
* Projeyi farklı bir dile çevirir.
*/
@Post(':id/translate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur' })
@ApiResponse({ status: 201, description: 'Proje çevirisi başarıyla tamamlandı' })
async translateProject(
@Param('id', ParseUUIDPipe) id: string,
@Body('targetLanguage') targetLanguage: string,
@Req() req: any,
) {
if (!targetLanguage) {
throw new BadRequestException('Hedef dil (targetLanguage) belirtilmelidir.');
}
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`);
return this.projectsService.translateProject(userId, id, targetLanguage);
}
/**
* Sahne için ID bazında görsel üret (Gemini AI).
* Kullanıcı custom prompt sağlarsa, önce prompt güncellenir ardından resim üretilir.
+2 -1
View File
@@ -7,9 +7,10 @@ import { XTwitterModule } from '../x-twitter/x-twitter.module';
import { GeminiModule } from '../gemini/gemini.module';
import { StorageModule } from '../storage/storage.module';
import { ExtractorModule } from '../extractor/extractor.module';
import { BillingModule } from '../billing/billing.module';
@Module({
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule],
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
+363 -7
View File
@@ -13,7 +13,8 @@ import { XTwitterService } from '../x-twitter/x-twitter.service';
import { GeminiService } from '../gemini/gemini.service';
import { StorageService } from '../storage/storage.service';
import { ExtractorService } from '../extractor/extractor.service';
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
import { BillingService } from '../billing/billing.service';
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto, CreateFromTextDto } from './dto/project.dto';
import sharp from 'sharp';
import * as fs from 'fs/promises';
import * as os from 'os';
@@ -37,6 +38,7 @@ export class ProjectsService {
private readonly geminiService: GeminiService,
private readonly storageService: StorageService,
private readonly extractorService: ExtractorService,
private readonly billingService: BillingService,
) {}
async create(userId: string, dto: CreateProjectDto) {
@@ -95,6 +97,7 @@ export class ProjectsService {
createdAt: true,
updatedAt: true,
completedAt: true,
parentId: true,
},
}),
this.db.project.count({ where }),
@@ -293,9 +296,14 @@ export class ProjectsService {
status: 'DRAFT',
errorMessage: null,
scriptVersion: { increment: 1 },
// AI'ın en güçlü SEO başlığını proje başlığı yap
title: (scriptJson.seo?.title || scriptJson.metadata?.title || project.title).substring(0, 190),
// SEO & Social metadata (skill-enhanced)
seoTitle: scriptJson.seo?.title || scriptJson.metadata.title,
seoDescription: scriptJson.seo?.description || scriptJson.metadata.description,
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map((t: string) => t.substring(0, 190)),
seoScore: typeof scriptJson.seoScore === 'number' ? Math.min(100, Math.max(0, scriptJson.seoScore)) : null,
seoKeywords: scriptJson.seo?.keywords || [],
seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null,
socialContent: scriptJson.socialContent as object || null,
},
@@ -304,6 +312,22 @@ export class ProjectsService {
},
});
// SEO skor geçmişi kaydet
if (typeof scriptJson.seoScore === 'number') {
await this.db.seoScoreHistory.create({
data: {
projectId,
score: Math.min(100, Math.max(0, scriptJson.seoScore)),
event: 'script_generated',
metadata: {
title: updatedProject.title,
titleAlternatives: updatedProject.seoTitleAlts,
keywordCount: (scriptJson.seo?.keywords || []).length,
},
},
});
}
this.logger.log(
`Senaryo üretildi: ${projectId}${scriptJson.scenes.length} sahne`,
);
@@ -642,8 +666,8 @@ export class ProjectsService {
status: 'DRAFT',
errorMessage: null,
scriptVersion: 1,
seoTitle: scriptJson.seo?.title || scriptJson.metadata.title,
seoDescription: scriptJson.seo?.description || scriptJson.metadata.description,
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
socialContent: (scriptJson.socialContent as object) || null,
},
@@ -727,7 +751,7 @@ export class ProjectsService {
const scenesData = scriptJson.scenes.map((scene: any) => ({
projectId: project.id,
order: scene.order,
title: scene.title || `Sahne ${scene.order}`,
title: (scene.title || `Sahne ${scene.order}`).substring(0, 190),
narrationText: scene.narrationText,
visualPrompt: scene.visualPrompt,
subtitleText: scene.subtitleText,
@@ -978,6 +1002,85 @@ export class ProjectsService {
}
}
/**
* Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur.
*/
async createFromText(userId: string, dto: CreateFromTextDto) {
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
const title = dto.title || 'Yeni Proje (Metinden)';
// AI'a gidecek detaylı prompt.
const fullAiPrompt = `Aşağıda kullanıcı tarafından verilen ana fikri, metni veya hikayeyi al. Bunu geliştir, detaylandır ve yüksek kaliteli, çarpıcı bir video senaryosuna dönüştür:\n\n${dto.text.substring(0, 15000)}`;
const shortDbPrompt = `Kullanıcı metninden üretildi.`;
const project = await this.db.project.create({
data: {
title,
description: `Serbest metin üzerinden üretildi.`,
prompt: shortDbPrompt,
language: dto.language || 'tr',
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
videoStyle: dto.videoStyle || 'CINEMATIC',
cinematicReference: dto.cinematicReference,
targetDuration: dto.targetDuration || 60,
status: 'GENERATING_SCRIPT',
userId,
sourceType: 'MANUAL',
},
});
try {
const scriptJson = await this.videoAiService.generateVideoScript({
topic: fullAiPrompt,
targetDurationSeconds: project.targetDuration,
language: project.language,
videoStyle: project.videoStyle,
cinematicReference: project.cinematicReference ?? undefined,
});
const scenesData = scriptJson.scenes.map((scene: any) => ({
projectId: project.id,
order: scene.order,
title: scene.title || `Sahne ${scene.order}`,
narrationText: scene.narrationText,
visualPrompt: scene.visualPrompt,
subtitleText: scene.subtitleText,
duration: scene.durationSeconds,
transitionType: this.mapTransitionType(scene.transitionType),
}));
await this.db.scene.createMany({ data: scenesData });
const updatedProject = await this.db.project.update({
where: { id: project.id },
data: {
scriptJson: scriptJson as object,
status: 'DRAFT',
errorMessage: null,
scriptVersion: 1,
},
include: {
scenes: { orderBy: { order: 'asc' } },
},
});
return updatedProject;
} catch (error) {
await this.db.project.update({
where: { id: project.id },
data: {
status: 'DRAFT',
errorMessage: error instanceof Error ? error.message : 'Serbest metin senaryo üretimi sırasında hata',
},
});
throw error;
}
}
/**
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
*/
@@ -1141,7 +1244,8 @@ Sadece bu tek sahneyi üret. JSON formatında:
try {
const rewritePrompt = `Rewrite the following image generation prompt. Remove the names of any real-world public figures (e.g., Elon Musk, politicians, celebrities, etc.) and replace their names with extremely detailed physical descriptions of their facial features, body type, age, hair style, and clothing, so the generated image will still look EXACTLY like them. Keep the rest of the prompt completely intact. Return ONLY the rewritten prompt without any conversational text or quotes.\n\nPrompt: ${scene.visualPrompt}`;
const rewrittenPrompt = await this.geminiService.generateText(rewritePrompt);
const textResult = await this.geminiService.generateText(rewritePrompt);
const rewrittenPrompt = textResult.text;
this.logger.log(`🔄 Anonimleştirilmiş Prompt: ${rewrittenPrompt}`);
const illustrationPrompt = `A highly detailed, premium digital illustration of the following scene. Make it an obvious illustration or artwork: ${rewrittenPrompt}. ${styleLabel}`;
@@ -1248,4 +1352,256 @@ Sadece bu tek sahneyi üret. JSON formatında:
throw new BadRequestException('Görsel bulunamadı veya işlenemedi.');
}
}
async deleteSceneMedia(userId: string, projectId: string, mediaId: string) {
const project = await this.findOne(userId, projectId);
const media = await this.db.mediaAsset.findFirst({
where: {
id: mediaId,
projectId: project.id
}
});
if (!media) {
throw new NotFoundException('Medya bulunamadı veya bu projeye ait değil');
}
try {
if (media.url) {
// R2'den silme işlemi için key'i url'den çıkarabiliriz veya doğrudan veritabanından silebiliriz.
// Şimdilik sadece DB'den siliyoruz, R2 temizliği storage service'te eklenebilir.
}
await this.db.mediaAsset.delete({
where: { id: mediaId }
});
this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`);
return { success: true, message: 'Medya başarıyla silindi' };
} catch (error) {
this.logger.error(`Medya silme hatası: ${error.message}`);
throw new BadRequestException('Medya silinirken bir hata oluştu');
}
}
/**
* Çeviri İşlemi
*/
async translateProject(userId: string, projectId: string, targetLanguage: string) {
const project = await this.findOne(userId, projectId);
if (!project) throw new NotFoundException('Proje bulunamadı');
// 1 Kredi Kesintisi
await this.billingService.spendCredits(userId, 1, projectId, `Proje çevirisi (${targetLanguage})`);
const prompt = `
Translate the following video project to the language: ${targetLanguage}.
Keep all structural metadata intact. For 'visualPrompt', it MUST strictly remain in English.
However, if the original visualPrompt contains any texts that are meant to be shown on screen (e.g., text on signs, labels, captions, or any on-screen text overlays), you MUST translate those specific on-screen text elements to ${targetLanguage} within the English visualPrompt. The rest of the visualPrompt should be kept in English.
Input Data:
${JSON.stringify({
title: project.title,
description: project.description,
seoTitle: project.seoTitle,
seoDescription: project.seoDescription,
socialContent: project.socialContent,
scenes: project.scenes.map(s => ({
id: s.id,
narrationText: s.narrationText,
subtitleText: s.subtitleText,
visualPrompt: s.visualPrompt
}))
}, null, 2)}`;
const schemaStr = `{
"title": "string",
"description": "string",
"seoTitle": "string",
"seoDescription": "string",
"socialContent": {
"youtubeTitle": "string",
"tiktokCaption": "string",
"instagramCaption": "string",
"twitterText": "string"
},
"scenes": [
{
"id": "string (MUST match original Scene ID)",
"narrationText": "string",
"subtitleText": "string",
"visualPrompt": "string"
}
]
}`;
let translatedData;
try {
const response = await this.geminiService.generateJSON(prompt, schemaStr, {
temperature: 0.3,
});
translatedData = response.data;
} catch (err) {
// Hata olursa krediyi iade et
await this.billingService.grantCredits(userId, 1, 'refund', `Proje çeviri hatası iadesi`);
this.logger.error(`Çeviri hatası: ${err.message}`);
throw new BadRequestException('Çeviri işlemi sırasında AI servisinde bir hata oluştu.');
}
const newProject = await this.db.project.create({
data: {
title: translatedData.title,
description: translatedData.description || project.description,
prompt: project.prompt,
language: targetLanguage,
aspectRatio: project.aspectRatio,
videoStyle: project.videoStyle,
cinematicReference: project.cinematicReference,
targetDuration: project.targetDuration,
seoKeywords: project.seoKeywords,
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(0, 190),
seoDescription: (translatedData.seoDescription || project.seoDescription || '').substring(0, 490),
socialContent: translatedData.socialContent || project.socialContent,
referenceUrl: project.referenceUrl,
sourceType: project.sourceType,
sourceTweetData: project.sourceTweetData as any,
status: 'DRAFT',
userId,
parentId: project.id,
}
});
for (const originalScene of project.scenes) {
const transScene = translatedData.scenes?.find((s: any) => s.id === originalScene.id);
await this.db.scene.create({
data: {
order: originalScene.order,
title: originalScene.title,
narrationText: transScene?.narrationText || originalScene.narrationText,
subtitleText: transScene?.subtitleText || originalScene.subtitleText,
visualPrompt: transScene?.visualPrompt || originalScene.visualPrompt,
duration: originalScene.duration,
transitionType: originalScene.transitionType,
projectId: newProject.id,
}
});
}
this.logger.log(`Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`);
return newProject;
}
// ════════════════════════════════════════════════════════════
// SEO Power Engine — Başlık Üretimi ve Seçimi
// ════════════════════════════════════════════════════════════
/**
* Mevcut proje için 5 yeni SEO-optimized başlık üretir.
* Gemini AI kullanarak farklı hook stratejileriyle başlıklar üretir.
*/
async generateSeoTitles(userId: string, projectId: string) {
const project = await this.db.project.findFirst({
where: { id: projectId, userId, deletedAt: null },
});
if (!project) {
throw new NotFoundException(`Proje bulunamadı: ${projectId}`);
}
this.logger.log(`SEO başlık üretimi başlatılıyor: ${projectId}`);
// Gemini ile 5 yeni başlık üret
const result = await this.videoAiService.generateAlternativeTitles(
project.prompt,
project.title,
project.language,
project.seoKeywords || [],
);
// Veritabanını güncelle
const updated = await this.db.project.update({
where: { id: projectId },
data: {
seoTitleAlts: result.titles.map((t) => t.substring(0, 190)),
seoScore: result.seoScore,
},
});
// SEO skor geçmişi kaydet
await this.db.seoScoreHistory.create({
data: {
projectId,
score: result.seoScore,
event: 'seo_titles_regenerated',
metadata: {
titles: result.titles,
previousTitle: project.title,
},
},
});
this.logger.log(`${result.titles.length} SEO başlığı üretildi: ${projectId}`);
return {
titles: updated.seoTitleAlts,
seoScore: updated.seoScore,
currentTitle: updated.title,
};
}
/**
* Alternatif SEO başlıklarından birini seçerek projenin ana başlığını günceller.
* Ayrıca seoTitle ve socialContent.youtubeTitle alanlarını da senkronize eder.
*/
async selectSeoTitle(userId: string, projectId: string, selectedTitle: string) {
const project = await this.db.project.findFirst({
where: { id: projectId, userId, deletedAt: null },
});
if (!project) {
throw new NotFoundException(`Proje bulunamadı: ${projectId}`);
}
if (!selectedTitle || selectedTitle.trim().length === 0) {
throw new BadRequestException('Geçerli bir başlık seçilmelidir.');
}
const trimmedTitle = selectedTitle.substring(0, 190);
// socialContent varsa youtubeTitle'ı da güncelle
let updatedSocialContent = project.socialContent as Record<string, any> || {};
if (updatedSocialContent && typeof updatedSocialContent === 'object') {
updatedSocialContent = { ...updatedSocialContent, youtubeTitle: trimmedTitle.substring(0, 60) };
}
const updated = await this.db.project.update({
where: { id: projectId },
data: {
title: trimmedTitle,
seoTitle: trimmedTitle,
socialContent: updatedSocialContent as object,
},
include: {
scenes: { orderBy: { order: 'asc' } },
},
});
// SEO skor geçmişi kaydet
await this.db.seoScoreHistory.create({
data: {
projectId,
score: project.seoScore || 0,
event: 'title_changed',
metadata: {
previousTitle: project.title,
newTitle: trimmedTitle,
},
},
});
this.logger.log(`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`);
return updated;
}
}
+13 -1
View File
@@ -50,7 +50,7 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
isActive?: boolean;
}
import { Exclude, Expose } from 'class-transformer';
import { Exclude, Expose, Transform } from 'class-transformer';
@Exclude()
export class UserResponseDto {
@@ -69,6 +69,18 @@ export class UserResponseDto {
@Expose()
isActive: boolean;
@Expose()
@Transform(({ obj }) => {
if (obj.roles && Array.isArray(obj.roles)) {
return obj.roles.map((r: any) => {
if (typeof r === 'string') return r;
return r?.role?.name || r?.name;
}).filter(Boolean);
}
return [];
})
roles: string[];
@Expose()
createdAt: Date;
+205 -63
View File
@@ -1,6 +1,7 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI } from '@google/genai';
import { jsonrepair } from 'jsonrepair';
export interface ScriptGenerationInput {
topic: string;
@@ -105,6 +106,8 @@ export interface SeoMetadata {
description: string;
keywords: string[];
hashtags: string[];
trendingHashtags?: string[]; // Trend hashtag'ler (AI tahmini)
estimatedSearchVolume?: string; // Anahtar kelimenin tahmini arama hacmi (AI tahmini)
schemaMarkup: Record<string, unknown>;
}
@@ -117,6 +120,8 @@ export interface GeneratedScript {
hashtags: string[];
};
seo: SeoMetadata;
seoTitleAlternatives: string[]; // 5 alternatif SEO başlığı (CTR sıralı)
seoScore: number; // 0-100 arası SEO güç skoru
scenes: GeneratedScene[];
musicPrompt: string;
musicStyle: string; // AudioCraft: genre/mood tanımı
@@ -170,13 +175,45 @@ HUMAN WRITING (anti-AI detection):
- Have opinions. React to facts, don't just report them
- Acknowledge uncertainty: "I'm not sure how to feel about this" is more human than listing pros/cons neutrally
SEO OPTIMIZATION:
- Video title: Primary keyword within first 3 words, under 60 characters
- Description: 2-3 secondary keywords naturally woven in, 150-200 chars
- Keywords: 8-12 LSI keywords related to the main topic
- Hashtags: 5-8 hashtags, mix of broad (#Shorts) and niche-specific
SEO OPTIMIZATION (CRITICAL — REVENUE DRIVER):
SEO TITLE STRATEGY — Generate exactly 5 alternative SEO-optimized titles:
- Each title MUST use a DIFFERENT hook strategy:
1. Curiosity Gap: "The [Topic] Secret Nobody Tells You 🤯"
2. Data-Driven: "[Number] [Topic] Facts That Will Blow Your Mind 🔥"
3. How-To/Listicle: "How [Topic] Actually Works (Explained) ⚡"
4. Emotional: "Why [Topic] Changes Everything You Thought You Knew 😱"
5. Contrarian: "[Common Belief About Topic] Is Dead Wrong 💀"
- Primary keyword within the FIRST 3 WORDS of every title
- Maximum 60 characters per title (YouTube optimal cut-off)
- EMOJI STRATEGY: Each title MUST contain 1-2 strategic emojis (🔥⚡🤯😱💀🚀💡🎯✅❌) placed at the end or mid-title to boost CTR by 15-20%
- Rank them by estimated CTR (Click-Through Rate) — BEST title FIRST
- Titles must be in the TARGET LANGUAGE
- The BEST title (index 0) will automatically become the project title
SEO SCORE — Calculate an seoScore (0-100) based on:
- Keyword placement in title first 3 words: 30 points
- Curiosity/click-bait factor: 25 points
- Specificity (numbers, names, data): 20 points
- Emotional trigger words: 15 points
- Title length optimization (40-60 chars): 10 points
SEO KEYWORDS & SEARCH VOLUME:
- Generate 12-15 LSI (Latent Semantic Indexing) keywords
- For each keyword, estimate relative search volume as: "HIGH", "MEDIUM", or "LOW"
- Provide this as the seo.estimatedSearchVolume field: a brief summary like "Primary keyword 'elon musk' has HIGH search volume (~500K monthly). Secondary keywords average MEDIUM volume."
- Include both short-tail (1-2 words) and long-tail (3-5 words) keywords
HASHTAG STRATEGY:
- Generate 5-8 standard hashtags (mix of broad like #Shorts and niche-specific)
- Additionally generate 3-5 trendingHashtags: hashtags you estimate to be currently trending on YouTube/TikTok/Instagram based on the topic's virality, seasonality, and cultural relevance
- Mark trending hashtags separately in seo.trendingHashtags array
SEO DESCRIPTION:
- Meta description: 150-200 chars, includes 2-3 secondary keywords naturally woven in
- Schema markup hint for VideoObject structured data
MEASUREMENTS:
- ALWAYS use the METRIC SYSTEM for any measurements (e.g. centimeters, meters, kilograms, liters) in the generated content.
- NEVER use imperial units like feet or inches. Translate them to metric if present in the source.
@@ -237,28 +274,29 @@ Mood reference options (use 2-3 per scene combined):
CRITICAL RULE: Once you establish a visual universe in Scene 1 (based on the provided Style DNA), ALL subsequent scenes MUST stay within that EXACT same visual world. Do not randomly switch styles between scenes.
━━━ LAYER 3: LIGHTING (Source, Direction, Quality) ━━━
Lighting is the single most important factor in image quality. Never leave it to chance.
Lighting is the single most important factor in image quality. Never leave it to chance. You MUST explicitly write "Lighting: [details]" in your prompt.
You must specify THREE lighting properties:
You must specify THREE lighting properties explicitly:
A) SOURCE — Where is the light coming from? (sun, neon signs, candle, spotlight, overcast sky, monitor glow)
B) DIRECTION — From which side relative to camera? (from camera-left, backlighting from behind subject, overhead, from below, rim light from behind-right)
C) QUALITY — How does the light feel? (soft and diffused through curtains, harsh and directional, dappled through tree leaves, warm golden, cold blue-white clinical)
❌ BAD: "good lighting" or just "golden hour"
✅ GOOD: "Late golden hour sunlight raking across the scene from camera-right at a low 15-degree angle, casting long dramatic shadows to the left, warm amber (3200K) backlighting creating a bright rim-light halo around the subject's silhouette, fill light bouncing softly off a nearby white wall on camera-left"
✅ GOOD: "Harsh overhead fluorescent tubes casting unflattering blue-white (6500K) light with hard-edged shadows directly below every object, a single warm desk lamp in the foreground creating a small pool of amber light that contrasts with the cold clinical environment"
✅ GOOD: "Diffused overcast daylight filtering through floor-to-ceiling frosted glass panels, creating even, shadowless illumination with a soft pearl-gray quality, punctuated by a single beam of direct sunlight breaking through a gap in the clouds and hitting the subject's hands"
✅ GOOD: "Lighting: Late golden hour sunlight raking across the scene from camera-right at a low 15-degree angle, casting long dramatic shadows to the left, warm amber (3200K) backlighting creating a bright rim-light halo around the subject's silhouette, fill light bouncing softly off a nearby white wall on camera-left"
✅ GOOD: "Lighting: Harsh overhead fluorescent tubes casting unflattering blue-white (6500K) light with hard-edged shadows directly below every object, a single warm desk lamp in the foreground creating a small pool of amber light that contrasts with the cold clinical environment"
✅ GOOD: "Lighting: Diffused overcast daylight filtering through floor-to-ceiling frosted glass panels, creating even, shadowless illumination with a soft pearl-gray quality, punctuated by a single beam of direct sunlight breaking through a gap in the clouds and hitting the subject's hands"
━━━ LAYER 4: COMPOSITION & CAMERA ━━━
Tell exactly where the camera is, what lens is being used, and how the frame is organized.
━━━ LAYER 4: COMPOSITION, CAMERA & LENS (Movement, Angle, Lens Type, Continuity) ━━━
Tell exactly where the camera is, what explicit lens is being used, how the camera moves, and ensure it connects logically with the previous/next scenes. You MUST explicitly write "Camera & Lens: [details]" in your prompt.
A) CAMERA POSITION: "eye-level", "low angle looking up (worm's eye)", "high angle looking down (bird's eye)", "overhead flat lay", "Dutch angle 15-degree tilt", "POV through character's eyes"
B) CAMERA DISTANCE: "extreme close-up on eyes", "medium close-up chest and up", "medium shot waist up", "full body shot", "wide establishing shot", "extreme wide showing entire landscape"
C) CAMERA MOVEMENT: "static locked-off tripod", "slow push-in towards subject", "smooth dolly tracking left-to-right", "orbiting 360° around subject", "crane rising up", "handheld with subtle shake"
D) FRAMING: "rule of thirds with subject on left intersect", "perfectly centered symmetrical", "framed through doorway", "leading lines converging to vanishing point", "negative space in upper third for text overlay"
A) LENS DETAILS (MANDATORY): Specify the exact focal length and lens type. Examples: "24mm wide-angle lens", "50mm standard prime", "85mm portrait lens", "200mm telephoto compression", "vintage anamorphic lens", "macro lens", "fisheye lens".
B) CAMERA POSITION/ANGLE: "eye-level", "low angle looking up (worm's eye)", "high angle looking down (bird's eye)", "overhead flat lay", "Dutch angle 15-degree tilt", "POV through character's eyes"
C) CAMERA DISTANCE: "extreme close-up on eyes", "medium close-up chest and up", "medium shot waist up", "full body shot", "wide establishing shot", "extreme wide showing entire landscape"
D) CAMERA MOVEMENT (MANDATORY): Specify EXACTLY how the camera moves during the scene. Examples: "slide in from left", "slow push-in (zoom in) towards subject's face", "arch in/orbiting 360° around subject", "smooth dolly tracking right", "crane rising up", "static locked-off tripod", "handheld with subtle shake"
E) CONTINUITY: Camera movements must make sense across scenes. If Scene 1 ends with a zoom-in, Scene 2 could start with an extreme close-up. If Scene 1 is an establishing wide shot panning right, Scene 2 could be a medium tracking shot moving right.
❌ BAD: "wide shot of the city"
✅ GOOD: "Extreme wide establishing shot from a drone at 300 meters altitude, camera slowly descending at 45-degree angle, the ancient temple complex positioned on the lower-right third of frame, leading lines of the river drawing the eye from lower-left foreground to the temple, vast jungle canopy filling the upper two-thirds creating a sense of overwhelming scale, a thin mist layer at the treeline adding depth separation between foreground and background"
✅ GOOD: "Camera & Lens: Shot on a 24mm wide-angle lens. Extreme wide establishing shot from a drone at 300 meters altitude, smooth crane descending at 45-degree angle while slowly panning right. The ancient temple complex positioned on the lower-right third of frame, leading lines of the river drawing the eye to the temple."
ASPECT RATIO COMPOSITION GUIDE:
• 9:16 (PORTRAIT — Shorts/Reels): Vertical framing, subject fills center-frame, use foreground-to-background depth, create visual interest through vertical stacking of elements, leave negative space in top or bottom third for text/subtitles
@@ -407,7 +445,7 @@ NARRATION TEXT (IN TARGET LANGUAGE)
• Scene 1: powerful hook creating instant curiosity
• Build escalating intrigue through middle scenes
• End with a thought-provoking statement
• Word count: targetDuration × 2.5 words/second
• Word count: targetDuration × 2 words/second (e.g., 120 words for 60s video)
• Conversational, not academic — like explaining to a smart friend
• Use rhetorical questions, surprising facts, emotional language
@@ -420,14 +458,17 @@ SUBTITLE TEXT (IN TARGET LANGUAGE)
• Simplify complex narration into punchy visual text
═══════════════════════════════════
SCENE STRUCTURE
SCENE STRUCTURE & CONTINUITY
═══════════════════════════════════
Min 4 scenes, max 10 scenes
Scene 1 (HOOK): 2-4 seconds — instant attention
Middle scenes: 5-12 seconds each — build the story
Final scene (CLOSER): 3-6 seconds — memorable conclusion
Total duration: within ±5 seconds of targetDuration
AI video generators produce best results in 4-6 second bursts.
You MUST generate enough scenes to cover the targetDuration (targetDuration / 5 = approximate scene count).
For a 60-second video, you must generate 10 to 15 scenes.
Scene 1 (HOOK): 3-5 seconds — instant attention
Middle scenes: 4-6 seconds each — continuous visual flow
• Final scene (CLOSER): 3-5 seconds — memorable conclusion
• Total duration: within ±2 seconds of targetDuration
• CONTINUITY: Ensure consecutive visual prompts maintain strict subject, lighting, and camera movement continuity so they blend seamlessly.
TRANSITION TYPES:
• CUT — Quick, impactful. Most scene changes
@@ -486,12 +527,17 @@ Describe ideal TTS voice with precision for ElevenLabs:
SOCIAL MEDIA CONTENT
═══════════════════════════════════
Generate platform-specific text:
- youtubeTitle: Primary keyword first, under 60 chars, curiosity-driven
- youtubeDescription: 500+ chars, include CTA, 2-3 secondary keywords, link placeholder
- tiktokCaption: Under 150 chars, trending format, 3-5 hashtags
- instagramCaption: Under 300 chars, emotional hook, 5 hashtags
- twitterText: Under 280 chars, hot take format, 2 hashtags
Generate platform-specific text (CTA-POWERED):
- youtubeTitle: BEST title from the 5 seoTitleAlternatives, under 60 chars, with emoji
- youtubeDescription: 500+ chars, structured EXACTLY as:
→ First 2 lines: Hook text (this is what shows before "...more")
→ 3-4 bullet points of value propositions with emojis
→ CTA line in target language (e.g., "Beğen, abone ol ve bildirimleri aç 🔔")
→ 3-5 related hashtags inline
→ "Chapters:" section with timestamps for each scene if applicable
- tiktokCaption: Under 150 chars, trending hook format + 3-5 hashtags, CTA: "Kaydet 📌" (in target lang)
- instagramCaption: Under 300 chars, emotional hook + value + CTA: "Yorum yap 💬" (in target lang) + 10-15 hashtags at end
- twitterText: Under 280 chars, hot take format + 2 hashtags, CTA: "RT et" (in target lang)
═══════════════════════════════════
OUTPUT FORMAT — STRICT JSON ONLY
@@ -508,10 +554,12 @@ Return ONLY valid JSON. No markdown. No backticks. No explanation.
"hashtags": ["string"] — 5-8 hashtags WITHOUT #
},
"seo": {
"title": "string — SEO-optimized title, primary keyword first, under 60 chars",
"title": "string — BEST SEO-optimized title with emoji, primary keyword first, under 60 chars",
"description": "string — meta description, 150-200 chars, includes secondary keywords",
"keywords": ["string"] — 8-12 LSI keywords,
"keywords": ["string"] — 12-15 LSI keywords (short-tail + long-tail mix),
"hashtags": ["string"] — same as metadata.hashtags,
"trendingHashtags": ["string"] — 3-5 hashtags estimated to be currently trending for this topic,
"estimatedSearchVolume": "string — brief summary of keyword search volume estimates, e.g. 'Primary keyword X has HIGH volume (~500K/mo)'",
"schemaMarkup": {
"@type": "VideoObject",
"name": "string",
@@ -519,6 +567,8 @@ Return ONLY valid JSON. No markdown. No backticks. No explanation.
"duration": "string — ISO 8601 format PT##S"
}
},
"seoTitleAlternatives": ["string"] — exactly 5 alternative SEO titles with emojis, ranked by estimated CTR (best first),
"seoScore": number — 0-100 SEO strength score based on: keyword placement (30), curiosity (25), specificity (20), emotion (15), length (10),
"scenes": [
{
"order": 1,
@@ -542,11 +592,11 @@ Return ONLY valid JSON. No markdown. No backticks. No explanation.
"ambientSoundPrompts": ["string"] — 2-3 project-level ambient sound descriptions for AudioGen,
"voiceStyle": "string — TTS characteristics for ElevenLabs",
"socialContent": {
"youtubeTitle": "string — under 60 chars",
"youtubeDescription": "string — 500+ chars with CTA",
"tiktokCaption": "string — under 150 chars",
"instagramCaption": "string — under 300 chars",
"twitterText": "string — under 280 chars"
"youtubeTitle": "string — BEST title from seoTitleAlternatives, under 60 chars, with emoji",
"youtubeDescription": "string — 500+ chars with CTA, bullet points, chapters",
"tiktokCaption": "string — under 150 chars with CTA",
"instagramCaption": "string — under 300 chars with CTA and 10-15 hashtags",
"twitterText": "string — under 280 chars with CTA"
}
}`;
@@ -620,6 +670,99 @@ export class VideoAiService {
}
}
/**
* Mevcut bir proje için 5 yeni SEO-optimized başlık üretir.
* Her başlık farklı bir hook stratejisi kullanır ve emoji içerir.
*/
async generateAlternativeTitles(
topic: string,
currentTitle: string,
language: string,
keywords: string[],
): Promise<{ titles: string[]; seoScore: number }> {
this.logger.log(`SEO başlık üretimi başladı — Konu: "${topic}", Dil: ${language}`);
const titlePrompt = `Generate exactly 5 alternative SEO-optimized video titles for the following topic.
TOPIC: "${topic}"
CURRENT TITLE: "${currentTitle}"
LANGUAGE: ${language} (generate titles in THIS language)
EXISTING KEYWORDS: ${keywords.join(', ')}
RULES:
1. Each title MUST use a DIFFERENT hook strategy:
- Curiosity Gap: Creates an information gap the viewer must fill
- Data-Driven: Uses specific numbers, statistics, or data points
- How-To/Listicle: Offers practical value or a numbered list
- Emotional: Triggers strong emotional response (surprise, fear, excitement)
- Contrarian: Challenges conventional wisdom or common beliefs
2. Each title MUST:
- Be under 60 characters
- Start with the primary keyword in the first 3 words
- Include 1-2 strategic emojis (🔥⚡🤯😱💀🚀💡🎯✅❌)
- Be significantly DIFFERENT from the current title
- Be in ${language} language
3. Rank titles by estimated CTR (best title FIRST)
4. Calculate seoScore (0-100) for the best title:
- Keyword in first 3 words: 30pts
- Curiosity factor: 25pts
- Specificity (numbers/names): 20pts
- Emotional trigger: 15pts
- Length (40-60 chars): 10pts
Return ONLY valid JSON:
{
"titles": ["best title", "second best", "third", "fourth", "fifth"],
"seoScore": number
}`;
try {
const response = await this.genAI.models.generateContent({
model: this.modelName,
contents: titlePrompt,
config: {
temperature: 0.9,
topP: 0.95,
maxOutputTokens: 2048,
responseMimeType: 'application/json',
},
});
const rawText = response.text ?? '';
if (!rawText.trim()) {
throw new InternalServerErrorException('Gemini API boş yanıt döndü.');
}
let parsed: { titles: string[]; seoScore: number };
try {
parsed = JSON.parse(rawText);
} catch {
const repaired = jsonrepair(rawText);
parsed = JSON.parse(repaired);
}
// Validasyon
if (!Array.isArray(parsed.titles) || parsed.titles.length === 0) {
throw new Error('titles dizisi boş veya eksik');
}
// En fazla 5 başlık, her biri 60 karakter
parsed.titles = parsed.titles.slice(0, 5).map((t: string) => t.substring(0, 60));
parsed.seoScore = Math.min(100, Math.max(0, parsed.seoScore || 75));
this.logger.log(`${parsed.titles.length} SEO başlığı üretildi — Skor: ${parsed.seoScore}`);
return parsed;
} catch (error) {
this.logger.error(`SEO başlık üretim hatası: ${error instanceof Error ? error.message : 'Bilinmeyen'}`);
throw new InternalServerErrorException(
`SEO başlık üretimi başarısız: ${error instanceof Error ? error.message : 'API hatası'}`,
);
}
}
/**
* Uzun metinlerden (kitap, uzun makale vb.) potansiyel video konuları çıkarır.
* Gemini 1.5 Flash kullanarak 3-4 çarpıcı YouTube video başlığı önerir.
@@ -686,6 +829,9 @@ REQUIREMENTS:
`- Visual prompts: ALWAYS in English (for AI image/video generation)\n` +
`- Video style: ${input.videoStyle} — STRICTLY follow the Visual DNA Map for this style\n` +
`- Aspect ratio: ${input.aspectRatio || 'PORTRAIT_9_16'}${aspectRatioGuide}\n` +
`- SCENE MATH: Each scene must represent 4-6 seconds of video. Total scenes = Target duration / 5.\n` +
`- NARRATION PACING: Strict limit of 2 words per second. For a ${input.targetDurationSeconds}s video, write exactly ~${input.targetDurationSeconds * 2} words total across all scenes.\n` +
`- CONTINUITY: Consecutive scenes MUST logically flow into each other. Use matching camera angles, consistent lighting, and coherent subject transitions so the video clips stitch together perfectly.\n` +
`- Make it viral-worthy, visually stunning, and intellectually captivating\n` +
`- The first 2 seconds must hook the viewer immediately\n` +
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
@@ -696,11 +842,12 @@ REQUIREMENTS:
// 5-Layer Architecture hatırlatması
prompt += `\n═══ VISUAL PROMPT REQUIREMENTS (CRITICAL) ═══\n`;
prompt += `Each visualPrompt MUST contain ALL 5 layers:\n`;
prompt += `Each visualPrompt MUST contain ALL 5 layers AND explicit "Camera:" and "Lighting:" labels:\n`;
prompt += `1. SUBJECT: Extreme specificity — materials, textures, spatial relationships, lived-in details\n`;
prompt += `2. MOOD/REFERENCE: Film/art/photography references that define the visual universe\n`;
prompt += `3. LIGHTING: Source + Direction + Quality (e.g. "golden hour from camera-right at 15°, warm amber 3200K")\n`;
prompt += `4. COMPOSITION: Camera position + distance + movement + framing rules\n`;
prompt += `3. LIGHTING: Explicitly label as "Lighting: [Source + Direction + Quality]" (e.g. "Lighting: golden hour from camera-right at 15°, warm amber 3200K")\n`;
prompt += `4. COMPOSITION & CAMERA: Explicitly label as "Camera & Lens: [Lens type + Angle + Distance + MOVEMENT]" (e.g. "Camera & Lens: 50mm prime lens, low angle, medium shot, sliding in from left to right, orbiting the subject")\n`;
prompt += ` -> CRITICAL: Ensure camera movements are logically consistent and flow smoothly between consecutive scenes.\n`;
prompt += `5. FINISHING: DOF, film stock, color grade, texture, post-processing\n`;
prompt += `Each visualPrompt MUST end with "Avoid: [list of things to avoid]"\n`;
prompt += `Scene 1 establishes the visual world — all subsequent scenes maintain continuity.\n`;
@@ -1282,37 +1429,32 @@ REQUIREMENTS:
try {
let cleanText = rawText.trim();
// Pass 1: Markdown code fence temizliği (başta, sonda, ortada)
// Pass 1: Markdown code fence temizliği
cleanText = cleanText.replace(/^```(?:json)?\s*/i, '');
cleanText = cleanText.replace(/\s*```\s*$/i, '');
// Ortada kalan fence'leri de temizle
cleanText = cleanText.replace(/```(?:json)?\s*/gi, '');
// Pass 2: BOM ve kontrol karakterleri temizliği
cleanText = cleanText.replace(/^\uFEFF/, '');
// JSON-dışı kontrol karakterleri kaldır (tab, newline, CR hariç)
cleanText = cleanText.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
// Pass 2: JSON bloğunu izole et
const startIndex = cleanText.indexOf('{');
const endIndex = cleanText.lastIndexOf('}');
if (startIndex >= 0 && endIndex > startIndex) {
cleanText = cleanText.substring(startIndex, endIndex + 1);
}
// Pass 3: Trailing comma — JSON'da yasak ama Gemini bazen koyuyor
cleanText = cleanText.replace(/,\s*([}\]])/g, '$1');
cleanText = cleanText.trim();
// İlk deneme: Direkt parse
// Pass 3: jsonrepair ile onar ve parse et
try {
parsed = JSON.parse(cleanText);
} catch {
// Pass 4: Kesilmiş JSON kurtarma — son geçerli } veya ] bul
const repaired = jsonrepair(cleanText);
parsed = JSON.parse(repaired);
} catch (repairError) {
// Eğer jsonrepair doğrudan başaramazsa, en son geçerli kapamaya kadar kesip tekrar deneyelim
const lastBrace = cleanText.lastIndexOf('}');
const lastBracket = cleanText.lastIndexOf(']');
const cutPoint = Math.max(lastBrace, lastBracket);
if (cutPoint > 0) {
const truncated = cleanText.substring(0, cutPoint + 1);
this.logger.warn(`JSON kesilmiş olabilir — son ${cleanText.length - cutPoint - 1} karakter atıldı, kurtarma deneniyor...`);
parsed = JSON.parse(truncated);
if (lastBrace > 0) {
const truncated = cleanText.substring(0, lastBrace + 1);
this.logger.warn(`jsonrepair başarısız, JSON kesilmiş olabilir. Kurtarma deneniyor...`);
const repairedTruncated = jsonrepair(truncated);
parsed = JSON.parse(repairedTruncated);
} else {
throw new Error('Kurtarılabilir JSON bulunamadı');
throw repairError;
}
}
} catch (parseError) {