generated from fahricansecer/boilerplate-be
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user