generated from fahricansecer/boilerplate-be
feat: SEO Power Engine backend updates and remove temp media files
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
This commit is contained in:
+2
-1
@@ -36,4 +36,5 @@ junit.xml
|
|||||||
dist
|
dist
|
||||||
|
|
||||||
cli-tool
|
cli-tool
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
data/media/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 351 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 425 KiB |
@@ -49,6 +49,7 @@
|
|||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.9.0",
|
"ioredis": "^5.9.0",
|
||||||
|
"jsonrepair": "^3.14.0",
|
||||||
"nestjs-i18n": "^10.6.0",
|
"nestjs-i18n": "^10.6.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
|
|||||||
Generated
+9
@@ -89,6 +89,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.9.0
|
specifier: ^5.9.0
|
||||||
version: 5.10.1
|
version: 5.10.1
|
||||||
|
jsonrepair:
|
||||||
|
specifier: ^3.14.0
|
||||||
|
version: 3.14.0
|
||||||
nestjs-i18n:
|
nestjs-i18n:
|
||||||
specifier: ^10.6.0
|
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)
|
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:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
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:
|
jsonwebtoken@9.0.3:
|
||||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
@@ -8333,6 +8340,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
jsonrepair@3.14.0: {}
|
||||||
|
|
||||||
jsonwebtoken@9.0.3:
|
jsonwebtoken@9.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
jws: 4.0.1
|
jws: 4.0.1
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ model Project {
|
|||||||
seoKeywords String[] // Hedeflenen SEO anahtar kelimeler
|
seoKeywords String[] // Hedeflenen SEO anahtar kelimeler
|
||||||
seoTitle String? @db.VarChar(200)
|
seoTitle String? @db.VarChar(200)
|
||||||
seoDescription String? @db.VarChar(500)
|
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
|
seoSchemaJson Json? // VideoObject structured data
|
||||||
socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText }
|
socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText }
|
||||||
referenceUrl String? @db.VarChar(500)
|
referenceUrl String? @db.VarChar(500)
|
||||||
@@ -297,6 +299,12 @@ model Project {
|
|||||||
mediaAssets MediaAsset[]
|
mediaAssets MediaAsset[]
|
||||||
renderJobs RenderJob[]
|
renderJobs RenderJob[]
|
||||||
templateEntry Template? @relation("SourceProject")
|
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
|
// Timestamps & Soft Delete
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -311,6 +319,25 @@ model Project {
|
|||||||
@@index([createdAt])
|
@@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 {
|
model Scene {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
order Int // Sahne sırası (1, 2, 3...)
|
order Int // Sahne sırası (1, 2, 3...)
|
||||||
|
|||||||
@@ -142,6 +142,49 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
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);
|
return this.generateTokens(user as unknown as UserWithRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// En-boy oranına göre yönlendirmeyi zorla
|
||||||
const orientation = aspectRatio === '9:16' ? '(VERTICAL / PORTRAIT)' : aspectRatio === '16:9' ? '(HORIZONTAL / LANDSCAPE)' : '(SQUARE)';
|
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
|
const enhancedPrompt = isIllustration
|
||||||
? `Premium digital illustration, highly detailed, NOT photorealistic. ${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.`
|
||||||
: `High-quality photorealistic cinematic image, professional lighting, detailed. ${prompt}. Aspect ratio: ${aspectRatio} ${orientation}`;
|
: `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 ──
|
// ── Katman 1: gemini-2.5-flash-image (Nano Banana) — 2 deneme ──
|
||||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
|||||||
@@ -373,3 +373,59 @@ export class CreateFromExtractedTextDto {
|
|||||||
@Max(180)
|
@Max(180)
|
||||||
targetDuration?: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ProjectsService } from './projects.service';
|
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')
|
@ApiTags('projects')
|
||||||
@ApiBearerAuth()
|
@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.
|
* Tek bir projeyi sahneleri ve medya asset'leriyle birlikte getirir.
|
||||||
*/
|
*/
|
||||||
@@ -157,17 +176,6 @@ export class ProjectsController {
|
|||||||
return this.projectsService.cancelRenderJob(userId, id);
|
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.
|
* 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.
|
* 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)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
|
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
|
||||||
@ApiResponse({ status: 201, description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi' })
|
@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) {
|
async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
||||||
return this.projectsService.createFromYoutube(userId, dto);
|
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.
|
* 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);
|
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).
|
* 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.
|
* Kullanıcı custom prompt sağlarsa, önce prompt güncellenir ardından resim üretilir.
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { XTwitterModule } from '../x-twitter/x-twitter.module';
|
|||||||
import { GeminiModule } from '../gemini/gemini.module';
|
import { GeminiModule } from '../gemini/gemini.module';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { ExtractorModule } from '../extractor/extractor.module';
|
import { ExtractorModule } from '../extractor/extractor.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule],
|
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule],
|
||||||
controllers: [ProjectsController],
|
controllers: [ProjectsController],
|
||||||
providers: [ProjectsService],
|
providers: [ProjectsService],
|
||||||
exports: [ProjectsService],
|
exports: [ProjectsService],
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { XTwitterService } from '../x-twitter/x-twitter.service';
|
|||||||
import { GeminiService } from '../gemini/gemini.service';
|
import { GeminiService } from '../gemini/gemini.service';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { ExtractorService } from '../extractor/extractor.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 sharp from 'sharp';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -37,6 +38,7 @@ export class ProjectsService {
|
|||||||
private readonly geminiService: GeminiService,
|
private readonly geminiService: GeminiService,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly extractorService: ExtractorService,
|
private readonly extractorService: ExtractorService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, dto: CreateProjectDto) {
|
async create(userId: string, dto: CreateProjectDto) {
|
||||||
@@ -95,6 +97,7 @@ export class ProjectsService {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
completedAt: true,
|
completedAt: true,
|
||||||
|
parentId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.db.project.count({ where }),
|
this.db.project.count({ where }),
|
||||||
@@ -293,9 +296,14 @@ export class ProjectsService {
|
|||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
scriptVersion: { increment: 1 },
|
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)
|
// SEO & Social metadata (skill-enhanced)
|
||||||
seoTitle: scriptJson.seo?.title || scriptJson.metadata.title,
|
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
||||||
seoDescription: scriptJson.seo?.description || scriptJson.metadata.description,
|
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,
|
seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null,
|
||||||
socialContent: scriptJson.socialContent 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(
|
this.logger.log(
|
||||||
`Senaryo üretildi: ${projectId} — ${scriptJson.scenes.length} sahne`,
|
`Senaryo üretildi: ${projectId} — ${scriptJson.scenes.length} sahne`,
|
||||||
);
|
);
|
||||||
@@ -642,8 +666,8 @@ export class ProjectsService {
|
|||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
scriptVersion: 1,
|
scriptVersion: 1,
|
||||||
seoTitle: scriptJson.seo?.title || scriptJson.metadata.title,
|
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
||||||
seoDescription: scriptJson.seo?.description || scriptJson.metadata.description,
|
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
|
||||||
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
||||||
socialContent: (scriptJson.socialContent as object) || null,
|
socialContent: (scriptJson.socialContent as object) || null,
|
||||||
},
|
},
|
||||||
@@ -727,7 +751,7 @@ export class ProjectsService {
|
|||||||
const scenesData = scriptJson.scenes.map((scene: any) => ({
|
const scenesData = scriptJson.scenes.map((scene: any) => ({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
order: scene.order,
|
order: scene.order,
|
||||||
title: scene.title || `Sahne ${scene.order}`,
|
title: (scene.title || `Sahne ${scene.order}`).substring(0, 190),
|
||||||
narrationText: scene.narrationText,
|
narrationText: scene.narrationText,
|
||||||
visualPrompt: scene.visualPrompt,
|
visualPrompt: scene.visualPrompt,
|
||||||
subtitleText: scene.subtitleText,
|
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.
|
* 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 {
|
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 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}`);
|
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}`;
|
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.');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Exclude, Expose } from 'class-transformer';
|
import { Exclude, Expose, Transform } from 'class-transformer';
|
||||||
|
|
||||||
@Exclude()
|
@Exclude()
|
||||||
export class UserResponseDto {
|
export class UserResponseDto {
|
||||||
@@ -69,6 +69,18 @@ export class UserResponseDto {
|
|||||||
@Expose()
|
@Expose()
|
||||||
isActive: boolean;
|
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()
|
@Expose()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import { jsonrepair } from 'jsonrepair';
|
||||||
|
|
||||||
export interface ScriptGenerationInput {
|
export interface ScriptGenerationInput {
|
||||||
topic: string;
|
topic: string;
|
||||||
@@ -105,6 +106,8 @@ export interface SeoMetadata {
|
|||||||
description: string;
|
description: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
hashtags: string[];
|
hashtags: string[];
|
||||||
|
trendingHashtags?: string[]; // Trend hashtag'ler (AI tahmini)
|
||||||
|
estimatedSearchVolume?: string; // Anahtar kelimenin tahmini arama hacmi (AI tahmini)
|
||||||
schemaMarkup: Record<string, unknown>;
|
schemaMarkup: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +120,8 @@ export interface GeneratedScript {
|
|||||||
hashtags: string[];
|
hashtags: string[];
|
||||||
};
|
};
|
||||||
seo: SeoMetadata;
|
seo: SeoMetadata;
|
||||||
|
seoTitleAlternatives: string[]; // 5 alternatif SEO başlığı (CTR sıralı)
|
||||||
|
seoScore: number; // 0-100 arası SEO güç skoru
|
||||||
scenes: GeneratedScene[];
|
scenes: GeneratedScene[];
|
||||||
musicPrompt: string;
|
musicPrompt: string;
|
||||||
musicStyle: string; // AudioCraft: genre/mood tanımı
|
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
|
- 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
|
- Acknowledge uncertainty: "I'm not sure how to feel about this" is more human than listing pros/cons neutrally
|
||||||
|
|
||||||
SEO OPTIMIZATION:
|
SEO OPTIMIZATION (CRITICAL — REVENUE DRIVER):
|
||||||
- Video title: Primary keyword within first 3 words, under 60 characters
|
|
||||||
- Description: 2-3 secondary keywords naturally woven in, 150-200 chars
|
SEO TITLE STRATEGY — Generate exactly 5 alternative SEO-optimized titles:
|
||||||
- Keywords: 8-12 LSI keywords related to the main topic
|
- Each title MUST use a DIFFERENT hook strategy:
|
||||||
- Hashtags: 5-8 hashtags, mix of broad (#Shorts) and niche-specific
|
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
|
- Schema markup hint for VideoObject structured data
|
||||||
|
|
||||||
|
|
||||||
MEASUREMENTS:
|
MEASUREMENTS:
|
||||||
- ALWAYS use the METRIC SYSTEM for any measurements (e.g. centimeters, meters, kilograms, liters) in the generated content.
|
- 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.
|
- 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.
|
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) ━━━
|
━━━ 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)
|
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)
|
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)
|
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"
|
❌ 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: "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: "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: 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: 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 ━━━
|
━━━ LAYER 4: COMPOSITION, CAMERA & LENS (Movement, Angle, Lens Type, Continuity) ━━━
|
||||||
Tell exactly where the camera is, what lens is being used, and how the frame is organized.
|
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"
|
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 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"
|
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 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"
|
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) 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"
|
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"
|
❌ 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:
|
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
|
• 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
|
• Scene 1: powerful hook creating instant curiosity
|
||||||
• Build escalating intrigue through middle scenes
|
• Build escalating intrigue through middle scenes
|
||||||
• End with a thought-provoking statement
|
• 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
|
• Conversational, not academic — like explaining to a smart friend
|
||||||
• Use rhetorical questions, surprising facts, emotional language
|
• Use rhetorical questions, surprising facts, emotional language
|
||||||
|
|
||||||
@@ -420,14 +458,17 @@ SUBTITLE TEXT (IN TARGET LANGUAGE)
|
|||||||
• Simplify complex narration into punchy visual text
|
• Simplify complex narration into punchy visual text
|
||||||
|
|
||||||
═══════════════════════════════════
|
═══════════════════════════════════
|
||||||
SCENE STRUCTURE
|
SCENE STRUCTURE & CONTINUITY
|
||||||
═══════════════════════════════════
|
═══════════════════════════════════
|
||||||
|
|
||||||
• Min 4 scenes, max 10 scenes
|
• AI video generators produce best results in 4-6 second bursts.
|
||||||
• Scene 1 (HOOK): 2-4 seconds — instant attention
|
• You MUST generate enough scenes to cover the targetDuration (targetDuration / 5 = approximate scene count).
|
||||||
• Middle scenes: 5-12 seconds each — build the story
|
• For a 60-second video, you must generate 10 to 15 scenes.
|
||||||
• Final scene (CLOSER): 3-6 seconds — memorable conclusion
|
• Scene 1 (HOOK): 3-5 seconds — instant attention
|
||||||
• Total duration: within ±5 seconds of targetDuration
|
• 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:
|
TRANSITION TYPES:
|
||||||
• CUT — Quick, impactful. Most scene changes
|
• CUT — Quick, impactful. Most scene changes
|
||||||
@@ -486,12 +527,17 @@ Describe ideal TTS voice with precision for ElevenLabs:
|
|||||||
SOCIAL MEDIA CONTENT
|
SOCIAL MEDIA CONTENT
|
||||||
═══════════════════════════════════
|
═══════════════════════════════════
|
||||||
|
|
||||||
Generate platform-specific text:
|
Generate platform-specific text (CTA-POWERED):
|
||||||
- youtubeTitle: Primary keyword first, under 60 chars, curiosity-driven
|
- youtubeTitle: BEST title from the 5 seoTitleAlternatives, under 60 chars, with emoji
|
||||||
- youtubeDescription: 500+ chars, include CTA, 2-3 secondary keywords, link placeholder
|
- youtubeDescription: 500+ chars, structured EXACTLY as:
|
||||||
- tiktokCaption: Under 150 chars, trending format, 3-5 hashtags
|
→ First 2 lines: Hook text (this is what shows before "...more")
|
||||||
- instagramCaption: Under 300 chars, emotional hook, 5 hashtags
|
→ 3-4 bullet points of value propositions with emojis
|
||||||
- twitterText: Under 280 chars, hot take format, 2 hashtags
|
→ 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
|
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 #
|
"hashtags": ["string"] — 5-8 hashtags WITHOUT #
|
||||||
},
|
},
|
||||||
"seo": {
|
"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",
|
"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,
|
"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": {
|
"schemaMarkup": {
|
||||||
"@type": "VideoObject",
|
"@type": "VideoObject",
|
||||||
"name": "string",
|
"name": "string",
|
||||||
@@ -519,6 +567,8 @@ Return ONLY valid JSON. No markdown. No backticks. No explanation.
|
|||||||
"duration": "string — ISO 8601 format PT##S"
|
"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": [
|
"scenes": [
|
||||||
{
|
{
|
||||||
"order": 1,
|
"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,
|
"ambientSoundPrompts": ["string"] — 2-3 project-level ambient sound descriptions for AudioGen,
|
||||||
"voiceStyle": "string — TTS characteristics for ElevenLabs",
|
"voiceStyle": "string — TTS characteristics for ElevenLabs",
|
||||||
"socialContent": {
|
"socialContent": {
|
||||||
"youtubeTitle": "string — under 60 chars",
|
"youtubeTitle": "string — BEST title from seoTitleAlternatives, under 60 chars, with emoji",
|
||||||
"youtubeDescription": "string — 500+ chars with CTA",
|
"youtubeDescription": "string — 500+ chars with CTA, bullet points, chapters",
|
||||||
"tiktokCaption": "string — under 150 chars",
|
"tiktokCaption": "string — under 150 chars with CTA",
|
||||||
"instagramCaption": "string — under 300 chars",
|
"instagramCaption": "string — under 300 chars with CTA and 10-15 hashtags",
|
||||||
"twitterText": "string — under 280 chars"
|
"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.
|
* 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.
|
* 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` +
|
`- 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` +
|
`- Video style: ${input.videoStyle} — STRICTLY follow the Visual DNA Map for this style\n` +
|
||||||
`- Aspect ratio: ${input.aspectRatio || 'PORTRAIT_9_16'} — ${aspectRatioGuide}\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` +
|
`- Make it viral-worthy, visually stunning, and intellectually captivating\n` +
|
||||||
`- The first 2 seconds must hook the viewer immediately\n` +
|
`- The first 2 seconds must hook the viewer immediately\n` +
|
||||||
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
|
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
|
||||||
@@ -696,11 +842,12 @@ REQUIREMENTS:
|
|||||||
|
|
||||||
// 5-Layer Architecture hatırlatması
|
// 5-Layer Architecture hatırlatması
|
||||||
prompt += `\n═══ VISUAL PROMPT REQUIREMENTS (CRITICAL) ═══\n`;
|
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 += `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 += `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 += `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 position + distance + movement + framing rules\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 += `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 += `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`;
|
prompt += `Scene 1 establishes the visual world — all subsequent scenes maintain continuity.\n`;
|
||||||
@@ -1282,37 +1429,32 @@ REQUIREMENTS:
|
|||||||
try {
|
try {
|
||||||
let cleanText = rawText.trim();
|
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(/^```(?:json)?\s*/i, '');
|
||||||
cleanText = cleanText.replace(/\s*```\s*$/i, '');
|
cleanText = cleanText.replace(/\s*```\s*$/i, '');
|
||||||
// Ortada kalan fence'leri de temizle
|
|
||||||
cleanText = cleanText.replace(/```(?:json)?\s*/gi, '');
|
cleanText = cleanText.replace(/```(?:json)?\s*/gi, '');
|
||||||
|
|
||||||
// Pass 2: BOM ve kontrol karakterleri temizliği
|
// Pass 2: JSON bloğunu izole et
|
||||||
cleanText = cleanText.replace(/^\uFEFF/, '');
|
const startIndex = cleanText.indexOf('{');
|
||||||
// JSON-dışı kontrol karakterleri kaldır (tab, newline, CR hariç)
|
const endIndex = cleanText.lastIndexOf('}');
|
||||||
cleanText = cleanText.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
if (startIndex >= 0 && endIndex > startIndex) {
|
||||||
|
cleanText = cleanText.substring(startIndex, endIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Pass 3: Trailing comma — JSON'da yasak ama Gemini bazen koyuyor
|
// Pass 3: jsonrepair ile onar ve parse et
|
||||||
cleanText = cleanText.replace(/,\s*([}\]])/g, '$1');
|
|
||||||
|
|
||||||
cleanText = cleanText.trim();
|
|
||||||
|
|
||||||
// İlk deneme: Direkt parse
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(cleanText);
|
const repaired = jsonrepair(cleanText);
|
||||||
} catch {
|
parsed = JSON.parse(repaired);
|
||||||
// Pass 4: Kesilmiş JSON kurtarma — son geçerli } veya ] bul
|
} catch (repairError) {
|
||||||
|
// Eğer jsonrepair doğrudan başaramazsa, en son geçerli kapamaya kadar kesip tekrar deneyelim
|
||||||
const lastBrace = cleanText.lastIndexOf('}');
|
const lastBrace = cleanText.lastIndexOf('}');
|
||||||
const lastBracket = cleanText.lastIndexOf(']');
|
if (lastBrace > 0) {
|
||||||
const cutPoint = Math.max(lastBrace, lastBracket);
|
const truncated = cleanText.substring(0, lastBrace + 1);
|
||||||
|
this.logger.warn(`jsonrepair başarısız, JSON kesilmiş olabilir. Kurtarma deneniyor...`);
|
||||||
if (cutPoint > 0) {
|
const repairedTruncated = jsonrepair(truncated);
|
||||||
const truncated = cleanText.substring(0, cutPoint + 1);
|
parsed = JSON.parse(repairedTruncated);
|
||||||
this.logger.warn(`JSON kesilmiş olabilir — son ${cleanText.length - cutPoint - 1} karakter atıldı, kurtarma deneniyor...`);
|
|
||||||
parsed = JSON.parse(truncated);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Kurtarılabilir JSON bulunamadı');
|
throw repairError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user