diff --git a/login.json b/login.json new file mode 100644 index 0000000..4812801 --- /dev/null +++ b/login.json @@ -0,0 +1 @@ +{"success":false,"status":401,"message":"Invalid email or password","data":null,"errors":[],"stack":"UnauthorizedException: INVALID_CREDENTIALS\n at AuthService.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.service.js:128:19)\n at async AuthController.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.controller.js:33:24)"} \ No newline at end of file diff --git a/query.ts b/query.ts new file mode 100644 index 0000000..2e969f2 --- /dev/null +++ b/query.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +async function main() { + const users = await prisma.user.findMany(); + console.log(users); +} +main().finally(() => prisma.$disconnect()); diff --git a/src/app.module.ts b/src/app.module.ts index b517f68..2adea61 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -96,7 +96,7 @@ import { LoggerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => { + useFactory: (configService: ConfigService) => { return { pinoHttp: { level: configService.get('app.isDevelopment') ? 'debug' : 'info', diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index ebf3fe1..fdfa24b 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { }); // Only update if translation exists (key is different from result) if (translatedMessage !== `errors.${message}`) { - message = translatedMessage as string; + message = translatedMessage; } } } catch { diff --git a/src/main.ts b/src/main.ts index 264b3fc..345153b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,13 +28,14 @@ async function bootstrap() { app.useLogger(app.get(Logger)); app.useGlobalInterceptors(new LoggerErrorInterceptor()); - // Security Headers - app.use(helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - crossOriginResourcePolicy: { policy: 'cross-origin' }, - })); + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, + }), + ); // Graceful Shutdown (Prisma & Docker) app.enableShutdownHooks(); @@ -45,7 +46,10 @@ async function bootstrap() { const nodeEnv = configService.get('NODE_ENV', 'development'); // ── Static File Serving — Medya dosyalarına HTTP erişim ── - const mediaPath = configService.get('STORAGE_LOCAL_PATH', './data/media'); + const mediaPath = configService.get( + 'STORAGE_LOCAL_PATH', + './data/media', + ); const absoluteMediaPath = path.resolve(mediaPath); // Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor) @@ -55,13 +59,16 @@ async function bootstrap() { next(); }); - app.use('/media', express.static(absoluteMediaPath, { - maxAge: '1d', - etag: true, - lastModified: true, - index: false, - dotfiles: 'deny', - })); + app.use( + '/media', + express.static(absoluteMediaPath, { + maxAge: '1d', + etag: true, + lastModified: true, + index: false, + dotfiles: 'deny', + }), + ); logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`); // Enable CORS diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 5a3f7b3..bd1301b 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -84,7 +84,11 @@ export class AdminController { @Param('userId') userId: string, @Body() data: { amount: number; description: string }, ): Promise> { - const tx = await this.adminService.grantCredits(userId, data.amount, data.description); + const tx = await this.adminService.grantCredits( + userId, + data.amount, + data.description, + ); return createSuccessResponse(tx, 'Kredi yüklendi'); } @@ -92,9 +96,7 @@ export class AdminController { @Get('users/:id/detail') @ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' }) - async getUserDetail( - @Param('id') id: string, - ): Promise> { + async getUserDetail(@Param('id') id: string): Promise> { const user = await this.adminService.getUserDetail(id); return createSuccessResponse(user); } @@ -325,7 +327,13 @@ export class AdminController { @Get('projects') @ApiOperation({ summary: 'Tüm projeleri getir (admin)' }) async getAllProjects( - @Query() query: { page?: number; limit?: number; status?: string; userId?: string }, + @Query() + query: { + page?: number; + limit?: number; + status?: string; + userId?: string; + }, ): Promise> { const result = await this.adminService.getAllProjects({ page: query.page ? Number(query.page) : 1, @@ -380,4 +388,3 @@ export class AdminController { ); } } - diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 7334bd1..31cbef9 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -10,4 +10,3 @@ import { StorageModule } from '../storage/storage.module'; exports: [AdminService], }) export class AdminModule {} - diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts index 5c14cdc..984606d 100644 --- a/src/modules/admin/admin.service.ts +++ b/src/modules/admin/admin.service.ts @@ -33,7 +33,13 @@ export class AdminService { this.prisma.user.findMany({ take: 5, orderBy: { createdAt: 'desc' }, - select: { id: true, email: true, firstName: true, lastName: true, createdAt: true }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + createdAt: true, + }, }), this.prisma.project.groupBy({ by: ['status'], @@ -65,17 +71,23 @@ export class AdminService { }, projects: { total: totalProjects, - byStatus: projectsByStatus.reduce((acc, item) => { - acc[item.status] = item._count.id; - return acc; - }, {} as Record), + byStatus: projectsByStatus.reduce( + (acc, item) => { + acc[item.status] = item._count.id; + return acc; + }, + {} as Record, + ), }, renderJobs: { total: totalRenderJobs, - byStatus: renderJobsByStatus.reduce((acc, item) => { - acc[item.status] = item._count.id; - return acc; - }, {} as Record), + byStatus: renderJobsByStatus.reduce( + (acc, item) => { + acc[item.status] = item._count.id; + return acc; + }, + {} as Record, + ), }, credits: { totalGranted: creditStats._sum.amount || 0, @@ -102,18 +114,21 @@ export class AdminService { }); } - async updatePlan(planId: string, data: { - displayName?: string; - description?: string; - monthlyPrice?: number; - yearlyPrice?: number; - monthlyCredits?: number; - maxDuration?: number; - maxResolution?: string; - maxProjects?: number; - isActive?: boolean; - features?: any; - }) { + async updatePlan( + planId: string, + data: { + displayName?: string; + description?: string; + monthlyPrice?: number; + yearlyPrice?: number; + monthlyCredits?: number; + maxDuration?: number; + maxResolution?: string; + maxProjects?: number; + isActive?: boolean; + features?: any; + }, + ) { return this.prisma.plan.update({ where: { id: planId }, data, @@ -122,9 +137,14 @@ export class AdminService { // ── Proje ve Render Yönetimi ────────────────────────────────────── - async getAllProjects(params: { page: number; limit: number; status?: string; userId?: string }) { + async getAllProjects(params: { + page: number; + limit: number; + status?: string; + userId?: string; + }) { const { page, limit, status, userId } = params; - + // Status filtresini prisma tarafında idari bir kontrole dönüştürmek gerek const whereCondition: any = { deletedAt: null }; if (status) whereCondition.status = status; @@ -133,7 +153,9 @@ export class AdminService { const [data, total] = await Promise.all([ this.prisma.project.findMany({ where: whereCondition, - include: { user: { select: { email: true, firstName: true, lastName: true } } }, + include: { + user: { select: { email: true, firstName: true, lastName: true } }, + }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, @@ -144,9 +166,13 @@ export class AdminService { return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; } - async getAllRenderJobs(params: { page: number; limit: number; status?: string }) { + async getAllRenderJobs(params: { + page: number; + limit: number; + status?: string; + }) { const { page, limit, status } = params; - + const whereCondition: any = {}; if (status) whereCondition.status = status; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 50456dd..bc66ad5 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -146,10 +146,12 @@ export class AuthService { 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' } }); + const adminRole = await this.prisma.role.findUnique({ + where: { name: 'admin' }, + }); if (adminRole) { await this.prisma.userRole.create({ - data: { userId: user.id, roleId: adminRole.id } + data: { userId: user.id, roleId: adminRole.id }, }); // Refresh user object const refreshedUser = await this.prisma.user.findUnique({ @@ -157,17 +159,25 @@ export class AuthService { include: { roles: { include: { - role: { include: { permissions: { include: { permission: true } } } } - } - } - } + 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' }, - }); + 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: { @@ -179,7 +189,9 @@ export class AuthService { }, }); } - return this.generateTokens(refreshedUser as unknown as UserWithRoles); + return this.generateTokens( + refreshedUser as unknown as UserWithRoles, + ); } } } @@ -297,13 +309,13 @@ export class AuthService { }; const isAdmin = roles.includes('admin'); - const accessExpiration = isAdmin - ? '7d' + const accessExpiration = isAdmin + ? '7d' : this.configService.get('JWT_ACCESS_EXPIRATION', '15m'); // Generate access token const accessToken = this.jwtService.sign(payload, { - expiresIn: accessExpiration as any, + expiresIn: accessExpiration, }); // Generate refresh token diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts index 52063da..49f5d6b 100644 --- a/src/modules/billing/billing.controller.ts +++ b/src/modules/billing/billing.controller.ts @@ -29,7 +29,10 @@ export class BillingController { private readonly configService: ConfigService, ) { const stripeKey = this.configService.get('STRIPE_SECRET_KEY'); - this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET', ''); + this.webhookSecret = this.configService.get( + 'STRIPE_WEBHOOK_SECRET', + '', + ); if (stripeKey) { this.stripe = new Stripe(stripeKey); @@ -54,7 +57,11 @@ export class BillingController { @Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' }, ) { const userId = req.user?.id || req.user?.sub; - return this.billingService.createCheckoutSession(userId, body.planName, body.billingCycle); + return this.billingService.createCheckoutSession( + userId, + body.planName, + body.billingCycle, + ); } @Get('credits/balance') diff --git a/src/modules/billing/billing.service.ts b/src/modules/billing/billing.service.ts index 00552f9..1b77918 100644 --- a/src/modules/billing/billing.service.ts +++ b/src/modules/billing/billing.service.ts @@ -1,14 +1,19 @@ -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../database/prisma.service'; import Stripe from 'stripe'; /** * Billing Service — Stripe entegrasyonu - * + * * stripe-integration + pricing-strategy skill'lerinden elde * edilen bilgilerle tasarlandı. - * + * * Akış: * 1. Kullanıcı plan seçer → Stripe Checkout Session oluşturulur * 2. Ödeme → Stripe Webhook → subscription aktif → kredi yüklenir @@ -32,14 +37,20 @@ export class BillingService { this.logger.log('💳 Stripe bağlantısı kuruldu'); } else { this.stripe = null; - this.logger.warn('⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı'); + this.logger.warn( + '⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı', + ); } } /** * Checkout Session oluştur — Value-Based Pricing (pricing-strategy skill) */ - async createCheckoutSession(userId: string, planName: string, billingCycle: 'monthly' | 'yearly') { + async createCheckoutSession( + userId: string, + planName: string, + billingCycle: 'monthly' | 'yearly', + ) { if (!this.stripe) { throw new BadRequestException('Ödeme sistemi şu anda aktif değil'); } @@ -61,12 +72,13 @@ export class BillingService { throw new NotFoundException('Kullanıcı bulunamadı'); } - const priceId = billingCycle === 'yearly' - ? plan.stripeYearlyPriceId - : plan.stripePriceId; + const priceId = + billingCycle === 'yearly' ? plan.stripeYearlyPriceId : plan.stripePriceId; if (!priceId) { - throw new BadRequestException(`Bu plan için ${billingCycle} fiyat tanımlı değil`); + throw new BadRequestException( + `Bu plan için ${billingCycle} fiyat tanımlı değil`, + ); } const session = await this.stripe.checkout.sessions.create({ @@ -88,7 +100,9 @@ export class BillingService { cancel_url: `${this.configService.get('APP_URL')}/dashboard/pricing?checkout=cancelled`, }); - this.logger.log(`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`); + this.logger.log( + `Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`, + ); return { sessionId: session.id, @@ -103,19 +117,19 @@ export class BillingService { async handleWebhookEvent(event: Stripe.Event): Promise { switch (event.type) { case 'checkout.session.completed': - await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session); + await this.handleCheckoutComplete(event.data.object); break; case 'invoice.payment_succeeded': - await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice); + await this.handlePaymentSucceeded(event.data.object); break; case 'customer.subscription.updated': - await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + await this.handleSubscriptionUpdated(event.data.object); break; case 'customer.subscription.deleted': - await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + await this.handleSubscriptionDeleted(event.data.object); break; default: @@ -157,7 +171,9 @@ export class BillingService { where: { userId }, include: { role: true }, }); - return userRoles.some((ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin'); + return userRoles.some( + (ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin', + ); } /** @@ -193,7 +209,9 @@ export class BillingService { const monthlyTransactions = transactions.filter( (tx) => tx.amount < 0 && new Date(tx.createdAt) >= monthStart, ); - const monthlyUsed = Math.abs(monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0)); + const monthlyUsed = Math.abs( + monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0), + ); // Kullanıcının aktif planından limit al const subscription = await this.db.subscription.findFirst({ @@ -217,12 +235,24 @@ export class BillingService { /** * Kredi harca (video üretimi için) */ - async spendCredits(userId: string, amount: number, projectId: string, description: string) { + async spendCredits( + userId: string, + amount: number, + projectId: string, + description: string, + ) { // Admin bypass — sınırsız kredi const admin = await this.isAdmin(userId); if (admin) { - this.logger.log(`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`); - return { id: 'admin-bypass', amount: -amount, type: 'usage', description }; + this.logger.log( + `🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`, + ); + return { + id: 'admin-bypass', + amount: -amount, + type: 'usage', + description, + }; } const balance = await this.getCreditBalance(userId); @@ -244,14 +274,21 @@ export class BillingService { }, }); - this.logger.log(`Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`); + this.logger.log( + `Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`, + ); return transaction; } /** * Kredi ekle (abonelik yenileme, bonus vb.) */ - async grantCredits(userId: string, amount: number, type: string, description: string) { + async grantCredits( + userId: string, + amount: number, + type: string, + description: string, + ) { const currentBalance = await this.getCreditBalance(userId); const transaction = await this.db.creditTransaction.create({ @@ -264,7 +301,9 @@ export class BillingService { }, }); - this.logger.log(`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`); + this.logger.log( + `Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`, + ); return transaction; } @@ -296,16 +335,22 @@ export class BillingService { }); // İlk ay kredilerini yükle - await this.grantCredits(userId, plan.monthlyCredits, 'grant', `${plan.displayName} abonelik kredisi`); + await this.grantCredits( + userId, + plan.monthlyCredits, + 'grant', + `${plan.displayName} abonelik kredisi`, + ); this.logger.log(`✅ Abonelik aktif: User ${userId}, Plan ${plan.name}`); } private async handlePaymentSucceeded(invoice: Stripe.Invoice) { const inv = invoice as any; - const subscriptionId = typeof inv.subscription === 'string' - ? inv.subscription - : inv.subscription?.id; + const subscriptionId = + typeof inv.subscription === 'string' + ? inv.subscription + : inv.subscription?.id; if (!subscriptionId) return; const subscription = await this.db.subscription.findFirst({ @@ -323,10 +368,14 @@ export class BillingService { `${subscription.plan.displayName} aylık kredi yenileme`, ); - this.logger.log(`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`); + this.logger.log( + `💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`, + ); } - private async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) { + private async handleSubscriptionUpdated( + stripeSubscription: Stripe.Subscription, + ) { const periodStart = (stripeSubscription as any).current_period_start; const periodEnd = (stripeSubscription as any).current_period_end; @@ -335,13 +384,17 @@ export class BillingService { data: { status: stripeSubscription.status, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, - ...(periodStart && { currentPeriodStart: new Date(periodStart * 1000) }), + ...(periodStart && { + currentPeriodStart: new Date(periodStart * 1000), + }), ...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }), }, }); } - private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription) { + private async handleSubscriptionDeleted( + stripeSubscription: Stripe.Subscription, + ) { await this.db.subscription.updateMany({ where: { stripeSubscriptionId: stripeSubscription.id }, data: { diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts index fbfaea6..6279fef 100644 --- a/src/modules/dashboard/dashboard.controller.ts +++ b/src/modules/dashboard/dashboard.controller.ts @@ -1,10 +1,10 @@ +import { Controller, Get, Logger, Req } from '@nestjs/common'; import { - Controller, - Get, - Logger, - Req, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; @ApiTags('dashboard') diff --git a/src/modules/dashboard/dashboard.service.ts b/src/modules/dashboard/dashboard.service.ts index 26479ba..5276b15 100644 --- a/src/modules/dashboard/dashboard.service.ts +++ b/src/modules/dashboard/dashboard.service.ts @@ -45,7 +45,14 @@ export class DashboardService { where: { userId, deletedAt: null, - status: { in: ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'] }, + status: { + in: [ + 'PENDING', + 'GENERATING_MEDIA', + 'RENDERING', + 'GENERATING_SCRIPT', + ], + }, }, }), diff --git a/src/modules/events/events.gateway.ts b/src/modules/events/events.gateway.ts index dbe6f46..5251a46 100644 --- a/src/modules/events/events.gateway.ts +++ b/src/modules/events/events.gateway.ts @@ -20,7 +20,11 @@ import { Server, Socket } from 'socket.io'; */ @WebSocketGateway({ cors: { - origin: ['http://localhost:3001', 'http://localhost:3000', process.env.FRONTEND_URL || '*'], + origin: [ + 'http://localhost:3001', + 'http://localhost:3000', + process.env.FRONTEND_URL || '*', + ], credentials: true, }, namespace: '/ws', @@ -35,12 +39,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { handleConnection(client: Socket) { this.connectedClients++; - this.logger.log(`Client bağlandı: ${client.id} (toplam: ${this.connectedClients})`); + this.logger.log( + `Client bağlandı: ${client.id} (toplam: ${this.connectedClients})`, + ); } handleDisconnect(client: Socket) { this.connectedClients--; - this.logger.log(`Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`); + this.logger.log( + `Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`, + ); } /** @@ -53,7 +61,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() data: { projectId: string }, ) { const room = `project:${data.projectId}`; - client.join(room); + void client.join(room); this.logger.debug(`Client ${client.id} → room: ${room}`); return { event: 'joined', data: { room, projectId: data.projectId } }; } @@ -67,7 +75,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() data: { projectId: string }, ) { const room = `project:${data.projectId}`; - client.leave(room); + void client.leave(room); this.logger.debug(`Client ${client.id} ← room: ${room}`); return { event: 'left', data: { room } }; } @@ -82,7 +90,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() data: { userId: string }, ) { const room = `user:${data.userId}`; - client.join(room); + void client.join(room); this.logger.debug(`Client ${client.id} → user room: ${room}`); return { event: 'joined', data: { room, userId: data.userId } }; } @@ -96,7 +104,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @MessageBody() data: { userId: string }, ) { const room = `user:${data.userId}`; - client.leave(room); + void client.leave(room); this.logger.debug(`Client ${client.id} ← user room: ${room}`); return { event: 'left', data: { room } }; } diff --git a/src/modules/extractor/extractor.service.ts b/src/modules/extractor/extractor.service.ts index b40e7b4..90fbaa5 100644 --- a/src/modules/extractor/extractor.service.ts +++ b/src/modules/extractor/extractor.service.ts @@ -6,27 +6,39 @@ import FormData from 'form-data'; @Injectable() export class ExtractorService { private readonly logger = new Logger(ExtractorService.name); - private readonly extractorUrl = process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000'; + private readonly extractorUrl = + process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000'; constructor() {} async extractFromUrl(url: string): Promise { this.logger.log(`URL'den içerik çekiliyor: ${url}`); try { - const response = await axios.post(`${this.extractorUrl}/extract/url`, { url }, { - timeout: 60000 // 60 seconds timeout - }); + const response = await axios.post( + `${this.extractorUrl}/extract/url`, + { url }, + { + timeout: 60000, // 60 seconds timeout + }, + ); return response.data.content; } catch (error: any) { this.logger.error(`URL extraction failed: ${error.message}`); if (error.response) { throw new HttpException(error.response.data, error.response.status); } - throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + 'Extractor servisi bulunamadı veya zaman aşımına uğradı', + HttpStatus.SERVICE_UNAVAILABLE, + ); } } - async extractFromFile(filePath: string, filename: string, mimeType: string): Promise { + async extractFromFile( + filePath: string, + filename: string, + mimeType: string, + ): Promise { this.logger.log(`Dosyadan içerik çekiliyor: ${filename}`); try { const formData = new FormData(); @@ -35,12 +47,16 @@ export class ExtractorService { contentType: mimeType, }); - const response = await axios.post(`${this.extractorUrl}/extract/file`, formData, { - headers: { - ...formData.getHeaders(), + const response = await axios.post( + `${this.extractorUrl}/extract/file`, + formData, + { + headers: { + ...formData.getHeaders(), + }, + timeout: 120000, // 2 minutes timeout for files }, - timeout: 120000 // 2 minutes timeout for files - }); + ); return response.data.content; } catch (error: any) { @@ -48,7 +64,10 @@ export class ExtractorService { if (error.response) { throw new HttpException(error.response.data, error.response.status); } - throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + 'Extractor servisi bulunamadı veya zaman aşımına uğradı', + HttpStatus.SERVICE_UNAVAILABLE, + ); } } } diff --git a/src/modules/gemini/gemini.config.ts b/src/modules/gemini/gemini.config.ts index 204c119..797f01b 100644 --- a/src/modules/gemini/gemini.config.ts +++ b/src/modules/gemini/gemini.config.ts @@ -4,6 +4,7 @@ export const geminiConfig = registerAs('gemini', () => ({ enabled: process.env.ENABLE_GEMINI === 'true', apiKey: process.env.GOOGLE_API_KEY, defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash', - imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation', + imageModel: + process.env.GEMINI_IMAGE_MODEL || + 'gemini-2.0-flash-preview-image-generation', })); - diff --git a/src/modules/gemini/gemini.service.ts b/src/modules/gemini/gemini.service.ts index 59b49de..056f72a 100644 --- a/src/modules/gemini/gemini.service.ts +++ b/src/modules/gemini/gemini.service.ts @@ -269,10 +269,17 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; const fallbackModel = 'gemini-3.1-flash-image-preview'; try { - this.logger.log(`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`); + this.logger.log( + `🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`, + ); // 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 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 @@ -283,23 +290,36 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; for (let attempt = 1; attempt <= 2; attempt++) { try { this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`); - const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt); + const result = await this.tryGenerateContentImage( + primaryModel, + enhancedPrompt, + ); if (result && result.buffer.length > 0) { - this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`); + this.logger.log( + `✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`, + ); return { buffer: result.buffer, mimeType: result.mimeType }; } - + const reason = result?.errorReason || 'null response'; - this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`); - - if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)) { - this.logger.warn(`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`); + this.logger.warn( + `⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`, + ); + + if ( + ['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason) + ) { + this.logger.warn( + `🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`, + ); break; // Fail fast for safety blocks } - + if (attempt < 2) await this.sleep(2000); } catch (err1: any) { - this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`); + this.logger.warn( + `⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`, + ); if (attempt < 2) await this.sleep(2000); } } @@ -307,18 +327,33 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; // ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ── try { this.logger.log(`🔄 Katman 2: ${fallbackModel}`); - const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt); + const result = await this.tryGenerateContentImage( + fallbackModel, + enhancedPrompt, + ); if (result && result.buffer.length > 0) { - this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`); + this.logger.log( + `✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`, + ); return { buffer: result.buffer, mimeType: result.mimeType }; } - this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`); - - if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(result?.errorReason || '')) { - this.logger.warn(`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`); + this.logger.warn( + `⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`, + ); + + if ( + ['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes( + result?.errorReason || '', + ) + ) { + this.logger.warn( + `🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`, + ); } } catch (err2: any) { - this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`); + this.logger.warn( + `⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`, + ); } // ── Katman 3: Imagen 4 Fast (generateImages API) ── @@ -335,20 +370,31 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; }); if (response.generatedImages?.[0]?.image?.imageBytes) { - const buffer = Buffer.from(response.generatedImages[0].image.imageBytes, 'base64'); + const buffer = Buffer.from( + response.generatedImages[0].image.imageBytes, + 'base64', + ); const mimeType = 'image/jpeg'; - this.logger.log(`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`); + this.logger.log( + `✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`, + ); return { buffer, mimeType }; } - this.logger.warn(`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`); + this.logger.warn( + `⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`, + ); } catch (err3: any) { - this.logger.warn(`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`); + this.logger.warn( + `⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`, + ); } this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu'); return null; } catch (error) { - this.logger.error(`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`); + this.logger.error( + `Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`, + ); throw error; } } @@ -360,7 +406,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; private async tryGenerateContentImage( model: string, prompt: string, - ): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> { + ): Promise<{ + buffer: Buffer; + mimeType: string; + errorReason?: string; + } | null> { const response = await this.client!.models.generateContent({ model, contents: prompt, @@ -374,12 +424,18 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; // Safety filter veya boş yanıt kontrolü if (!candidate?.content?.parts || candidate.content.parts.length === 0) { const finishReason = candidate?.finishReason || 'UNKNOWN'; - this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`); - return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason }; + this.logger.warn( + `⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`, + ); + return { + buffer: Buffer.from([]), + mimeType: '', + errorReason: finishReason, + }; } - const imagePart = candidate.content.parts.find( - (p: any) => p.inlineData?.mimeType?.startsWith('image/'), + const imagePart = candidate.content.parts.find((p: any) => + p.inlineData?.mimeType?.startsWith('image/'), ); if (imagePart?.inlineData?.data) { @@ -391,16 +447,26 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; // Text-only response geldi (görsel yok) const textParts = candidate.content.parts.filter((p: any) => p.text); if (textParts.length > 0) { - this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`); - return { buffer: Buffer.from([]), mimeType: '', errorReason: 'TEXT_ONLY' }; + this.logger.warn( + `⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`, + ); + return { + buffer: Buffer.from([]), + mimeType: '', + errorReason: 'TEXT_ONLY', + }; } - return { buffer: Buffer.from([]), mimeType: '', errorReason: 'NO_IMAGE_DATA' }; + return { + buffer: Buffer.from([]), + mimeType: '', + errorReason: 'NO_IMAGE_DATA', + }; } /** Basit uyku fonksiyonu — retry aralarında kullanılır */ private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -435,4 +501,3 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; return this.generateImage(prompt, '16:9'); } } - diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 0da2504..8158afb 100644 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -9,7 +9,12 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; /** @@ -62,10 +67,7 @@ export class NotificationsController { @Patch(':id/read') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' }) - async markAsRead( - @Req() req: any, - @Param('id') id: string, - ) { + async markAsRead(@Req() req: any, @Param('id') id: string) { const userId = req.user?.id || req.user?.sub; return this.notificationsService.markAsRead(id, userId); } @@ -73,10 +75,7 @@ export class NotificationsController { @Delete(':id') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Bildirimi sil' }) - async deleteNotification( - @Req() req: any, - @Param('id') id: string, - ) { + async deleteNotification(@Req() req: any, @Param('id') id: string) { const userId = req.user?.id || req.user?.sub; return this.notificationsService.deleteNotification(id, userId); } diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 2909d9c..12323cb 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../database/prisma.service'; import { EventsGateway } from '../events/events.gateway'; @@ -77,11 +82,7 @@ export class NotificationsService { /** * Kullanıcının bildirimlerini getir (pagination). */ - async getUserNotifications( - userId: string, - page = 1, - limit = 20, - ) { + async getUserNotifications(userId: string, page = 1, limit = 20) { const skip = (page - 1) * limit; const [notifications, total] = await Promise.all([ @@ -162,7 +163,9 @@ export class NotificationsService { }, }); - this.logger.debug(`${result.count} bildirim okundu işaretlendi — User: ${userId}`); + this.logger.debug( + `${result.count} bildirim okundu işaretlendi — User: ${userId}`, + ); return { updated: result.count }; } diff --git a/src/modules/projects/dto/project.dto.ts b/src/modules/projects/dto/project.dto.ts index a2fa498..84f9542 100644 --- a/src/modules/projects/dto/project.dto.ts +++ b/src/modules/projects/dto/project.dto.ts @@ -64,7 +64,8 @@ export class CreateProjectDto { aspectRatio?: AspectRatioDto; @ApiPropertyOptional({ - description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', + description: + 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', example: 'CINEMATIC', default: 'CINEMATIC', }) @@ -73,7 +74,9 @@ export class CreateProjectDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) @@ -136,14 +139,17 @@ export class UpdateProjectDto { aspectRatio?: AspectRatioDto; @ApiPropertyOptional({ - description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', + description: + 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', }) @IsString() @IsOptional() @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) @@ -163,19 +169,19 @@ export class UpdateProjectDto { */ export class CreateFromTweetDto { @ApiProperty({ - description: 'X/Twitter tweet URL\'si', + description: "X/Twitter tweet URL'si", example: 'https://x.com/elonmusk/status/1893456789012345678', }) @IsString() - @IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' }) + @IsNotEmpty({ message: "Tweet URL'si boş olamaz" }) @Matches( /^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/, - { message: 'Geçerli bir X/Twitter tweet URL\'si girin' }, + { message: "Geçerli bir X/Twitter tweet URL'si girin" }, ) tweetUrl: string; @ApiPropertyOptional({ - description: 'Proje başlığı (boş bırakılırsa tweet\'ten otomatik üretilir)', + description: "Proje başlığı (boş bırakılırsa tweet'ten otomatik üretilir)", }) @IsString() @IsOptional() @@ -190,13 +196,17 @@ export class CreateFromTweetDto { @IsOptional() language?: string; - @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @ApiPropertyOptional({ + enum: AspectRatioDto, + default: AspectRatioDto.PORTRAIT_9_16, + }) @IsEnum(AspectRatioDto) @IsOptional() aspectRatio?: AspectRatioDto; @ApiPropertyOptional({ - description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', + description: + 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)', default: 'CINEMATIC', }) @IsString() @@ -204,13 +214,18 @@ export class CreateFromTweetDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) cinematicReference?: string; - @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + default: 60, + }) @IsInt() @IsOptional() @Min(15) @@ -220,19 +235,19 @@ export class CreateFromTweetDto { export class CreateFromYoutubeDto { @ApiProperty({ - description: 'YouTube Video URL\'si', + description: "YouTube Video URL'si", example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', }) @IsString() - @IsNotEmpty({ message: 'YouTube URL\'si boş olamaz' }) - @Matches( - /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, - { message: 'Geçerli bir YouTube URL\'si girin' }, - ) + @IsNotEmpty({ message: "YouTube URL'si boş olamaz" }) + @Matches(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, { + message: "Geçerli bir YouTube URL'si girin", + }) youtubeUrl: string; @ApiPropertyOptional({ - description: 'Proje başlığı (boş bırakılırsa YouTube\'dan otomatik üretilir)', + description: + "Proje başlığı (boş bırakılırsa YouTube'dan otomatik üretilir)", }) @IsString() @IsOptional() @@ -247,7 +262,10 @@ export class CreateFromYoutubeDto { @IsOptional() language?: string; - @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @ApiPropertyOptional({ + enum: AspectRatioDto, + default: AspectRatioDto.PORTRAIT_9_16, + }) @IsEnum(AspectRatioDto) @IsOptional() aspectRatio?: AspectRatioDto; @@ -261,13 +279,18 @@ export class CreateFromYoutubeDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) cinematicReference?: string; - @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + default: 60, + }) @IsInt() @IsOptional() @Min(15) @@ -292,7 +315,10 @@ export class CreateFromDocumentDto { @IsOptional() language?: string; - @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @ApiPropertyOptional({ + enum: AspectRatioDto, + default: AspectRatioDto.PORTRAIT_9_16, + }) @IsEnum(AspectRatioDto) @IsOptional() aspectRatio?: AspectRatioDto; @@ -306,13 +332,18 @@ export class CreateFromDocumentDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) cinematicReference?: string; - @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + default: 60, + }) @IsInt() @IsOptional() @Min(15) @@ -360,13 +391,18 @@ export class CreateFromExtractedTextDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) cinematicReference?: string; - @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + default: 60, + }) @IsInt() @IsOptional() @Min(15) @@ -387,7 +423,8 @@ export class CreateFromTextDto { text: string; @ApiPropertyOptional({ - description: 'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)', + description: + 'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)', }) @IsString() @IsOptional() @@ -402,7 +439,10 @@ export class CreateFromTextDto { @IsOptional() language?: string; - @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @ApiPropertyOptional({ + enum: AspectRatioDto, + default: AspectRatioDto.PORTRAIT_9_16, + }) @IsEnum(AspectRatioDto) @IsOptional() aspectRatio?: AspectRatioDto; @@ -416,13 +456,18 @@ export class CreateFromTextDto { @MaxLength(50) videoStyle?: string; - @ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' }) + @ApiPropertyOptional({ + description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)', + }) @IsString() @IsOptional() @MaxLength(200) cinematicReference?: string; - @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + default: 60, + }) @IsInt() @IsOptional() @Min(15) diff --git a/src/modules/projects/projects.controller.ts b/src/modules/projects/projects.controller.ts index 672164b..06d3a8b 100644 --- a/src/modules/projects/projects.controller.ts +++ b/src/modules/projects/projects.controller.ts @@ -26,12 +26,12 @@ import { } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { ProjectsService } from './projects.service'; -import { - CreateProjectDto, - UpdateProjectDto, - CreateFromTweetDto, - CreateFromYoutubeDto, - CreateFromDocumentDto, +import { + CreateProjectDto, + UpdateProjectDto, + CreateFromTweetDto, + CreateFromYoutubeDto, + CreateFromDocumentDto, CreateFromExtractedTextDto, CreateFromTextDto, } from './dto/project.dto'; @@ -138,7 +138,10 @@ export class ProjectsController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'AI ile senaryo üret (Gemini)' }) @ApiResponse({ status: 200, description: 'Senaryo üretildi ve kaydedildi' }) - async generateScript(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { + async generateScript( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: any, + ) { const userId = req.user?.id || req.user?.sub; this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`); return this.projectsService.generateScript(userId, id); @@ -167,10 +170,7 @@ export class ProjectsController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Aktif render işlemini iptal et' }) @ApiResponse({ status: 200, description: 'Render işlemi iptal edildi' }) - async cancelRender( - @Param('id', ParseUUIDPipe) id: string, - @Req() req: any, - ) { + async cancelRender(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const userId = req.user?.id || req.user?.sub; this.logger.log(`Render iptal isteği: ${id}`); return this.projectsService.cancelRenderJob(userId, id); @@ -182,9 +182,15 @@ export class ProjectsController { */ @Post('from-tweet') @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'X/Twitter tweet\'ten proje oluştur' }) - @ApiResponse({ status: 201, description: 'Tweet\'ten proje oluşturuldu ve senaryo üretildi' }) - @ApiResponse({ status: 400, description: 'Geçersiz tweet URL\'si veya tweet bulunamadı' }) + @ApiOperation({ summary: "X/Twitter tweet'ten proje oluştur" }) + @ApiResponse({ + status: 201, + description: "Tweet'ten proje oluşturuldu ve senaryo üretildi", + }) + @ApiResponse({ + status: 400, + description: "Geçersiz tweet URL'si veya tweet bulunamadı", + }) async createFromTweet(@Body() dto: CreateFromTweetDto, @Req() req: any) { const userId = req.user?.id || req.user?.sub; this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`); @@ -197,8 +203,14 @@ export class ProjectsController { @Post('from-youtube') @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ı' }) + @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}`); @@ -211,7 +223,10 @@ export class ProjectsController { @Post('from-text') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Serbest metinden proje oluştur' }) - @ApiResponse({ status: 201, description: 'Metinden proje oluşturuldu ve senaryo üretildi' }) + @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...`); @@ -226,7 +241,10 @@ export class ProjectsController { @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Dosyadan/Dokümandan proje oluştur' }) - @ApiResponse({ status: 201, description: 'Belgeden proje oluşturuldu ve senaryo üretildi' }) + @ApiResponse({ + status: 201, + description: 'Belgeden proje oluşturuldu ve senaryo üretildi', + }) async createFromDocument( @UploadedFile() file: Express.Multer.File, @Body() dto: CreateFromDocumentDto, @@ -248,12 +266,14 @@ export class ProjectsController { @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' }) - @ApiResponse({ status: 200, description: 'Metin ve konular başarıyla çıkarıldı' }) - async extractDocumentTopics( - @UploadedFile() file: Express.Multer.File, - @Req() req: any, - ) { - this.logger.log(`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`); + @ApiResponse({ + status: 200, + description: 'Metin ve konular başarıyla çıkarıldı', + }) + async extractDocumentTopics(@UploadedFile() file: Express.Multer.File) { + this.logger.log( + `Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`, + ); if (!file) { throw new BadRequestException('Dosya yüklenmedi'); } @@ -266,10 +286,18 @@ export class ProjectsController { @Post('document-from-topic') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' }) - @ApiResponse({ status: 201, description: 'Seçilen konu baz alınarak proje oluşturuldu' }) - async createFromTopic(@Body() dto: CreateFromExtractedTextDto, @Req() req: any) { + @ApiResponse({ + status: 201, + description: 'Seçilen konu baz alınarak proje oluşturuldu', + }) + async createFromTopic( + @Body() dto: CreateFromExtractedTextDto, + @Req() req: any, + ) { const userId = req.user?.id || req.user?.sub; - this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`); + this.logger.log( + `Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`, + ); return this.projectsService.createFromExtractedText(userId, dto); } @@ -282,7 +310,13 @@ export class ProjectsController { async updateScene( @Param('id', ParseUUIDPipe) id: string, @Param('sceneId', ParseUUIDPipe) sceneId: string, - @Body() body: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number }, + @Body() + body: { + narrationText?: string; + visualPrompt?: string; + subtitleText?: string; + duration?: number; + }, @Req() req: any, ) { const userId = req.user?.id || req.user?.sub; @@ -326,7 +360,10 @@ export class ProjectsController { @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' }) + @ApiResponse({ + status: 200, + description: 'SEO başlıkları başarıyla üretildi', + }) async generateSeoTitles( @Param('id', ParseUUIDPipe) id: string, @Req() req: any, @@ -361,15 +398,22 @@ export class ProjectsController { */ @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ı' }) + @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.'); + throw new BadRequestException( + 'Hedef dil (targetLanguage) belirtilmelidir.', + ); } const userId = req.user?.id || req.user?.sub; this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`); @@ -392,7 +436,12 @@ export class ProjectsController { ) { const userId = req.user?.id || req.user?.sub; this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${id})`); - return this.projectsService.generateSceneImage(userId, id, sceneId, body?.customPrompt); + return this.projectsService.generateSceneImage( + userId, + id, + sceneId, + body?.customPrompt, + ); } /** @@ -408,7 +457,9 @@ export class ProjectsController { @Req() req: any, ) { const userId = req.user?.id || req.user?.sub; - this.logger.log(`Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`); + this.logger.log( + `Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`, + ); return this.projectsService.upscaleSceneImage(userId, id, sceneId); } } diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index c336143..c103b52 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -10,7 +10,15 @@ import { ExtractorModule } from '../extractor/extractor.module'; import { BillingModule } from '../billing/billing.module'; @Module({ - imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule], + imports: [ + VideoAiModule, + VideoQueueModule, + XTwitterModule, + GeminiModule, + StorageModule, + ExtractorModule, + BillingModule, + ], controllers: [ProjectsController], providers: [ProjectsService], exports: [ProjectsService], diff --git a/src/modules/projects/projects.service.ts b/src/modules/projects/projects.service.ts index 5e77e75..5344a35 100644 --- a/src/modules/projects/projects.service.ts +++ b/src/modules/projects/projects.service.ts @@ -7,14 +7,22 @@ import { import { TransitionType, AspectRatio } from '@prisma/client'; import { PrismaService } from '../../database/prisma.service'; import { VideoAiService } from '../video-ai/video-ai.service'; -import { VideoQueueModule } from '../video-queue/video-queue.module'; + import { VideoGenerationProducer } from '../video-queue/video-generation.producer'; 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 { BillingService } from '../billing/billing.service'; -import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto, CreateFromTextDto } from './dto/project.dto'; +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'; @@ -170,58 +178,77 @@ export class ProjectsService { ...(dto.language && { language: dto.language }), ...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }), ...(dto.videoStyle && { videoStyle: dto.videoStyle }), - ...(dto.cinematicReference !== undefined && { cinematicReference: dto.cinematicReference }), + ...(dto.cinematicReference !== undefined && { + cinematicReference: dto.cinematicReference, + }), ...(dto.targetDuration && { targetDuration: dto.targetDuration }), } as any, }); this.logger.log(`Proje güncellendi: ${projectId}`); - + // Stil ya da görsel parametre değiştiyse ve senaryo/sahneler varsa otomatik rewrite başlat const styleChanged = (dto.videoStyle && dto.videoStyle !== project.videoStyle) || - (dto.cinematicReference !== undefined && dto.cinematicReference !== (project as any).cinematicReference) || - (dto.aspectRatio && dto.aspectRatio !== project.aspectRatio); + (dto.cinematicReference !== undefined && + dto.cinematicReference !== (project as any).cinematicReference) || + (dto.aspectRatio && + String(dto.aspectRatio) !== String(project.aspectRatio)); if (styleChanged && project.scenes && project.scenes.length > 0) { - this.logger.log(`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`); - this.rewriteVisualPromptsBackground(userId, projectId, updated).catch((err) => { - this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`); - }); + this.logger.log( + `Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`, + ); + this.rewriteVisualPromptsBackground(userId, projectId, updated).catch( + (err) => { + this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`); + }, + ); } return updated; } // Arka planda tüm promptları güncelleyen metod - private async rewriteVisualPromptsBackground(userId: string, projectId: string, updatedProject: any) { + private async rewriteVisualPromptsBackground( + userId: string, + projectId: string, + updatedProject: any, + ) { const targetProject = await this.findOne(userId, projectId); - if (!targetProject || !targetProject.scenes || targetProject.scenes.length === 0) return; + if ( + !targetProject || + !targetProject.scenes || + targetProject.scenes.length === 0 + ) + return; try { // Sahnelerin visual prompt'larını yenile (ID'leri ve narration'ları gönderiyoruz) - const mappedScenes = targetProject.scenes.map(s => ({ + const mappedScenes = targetProject.scenes.map((s) => ({ id: s.id, order: s.order, narrationText: s.narrationText, - visualPrompt: s.visualPrompt + visualPrompt: s.visualPrompt, })); const rewritten = await this.videoAiService.rewriteAllVisualPrompts( mappedScenes, updatedProject.videoStyle, - (updatedProject as any).cinematicReference, - updatedProject.aspectRatio + updatedProject.cinematicReference, + updatedProject.aspectRatio, ); // Veritabanına kaydet for (const newScene of rewritten) { await this.db.scene.update({ where: { id: newScene.id }, - data: { visualPrompt: newScene.visualPrompt } + data: { visualPrompt: newScene.visualPrompt }, }); } - this.logger.log(`Görsel promptlar ${projectId} için başarıyla yenilendi.`); + this.logger.log( + `Görsel promptlar ${projectId} için başarıyla yenilendi.`, + ); } catch (err) { this.logger.error(`Visual promptları yenileme işlemi başarısız: ${err}`); } @@ -297,15 +324,32 @@ export class ProjectsService { 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), + title: ( + scriptJson.seo?.title || + scriptJson.metadata?.title || + project.title + ).substring(0, 190), // SEO & Social metadata (skill-enhanced) - 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, + 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, + seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null, + socialContent: (scriptJson.socialContent as object) || null, }, include: { scenes: { orderBy: { order: 'asc' } }, @@ -387,8 +431,11 @@ export class ProjectsService { videoStyle: project.videoStyle, targetDuration: project.targetDuration, scenes: project.scenes.map((s) => { - const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL'); - const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined; + const thumbnail = s.mediaAssets?.find((m) => m.type === 'THUMBNAIL'); + const imagePath = + thumbnail && thumbnail.s3Key + ? this.storageService.getAbsolutePath(thumbnail.s3Key) + : undefined; return { id: s.id, order: s.order, @@ -472,8 +519,10 @@ export class ProjectsService { const avgProcessingTime = completedWithTime.length > 0 ? Math.round( - completedWithTime.reduce((sum, j) => sum + (j.processingTimeMs ?? 0), 0) / - completedWithTime.length, + completedWithTime.reduce( + (sum, j) => sum + (j.processingTimeMs ?? 0), + 0, + ) / completedWithTime.length, ) : null; @@ -499,19 +548,21 @@ export class ProjectsService { project: j.project, logs: j.logs, })), - recentJobs: [...completed, ...failed, ...cancelled].slice(0, 20).map((j) => ({ - id: j.id, - status: j.status, - currentStage: j.currentStage, - attemptNumber: j.attemptNumber, - processingTimeMs: j.processingTimeMs, - errorMessage: j.errorMessage, - finalVideoUrl: j.finalVideoUrl, - createdAt: j.createdAt, - startedAt: j.startedAt, - completedAt: j.completedAt, - project: j.project, - })), + recentJobs: [...completed, ...failed, ...cancelled] + .slice(0, 20) + .map((j) => ({ + id: j.id, + status: j.status, + currentStage: j.currentStage, + attemptNumber: j.attemptNumber, + processingTimeMs: j.processingTimeMs, + errorMessage: j.errorMessage, + finalVideoUrl: j.finalVideoUrl, + createdAt: j.createdAt, + startedAt: j.startedAt, + completedAt: j.completedAt, + project: j.project, + })), }; } @@ -533,7 +584,9 @@ export class ProjectsService { } if (project.renderJobs.length === 0) { - throw new BadRequestException('İptal edilecek aktif bir render işlemi bulunamadı'); + throw new BadRequestException( + 'İptal edilecek aktif bir render işlemi bulunamadı', + ); } // Aktif olan ilk render job'u al @@ -542,7 +595,10 @@ export class ProjectsService { // Status'ü güncelle await this.db.renderJob.update({ where: { id: activeJob.id }, - data: { status: 'CANCELLED', errorMessage: 'Kullanıcı tarafından iptal edildi' }, + data: { + status: 'CANCELLED', + errorMessage: 'Kullanıcı tarafından iptal edildi', + }, }); // Projeyi tekrar DRAFT durumuna döndür (senaryosu hâlâ mevcut) @@ -551,7 +607,9 @@ export class ProjectsService { data: { status: 'DRAFT' }, }); - this.logger.log(`Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`); + this.logger.log( + `Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`, + ); return { message: 'Render başarıyla iptal edildi', @@ -666,8 +724,16 @@ export class ProjectsService { status: 'DRAFT', errorMessage: null, scriptVersion: 1, - seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190), - seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490), + 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, }, @@ -708,11 +774,15 @@ export class ProjectsService { * YouTube URL'sinden proje oluşturur. Extractor servisi kullanılarak video transkripti çekilir. */ async createFromYoutube(userId: string, dto: CreateFromYoutubeDto) { - this.logger.log(`YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`); + this.logger.log( + `YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`, + ); // 1. YouTube url'den MarkItDown yardımı ile metni çek - const extractedText = await this.extractorService.extractFromUrl(dto.youtubeUrl); - + const extractedText = await this.extractorService.extractFromUrl( + dto.youtubeUrl, + ); + // 2. Proje başlığı veya varsayılan prompt'u oluştur const title = dto.title || 'YouTube Shorts Üretimi'; const prompt = `Aşağıda dökümü (transcript) verilmiş YouTube videosundan en can alıcı 60 saniyelik bir Shorts videosu üret:\n\n${extractedText.substring(0, 15000)}`; @@ -734,7 +804,9 @@ export class ProjectsService { }, }); - this.logger.log(`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`); + this.logger.log( + `YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`, + ); try { const scriptJson = await this.videoAiService.generateVideoScript({ @@ -774,14 +846,19 @@ export class ProjectsService { }, }); - this.logger.log(`YouTube senaryo tamamlandı: ${project.id} — ${scriptJson.scenes.length} sahne`); + this.logger.log( + `YouTube senaryo tamamlandı: ${project.id} — ${scriptJson.scenes.length} sahne`, + ); return updatedProject; } catch (error) { await this.db.project.update({ where: { id: project.id }, data: { status: 'DRAFT', - errorMessage: error instanceof Error ? error.message : 'YouTube senaryo üretimi sırasında hata', + errorMessage: + error instanceof Error + ? error.message + : 'YouTube senaryo üretimi sırasında hata', }, }); throw error; @@ -792,7 +869,9 @@ export class ProjectsService { * PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir. */ async extractDocumentTopics(file: Express.Multer.File) { - this.logger.log(`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`); + this.logger.log( + `Belgeden konu önerileri çıkarılıyor: ${file.originalname}`, + ); let tempFilePath: string | null = null; let extractedText = ''; @@ -801,42 +880,60 @@ export class ProjectsService { if (file.path) { tempFilePath = file.path; } else if (file.buffer) { - tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`); + tempFilePath = path.join( + os.tmpdir(), + `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`, + ); await fs.writeFile(tempFilePath, file.buffer); } else { - throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok)."); + throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).'); } - extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype); + extractedText = await this.extractorService.extractFromFile( + tempFilePath, + file.originalname, + file.mimetype, + ); } finally { if (tempFilePath && !file.path) { - await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`)); + await fs + .unlink(tempFilePath) + .catch((e) => + this.logger.warn(`Temp dosya silinemedi: ${e.message}`), + ); } } if (!extractedText || extractedText.trim().length === 0) { - throw new BadRequestException("Belgeden okunabilir metin çıkarılamadı."); + throw new BadRequestException('Belgeden okunabilir metin çıkarılamadı.'); } // Kısa metinse doğrudan 1 konu öner (kendi başlığı gibi), uzunsa çoklu konu let topics: string[] = []; if (extractedText.length < 5000) { - topics = [file.originalname.split('.')[0] || "Belge Özeti"]; + topics = [file.originalname.split('.')[0] || 'Belge Özeti']; } else { - topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4); + topics = await this.videoAiService.suggestDocumentTopics( + extractedText, + 4, + ); } return { text: extractedText, topics, - originalFilename: file.originalname + originalFilename: file.originalname, }; } /** * PDF, Word vb. dokümandan proje oluşturur. */ - async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) { + async createFromDocument( + userId: string, + file: Express.Multer.File, + dto: CreateFromDocumentDto, + ) { this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`); let tempFilePath: string | null = null; @@ -846,16 +943,27 @@ export class ProjectsService { if (file.path) { tempFilePath = file.path; } else if (file.buffer) { - tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`); + tempFilePath = path.join( + os.tmpdir(), + `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`, + ); await fs.writeFile(tempFilePath, file.buffer); } else { - throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok)."); + throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).'); } - extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype); + extractedText = await this.extractorService.extractFromFile( + tempFilePath, + file.originalname, + file.mimetype, + ); } finally { if (tempFilePath && !file.path) { - await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`)); + await fs + .unlink(tempFilePath) + .catch((e) => + this.logger.warn(`Temp dosya silinemedi: ${e.message}`), + ); } } @@ -919,7 +1027,10 @@ export class ProjectsService { where: { id: project.id }, data: { status: 'DRAFT', - errorMessage: error instanceof Error ? error.message : 'Belge senaryo üretimi sırasında hata', + errorMessage: + error instanceof Error + ? error.message + : 'Belge senaryo üretimi sırasında hata', }, }); throw error; @@ -929,8 +1040,13 @@ export class ProjectsService { /** * Çıkarılmış metin ve kullanıcının seçtiği bir "topic" üzerinden proje oluşturur. */ - async createFromExtractedText(userId: string, dto: CreateFromExtractedTextDto) { - this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`); + async createFromExtractedText( + userId: string, + dto: CreateFromExtractedTextDto, + ) { + this.logger.log( + `Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`, + ); const title = dto.topic; // Tam prompt metni (AI'a gönderilecek) @@ -941,10 +1057,13 @@ export class ProjectsService { const project = await this.db.project.create({ data: { title, - description: dto.originalFilename ? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})` : `Metinden üretildi (Konu: ${dto.topic})`, + description: dto.originalFilename + ? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})` + : `Metinden üretildi (Konu: ${dto.topic})`, prompt: shortDbPrompt, language: dto.language || 'tr', - aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16, + aspectRatio: + (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16, videoStyle: dto.videoStyle || 'CINEMATIC', cinematicReference: dto.cinematicReference, targetDuration: dto.targetDuration || 60, @@ -995,15 +1114,16 @@ export class ProjectsService { where: { id: project.id }, data: { status: 'DRAFT', - errorMessage: error instanceof Error ? error.message : 'Konu bazlı senaryo üretimi sırasında hata', + errorMessage: + error instanceof Error + ? error.message + : 'Konu bazlı senaryo üretimi sırasında hata', }, }); throw error; } } - - /** * Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur. */ @@ -1011,10 +1131,10 @@ export class ProjectsService { 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({ @@ -1023,7 +1143,8 @@ export class ProjectsService { description: `Serbest metin üzerinden üretildi.`, prompt: shortDbPrompt, language: dto.language || 'tr', - aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16, + aspectRatio: + (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16, videoStyle: dto.videoStyle || 'CINEMATIC', cinematicReference: dto.cinematicReference, targetDuration: dto.targetDuration || 60, @@ -1074,7 +1195,10 @@ export class ProjectsService { where: { id: project.id }, data: { status: 'DRAFT', - errorMessage: error instanceof Error ? error.message : 'Serbest metin senaryo üretimi sırasında hata', + errorMessage: + error instanceof Error + ? error.message + : 'Serbest metin senaryo üretimi sırasında hata', }, }); throw error; @@ -1088,7 +1212,12 @@ export class ProjectsService { userId: string, projectId: string, sceneId: string, - data: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number }, + data: { + narrationText?: string; + visualPrompt?: string; + subtitleText?: string; + duration?: number; + }, ) { // Proje sahipliğini doğrula const project = await this.findOne(userId, projectId); @@ -1109,9 +1238,15 @@ export class ProjectsService { const updated = await this.db.scene.update({ where: { id: sceneId }, data: { - ...(data.narrationText !== undefined && { narrationText: data.narrationText }), - ...(data.visualPrompt !== undefined && { visualPrompt: data.visualPrompt }), - ...(data.subtitleText !== undefined && { subtitleText: data.subtitleText }), + ...(data.narrationText !== undefined && { + narrationText: data.narrationText, + }), + ...(data.visualPrompt !== undefined && { + visualPrompt: data.visualPrompt, + }), + ...(data.subtitleText !== undefined && { + subtitleText: data.subtitleText, + }), ...(data.duration !== undefined && { duration: data.duration }), }, include: { mediaAssets: true }, @@ -1146,7 +1281,10 @@ export class ProjectsService { // Stil DNA bilgilerini prompt'a dahil et const cinematicRef = (project as any).cinematicReference || ''; - const styleDNA = this.videoAiService.getStyleDNA(project.videoStyle, cinematicRef || undefined); + const styleDNA = this.videoAiService.getStyleDNA( + project.videoStyle, + cinematicRef || undefined, + ); const contextPrompt = ` Bir video senaryosunun ${scene.order}. sahnesini yeniden üret. @@ -1202,11 +1340,18 @@ Sadece bu tek sahneyi üret. JSON formatında: include: { mediaAssets: true }, }); - this.logger.log(`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`); + this.logger.log( + `Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`, + ); return updated; } - async generateSceneImage(userId: string, projectId: string, sceneId: string, customPrompt?: string) { + async generateSceneImage( + userId: string, + projectId: string, + sceneId: string, + customPrompt?: string, + ) { const project = await this.findOne(userId, projectId); const scene = project.scenes.find((s) => s.id === sceneId); if (!scene) { @@ -1215,16 +1360,20 @@ Sadece bu tek sahneyi üret. JSON formatında: if (customPrompt && customPrompt !== scene.visualPrompt) { // First update the prompt - await this.updateScene(userId, projectId, sceneId, { visualPrompt: customPrompt }); + await this.updateScene(userId, projectId, sceneId, { + visualPrompt: customPrompt, + }); scene.visualPrompt = customPrompt; } - this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`); + this.logger.log( + `Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`, + ); const aspectRatioMap: Record = { - 'PORTRAIT_9_16': '9:16', - 'LANDSCAPE_16_9': '16:9', - 'SQUARE_1_1': '1:1', + PORTRAIT_9_16: '9:16', + LANDSCAPE_16_9: '16:9', + SQUARE_1_1: '1:1', }; const mappedRatio = aspectRatioMap[project.aspectRatio] || '9:16'; @@ -1237,35 +1386,49 @@ Sadece bu tek sahneyi üret. JSON formatında: `${scene.visualPrompt}. ${styleLabel}`, mappedRatio, ); - + if (!imageResult) { - this.logger.warn(`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`); - + this.logger.warn( + `⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`, + ); + 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 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}`; - imageResult = await this.geminiService.generateImage(illustrationPrompt, mappedRatio, true); + imageResult = await this.geminiService.generateImage( + illustrationPrompt, + mappedRatio, + true, + ); } catch (err: any) { - this.logger.error(`Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`); + this.logger.error( + `Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`, + ); } } if (!imageResult) { - throw new BadRequestException('Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.'); + throw new BadRequestException( + 'Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.', + ); } // Storage'a kaydet const key = this.storageService.getSceneImageKey(projectId, scene.order); - await this.storageService.upload(key, imageResult.buffer, imageResult.mimeType); + await this.storageService.upload( + key, + imageResult.buffer, + imageResult.mimeType, + ); const url = this.storageService.getPublicUrl(key); // MediaRecord oluştur veya güncelle - let mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL'); + const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL'); let mediaId = mediaAsset?.id; if (!mediaId) { const media = await this.db.mediaAsset.create({ @@ -1301,16 +1464,22 @@ Sadece bu tek sahneyi üret. JSON formatında: throw new NotFoundException('Sahne bulunamadı'); } - const mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL'); - let mediaId = mediaAsset?.id; + const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL'); + const mediaId = mediaAsset?.id; if (!mediaId) { - throw new BadRequestException('Bu sahne için upscaled edilecek görsel bulunamadı.'); + throw new BadRequestException( + 'Bu sahne için upscaled edilecek görsel bulunamadı.', + ); } - const media = await this.db.mediaAsset.findUnique({ where: { id: mediaId } }); + const media = await this.db.mediaAsset.findUnique({ + where: { id: mediaId }, + }); if (!media) throw new NotFoundException('Medya kaydı bulunamadı'); - this.logger.log(`Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`); + this.logger.log( + `Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`, + ); const key = this.storageService.getSceneImageKey(projectId, scene.order); const absPath = this.storageService.getAbsolutePath(key); @@ -1355,12 +1524,12 @@ Sadece bu tek sahneyi üret. JSON formatında: 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 - } + projectId: project.id, + }, }); if (!media) { @@ -1372,11 +1541,11 @@ Sadece bu tek sahneyi üret. JSON formatında: // 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 } + where: { id: mediaId }, }); - + this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`); return { success: true, message: 'Medya başarıyla silindi' }; } catch (error) { @@ -1388,12 +1557,21 @@ Sadece bu tek sahneyi üret. JSON formatında: /** * Çeviri İşlemi */ - async translateProject(userId: string, projectId: string, targetLanguage: string) { + 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})`); + await this.billingService.spendCredits( + userId, + 1, + projectId, + `Proje çevirisi (${targetLanguage})`, + ); const prompt = ` Translate the following video project to the language: ${targetLanguage}. @@ -1401,19 +1579,23 @@ Keep all structural metadata intact. For 'visualPrompt', it MUST strictly remain 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)}`; +${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", @@ -1438,15 +1620,26 @@ ${JSON.stringify({ let translatedData; try { - const response = await this.geminiService.generateJSON(prompt, schemaStr, { - temperature: 0.3, - }); + 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`); + 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.'); + throw new BadRequestException( + 'Çeviri işlemi sırasında AI servisinde bir hata oluştu.', + ); } const newProject = await this.db.project.create({ @@ -1460,8 +1653,15 @@ ${JSON.stringify({ 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), + 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, @@ -1469,27 +1669,32 @@ ${JSON.stringify({ status: 'DRAFT', userId, parentId: project.id, - } + }, }); for (const originalScene of project.scenes) { - const transScene = translatedData.scenes?.find((s: any) => s.id === originalScene.id); - + 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, + 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})`); + this.logger.log( + `Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`, + ); return newProject; } @@ -1542,7 +1747,9 @@ ${JSON.stringify({ }, }); - this.logger.log(`✅ ${result.titles.length} SEO başlığı üretildi: ${projectId}`); + this.logger.log( + `✅ ${result.titles.length} SEO başlığı üretildi: ${projectId}`, + ); return { titles: updated.seoTitleAlts, @@ -1555,7 +1762,11 @@ ${JSON.stringify({ * 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) { + async selectSeoTitle( + userId: string, + projectId: string, + selectedTitle: string, + ) { const project = await this.db.project.findFirst({ where: { id: projectId, userId, deletedAt: null }, }); @@ -1571,9 +1782,13 @@ ${JSON.stringify({ const trimmedTitle = selectedTitle.substring(0, 190); // socialContent varsa youtubeTitle'ı da güncelle - let updatedSocialContent = project.socialContent as Record || {}; + let updatedSocialContent = + (project.socialContent as Record) || {}; if (updatedSocialContent && typeof updatedSocialContent === 'object') { - updatedSocialContent = { ...updatedSocialContent, youtubeTitle: trimmedTitle.substring(0, 60) }; + updatedSocialContent = { + ...updatedSocialContent, + youtubeTitle: trimmedTitle.substring(0, 60), + }; } const updated = await this.db.project.update({ @@ -1601,7 +1816,9 @@ ${JSON.stringify({ }, }); - this.logger.log(`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`); + this.logger.log( + `Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`, + ); return updated; } } diff --git a/src/modules/render-callback/render-callback.controller.ts b/src/modules/render-callback/render-callback.controller.ts index ac4e204..fa4f00f 100644 --- a/src/modules/render-callback/render-callback.controller.ts +++ b/src/modules/render-callback/render-callback.controller.ts @@ -68,7 +68,10 @@ export class RenderCallbackController { private readonly configService: ConfigService, private readonly notificationsService: NotificationsService, ) { - this.apiKey = this.configService.get('RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026'); + this.apiKey = this.configService.get( + 'RENDER_CALLBACK_API_KEY', + 'contgen-worker-secret-2026', + ); } /** @@ -316,4 +319,3 @@ export class RenderCallbackController { } } } - diff --git a/src/modules/storage/storage.service.ts b/src/modules/storage/storage.service.ts index 658cbb1..22a2de7 100644 --- a/src/modules/storage/storage.service.ts +++ b/src/modules/storage/storage.service.ts @@ -49,7 +49,10 @@ export class StorageService { private readonly config: StorageConfig; constructor(private readonly configService: ConfigService) { - const basePath = this.configService.get('STORAGE_LOCAL_PATH', './data/media'); + const basePath = this.configService.get( + 'STORAGE_LOCAL_PATH', + './data/media', + ); const port = this.configService.get('PORT', 3000); const cdnUrl = this.configService.get('STORAGE_CDN_URL'); @@ -60,7 +63,7 @@ export class StorageService { }; this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`); - this.ensureBaseDir(); + void this.ensureBaseDir(); } /** @@ -69,7 +72,9 @@ export class StorageService { private async ensureBaseDir() { try { await fs.mkdir(this.config.basePath, { recursive: true }); - await fs.mkdir(path.join(this.config.basePath, 'temp'), { recursive: true }); + await fs.mkdir(path.join(this.config.basePath, 'temp'), { + recursive: true, + }); } catch (error) { this.logger.error(`Temel dizin oluşturulamadı: ${error}`); } @@ -119,7 +124,11 @@ export class StorageService { /** * Dosya yükle (Buffer → disk). */ - async upload(key: string, data: Buffer, mimeType: string): Promise { + async upload( + key: string, + data: Buffer, + mimeType: string, + ): Promise { const filePath = path.join(this.config.basePath, key); const dir = path.dirname(filePath); @@ -128,7 +137,9 @@ export class StorageService { const sizeBytes = data.length; - this.logger.debug(`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`); + this.logger.debug( + `📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`, + ); return { key, @@ -142,7 +153,11 @@ export class StorageService { /** * Stream olarak dosya yükle — büyük dosyalar için (Raspberry Pi bellek koruması). */ - async uploadFromPath(key: string, sourcePath: string, mimeType: string): Promise { + async uploadFromPath( + key: string, + sourcePath: string, + mimeType: string, + ): Promise { const destPath = path.join(this.config.basePath, key); const dir = path.dirname(destPath); @@ -151,7 +166,9 @@ export class StorageService { const stats = await fs.stat(destPath); - this.logger.debug(`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`); + this.logger.debug( + `📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`, + ); return { key, @@ -307,14 +324,22 @@ export class StorageService { // ── Private Helpers ──────────────────────────────────────────────── - private async listFilesRecursive(dir: string, prefix: string): Promise { + private async listFilesRecursive( + dir: string, + prefix: string, + ): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const relative = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { - files.push(...(await this.listFilesRecursive(path.join(dir, entry.name), relative))); + files.push( + ...(await this.listFilesRecursive( + path.join(dir, entry.name), + relative, + )), + ); } else { files.push(relative); } diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts index 1bee6f8..d3cbe2a 100644 --- a/src/modules/users/dto/user.dto.ts +++ b/src/modules/users/dto/user.dto.ts @@ -72,10 +72,12 @@ export class UserResponseDto { @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 obj.roles + .map((r: any) => { + if (typeof r === 'string') return r; + return r?.role?.name || r?.name; + }) + .filter(Boolean); } return []; }) diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 6900a5b..0e1cba1 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,4 +1,10 @@ -import { Controller, Get, Patch, Body, BadRequestException } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Body, + BadRequestException, +} from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { BaseController } from '../../common/base'; import { UsersService } from './users.service'; @@ -73,13 +79,19 @@ export class UsersController extends BaseController< if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı'); const bcrypt = await import('bcrypt'); - const isValid = await bcrypt.compare(body.currentPassword, fullUser.password); + const isValid = await bcrypt.compare( + body.currentPassword, + fullUser.password, + ); if (!isValid) { throw new BadRequestException('Mevcut şifre hatalı'); } await this.usersService.update(user.id, { password: body.newPassword }); - return createSuccessResponse({ success: true }, 'Şifre başarıyla güncellendi'); + return createSuccessResponse( + { success: true }, + 'Şifre başarıyla güncellendi', + ); } // Override create to require admin role diff --git a/src/modules/video-ai/video-ai.service.ts b/src/modules/video-ai/video-ai.service.ts index edf9ec8..2c46bba 100644 --- a/src/modules/video-ai/video-ai.service.ts +++ b/src/modules/video-ai/video-ai.service.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; +import { + Injectable, + Logger, + InternalServerErrorException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GoogleGenAI } from '@google/genai'; import { jsonrepair } from 'jsonrepair'; @@ -17,7 +21,12 @@ export interface ScriptGenerationInput { authorUsername: string; text: string; media: Array<{ type: string; url: string; width: number; height: number }>; - metrics: { replies: number; retweets: number; likes: number; views: number }; + metrics: { + replies: number; + retweets: number; + likes: number; + views: number; + }; isThread: boolean; }; } @@ -45,69 +54,284 @@ export interface StyleDNA { /** Desteklenen tüm video stilleri — frontend stil seçimi için export */ export const VIDEO_STYLES = [ // === Sinematik & Film === - { value: 'CINEMATIC', label: 'Sinematik', category: 'Film & Sinema', icon: '🎬' }, - { value: 'DOCUMENTARY', label: 'Belgesel', category: 'Film & Sinema', icon: '📹' }, - { value: 'STORYTELLING', label: 'Hikâye Anlatımı', category: 'Film & Sinema', icon: '📖' }, + { + value: 'CINEMATIC', + label: 'Sinematik', + category: 'Film & Sinema', + icon: '🎬', + }, + { + value: 'DOCUMENTARY', + label: 'Belgesel', + category: 'Film & Sinema', + icon: '📹', + }, + { + value: 'STORYTELLING', + label: 'Hikâye Anlatımı', + category: 'Film & Sinema', + icon: '📖', + }, { value: 'NEWS', label: 'Haber', category: 'Film & Sinema', icon: '📰' }, - { value: 'ARTISTIC', label: 'Sanatsal', category: 'Film & Sinema', icon: '🎨' }, + { + value: 'ARTISTIC', + label: 'Sanatsal', + category: 'Film & Sinema', + icon: '🎨', + }, { value: 'NOIR', label: 'Film Noir', category: 'Film & Sinema', icon: '🖤' }, - { value: 'VLOG', label: 'Vlog (Günlük)', category: 'Film & Sinema', icon: '📱' }, + { + value: 'VLOG', + label: 'Vlog (Günlük)', + category: 'Film & Sinema', + icon: '📱', + }, // === Animasyon === { value: 'ANIME', label: 'Anime', category: 'Animasyon', icon: '⛩️' }, - { value: 'ANIMATION_3D', label: '3D Animasyon (Pixar)', category: 'Animasyon', icon: '🧊' }, - { value: 'ANIMATION_2D', label: '2D Animasyon (Klasik)', category: 'Animasyon', icon: '✏️' }, - { value: 'STOP_MOTION', label: 'Stop Motion', category: 'Animasyon', icon: '🧸' }, - { value: 'MOTION_COMIC', label: 'Hareketli Çizgi Roman', category: 'Animasyon', icon: '💥' }, - { value: 'CARTOON', label: 'Karikatür / Çizgi Film', category: 'Animasyon', icon: '🎭' }, - { value: 'CLAYMATION', label: 'Claymation (Kil Animasyon)', category: 'Animasyon', icon: '🏺' }, - { value: 'PIXEL_ART', label: 'Pixel Art (8-bit)', category: 'Animasyon', icon: '👾' }, - { value: 'ISOMETRIC', label: 'İzometrik Animasyon', category: 'Animasyon', icon: '🔷' }, + { + value: 'ANIMATION_3D', + label: '3D Animasyon (Pixar)', + category: 'Animasyon', + icon: '🧊', + }, + { + value: 'ANIMATION_2D', + label: '2D Animasyon (Klasik)', + category: 'Animasyon', + icon: '✏️', + }, + { + value: 'STOP_MOTION', + label: 'Stop Motion', + category: 'Animasyon', + icon: '🧸', + }, + { + value: 'MOTION_COMIC', + label: 'Hareketli Çizgi Roman', + category: 'Animasyon', + icon: '💥', + }, + { + value: 'CARTOON', + label: 'Karikatür / Çizgi Film', + category: 'Animasyon', + icon: '🎭', + }, + { + value: 'CLAYMATION', + label: 'Claymation (Kil Animasyon)', + category: 'Animasyon', + icon: '🏺', + }, + { + value: 'PIXEL_ART', + label: 'Pixel Art (8-bit)', + category: 'Animasyon', + icon: '👾', + }, + { + value: 'ISOMETRIC', + label: 'İzometrik Animasyon', + category: 'Animasyon', + icon: '🔷', + }, // === Eğitim & Bilgi === - { value: 'EDUCATIONAL', label: 'Eğitim', category: 'Eğitim & Bilgi', icon: '🎓' }, - { value: 'INFOGRAPHIC', label: 'İnfografik', category: 'Eğitim & Bilgi', icon: '📊' }, - { value: 'WHITEBOARD', label: 'Whiteboard Animasyon', category: 'Eğitim & Bilgi', icon: '📝' }, - { value: 'EXPLAINER', label: 'Explainer Video', category: 'Eğitim & Bilgi', icon: '💡' }, - { value: 'DATA_VIZ', label: 'Veri Görselleştirme', category: 'Eğitim & Bilgi', icon: '📈' }, + { + value: 'EDUCATIONAL', + label: 'Eğitim', + category: 'Eğitim & Bilgi', + icon: '🎓', + }, + { + value: 'INFOGRAPHIC', + label: 'İnfografik', + category: 'Eğitim & Bilgi', + icon: '📊', + }, + { + value: 'WHITEBOARD', + label: 'Whiteboard Animasyon', + category: 'Eğitim & Bilgi', + icon: '📝', + }, + { + value: 'EXPLAINER', + label: 'Explainer Video', + category: 'Eğitim & Bilgi', + icon: '💡', + }, + { + value: 'DATA_VIZ', + label: 'Veri Görselleştirme', + category: 'Eğitim & Bilgi', + icon: '📈', + }, // === Retro & Nostaljik === - { value: 'RETRO_80S', label: 'Retro 80s Synthwave', category: 'Retro & Nostaljik', icon: '🕹️' }, - { value: 'VINTAGE_FILM', label: 'Vintage Film (Super 8)', category: 'Retro & Nostaljik', icon: '📽️' }, - { value: 'VHS', label: 'VHS Aesthetic', category: 'Retro & Nostaljik', icon: '📼' }, - { value: 'POLAROID', label: 'Polaroid / Analog Fotoğraf', category: 'Retro & Nostaljik', icon: '📸' }, - { value: 'RETRO_90S', label: 'Retro 90s Y2K', category: 'Retro & Nostaljik', icon: '💿' }, + { + value: 'RETRO_80S', + label: 'Retro 80s Synthwave', + category: 'Retro & Nostaljik', + icon: '🕹️', + }, + { + value: 'VINTAGE_FILM', + label: 'Vintage Film (Super 8)', + category: 'Retro & Nostaljik', + icon: '📽️', + }, + { + value: 'VHS', + label: 'VHS Aesthetic', + category: 'Retro & Nostaljik', + icon: '📼', + }, + { + value: 'POLAROID', + label: 'Polaroid / Analog Fotoğraf', + category: 'Retro & Nostaljik', + icon: '📸', + }, + { + value: 'RETRO_90S', + label: 'Retro 90s Y2K', + category: 'Retro & Nostaljik', + icon: '💿', + }, // === Sanat Akımları === - { value: 'WATERCOLOR', label: 'Suluboya', category: 'Sanat Akımları', icon: '🎨' }, - { value: 'OIL_PAINTING', label: 'Yağlı Boya', category: 'Sanat Akımları', icon: '🖌️' }, - { value: 'IMPRESSIONIST', label: 'Empresyonist', category: 'Sanat Akımları', icon: '🌅' }, - { value: 'POP_ART', label: 'Pop Art (Warhol)', category: 'Sanat Akımları', icon: '🎯' }, - { value: 'UKIYO_E', label: 'Ukiyo-e (Japon Ahşap Baskı)', category: 'Sanat Akımları', icon: '🏯' }, - { value: 'ART_DECO', label: 'Art Deco', category: 'Sanat Akımları', icon: '✨' }, - { value: 'SURREAL', label: 'Sürrealist (Dalí)', category: 'Sanat Akımları', icon: '🌀' }, - { value: 'COMIC_BOOK', label: 'Çizgi Roman (Marvel/DC)', category: 'Sanat Akımları', icon: '💬' }, - { value: 'SKETCH', label: 'Karakalem Çizim', category: 'Sanat Akımları', icon: '✍️' }, + { + value: 'WATERCOLOR', + label: 'Suluboya', + category: 'Sanat Akımları', + icon: '🎨', + }, + { + value: 'OIL_PAINTING', + label: 'Yağlı Boya', + category: 'Sanat Akımları', + icon: '🖌️', + }, + { + value: 'IMPRESSIONIST', + label: 'Empresyonist', + category: 'Sanat Akımları', + icon: '🌅', + }, + { + value: 'POP_ART', + label: 'Pop Art (Warhol)', + category: 'Sanat Akımları', + icon: '🎯', + }, + { + value: 'UKIYO_E', + label: 'Ukiyo-e (Japon Ahşap Baskı)', + category: 'Sanat Akımları', + icon: '🏯', + }, + { + value: 'ART_DECO', + label: 'Art Deco', + category: 'Sanat Akımları', + icon: '✨', + }, + { + value: 'SURREAL', + label: 'Sürrealist (Dalí)', + category: 'Sanat Akımları', + icon: '🌀', + }, + { + value: 'COMIC_BOOK', + label: 'Çizgi Roman (Marvel/DC)', + category: 'Sanat Akımları', + icon: '💬', + }, + { + value: 'SKETCH', + label: 'Karakalem Çizim', + category: 'Sanat Akımları', + icon: '✍️', + }, // === Modern & Minimal === - { value: 'MINIMALIST', label: 'Minimalist (Apple)', category: 'Modern & Minimal', icon: '⚪' }, - { value: 'GLASSMORPHISM', label: 'Glassmorphism / Cam', category: 'Modern & Minimal', icon: '🔮' }, - { value: 'NEON', label: 'Neon Glow', category: 'Modern & Minimal', icon: '💜' }, - { value: 'CYBERPUNK', label: 'Cyberpunk', category: 'Modern & Minimal', icon: '🤖' }, - { value: 'STEAMPUNK', label: 'Steampunk', category: 'Modern & Minimal', icon: '⚙️' }, - { value: 'ABSTRACT', label: 'Soyut / Abstract', category: 'Modern & Minimal', icon: '🔵' }, + { + value: 'MINIMALIST', + label: 'Minimalist (Apple)', + category: 'Modern & Minimal', + icon: '⚪', + }, + { + value: 'GLASSMORPHISM', + label: 'Glassmorphism / Cam', + category: 'Modern & Minimal', + icon: '🔮', + }, + { + value: 'NEON', + label: 'Neon Glow', + category: 'Modern & Minimal', + icon: '💜', + }, + { + value: 'CYBERPUNK', + label: 'Cyberpunk', + category: 'Modern & Minimal', + icon: '🤖', + }, + { + value: 'STEAMPUNK', + label: 'Steampunk', + category: 'Modern & Minimal', + icon: '⚙️', + }, + { + value: 'ABSTRACT', + label: 'Soyut / Abstract', + category: 'Modern & Minimal', + icon: '🔵', + }, // === Fotoğrafik === - { value: 'PRODUCT', label: 'Ürün Fotoğrafçılığı', category: 'Fotoğrafik', icon: '📦' }, - { value: 'FASHION', label: 'Moda Fotoğrafçılığı', category: 'Fotoğrafik', icon: '👗' }, - { value: 'AERIAL', label: 'Havadan (Drone)', category: 'Fotoğrafik', icon: '🚁' }, - { value: 'MACRO', label: 'Makro / Yakın Çekim', category: 'Fotoğrafik', icon: '🔬' }, - { value: 'PORTRAIT', label: 'Portre Fotoğrafçılığı', category: 'Fotoğrafik', icon: '🧑' }, + { + value: 'PRODUCT', + label: 'Ürün Fotoğrafçılığı', + category: 'Fotoğrafik', + icon: '📦', + }, + { + value: 'FASHION', + label: 'Moda Fotoğrafçılığı', + category: 'Fotoğrafik', + icon: '👗', + }, + { + value: 'AERIAL', + label: 'Havadan (Drone)', + category: 'Fotoğrafik', + icon: '🚁', + }, + { + value: 'MACRO', + label: 'Makro / Yakın Çekim', + category: 'Fotoğrafik', + icon: '🔬', + }, + { + value: 'PORTRAIT', + label: 'Portre Fotoğrafçılığı', + category: 'Fotoğrafik', + icon: '🧑', + }, ] as const; -export type VideoStyleKey = typeof VIDEO_STYLES[number]['value']; +export type VideoStyleKey = (typeof VIDEO_STYLES)[number]['value']; export interface SeoMetadata { title: string; description: string; keywords: string[]; hashtags: string[]; - trendingHashtags?: string[]; // Trend hashtag'ler (AI tahmini) - estimatedSearchVolume?: string; // Anahtar kelimenin tahmini arama hacmi (AI tahmini) + trendingHashtags?: string[]; // Trend hashtag'ler (AI tahmini) + estimatedSearchVolume?: string; // Anahtar kelimenin tahmini arama hacmi (AI tahmini) schemaMarkup: Record; } @@ -120,18 +344,19 @@ 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 + 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ı - musicTechnical: { // AudioCraft: teknik parametreler + musicStyle: string; // AudioCraft: genre/mood tanımı + musicTechnical: { + // AudioCraft: teknik parametreler bpm: number; key?: string; instruments: string[]; emotionalArc: string; }; - ambientSoundPrompts: string[]; // AudioGen: proje geneli ambient sesler + ambientSoundPrompts: string[]; // AudioGen: proje geneli ambient sesler voiceStyle: string; socialContent: { youtubeTitle: string; @@ -608,16 +833,23 @@ export class VideoAiService { constructor(private readonly configService: ConfigService) { const apiKey = this.configService.get('gemini.apiKey', ''); - this.modelName = this.configService.get('gemini.model', 'gemini-2.5-flash'); + this.modelName = this.configService.get( + 'gemini.model', + 'gemini-2.5-flash', + ); if (!apiKey) { - this.logger.warn('⚠️ GOOGLE_API_KEY ayarlanmamış — AI servisi devre dışı'); + this.logger.warn( + '⚠️ GOOGLE_API_KEY ayarlanmamış — AI servisi devre dışı', + ); } this.genAI = new GoogleGenAI({ apiKey }); } - async generateVideoScript(input: ScriptGenerationInput): Promise { + async generateVideoScript( + input: ScriptGenerationInput, + ): Promise { this.logger.log( `Senaryo üretimi başladı — Konu: "${input.topic}", ` + `Süre: ${input.targetDurationSeconds}s, Dil: ${input.language}`, @@ -649,7 +881,12 @@ export class VideoAiService { const script = this.parseAndValidateScript(rawText); const humanizedScript = this.applyHumanizerPass(script); - const enrichedScript = this.enrichVisualPrompts(humanizedScript, input.videoStyle, input.cinematicReference, input.aspectRatio); + const enrichedScript = this.enrichVisualPrompts( + humanizedScript, + input.videoStyle, + input.cinematicReference, + input.aspectRatio, + ); this.logger.log( `✅ Senaryo üretildi — "${enrichedScript.metadata.title}", ` + @@ -680,7 +917,9 @@ export class VideoAiService { language: string, keywords: string[], ): Promise<{ titles: string[]; seoScore: number }> { - this.logger.log(`SEO başlık üretimi başladı — Konu: "${topic}", Dil: ${language}`); + 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. @@ -750,13 +989,19 @@ Return ONLY valid JSON: } // 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.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}`); + 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'}`); + 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ı'}`, ); @@ -767,8 +1012,13 @@ Return ONLY valid JSON: * 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. */ - async suggestDocumentTopics(text: string, count: number = 4): Promise { - this.logger.log(`Dokümandan konu önerileri çıkarılıyor... (Metin uzunluğu: ${text.length})`); + async suggestDocumentTopics( + text: string, + count: number = 4, + ): Promise { + this.logger.log( + `Dokümandan konu önerileri çıkarılıyor... (Metin uzunluğu: ${text.length})`, + ); const systemPrompt = `You are an elite YouTube producer and content strategist. Your task is to analyze the provided book/document extract and suggest exactly ${count} highly engaging, distinct video topics or angles that could be made into successful YouTube Shorts or videos. @@ -810,10 +1060,22 @@ REQUIREMENTS: private buildUserPrompt(input: ScriptGenerationInput): string { const langMap: Record = { - tr: 'Turkish', en: 'English', es: 'Spanish', de: 'German', - fr: 'French', it: 'Italian', pt: 'Portuguese', ru: 'Russian', - ja: 'Japanese', ko: 'Korean', zh: 'Chinese (Simplified)', - ar: 'Arabic', hi: 'Hindi', nl: 'Dutch', sv: 'Swedish', pl: 'Polish', + tr: 'Turkish', + en: 'English', + es: 'Spanish', + de: 'German', + fr: 'French', + it: 'Italian', + pt: 'Portuguese', + ru: 'Russian', + ja: 'Japanese', + ko: 'Korean', + zh: 'Chinese (Simplified)', + ar: 'Arabic', + hi: 'Hindi', + nl: 'Dutch', + sv: 'Swedish', + pl: 'Polish', }; const languageName = langMap[input.language] || input.language; @@ -872,7 +1134,7 @@ REQUIREMENTS: prompt += `\nOriginal tweet text:\n"${tw.text}"\n`; if (tw.media.length > 0) { - const photos = tw.media.filter(m => m.type === 'photo'); + const photos = tw.media.filter((m) => m.type === 'photo'); if (photos.length > 0) { prompt += `\nThe tweet has ${photos.length} photo(s). Use these as VISUAL REFERENCES in your visual prompts.\n`; prompt += `Also generate AI-enhanced visuals inspired by these reference images.\n`; @@ -919,33 +1181,60 @@ REQUIREMENTS: */ private applyHumanizerPass(script: GeneratedScript): GeneratedScript { const aiWords = [ - 'delve', 'tapestry', 'landscape', 'crucial', 'moreover', 'furthermore', - 'testament', 'underscore', 'foster', 'garner', 'showcase', 'pivotal', - 'groundbreaking', 'vibrant', 'nestled', 'renowned', 'breathtaking', - 'interplay', 'intricacies', 'endeavor', 'exemplifies', 'comprehensive', + 'delve', + 'tapestry', + 'landscape', + 'crucial', + 'moreover', + 'furthermore', + 'testament', + 'underscore', + 'foster', + 'garner', + 'showcase', + 'pivotal', + 'groundbreaking', + 'vibrant', + 'nestled', + 'renowned', + 'breathtaking', + 'interplay', + 'intricacies', + 'endeavor', + 'exemplifies', + 'comprehensive', ]; const aiPhrases = [ - 'in the realm of', 'it is important to note', 'in today\'s world', - 'serves as a testament', 'stands as a', 'it\'s not just', - 'at the end of the day', 'the fact of the matter', + 'in the realm of', + 'it is important to note', + "in today's world", + 'serves as a testament', + 'stands as a', + "it's not just", + 'at the end of the day', + 'the fact of the matter', ]; for (const scene of script.scenes) { - let text = scene.narrationText; + const text = scene.narrationText; // AI kelimelerini kontrol et (case-insensitive) for (const word of aiWords) { const regex = new RegExp(`\\b${word}\\b`, 'gi'); if (regex.test(text)) { - this.logger.debug(`Humanizer: "${word}" kelimesi tespit edildi, sahne ${scene.order}`); + this.logger.debug( + `Humanizer: "${word}" kelimesi tespit edildi, sahne ${scene.order}`, + ); } } // AI cümle kalıplarını kontrol et for (const phrase of aiPhrases) { if (text.toLowerCase().includes(phrase)) { - this.logger.debug(`Humanizer: "${phrase}" kalıbı tespit edildi, sahne ${scene.order}`); + this.logger.debug( + `Humanizer: "${phrase}" kalıbı tespit edildi, sahne ${scene.order}`, + ); } } @@ -997,7 +1286,8 @@ REQUIREMENTS: aspectRatio?: string, ): GeneratedScript { const styleDNA = this.getStyleDNA(videoStyle, cinematicReference); - const defaultNegative = 'Avoid: text overlays, watermarks, brand logos, recognizable celebrity faces, distorted anatomy, extra fingers, blurry faces, stock photo aesthetic, oversaturated CGI plastic look, generic clip art, UI elements'; + const defaultNegative = + 'Avoid: text overlays, watermarks, brand logos, recognizable celebrity faces, distorted anatomy, extra fingers, blurry faces, stock photo aesthetic, oversaturated CGI plastic look, generic clip art, UI elements'; for (let i = 0; i < script.scenes.length; i++) { const scene = script.scenes[i]; @@ -1020,12 +1310,17 @@ REQUIREMENTS: } // 3. Aspect ratio compositional hint — eksikse ekle - if (aspectRatio && !vp.toLowerCase().includes('framing') && !vp.toLowerCase().includes('composition')) { - const arHint = aspectRatio === 'PORTRAIT_9_16' - ? 'Vertical framing optimized for mobile viewing.' - : aspectRatio === 'LANDSCAPE_16_9' - ? 'Wide cinematic horizontal composition.' - : 'Square centered symmetrical framing.'; + if ( + aspectRatio && + !vp.toLowerCase().includes('framing') && + !vp.toLowerCase().includes('composition') + ) { + const arHint = + aspectRatio === 'PORTRAIT_9_16' + ? 'Vertical framing optimized for mobile viewing.' + : aspectRatio === 'LANDSCAPE_16_9' + ? 'Wide cinematic horizontal composition.' + : 'Square centered symmetrical framing.'; vp = `${vp} ${arHint}`; } @@ -1053,33 +1348,58 @@ REQUIREMENTS: const additions: string[] = []; // Lighting eksikse ekle - if (!prompt.toLowerCase().includes('light') && !prompt.toLowerCase().includes('shadow')) { + if ( + !prompt.toLowerCase().includes('light') && + !prompt.toLowerCase().includes('shadow') + ) { additions.push(styleDNA.lighting); } // Lens/DOF eksikse ekle - if (!prompt.toLowerCase().includes('lens') && !prompt.toLowerCase().includes('depth of field') && !prompt.toLowerCase().includes('dof') && !prompt.toLowerCase().includes('f/')) { + if ( + !prompt.toLowerCase().includes('lens') && + !prompt.toLowerCase().includes('depth of field') && + !prompt.toLowerCase().includes('dof') && + !prompt.toLowerCase().includes('f/') + ) { additions.push(styleDNA.lens); } // Color grade eksikse ekle - if (!prompt.toLowerCase().includes('color') && !prompt.toLowerCase().includes('grade') && !prompt.toLowerCase().includes('palette')) { + if ( + !prompt.toLowerCase().includes('color') && + !prompt.toLowerCase().includes('grade') && + !prompt.toLowerCase().includes('palette') + ) { additions.push(styleDNA.color); } // Texture eksikse ekle - if (!prompt.toLowerCase().includes('grain') && !prompt.toLowerCase().includes('texture') && !prompt.toLowerCase().includes('film')) { + if ( + !prompt.toLowerCase().includes('grain') && + !prompt.toLowerCase().includes('texture') && + !prompt.toLowerCase().includes('film') + ) { additions.push(styleDNA.texture); } // Referans eksikse ekle - if (!prompt.toLowerCase().includes('style') && !prompt.toLowerCase().includes('inspired') && !prompt.toLowerCase().includes('aesthetic')) { + if ( + !prompt.toLowerCase().includes('style') && + !prompt.toLowerCase().includes('inspired') && + !prompt.toLowerCase().includes('aesthetic') + ) { additions.push(`Visual style inspired by ${styleDNA.reference}.`); } // Hook sahne için ekstra detay - if (isHook && currentWords + additions.join(' ').split(/\s+/).length < targetWords) { - additions.push('This is the opening shot — it must immediately capture attention and establish the visual world of the entire video.'); + if ( + isHook && + currentWords + additions.join(' ').split(/\s+/).length < targetWords + ) { + additions.push( + 'This is the opening shot — it must immediately capture attention and establish the visual world of the entire video.', + ); } return `${prompt} ${additions.join(' ')}`; @@ -1088,336 +1408,517 @@ REQUIREMENTS: /** * Video stiline göre varsayılan görsel DNA değerlerini döndürür. */ - public getStyleDNA(videoStyle: string, cinematicReference?: string): StyleDNA { + public getStyleDNA( + videoStyle: string, + cinematicReference?: string, + ): StyleDNA { const dnaMap: Record = { CINEMATIC: { - reference: cinematicReference ? `${cinematicReference} visual style and cinematography` : 'High-end cinematic production with professional cinematography techniques', - lighting: cinematicReference ? `Iconic lighting setup matching the ${cinematicReference} cinematic style` : 'Dramatic key-and-fill lighting with motivated light sources, sculpted shadows, and cinematic depth.', - lens: cinematicReference ? `Signature camera lens choice and depth of field suitable for ${cinematicReference}` : 'Shot on 35mm anamorphic lens with cinematic depth of field and professional framing.', - color: cinematicReference ? `Color grading and palette uniquely associated with ${cinematicReference}` : 'Professional cinematic color grading with balanced contrast, natural skin tones, and atmospheric depth.', - texture: cinematicReference ? `Film grain and visual texture evoking the ${cinematicReference} experience` : 'Subtle organic film grain, natural lens characteristics, cinematic light bloom on practical sources.', + reference: cinematicReference + ? `${cinematicReference} visual style and cinematography` + : 'High-end cinematic production with professional cinematography techniques', + lighting: cinematicReference + ? `Iconic lighting setup matching the ${cinematicReference} cinematic style` + : 'Dramatic key-and-fill lighting with motivated light sources, sculpted shadows, and cinematic depth.', + lens: cinematicReference + ? `Signature camera lens choice and depth of field suitable for ${cinematicReference}` + : 'Shot on 35mm anamorphic lens with cinematic depth of field and professional framing.', + color: cinematicReference + ? `Color grading and palette uniquely associated with ${cinematicReference}` + : 'Professional cinematic color grading with balanced contrast, natural skin tones, and atmospheric depth.', + texture: cinematicReference + ? `Film grain and visual texture evoking the ${cinematicReference} experience` + : 'Subtle organic film grain, natural lens characteristics, cinematic light bloom on practical sources.', }, DOCUMENTARY: { reference: 'National Geographic and Planet Earth II', - lighting: 'Natural available daylight, no artificial sources, authentic and observational.', + lighting: + 'Natural available daylight, no artificial sources, authentic and observational.', lens: 'Shot on 50mm prime lens with deep focus f/8, everything sharp and clear.', - color: 'Natural warm tones, true-to-life rendering with slight warm saturation boost.', - texture: 'Clean digital capture with slight handheld vibration feel, no post-processing artifacts.', + color: + 'Natural warm tones, true-to-life rendering with slight warm saturation boost.', + texture: + 'Clean digital capture with slight handheld vibration feel, no post-processing artifacts.', }, EDUCATIONAL: { reference: 'Kurzgesagt and 3Blue1Brown explainer videos', - lighting: 'Flat even illumination, clean and clear, no directional shadows.', + lighting: + 'Flat even illumination, clean and clear, no directional shadows.', lens: 'Overhead diagram view or isometric angles, deep focus everything sharp.', - color: 'Bold saturated primary colors on dark background, data-visualization palette.', - texture: 'Vector-clean sharp edges, infographic precision, flat design with subtle drop shadows.', + color: + 'Bold saturated primary colors on dark background, data-visualization palette.', + texture: + 'Vector-clean sharp edges, infographic precision, flat design with subtle drop shadows.', }, STORYTELLING: { reference: 'Wes Anderson symmetry and Studio Ghibli warmth', - lighting: 'Warm golden soft diffused light with fairy-tale quality, gentle and inviting.', + lighting: + 'Warm golden soft diffused light with fairy-tale quality, gentle and inviting.', lens: 'Medium lens with symmetrical centered framing, storybook composition.', - color: 'Pastel palette with vintage warmth, muted yet colorful, nostalgic.', - texture: 'Painterly soft texture with watercolor wash quality, gentle and dreamy.', + color: + 'Pastel palette with vintage warmth, muted yet colorful, nostalgic.', + texture: + 'Painterly soft texture with watercolor wash quality, gentle and dreamy.', }, NEWS: { reference: 'BBC World and CNN broadcast graphics', - lighting: 'High-key even broadcast studio lighting or natural location light.', + lighting: + 'High-key even broadcast studio lighting or natural location light.', lens: 'Standard 50mm at eye-level, clean professional composition.', - color: 'Neutral cool tones, high contrast, professional and authoritative.', + color: + 'Neutral cool tones, high contrast, professional and authoritative.', texture: 'Clean sharp digital, motion graphics readiness, no grain.', }, ARTISTIC: { reference: 'Tarkovsky, Wong Kar-wai, and Terrence Malick', - lighting: 'Extreme chiaroscuro with unconventional color temperatures, moody and atmospheric.', + lighting: + 'Extreme chiaroscuro with unconventional color temperatures, moody and atmospheric.', lens: 'Wide angle with slight distortion or extreme close macro, creative framing.', - color: 'Surreal color shifts with split-toning, bold unconventional palettes.', - texture: 'Heavy analog film grain, intentional imperfections, light leaks, vintage artifacts.', + color: + 'Surreal color shifts with split-toning, bold unconventional palettes.', + texture: + 'Heavy analog film grain, intentional imperfections, light leaks, vintage artifacts.', }, ANIME: { - reference: 'Makoto Shinkai (Your Name, Weathering With You) and Studio Ghibli', - lighting: 'Ethereal glowing light rays with dramatic cel-shaded lighting, luminous bloom effects.', + reference: + 'Makoto Shinkai (Your Name, Weathering With You) and Studio Ghibli', + lighting: + 'Ethereal glowing light rays with dramatic cel-shaded lighting, luminous bloom effects.', lens: 'Dynamic manga-inspired angles with dramatic low and high perspectives, speed lines for action.', - color: 'Vivid saturated anime palette with glowing skies and luminous highlights.', - texture: 'Clean cel-shaded lines with painted backgrounds, photorealistic environmental detail, sparkle particles.', + color: + 'Vivid saturated anime palette with glowing skies and luminous highlights.', + texture: + 'Clean cel-shaded lines with painted backgrounds, photorealistic environmental detail, sparkle particles.', }, ANIMATION_3D: { reference: 'Pixar (Soul, WALL-E) and Unreal Engine 5 cinematics', - lighting: 'Global illumination with subsurface scattering on skin, volumetric god rays through atmosphere.', + lighting: + 'Global illumination with subsurface scattering on skin, volumetric god rays through atmosphere.', lens: 'Virtual cinema camera with realistic depth of field, Pixar-style dramatic angles.', - color: 'Rich saturated yet natural rendering palette, photorealistic material shaders.', - texture: 'Smooth subdivision surfaces with micro-detail on materials, photorealistic shader quality.', + color: + 'Rich saturated yet natural rendering palette, photorealistic material shaders.', + texture: + 'Smooth subdivision surfaces with micro-detail on materials, photorealistic shader quality.', }, ANIMATION_2D: { - reference: 'Classic Disney hand-drawn era and Cartoon Saloon (Wolfwalkers)', - lighting: 'Painted light and shadow, flat but highly expressive, artistic lighting.', + reference: + 'Classic Disney hand-drawn era and Cartoon Saloon (Wolfwalkers)', + lighting: + 'Painted light and shadow, flat but highly expressive, artistic lighting.', lens: 'Flat 2D composition with layered parallax depth, theatrical staging.', - color: 'Gouache and watercolor palette, limited but expressive color choices.', - texture: 'Visible brushstrokes, hand-drawn line quality, subtle paper texture.', + color: + 'Gouache and watercolor palette, limited but expressive color choices.', + texture: + 'Visible brushstrokes, hand-drawn line quality, subtle paper texture.', }, STOP_MOTION: { - reference: 'Laika Studios (Coraline, Kubo) and Wes Anderson (Fantastic Mr. Fox)', - lighting: 'Miniature set practical lighting with visible small-scale light rigs, warm intimate.', + reference: + 'Laika Studios (Coraline, Kubo) and Wes Anderson (Fantastic Mr. Fox)', + lighting: + 'Miniature set practical lighting with visible small-scale light rigs, warm intimate.', lens: 'Macro lens with shallow DOF revealing miniature scale, tilt-shift effect.', color: 'Handcrafted tactile palette, slightly desaturated warm tones.', - texture: 'Visible material textures — clay, felt, wood, fabric, fingerprints on clay, puppet joints visible.', + texture: + 'Visible material textures — clay, felt, wood, fabric, fingerprints on clay, puppet joints visible.', }, INFOGRAPHIC: { - reference: 'Kurzgesagt, Visual Capitalist, and Hans Rosling data visualization', + reference: + 'Kurzgesagt, Visual Capitalist, and Hans Rosling data visualization', lighting: 'Flat pure graphic illumination, no directional light, even.', lens: 'Orthographic or isometric projection, no perspective distortion.', - color: 'Data-driven palette with 3-5 semantic colors, dark background with bright accent colors.', - texture: 'Ultra-clean vector graphics, sharp geometric edges, flat design with subtle shadows.', + color: + 'Data-driven palette with 3-5 semantic colors, dark background with bright accent colors.', + texture: + 'Ultra-clean vector graphics, sharp geometric edges, flat design with subtle shadows.', }, RETRO_80S: { - reference: 'Synthwave/Outrun aesthetic, Stranger Things, and Drive (2011)', - lighting: 'Neon purple, pink, and cyan glow with laser grid lines and chrome reflections.', + reference: + 'Synthwave/Outrun aesthetic, Stranger Things, and Drive (2011)', + lighting: + 'Neon purple, pink, and cyan glow with laser grid lines and chrome reflections.', lens: 'Wide angle capturing expansive neon landscapes from low angle.', - color: 'Neon magenta, electric cyan, deep purple, chrome silver, hot pink sunset gradients.', - texture: 'CRT scanlines, VHS tracking artifacts, retro pixel grid, reflective chrome surfaces.', + color: + 'Neon magenta, electric cyan, deep purple, chrome silver, hot pink sunset gradients.', + texture: + 'CRT scanlines, VHS tracking artifacts, retro pixel grid, reflective chrome surfaces.', }, MINIMALIST: { reference: 'Apple product design language and Japanese zen aesthetics', - lighting: 'Clean soft diffused studio light, seamless white or gray gradient background.', + lighting: + 'Clean soft diffused studio light, seamless white or gray gradient background.', lens: 'Product photography precision, clean medium shot with perfect focus.', - color: 'Monochrome palette with single accent color, vast negative space.', - texture: 'Ultra-smooth surfaces, absolutely no grain or artifacts, pristine and clinical.', + color: + 'Monochrome palette with single accent color, vast negative space.', + texture: + 'Ultra-smooth surfaces, absolutely no grain or artifacts, pristine and clinical.', }, SURREAL: { reference: 'Salvador Dalí, René Magritte, and M.C. Escher', - lighting: 'Impossible multiple light sources, conflicting shadow directions, dreamy supernatural glow.', + lighting: + 'Impossible multiple light sources, conflicting shadow directions, dreamy supernatural glow.', lens: 'Fish-eye distortion, impossible recursive geometry, Droste effect perspectives.', - color: 'Hyper-vivid otherworldly palette, colors that defy natural physics.', - texture: 'Ultra-detailed photorealistic rendering of impossible and paradoxical objects, smooth dreamlike surfaces.', + color: + 'Hyper-vivid otherworldly palette, colors that defy natural physics.', + texture: + 'Ultra-detailed photorealistic rendering of impossible and paradoxical objects, smooth dreamlike surfaces.', }, // === Ek Film & Sinema === NOIR: { - reference: 'Classic Film Noir — Double Indemnity, The Third Man, Sin City', - lighting: 'High-contrast chiaroscuro with venetian blind shadow patterns, single hard spotlight from above-left, deep impenetrable blacks.', + reference: + 'Classic Film Noir — Double Indemnity, The Third Man, Sin City', + lighting: + 'High-contrast chiaroscuro with venetian blind shadow patterns, single hard spotlight from above-left, deep impenetrable blacks.', lens: 'Wide angle 28mm with Dutch angle tilts, deep focus noir staging, low camera angles.', - color: 'Stark black-and-white or heavily desaturated with single color accent (red lips, neon sign), crushed blacks.', - texture: 'Heavy film grain ISO 1600, scratched celluloid, cigarette smoke diffusion, rain-streaked windows.', + color: + 'Stark black-and-white or heavily desaturated with single color accent (red lips, neon sign), crushed blacks.', + texture: + 'Heavy film grain ISO 1600, scratched celluloid, cigarette smoke diffusion, rain-streaked windows.', }, VLOG: { - reference: 'Casey Neistat, MrBeast, authentic YouTube creator aesthetic', - lighting: 'Natural mixed lighting — ring light on face, window daylight, practical room lights visible.', + reference: + 'Casey Neistat, MrBeast, authentic YouTube creator aesthetic', + lighting: + 'Natural mixed lighting — ring light on face, window daylight, practical room lights visible.', lens: 'Wide angle 16mm GoPro or 24mm vlog lens, slight barrel distortion, close to subject.', - color: 'Punchy saturated colors, slightly lifted shadows, bright and energetic YouTube grade.', - texture: 'Clean digital with slight motion blur from handheld movement, casual and authentic feel.', + color: + 'Punchy saturated colors, slightly lifted shadows, bright and energetic YouTube grade.', + texture: + 'Clean digital with slight motion blur from handheld movement, casual and authentic feel.', }, // === Ek Animasyon === MOTION_COMIC: { - reference: 'Marvel Motion Comics, Watchmen Motion Comic, DC animated panels', - lighting: 'Dramatic comic book lighting with bold cast shadows, high-contrast key light.', + reference: + 'Marvel Motion Comics, Watchmen Motion Comic, DC animated panels', + lighting: + 'Dramatic comic book lighting with bold cast shadows, high-contrast key light.', lens: 'Panel-framed compositions with zoom-and-pan (Ken Burns effect on comic panels), dramatic angles.', - color: 'Rich saturated comic book palette with bold primaries, inked outlines, Ben-Day dots.', - texture: 'Printed comic texture with halftone dots, speech bubble spaces, panel border lines, ink splatter.', + color: + 'Rich saturated comic book palette with bold primaries, inked outlines, Ben-Day dots.', + texture: + 'Printed comic texture with halftone dots, speech bubble spaces, panel border lines, ink splatter.', }, CARTOON: { - reference: 'Looney Tunes, The Simpsons, Adventure Time, modern Cartoon Network', - lighting: 'Flat cartoon lighting with simple cast shadows, bright and even, no complex lighting.', + reference: + 'Looney Tunes, The Simpsons, Adventure Time, modern Cartoon Network', + lighting: + 'Flat cartoon lighting with simple cast shadows, bright and even, no complex lighting.', lens: 'Exaggerated cartoon perspectives, squash and stretch compositions, dynamic action poses.', - color: 'Bold flat colors with thick outlines, limited palette per scene, saturated and cheerful.', - texture: 'Clean vector lines, smooth flat fills, no grain or noise, crisp digital cartoon rendering.', + color: + 'Bold flat colors with thick outlines, limited palette per scene, saturated and cheerful.', + texture: + 'Clean vector lines, smooth flat fills, no grain or noise, crisp digital cartoon rendering.', }, CLAYMATION: { - reference: 'Aardman Animations (Wallace & Gromit), Celebrity Deathmatch, Robot Chicken', - lighting: 'Warm miniature set practical lighting with soft shadows, slightly uneven handmade quality.', + reference: + 'Aardman Animations (Wallace & Gromit), Celebrity Deathmatch, Robot Chicken', + lighting: + 'Warm miniature set practical lighting with soft shadows, slightly uneven handmade quality.', lens: 'Macro lens at miniature scale, moderate depth of field, slightly wobbly stop-motion framing.', - color: 'Earthy clay-like palette, warm skin tones on plasticine, handcrafted color mixing.', - texture: 'Visible clay fingerprints, plasticine surface texture, wire armature hints, handmade imperfections.', + color: + 'Earthy clay-like palette, warm skin tones on plasticine, handcrafted color mixing.', + texture: + 'Visible clay fingerprints, plasticine surface texture, wire armature hints, handmade imperfections.', }, PIXEL_ART: { - reference: '8-bit/16-bit retro gaming — Final Fantasy VI, Chrono Trigger, Celeste, Stardew Valley', - lighting: 'Pixel-based dithered lighting, limited shading levels (3-4 tones per color), NES/SNES era.', + reference: + '8-bit/16-bit retro gaming — Final Fantasy VI, Chrono Trigger, Celeste, Stardew Valley', + lighting: + 'Pixel-based dithered lighting, limited shading levels (3-4 tones per color), NES/SNES era.', lens: 'Flat orthographic top-down or side-scrolling view, tile-based grid composition.', - color: 'Limited retro palette (16-64 colors), vibrant pixel colors, classic hardware palette constraints.', - texture: 'Visible individual pixels, no anti-aliasing, crisp hard pixel edges, scanline optional.', + color: + 'Limited retro palette (16-64 colors), vibrant pixel colors, classic hardware palette constraints.', + texture: + 'Visible individual pixels, no anti-aliasing, crisp hard pixel edges, scanline optional.', }, ISOMETRIC: { - reference: 'Monument Valley, SimCity, isometric architectural illustration, Diablo II', - lighting: 'Even isometric lighting from upper-left at 45 degrees, clean predictable shadows.', + reference: + 'Monument Valley, SimCity, isometric architectural illustration, Diablo II', + lighting: + 'Even isometric lighting from upper-left at 45 degrees, clean predictable shadows.', lens: 'True isometric projection (30-degree angle), no perspective vanishing points, tiled grid.', - color: 'Clean architectural palette, organized by function/zone, pastel or bold depending on theme.', - texture: 'Clean geometric surfaces, subtle material differentiation, architectural precision.', + color: + 'Clean architectural palette, organized by function/zone, pastel or bold depending on theme.', + texture: + 'Clean geometric surfaces, subtle material differentiation, architectural precision.', }, // === Ek Eğitim & Bilgi === WHITEBOARD: { reference: 'RSA Animate, Khan Academy, TED-Ed whiteboard explainers', - lighting: 'Bright even overhead lighting on white surface, no shadows, pure clarity.', + lighting: + 'Bright even overhead lighting on white surface, no shadows, pure clarity.', lens: 'Top-down or slight angle on whiteboard surface, steady and centered.', - color: 'Black ink on white background, limited accent colors (red, blue, green markers).', - texture: 'Whiteboard surface with slight marker texture, hand-drawn line quality, dry-erase aesthetic.', + color: + 'Black ink on white background, limited accent colors (red, blue, green markers).', + texture: + 'Whiteboard surface with slight marker texture, hand-drawn line quality, dry-erase aesthetic.', }, EXPLAINER: { - reference: 'Slack, Dropbox, and Stripe product explainer videos, Lottie animations', - lighting: 'Flat design lighting, no directional shadows, clean and corporate.', + reference: + 'Slack, Dropbox, and Stripe product explainer videos, Lottie animations', + lighting: + 'Flat design lighting, no directional shadows, clean and corporate.', lens: 'Centered framing with smooth transitions, screen-recording-like precision.', - color: 'Brand-consistent palette with 2-3 primary colors, white or light gray background.', - texture: 'Smooth vector animation, Lottie-style motion, rounded corners, friendly and approachable.', + color: + 'Brand-consistent palette with 2-3 primary colors, white or light gray background.', + texture: + 'Smooth vector animation, Lottie-style motion, rounded corners, friendly and approachable.', }, DATA_VIZ: { - reference: 'New York Times data journalism, Flourish, D3.js visualizations, Reuters Graphics', - lighting: 'Flat graphic illumination, data-first clarity, no atmospheric effects.', + reference: + 'New York Times data journalism, Flourish, D3.js visualizations, Reuters Graphics', + lighting: + 'Flat graphic illumination, data-first clarity, no atmospheric effects.', lens: 'Orthographic or minimal perspective, focus on data readability and visual hierarchy.', - color: 'Sequential and diverging color scales (viridis, plasma), accessible color-blind-safe palettes.', - texture: 'Ultra-clean SVG precision, smooth gradients in charts, minimal grid lines, data-ink ratio optimized.', + color: + 'Sequential and diverging color scales (viridis, plasma), accessible color-blind-safe palettes.', + texture: + 'Ultra-clean SVG precision, smooth gradients in charts, minimal grid lines, data-ink ratio optimized.', }, // === Ek Retro & Nostaljik === VINTAGE_FILM: { - reference: 'Super 8 home movies, 1960s-70s amateur filmmaking, Kodachrome slides', - lighting: 'Overexposed daylight with lens flare, warm sunlight washing out highlights.', + reference: + 'Super 8 home movies, 1960s-70s amateur filmmaking, Kodachrome slides', + lighting: + 'Overexposed daylight with lens flare, warm sunlight washing out highlights.', lens: 'Vintage Super 8 lens with soft focus and vignette, slight zoom wobble.', - color: 'Faded Kodachrome warm tones, yellowed highlights, shifted reds toward orange.', - texture: 'Heavy film grain, light leaks, sprocket hole marks, dust and scratches, frame jitter.', + color: + 'Faded Kodachrome warm tones, yellowed highlights, shifted reds toward orange.', + texture: + 'Heavy film grain, light leaks, sprocket hole marks, dust and scratches, frame jitter.', }, VHS: { - reference: '1980s-90s VHS home recordings, retro TV aesthetic, analog glitch art', - lighting: 'CRT television glow, slightly blown-out highlights, low dynamic range.', + reference: + '1980s-90s VHS home recordings, retro TV aesthetic, analog glitch art', + lighting: + 'CRT television glow, slightly blown-out highlights, low dynamic range.', lens: 'Consumer camcorder wide angle, auto-focus hunting, slight barrel distortion.', - color: 'Washed-out blues and reds, bleeding color channels, oversaturated skin tones.', - texture: 'VHS tracking lines, horizontal noise bands, tape dropout artifacts, CRT scanlines, timecode overlay.', + color: + 'Washed-out blues and reds, bleeding color channels, oversaturated skin tones.', + texture: + 'VHS tracking lines, horizontal noise bands, tape dropout artifacts, CRT scanlines, timecode overlay.', }, POLAROID: { - reference: 'Instant film photography — Polaroid SX-70, Fujifilm Instax, analog snap aesthetic', - lighting: 'Flash-heavy with harsh direct flash shadows, or warm window light for lifestyle shots.', + reference: + 'Instant film photography — Polaroid SX-70, Fujifilm Instax, analog snap aesthetic', + lighting: + 'Flash-heavy with harsh direct flash shadows, or warm window light for lifestyle shots.', lens: 'Fixed focal length instant camera lens, moderate depth of field, square or 3:4 crop.', - color: 'Characteristic Polaroid color shift — green shadows, warm creamy highlights, soft pastels.', - texture: 'Instant film border frame, slightly soft focus, chemical development artifacts, white border.', + color: + 'Characteristic Polaroid color shift — green shadows, warm creamy highlights, soft pastels.', + texture: + 'Instant film border frame, slightly soft focus, chemical development artifacts, white border.', }, RETRO_90S: { - reference: 'Y2K aesthetic, early internet, Windows 95, rave culture, Saved by the Bell', - lighting: 'Colorful gelled lights, UV blacklight, early digital camera flash.', + reference: + 'Y2K aesthetic, early internet, Windows 95, rave culture, Saved by the Bell', + lighting: + 'Colorful gelled lights, UV blacklight, early digital camera flash.', lens: 'Point-and-shoot digital camera look, red-eye flash, auto-everything.', - color: 'Y2K palette — iridescent, holographic, lime green, hot pink, electric blue, chrome.', - texture: 'Early JPEG compression artifacts, low-res pixelation, dial-up era digital, bubble fonts.', + color: + 'Y2K palette — iridescent, holographic, lime green, hot pink, electric blue, chrome.', + texture: + 'Early JPEG compression artifacts, low-res pixelation, dial-up era digital, bubble fonts.', }, // === Ek Sanat Akımları === WATERCOLOR: { - reference: 'Traditional watercolor illustration, botanical art, childrens book illustration', - lighting: 'Soft diffused natural light suggesting form through color temperature shifts.', + reference: + 'Traditional watercolor illustration, botanical art, childrens book illustration', + lighting: + 'Soft diffused natural light suggesting form through color temperature shifts.', lens: 'Flat illustration composition, no perspective distortion, art print framing.', - color: 'Transparent layered washes, wet-on-wet bleeding edges, limited palette with visible mixing.', - texture: 'Visible watercolor paper grain (cold-pressed), pigment granulation, paint blooms, white paper showing through.', + color: + 'Transparent layered washes, wet-on-wet bleeding edges, limited palette with visible mixing.', + texture: + 'Visible watercolor paper grain (cold-pressed), pigment granulation, paint blooms, white paper showing through.', }, OIL_PAINTING: { - reference: 'Classical oil painting — Rembrandt, Vermeer, John Singer Sargent, plein-air impressionism', - lighting: 'Rembrandt triangle lighting, warm candle-like illumination, dramatic tonal contrast.', + reference: + 'Classical oil painting — Rembrandt, Vermeer, John Singer Sargent, plein-air impressionism', + lighting: + 'Rembrandt triangle lighting, warm candle-like illumination, dramatic tonal contrast.', lens: 'Classical portrait or landscape composition, Renaissance perspective, golden ratio.', - color: 'Rich oil pigment colors — cadmium yellow, burnt sienna, ultramarine blue, titanium white.', - texture: 'Visible impasto brushstrokes, canvas weave texture, palette knife marks, layered glazes.', + color: + 'Rich oil pigment colors — cadmium yellow, burnt sienna, ultramarine blue, titanium white.', + texture: + 'Visible impasto brushstrokes, canvas weave texture, palette knife marks, layered glazes.', }, IMPRESSIONIST: { - reference: 'Claude Monet, Pierre-Auguste Renoir, Edgar Degas, late 19th-century plein-air painting', - lighting: 'Atmospheric outdoor light capturing specific time of day, light as the subject itself.', + reference: + 'Claude Monet, Pierre-Auguste Renoir, Edgar Degas, late 19th-century plein-air painting', + lighting: + 'Atmospheric outdoor light capturing specific time of day, light as the subject itself.', lens: 'Plein-air landscape or intimate scene framing, slightly cropped like a snapshot.', - color: 'Broken color technique — short dabs of pure pigment that blend optically, complementary vibrations.', - texture: 'Dense visible brushstrokes, comma and dash marks, scumbled passages, canvas texture beneath paint.', + color: + 'Broken color technique — short dabs of pure pigment that blend optically, complementary vibrations.', + texture: + 'Dense visible brushstrokes, comma and dash marks, scumbled passages, canvas texture beneath paint.', }, POP_ART: { - reference: 'Andy Warhol, Roy Lichtenstein, Keith Haring, Takashi Murakami', - lighting: 'Flat even lighting, no shadows, silk-screen reproduction aesthetic.', + reference: + 'Andy Warhol, Roy Lichtenstein, Keith Haring, Takashi Murakami', + lighting: + 'Flat even lighting, no shadows, silk-screen reproduction aesthetic.', lens: 'Flat graphic composition, repeated grid patterns, bold iconic framing.', - color: 'CMYK primary colors — bold red, yellow, blue, black outlines, flat color fills.', - texture: 'Ben-Day dots, silk-screen print registration, halftone patterns, bold black outlines.', + color: + 'CMYK primary colors — bold red, yellow, blue, black outlines, flat color fills.', + texture: + 'Ben-Day dots, silk-screen print registration, halftone patterns, bold black outlines.', }, UKIYO_E: { - reference: 'Hokusai (The Great Wave), Hiroshige, traditional Japanese woodblock prints', - lighting: 'Flat decorative lighting with no cast shadows, atmospheric perspective through color.', + reference: + 'Hokusai (The Great Wave), Hiroshige, traditional Japanese woodblock prints', + lighting: + 'Flat decorative lighting with no cast shadows, atmospheric perspective through color.', lens: 'Flat 2D composition with layered depth planes, floating world perspective.', - color: 'Traditional Japanese pigments — indigo, vermillion, saffron, black sumi ink, muted earth tones.', - texture: 'Woodblock print grain, visible wood grain texture in flat areas, hand-carved line quality.', + color: + 'Traditional Japanese pigments — indigo, vermillion, saffron, black sumi ink, muted earth tones.', + texture: + 'Woodblock print grain, visible wood grain texture in flat areas, hand-carved line quality.', }, ART_DECO: { - reference: '1920s-30s Art Deco — Chrysler Building, Tamara de Lempicka, The Great Gatsby', - lighting: 'Glamorous theatrical lighting with gold reflections, spotlight elegance.', + reference: + '1920s-30s Art Deco — Chrysler Building, Tamara de Lempicka, The Great Gatsby', + lighting: + 'Glamorous theatrical lighting with gold reflections, spotlight elegance.', lens: 'Symmetrical architectural framing, towering vertical compositions, geometric precision.', - color: 'Gold, black, silver, deep emerald, sapphire blue, ivory — luxurious metallic palette.', - texture: 'Geometric patterns, sunburst rays, chevrons, stepped forms, chrome and glass surfaces.', + color: + 'Gold, black, silver, deep emerald, sapphire blue, ivory — luxurious metallic palette.', + texture: + 'Geometric patterns, sunburst rays, chevrons, stepped forms, chrome and glass surfaces.', }, COMIC_BOOK: { - reference: 'Marvel Comics (Jack Kirby), DC Comics, Manga (Akira, Dragon Ball)', - lighting: 'Dynamic action lighting with speed lines, explosive rim lights, dramatic chiaroscuro.', + reference: + 'Marvel Comics (Jack Kirby), DC Comics, Manga (Akira, Dragon Ball)', + lighting: + 'Dynamic action lighting with speed lines, explosive rim lights, dramatic chiaroscuro.', lens: 'Extreme foreshortening, dynamic action angles, upshot hero poses, panel-based framing.', - color: 'Bold four-color printing palette, flat fills with gradient shading, spot blacks.', - texture: 'Ink line art with cross-hatching, Zip-A-Tone dot patterns, action lines, impact effects.', + color: + 'Bold four-color printing palette, flat fills with gradient shading, spot blacks.', + texture: + 'Ink line art with cross-hatching, Zip-A-Tone dot patterns, action lines, impact effects.', }, SKETCH: { - reference: 'Pencil sketching, charcoal drawing, architectural rendering, fashion illustration', - lighting: 'Implied through hatching density and paper-white highlights, no color-based lighting.', + reference: + 'Pencil sketching, charcoal drawing, architectural rendering, fashion illustration', + lighting: + 'Implied through hatching density and paper-white highlights, no color-based lighting.', lens: 'Sketchbook page composition, slightly off-center, intimate and personal framing.', - color: 'Monochrome graphite gray scale, or limited sepia/sanguine warm tones, white paper dominant.', - texture: 'Visible pencil strokes, cross-hatching, smudged graphite, eraser marks, paper tooth texture.', + color: + 'Monochrome graphite gray scale, or limited sepia/sanguine warm tones, white paper dominant.', + texture: + 'Visible pencil strokes, cross-hatching, smudged graphite, eraser marks, paper tooth texture.', }, // === Ek Modern & Minimal === GLASSMORPHISM: { - reference: 'Apple iOS frosted glass, modern UI design, translucent material design', - lighting: 'Soft diffused backlight through frosted glass, colorful blurred background gradients.', + reference: + 'Apple iOS frosted glass, modern UI design, translucent material design', + lighting: + 'Soft diffused backlight through frosted glass, colorful blurred background gradients.', lens: 'Clean UI-like framing, centered elements, clear visual hierarchy.', - color: 'Frosted semi-transparent whites and pastels over vibrant gradient backgrounds.', - texture: 'Frosted glass blur, subtle border glow, drop shadows, backdrop-filter blur effect.', + color: + 'Frosted semi-transparent whites and pastels over vibrant gradient backgrounds.', + texture: + 'Frosted glass blur, subtle border glow, drop shadows, backdrop-filter blur effect.', }, NEON: { - reference: 'Tokyo Shinjuku at night, Las Vegas strip, neon sign art, luminous night photography', - lighting: 'Multiple neon tube light sources casting colored glows, reflections on wet surfaces and glass.', + reference: + 'Tokyo Shinjuku at night, Las Vegas strip, neon sign art, luminous night photography', + lighting: + 'Multiple neon tube light sources casting colored glows, reflections on wet surfaces and glass.', lens: 'Night photography with wide aperture f/1.4, bokeh circles from background neon, slight motion blur.', - color: 'Electric neon palette — hot pink, electric blue, vivid green, purple, against deep black.', - texture: 'Wet street reflections, glass and chrome reflections, light bloom around neon tubes, night grain.', + color: + 'Electric neon palette — hot pink, electric blue, vivid green, purple, against deep black.', + texture: + 'Wet street reflections, glass and chrome reflections, light bloom around neon tubes, night grain.', }, CYBERPUNK: { - reference: 'Cyberpunk 2077, Ghost in the Shell, Akira, Blade Runner, William Gibson', - lighting: 'Neon-lit rain-soaked scenes, holographic projections, LED screens casting colored light.', + reference: + 'Cyberpunk 2077, Ghost in the Shell, Akira, Blade Runner, William Gibson', + lighting: + 'Neon-lit rain-soaked scenes, holographic projections, LED screens casting colored light.', lens: 'Wide angle capturing dense urban environments, low angle looking up at megastructures.', - color: 'Toxic neon green, deep magenta, cyan HUD blue, Chrome silver, against polluted dark skies.', - texture: 'Rain droplets on lens, holographic glitch artifacts, digital noise, chrome and wet surfaces.', + color: + 'Toxic neon green, deep magenta, cyan HUD blue, Chrome silver, against polluted dark skies.', + texture: + 'Rain droplets on lens, holographic glitch artifacts, digital noise, chrome and wet surfaces.', }, STEAMPUNK: { - reference: 'Victorian-era mechanical fantasy, Jules Verne, H.G. Wells, Bioshock Infinite', - lighting: 'Warm gas lamp and candle light, brass reflections, furnace glow, London fog diffusion.', + reference: + 'Victorian-era mechanical fantasy, Jules Verne, H.G. Wells, Bioshock Infinite', + lighting: + 'Warm gas lamp and candle light, brass reflections, furnace glow, London fog diffusion.', lens: 'Period-appropriate framing with brass vignette edges, medium shots of intricate machinery.', - color: 'Brass, copper, aged leather brown, dark mahogany, forest green, ivory, sepia warmth.', - texture: 'Victorian ornate metalwork, riveted brass plates, leather straps, exposed gears, steam clouds.', + color: + 'Brass, copper, aged leather brown, dark mahogany, forest green, ivory, sepia warmth.', + texture: + 'Victorian ornate metalwork, riveted brass plates, leather straps, exposed gears, steam clouds.', }, ABSTRACT: { - reference: 'Kandinsky, Mondrian, Jackson Pollock, Rothko, generative art, Processing/p5.js', - lighting: 'Non-representational — light as pure color fields, no physical light source.', + reference: + 'Kandinsky, Mondrian, Jackson Pollock, Rothko, generative art, Processing/p5.js', + lighting: + 'Non-representational — light as pure color fields, no physical light source.', lens: 'No traditional perspective, flat or infinite depth, purely compositional framing.', - color: 'Pure color theory exploration — complementary, analogous, triadic harmonies, bold saturation.', - texture: 'Paint splatter, geometric precision, generative algorithmic patterns, or pure smooth gradients.', + color: + 'Pure color theory exploration — complementary, analogous, triadic harmonies, bold saturation.', + texture: + 'Paint splatter, geometric precision, generative algorithmic patterns, or pure smooth gradients.', }, // === Fotoğrafik === PRODUCT: { - reference: 'Apple product photography, premium e-commerce, studio packshot lighting', - lighting: 'Multi-light studio setup — key light from 45°, fill from opposite, rim light for edge separation, white seamless background.', + reference: + 'Apple product photography, premium e-commerce, studio packshot lighting', + lighting: + 'Multi-light studio setup — key light from 45°, fill from opposite, rim light for edge separation, white seamless background.', lens: 'Macro to medium shot, 100mm macro lens, f/8 deep focus on product, clean background.', - color: 'Neutral white or gradient background, products true colors faithfully rendered, no color cast.', - texture: 'Ultra-sharp material details, visible surface finishes (brushed metal, glass clarity, fabric weave), pristine.', + color: + 'Neutral white or gradient background, products true colors faithfully rendered, no color cast.', + texture: + 'Ultra-sharp material details, visible surface finishes (brushed metal, glass clarity, fabric weave), pristine.', }, FASHION: { - reference: 'Vogue editorial, Annie Leibovitz portraits, high-fashion runway photography', - lighting: 'Dramatic fashion lighting — beauty dish from above, clamshell setup, or single Profoto strobe with modifier.', + reference: + 'Vogue editorial, Annie Leibovitz portraits, high-fashion runway photography', + lighting: + 'Dramatic fashion lighting — beauty dish from above, clamshell setup, or single Profoto strobe with modifier.', lens: '85mm portrait lens with creamy f/1.8 bokeh, or 35mm editorial wide for environmental fashion.', - color: 'High-fashion color grading — lifted blacks, color-tinted shadows, editorial mood-specific palettes.', - texture: 'Fabric texture detail visible, skin retouching (frequency separation look), magazine-print quality.', + color: + 'High-fashion color grading — lifted blacks, color-tinted shadows, editorial mood-specific palettes.', + texture: + 'Fabric texture detail visible, skin retouching (frequency separation look), magazine-print quality.', }, AERIAL: { - reference: 'Drone photography, DJI Mavic, National Geographic aerial, Google Earth perspective', - lighting: 'Natural sunlight from above with long shadows (golden hour ideal), atmospheric haze in distance.', + reference: + 'Drone photography, DJI Mavic, National Geographic aerial, Google Earth perspective', + lighting: + 'Natural sunlight from above with long shadows (golden hour ideal), atmospheric haze in distance.', lens: 'Wide angle drone camera (24mm equivalent), deep focus f/5.6, straight-down or 45-degree angle.', - color: 'Vivid natural landscape colors enhanced — deep greens, turquoise water, golden sand, earth tones.', - texture: 'Ultra-sharp aerial detail, visible terrain texture, atmospheric perspective fading distant objects.', + color: + 'Vivid natural landscape colors enhanced — deep greens, turquoise water, golden sand, earth tones.', + texture: + 'Ultra-sharp aerial detail, visible terrain texture, atmospheric perspective fading distant objects.', }, MACRO: { - reference: 'Extreme close-up nature photography, Nikon Small World, scientific imaging', - lighting: 'Ring flash or dual macro flash for even close-up illumination, focus stacked lighting.', + reference: + 'Extreme close-up nature photography, Nikon Small World, scientific imaging', + lighting: + 'Ring flash or dual macro flash for even close-up illumination, focus stacked lighting.', lens: 'True macro 1:1 or greater magnification, 100mm macro lens, razor-thin DOF at f/2.8.', - color: 'Vivid detail colors invisible to naked eye, iridescent surfaces, micro-texture color variations.', - texture: 'Extreme detail — visible cell structures, surface micro-textures, water droplets, compound eye facets.', + color: + 'Vivid detail colors invisible to naked eye, iridescent surfaces, micro-texture color variations.', + texture: + 'Extreme detail — visible cell structures, surface micro-textures, water droplets, compound eye facets.', }, PORTRAIT: { - reference: 'Annie Leibovitz, Peter Lindbergh, classic studio portraiture, environmental portraits', - lighting: 'Rembrandt or loop lighting from 45° camera-right, reflector fill from camera-left, hair light from behind.', + reference: + 'Annie Leibovitz, Peter Lindbergh, classic studio portraiture, environmental portraits', + lighting: + 'Rembrandt or loop lighting from 45° camera-right, reflector fill from camera-left, hair light from behind.', lens: '85mm f/1.4 portrait lens with beautiful circular bokeh, or 50mm for environmental context.', - color: 'Skin-tone-faithful rendering, warm and flattering, slight warm grade on highlights.', - texture: 'Natural skin texture (not over-smoothed), catch-light in eyes, subtle background separation.', + color: + 'Skin-tone-faithful rendering, warm and flattering, slight warm grade on highlights.', + texture: + 'Natural skin texture (not over-smoothed), catch-light in eyes, subtle background separation.', }, }; @@ -1450,7 +1951,9 @@ REQUIREMENTS: const lastBrace = cleanText.lastIndexOf('}'); if (lastBrace > 0) { const truncated = cleanText.substring(0, lastBrace + 1); - this.logger.warn(`jsonrepair başarısız, JSON kesilmiş olabilir. Kurtarma deneniyor...`); + this.logger.warn( + `jsonrepair başarısız, JSON kesilmiş olabilir. Kurtarma deneniyor...`, + ); const repairedTruncated = jsonrepair(truncated); parsed = JSON.parse(repairedTruncated); } else { @@ -1458,15 +1961,21 @@ REQUIREMENTS: } } } catch (parseError) { - this.logger.error(`JSON parse hatası — İlk 1000 karakter:\n${rawText.substring(0, 1000)}`); - this.logger.error(`Parse error: ${parseError instanceof Error ? parseError.message : parseError}`); + this.logger.error( + `JSON parse hatası — İlk 1000 karakter:\n${rawText.substring(0, 1000)}`, + ); + this.logger.error( + `Parse error: ${parseError instanceof Error ? parseError.message : parseError}`, + ); throw new InternalServerErrorException( 'AI yanıtı geçerli JSON formatında değil. Lütfen "Yeniden Üret" butonuyla tekrar deneyin.', ); } if (!parsed.metadata || !parsed.scenes || !Array.isArray(parsed.scenes)) { - throw new InternalServerErrorException('AI yanıtı beklenen yapıda değil.'); + throw new InternalServerErrorException( + 'AI yanıtı beklenen yapıda değil.', + ); } if (parsed.scenes.length < 2) { @@ -1479,13 +1988,15 @@ REQUIREMENTS: `Sahne ${scene.order}: narrationText ve visualPrompt zorunludur.`, ); } - if (!scene.durationSeconds || scene.durationSeconds < 1) scene.durationSeconds = 5; + if (!scene.durationSeconds || scene.durationSeconds < 1) + scene.durationSeconds = 5; if (!scene.subtitleText) scene.subtitleText = scene.narrationText; if (!scene.transitionType) scene.transitionType = 'CUT'; } if (!parsed.musicPrompt) { - parsed.musicPrompt = 'Cinematic orchestral, mysterious, 80 BPM, minor key, strings and piano, slow ethereal build'; + parsed.musicPrompt = + 'Cinematic orchestral, mysterious, 80 BPM, minor key, strings and piano, slow ethereal build'; } if (!parsed.musicStyle) { parsed.musicStyle = 'cinematic-orchestral'; @@ -1502,7 +2013,8 @@ REQUIREMENTS: parsed.ambientSoundPrompts = []; } if (!parsed.voiceStyle) { - parsed.voiceStyle = 'Deep, authoritative male voice, warm tone, measured pacing for data, slight dramatic pauses for reveals'; + parsed.voiceStyle = + 'Deep, authoritative male voice, warm tone, measured pacing for data, slight dramatic pauses for reveals'; } return parsed; @@ -1521,7 +2033,8 @@ REQUIREMENTS: isHookScene: boolean = false, ): string { const styleDNA = this.getStyleDNA(videoStyle, cinematicReference); - const defaultNegative = 'Avoid: text overlays, watermarks, brand logos, recognizable celebrity faces, distorted anatomy, extra fingers, blurry faces, stock photo aesthetic, oversaturated CGI plastic look, generic clip art, UI elements'; + const defaultNegative = + 'Avoid: text overlays, watermarks, brand logos, recognizable celebrity faces, distorted anatomy, extra fingers, blurry faces, stock photo aesthetic, oversaturated CGI plastic look, generic clip art, UI elements'; let vp = visualPrompt; const wordCount = vp.split(/\s+/).length; const minWords = isHookScene ? 80 : 50; @@ -1533,19 +2046,27 @@ REQUIREMENTS: // 2. Visual continuity anchor // Önceki prefix'i temizle ki eski stiller yapışıp kalmasın (örn: Blade Runner) - vp = vp.replace(/^Continuing the .*? visual language (established in previous scenes|from previous scenes)?:\s*/i, ''); - + vp = vp.replace( + /^Continuing the .*? visual language (established in previous scenes|from previous scenes)?:\s*/i, + '', + ); + if (!isHookScene) { vp = `Continuing the ${styleDNA.reference} visual language established in previous scenes: ${vp}`; } // 3. Aspect ratio hint - if (aspectRatio && !vp.toLowerCase().includes('framing') && !vp.toLowerCase().includes('composition')) { - const arHint = aspectRatio === 'PORTRAIT_9_16' - ? 'Vertical framing optimized for mobile viewing.' - : aspectRatio === 'LANDSCAPE_16_9' - ? 'Wide cinematic horizontal composition.' - : 'Square centered symmetrical framing.'; + if ( + aspectRatio && + !vp.toLowerCase().includes('framing') && + !vp.toLowerCase().includes('composition') + ) { + const arHint = + aspectRatio === 'PORTRAIT_9_16' + ? 'Vertical framing optimized for mobile viewing.' + : aspectRatio === 'LANDSCAPE_16_9' + ? 'Wide cinematic horizontal composition.' + : 'Square centered symmetrical framing.'; vp = `${vp} ${arHint}`; } @@ -1567,7 +2088,9 @@ REQUIREMENTS: durationSeconds: number; }> { if (!this.genAI) { - throw new InternalServerErrorException('AI servisi etkin değil — Google API Key gerekli.'); + throw new InternalServerErrorException( + 'AI servisi etkin değil — Google API Key gerekli.', + ); } try { @@ -1582,7 +2105,10 @@ REQUIREMENTS: }); const rawText = response.text || ''; - const cleaned = rawText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + const cleaned = rawText + .replace(/```json\n?/g, '') + .replace(/```\n?/g, '') + .trim(); const parsed = JSON.parse(cleaned); return { @@ -1602,7 +2128,12 @@ REQUIREMENTS: * Narration ve ID'leri korur, sadece görsel betimlemeleri AI ile baştan yazar. */ async rewriteAllVisualPrompts( - scenes: { id: string; order: number; narrationText: string; visualPrompt: string }[], + scenes: { + id: string; + order: number; + narrationText: string; + visualPrompt: string; + }[], videoStyle: string, cinematicReference?: string, aspectRatio?: string, @@ -1666,7 +2197,10 @@ OUTPUT FORMAT (JSON ONLY, NO MARKDOWN FENCES): }); const rawText = response.text || ''; - const cleaned = rawText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + const cleaned = rawText + .replace(/```json\n?/g, '') + .replace(/```\n?/g, '') + .trim(); const parsed = JSON.parse(cleaned); if (!parsed.scenes || !Array.isArray(parsed.scenes)) { diff --git a/src/modules/video-queue/video-generation.producer.ts b/src/modules/video-queue/video-generation.producer.ts index 633cd65..c12cc0d 100644 --- a/src/modules/video-queue/video-generation.producer.ts +++ b/src/modules/video-queue/video-generation.producer.ts @@ -51,18 +51,16 @@ export class VideoGenerationProducer { * BullMQ: NestJS tarafında lifecycle tracking * Redis List: C# Worker BRPOP ile consume eder */ - async addVideoGenerationJob(payload: VideoGenerationJobPayload): Promise { - const bullJob = await this.videoQueue.add( - 'generate-video', - payload, - { - attempts: 3, - backoff: { type: 'exponential', delay: 5000 }, - removeOnComplete: { count: 100, age: 7 * 24 * 3600 }, - removeOnFail: { count: 50 }, - priority: 1, - }, - ); + async addVideoGenerationJob( + payload: VideoGenerationJobPayload, + ): Promise { + const bullJob = await this.videoQueue.add('generate-video', payload, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: { count: 100, age: 7 * 24 * 3600 }, + removeOnFail: { count: 50 }, + priority: 1, + }); const workerPayload = JSON.stringify({ jobId: bullJob.id, diff --git a/src/modules/x-twitter/dto/x-twitter.dto.ts b/src/modules/x-twitter/dto/x-twitter.dto.ts index 8cc4a08..555ded7 100644 --- a/src/modules/x-twitter/dto/x-twitter.dto.ts +++ b/src/modules/x-twitter/dto/x-twitter.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, Matches } from 'class-validator'; /** @@ -10,14 +10,14 @@ import { IsString, IsNotEmpty, Matches } from 'class-validator'; */ export class FetchTweetDto { @ApiProperty({ - description: 'X/Twitter tweet URL\'si', + description: "X/Twitter tweet URL'si", example: 'https://x.com/elonmusk/status/1893456789012345678', }) @IsString() - @IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' }) + @IsNotEmpty({ message: "Tweet URL'si boş olamaz" }) @Matches( /^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/, - { message: 'Geçerli bir X/Twitter tweet URL\'si girin' }, + { message: "Geçerli bir X/Twitter tweet URL'si girin" }, ) tweetUrl: string; } diff --git a/src/modules/x-twitter/x-twitter.service.ts b/src/modules/x-twitter/x-twitter.service.ts index b866ea9..29703a9 100644 --- a/src/modules/x-twitter/x-twitter.service.ts +++ b/src/modules/x-twitter/x-twitter.service.ts @@ -71,7 +71,7 @@ export class XTwitterService { const parsed = this.parseFxTweet(response.tweet); // Thread tespiti ve toplama - const thread = await this.collectThread(parsed, username); + const thread = this.collectThread(parsed, username); if (thread.length > 1) { parsed.isThread = true; parsed.threadTweets = thread; @@ -103,7 +103,7 @@ export class XTwitterService { : tweet.text; const wordCount = totalText.split(/\s+/).length; const estimatedDuration = Math.min( - Math.max(Math.ceil((wordCount / 2.5) + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma + Math.max(Math.ceil(wordCount / 2.5 + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma 90, // Max 90sn ); @@ -182,10 +182,10 @@ export class XTwitterService { * Thread tweet'lerini toplar. * FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et. */ - private async collectThread( + private collectThread( rootTweet: ParsedTweet, - username: string, - ): Promise { + _username: string, + ): ParsedTweet[] { const threadTweets: ParsedTweet[] = [rootTweet]; // Tweet'in reply olup olmadığını kontrol et @@ -196,7 +196,9 @@ export class XTwitterService { // İleride Xquik thread_extractor ile genişletilebilir // Şu an: Eğer tweet uzunsa (280+ karakter) ve satır sonları varsa, thread benzeri - const lines = rootTweet.text.split('\n').filter((l) => l.trim().length > 0); + const lines = rootTweet.text + .split('\n') + .filter((l) => l.trim().length > 0); if (lines.length >= 3) { // Uzun tek tweet — thread gibi ele alınabilir return threadTweets; @@ -214,7 +216,9 @@ export class XTwitterService { /** * FXTwitter API response'unu ParsedTweet'e dönüştürür. */ - private parseFxTweet(raw: NonNullable): ParsedTweet { + private parseFxTweet( + raw: NonNullable, + ): ParsedTweet { const views = raw.views || 1; const engagement = raw.likes + raw.retweets + raw.replies; @@ -236,7 +240,8 @@ export class XTwitterService { retweets: raw.retweets, likes: raw.likes, views, - engagementRate: views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0, + engagementRate: + views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0, }, media: (raw.media?.all || []).map((m) => ({ type: m.type, @@ -261,7 +266,7 @@ export class XTwitterService { * Engagement rate, takipçi oranı ve toplam etkileşim bazlı. */ private calculateViralScore(tweet: ParsedTweet): number { - const { metrics, author } = tweet; + const { metrics } = tweet; let score = 0; // Engagement rate katkısı (max 40 puan) @@ -340,7 +345,9 @@ export class XTwitterService { if (res.status === 429) { const retryAfter = res.headers.get('Retry-After'); const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000; - this.logger.warn(`Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`); + this.logger.warn( + `Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`, + ); await this.sleep(delay); continue; } diff --git a/test-roles.ts b/test-roles.ts new file mode 100644 index 0000000..b24e20d --- /dev/null +++ b/test-roles.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +async function main() { + const admin = await prisma.user.findFirst({ + where: { email: 'admin@contentgen.ai' }, + include: { roles: { include: { role: true } } } + }); + console.log('admin roles:', JSON.stringify(admin?.roles, null, 2)); +} +main().catch(console.error).finally(() => prisma.$disconnect()); diff --git a/test-users.ts b/test-users.ts new file mode 100644 index 0000000..d03db4b --- /dev/null +++ b/test-users.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +async function main() { + const admin = await prisma.user.findFirst({ where: { email: 'admin@contentgen.ai' } }); + console.log('admin record:', admin); +} +main().catch(console.error).finally(() => prisma.$disconnect());