From 23eed2982cdd9698fa1d96b502f0a83771ed2ecd Mon Sep 17 00:00:00 2001 From: Harun CAN Date: Sun, 12 Apr 2026 11:44:08 +0200 Subject: [PATCH] main --- src/app.module.ts | 2 + src/modules/extractor/extractor.module.ts | 8 + src/modules/extractor/extractor.service.ts | 54 +++++++ src/modules/projects/dto/project.dto.ts | 90 +++++++++++ src/modules/projects/projects.controller.ts | 42 ++++- src/modules/projects/projects.module.ts | 3 +- src/modules/projects/projects.service.ts | 170 +++++++++++++++++++- 7 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 src/modules/extractor/extractor.module.ts create mode 100644 src/modules/extractor/extractor.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 7d33808..4fb5d26 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -52,6 +52,7 @@ import { EventsModule } from './modules/events/events.module'; import { RenderCallbackModule } from './modules/render-callback/render-callback.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { ExtractorModule } from './modules/extractor/extractor.module'; // Guards import { @@ -200,6 +201,7 @@ import { RenderCallbackModule, DashboardModule, NotificationsModule, + ExtractorModule, ], providers: [ // Global Exception Filter diff --git a/src/modules/extractor/extractor.module.ts b/src/modules/extractor/extractor.module.ts new file mode 100644 index 0000000..367c72d --- /dev/null +++ b/src/modules/extractor/extractor.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ExtractorService } from './extractor.service'; + +@Module({ + providers: [ExtractorService], + exports: [ExtractorService], +}) +export class ExtractorModule {} diff --git a/src/modules/extractor/extractor.service.ts b/src/modules/extractor/extractor.service.ts new file mode 100644 index 0000000..b40e7b4 --- /dev/null +++ b/src/modules/extractor/extractor.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import axios from 'axios'; +import * as fs from 'fs'; +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'; + + 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 + }); + 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); + } + } + + async extractFromFile(filePath: string, filename: string, mimeType: string): Promise { + this.logger.log(`Dosyadan içerik çekiliyor: ${filename}`); + try { + const formData = new FormData(); + formData.append('file', fs.createReadStream(filePath), { + filename: filename, + contentType: mimeType, + }); + + const response = await axios.post(`${this.extractorUrl}/extract/file`, formData, { + headers: { + ...formData.getHeaders(), + }, + timeout: 120000 // 2 minutes timeout for files + }); + + return response.data.content; + } catch (error: any) { + this.logger.error(`File 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); + } + } +} diff --git a/src/modules/projects/dto/project.dto.ts b/src/modules/projects/dto/project.dto.ts index ef881b7..b5db08d 100644 --- a/src/modules/projects/dto/project.dto.ts +++ b/src/modules/projects/dto/project.dto.ts @@ -217,3 +217,93 @@ export class CreateFromTweetDto { @Max(90) targetDuration?: number; } + +export class CreateFromYoutubeDto { + @ApiProperty({ + 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' }, + ) + youtubeUrl: string; + + @ApiPropertyOptional({ + description: 'Proje başlığı (boş bırakılırsa YouTube\'dan otomatik üretilir)', + }) + @IsString() + @IsOptional() + @MaxLength(200) + title?: string; + + @ApiPropertyOptional({ + description: 'Hedef video dili (ISO 639-1)', + default: 'tr', + }) + @IsString() + @IsOptional() + language?: string; + + @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @IsEnum(AspectRatioDto) + @IsOptional() + aspectRatio?: AspectRatioDto; + + @ApiPropertyOptional({ + description: 'Video stili (CINEMATIC, DOCUMENTARY, vb.)', + default: 'CINEMATIC', + }) + @IsString() + @IsOptional() + @MaxLength(50) + videoStyle?: string; + + @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @IsInt() + @IsOptional() + @Min(15) + @Max(90) + targetDuration?: number; +} + +export class CreateFromDocumentDto { + @ApiPropertyOptional({ + description: 'Proje başlığı (boş bırakılırsa belgeden otomatik üretilir)', + }) + @IsString() + @IsOptional() + @MaxLength(200) + title?: string; + + @ApiPropertyOptional({ + description: 'Hedef video dili (ISO 639-1)', + default: 'tr', + }) + @IsString() + @IsOptional() + language?: string; + + @ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 }) + @IsEnum(AspectRatioDto) + @IsOptional() + aspectRatio?: AspectRatioDto; + + @ApiPropertyOptional({ + description: 'Video stili (CINEMATIC, DOCUMENTARY, vb.)', + default: 'CINEMATIC', + }) + @IsString() + @IsOptional() + @MaxLength(50) + videoStyle?: string; + + @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 }) + @IsInt() + @IsOptional() + @Min(15) + @Max(90) + targetDuration?: number; +} diff --git a/src/modules/projects/projects.controller.ts b/src/modules/projects/projects.controller.ts index 4eb3130..a395b18 100644 --- a/src/modules/projects/projects.controller.ts +++ b/src/modules/projects/projects.controller.ts @@ -12,6 +12,9 @@ import { Logger, ParseUUIDPipe, Req, + UploadedFile, + UseInterceptors, + BadRequestException, } from '@nestjs/common'; import { ApiTags, @@ -19,9 +22,11 @@ import { ApiResponse, ApiBearerAuth, ApiQuery, + ApiConsumes, } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ProjectsService } from './projects.service'; -import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto } from './dto/project.dto'; +import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto'; @ApiTags('projects') @ApiBearerAuth() @@ -151,6 +156,41 @@ export class ProjectsController { return this.projectsService.createFromTweet(userId, dto); } + /** + * YouTube URL'sinden otomatik proje oluşturur ve senaryo üretir. + */ + @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' }) + async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`); + return this.projectsService.createFromYoutube(userId, dto); + } + + /** + * Yüklenen dokümandan (Word, PDF, Excel vb.) otomatik proje oluşturur. + */ + @Post('from-document') + @HttpCode(HttpStatus.CREATED) + @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' }) + async createFromDocument( + @UploadedFile() file: Express.Multer.File, + @Body() dto: CreateFromDocumentDto, + @Req() req: any, + ) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Dosyadan proje oluşturuluyor: ${file?.originalname}`); + if (!file) { + throw new BadRequestException('Dosya yüklenmedi'); + } + return this.projectsService.createFromDocument(userId, file, dto); + } + /** * Tekil sahne güncelleme (narrasyon, görsel prompt, süre). */ diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index 0c359bc..5b7a364 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -6,9 +6,10 @@ import { VideoQueueModule } from '../video-queue/video-queue.module'; import { XTwitterModule } from '../x-twitter/x-twitter.module'; import { GeminiModule } from '../gemini/gemini.module'; import { StorageModule } from '../storage/storage.module'; +import { ExtractorModule } from '../extractor/extractor.module'; @Module({ - imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule], + imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule], controllers: [ProjectsController], providers: [ProjectsService], exports: [ProjectsService], diff --git a/src/modules/projects/projects.service.ts b/src/modules/projects/projects.service.ts index 60b5e3a..aba0924 100644 --- a/src/modules/projects/projects.service.ts +++ b/src/modules/projects/projects.service.ts @@ -12,7 +12,8 @@ import { VideoGenerationProducer } from '../video-queue/video-generation.produce import { XTwitterService } from '../x-twitter/x-twitter.service'; import { GeminiService } from '../gemini/gemini.service'; import { StorageService } from '../storage/storage.service'; -import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto } from './dto/project.dto'; +import { ExtractorService } from '../extractor/extractor.service'; +import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto'; import sharp from 'sharp'; import * as fs from 'fs/promises'; @@ -33,6 +34,7 @@ export class ProjectsService { private readonly xTwitterService: XTwitterService, private readonly geminiService: GeminiService, private readonly storageService: StorageService, + private readonly extractorService: ExtractorService, ) {} async create(userId: string, dto: CreateProjectDto) { @@ -536,6 +538,172 @@ 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}`); + + // 1. YouTube url'den MarkItDown yardımı ile metni çek + 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)}`; + + // 3. Projeyi DRAFT vb. statülerle oluştur (Sonra AI için tetikleyebiliriz) + const project = await this.db.project.create({ + data: { + title, + description: `YouTube video kaynağındaki veriden üretildi. URL: ${dto.youtubeUrl}`, + prompt, + language: dto.language || 'tr', + aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16', + videoStyle: dto.videoStyle || 'CINEMATIC', + targetDuration: dto.targetDuration || 60, + status: 'GENERATING_SCRIPT', + userId, + sourceType: 'YOUTUBE', + referenceUrl: dto.youtubeUrl, + }, + }); + + this.logger.log(`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`); + + try { + const scriptJson = await this.videoAiService.generateVideoScript({ + topic: prompt, + targetDurationSeconds: project.targetDuration, + language: project.language, + videoStyle: project.videoStyle, + cinematicReference: undefined, + seoKeywords: [], + referenceUrl: dto.youtubeUrl, + }); + + // Sahneleri kaydet + 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' } }, + }, + }); + + 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', + }, + }); + throw error; + } + } + + /** + * 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."); + } + + const extractedText = await this.extractorService.extractFromFile(file.path, file.originalname, file.mimetype); + + // 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 project = await this.db.project.create({ + data: { + title, + description: `Belge üzerinden üretildi: ${file.originalname}`, + prompt, + language: dto.language || 'tr', + aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16', + videoStyle: dto.videoStyle || 'CINEMATIC', + targetDuration: dto.targetDuration || 60, + status: 'GENERATING_SCRIPT', + userId, + sourceType: 'DOCUMENT', + }, + }); + + try { + const scriptJson = await this.videoAiService.generateVideoScript({ + topic: prompt, + targetDurationSeconds: project.targetDuration, + language: project.language, + videoStyle: project.videoStyle, + }); + + 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 : 'Belge senaryo üretimi sırasında hata', + }, + }); + throw error; + } + } + /** * Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre. */