generated from fahricansecer/boilerplate-be
@@ -253,9 +253,14 @@ export class AuthService {
|
||||
tenantId: user.tenantId || undefined,
|
||||
};
|
||||
|
||||
const isAdmin = roles.includes('admin');
|
||||
const accessExpiration = isAdmin
|
||||
? '7d'
|
||||
: this.configService.get('JWT_ACCESS_EXPIRATION', '15m');
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
expiresIn: accessExpiration as any,
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
@@ -276,10 +281,7 @@ export class AuthService {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn:
|
||||
this.parseExpiration(
|
||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
) / 1000, // Convert to seconds
|
||||
expiresIn: this.parseExpiration(accessExpiration) / 1000, // Convert to seconds
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -279,11 +279,19 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
try {
|
||||
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
||||
const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt);
|
||||
if (result) {
|
||||
if (result && result.buffer.length > 0) {
|
||||
this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||
return result;
|
||||
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||
}
|
||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (null response)`);
|
||||
|
||||
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.`);
|
||||
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)}`);
|
||||
@@ -295,11 +303,15 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
try {
|
||||
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
||||
const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt);
|
||||
if (result) {
|
||||
if (result && result.buffer.length > 0) {
|
||||
this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||
return result;
|
||||
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 (null response)`);
|
||||
} catch (err2: any) {
|
||||
this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`);
|
||||
}
|
||||
@@ -323,7 +335,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
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');
|
||||
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)}`);
|
||||
}
|
||||
@@ -343,7 +355,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
private async tryGenerateContentImage(
|
||||
model: string,
|
||||
prompt: string,
|
||||
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
||||
): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> {
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents: prompt,
|
||||
@@ -358,7 +370,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
||||
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
||||
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
|
||||
return null;
|
||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
|
||||
}
|
||||
|
||||
const imagePart = candidate.content.parts.find(
|
||||
@@ -375,9 +387,10 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
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' };
|
||||
}
|
||||
|
||||
return null;
|
||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'NO_IMAGE_DATA' };
|
||||
}
|
||||
|
||||
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
||||
|
||||
@@ -214,7 +214,7 @@ export class CreateFromTweetDto {
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@Max(90)
|
||||
@Max(180)
|
||||
targetDuration?: number;
|
||||
}
|
||||
|
||||
@@ -261,11 +261,17 @@ export class CreateFromYoutubeDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@Max(90)
|
||||
@Max(180)
|
||||
targetDuration?: number;
|
||||
}
|
||||
|
||||
@@ -300,10 +306,70 @@ export class CreateFromDocumentDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@Max(90)
|
||||
@Max(180)
|
||||
targetDuration?: number;
|
||||
}
|
||||
|
||||
export class CreateFromExtractedTextDto {
|
||||
@ApiProperty({ description: 'Çıkarılan metin' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
text: string;
|
||||
|
||||
@ApiProperty({ description: 'Seçilen video konusu' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
topic: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Orijinal dosya adı' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
originalFilename?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Video dili (ISO 639-1)', default: 'tr' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(5)
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'En-boy oranı (PORTRAIT_9_16, LANDSCAPE_16_9, SQUARE_1_1)',
|
||||
default: 'PORTRAIT_9_16',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
aspectRatio?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, vb.)',
|
||||
default: 'CINEMATIC',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@Max(180)
|
||||
targetDuration?: number;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto';
|
||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
|
||||
|
||||
@ApiTags('projects')
|
||||
@ApiBearerAuth()
|
||||
@@ -191,6 +191,39 @@ export class ProjectsController {
|
||||
return this.projectsService.createFromDocument(userId, file, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doküman yüklenip metni çıkarılır ve video konu önerileri üretilir.
|
||||
*/
|
||||
@Post('extract-document-topics')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@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}`);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Dosya yüklenmedi');
|
||||
}
|
||||
return this.projectsService.extractDocumentTopics(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracted text ve seçilen konu üzerinden doğrudan proje oluşturur.
|
||||
*/
|
||||
@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) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`);
|
||||
return this.projectsService.createFromExtractedText(userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { TransitionType } from '@prisma/client';
|
||||
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';
|
||||
@@ -13,9 +13,11 @@ import { XTwitterService } from '../x-twitter/x-twitter.service';
|
||||
import { GeminiService } from '../gemini/gemini.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ExtractorService } from '../extractor/extractor.service';
|
||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto';
|
||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
|
||||
import sharp from 'sharp';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
interface FindAllOptions {
|
||||
page: number;
|
||||
@@ -360,15 +362,21 @@ export class ProjectsService {
|
||||
aspectRatio: project.aspectRatio,
|
||||
videoStyle: project.videoStyle,
|
||||
targetDuration: project.targetDuration,
|
||||
scenes: project.scenes.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
narrationText: s.narrationText,
|
||||
visualPrompt: s.visualPrompt,
|
||||
subtitleText: s.subtitleText || s.narrationText,
|
||||
duration: s.duration,
|
||||
transitionType: s.transitionType,
|
||||
})),
|
||||
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;
|
||||
return {
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
narrationText: s.narrationText,
|
||||
visualPrompt: s.visualPrompt,
|
||||
subtitleText: s.subtitleText || s.narrationText,
|
||||
duration: s.duration,
|
||||
transitionType: s.transitionType,
|
||||
imagePath,
|
||||
ttsProvider: 'openai', // TODO: Make configurable from frontend or project settings
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await this.db.renderJob.update({
|
||||
@@ -622,31 +630,87 @@ 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}`);
|
||||
|
||||
let tempFilePath: string | null = null;
|
||||
let extractedText = '';
|
||||
|
||||
try {
|
||||
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, '_')}`);
|
||||
await fs.writeFile(tempFilePath, file.buffer);
|
||||
} else {
|
||||
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||
}
|
||||
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!extractedText || extractedText.trim().length === 0) {
|
||||
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"];
|
||||
} else {
|
||||
topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4);
|
||||
}
|
||||
|
||||
return {
|
||||
text: extractedText,
|
||||
topics,
|
||||
originalFilename: file.originalname
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF, Word vb. dokümandan proje oluşturur.
|
||||
*/
|
||||
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
|
||||
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
|
||||
|
||||
// Gelen dosyanın geçici path'i
|
||||
// Not: multer ile yüklendiğinde `file.path` üzerinden geçici dosya adresini alabiliyoruz
|
||||
if (!file.path) {
|
||||
// Eğer memoryStorage kullanılıyorsa temp dizine yazılarak paslanabilir,
|
||||
// bu örnekte form-data ile Python extractor'a gönderileceği varsayılıyor
|
||||
throw new Error("Multer destPath bulunamadı, diskStorage kullanılmalıdır.");
|
||||
}
|
||||
let tempFilePath: string | null = null;
|
||||
let extractedText = '';
|
||||
|
||||
const extractedText = await this.extractorService.extractFromFile(file.path, file.originalname, file.mimetype);
|
||||
try {
|
||||
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, '_')}`);
|
||||
await fs.writeFile(tempFilePath, file.buffer);
|
||||
} else {
|
||||
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||
}
|
||||
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Başlık ve prompt belirlenmesi
|
||||
const title = dto.title || `${file.originalname} Özeti`;
|
||||
const prompt = `Aşağıda içeriği verilen dökümandan çarpıcı bir video senaryosu üret:\n\n${extractedText.substring(0, 15000)}`;
|
||||
const fullAiPrompt = `Aşağıda içeriği verilen dökümandan çarpıcı bir video senaryosu üret:\n\n${extractedText.substring(0, 15000)}`;
|
||||
const shortDbPrompt = `Belge üzerinden oluşturuldu: ${file.originalname}`;
|
||||
|
||||
const project = await this.db.project.create({
|
||||
data: {
|
||||
title,
|
||||
description: `Belge üzerinden üretildi: ${file.originalname}`,
|
||||
prompt,
|
||||
prompt: shortDbPrompt,
|
||||
language: dto.language || 'tr',
|
||||
aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16',
|
||||
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||
@@ -659,7 +723,7 @@ export class ProjectsService {
|
||||
|
||||
try {
|
||||
const scriptJson = await this.videoAiService.generateVideoScript({
|
||||
topic: prompt,
|
||||
topic: fullAiPrompt,
|
||||
targetDurationSeconds: project.targetDuration,
|
||||
language: project.language,
|
||||
videoStyle: project.videoStyle,
|
||||
@@ -704,6 +768,82 @@ 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}`);
|
||||
|
||||
const title = dto.topic;
|
||||
// Tam prompt metni (AI'a gönderilecek)
|
||||
const fullAiPrompt = `Aşağıda içeriği verilen metinden, özellikle "${dto.topic}" konusuna odaklanan çarpıcı bir video senaryosu üret:\n\n${dto.text.substring(0, 15000)}`;
|
||||
// Veritabanına kaydedilecek kısa prompt metni (VarChar 2000 limitine takılmaması için)
|
||||
const shortDbPrompt = `Belge/Metin üzerinden "${dto.topic}" konusu hedeflenerek oluşturuldu.`;
|
||||
|
||||
const project = await this.db.project.create({
|
||||
data: {
|
||||
title,
|
||||
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,
|
||||
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||
cinematicReference: dto.cinematicReference,
|
||||
targetDuration: dto.targetDuration || 60,
|
||||
status: 'GENERATING_SCRIPT',
|
||||
userId,
|
||||
sourceType: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const scriptJson = await this.videoAiService.generateVideoScript({
|
||||
topic: fullAiPrompt,
|
||||
targetDurationSeconds: project.targetDuration,
|
||||
language: project.language,
|
||||
videoStyle: project.videoStyle,
|
||||
cinematicReference: project.cinematicReference ?? undefined,
|
||||
});
|
||||
|
||||
const scenesData = scriptJson.scenes.map((scene: any) => ({
|
||||
projectId: project.id,
|
||||
order: scene.order,
|
||||
title: scene.title || `Sahne ${scene.order}`,
|
||||
narrationText: scene.narrationText,
|
||||
visualPrompt: scene.visualPrompt,
|
||||
subtitleText: scene.subtitleText,
|
||||
duration: scene.durationSeconds,
|
||||
transitionType: this.mapTransitionType(scene.transitionType),
|
||||
}));
|
||||
|
||||
await this.db.scene.createMany({ data: scenesData });
|
||||
|
||||
const updatedProject = await this.db.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
scriptJson: scriptJson as object,
|
||||
status: 'DRAFT',
|
||||
errorMessage: null,
|
||||
scriptVersion: 1,
|
||||
},
|
||||
include: {
|
||||
scenes: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return updatedProject;
|
||||
} catch (error) {
|
||||
await this.db.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
status: 'DRAFT',
|
||||
errorMessage: error instanceof Error ? error.message : 'Konu bazlı senaryo üretimi sırasında hata',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
|
||||
*/
|
||||
@@ -856,13 +996,19 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
const styleLabel = cinematicRef
|
||||
? `Style: ${project.videoStyle}, Cinematic reference: ${cinematicRef}`
|
||||
: `Style: ${project.videoStyle}`;
|
||||
const imageResult = await this.geminiService.generateImage(
|
||||
let imageResult = await this.geminiService.generateImage(
|
||||
`${scene.visualPrompt}. ${styleLabel}`,
|
||||
mappedRatio,
|
||||
);
|
||||
|
||||
if (!imageResult) {
|
||||
throw new BadRequestException('Görsel üretilemedi, servis yanıt vermedi');
|
||||
this.logger.warn(`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenli fallback deneniyor...`);
|
||||
const safePrompt = `A cinematic, highly detailed abstract visualization matching the mood of: ${project.videoStyle}. Ensure professional quality, 8k resolution. Do not include specific people, recognizable faces, or real-world public figures.`;
|
||||
imageResult = await this.geminiService.generateImage(safePrompt, mappedRatio);
|
||||
}
|
||||
|
||||
if (!imageResult) {
|
||||
throw new BadRequestException('Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.');
|
||||
}
|
||||
|
||||
// Storage'a kaydet
|
||||
|
||||
@@ -298,11 +298,11 @@ This is CRITICAL. All scenes in one project must feel like they belong to the sa
|
||||
Match the "videoStyle" to its corresponding visual DNA. These are your default creative parameters per style:
|
||||
|
||||
CINEMATIC:
|
||||
Reference: Denis Villeneuve, Roger Deakins cinematography, Christopher Nolan IMAX
|
||||
Reference: High-end cinematic production with professional cinematography techniques
|
||||
Lighting: Dramatic key-and-fill, single strong motivated source, deep shadows
|
||||
Lens: 35mm anamorphic or 65mm IMAX, shallow DOF
|
||||
Color: Teal-orange grade, desaturated midtones, crushed blacks
|
||||
Texture: Film grain, anamorphic lens flare, subtle vignette
|
||||
Color: Professional cinematic color grading, balanced contrast, atmospheric depth
|
||||
Texture: Subtle organic film grain, anamorphic lens flare, cinematic light bloom
|
||||
|
||||
DOCUMENTARY:
|
||||
Reference: National Geographic, Planet Earth II, David Attenborough
|
||||
@@ -616,6 +616,51 @@ export class VideoAiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]> {
|
||||
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.
|
||||
|
||||
REQUIREMENTS:
|
||||
- Return ONLY a JSON array of strings. No markdown, no explanations, no wrapping object.
|
||||
- Example: ["The Hidden Psychology of Habits", "Why Discipline Beats Motivation", "The 5-Second Rule Explained"]
|
||||
- Each topic should be punchy, curiosity-driven, and clearly related to the core themes of the text.
|
||||
- Language: Turkish.`;
|
||||
|
||||
const userPrompt = `Extract ${count} engaging video topics from this text:\n\n${text.substring(0, 20000)}`;
|
||||
|
||||
try {
|
||||
const response = await this.genAI.models.generateContent({
|
||||
model: this.modelName,
|
||||
contents: userPrompt,
|
||||
config: {
|
||||
systemInstruction: systemPrompt,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const rawText = response.text ?? '[]';
|
||||
const topics: string[] = JSON.parse(rawText);
|
||||
|
||||
this.logger.log(`✅ ${topics.length} adet konu önerisi çıkarıldı.`);
|
||||
return topics;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Konu çıkarma hatası: ${error instanceof Error ? error.message : 'Bilinmeyen'}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
`Video konuları çıkarılamadı: ${error instanceof Error ? error.message : 'API hatası'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUserPrompt(input: ScriptGenerationInput): string {
|
||||
const langMap: Record<string, string> = {
|
||||
tr: 'Turkish', en: 'English', es: 'Spanish', de: 'German',
|
||||
@@ -640,6 +685,8 @@ export class VideoAiService {
|
||||
`- Make it viral-worthy, visually stunning, and intellectually captivating\n` +
|
||||
`- The first 2 seconds must hook the viewer immediately\n` +
|
||||
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
|
||||
`- WHITE-LABELING (CRITICAL): NEVER mention the original source, creator, author, URL, channel name, or @username. Present all content as if YOU are the original creator.\n` +
|
||||
`- DO NOT include logos, handles, or mentions of the original source in your visual prompts.\n` +
|
||||
`- Include SEO-optimized metadata with keywords and schema markup\n` +
|
||||
`- Generate social media captions for YouTube, TikTok, Instagram, Twitter\n`;
|
||||
|
||||
@@ -684,11 +731,13 @@ export class VideoAiService {
|
||||
}
|
||||
}
|
||||
|
||||
prompt += `\nIMPORTANT:\n`;
|
||||
prompt += `- Analyze WHY this tweet went viral and capture that energy\n`;
|
||||
prompt += `- The narration should feel like a reaction/commentary on the tweet content\n`;
|
||||
prompt += `- Mention the original tweet author @${tw.authorUsername} naturally in narration\n`;
|
||||
prompt += `- Use both the tweet's images as reference AND generate new AI visuals\n`;
|
||||
prompt += `\nIMPORTANT WHITE-LABELING RULES (CRITICAL):\n`;
|
||||
prompt += `- Analyze the core message of the tweet and capture its energy.\n`;
|
||||
prompt += `- You are creating ORIGINAL content. Do NOT act like you are reacting to or commenting on someone else's post.\n`;
|
||||
prompt += `- ABSOLUTELY DO NOT mention the original author (@${tw.authorUsername}), their real name, or the fact that this is from a tweet/X.\n`;
|
||||
prompt += `- DO NOT include any logos, usernames, or references to the original source in your visual prompts (e.g. no "@${tw.authorUsername} logo").\n`;
|
||||
prompt += `- Present the facts, stories, or insights as if YOU are the original expert creator.\n`;
|
||||
prompt += `- Use the tweet's images as reference for the visuals, but describe them generally without mentioning any source brands or handles.\n`;
|
||||
prompt += `═══════════════════════════════\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface VideoGenerationJobPayload {
|
||||
duration: number;
|
||||
transitionType: string;
|
||||
ambientSoundPrompt?: string; // AudioGen: sahne bazlı ortam sesi
|
||||
imagePath?: string; // Gemini'den üretilen görselin yerel yolu
|
||||
ttsProvider?: string; // openai veya elevenlabs
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user