main
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-12 11:44:08 +02:00
parent 5f78ce274e
commit 23eed2982c
7 changed files with 366 additions and 3 deletions
+2
View File
@@ -52,6 +52,7 @@ import { EventsModule } from './modules/events/events.module';
import { RenderCallbackModule } from './modules/render-callback/render-callback.module'; import { RenderCallbackModule } from './modules/render-callback/render-callback.module';
import { DashboardModule } from './modules/dashboard/dashboard.module'; import { DashboardModule } from './modules/dashboard/dashboard.module';
import { NotificationsModule } from './modules/notifications/notifications.module'; import { NotificationsModule } from './modules/notifications/notifications.module';
import { ExtractorModule } from './modules/extractor/extractor.module';
// Guards // Guards
import { import {
@@ -200,6 +201,7 @@ import {
RenderCallbackModule, RenderCallbackModule,
DashboardModule, DashboardModule,
NotificationsModule, NotificationsModule,
ExtractorModule,
], ],
providers: [ providers: [
// Global Exception Filter // Global Exception Filter
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ExtractorService } from './extractor.service';
@Module({
providers: [ExtractorService],
exports: [ExtractorService],
})
export class ExtractorModule {}
@@ -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<string> {
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<string> {
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);
}
}
}
+90
View File
@@ -217,3 +217,93 @@ export class CreateFromTweetDto {
@Max(90) @Max(90)
targetDuration?: number; 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;
}
+41 -1
View File
@@ -12,6 +12,9 @@ import {
Logger, Logger,
ParseUUIDPipe, ParseUUIDPipe,
Req, Req,
UploadedFile,
UseInterceptors,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@@ -19,9 +22,11 @@ import {
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
ApiConsumes,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { ProjectsService } from './projects.service'; 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') @ApiTags('projects')
@ApiBearerAuth() @ApiBearerAuth()
@@ -151,6 +156,41 @@ export class ProjectsController {
return this.projectsService.createFromTweet(userId, dto); 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). * Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
*/ */
+2 -1
View File
@@ -6,9 +6,10 @@ import { VideoQueueModule } from '../video-queue/video-queue.module';
import { XTwitterModule } from '../x-twitter/x-twitter.module'; import { XTwitterModule } from '../x-twitter/x-twitter.module';
import { GeminiModule } from '../gemini/gemini.module'; import { GeminiModule } from '../gemini/gemini.module';
import { StorageModule } from '../storage/storage.module'; import { StorageModule } from '../storage/storage.module';
import { ExtractorModule } from '../extractor/extractor.module';
@Module({ @Module({
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule], imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule],
controllers: [ProjectsController], controllers: [ProjectsController],
providers: [ProjectsService], providers: [ProjectsService],
exports: [ProjectsService], exports: [ProjectsService],
+169 -1
View File
@@ -12,7 +12,8 @@ import { VideoGenerationProducer } from '../video-queue/video-generation.produce
import { XTwitterService } from '../x-twitter/x-twitter.service'; import { XTwitterService } from '../x-twitter/x-twitter.service';
import { GeminiService } from '../gemini/gemini.service'; import { GeminiService } from '../gemini/gemini.service';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { 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 sharp from 'sharp';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
@@ -33,6 +34,7 @@ export class ProjectsService {
private readonly xTwitterService: XTwitterService, private readonly xTwitterService: XTwitterService,
private readonly geminiService: GeminiService, private readonly geminiService: GeminiService,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly extractorService: ExtractorService,
) {} ) {}
async create(userId: string, dto: CreateProjectDto) { 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. * Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
*/ */