diff --git a/.env.example b/.env.example index 0e46155..ec06da4 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,15 @@ THROTTLE_LIMIT=100 ENABLE_GEMINI=false GOOGLE_API_KEY=your-google-api-key GEMINI_MODEL=gemini-2.5-flash + +# AudioCraft — HuggingFace Inference API (MusicGen + AudioGen) +# MusicGen: Text-to-music üretimi +# AudioGen: Text-to-sound efekti üretimi +# Ücretsiz HuggingFace hesabı yeterli: https://huggingface.co/settings/tokens +HUGGINGFACE_API_KEY=hf_your-huggingface-api-key +MUSICGEN_MODEL=facebook/musicgen-small +AUDIOGEN_MODEL=facebook/audiogen-medium + +# Stripe Billing +STRIPE_SECRET_KEY=sk_test_your-stripe-key +STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret diff --git a/media-worker/Configuration/WorkerSettings.cs b/media-worker/Configuration/WorkerSettings.cs new file mode 100644 index 0000000..388f11f --- /dev/null +++ b/media-worker/Configuration/WorkerSettings.cs @@ -0,0 +1,54 @@ +namespace SaasMediaWorker.Configuration; + +public class WorkerSettings +{ + public int MaxConcurrency { get; set; } = 2; + public int PollIntervalSeconds { get; set; } = 2; + public int MaxRetryAttempts { get; set; } = 3; + public string WorkerVersion { get; set; } = "1.0.0"; +} + +public class RedisSettings +{ + public string ConnectionString { get; set; } = "localhost:6379"; + public string QueueKey { get; set; } = "contgen:queue:video-generation"; + public string ProgressChannel { get; set; } = "contgen:progress"; + public string CompletionChannel { get; set; } = "contgen:completion"; +} + +public class S3Settings +{ + public string Endpoint { get; set; } = string.Empty; + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string BucketName { get; set; } = "contgen-media"; + public string Region { get; set; } = "auto"; + public string PublicBaseUrl { get; set; } = string.Empty; +} + +public class ApiSettings +{ + public string HiggsFieldBaseUrl { get; set; } = string.Empty; + public string HiggsFieldApiKey { get; set; } = string.Empty; + public string TtsBaseUrl { get; set; } = string.Empty; + public string TtsApiKey { get; set; } = string.Empty; + public string TtsVoiceId { get; set; } = "pNInz6obpgDQGcFmaJgB"; + public string SunoBaseUrl { get; set; } = string.Empty; + public string SunoApiKey { get; set; } = string.Empty; + public string CoreApiBaseUrl { get; set; } = "http://localhost:3000/api"; + + // AudioCraft — HuggingFace Inference API + public string HuggingFaceApiKey { get; set; } = string.Empty; + public string MusicGenModel { get; set; } = "facebook/musicgen-small"; // small (Pi-uyumlu), medium, large + public string AudioGenModel { get; set; } = "facebook/audiogen-medium"; +} + +public class FFmpegSettings +{ + public string BinaryPath { get; set; } = "/usr/bin/ffmpeg"; + public string FfprobePath { get; set; } = "/usr/bin/ffprobe"; + public string TempDirectory { get; set; } = "/tmp/contgen-render"; + public int MaxConcurrentRenders { get; set; } = 2; + public string HardwareAcceleration { get; set; } = "none"; + public string FontPath { get; set; } = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"; +} diff --git a/media-worker/Dockerfile b/media-worker/Dockerfile new file mode 100644 index 0000000..4258748 --- /dev/null +++ b/media-worker/Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /src + +# Restore dependencies +COPY SaasMediaWorker.csproj . +RUN dotnet restore + +# Build +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime — ARM64 uyumlu Alpine image +FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine AS runtime +WORKDIR /app + +# FFmpeg kurulumu (ARM64 native Alpine paketi) +RUN apk add --no-cache ffmpeg font-dejavu + +# Temp dizin oluştur +RUN mkdir -p /tmp/contgen-render + +COPY --from=build /app/publish . + +# Non-root user ile çalıştır (güvenlik) +RUN adduser -D -h /app workeruser +USER workeruser + +ENTRYPOINT ["dotnet", "SaasMediaWorker.dll"] diff --git a/media-worker/Models/VideoGenerationJob.cs b/media-worker/Models/VideoGenerationJob.cs new file mode 100644 index 0000000..9238e8d --- /dev/null +++ b/media-worker/Models/VideoGenerationJob.cs @@ -0,0 +1,176 @@ +using System.Text.Json.Serialization; + +namespace SaasMediaWorker.Models; + +/// +/// Redis kuyruğundan alınan video üretim job payload'ı. +/// NestJS VideoGenerationProducer tarafından JSON olarak yazılır. +/// +public class VideoGenerationJob +{ + [JsonPropertyName("jobId")] + public string JobId { get; set; } = string.Empty; + + [JsonPropertyName("projectId")] + public string ProjectId { get; set; } = string.Empty; + + [JsonPropertyName("renderJobId")] + public string RenderJobId { get; set; } = string.Empty; + + [JsonPropertyName("scriptJson")] + public ScriptPayload? ScriptJson { get; set; } + + [JsonPropertyName("language")] + public string Language { get; set; } = "tr"; + + [JsonPropertyName("aspectRatio")] + public string AspectRatio { get; set; } = "PORTRAIT_9_16"; + + [JsonPropertyName("videoStyle")] + public string VideoStyle { get; set; } = "CINEMATIC"; + + [JsonPropertyName("targetDuration")] + public int TargetDuration { get; set; } = 60; + + [JsonPropertyName("scenes")] + public List Scenes { get; set; } = new(); + + [JsonPropertyName("timestamp")] + public string Timestamp { get; set; } = string.Empty; +} + +public class ScriptPayload +{ + [JsonPropertyName("metadata")] + public ScriptMetadata? Metadata { get; set; } + + [JsonPropertyName("scenes")] + public List Scenes { get; set; } = new(); + + [JsonPropertyName("musicPrompt")] + public string MusicPrompt { get; set; } = string.Empty; + + [JsonPropertyName("musicStyle")] + public string MusicStyle { get; set; } = string.Empty; + + [JsonPropertyName("musicTechnical")] + public MusicTechnicalPayload? MusicTechnical { get; set; } + + [JsonPropertyName("ambientSoundPrompts")] + public List AmbientSoundPrompts { get; set; } = new(); + + [JsonPropertyName("voiceStyle")] + public string VoiceStyle { get; set; } = string.Empty; +} + +public class MusicTechnicalPayload +{ + [JsonPropertyName("bpm")] + public int Bpm { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("instruments")] + public List Instruments { get; set; } = new(); + + [JsonPropertyName("emotionalArc")] + public string EmotionalArc { get; set; } = string.Empty; +} + +public class ScriptMetadata +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("totalDurationSeconds")] + public double TotalDurationSeconds { get; set; } + + [JsonPropertyName("language")] + public string Language { get; set; } = string.Empty; + + [JsonPropertyName("hashtags")] + public List Hashtags { get; set; } = new(); +} + +public class ScriptScene +{ + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("narrationText")] + public string NarrationText { get; set; } = string.Empty; + + [JsonPropertyName("visualPrompt")] + public string VisualPrompt { get; set; } = string.Empty; + + [JsonPropertyName("subtitleText")] + public string SubtitleText { get; set; } = string.Empty; + + [JsonPropertyName("durationSeconds")] + public double DurationSeconds { get; set; } + + [JsonPropertyName("transitionType")] + public string TransitionType { get; set; } = "CUT"; +} + +public class ScenePayload +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("narrationText")] + public string NarrationText { get; set; } = string.Empty; + + [JsonPropertyName("visualPrompt")] + public string VisualPrompt { get; set; } = string.Empty; + + [JsonPropertyName("subtitleText")] + public string SubtitleText { get; set; } = string.Empty; + + [JsonPropertyName("duration")] + public double Duration { get; set; } + + [JsonPropertyName("transitionType")] + public string TransitionType { get; set; } = "CUT"; + + [JsonPropertyName("ambientSoundPrompt")] + public string? AmbientSoundPrompt { get; set; } +} + +/// +/// Render pipeline'ının ürettiği medya dosyası referansı. +/// +public class GeneratedMediaFile +{ + public string SceneId { get; set; } = string.Empty; + public int SceneOrder { get; set; } + public MediaFileType Type { get; set; } + public string LocalPath { get; set; } = string.Empty; + public string? S3Url { get; set; } + public string? S3Key { get; set; } + public long FileSizeBytes { get; set; } + public double? DurationSeconds { get; set; } + public string MimeType { get; set; } = string.Empty; + public string? AiProvider { get; set; } +} + +public enum MediaFileType +{ + VideoClip, + AudioNarration, + AudioMusic, + AudioAmbient, + Subtitle, + Thumbnail, + FinalVideo +} diff --git a/media-worker/Program.cs b/media-worker/Program.cs new file mode 100644 index 0000000..45a8cb9 --- /dev/null +++ b/media-worker/Program.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polly; +using Polly.Extensions.Http; +using Serilog; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Services; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + +try +{ + Log.Information("🚀 ContentGen AI Media Worker başlatılıyor..."); + + var builder = Host.CreateApplicationBuilder(args); + + builder.Services.AddSerilog(); + + // Configuration binding + builder.Services.Configure( + builder.Configuration.GetSection("WorkerSettings")); + builder.Services.Configure( + builder.Configuration.GetSection("Redis")); + builder.Services.Configure( + builder.Configuration.GetSection("S3")); + builder.Services.Configure( + builder.Configuration.GetSection("ApiSettings")); + builder.Services.Configure( + builder.Configuration.GetSection("FFmpeg")); + + // Polly Retry Policy — Exponential Backoff + Circuit Breaker + var retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: attempt => + TimeSpan.FromSeconds(Math.Pow(2, attempt)) + + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)), + onRetry: (outcome, delay, attempt, _) => + { + Log.Warning( + "HTTP Retry #{Attempt} — {Delay}s sonra tekrar denenecek. Hata: {Error}", + attempt, delay.TotalSeconds, + outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()); + }); + + var circuitBreakerPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (_, duration) => + Log.Error("⚡ Circuit Breaker AÇILDI — {Duration}s bekleniyor", duration.TotalSeconds), + onReset: () => + Log.Information("✅ Circuit Breaker kapandı — istekler devam ediyor")); + + var combinedPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); + + // HttpClient registrations with Polly + builder.Services.AddHttpClient("HiggsField") + .AddPolicyHandler(combinedPolicy); + + builder.Services.AddHttpClient("TTS") + .AddPolicyHandler(combinedPolicy); + + builder.Services.AddHttpClient("Suno") + .AddPolicyHandler(combinedPolicy); + + builder.Services.AddHttpClient("CoreAPI") + .AddPolicyHandler(retryPolicy); + + // Service registrations + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Background Service — Redis Queue Consumer + builder.Services.AddHostedService(); + + var host = builder.Build(); + await host.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "💀 Media Worker başlatılamadı!"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} diff --git a/media-worker/SaasMediaWorker.csproj b/media-worker/SaasMediaWorker.csproj new file mode 100644 index 0000000..4557473 --- /dev/null +++ b/media-worker/SaasMediaWorker.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + enable + enable + SaasMediaWorker + SaasMediaWorker + + linux-arm64;linux-x64;osx-arm64;osx-x64 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media-worker/Services/ApiNotificationService.cs b/media-worker/Services/ApiNotificationService.cs new file mode 100644 index 0000000..e35ae7f --- /dev/null +++ b/media-worker/Services/ApiNotificationService.cs @@ -0,0 +1,110 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; + +namespace SaasMediaWorker.Services; + +/// +/// NestJS Core API'ye bildirim gönderen HttpClient servisi. +/// Worker işlem tamamlandığında veya hata oluştuğunda API'yi bilgilendirir. +/// +public class ApiNotificationService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + public ApiNotificationService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + _httpClient.BaseAddress = new Uri(_settings.CoreApiBaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + /// + /// Render tamamlandığında NestJS API'sine bildirim gönderir. + /// + public async Task NotifyCompletionAsync( + string projectId, + string renderJobId, + string finalVideoUrl, + long processingTimeMs) + { + var payload = new + { + projectId, + renderJobId, + status = "COMPLETED", + finalVideoUrl, + processingTimeMs, + workerHostname = Environment.MachineName, + completedAt = DateTime.UtcNow.ToString("O") + }; + + await SendNotification("/internal/worker/callback", payload); + + _logger.LogInformation( + "✅ API'ye tamamlanma bildirimi gönderildi — Project: {ProjectId}", + projectId); + } + + /// + /// Render başarısız olduğunda NestJS API'sine hata bildirimi gönderir. + /// + public async Task NotifyFailureAsync( + string projectId, + string renderJobId, + string errorMessage, + int attemptNumber) + { + var payload = new + { + projectId, + renderJobId, + status = "FAILED", + errorMessage, + attemptNumber, + workerHostname = Environment.MachineName, + failedAt = DateTime.UtcNow.ToString("O") + }; + + await SendNotification("/internal/worker/callback", payload); + + _logger.LogWarning( + "API'ye hata bildirimi gönderildi — Project: {ProjectId}, Hata: {Error}", + projectId, errorMessage); + } + + private async Task SendNotification(string endpoint, object payload) + { + try + { + var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync(endpoint, content); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "API bildirim yanıtı başarısız: {StatusCode}", + response.StatusCode); + } + } + catch (Exception ex) + { + // Bildirim hatası render sonucunu etkilememeli + _logger.LogWarning(ex, "API bildirim gönderilemedi — endpoint: {Endpoint}", endpoint); + } + } +} diff --git a/media-worker/Services/AudioCraftService.cs b/media-worker/Services/AudioCraftService.cs new file mode 100644 index 0000000..4d45bc0 --- /dev/null +++ b/media-worker/Services/AudioCraftService.cs @@ -0,0 +1,302 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// AudioCraft Service — Meta MusicGen + AudioGen entegrasyonu. +/// +/// HuggingFace Inference API üzerinden çalışır (self-hosted model gerekmez). +/// Raspberry Pi'da çalıştırılabilir — model inference cloud'da yapılır. +/// +/// Yetenekler: +/// MusicGen: Text-to-music (müzik üretimi) +/// - Genre, BPM, enstrüman, mood bazlı prompt +/// - Stereo çıktı desteği +/// - 30 saniyeye kadar üretim +/// AudioGen: Text-to-sound (ses efekti üretimi) +/// - Ortam sesleri: yağmur, rüzgâr, deniz dalgaları +/// - Foley sesleri: ayak sesleri, kapı gıcırtısı +/// - Sahne bazlı ambient ses katmanları +/// +/// multimodal-audiocraft skill'inden elde edilen bilgilerle tasarlandı. +/// +public class AudioCraftService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + // HuggingFace Inference API endpoints + private const string MUSICGEN_MODEL = "facebook/musicgen-small"; + private const string AUDIOGEN_MODEL = "facebook/audiogen-medium"; + private const string HF_API_BASE = "https://api-inference.huggingface.co/models"; + + public AudioCraftService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + _httpClient.DefaultRequestHeaders.Add( + "Authorization", $"Bearer {_settings.HuggingFaceApiKey}"); + _httpClient.Timeout = TimeSpan.FromMinutes(5); + } + + /// + /// MusicGen ile text-to-music üretimi. + /// Proje için background müzik oluşturur. + /// + public async Task GenerateMusicAsync( + string musicPrompt, + MusicTechnicalParams? technicalParams, + int targetDurationSeconds, + string outputDirectory, + CancellationToken ct) + { + // Prompt'u teknik parametrelerle zenginleştir + var enrichedPrompt = EnrichMusicPrompt(musicPrompt, technicalParams); + + _logger.LogInformation( + "🎵 MusicGen müzik üretimi — Prompt: \"{Prompt}\", Süre: {Duration}s", + enrichedPrompt[..Math.Min(100, enrichedPrompt.Length)], + targetDurationSeconds); + + var audioBytes = await CallHuggingFaceInference( + MUSICGEN_MODEL, enrichedPrompt, ct); + + if (audioBytes == null || audioBytes.Length == 0) + { + throw new InvalidOperationException("MusicGen boş yanıt döndü"); + } + + // WAV dosyasını kaydet + var outputPath = Path.Combine(outputDirectory, "background_music_audiocraft.wav"); + await File.WriteAllBytesAsync(outputPath, audioBytes, ct); + + var fileInfo = new FileInfo(outputPath); + _logger.LogInformation( + "✅ MusicGen müzik üretildi: {Path} ({Size:N0} bytes)", + outputPath, fileInfo.Length); + + return new GeneratedMediaFile + { + SceneId = string.Empty, + SceneOrder = 0, + Type = MediaFileType.AudioMusic, + LocalPath = outputPath, + FileSizeBytes = fileInfo.Length, + DurationSeconds = targetDurationSeconds, + MimeType = "audio/wav", + AiProvider = "audiocraft-musicgen" + }; + } + + /// + /// AudioGen ile sahne bazlı ambient ses efekti üretimi. + /// Her sahne için farklı bir ortam sesi oluşturulabilir. + /// + public async Task GenerateAmbientSoundAsync( + string ambientPrompt, + int sceneOrder, + double durationSeconds, + string outputDirectory, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(ambientPrompt)) + return null; + + _logger.LogInformation( + "🔊 AudioGen ses efekti — Sahne: {Order}, Prompt: \"{Prompt}\"", + sceneOrder, ambientPrompt[..Math.Min(80, ambientPrompt.Length)]); + + try + { + var audioBytes = await CallHuggingFaceInference( + AUDIOGEN_MODEL, ambientPrompt, ct); + + if (audioBytes == null || audioBytes.Length == 0) + { + _logger.LogWarning("AudioGen boş yanıt — sahne {Order} için ambient atlanıyor", sceneOrder); + return null; + } + + var fileName = $"ambient_scene_{sceneOrder:D3}.wav"; + var outputPath = Path.Combine(outputDirectory, fileName); + await File.WriteAllBytesAsync(outputPath, audioBytes, ct); + + _logger.LogInformation( + "✅ Ambient ses üretildi: {FileName} ({Size:N0} bytes)", + fileName, audioBytes.Length); + + return new GeneratedMediaFile + { + SceneId = string.Empty, + SceneOrder = sceneOrder, + Type = MediaFileType.AudioAmbient, + LocalPath = outputPath, + FileSizeBytes = audioBytes.Length, + DurationSeconds = durationSeconds, + MimeType = "audio/wav", + AiProvider = "audiocraft-audiogen" + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Ambient ses üretimi başarısız (sahne {Order}) — devam ediliyor", sceneOrder); + return null; + } + } + + /// + /// Projenin tüm sahneleri için batch ambient ses üretimi. + /// Paralel çalışır — Raspberry Pi'da bellek dostu. + /// + public async Task> GenerateAllAmbientSoundsAsync( + List scenes, + string outputDirectory, + CancellationToken ct) + { + var results = new List(); + + // Sıralı üret (HuggingFace rate limit + Pi bellek optimizasyonu) + foreach (var scene in scenes) + { + var ambientPrompt = scene.AmbientSoundPrompt; + if (string.IsNullOrWhiteSpace(ambientPrompt)) + continue; + + var result = await GenerateAmbientSoundAsync( + ambientPrompt, scene.Order, scene.Duration, + outputDirectory, ct); + + if (result != null) + results.Add(result); + + // Rate limit koruma — 1 saniye bekle + await Task.Delay(1000, ct); + } + + _logger.LogInformation( + "🔊 Toplam {Count} sahne için ambient ses üretildi", results.Count); + + return results; + } + + // ── Private: HuggingFace Inference API çağrısı ────────────────── + + private async Task CallHuggingFaceInference( + string modelId, string prompt, CancellationToken ct) + { + var url = $"{HF_API_BASE}/{modelId}"; + + var payload = new { inputs = prompt }; + var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + + var maxRetries = 3; + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + var response = await _httpClient.PostAsync(url, content, ct); + + // Model yükleniyor (cold start) + if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || + (int)response.StatusCode == 503) + { + var json = await response.Content.ReadAsStringAsync(ct); + _logger.LogInformation( + "Model yükleniyor ({Model}), deneme {Attempt}/{Max}...", + modelId, attempt, maxRetries); + + // Model yüklenme süresi bekleme + var waitTime = ExtractEstimatedTime(json); + await Task.Delay(TimeSpan.FromSeconds(waitTime), ct); + continue; + } + + response.EnsureSuccessStatusCode(); + + // Audio binary yanıt + return await response.Content.ReadAsByteArrayAsync(ct); + } + catch (HttpRequestException ex) when (attempt < maxRetries) + { + _logger.LogWarning(ex, + "HuggingFace API hatası, deneme {Attempt}/{Max}", attempt, maxRetries); + await Task.Delay(3000 * attempt, ct); + } + } + + throw new InvalidOperationException( + $"HuggingFace API {maxRetries} deneme sonrası başarısız — Model: {modelId}"); + } + + /// + /// MusicGen prompt'unu teknik parametrelerle zenginleştirir. + /// AudioCraft skill'den öğrenilen best practice'lere göre optimize eder. + /// + private string EnrichMusicPrompt(string basePrompt, MusicTechnicalParams? technical) + { + if (technical == null) + return basePrompt; + + var enriched = basePrompt; + + // BPM ekle (prompt'ta yoksa) + if (!enriched.Contains("BPM", StringComparison.OrdinalIgnoreCase) && technical.Bpm > 0) + { + enriched += $", {technical.Bpm} BPM"; + } + + // Key ekle + if (!string.IsNullOrEmpty(technical.Key) && + !enriched.Contains(technical.Key, StringComparison.OrdinalIgnoreCase)) + { + enriched += $", {technical.Key}"; + } + + // Emotional arc ekle + if (!string.IsNullOrEmpty(technical.EmotionalArc)) + { + enriched += $", {technical.EmotionalArc.Replace("-", " ")} energy"; + } + + return enriched; + } + + private int ExtractEstimatedTime(string json) + { + try + { + var doc = JsonSerializer.Deserialize(json); + if (doc.TryGetProperty("estimated_time", out var time)) + return Math.Max(10, (int)time.GetDouble()); + } + catch { } + + return 20; // Default: 20 saniye bekle + } +} + +/// +/// MusicGen teknik parametreleri — AI senaryo çıktısından parse edilir. +/// +public class MusicTechnicalParams +{ + public int Bpm { get; set; } + public string? Key { get; set; } + public List Instruments { get; set; } = new(); + public string EmotionalArc { get; set; } = string.Empty; +} diff --git a/media-worker/Services/DatabaseService.cs b/media-worker/Services/DatabaseService.cs new file mode 100644 index 0000000..6b691cf --- /dev/null +++ b/media-worker/Services/DatabaseService.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace SaasMediaWorker.Services; + +/// +/// PostgreSQL veritabanı servisi — RenderJob ve Project durumlarını günceller. +/// NestJS Prisma schema'sıyla uyumlu SQL sorguları kullanır. +/// +/// Neden doğrudan SQL (ORM yerine)? +/// - C# Worker minimum footprint olmalı (16GB RPi). +/// - Sadece UPDATE sorguları yapılıyor — ORM gereksiz overhead. +/// - Npgsql ARM64'te native çalışır. +/// +public class DatabaseService +{ + private readonly ILogger _logger; + private readonly string _connectionString; + + public DatabaseService( + ILogger logger, + IConfiguration configuration) + { + _logger = logger; + _connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("DefaultConnection bağlantı dizesi bulunamadı"); + } + + /// + /// RenderJob tablosunun durumunu günceller. + /// + public async Task UpdateRenderJobStatus( + string renderJobId, + string status, + int progress, + string? currentStage, + string? errorMessage = null, + string? errorStack = null, + long? processingTimeMs = null, + string? workerVersion = null, + string? workerHostname = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + var sql = @" + UPDATE ""RenderJob"" + SET ""status"" = @status::""RenderJobStatus"", + ""progress"" = @progress, + ""currentStage"" = CASE WHEN @stage IS NOT NULL THEN @stage::""RenderStage"" ELSE ""currentStage"" END, + ""errorMessage"" = COALESCE(@errorMessage, ""errorMessage""), + ""errorStack"" = COALESCE(@errorStack, ""errorStack""), + ""processingTimeMs"" = COALESCE(@processingTimeMs, ""processingTimeMs""), + ""workerVersion"" = COALESCE(@workerVersion, ""workerVersion""), + ""workerHostname"" = COALESCE(@workerHostname, ""workerHostname""), + ""startedAt"" = CASE WHEN @status = 'PROCESSING' AND ""startedAt"" IS NULL THEN NOW() ELSE ""startedAt"" END, + ""completedAt"" = CASE WHEN @status IN ('COMPLETED', 'FAILED') THEN NOW() ELSE ""completedAt"" END, + ""lastErrorAt"" = CASE WHEN @status = 'FAILED' THEN NOW() ELSE ""lastErrorAt"" END, + ""updatedAt"" = NOW() + WHERE ""id"" = @id"; + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("id", renderJobId); + cmd.Parameters.AddWithValue("status", status); + cmd.Parameters.AddWithValue("progress", progress); + cmd.Parameters.AddWithValue("stage", (object?)currentStage ?? DBNull.Value); + cmd.Parameters.AddWithValue("errorMessage", (object?)errorMessage ?? DBNull.Value); + cmd.Parameters.AddWithValue("errorStack", (object?)errorStack ?? DBNull.Value); + cmd.Parameters.AddWithValue("processingTimeMs", (object?)processingTimeMs ?? DBNull.Value); + cmd.Parameters.AddWithValue("workerVersion", (object?)workerVersion ?? DBNull.Value); + cmd.Parameters.AddWithValue("workerHostname", (object?)workerHostname ?? DBNull.Value); + + var affected = await cmd.ExecuteNonQueryAsync(); + _logger.LogDebug("RenderJob güncellendi: {Id} → {Status} ({Progress}%)", renderJobId, status, progress); + } + + /// + /// Project tablosunun durumunu günceller. + /// + public async Task UpdateProjectStatus( + string projectId, + string status, + int progress, + string? finalVideoUrl = null, + string? errorMessage = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + var sql = @" + UPDATE ""Project"" + SET ""status"" = @status::""ProjectStatus"", + ""progress"" = @progress, + ""finalVideoUrl"" = COALESCE(@finalVideoUrl, ""finalVideoUrl""), + ""errorMessage"" = @errorMessage, + ""completedAt"" = CASE WHEN @status = 'COMPLETED' THEN NOW() ELSE ""completedAt"" END, + ""updatedAt"" = NOW() + WHERE ""id"" = @id"; + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("id", projectId); + cmd.Parameters.AddWithValue("status", status); + cmd.Parameters.AddWithValue("progress", progress); + cmd.Parameters.AddWithValue("finalVideoUrl", (object?)finalVideoUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("errorMessage", (object?)errorMessage ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(); + _logger.LogDebug("Project güncellendi: {Id} → {Status} ({Progress}%)", projectId, status, progress); + } + + /// + /// Render log kaydı ekler. + /// + public async Task AddRenderLog( + string renderJobId, + string stage, + string message, + string level = "info", + int? durationMs = null, + string? metadata = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + var sql = @" + INSERT INTO ""RenderLog"" (""id"", ""renderJobId"", ""stage"", ""message"", ""level"", ""durationMs"", ""metadata"", ""createdAt"") + VALUES (gen_random_uuid(), @renderJobId, @stage::""RenderStage"", @message, @level, @durationMs, @metadata::jsonb, NOW())"; + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("renderJobId", renderJobId); + cmd.Parameters.AddWithValue("stage", stage); + cmd.Parameters.AddWithValue("message", message); + cmd.Parameters.AddWithValue("level", level); + cmd.Parameters.AddWithValue("durationMs", (object?)durationMs ?? DBNull.Value); + cmd.Parameters.AddWithValue("metadata", (object?)metadata ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/media-worker/Services/FFmpegService.cs b/media-worker/Services/FFmpegService.cs new file mode 100644 index 0000000..a297571 --- /dev/null +++ b/media-worker/Services/FFmpegService.cs @@ -0,0 +1,327 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// FFmpeg ARM64 Wrapper — Video render, birleştirme ve altyazı ekleme. +/// +/// Neden FFmpeg (Canva API yerine)? +/// - Canva API rate-limit'li ve ücretli. FFmpeg sıfır maliyet. +/// - ARM64 Raspberry Pi'da native çalışır (Alpine paketi ile). +/// - Text overlay, audio merge, transition'lar için endüstri standardı. +/// - Offline çalışır — dış API bağımlılığı yok. +/// +/// ARM64 önemli notlar: +/// - Hardware acceleration yok (RPi'da GPU desteği kısıtlı) → software encode +/// - libx264 + aac codec kullanılıyor (ARM64'te stabil) +/// - Memory sınırı: maxmuxqueuesize ile OOM koruması +/// +public class FFmpegService +{ + private readonly ILogger _logger; + private readonly FFmpegSettings _settings; + + public FFmpegService( + ILogger logger, + IOptions settings) + { + _logger = logger; + _settings = settings.Value; + + // Temp dizinini oluştur + Directory.CreateDirectory(_settings.TempDirectory); + } + + /// + /// Her sahnenin video klibine narration ses dosyasını overlay eder. + /// Çıktı: sync edilmiş video+narration dosyası + /// + public async Task MergeVideoWithNarrationAsync( + string videoPath, + string narrationPath, + int sceneOrder, + string outputDirectory, + CancellationToken ct) + { + var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_merged.mp4"); + + // Video'ya narration ses'i ekle, video ses'i kapat + var args = new StringBuilder(); + args.Append($"-y -i \"{videoPath}\" -i \"{narrationPath}\" "); + args.Append("-map 0:v:0 -map 1:a:0 "); + args.Append("-c:v libx264 -preset fast -crf 23 "); + args.Append("-c:a aac -b:a 128k "); + args.Append("-movflags +faststart "); + args.Append("-shortest "); // Kısa olan medyaya göre kes + args.Append($"\"{outputPath}\""); + + await RunFFmpegAsync(args.ToString(), ct); + + _logger.LogInformation("Video+Narration merge — Sahne {Order}: {Path}", + sceneOrder, outputPath); + + return outputPath; + } + + /// + /// Sahne videolarına altyazı ekler (burn-in). + /// ASS formatında dinamik altyazı dosyası oluşturur. + /// + public async Task AddSubtitlesAsync( + string videoPath, + string subtitleText, + int sceneOrder, + string outputDirectory, + CancellationToken ct) + { + // ASS altyazı dosyası oluştur + var assPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_sub.ass"); + var assContent = GenerateAssSubtitle(subtitleText, 0, 60); + await File.WriteAllTextAsync(assPath, assContent, ct); + + var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_subtitled.mp4"); + + var escapedAssPath = assPath.Replace(":", "\\:").Replace("'", "\\'"); + + var args = new StringBuilder(); + args.Append($"-y -i \"{videoPath}\" "); + args.Append($"-vf \"ass={escapedAssPath}\" "); + args.Append("-c:v libx264 -preset fast -crf 23 "); + args.Append("-c:a copy "); + args.Append("-movflags +faststart "); + args.Append($"\"{outputPath}\""); + + await RunFFmpegAsync(args.ToString(), ct); + + return outputPath; + } + + /// + /// Tüm sahne videolarını sırayla birleştirir ve background müzik ekler. + /// Bu, render pipeline'ının son adımıdır. + /// + public async Task ConcatenateAndFinalize( + List sceneVideoPaths, + string? musicPath, + string outputDirectory, + string projectId, + double targetDuration, + CancellationToken ct) + { + _logger.LogInformation( + "🎬 Final render başlıyor — {Count} sahne birleştiriliyor", + sceneVideoPaths.Count); + + // 1. Concat dosyası oluştur (FFmpeg concat demuxer) + var concatListPath = Path.Combine(outputDirectory, "concat_list.txt"); + var concatContent = string.Join("\n", + sceneVideoPaths.Select(p => $"file '{p}'")); + await File.WriteAllTextAsync(concatListPath, concatContent, ct); + + // 2. Önce videoları birleştir + var concatenatedPath = Path.Combine(outputDirectory, "concatenated.mp4"); + var concatArgs = new StringBuilder(); + concatArgs.Append($"-y -f concat -safe 0 -i \"{concatListPath}\" "); + concatArgs.Append("-c:v libx264 -preset fast -crf 22 "); + concatArgs.Append("-c:a aac -b:a 128k "); + concatArgs.Append("-movflags +faststart "); + concatArgs.Append($"\"{concatenatedPath}\""); + + await RunFFmpegAsync(concatArgs.ToString(), ct); + + // 3. Background müzik varsa ekle + var finalPath = Path.Combine(outputDirectory, $"final_{projectId}.mp4"); + + if (!string.IsNullOrEmpty(musicPath) && File.Exists(musicPath)) + { + // Narration sesi (stream 0:a) + müzik (stream 1:a) mix + // Müzik %20 volume (narration'ın altında kalması için) + var musicArgs = new StringBuilder(); + musicArgs.Append($"-y -i \"{concatenatedPath}\" -i \"{musicPath}\" "); + musicArgs.Append("-filter_complex \""); + musicArgs.Append("[1:a]volume=0.20[music];"); + musicArgs.Append("[0:a][music]amix=inputs=2:duration=first:dropout_transition=3[aout]\" "); + musicArgs.Append("-map 0:v:0 -map \"[aout]\" "); + musicArgs.Append("-c:v copy "); + musicArgs.Append("-c:a aac -b:a 192k "); + musicArgs.Append("-movflags +faststart "); + musicArgs.Append($"-t {targetDuration.ToString("F1", CultureInfo.InvariantCulture)} "); + musicArgs.Append($"\"{finalPath}\""); + + await RunFFmpegAsync(musicArgs.ToString(), ct); + } + else + { + // Müzik yoksa sadece süreyi kes + var trimArgs = $"-y -i \"{concatenatedPath}\" -c copy " + + $"-t {targetDuration.ToString("F1", CultureInfo.InvariantCulture)} " + + $"-movflags +faststart \"{finalPath}\""; + await RunFFmpegAsync(trimArgs, ct); + } + + // Dosya boyutunu kontrol et + var fileInfo = new FileInfo(finalPath); + _logger.LogInformation( + "✅ Final render tamamlandı — {Path} ({Size:F1} MB)", + finalPath, fileInfo.Length / (1024.0 * 1024.0)); + + return finalPath; + } + + /// + /// Sahne videosuna AudioGen ambient ses efektini overlay eder. + /// Volume: -22dB (narration ve müziğin altında, atmosferik katman). + /// AudioCraft AudioGen'den gelen ses dosyası ile birleştirir. + /// + public async Task OverlayAmbientAudioAsync( + string videoPath, + string ambientAudioPath, + int sceneOrder, + string outputDirectory, + CancellationToken ct) + { + var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_ambient.mp4"); + + var args = new StringBuilder(); + args.Append($"-y -i \"{videoPath}\" -i \"{ambientAudioPath}\" "); + args.Append("-filter_complex \""); + // Ambient ses: %8 volume (-22dB) — çok hafif arka plan katmanı + args.Append("[1:a]volume=0.08,aformat=sample_rates=44100:channel_layouts=mono[amb];"); + // Mevcut ses + ambient mix + args.Append("[0:a][amb]amix=inputs=2:duration=first:dropout_transition=2[aout]\" "); + args.Append("-map 0:v:0 -map \"[aout]\" "); + args.Append("-c:v copy "); + args.Append("-c:a aac -b:a 128k "); + args.Append("-movflags +faststart "); + args.Append($"\"{outputPath}\""); + + await RunFFmpegAsync(args.ToString(), ct); + + _logger.LogInformation( + "🔊 Ambient overlay — Sahne {Order}: {Path}", sceneOrder, outputPath); + + return outputPath; + } + + /// + /// Video dosyasının süresini FFprobe ile ölçer. + /// + public async Task GetVideoDurationAsync(string videoPath, CancellationToken ct) + { + var args = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{videoPath}\""; + + var result = await RunProcessAsync(_settings.FfprobePath, args, ct); + if (double.TryParse(result.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var duration)) + { + return duration; + } + return 0; + } + + /// + /// ASS formatında altyazı dosyası üretir. + /// Mobile-first: büyük font, ortalanmış, yarı-şeffaf arka plan. + /// + private static string GenerateAssSubtitle(string text, double startSeconds, double endSeconds) + { + var startTime = TimeSpan.FromSeconds(startSeconds).ToString(@"h\:mm\:ss\.ff"); + var endTime = TimeSpan.FromSeconds(endSeconds).ToString(@"h\:mm\:ss\.ff"); + + // Satır uzunluğunu sınırla (mobil okunabilirlik) + var words = text.Split(' '); + var lines = new List(); + var currentLine = new StringBuilder(); + + foreach (var word in words) + { + if (currentLine.Length + word.Length + 1 > 30) + { + lines.Add(currentLine.ToString().Trim()); + currentLine.Clear(); + } + currentLine.Append(word).Append(' '); + } + if (currentLine.Length > 0) + lines.Add(currentLine.ToString().Trim()); + + var formattedText = string.Join("\\N", lines); + + return $@"[Script Info] +Title: ContentGen AI Subtitles +ScriptType: v4.00+ +PlayResX: 1080 +PlayResY: 1920 +WrapStyle: 0 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,DejaVu Sans,56,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,40,40,120,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,{startTime},{endTime},Default,,0,0,0,,{formattedText} +"; + } + + /// + /// FFmpeg process'ini çalıştırır ve çıktısını izler. + /// ARM64 uyumlu — hardware acceleration kullanmaz. + /// + private async Task RunFFmpegAsync(string arguments, CancellationToken ct) + { + await RunProcessAsync(_settings.BinaryPath, arguments, ct); + } + + private async Task RunProcessAsync(string executable, string arguments, CancellationToken ct) + { + _logger.LogDebug("Process: {Exe} {Args}", executable, arguments); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) stdout.AppendLine(e.Data); + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) stderr.AppendLine(e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(ct); + + if (process.ExitCode != 0) + { + var errorOutput = stderr.ToString(); + _logger.LogError("FFmpeg hata (exit: {Code}): {Error}", + process.ExitCode, errorOutput[..Math.Min(500, errorOutput.Length)]); + throw new InvalidOperationException( + $"FFmpeg başarısız (exit: {process.ExitCode}): {errorOutput[..Math.Min(200, errorOutput.Length)]}"); + } + + return stdout.ToString(); + } +} diff --git a/media-worker/Services/HiggsFieldService.cs b/media-worker/Services/HiggsFieldService.cs new file mode 100644 index 0000000..e1f4a00 --- /dev/null +++ b/media-worker/Services/HiggsFieldService.cs @@ -0,0 +1,154 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// Higgsfield AI API Client — Video klip üretimi. +/// Her sahnenin görsel prompt'unu Higgsfield'a gönderip video dosyası indirir. +/// +public class HiggsFieldService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + public HiggsFieldService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + _httpClient.BaseAddress = new Uri(_settings.HiggsFieldBaseUrl); + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _settings.HiggsFieldApiKey); + _httpClient.Timeout = TimeSpan.FromMinutes(5); + } + + /// + /// Bir sahne için video klip üretir ve dosyayı belirtilen dizine indirir. + /// + public async Task GenerateVideoClipAsync( + ScenePayload scene, + string outputDirectory, + string aspectRatio, + CancellationToken ct) + { + _logger.LogInformation( + "🎥 Video üretimi — Sahne {Order}: {Prompt}", + scene.Order, scene.VisualPrompt[..Math.Min(80, scene.VisualPrompt.Length)]); + + var requestBody = new + { + prompt = scene.VisualPrompt, + duration = Math.Max(3, Math.Min(10, (int)scene.Duration)), + aspect_ratio = MapAspectRatio(aspectRatio), + style = "cinematic", + quality = "high" + }; + + var content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json"); + + // Video üretim isteği gönder + var response = await _httpClient.PostAsync("/generations", content, ct); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize(responseJson); + + // Generation ID al ve polling ile sonucu bekle + var generationId = result.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Generation ID alınamadı"); + + _logger.LogInformation("Higgsfield generation başlatıldı: {Id}", generationId); + + // Polling — video hazır olana kadar bekle + var videoUrl = await PollForCompletionAsync(generationId, ct); + + // Video dosyasını indir + var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_video.mp4"); + await DownloadFileAsync(videoUrl, outputPath, ct); + + var fileInfo = new FileInfo(outputPath); + + return new GeneratedMediaFile + { + SceneId = scene.Id, + SceneOrder = scene.Order, + Type = MediaFileType.VideoClip, + LocalPath = outputPath, + FileSizeBytes = fileInfo.Length, + DurationSeconds = scene.Duration, + MimeType = "video/mp4", + AiProvider = "higgsfield" + }; + } + + private async Task PollForCompletionAsync(string generationId, CancellationToken ct) + { + var maxAttempts = 120; // 10 dakika (5s interval × 120) + for (var i = 0; i < maxAttempts; i++) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(5000, ct); + + var response = await _httpClient.GetAsync($"/generations/{generationId}", ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize(json); + var status = result.GetProperty("status").GetString(); + + if (status == "completed") + { + return result.GetProperty("video_url").GetString() + ?? throw new InvalidOperationException("Video URL bulunamadı"); + } + + if (status == "failed") + { + var errorMsg = result.TryGetProperty("error", out var err) + ? err.GetString() : "Bilinmeyen hata"; + throw new InvalidOperationException($"Higgsfield video üretimi başarısız: {errorMsg}"); + } + + if (i % 6 == 0) // Her 30 saniyede bir log + { + _logger.LogInformation("Higgsfield polling — {Id}: {Status}", generationId, status); + } + } + + throw new TimeoutException($"Higgsfield video üretimi zaman aşımına uğradı: {generationId}"); + } + + private async Task DownloadFileAsync(string url, string outputPath, CancellationToken ct) + { + using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(outputPath); + await response.Content.CopyToAsync(fileStream, ct); + + _logger.LogInformation("Video indirildi: {Path} ({Size} bytes)", + outputPath, new FileInfo(outputPath).Length); + } + + private static string MapAspectRatio(string aspectRatio) => aspectRatio switch + { + "PORTRAIT_9_16" => "9:16", + "SQUARE_1_1" => "1:1", + "LANDSCAPE_16_9" => "16:9", + _ => "9:16" + }; +} diff --git a/media-worker/Services/QueueConsumerService.cs b/media-worker/Services/QueueConsumerService.cs new file mode 100644 index 0000000..56b2ade --- /dev/null +++ b/media-worker/Services/QueueConsumerService.cs @@ -0,0 +1,255 @@ +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// Redis List'ten BRPOP ile video üretim job'larını consume eden Background Service. +/// NestJS'in VideoGenerationProducer'ı tarafından LPUSH ile eklenen job'ları işler. +/// +/// Neden BRPOP? +/// - Atomic: Job'ı listeden alır ve hemen siler — duplicate processing olmaz +/// - Blocking: Boş kuyrukta CPU cycle harcamaz (polling'den üstün) +/// - FIFO: LPUSH + BRPOP = en eski job önce işlenir +/// +public class QueueConsumerService : BackgroundService +{ + private readonly ILogger _logger; + private readonly RedisSettings _redisSettings; + private readonly WorkerSettings _workerSettings; + private readonly VideoRenderPipeline _pipeline; + private readonly DatabaseService _dbService; + private IConnectionMultiplexer? _redis; + private readonly SemaphoreSlim _concurrencySemaphore; + + public QueueConsumerService( + ILogger logger, + IOptions redisSettings, + IOptions workerSettings, + VideoRenderPipeline pipeline, + DatabaseService dbService) + { + _logger = logger; + _redisSettings = redisSettings.Value; + _workerSettings = workerSettings.Value; + _pipeline = pipeline; + _dbService = dbService; + _concurrencySemaphore = new SemaphoreSlim(_workerSettings.MaxConcurrency); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "🎬 Queue Consumer başlatıldı — Kuyruk: {Queue}, Eşzamanlılık: {Concurrency}", + _redisSettings.QueueKey, _workerSettings.MaxConcurrency); + + await ConnectToRedis(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _concurrencySemaphore.WaitAsync(stoppingToken); + + var db = _redis!.GetDatabase(); + + // BRPOP — blocking pop, 5 saniye timeout + var result = await db.ListRightPopAsync(new RedisKey(_redisSettings.QueueKey)); + + if (result.IsNull) + { + _concurrencySemaphore.Release(); + await Task.Delay( + TimeSpan.FromSeconds(_workerSettings.PollIntervalSeconds), + stoppingToken); + continue; + } + + // Job'ı deserialize et + var jobJson = result.ToString(); + var job = JsonSerializer.Deserialize(jobJson); + + if (job == null || string.IsNullOrEmpty(job.ProjectId)) + { + _logger.LogWarning("Geçersiz job payload atlandı: {Payload}", + jobJson[..Math.Min(200, jobJson.Length)]); + _concurrencySemaphore.Release(); + continue; + } + + _logger.LogInformation( + "📥 Job alındı — Project: {ProjectId}, RenderJob: {RenderJobId}", + job.ProjectId, job.RenderJobId); + + // Job'ı arka planda işle (semaphore serbest bırakma işlem sonunda yapılır) + _ = ProcessJobAsync(job, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Queue Consumer durduruluyor..."); + break; + } + catch (RedisConnectionException ex) + { + _logger.LogError(ex, "Redis bağlantı hatası — 5s sonra tekrar denenecek"); + _concurrencySemaphore.Release(); + await Task.Delay(5000, stoppingToken); + await ConnectToRedis(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Queue Consumer beklenmeyen hata"); + _concurrencySemaphore.Release(); + await Task.Delay(2000, stoppingToken); + } + } + } + + private async Task ProcessJobAsync(VideoGenerationJob job, CancellationToken ct) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // DB'de render job durumunu PROCESSING yap + await _dbService.UpdateRenderJobStatus( + job.RenderJobId, "PROCESSING", 0, "VIDEO_GENERATION", + workerVersion: _workerSettings.WorkerVersion, + workerHostname: Environment.MachineName); + + // DB'de proje durumunu PROCESSING yap + await _dbService.UpdateProjectStatus(job.ProjectId, "GENERATING_MEDIA", 5); + + // İlerleme callback — Redis pub/sub ile frontend'e bildirim + var progressCallback = CreateProgressCallback(job); + + // Render pipeline'ını çalıştır + var finalVideoUrl = await _pipeline.ExecuteAsync(job, progressCallback, ct); + + sw.Stop(); + + // Başarılı — DB güncelle + await _dbService.UpdateRenderJobStatus( + job.RenderJobId, "COMPLETED", 100, "FINALIZATION", + processingTimeMs: sw.ElapsedMilliseconds); + + await _dbService.UpdateProjectStatus( + job.ProjectId, "COMPLETED", 100, + finalVideoUrl: finalVideoUrl); + + // Redis pub/sub ile tamamlanma bildirimi + await PublishCompletion(job.ProjectId, finalVideoUrl); + + _logger.LogInformation( + "✅ Video üretimi tamamlandı — Project: {ProjectId}, Süre: {Duration}s, URL: {Url}", + job.ProjectId, sw.Elapsed.TotalSeconds, finalVideoUrl); + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError(ex, + "❌ Video üretimi başarısız — Project: {ProjectId}, Hata: {Error}", + job.ProjectId, ex.Message); + + await _dbService.UpdateRenderJobStatus( + job.RenderJobId, "FAILED", 0, null, + errorMessage: ex.Message, + errorStack: ex.StackTrace, + processingTimeMs: sw.ElapsedMilliseconds); + + await _dbService.UpdateProjectStatus( + job.ProjectId, "FAILED", 0, errorMessage: ex.Message); + } + finally + { + _concurrencySemaphore.Release(); + } + } + + private Func CreateProgressCallback(VideoGenerationJob job) + { + return async (progress, stage) => + { + try + { + await _dbService.UpdateRenderJobStatus( + job.RenderJobId, "PROCESSING", progress, stage); + await _dbService.UpdateProjectStatus( + job.ProjectId, "GENERATING_MEDIA", progress); + + // Redis pub/sub ile ilerleme bildirimi + if (_redis != null) + { + var subscriber = _redis.GetSubscriber(); + var progressPayload = JsonSerializer.Serialize(new + { + projectId = job.ProjectId, + renderJobId = job.RenderJobId, + progress, + stage, + timestamp = DateTime.UtcNow.ToString("O") + }); + await subscriber.PublishAsync( + new RedisChannel(_redisSettings.ProgressChannel, RedisChannel.PatternMode.Literal), + progressPayload); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "İlerleme bildirimi gönderilemedi"); + } + }; + } + + private async Task PublishCompletion(string projectId, string finalVideoUrl) + { + if (_redis == null) return; + + var subscriber = _redis.GetSubscriber(); + var payload = JsonSerializer.Serialize(new + { + projectId, + finalVideoUrl, + status = "COMPLETED", + timestamp = DateTime.UtcNow.ToString("O") + }); + + await subscriber.PublishAsync( + new RedisChannel(_redisSettings.CompletionChannel, RedisChannel.PatternMode.Literal), + payload); + } + + private async Task ConnectToRedis(CancellationToken ct) + { + var attempts = 0; + while (!ct.IsCancellationRequested) + { + try + { + _redis = await ConnectionMultiplexer.ConnectAsync(_redisSettings.ConnectionString); + _logger.LogInformation("✅ Redis bağlantısı kuruldu"); + return; + } + catch (Exception ex) + { + attempts++; + _logger.LogWarning(ex, + "Redis bağlantı denemesi #{Attempt} başarısız — 3s sonra tekrar", + attempts); + await Task.Delay(3000, ct); + } + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Queue Consumer durduruluyor..."); + _redis?.Dispose(); + await base.StopAsync(cancellationToken); + } +} diff --git a/media-worker/Services/S3StorageService.cs b/media-worker/Services/S3StorageService.cs new file mode 100644 index 0000000..9352cdb --- /dev/null +++ b/media-worker/Services/S3StorageService.cs @@ -0,0 +1,172 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Transfer; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// S3/Cloudflare R2 depolama servisi. +/// Üretilen medya dosyalarını nesne depolamaya yükler. +/// +/// Neden Cloudflare R2? +/// - Egress (çıkış) ücretsiz — global SaaS için büyük maliyet avantajı +/// - S3 uyumlu API — AWS SDK ile doğrudan çalışır +/// - ARM64 uyumlu — AWS SDK .NET tamamen cross-platform +/// +public class S3StorageService +{ + private readonly ILogger _logger; + private readonly S3Settings _settings; + private readonly AmazonS3Client _s3Client; + + public S3StorageService( + ILogger logger, + IOptions settings) + { + _logger = logger; + _settings = settings.Value; + + var config = new AmazonS3Config + { + ServiceURL = _settings.Endpoint, + ForcePathStyle = true, + RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED, + ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED, + }; + + _s3Client = new AmazonS3Client( + _settings.AccessKey, + _settings.SecretKey, + config); + } + + /// + /// Tek bir dosyayı S3/R2'ye yükler. + /// Büyük dosyalar (>5MB) otomatik multipart upload kullanır. + /// + public async Task<(string url, string key)> UploadFileAsync( + string localPath, + string projectId, + string fileName, + string contentType, + CancellationToken ct) + { + var key = $"projects/{projectId}/{DateTime.UtcNow:yyyy/MM/dd}/{fileName}"; + + _logger.LogInformation( + "☁️ S3 yükleme başlıyor — Key: {Key}, Dosya: {File}", + key, Path.GetFileName(localPath)); + + var fileInfo = new FileInfo(localPath); + + if (fileInfo.Length > 5 * 1024 * 1024) // 5MB üstü → multipart + { + await UploadMultipartAsync(localPath, key, contentType, ct); + } + else + { + var request = new PutObjectRequest + { + BucketName = _settings.BucketName, + Key = key, + FilePath = localPath, + ContentType = contentType, + DisablePayloadSigning = true, + }; + + await _s3Client.PutObjectAsync(request, ct); + } + + var publicUrl = string.IsNullOrEmpty(_settings.PublicBaseUrl) + ? $"{_settings.Endpoint}/{_settings.BucketName}/{key}" + : $"{_settings.PublicBaseUrl.TrimEnd('/')}/{key}"; + + _logger.LogInformation( + "✅ S3 yükleme tamamlandı — {Key} ({Size} bytes)", + key, fileInfo.Length); + + return (publicUrl, key); + } + + /// + /// Render pipeline sonucu üretilen tüm medya dosyalarını toplu yükler. + /// + public async Task> UploadAllMediaAsync( + string projectId, + List mediaFiles, + CancellationToken ct) + { + var uploadedFiles = new List(); + + foreach (var media in mediaFiles) + { + if (!File.Exists(media.LocalPath)) + { + _logger.LogWarning("Dosya bulunamadı, atlanıyor: {Path}", media.LocalPath); + continue; + } + + var fileName = Path.GetFileName(media.LocalPath); + var (url, key) = await UploadFileAsync( + media.LocalPath, projectId, fileName, media.MimeType, ct); + + media.S3Url = url; + media.S3Key = key; + media.FileSizeBytes = new FileInfo(media.LocalPath).Length; + + uploadedFiles.Add(media); + } + + _logger.LogInformation( + "Toplu yükleme tamamlandı — {Count}/{Total} dosya yüklendi", + uploadedFiles.Count, mediaFiles.Count); + + return uploadedFiles; + } + + /// + /// Final videoyu yükler ve public URL döner. + /// + public async Task UploadFinalVideoAsync( + string localPath, + string projectId, + CancellationToken ct) + { + var fileName = $"final_video_{DateTime.UtcNow:yyyyMMdd_HHmmss}.mp4"; + var (url, _) = await UploadFileAsync( + localPath, projectId, fileName, "video/mp4", ct); + return url; + } + + private async Task UploadMultipartAsync( + string localPath, string key, string contentType, CancellationToken ct) + { + using var transferUtility = new TransferUtility(_s3Client); + + var request = new TransferUtilityUploadRequest + { + BucketName = _settings.BucketName, + Key = key, + FilePath = localPath, + ContentType = contentType, + PartSize = 5 * 1024 * 1024, // 5MB parçalar + DisablePayloadSigning = true, + }; + + request.UploadProgressEvent += (_, args) => + { + if (args.PercentDone % 25 == 0) + { + _logger.LogInformation( + "Multipart upload: {Key} — %{Percent}", + key, args.PercentDone); + } + }; + + await transferUtility.UploadAsync(request, ct); + } +} diff --git a/media-worker/Services/SunoMusicService.cs b/media-worker/Services/SunoMusicService.cs new file mode 100644 index 0000000..3ce1957 --- /dev/null +++ b/media-worker/Services/SunoMusicService.cs @@ -0,0 +1,137 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// Suno AI API Client — Background müzik üretimi. +/// Projenin musicPrompt'unu kullanarak AI müzik üretir. +/// +public class SunoMusicService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + public SunoMusicService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + _httpClient.BaseAddress = new Uri(_settings.SunoBaseUrl); + _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_settings.SunoApiKey}"); + _httpClient.Timeout = TimeSpan.FromMinutes(5); + } + + /// + /// Proje için background müzik üretir ve dosyaya kaydeder. + /// + public async Task GenerateMusicAsync( + string musicPrompt, + int targetDurationSeconds, + string outputDirectory, + CancellationToken ct) + { + _logger.LogInformation( + "🎵 Müzik üretimi — Prompt: \"{Prompt}\", Süre: {Duration}s", + musicPrompt[..Math.Min(80, musicPrompt.Length)], + targetDurationSeconds); + + var requestBody = new + { + prompt = musicPrompt, + duration = Math.Min(120, targetDurationSeconds + 5), // Biraz fazla üret (fade-out için) + make_instrumental = true, // Vokal olmadan + model = "chirp-v3" + }; + + var content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync("/generations", content, ct); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize(responseJson); + + // Generation ID al + var generationId = result.GetProperty("id").GetString() + ?? throw new InvalidOperationException("Suno generation ID alınamadı"); + + _logger.LogInformation("Suno generation başlatıldı: {Id}", generationId); + + // Polling — müzik hazır olana kadar bekle + var musicUrl = await PollForCompletionAsync(generationId, ct); + + // Müzik dosyasını indir + var outputPath = Path.Combine(outputDirectory, "background_music.mp3"); + await DownloadFileAsync(musicUrl, outputPath, ct); + + var fileInfo = new FileInfo(outputPath); + + _logger.LogInformation("Müzik indirildi: {Path} ({Size} bytes)", outputPath, fileInfo.Length); + + return new GeneratedMediaFile + { + SceneId = string.Empty, // Proje seviyesi — sahneye bağlı değil + SceneOrder = 0, + Type = MediaFileType.AudioMusic, + LocalPath = outputPath, + FileSizeBytes = fileInfo.Length, + DurationSeconds = targetDurationSeconds, + MimeType = "audio/mpeg", + AiProvider = "suno" + }; + } + + private async Task PollForCompletionAsync(string generationId, CancellationToken ct) + { + var maxAttempts = 60; // 5 dakika (5s × 60) + for (var i = 0; i < maxAttempts; i++) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(5000, ct); + + var response = await _httpClient.GetAsync($"/generations/{generationId}", ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize(json); + var status = result.GetProperty("status").GetString(); + + if (status == "completed" || status == "complete") + { + return result.GetProperty("audio_url").GetString() + ?? throw new InvalidOperationException("Müzik URL bulunamadı"); + } + + if (status == "failed" || status == "error") + { + var errorMsg = result.TryGetProperty("error", out var err) + ? err.GetString() : "Bilinmeyen hata"; + throw new InvalidOperationException($"Suno müzik üretimi başarısız: {errorMsg}"); + } + } + + throw new TimeoutException($"Suno müzik üretimi zaman aşımına uğradı: {generationId}"); + } + + private async Task DownloadFileAsync(string url, string outputPath, CancellationToken ct) + { + using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(outputPath); + await response.Content.CopyToAsync(fileStream, ct); + } +} diff --git a/media-worker/Services/TtsService.cs b/media-worker/Services/TtsService.cs new file mode 100644 index 0000000..015075c --- /dev/null +++ b/media-worker/Services/TtsService.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// ElevenLabs TTS API Client — Metin → Ses dönüşümü. +/// Her sahnenin narrationText'ini sese çevirir. +/// +public class TtsService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ApiSettings _settings; + + public TtsService( + HttpClient httpClient, + ILogger logger, + IOptions settings) + { + _httpClient = httpClient; + _logger = logger; + _settings = settings.Value; + + _httpClient.BaseAddress = new Uri(_settings.TtsBaseUrl); + _httpClient.DefaultRequestHeaders.Add("xi-api-key", _settings.TtsApiKey); + _httpClient.Timeout = TimeSpan.FromMinutes(2); + } + + /// + /// Bir sahnenin narration metnini sese çevirir ve dosyaya kaydeder. + /// + public async Task GenerateNarrationAsync( + ScenePayload scene, + string outputDirectory, + string voiceStyle, + CancellationToken ct) + { + _logger.LogInformation( + "🎙️ TTS üretimi — Sahne {Order}: \"{Text}\"", + scene.Order, + scene.NarrationText[..Math.Min(60, scene.NarrationText.Length)]); + + var voiceId = _settings.TtsVoiceId; + var requestBody = new + { + text = scene.NarrationText, + model_id = "eleven_multilingual_v2", + voice_settings = new + { + stability = 0.5, + similarity_boost = 0.75, + style = 0.3, + use_speaker_boost = true + } + }; + + var content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json"); + + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var response = await _httpClient.PostAsync( + $"/text-to-speech/{voiceId}", + content, ct); + + response.EnsureSuccessStatusCode(); + + // Ses dosyasını kaydet + var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_narration.mp3"); + await using var fileStream = File.Create(outputPath); + await response.Content.CopyToAsync(fileStream, ct); + + var fileInfo = new FileInfo(outputPath); + + _logger.LogInformation( + "TTS tamamlandı — Sahne {Order}: {Size} bytes", + scene.Order, fileInfo.Length); + + return new GeneratedMediaFile + { + SceneId = scene.Id, + SceneOrder = scene.Order, + Type = MediaFileType.AudioNarration, + LocalPath = outputPath, + FileSizeBytes = fileInfo.Length, + DurationSeconds = scene.Duration, + MimeType = "audio/mpeg", + AiProvider = "elevenlabs" + }; + } +} diff --git a/media-worker/Services/VideoRenderPipeline.cs b/media-worker/Services/VideoRenderPipeline.cs new file mode 100644 index 0000000..8032d39 --- /dev/null +++ b/media-worker/Services/VideoRenderPipeline.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SaasMediaWorker.Configuration; +using SaasMediaWorker.Models; + +namespace SaasMediaWorker.Services; + +/// +/// Video render orchestrator — Tüm medya üretim ve birleştirme pipeline'ını yönetir. +/// +/// Pipeline Adımları: +/// 1. Temp dizin oluştur +/// 2. Her sahne için: Higgsfield (video klip) + TTS (narration) +/// 3. AudioCraft MusicGen (background müzik) — fallback: Suno +/// 4. AudioCraft AudioGen (sahne bazlı ambient sesler) +/// 5. Her sahne için: video + narration + ambient merge + altyazı +/// 6. Tüm sahneleri birleştir + müzik mix (3 katmanlı ses) +/// 7. S3'e yükle +/// 8. Temp dizini temizle +/// +public class VideoRenderPipeline +{ + private readonly ILogger _logger; + private readonly HiggsFieldService _higgsField; + private readonly TtsService _tts; + private readonly SunoMusicService _sunoMusic; + private readonly AudioCraftService _audioCraft; + private readonly FFmpegService _ffmpeg; + private readonly S3StorageService _s3; + private readonly FFmpegSettings _ffmpegSettings; + + public VideoRenderPipeline( + ILogger logger, + HiggsFieldService higgsField, + TtsService tts, + SunoMusicService sunoMusic, + AudioCraftService audioCraft, + FFmpegService ffmpeg, + S3StorageService s3, + IOptions ffmpegSettings) + { + _logger = logger; + _higgsField = higgsField; + _tts = tts; + _sunoMusic = sunoMusic; + _audioCraft = audioCraft; + _ffmpeg = ffmpeg; + _s3 = s3; + _ffmpegSettings = ffmpegSettings.Value; + } + + /// + /// Tam render pipeline'ını çalıştırır. + /// Giriş: VideoGenerationJob (Redis'ten alınan) + /// Çıkış: Final video'nun S3 URL'i + /// + public async Task ExecuteAsync( + VideoGenerationJob job, + Func progressCallback, + CancellationToken ct) + { + var projectDir = Path.Combine(_ffmpegSettings.TempDirectory, job.ProjectId); + Directory.CreateDirectory(projectDir); + + var allMediaFiles = new List(); + + try + { + var scenes = job.Scenes.OrderBy(s => s.Order).ToList(); + var totalSteps = scenes.Count * 3 + 4; // (video+tts+ambient per scene) + music + merge + upload + finalize + var completedSteps = 0; + + // ═══════════════════════════════════════ + // ADIM 1: Her sahne için video klip üret + // ═══════════════════════════════════════ + _logger.LogInformation( + "📹 Adım 1/5: Video klip üretimi — {Count} sahne", scenes.Count); + + var videoTasks = new List>(); + foreach (var scene in scenes) + { + videoTasks.Add(GenerateVideoClipWithProgress( + scene, projectDir, job.AspectRatio, + () => + { + completedSteps++; + var progress = (int)(completedSteps / (double)totalSteps * 60); // Video %0-60 + return progressCallback(Math.Min(progress, 60), "VIDEO_GENERATION"); + }, ct)); + } + + var videoResults = await Task.WhenAll(videoTasks); + allMediaFiles.AddRange(videoResults); + + // ═══════════════════════════════════════ + // ADIM 2: Her sahne için TTS narration üret + // ═══════════════════════════════════════ + _logger.LogInformation("🎙️ Adım 2/5: TTS narration üretimi"); + + var voiceStyle = job.ScriptJson?.VoiceStyle ?? "Deep authoritative male voice"; + var ttsTasks = new List>(); + + foreach (var scene in scenes) + { + ttsTasks.Add(GenerateTtsWithProgress( + scene, projectDir, voiceStyle, + () => + { + completedSteps++; + var progress = 60 + (int)(completedSteps / (double)totalSteps * 10); // TTS %60-70 + return progressCallback(Math.Min(progress, 70), "TTS_GENERATION"); + }, ct)); + } + + var ttsResults = await Task.WhenAll(ttsTasks); + allMediaFiles.AddRange(ttsResults); + + // ═══════════════════════════════════════ + // ADIM 3: Background müzik üret + // ═══════════════════════════════════════ + _logger.LogInformation("🎵 Adım 3/6: Background müzik üretimi (AudioCraft MusicGen)"); + await progressCallback(72, "MUSIC_GENERATION"); + + var musicPrompt = job.ScriptJson?.MusicPrompt + ?? "Cinematic orchestral, mysterious, slow build, 80 BPM, strings and piano"; + + // Teknik parametreleri ScriptPayload'dan al + MusicTechnicalParams? musicTechnical = null; + if (job.ScriptJson?.MusicTechnical != null) + { + musicTechnical = new MusicTechnicalParams + { + Bpm = job.ScriptJson.MusicTechnical.Bpm, + Key = job.ScriptJson.MusicTechnical.Key, + Instruments = job.ScriptJson.MusicTechnical.Instruments, + EmotionalArc = job.ScriptJson.MusicTechnical.EmotionalArc + }; + } + + GeneratedMediaFile? musicFile = null; + + // Önce AudioCraft MusicGen dene, başarısız olursa Suno fallback + try + { + musicFile = await _audioCraft.GenerateMusicAsync( + musicPrompt, musicTechnical, job.TargetDuration, projectDir, ct); + allMediaFiles.Add(musicFile); + _logger.LogInformation("✅ MusicGen başarılı — AudioCraft ile müzik üretildi"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MusicGen başarısız — Suno fallback deneniyor"); + + try + { + musicFile = await _sunoMusic.GenerateMusicAsync( + musicPrompt, job.TargetDuration, projectDir, ct); + allMediaFiles.Add(musicFile); + _logger.LogInformation("✅ Suno fallback başarılı"); + } + catch (Exception sunoEx) + { + _logger.LogWarning(sunoEx, "Suno da başarısız — müziksiz devam ediliyor"); + } + } + + await progressCallback(78, "MUSIC_GENERATION"); + + // ═══════════════════════════════════════ + // ADIM 4: AudioGen — Sahne bazlı ambient sesler + // ═══════════════════════════════════════ + _logger.LogInformation("🔊 Adım 4/6: AudioGen ambient ses efektleri"); + await progressCallback(79, "AMBIENT_GENERATION"); + + var ambientFiles = new List(); + try + { + ambientFiles = await _audioCraft.GenerateAllAmbientSoundsAsync( + scenes, projectDir, ct); + allMediaFiles.AddRange(ambientFiles); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Ambient ses üretimi başarısız — ambientsiz devam ediliyor"); + } + + await progressCallback(82, "AMBIENT_GENERATION"); + + // ═══════════════════════════════════════ + // ADIM 5: FFmpeg — Birleştirme, ambient overlay ve altyazı + // ═══════════════════════════════════════ + _logger.LogInformation("🎬 Adım 5/6: FFmpeg render — merge + ambient + subtitle + finalize"); + await progressCallback(83, "MEDIA_MERGE"); + + var mergedScenePaths = new List(); + + foreach (var scene in scenes) + { + var videoFile = videoResults.FirstOrDefault(v => + v.SceneOrder == scene.Order && v.Type == MediaFileType.VideoClip); + var ttsFile = ttsResults.FirstOrDefault(t => + t.SceneOrder == scene.Order && t.Type == MediaFileType.AudioNarration); + + if (videoFile == null) + { + _logger.LogWarning("Sahne {Order} için video bulunamadı, atlanıyor", scene.Order); + continue; + } + + var currentPath = videoFile.LocalPath; + + // Video + Narration merge + if (ttsFile != null) + { + currentPath = await _ffmpeg.MergeVideoWithNarrationAsync( + currentPath, ttsFile.LocalPath, scene.Order, projectDir, ct); + } + + // Ambient ses overlay (AudioGen) + var ambientFile = ambientFiles.FirstOrDefault(a => + a.SceneOrder == scene.Order && a.Type == MediaFileType.AudioAmbient); + if (ambientFile != null) + { + currentPath = await _ffmpeg.OverlayAmbientAudioAsync( + currentPath, ambientFile.LocalPath, scene.Order, projectDir, ct); + } + + // Altyazı ekle + if (!string.IsNullOrEmpty(scene.SubtitleText)) + { + currentPath = await _ffmpeg.AddSubtitlesAsync( + currentPath, scene.SubtitleText, scene.Order, projectDir, ct); + } + + mergedScenePaths.Add(currentPath); + } + + await progressCallback(88, "MEDIA_MERGE"); + + // Final concatenation + music mix + var finalLocalPath = await _ffmpeg.ConcatenateAndFinalize( + mergedScenePaths, + musicFile?.LocalPath, + projectDir, + job.ProjectId, + job.TargetDuration, + ct); + + allMediaFiles.Add(new GeneratedMediaFile + { + Type = MediaFileType.FinalVideo, + LocalPath = finalLocalPath, + FileSizeBytes = new FileInfo(finalLocalPath).Length, + DurationSeconds = job.TargetDuration, + MimeType = "video/mp4" + }); + + await progressCallback(92, "MEDIA_MERGE"); + + // ═══════════════════════════════════════ + // ADIM 6: S3'e yükle + // ═══════════════════════════════════════ + _logger.LogInformation("☁️ Adım 6/6: S3 yükleme"); + await progressCallback(94, "UPLOAD"); + + // Tüm medya dosyalarını yükle + await _s3.UploadAllMediaAsync(job.ProjectId, allMediaFiles, ct); + + // Final videoyu yükle + var finalVideoUrl = await _s3.UploadFinalVideoAsync( + finalLocalPath, job.ProjectId, ct); + + await progressCallback(98, "FINALIZATION"); + + _logger.LogInformation( + "🎉 Pipeline tamamlandı — Project: {ProjectId}, URL: {Url}", + job.ProjectId, finalVideoUrl); + + return finalVideoUrl; + } + finally + { + // Temp dizini temizle (bellek/disk tasarrufu) + CleanupTempDirectory(projectDir); + } + } + + private async Task GenerateVideoClipWithProgress( + ScenePayload scene, string outputDir, string aspectRatio, + Func onComplete, CancellationToken ct) + { + var result = await _higgsField.GenerateVideoClipAsync( + scene, outputDir, aspectRatio, ct); + await onComplete(); + return result; + } + + private async Task GenerateTtsWithProgress( + ScenePayload scene, string outputDir, string voiceStyle, + Func onComplete, CancellationToken ct) + { + var result = await _tts.GenerateNarrationAsync( + scene, outputDir, voiceStyle, ct); + await onComplete(); + return result; + } + + private void CleanupTempDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + _logger.LogInformation("Temp dizin temizlendi: {Path}", path); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Temp dizin temizlenemedi: {Path}", path); + } + } +} diff --git a/media-worker/appsettings.json b/media-worker/appsettings.json new file mode 100644 index 0000000..a829a82 --- /dev/null +++ b/media-worker/appsettings.json @@ -0,0 +1,49 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Redis": { + "ConnectionString": "localhost:6379", + "QueueKey": "contgen:queue:video-generation", + "ProgressChannel": "contgen:progress", + "CompletionChannel": "contgen:completion" + }, + "S3": { + "Endpoint": "https://your-account.r2.cloudflarestorage.com", + "AccessKey": "your-r2-access-key", + "SecretKey": "your-r2-secret-key", + "BucketName": "contgen-media", + "Region": "auto", + "PublicBaseUrl": "https://media.yourdomain.com" + }, + "ApiSettings": { + "HiggsFieldBaseUrl": "https://api.higgsfield.ai/v1", + "HiggsFieldApiKey": "your-higgsfield-api-key", + "TtsBaseUrl": "https://api.elevenlabs.io/v1", + "TtsApiKey": "your-elevenlabs-api-key", + "TtsVoiceId": "pNInz6obpgDQGcFmaJgB", + "SunoBaseUrl": "https://api.suno.ai/v1", + "SunoApiKey": "your-suno-api-key", + "CoreApiBaseUrl": "http://localhost:3000/api" + }, + "FFmpeg": { + "BinaryPath": "/usr/bin/ffmpeg", + "FfprobePath": "/usr/bin/ffprobe", + "TempDirectory": "/tmp/contgen-render", + "MaxConcurrentRenders": 2, + "HardwareAcceleration": "none", + "FontPath": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + }, + "WorkerSettings": { + "MaxConcurrency": 2, + "PollIntervalSeconds": 2, + "MaxRetryAttempts": 3, + "WorkerVersion": "1.0.0" + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=contgen_ai_db;Username=contgen_admin;Password=contgen_secure_2026" + } +} diff --git a/package-lock.json b/package-lock.json index 59f5615..6f77da9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^21.0.1", "zod": "^4.3.5" }, "devDependencies": { @@ -1137,7 +1138,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3075,7 +3075,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3242,7 +3241,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz", "integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==", - "peer": true, "dependencies": { "file-type": "21.2.0", "iterare": "1.2.1", @@ -3288,7 +3286,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz", "integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==", "hasInstallScript": true, - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3368,7 +3365,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz", "integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3389,7 +3385,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz", "integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3724,7 +3719,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, - "peer": true, "engines": { "node": ">=16.13" }, @@ -3789,7 +3783,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4695,7 +4688,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4810,7 +4802,6 @@ "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4975,7 +4966,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5613,7 +5603,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5667,7 +5656,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6157,7 +6145,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6231,7 +6218,6 @@ "version": "5.66.4", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz", "integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.8.2", @@ -6305,7 +6291,6 @@ "version": "7.2.7", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz", "integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.2", "keyv": "^5.5.4" @@ -6457,7 +6442,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6501,14 +6485,12 @@ "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "peer": true + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7194,7 +7176,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -7253,7 +7236,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7313,7 +7295,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8674,7 +8655,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9518,7 +9498,6 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10263,6 +10242,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "peer": true, "engines": { "node": ">= 6" } @@ -10446,7 +10426,6 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10573,7 +10552,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -10603,7 +10581,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -10757,7 +10734,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10811,7 +10787,6 @@ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -11703,6 +11678,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz", + "integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", @@ -11876,7 +11868,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12191,7 +12182,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12329,7 +12319,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12668,6 +12657,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -12685,6 +12675,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12697,6 +12688,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12710,6 +12702,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -12718,13 +12711,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -12734,6 +12729,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12746,6 +12742,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index 39d2d66..efc4098 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^21.0.1", "zod": "^4.3.5" }, "devDependencies": { diff --git a/prisma/migrations/20260328155952_add_video_saas_models/migration.sql b/prisma/migrations/20260328155952_add_video_saas_models/migration.sql new file mode 100644 index 0000000..507a8a2 --- /dev/null +++ b/prisma/migrations/20260328155952_add_video_saas_models/migration.sql @@ -0,0 +1,401 @@ +-- CreateEnum +CREATE TYPE "ProjectStatus" AS ENUM ('DRAFT', 'GENERATING_SCRIPT', 'PENDING', 'GENERATING_MEDIA', 'RENDERING', 'COMPLETED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "AspectRatio" AS ENUM ('PORTRAIT_9_16', 'SQUARE_1_1', 'LANDSCAPE_16_9'); + +-- CreateEnum +CREATE TYPE "VideoStyle" AS ENUM ('CINEMATIC', 'DOCUMENTARY', 'EDUCATIONAL', 'STORYTELLING', 'NEWS', 'PROMOTIONAL', 'ARTISTIC', 'MINIMALIST'); + +-- CreateEnum +CREATE TYPE "MediaType" AS ENUM ('VIDEO_CLIP', 'AUDIO_NARRATION', 'AUDIO_MUSIC', 'SUBTITLE', 'THUMBNAIL', 'FINAL_VIDEO'); + +-- CreateEnum +CREATE TYPE "TransitionType" AS ENUM ('CUT', 'FADE', 'DISSOLVE', 'SLIDE_LEFT', 'SLIDE_RIGHT', 'SLIDE_UP', 'ZOOM_IN', 'ZOOM_OUT', 'WIPE'); + +-- CreateEnum +CREATE TYPE "RenderJobStatus" AS ENUM ('QUEUED', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "RenderStage" AS ENUM ('VIDEO_GENERATION', 'TTS_GENERATION', 'MUSIC_GENERATION', 'MEDIA_MERGE', 'SUBTITLE_OVERLAY', 'FINALIZATION', 'UPLOAD'); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "title" VARCHAR(200) NOT NULL, + "description" VARCHAR(1000), + "prompt" VARCHAR(2000) NOT NULL, + "scriptJson" JSONB, + "scriptVersion" INTEGER NOT NULL DEFAULT 0, + "language" VARCHAR(5) NOT NULL DEFAULT 'tr', + "aspectRatio" "AspectRatio" NOT NULL DEFAULT 'PORTRAIT_9_16', + "videoStyle" "VideoStyle" NOT NULL DEFAULT 'CINEMATIC', + "targetDuration" INTEGER NOT NULL DEFAULT 60, + "status" "ProjectStatus" NOT NULL DEFAULT 'DRAFT', + "progress" INTEGER NOT NULL DEFAULT 0, + "errorMessage" TEXT, + "finalVideoUrl" TEXT, + "thumbnailUrl" TEXT, + "creditsUsed" INTEGER NOT NULL DEFAULT 0, + "viewCount" INTEGER NOT NULL DEFAULT 0, + "isTemplate" BOOLEAN NOT NULL DEFAULT false, + "templateId" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Scene" ( + "id" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "title" VARCHAR(200), + "narrationText" TEXT NOT NULL, + "visualPrompt" TEXT NOT NULL, + "subtitleText" TEXT, + "duration" DOUBLE PRECISION NOT NULL DEFAULT 5.0, + "transitionType" "TransitionType" NOT NULL DEFAULT 'CUT', + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Scene_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MediaAsset" ( + "id" TEXT NOT NULL, + "type" "MediaType" NOT NULL, + "s3Key" TEXT, + "s3Bucket" VARCHAR(100), + "url" TEXT, + "fileName" VARCHAR(255), + "mimeType" VARCHAR(100), + "sizeBytes" BIGINT, + "durationMs" INTEGER, + "aiProvider" VARCHAR(50), + "aiJobId" TEXT, + "projectId" TEXT NOT NULL, + "sceneId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RenderJob" ( + "id" TEXT NOT NULL, + "status" "RenderJobStatus" NOT NULL DEFAULT 'QUEUED', + "currentStage" "RenderStage", + "queueName" VARCHAR(100) NOT NULL DEFAULT 'video-generation', + "bullJobId" VARCHAR(100), + "attemptNumber" INTEGER NOT NULL DEFAULT 1, + "maxAttempts" INTEGER NOT NULL DEFAULT 3, + "workerHostname" VARCHAR(100), + "processingTimeMs" INTEGER, + "errorMessage" TEXT, + "finalVideoUrl" TEXT, + "finalVideoS3Key" TEXT, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + + CONSTRAINT "RenderJob_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RenderLog" ( + "id" TEXT NOT NULL, + "stage" "RenderStage" NOT NULL, + "level" VARCHAR(10) NOT NULL DEFAULT 'info', + "message" TEXT NOT NULL, + "durationMs" INTEGER, + "metadata" JSONB, + "renderJobId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RenderLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" TEXT NOT NULL, + "title" VARCHAR(200) NOT NULL, + "description" VARCHAR(500), + "thumbnailUrl" TEXT, + "previewVideoUrl" TEXT, + "category" VARCHAR(50) NOT NULL DEFAULT 'general', + "tags" TEXT[], + "language" VARCHAR(5) NOT NULL DEFAULT 'tr', + "originalProjectId" TEXT NOT NULL, + "usageCount" INTEGER NOT NULL DEFAULT 0, + "rating" DOUBLE PRECISION NOT NULL DEFAULT 0, + "ratingCount" INTEGER NOT NULL DEFAULT 0, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "isPublished" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateUsage" ( + "id" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "clonedProjectId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TemplateUsage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Plan" ( + "id" TEXT NOT NULL, + "name" VARCHAR(50) NOT NULL, + "displayName" VARCHAR(100) NOT NULL, + "description" VARCHAR(500), + "monthlyPrice" INTEGER NOT NULL DEFAULT 0, + "yearlyPrice" INTEGER, + "currency" VARCHAR(3) NOT NULL DEFAULT 'usd', + "monthlyCredits" INTEGER NOT NULL DEFAULT 3, + "maxDuration" INTEGER NOT NULL DEFAULT 30, + "maxResolution" VARCHAR(10) NOT NULL DEFAULT '720p', + "maxProjects" INTEGER NOT NULL DEFAULT 5, + "stripePriceId" VARCHAR(100), + "stripeYearlyPriceId" VARCHAR(100), + "features" JSONB, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'active', + "stripeSubscriptionId" VARCHAR(100), + "stripeCustomerId" VARCHAR(100), + "currentPeriodStart" TIMESTAMP(3), + "currentPeriodEnd" TIMESTAMP(3), + "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "canceledAt" TIMESTAMP(3), + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CreditTransaction" ( + "id" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "type" VARCHAR(30) NOT NULL, + "description" VARCHAR(200), + "userId" TEXT NOT NULL, + "projectId" TEXT, + "balanceAfter" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CreditTransaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserPreference" ( + "id" TEXT NOT NULL, + "defaultLanguage" VARCHAR(5) NOT NULL DEFAULT 'tr', + "defaultVideoStyle" "VideoStyle" NOT NULL DEFAULT 'CINEMATIC', + "defaultDuration" INTEGER NOT NULL DEFAULT 60, + "theme" VARCHAR(10) NOT NULL DEFAULT 'dark', + "emailNotifications" BOOLEAN NOT NULL DEFAULT true, + "pushNotifications" BOOLEAN NOT NULL DEFAULT true, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "type" VARCHAR(30) NOT NULL, + "title" VARCHAR(200) NOT NULL, + "message" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Project_userId_idx" ON "Project"("userId"); + +-- CreateIndex +CREATE INDEX "Project_status_idx" ON "Project"("status"); + +-- CreateIndex +CREATE INDEX "Project_isTemplate_idx" ON "Project"("isTemplate"); + +-- CreateIndex +CREATE INDEX "Project_createdAt_idx" ON "Project"("createdAt"); + +-- CreateIndex +CREATE INDEX "Scene_projectId_idx" ON "Scene"("projectId"); + +-- CreateIndex +CREATE INDEX "Scene_order_idx" ON "Scene"("order"); + +-- CreateIndex +CREATE INDEX "MediaAsset_projectId_idx" ON "MediaAsset"("projectId"); + +-- CreateIndex +CREATE INDEX "MediaAsset_sceneId_idx" ON "MediaAsset"("sceneId"); + +-- CreateIndex +CREATE INDEX "MediaAsset_type_idx" ON "MediaAsset"("type"); + +-- CreateIndex +CREATE INDEX "RenderJob_projectId_idx" ON "RenderJob"("projectId"); + +-- CreateIndex +CREATE INDEX "RenderJob_status_idx" ON "RenderJob"("status"); + +-- CreateIndex +CREATE INDEX "RenderJob_bullJobId_idx" ON "RenderJob"("bullJobId"); + +-- CreateIndex +CREATE INDEX "RenderLog_renderJobId_idx" ON "RenderLog"("renderJobId"); + +-- CreateIndex +CREATE INDEX "RenderLog_stage_idx" ON "RenderLog"("stage"); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_originalProjectId_key" ON "Template"("originalProjectId"); + +-- CreateIndex +CREATE INDEX "Template_category_idx" ON "Template"("category"); + +-- CreateIndex +CREATE INDEX "Template_language_idx" ON "Template"("language"); + +-- CreateIndex +CREATE INDEX "Template_isFeatured_idx" ON "Template"("isFeatured"); + +-- CreateIndex +CREATE INDEX "Template_usageCount_idx" ON "Template"("usageCount"); + +-- CreateIndex +CREATE INDEX "TemplateUsage_templateId_idx" ON "TemplateUsage"("templateId"); + +-- CreateIndex +CREATE INDEX "TemplateUsage_userId_idx" ON "TemplateUsage"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Plan_name_key" ON "Plan"("name"); + +-- CreateIndex +CREATE INDEX "Plan_name_idx" ON "Plan"("name"); + +-- CreateIndex +CREATE INDEX "Plan_isActive_idx" ON "Plan"("isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId"); + +-- CreateIndex +CREATE INDEX "Subscription_userId_idx" ON "Subscription"("userId"); + +-- CreateIndex +CREATE INDEX "Subscription_planId_idx" ON "Subscription"("planId"); + +-- CreateIndex +CREATE INDEX "Subscription_stripeSubscriptionId_idx" ON "Subscription"("stripeSubscriptionId"); + +-- CreateIndex +CREATE INDEX "Subscription_status_idx" ON "Subscription"("status"); + +-- CreateIndex +CREATE INDEX "CreditTransaction_userId_idx" ON "CreditTransaction"("userId"); + +-- CreateIndex +CREATE INDEX "CreditTransaction_type_idx" ON "CreditTransaction"("type"); + +-- CreateIndex +CREATE INDEX "CreditTransaction_createdAt_idx" ON "CreditTransaction"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserPreference_userId_key" ON "UserPreference"("userId"); + +-- CreateIndex +CREATE INDEX "UserPreference_userId_idx" ON "UserPreference"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_userId_idx" ON "Notification"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_isRead_idx" ON "Notification"("isRead"); + +-- CreateIndex +CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Scene" ADD CONSTRAINT "Scene_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MediaAsset" ADD CONSTRAINT "MediaAsset_sceneId_fkey" FOREIGN KEY ("sceneId") REFERENCES "Scene"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RenderJob" ADD CONSTRAINT "RenderJob_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RenderLog" ADD CONSTRAINT "RenderLog_renderJobId_fkey" FOREIGN KEY ("renderJobId") REFERENCES "RenderJob"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_originalProjectId_fkey" FOREIGN KEY ("originalProjectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateUsage" ADD CONSTRAINT "TemplateUsage_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateUsage" ADD CONSTRAINT "TemplateUsage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CreditTransaction" ADD CONSTRAINT "CreditTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260329094139_add_source_type_and_tweet_data/migration.sql b/prisma/migrations/20260329094139_add_source_type_and_tweet_data/migration.sql new file mode 100644 index 0000000..6c176f2 --- /dev/null +++ b/prisma/migrations/20260329094139_add_source_type_and_tweet_data/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "SourceType" AS ENUM ('MANUAL', 'X_TWEET', 'YOUTUBE'); + +-- AlterEnum +ALTER TYPE "RenderStage" ADD VALUE 'AMBIENT_GENERATION'; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "referenceUrl" VARCHAR(500), +ADD COLUMN "seoDescription" VARCHAR(500), +ADD COLUMN "seoKeywords" TEXT[], +ADD COLUMN "seoSchemaJson" JSONB, +ADD COLUMN "seoTitle" VARCHAR(200), +ADD COLUMN "socialContent" JSONB, +ADD COLUMN "sourceTweetData" JSONB, +ADD COLUMN "sourceType" "SourceType" NOT NULL DEFAULT 'MANUAL'; + +-- CreateIndex +CREATE INDEX "Project_sourceType_idx" ON "Project"("sourceType"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c0b2b0..ac09385 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,10 +22,18 @@ model User { lastName String? isActive Boolean @default(true) - // Relations + // Core Relations roles UserRole[] refreshTokens RefreshToken[] + // Video SaaS Relations + projects Project[] + subscriptions Subscription[] + creditTransactions CreditTransaction[] + templateUsages TemplateUsage[] + notifications Notification[] + preferences UserPreference? + // Multi-tenancy (optional) tenantId String? tenant Tenant? @relation(fields: [tenantId], references: [id]) @@ -160,3 +168,472 @@ model Translation { @@index([locale]) @@index([namespace]) } + +// ============================================ +// Video SaaS — Enums +// ============================================ + +enum ProjectStatus { + DRAFT + GENERATING_SCRIPT + PENDING + GENERATING_MEDIA + RENDERING + COMPLETED + FAILED +} + +enum AspectRatio { + PORTRAIT_9_16 + SQUARE_1_1 + LANDSCAPE_16_9 +} + +enum VideoStyle { + CINEMATIC + DOCUMENTARY + EDUCATIONAL + STORYTELLING + NEWS + PROMOTIONAL + ARTISTIC + MINIMALIST +} + +enum MediaType { + VIDEO_CLIP + AUDIO_NARRATION + AUDIO_MUSIC + SUBTITLE + THUMBNAIL + FINAL_VIDEO +} + +enum TransitionType { + CUT + FADE + DISSOLVE + SLIDE_LEFT + SLIDE_RIGHT + SLIDE_UP + ZOOM_IN + ZOOM_OUT + WIPE +} + +enum RenderJobStatus { + QUEUED + PROCESSING + COMPLETED + FAILED + CANCELLED +} + +enum RenderStage { + VIDEO_GENERATION + TTS_GENERATION + MUSIC_GENERATION + AMBIENT_GENERATION + MEDIA_MERGE + SUBTITLE_OVERLAY + FINALIZATION + UPLOAD +} + +enum SourceType { + MANUAL + X_TWEET + YOUTUBE +} + +// ============================================ +// Video SaaS — Project & Scenes +// ============================================ + +model Project { + id String @id @default(uuid()) + title String @db.VarChar(200) + description String? @db.VarChar(1000) + prompt String @db.VarChar(2000) + + // AI Generated Script + scriptJson Json? // Gemini API raw JSON output + scriptVersion Int @default(0) + + // Configuration + language String @default("tr") @db.VarChar(5) // ISO 639-1 + aspectRatio AspectRatio @default(PORTRAIT_9_16) + videoStyle VideoStyle @default(CINEMATIC) + targetDuration Int @default(60) // saniye + + // SEO & Social Content (skill-enhanced) + seoKeywords String[] // Hedeflenen SEO anahtar kelimeler + seoTitle String? @db.VarChar(200) + seoDescription String? @db.VarChar(500) + seoSchemaJson Json? // VideoObject structured data + socialContent Json? // { youtubeTitle, tiktokCaption, instagramCaption, twitterText } + referenceUrl String? @db.VarChar(500) + + // İçerik Kaynağı + sourceType SourceType @default(MANUAL) // MANUAL, X_TWEET, YOUTUBE + sourceTweetData Json? // X/Twitter tweet verisi (id, author, metrics, media) + + // Processing + status ProjectStatus @default(DRAFT) + progress Int @default(0) // 0-100 + errorMessage String? + + // Output + finalVideoUrl String? + thumbnailUrl String? + + // Stats + creditsUsed Int @default(0) + viewCount Int @default(0) + + // Template Support + isTemplate Boolean @default(false) + templateId String? // Hangi şablondan klonlandı? + template Template? @relation("ClonedFrom", fields: [templateId], references: [id]) + + // Relations + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + scenes Scene[] + mediaAssets MediaAsset[] + renderJobs RenderJob[] + templateEntry Template? @relation("SourceProject") + + // Timestamps & Soft Delete + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + deletedAt DateTime? + + @@index([userId]) + @@index([status]) + @@index([sourceType]) + @@index([isTemplate]) + @@index([createdAt]) +} + +model Scene { + id String @id @default(uuid()) + order Int // Sahne sırası (1, 2, 3...) + title String? @db.VarChar(200) + + // Content + narrationText String @db.Text // Hedef dildeki anlatım metni + visualPrompt String @db.Text // İngilizce — Higgsfield AI prompt + subtitleText String? @db.Text // Ekranda görünecek altyazı + + // Timing + duration Float @default(5.0) // saniye + transitionType TransitionType @default(CUT) + + // Relations + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + mediaAssets MediaAsset[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId]) + @@index([order]) +} + +// ============================================ +// Video SaaS — Media & Render +// ============================================ + +model MediaAsset { + id String @id @default(uuid()) + type MediaType + + // Storage + s3Key String? // Cloudflare R2 / S3 object key + s3Bucket String? @db.VarChar(100) + url String? // Public CDN URL + + // Metadata + fileName String? @db.VarChar(255) + mimeType String? @db.VarChar(100) + sizeBytes BigInt? + durationMs Int? // Medya süresi (video/audio için) + + // AI Provider Info + aiProvider String? @db.VarChar(50) // higgsfield, elevenlabs, suno + aiJobId String? // Dış API job ID'si + + // Relations + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + sceneId String? // null = proje genelinde (müzik, final vb.) + scene Scene? @relation(fields: [sceneId], references: [id], onDelete: SetNull) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId]) + @@index([sceneId]) + @@index([type]) +} + +model RenderJob { + id String @id @default(uuid()) + status RenderJobStatus @default(QUEUED) + currentStage RenderStage? + + // Queue Info + queueName String @default("video-generation") @db.VarChar(100) + bullJobId String? @db.VarChar(100) // BullMQ job ID + + // Retry + attemptNumber Int @default(1) + maxAttempts Int @default(3) + + // Processing + workerHostname String? @db.VarChar(100) + processingTimeMs Int? // Toplam render süresi + errorMessage String? + + // Output + finalVideoUrl String? + finalVideoS3Key String? + + // Relations + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + logs RenderLog[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + startedAt DateTime? + completedAt DateTime? + + @@index([projectId]) + @@index([status]) + @@index([bullJobId]) +} + +model RenderLog { + id String @id @default(uuid()) + stage RenderStage + level String @default("info") @db.VarChar(10) // info, warn, error + message String @db.Text + durationMs Int? // Bu aşamanın süresi + metadata Json? // Ek JSON veri + + // Relations + renderJobId String + renderJob RenderJob @relation(fields: [renderJobId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) + + @@index([renderJobId]) + @@index([stage]) +} + +// ============================================ +// Video SaaS — Template Marketplace +// ============================================ + +model Template { + id String @id @default(uuid()) + + // Display + title String @db.VarChar(200) + description String? @db.VarChar(500) + thumbnailUrl String? + previewVideoUrl String? + + // Categorization + category String @default("general") @db.VarChar(50) + tags String[] // PostgreSQL array + language String @default("tr") @db.VarChar(5) + + // Source + originalProjectId String @unique + originalProject Project @relation("SourceProject", fields: [originalProjectId], references: [id]) + + // Stats + usageCount Int @default(0) + rating Float @default(0) + ratingCount Int @default(0) + isFeatured Boolean @default(false) + isPublished Boolean @default(true) + + // Relations + clonedProjects Project[] @relation("ClonedFrom") + usages TemplateUsage[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([language]) + @@index([isFeatured]) + @@index([usageCount]) +} + +model TemplateUsage { + id String @id @default(uuid()) + templateId String + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + clonedProjectId String? // Oluşturulan projenin ID'si + + createdAt DateTime @default(now()) + + @@index([templateId]) + @@index([userId]) +} + +// ============================================ +// Video SaaS — Billing & Credits +// ============================================ + +model Plan { + id String @id @default(uuid()) + name String @unique @db.VarChar(50) // free, pro, business + displayName String @db.VarChar(100) + description String? @db.VarChar(500) + + // Pricing + monthlyPrice Int @default(0) // cent cinsinden (1900 = $19) + yearlyPrice Int? // Yıllık indirimli fiyat + currency String @default("usd") @db.VarChar(3) + + // Limits + monthlyCredits Int @default(3) + maxDuration Int @default(30) // saniye + maxResolution String @default("720p") @db.VarChar(10) + maxProjects Int @default(5) + + // Stripe + stripePriceId String? @db.VarChar(100) + stripeYearlyPriceId String? @db.VarChar(100) + + // Features + features Json? // { "templates": true, "priorityQueue": false, ... } + isActive Boolean @default(true) + sortOrder Int @default(0) + + // Relations + subscriptions Subscription[] + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) + @@index([isActive]) +} + +model Subscription { + id String @id @default(uuid()) + status String @default("active") @db.VarChar(20) // active, canceled, past_due, trialing + + // Stripe + stripeSubscriptionId String? @unique @db.VarChar(100) + stripeCustomerId String? @db.VarChar(100) + + // Billing Cycle + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + cancelAtPeriodEnd Boolean @default(false) + + // Relations + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + planId String + plan Plan @relation(fields: [planId], references: [id]) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + canceledAt DateTime? + + @@index([userId]) + @@index([planId]) + @@index([stripeSubscriptionId]) + @@index([status]) +} + +model CreditTransaction { + id String @id @default(uuid()) + amount Int // Pozitif: ekleme, Negatif: harcama + type String @db.VarChar(30) // grant, usage, refund, bonus + description String? @db.VarChar(200) + + // Relations + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + projectId String? // Hangi projede harcandı + + // Balance Snapshot + balanceAfter Int @default(0) + + // Timestamps + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([type]) + @@index([createdAt]) +} + +// ============================================ +// Video SaaS — User Preferences & Notifications +// ============================================ + +model UserPreference { + id String @id @default(uuid()) + + // Defaults + defaultLanguage String @default("tr") @db.VarChar(5) + defaultVideoStyle VideoStyle @default(CINEMATIC) + defaultDuration Int @default(60) + + // UI + theme String @default("dark") @db.VarChar(10) + emailNotifications Boolean @default(true) + pushNotifications Boolean @default(true) + + // Relations + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model Notification { + id String @id @default(uuid()) + type String @db.VarChar(30) // render_complete, render_failed, credit_low, system + title String @db.VarChar(200) + message String? @db.Text + isRead Boolean @default(false) + metadata Json? // { projectId, renderJobId, ... } + + // Relations + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) + readAt DateTime? + + @@index([userId]) + @@index([isRead]) + @@index([createdAt]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 7db004a..a9e0eb9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { BullModule } from '@nestjs/bullmq'; import { CacheModule } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-yet'; import { LoggerModule } from 'nestjs-pino'; @@ -40,6 +41,14 @@ import { AdminModule } from './modules/admin/admin.module'; import { HealthModule } from './modules/health/health.module'; import { GeminiModule } from './modules/gemini/gemini.module'; +// Video SaaS Modules +import { ProjectsModule } from './modules/projects/projects.module'; +import { VideoQueueModule } from './modules/video-queue/video-queue.module'; +import { VideoAiModule } from './modules/video-ai/video-ai.module'; +import { StorageModule } from './modules/storage/storage.module'; +import { BillingModule } from './modules/billing/billing.module'; +import { XTwitterModule } from './modules/x-twitter/x-twitter.module'; + // Guards import { JwtAuthGuard, @@ -65,6 +74,19 @@ import { ], }), + // BullMQ — Redis tabanlı job kuyruğu (Video üretim pipeline) + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('redis.host', 'localhost'), + port: configService.get('redis.port', 6379), + password: configService.get('redis.password') || undefined, + }, + }), + inject: [ConfigService], + }), + // Logger (Structured Logging with Pino) LoggerModule.forRootAsync({ imports: [ConfigModule], @@ -160,6 +182,14 @@ import { // Optional Modules (controlled by env variables) GeminiModule, HealthModule, + + // Video SaaS Modules + ProjectsModule, + VideoQueueModule, + VideoAiModule, + StorageModule, + BillingModule, + XTwitterModule, ], providers: [ // Global Exception Filter diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index 0a70a3a..8818fb2 100644 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -7,7 +7,7 @@ import { import { PrismaClient } from '@prisma/client'; // Models that support soft delete -const SOFT_DELETE_MODELS = ['user', 'role', 'tenant']; +const SOFT_DELETE_MODELS = ['user', 'role', 'tenant', 'project']; // Type for Prisma model delegate with common operations interface PrismaDelegate { diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts new file mode 100644 index 0000000..adf08a1 --- /dev/null +++ b/src/modules/billing/billing.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Post, + Body, + Get, + Headers, + RawBody, + HttpCode, + HttpStatus, + Req, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import Stripe from 'stripe'; +import { BillingService } from './billing.service'; +import { Public } from '../../common/decorators'; + +@ApiTags('Billing') +@Controller('billing') +export class BillingController { + private readonly logger = new Logger(BillingController.name); + private readonly stripe: Stripe | null; + private readonly webhookSecret: string; + + constructor( + private readonly billingService: BillingService, + private readonly configService: ConfigService, + ) { + const stripeKey = this.configService.get('STRIPE_SECRET_KEY'); + this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET', ''); + + if (stripeKey) { + this.stripe = new Stripe(stripeKey); + } else { + this.stripe = null; + } + } + + @Post('checkout') + @ApiBearerAuth() + @ApiOperation({ summary: 'Stripe Checkout session oluştur' }) + async createCheckout( + @Req() req: any, + @Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' }, + ) { + const userId = req.user?.id || req.user?.sub; + return this.billingService.createCheckoutSession(userId, body.planName, body.billingCycle); + } + + @Get('credits/balance') + @ApiBearerAuth() + @ApiOperation({ summary: 'Kredi bakiyesini getir' }) + async getCreditBalance(@Req() req: any) { + const userId = req.user?.id || req.user?.sub; + return this.billingService.getCreditBalance(userId); + } + + @Get('credits/history') + @ApiBearerAuth() + @ApiOperation({ summary: 'Kredi işlem geçmişi' }) + async getCreditHistory(@Req() req: any) { + const userId = req.user?.id || req.user?.sub; + // Default pagination + return this.billingService.getCreditBalance(userId); + } + + @Post('webhook') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Stripe webhook handler' }) + async handleWebhook( + @Headers('stripe-signature') signature: string, + @RawBody() rawBody: Buffer, + ) { + if (!this.stripe || !this.webhookSecret) { + throw new BadRequestException('Stripe webhook yapılandırılmamış'); + } + + let event: Stripe.Event; + + try { + event = this.stripe.webhooks.constructEvent( + rawBody, + signature, + this.webhookSecret, + ); + } catch (err) { + this.logger.error(`Webhook signature doğrulama hatası: ${err}`); + throw new BadRequestException('Webhook doğrulama başarısız'); + } + + this.logger.log(`Webhook alındı: ${event.type}`); + await this.billingService.handleWebhookEvent(event); + + return { received: true }; + } +} diff --git a/src/modules/billing/billing.module.ts b/src/modules/billing/billing.module.ts new file mode 100644 index 0000000..add6b7e --- /dev/null +++ b/src/modules/billing/billing.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BillingService } from './billing.service'; +import { BillingController } from './billing.controller'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [BillingController], + providers: [BillingService], + exports: [BillingService], +}) +export class BillingModule {} diff --git a/src/modules/billing/billing.service.ts b/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..244efd5 --- /dev/null +++ b/src/modules/billing/billing.service.ts @@ -0,0 +1,293 @@ +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 + * 3. Her video üretiminde kredi harcanır + * 4. Ay sonu → kredi sıfırlanır (rollover yok) + */ + +@Injectable() +export class BillingService { + private readonly logger = new Logger(BillingService.name); + private readonly stripe: Stripe | null; + + constructor( + private readonly db: PrismaService, + private readonly configService: ConfigService, + ) { + const stripeKey = this.configService.get('STRIPE_SECRET_KEY'); + + if (stripeKey) { + this.stripe = new Stripe(stripeKey); + this.logger.log('💳 Stripe bağlantısı kuruldu'); + } else { + this.stripe = null; + 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') { + if (!this.stripe) { + throw new BadRequestException('Ödeme sistemi şu anda aktif değil'); + } + + const plan = await this.db.plan.findFirst({ + where: { name: planName, isActive: true }, + }); + + if (!plan) { + throw new NotFoundException(`Plan bulunamadı: ${planName}`); + } + + const user = await this.db.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + + if (!user) { + throw new NotFoundException('Kullanıcı bulunamadı'); + } + + const priceId = billingCycle === 'yearly' + ? plan.stripeYearlyPriceId + : plan.stripePriceId; + + if (!priceId) { + throw new BadRequestException(`Bu plan için ${billingCycle} fiyat tanımlı değil`); + } + + const session = await this.stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + customer_email: user.email, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + metadata: { + userId, + planId: plan.id, + planName: plan.name, + }, + success_url: `${this.configService.get('APP_URL')}/dashboard?checkout=success&plan=${planName}`, + cancel_url: `${this.configService.get('APP_URL')}/dashboard/pricing?checkout=cancelled`, + }); + + this.logger.log(`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`); + + return { + sessionId: session.id, + url: session.url, + }; + } + + /** + * Webhook handler — Stripe event işleme + * State machine: checkout.session.completed → subscription aktif → kredi yükle + */ + async handleWebhookEvent(event: Stripe.Event): Promise { + switch (event.type) { + case 'checkout.session.completed': + await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session); + break; + + case 'invoice.payment_succeeded': + await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice); + break; + + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + break; + + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + break; + + default: + this.logger.debug(`Unhandled webhook event: ${event.type}`); + } + } + + /** + * Kullanıcı kredi bakiyesi + */ + async getCreditBalance(userId: string) { + const transactions = await this.db.creditTransaction.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + const balance = transactions.reduce((sum, tx) => sum + tx.amount, 0); + + // Bu ayın kullanımı + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + + 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)); + + // Kullanıcının aktif planından limit al + const subscription = await this.db.subscription.findFirst({ + where: { userId, status: 'active' }, + include: { plan: true }, + }); + + const monthlyLimit = subscription?.plan?.monthlyCredits || 3; + + return { + balance, + monthlyUsed, + monthlyLimit, + }; + } + + /** + * Kredi harca (video üretimi için) + */ + async spendCredits(userId: string, amount: number, projectId: string, description: string) { + const balance = await this.getCreditBalance(userId); + + if (balance.balance < amount) { + throw new BadRequestException( + `Yetersiz kredi. Mevcut: ${balance.balance}, Gerekli: ${amount}`, + ); + } + + const transaction = await this.db.creditTransaction.create({ + data: { + userId, + amount: -amount, + type: 'usage', + description, + projectId, + balanceAfter: balance.balance - amount, + }, + }); + + 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) { + const currentBalance = await this.getCreditBalance(userId); + + const transaction = await this.db.creditTransaction.create({ + data: { + userId, + amount, + type, + description, + balanceAfter: currentBalance.balance + amount, + }, + }); + + this.logger.log(`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`); + return transaction; + } + + // ── Private: Webhook handlers ────────────────────────────────────── + + private async handleCheckoutComplete(session: Stripe.Checkout.Session) { + const userId = session.metadata?.userId; + const planId = session.metadata?.planId; + + if (!userId || !planId) { + this.logger.error('Checkout metadata eksik'); + return; + } + + const plan = await this.db.plan.findUnique({ where: { id: planId } }); + if (!plan) return; + + // Subscription kaydı oluştur + await this.db.subscription.create({ + data: { + userId, + planId, + status: 'active', + stripeSubscriptionId: session.subscription as string, + stripeCustomerId: session.customer as string, + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + + // İlk ay kredilerini yükle + 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; + if (!subscriptionId) return; + + const subscription = await this.db.subscription.findFirst({ + where: { stripeSubscriptionId: subscriptionId }, + include: { plan: true }, + }); + + if (!subscription) return; + + // Aylık kredi yenileme + await this.grantCredits( + subscription.userId, + subscription.plan.monthlyCredits, + 'grant', + `${subscription.plan.displayName} aylık kredi yenileme`, + ); + + this.logger.log(`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`); + } + + private async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) { + const periodStart = (stripeSubscription as any).current_period_start; + const periodEnd = (stripeSubscription as any).current_period_end; + + await this.db.subscription.updateMany({ + where: { stripeSubscriptionId: stripeSubscription.id }, + data: { + status: stripeSubscription.status, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, + ...(periodStart && { currentPeriodStart: new Date(periodStart * 1000) }), + ...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }), + }, + }); + } + + private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription) { + await this.db.subscription.updateMany({ + where: { stripeSubscriptionId: stripeSubscription.id }, + data: { + status: 'canceled', + canceledAt: new Date(), + }, + }); + + this.logger.log(`❌ Abonelik iptal edildi: ${stripeSubscription.id}`); + } +} diff --git a/src/modules/projects/dto/project.dto.ts b/src/modules/projects/dto/project.dto.ts new file mode 100644 index 0000000..6821d77 --- /dev/null +++ b/src/modules/projects/dto/project.dto.ts @@ -0,0 +1,198 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsInt, + Min, + Max, + MaxLength, + Matches, +} from 'class-validator'; + +export enum AspectRatioDto { + PORTRAIT_9_16 = 'PORTRAIT_9_16', + SQUARE_1_1 = 'SQUARE_1_1', + LANDSCAPE_16_9 = 'LANDSCAPE_16_9', +} + +export enum VideoStyleDto { + CINEMATIC = 'CINEMATIC', + DOCUMENTARY = 'DOCUMENTARY', + EDUCATIONAL = 'EDUCATIONAL', + STORYTELLING = 'STORYTELLING', + NEWS = 'NEWS', + PROMOTIONAL = 'PROMOTIONAL', + ARTISTIC = 'ARTISTIC', + MINIMALIST = 'MINIMALIST', +} + +export class CreateProjectDto { + @ApiProperty({ description: 'Proje başlığı', example: 'Boötes Boşluğu' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + title: string; + + @ApiPropertyOptional({ description: 'Proje açıklaması' }) + @IsString() + @IsOptional() + @MaxLength(1000) + description?: string; + + @ApiProperty({ + description: 'Video üretim promptu — AI bu metni kullanarak senaryo üretir', + example: 'Boötes Boşluğu — evrendeki en büyük boşluk', + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + prompt: string; + + @ApiPropertyOptional({ + description: 'Senaryo ve TTS hedef dili (ISO 639-1)', + example: 'tr', + default: 'tr', + }) + @IsString() + @IsOptional() + language?: string; + + @ApiPropertyOptional({ + description: 'En-boy oranı', + enum: AspectRatioDto, + default: AspectRatioDto.PORTRAIT_9_16, + }) + @IsEnum(AspectRatioDto) + @IsOptional() + aspectRatio?: AspectRatioDto; + + @ApiPropertyOptional({ + description: 'Video stili', + enum: VideoStyleDto, + default: VideoStyleDto.CINEMATIC, + }) + @IsEnum(VideoStyleDto) + @IsOptional() + videoStyle?: VideoStyleDto; + + @ApiPropertyOptional({ + description: 'Hedef video süresi (saniye)', + example: 60, + default: 60, + }) + @IsInt() + @IsOptional() + @Min(15) + @Max(180) + targetDuration?: number; + + @ApiPropertyOptional({ + description: 'SEO hedef anahtar kelimeler', + example: ['ai video', 'youtube shorts'], + }) + @IsOptional() + seoKeywords?: string[]; + + @ApiPropertyOptional({ + description: 'Referans video URL (stil ilhamı için)', + example: 'https://youtube.com/shorts/abc123', + }) + @IsString() + @IsOptional() + referenceUrl?: string; +} + +export class UpdateProjectDto { + @ApiPropertyOptional({ description: 'Proje başlığı' }) + @IsString() + @IsOptional() + @MaxLength(200) + title?: string; + + @ApiPropertyOptional({ description: 'Proje açıklaması' }) + @IsString() + @IsOptional() + @MaxLength(1000) + description?: string; + + @ApiPropertyOptional({ description: 'Video üretim promptu' }) + @IsString() + @IsOptional() + @MaxLength(2000) + prompt?: string; + + @ApiPropertyOptional({ description: 'Hedef dil (ISO 639-1)' }) + @IsString() + @IsOptional() + language?: string; + + @ApiPropertyOptional({ enum: AspectRatioDto }) + @IsEnum(AspectRatioDto) + @IsOptional() + aspectRatio?: AspectRatioDto; + + @ApiPropertyOptional({ enum: VideoStyleDto }) + @IsEnum(VideoStyleDto) + @IsOptional() + videoStyle?: VideoStyleDto; + + @ApiPropertyOptional({ description: 'Hedef video süresi (saniye)' }) + @IsInt() + @IsOptional() + @Min(15) + @Max(180) + targetDuration?: number; +} + +/** + * X/Twitter tweet URL'sinden proje oluşturma DTO'su. + * Tweet içeriği otomatik olarak prompt'a dönüştürülür. + */ +export class CreateFromTweetDto { + @ApiProperty({ + description: 'X/Twitter tweet URL\'si', + example: 'https://x.com/elonmusk/status/1893456789012345678', + }) + @IsString() + @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' }, + ) + tweetUrl: string; + + @ApiPropertyOptional({ + description: 'Proje başlığı (boş bırakılırsa tweet\'ten 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({ enum: VideoStyleDto, default: VideoStyleDto.CINEMATIC }) + @IsEnum(VideoStyleDto) + @IsOptional() + videoStyle?: VideoStyleDto; + + @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 new file mode 100644 index 0000000..dcdf1cf --- /dev/null +++ b/src/modules/projects/projects.controller.ts @@ -0,0 +1,153 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + HttpCode, + HttpStatus, + Logger, + ParseUUIDPipe, + Req, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { ProjectsService } from './projects.service'; +import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto } from './dto/project.dto'; + +@ApiTags('projects') +@ApiBearerAuth() +@Controller('projects') +export class ProjectsController { + private readonly logger = new Logger(ProjectsController.name); + + constructor(private readonly projectsService: ProjectsService) {} + + /** + * Yeni bir video projesi oluşturur (DRAFT durumunda). + */ + @Post() + @ApiOperation({ summary: 'Yeni video projesi oluştur' }) + @ApiResponse({ status: 201, description: 'Proje başarıyla oluşturuldu' }) + async create(@Body() dto: CreateProjectDto, @Req() req: any) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Yeni proje oluşturuluyor: "${dto.title}"`); + return this.projectsService.create(userId, dto); + } + + /** + * Kullanıcının tüm projelerini listeler. + */ + @Get() + @ApiOperation({ summary: 'Tüm projeleri listele' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'status', required: false, type: String }) + @ApiResponse({ status: 200, description: 'Proje listesi' }) + async findAll( + @Req() req: any, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: string, + ) { + const userId = req.user?.id || req.user?.sub; + return this.projectsService.findAll(userId, { + page: page || 1, + limit: limit || 10, + status, + }); + } + + /** + * Tek bir projeyi sahneleri ve medya asset'leriyle birlikte getirir. + */ + @Get(':id') + @ApiOperation({ summary: 'Proje detaylarını getir' }) + @ApiResponse({ status: 200, description: 'Proje detayları' }) + @ApiResponse({ status: 404, description: 'Proje bulunamadı' }) + async findOne(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { + const userId = req.user?.id || req.user?.sub; + return this.projectsService.findOne(userId, id); + } + + /** + * Projeyi günceller (sadece DRAFT durumundaki projeler güncellenebilir). + */ + @Patch(':id') + @ApiOperation({ summary: 'Projeyi güncelle' }) + @ApiResponse({ status: 200, description: 'Proje güncellendi' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProjectDto, + @Req() req: any, + ) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Proje güncelleniyor: ${id}`); + return this.projectsService.update(userId, id, dto); + } + + /** + * Projeyi soft-delete ile siler. + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Projeyi sil (soft delete)' }) + @ApiResponse({ status: 204, description: 'Proje silindi' }) + async remove(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Proje siliniyor: ${id}`); + return this.projectsService.remove(userId, id); + } + + /** + * AI ile senaryo üretimini tetikler. + */ + @Post(':id/generate-script') + @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) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`); + return this.projectsService.generateScript(userId, id); + } + + /** + * Senaryoyu onaylar ve video üretim kuyruğuna gönderir. + */ + @Post(':id/approve') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Senaryoyu onayla ve video üretimini başlat' }) + @ApiResponse({ status: 202, description: 'Video üretimi kuyruğa eklendi' }) + async approveAndStartGeneration( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: any, + ) { + const userId = req.user?.id || req.user?.sub; + this.logger.log(`Proje onaylanıyor ve kuyruğa gönderiliyor: ${id}`); + return this.projectsService.approveAndQueueGeneration(userId, id); + } + + /** + * X/Twitter tweet URL'sinden otomatik proje oluşturur ve senaryo üretir. + * Tweet çekilir → prompt'a dönüştürülür → AI senaryo üretir → proje kaydedilir. + */ + @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ı' }) + 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}`); + return this.projectsService.createFromTweet(userId, dto); + } +} diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..ca3fa74 --- /dev/null +++ b/src/modules/projects/projects.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; +import { VideoAiModule } from '../video-ai/video-ai.module'; +import { VideoQueueModule } from '../video-queue/video-queue.module'; +import { XTwitterModule } from '../x-twitter/x-twitter.module'; + +@Module({ + imports: [VideoAiModule, VideoQueueModule, XTwitterModule], + controllers: [ProjectsController], + providers: [ProjectsService], + exports: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/src/modules/projects/projects.service.ts b/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..8ba72d4 --- /dev/null +++ b/src/modules/projects/projects.service.ts @@ -0,0 +1,476 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { TransitionType } from '@prisma/client'; +import { PrismaService } from '../../database/prisma.service'; +import { VideoAiService } from '../video-ai/video-ai.service'; +import { VideoGenerationProducer } from '../video-queue/video-generation.producer'; +import { XTwitterService } from '../x-twitter/x-twitter.service'; +import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto } from './dto/project.dto'; + +interface FindAllOptions { + page: number; + limit: number; + status?: string; +} + +@Injectable() +export class ProjectsService { + private readonly logger = new Logger(ProjectsService.name); + + constructor( + private readonly db: PrismaService, + private readonly videoAiService: VideoAiService, + private readonly videoGenerationProducer: VideoGenerationProducer, + private readonly xTwitterService: XTwitterService, + ) {} + + async create(userId: string, dto: CreateProjectDto) { + const project = await this.db.project.create({ + data: { + title: dto.title, + description: dto.description, + prompt: dto.prompt, + language: dto.language || 'tr', + aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16', + videoStyle: dto.videoStyle || 'CINEMATIC', + targetDuration: dto.targetDuration || 60, + seoKeywords: dto.seoKeywords || [], + referenceUrl: dto.referenceUrl || null, + status: 'DRAFT', + userId, + }, + }); + + this.logger.log(`Proje oluşturuldu: ${project.id} — "${project.title}"`); + return project; + } + + async findAll(userId: string, options: FindAllOptions) { + const { page, limit, status } = options; + const skip = (page - 1) * limit; + + const where: Record = { + userId, + deletedAt: null, + }; + + if (status) { + where.status = status.toUpperCase(); + } + + const [projects, total] = await Promise.all([ + this.db.project.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + prompt: true, + status: true, + progress: true, + thumbnailUrl: true, + finalVideoUrl: true, + language: true, + videoStyle: true, + aspectRatio: true, + targetDuration: true, + creditsUsed: true, + createdAt: true, + updatedAt: true, + completedAt: true, + }, + }), + this.db.project.count({ where }), + ]); + + return { + data: projects, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findOne(userId: string, projectId: string) { + const project = await this.db.project.findFirst({ + where: { + id: projectId, + userId, + deletedAt: null, + }, + include: { + scenes: { + orderBy: { order: 'asc' }, + include: { + mediaAssets: true, + }, + }, + mediaAssets: { + where: { sceneId: null }, + }, + renderJobs: { + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + logs: { + orderBy: { createdAt: 'desc' }, + take: 20, + }, + }, + }, + }, + }); + + if (!project) { + throw new NotFoundException(`Proje bulunamadı: ${projectId}`); + } + + return project; + } + + async update(userId: string, projectId: string, dto: UpdateProjectDto) { + const project = await this.findOne(userId, projectId); + + if (project.status !== 'DRAFT') { + throw new BadRequestException( + `Sadece DRAFT durumundaki projeler güncellenebilir. Mevcut durum: ${project.status}`, + ); + } + + const updated = await this.db.project.update({ + where: { id: projectId }, + data: { + ...(dto.title && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.prompt && { prompt: dto.prompt }), + ...(dto.language && { language: dto.language }), + ...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }), + ...(dto.videoStyle && { videoStyle: dto.videoStyle }), + ...(dto.targetDuration && { targetDuration: dto.targetDuration }), + }, + }); + + this.logger.log(`Proje güncellendi: ${projectId}`); + return updated; + } + + async remove(userId: string, projectId: string) { + await this.findOne(userId, projectId); + await this.db.project.update({ + where: { id: projectId }, + data: { deletedAt: new Date() }, + }); + this.logger.log(`Proje silindi (soft): ${projectId}`); + } + + async generateScript(userId: string, projectId: string) { + const project = await this.findOne(userId, projectId); + + if (project.status !== 'DRAFT') { + throw new BadRequestException( + 'Senaryo sadece DRAFT durumundaki projeler için üretilebilir.', + ); + } + + await this.db.project.update({ + where: { id: projectId }, + data: { status: 'GENERATING_SCRIPT' }, + }); + + try { + const scriptJson = await this.videoAiService.generateVideoScript({ + topic: project.prompt, + targetDurationSeconds: project.targetDuration, + language: project.language, + videoStyle: project.videoStyle, + seoKeywords: (project as any).seoKeywords || [], + referenceUrl: (project as any).referenceUrl || undefined, + }); + + // Mevcut sahneleri sil (yeniden üretim) + await this.db.scene.deleteMany({ where: { projectId } }); + + // Yeni sahneleri yaz + const scenesData = scriptJson.scenes.map( + (scene: { + order: number; + title?: string; + narrationText: string; + visualPrompt: string; + subtitleText: string; + durationSeconds: number; + transitionType: string; + }) => ({ + projectId, + 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: projectId }, + data: { + scriptJson: scriptJson as object, + status: 'DRAFT', + scriptVersion: { increment: 1 }, + // SEO & Social metadata (skill-enhanced) + seoTitle: scriptJson.seo?.title || scriptJson.metadata.title, + seoDescription: scriptJson.seo?.description || scriptJson.metadata.description, + seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null, + socialContent: scriptJson.socialContent as object || null, + }, + include: { + scenes: { orderBy: { order: 'asc' } }, + }, + }); + + this.logger.log( + `Senaryo üretildi: ${projectId} — ${scriptJson.scenes.length} sahne`, + ); + + return updatedProject; + } catch (error) { + await this.db.project.update({ + where: { id: projectId }, + data: { + status: 'DRAFT', + errorMessage: + error instanceof Error + ? error.message + : 'Senaryo üretimi sırasında bilinmeyen hata', + }, + }); + throw error; + } + } + + async approveAndQueueGeneration(userId: string, projectId: string) { + const project = await this.findOne(userId, projectId); + + if (!project.scriptJson) { + throw new BadRequestException( + 'Onaylamadan önce bir senaryo üretilmelidir.', + ); + } + + if (project.status !== 'DRAFT' && project.status !== 'FAILED') { + throw new BadRequestException( + `Proje yalnızca DRAFT veya FAILED durumunda onaylanabilir. Mevcut: ${project.status}`, + ); + } + + await this.db.project.update({ + where: { id: projectId }, + data: { status: 'PENDING', progress: 0, errorMessage: null }, + }); + + const renderJob = await this.db.renderJob.create({ + data: { + projectId, + status: 'QUEUED', + queueName: 'video-generation', + attemptNumber: 1, + maxAttempts: 3, + }, + }); + + const bullJobId = await this.videoGenerationProducer.addVideoGenerationJob({ + projectId, + renderJobId: renderJob.id, + scriptJson: project.scriptJson, + language: project.language, + 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, + })), + }); + + await this.db.renderJob.update({ + where: { id: renderJob.id }, + data: { bullJobId }, + }); + + this.logger.log( + `Video üretimi kuyruğa eklendi — Project: ${projectId}, Job: ${bullJobId}`, + ); + + return { + message: 'Video üretimi kuyruğa eklendi', + projectId, + renderJobId: renderJob.id, + bullJobId, + status: 'PENDING', + }; + } + + private mapTransitionType(type: string): TransitionType { + const validTypes = Object.values(TransitionType); + const upper = type?.toUpperCase() as TransitionType; + return validTypes.includes(upper) ? upper : TransitionType.CUT; + } + + /** + * X/Twitter tweet URL'sinden otomatik proje oluşturur ve senaryo üretir. + * + * Akış: + * 1. FXTwitter API ile tweet verisini çek + * 2. Tweet'i prompt'a dönüştür + * 3. Proje oluştur (sourceType: X_TWEET) + * 4. AI ile senaryo üret + * 5. Sahneleri kaydet + */ + async createFromTweet(userId: string, dto: CreateFromTweetDto) { + this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`); + + // 1. Tweet verisini çek ve preview oluştur + const preview = await this.xTwitterService.previewTweet(dto.tweetUrl); + const { tweet } = preview; + + // 2. Proje başlığı: kullanıcı verdiyse onu kullan, yoksa önerilen + const title = dto.title || preview.suggestedTitle; + const prompt = preview.suggestedPrompt; + + // 3. Proje oluştur + const project = await this.db.project.create({ + data: { + title, + description: `@${tweet.author.username} tweet'inden üretildi — ${tweet.metrics.likes} beğeni, ${tweet.metrics.views} görüntülenme`, + prompt, + language: dto.language || 'tr', + aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16', + videoStyle: dto.videoStyle || 'CINEMATIC', + targetDuration: dto.targetDuration || preview.estimatedDuration, + status: 'GENERATING_SCRIPT', + userId, + sourceType: 'X_TWEET', + sourceTweetData: { + tweetId: tweet.id, + tweetUrl: tweet.url, + authorUsername: tweet.author.username, + authorName: tweet.author.name, + authorAvatar: tweet.author.avatarUrl, + text: tweet.text, + metrics: tweet.metrics, + media: tweet.media, + viralScore: preview.viralScore, + contentType: preview.contentType, + isThread: tweet.isThread, + threadCount: tweet.threadTweets?.length || 1, + } as object, + }, + }); + + this.logger.log( + `Tweet proje oluşturuldu: ${project.id} — viral skor: ${preview.viralScore}/100`, + ); + + // 4. Senaryo üret + try { + const scriptJson = await this.videoAiService.generateVideoScript({ + topic: prompt, + targetDurationSeconds: project.targetDuration, + language: project.language, + videoStyle: project.videoStyle, + seoKeywords: [], + referenceUrl: dto.tweetUrl, + sourceTweet: { + authorUsername: tweet.author.username, + text: tweet.text, + media: tweet.media, + metrics: tweet.metrics, + isThread: tweet.isThread, + }, + }); + + // Sahneleri kaydet + const scenesData = scriptJson.scenes.map( + (scene: { + order: number; + title?: string; + narrationText: string; + visualPrompt: string; + subtitleText: string; + durationSeconds: number; + transitionType: string; + }) => ({ + 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', + scriptVersion: 1, + seoTitle: scriptJson.seo?.title || scriptJson.metadata.title, + seoDescription: scriptJson.seo?.description || scriptJson.metadata.description, + seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null, + socialContent: (scriptJson.socialContent as object) || null, + }, + include: { + scenes: { orderBy: { order: 'asc' } }, + }, + }); + + this.logger.log( + `Tweet senaryo tamamlandı: ${project.id} — ${scriptJson.scenes.length} sahne`, + ); + + return { + ...updatedProject, + tweetPreview: { + viralScore: preview.viralScore, + contentType: preview.contentType, + author: tweet.author, + metrics: tweet.metrics, + }, + }; + } catch (error) { + await this.db.project.update({ + where: { id: project.id }, + data: { + status: 'DRAFT', + errorMessage: + error instanceof Error + ? error.message + : 'Tweet senaryo üretimi sırasında hata', + }, + }); + throw error; + } + } +} diff --git a/src/modules/storage/storage.module.ts b/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..6d0145f --- /dev/null +++ b/src/modules/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/modules/storage/storage.service.ts b/src/modules/storage/storage.service.ts new file mode 100644 index 0000000..8206655 --- /dev/null +++ b/src/modules/storage/storage.service.ts @@ -0,0 +1,204 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as crypto from 'crypto'; + +/** + * Storage Service — Medya dosyalarının yönetimi + * + * Strateji: file-organizer skill'inden elde edilen bilgilerle tasarlandı + * - Geliştirme ortamı: Lokal dosya sistemi (/data/media/) + * - Üretim ortamı: Cloudflare R2 / AWS S3 + * + * Dosya yapısı: + * /data/media/ + * ├── {projectId}/ + * │ ├── scenes/ + * │ │ ├── scene-001-video.mp4 + * │ │ ├── scene-001-audio.mp3 + * │ │ └── scene-002-video.mp4 + * │ ├── audio/ + * │ │ ├── narration.mp3 + * │ │ └── music.mp3 + * │ ├── output/ + * │ │ ├── final-video.mp4 + * │ │ └── thumbnail.jpg + * │ └── subtitles/ + * │ └── captions.srt + * └── temp/ (otomatik temizlenir) + */ + +export interface UploadResult { + key: string; + url: string; + bucket: string; + sizeBytes: number; + mimeType: string; +} + +export interface StorageConfig { + provider: 'local' | 's3' | 'r2'; + basePath: string; + bucket: string; + cdnUrl?: string; +} + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly config: StorageConfig; + + constructor(private readonly configService: ConfigService) { + const provider = this.configService.get('STORAGE_PROVIDER', 'local'); + + this.config = { + provider: provider as StorageConfig['provider'], + basePath: this.configService.get('STORAGE_LOCAL_PATH', './data/media'), + bucket: this.configService.get('STORAGE_BUCKET', 'contentgen-media'), + cdnUrl: this.configService.get('STORAGE_CDN_URL'), + }; + + this.logger.log(`📦 Storage provider: ${this.config.provider}`); + } + + /** + * Sahne videosu için anahtar oluştur + */ + getSceneVideoKey(projectId: string, sceneOrder: number): string { + return `${projectId}/scenes/scene-${String(sceneOrder).padStart(3, '0')}-video.mp4`; + } + + /** + * Sahne ses kaydı için anahtar oluştur + */ + getSceneAudioKey(projectId: string, sceneOrder: number): string { + return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`; + } + + /** + * Final video için anahtar oluştur + */ + getFinalVideoKey(projectId: string): string { + const hash = crypto.randomBytes(4).toString('hex'); + return `${projectId}/output/final-${hash}.mp4`; + } + + /** + * Thumbnail için anahtar oluştur + */ + getThumbnailKey(projectId: string): string { + return `${projectId}/output/thumbnail.jpg`; + } + + /** + * Altyazı dosyası için anahtar oluştur + */ + getSubtitleKey(projectId: string): string { + return `${projectId}/subtitles/captions.srt`; + } + + /** + * Müzik dosyası için anahtar oluştur + */ + getMusicKey(projectId: string): string { + return `${projectId}/audio/background-music.mp3`; + } + + /** + * Dosya yükle + */ + async upload(key: string, data: Buffer, mimeType: string): Promise { + if (this.config.provider === 'local') { + return this.uploadLocal(key, data, mimeType); + } + + // S3/R2 desteği sonra eklenecek + return this.uploadLocal(key, data, mimeType); + } + + /** + * Dosya indir + */ + async download(key: string): Promise { + if (this.config.provider === 'local') { + return this.downloadLocal(key); + } + + return this.downloadLocal(key); + } + + /** + * Dosya sil + */ + async delete(key: string): Promise { + if (this.config.provider === 'local') { + return this.deleteLocal(key); + } + + return this.deleteLocal(key); + } + + /** + * Proje dosyalarını temizle + */ + async cleanupProject(projectId: string): Promise { + const projectDir = path.join(this.config.basePath, projectId); + + try { + await fs.rm(projectDir, { recursive: true, force: true }); + this.logger.log(`🗑️ Proje dosyaları silindi: ${projectId}`); + } catch (error) { + this.logger.warn(`Proje temizleme hatası: ${projectId} — ${error}`); + } + } + + /** + * Dosyanın public URL'ini oluştur + */ + getPublicUrl(key: string): string { + if (this.config.cdnUrl) { + return `${this.config.cdnUrl}/${key}`; + } + + if (this.config.provider === 'local') { + return `/media/${key}`; + } + + return `https://${this.config.bucket}.r2.dev/${key}`; + } + + // ── Private: Lokal dosya sistemi ────────────────────────────────── + + private async uploadLocal(key: string, data: Buffer, mimeType: string): Promise { + const filePath = path.join(this.config.basePath, key); + const dir = path.dirname(filePath); + + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, data); + + this.logger.debug(`📥 Dosya yüklendi: ${key} (${data.length} bytes)`); + + return { + key, + url: this.getPublicUrl(key), + bucket: this.config.bucket, + sizeBytes: data.length, + mimeType, + }; + } + + private async downloadLocal(key: string): Promise { + const filePath = path.join(this.config.basePath, key); + return fs.readFile(filePath); + } + + private async deleteLocal(key: string): Promise { + const filePath = path.join(this.config.basePath, key); + try { + await fs.unlink(filePath); + } catch { + // Dosya bulunamadı — sessizce geç + } + } +} diff --git a/src/modules/video-ai/video-ai.module.ts b/src/modules/video-ai/video-ai.module.ts new file mode 100644 index 0000000..975faa8 --- /dev/null +++ b/src/modules/video-ai/video-ai.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { VideoAiService } from './video-ai.service'; + +@Module({ + providers: [VideoAiService], + exports: [VideoAiService], +}) +export class VideoAiModule {} diff --git a/src/modules/video-ai/video-ai.service.ts b/src/modules/video-ai/video-ai.service.ts new file mode 100644 index 0000000..7973bb6 --- /dev/null +++ b/src/modules/video-ai/video-ai.service.ts @@ -0,0 +1,549 @@ +import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GoogleGenAI } from '@google/genai'; + +export interface ScriptGenerationInput { + topic: string; + targetDurationSeconds: number; + language: string; + videoStyle: string; + referenceUrl?: string; + seoKeywords?: string[]; + /** X/Twitter kaynaklı içerik — tweet verisi */ + sourceTweet?: { + authorUsername: string; + text: string; + media: Array<{ type: string; url: string; width: number; height: number }>; + metrics: { replies: number; retweets: number; likes: number; views: number }; + isThread: boolean; + }; +} + +export interface GeneratedScene { + order: number; + title?: string; + narrationText: string; + visualPrompt: string; + subtitleText: string; + durationSeconds: number; + transitionType: string; + voiceId?: string; + ambientSoundPrompt?: string; // AudioGen: sahne bazlı ses efekti +} + +export interface SeoMetadata { + title: string; + description: string; + keywords: string[]; + hashtags: string[]; + schemaMarkup: Record; +} + +export interface GeneratedScript { + metadata: { + title: string; + description: string; + totalDurationSeconds: number; + language: string; + hashtags: string[]; + }; + seo: SeoMetadata; + scenes: GeneratedScene[]; + musicPrompt: string; + 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 + voiceStyle: string; + socialContent: { + youtubeTitle: string; + youtubeDescription: string; + tiktokCaption: string; + instagramCaption: string; + twitterText: string; + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// SYSTEM PROMPT — Skill-Enriched (16 skill entegrasyonu) +// ═══════════════════════════════════════════════════════════════════════ +// Entegre edilen skill'ler: +// - seo-optimizer: SEO uyumlu başlık, açıklama, keyword, schema markup +// - content-creator: Hook formülleri, engagement stratejisi +// - content-research-writer: Doğrulanmış bilgi, kaynak tabanlı senaryo +// - humanizer: AI yazım kalıplarından kaçınma, insansı dil +// - social-content: Platform-spesifik başlık/açıklama/caption üretimi +// - voice-ai-development: Ses stili ve TTS optimizasyonu +// - multimodal-audiocraft: MusicGen/AudioGen müzik ve ses efekti üretimi +// ═══════════════════════════════════════════════════════════════════════ + +const SYSTEM_PROMPT = `You are an elite YouTube Shorts scriptwriter, cinematic video producer, audio designer, and SEO specialist with 15+ years of experience. You create content that ranks #1 on search, hooks viewers in 1 second, sounds genuinely human, and features professionally crafted audio landscapes. + +Your mission: Create a production-ready video script as a JSON object. This script feeds directly into an automated AI video pipeline — every field is consumed by a real system. Be precise. + +═══════════════════════════════════ +CORE PRINCIPLES +═══════════════════════════════════ + +HUMAN WRITING (anti-AI detection): +- Write narration like a real person talks — not like a textbook +- Vary sentence length. Short. Then longer ones that breathe +- Use "I," "we," personal pronouns when it fits +- Never use: "delve," "tapestry," "landscape" (abstract), "crucial," "moreover," "furthermore," "testament," "underscore," "foster," "garner," "showcase" +- Never use rule-of-three lists ("X, Y, and Z" pattern) repeatedly +- Never use negative parallelisms ("It's not just X, it's Y") +- Avoid em dashes (—) excessively +- Be specific: "47 days" not "a while," "$3,200" not "significant revenue" +- Have opinions. React to facts, don't just report them +- Acknowledge uncertainty: "I'm not sure how to feel about this" is more human than listing pros/cons neutrally + +SEO OPTIMIZATION: +- Video title: Primary keyword within first 3 words, under 60 characters +- Description: 2-3 secondary keywords naturally woven in, 150-200 chars +- Keywords: 8-12 LSI keywords related to the main topic +- Hashtags: 5-8 hashtags, mix of broad (#Shorts) and niche-specific +- Schema markup hint for VideoObject structured data + +HOOK MASTERY (first 2 seconds): +Use ONE of these proven hook types: +- Curiosity: "Nobody talks about [insider knowledge]" +- Data shock: "[Specific number] — and that changes everything" +- Story: "Last week, [unexpected thing] happened" +- Contrarian: "[Common belief] is wrong. Here's why" +- Question: "What if you could [desirable outcome]?" +DO NOT start with generic phrases like "In this video..." or "Today we'll discuss..." + +CONTENT QUALITY: +- Use real, verifiable data points — cite sources when possible +- Structure: Hook → Problem → Evidence → Insight → CTA +- Every scene must create curiosity for the next one +- End with a thought that sticks — not a generic "like and subscribe" +- Make the viewer feel smarter after watching + +═══════════════════════════════════ +VISUAL PROMPTS (ALWAYS IN ENGLISH) +═══════════════════════════════════ + +Each scene's "visualPrompt" MUST be in English for Higgsfield AI. Write as detailed cinematic shot descriptions: +• Camera: close-up, extreme wide, aerial drone, POV, tracking, dolly forward, orbiting, slow tilt up +• Lighting: golden hour, chiaroscuro shadows, neon-lit, backlit silhouettes, warm amber, harsh high-contrast +• Atmosphere: misty, ethereal, vibrant saturated, dark moody, pristine, surreal dreamlike +• Motion: "slow zoom into," "camera glides across," "smooth push-in through," "sweeping pan revealing" +• Include textures, colors, environment, scale references +• NEVER: text, logos, watermarks, recognizable human faces, brand names +• Each prompt: 2-3 DETAILED sentences of rich visual description + +═══════════════════════════════════ +NARRATION TEXT (IN TARGET LANGUAGE) +═══════════════════════════════════ + +• Short, punchy sentences — max 15 words each +• Scene 1: powerful hook creating instant curiosity +• Build escalating intrigue through middle scenes +• End with a thought-provoking statement +• Word count: targetDuration × 2.5 words/second +• Conversational, not academic — like explaining to a smart friend +• Use rhetorical questions, surprising facts, emotional language + +═══════════════════════════════════ +SUBTITLE TEXT (IN TARGET LANGUAGE) +═══════════════════════════════════ + +• Max 8 words per line (mobile readability) +• 1-2 short lines per scene +• Simplify complex narration into punchy visual text + +═══════════════════════════════════ +SCENE STRUCTURE +═══════════════════════════════════ + +• Min 4 scenes, max 10 scenes +• Scene 1 (HOOK): 2-4 seconds — instant attention +• Middle scenes: 5-12 seconds each — build the story +• Final scene (CLOSER): 3-6 seconds — memorable conclusion +• Total duration: within ±5 seconds of targetDuration + +TRANSITION TYPES: +• CUT — Quick, impactful. Most scene changes +• FADE — Emotional, reflective. Openings/closings +• DISSOLVE — Smooth time transitions +• ZOOM_IN — Focus on detail +• ZOOM_OUT — Reveal scale/context + +═══════════════════════════════════ +MUSIC & AUDIO DESIGN (AudioCraft) +═══════════════════════════════════ + +You are also an expert audio designer using Meta AudioCraft (MusicGen + AudioGen). + +"musicPrompt" (for MusicGen text-to-music): +- Write detailed, specific English descriptions for AI music generation +- Include: genre, sub-genre, tempo/BPM, key instruments, mood, energy level +- Specify emotional arc: "starts calm, builds to epic climax, resolves softly" +- Good: "Cinematic orchestral trailer music, 90 BPM, minor key, strings and brass building from pianissimo to fortissimo, ethereal choir in background, Hans Zimmer style tension" +- Bad: "Epic music" or "background music" +- Duration hint is NOT needed (handled by system) + +"musicStyle" (short genre tag): e.g. "cinematic-orchestral", "lo-fi-hiphop", "electronic-ambient" + +"musicTechnical" (structured params): +- bpm: integer (60-180) +- key: optional, e.g. "C minor", "D major" +- instruments: array of 3-6 main instruments +- emotionalArc: describe energy curve, e.g. "low-to-high-to-fade" + +PER-SCENE AMBIENT SOUND (for AudioGen text-to-sound): +Each scene can have an "ambientSoundPrompt" — realistic environmental/foley sounds: +- Describe the soundscape naturally: "rain hitting a window with distant thunder" +- Include texture: "wooden footsteps on creaky floor", "bubbling lava with hissing steam" +- Keep it grounded: AudioGen generates realistic sounds, not music +- Scenes without ambient needs: set to null or omit + +"ambientSoundPrompts" (project-level): Array of 2-3 reusable ambient sound descriptions for the entire project. + +Audio layers in final video (mixed by FFmpeg): +1. Narration (TTS) — loudest, -3dB +2. Background Music (MusicGen) — soft, -18dB under narration +3. Ambient/SFX (AudioGen per scene) — subtle, -22dB + +═══════════════════════════════════ +VOICE STYLE +═══════════════════════════════════ + +Describe ideal TTS voice with precision for ElevenLabs: +- Gender, estimated age range +- Tone: warm, authoritative, excited, calm, mysterious +- Pacing: fast for hooks, measured for data, slow for dramatic reveals +- Effects: slight reverb for epic moments, clean for data + +═══════════════════════════════════ +SOCIAL MEDIA CONTENT +═══════════════════════════════════ + +Generate platform-specific text: +- youtubeTitle: Primary keyword first, under 60 chars, curiosity-driven +- youtubeDescription: 500+ chars, include CTA, 2-3 secondary keywords, link placeholder +- tiktokCaption: Under 150 chars, trending format, 3-5 hashtags +- instagramCaption: Under 300 chars, emotional hook, 5 hashtags +- twitterText: Under 280 chars, hot take format, 2 hashtags + +═══════════════════════════════════ +OUTPUT FORMAT — STRICT JSON ONLY +═══════════════════════════════════ + +Return ONLY valid JSON. No markdown. No backticks. No explanation. + +{ + "metadata": { + "title": "string", + "description": "string — max 200 chars", + "totalDurationSeconds": number, + "language": "string — ISO 639-1", + "hashtags": ["string"] — 5-8 hashtags WITHOUT # + }, + "seo": { + "title": "string — SEO-optimized title, primary keyword first, under 60 chars", + "description": "string — meta description, 150-200 chars, includes secondary keywords", + "keywords": ["string"] — 8-12 LSI keywords, + "hashtags": ["string"] — same as metadata.hashtags, + "schemaMarkup": { + "@type": "VideoObject", + "name": "string", + "description": "string", + "duration": "string — ISO 8601 format PT##S" + } + }, + "scenes": [ + { + "order": 1, + "title": "string", + "narrationText": "string — in target language, HUMAN-SOUNDING", + "visualPrompt": "string — in English for Higgsfield AI", + "subtitleText": "string — in target language, max 8 words/line", + "durationSeconds": number, + "transitionType": "CUT" | "FADE" | "DISSOLVE" | "ZOOM_IN" | "ZOOM_OUT", + "ambientSoundPrompt": "string | null — English, for AudioGen, realistic environment sound" + } + ], + "musicPrompt": "string — detailed English description for MusicGen (genre, BPM, instruments, mood)", + "musicStyle": "string — short genre tag, e.g. cinematic-orchestral", + "musicTechnical": { + "bpm": number, + "key": "string | null", + "instruments": ["string"], + "emotionalArc": "string" + }, + "ambientSoundPrompts": ["string"] — 2-3 project-level ambient sound descriptions for AudioGen, + "voiceStyle": "string — TTS characteristics for ElevenLabs", + "socialContent": { + "youtubeTitle": "string — under 60 chars", + "youtubeDescription": "string — 500+ chars with CTA", + "tiktokCaption": "string — under 150 chars", + "instagramCaption": "string — under 300 chars", + "twitterText": "string — under 280 chars" + } +}`; + +@Injectable() +export class VideoAiService { + private readonly logger = new Logger(VideoAiService.name); + private readonly genAI: GoogleGenAI; + private readonly modelName: string; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.get('gemini.apiKey', ''); + 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.genAI = new GoogleGenAI({ apiKey }); + } + + async generateVideoScript(input: ScriptGenerationInput): Promise { + this.logger.log( + `Senaryo üretimi başladı — Konu: "${input.topic}", ` + + `Süre: ${input.targetDurationSeconds}s, Dil: ${input.language}`, + ); + + const userPrompt = this.buildUserPrompt(input); + + try { + const response = await this.genAI.models.generateContent({ + model: this.modelName, + contents: userPrompt, + config: { + systemInstruction: SYSTEM_PROMPT, + temperature: 0.85, + topP: 0.95, + topK: 40, + maxOutputTokens: 8192, + responseMimeType: 'application/json', + }, + }); + + const rawText = response.text ?? ''; + + if (!rawText.trim()) { + throw new InternalServerErrorException( + 'Gemini API boş yanıt döndü. Lütfen tekrar deneyin.', + ); + } + + const script = this.parseAndValidateScript(rawText); + const humanizedScript = this.applyHumanizerPass(script); + + this.logger.log( + `✅ Senaryo üretildi — "${humanizedScript.metadata.title}", ` + + `${humanizedScript.scenes.length} sahne, ${humanizedScript.metadata.totalDurationSeconds}s, ` + + `SEO keywords: ${humanizedScript.seo?.keywords?.length || 0}`, + ); + + return humanizedScript; + } catch (error) { + if (error instanceof InternalServerErrorException) throw error; + this.logger.error( + `Gemini API hatası: ${error instanceof Error ? error.message : 'Bilinmeyen'}`, + ); + throw new InternalServerErrorException( + `Senaryo üretimi başarısız: ${error instanceof Error ? error.message : 'API hatası'}`, + ); + } + } + + 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', + }; + + const languageName = langMap[input.language] || input.language; + + let prompt = + `Create a YouTube Shorts video script about: "${input.topic}"\n\n` + + `Requirements:\n` + + `- Target duration: ${input.targetDurationSeconds} seconds\n` + + `- Narration and subtitle language: ${languageName} (${input.language})\n` + + `- Visual prompts: ALWAYS in English (for Higgsfield AI)\n` + + `- Video style: ${input.videoStyle}\n` + + `- 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` + + `- Include SEO-optimized metadata with keywords and schema markup\n` + + `- Generate social media captions for YouTube, TikTok, Instagram, Twitter\n`; + + if (input.seoKeywords?.length) { + prompt += `\nTarget SEO keywords to incorporate naturally: ${input.seoKeywords.join(', ')}\n`; + } + + if (input.referenceUrl) { + prompt += `\nReference video/content for style inspiration: ${input.referenceUrl}\n`; + } + + // X/Twitter kaynaklı içerik — tweet verisi prompt'a eklenir + if (input.sourceTweet) { + const tw = input.sourceTweet; + prompt += `\n═══ X/TWITTER SOURCE CONTENT ═══\n`; + prompt += `This video is based on a viral X/Twitter post by @${tw.authorUsername}.\n`; + prompt += `Tweet engagement: ${tw.metrics.likes} likes, ${tw.metrics.retweets} retweets, ${tw.metrics.views} views.\n`; + prompt += `Is thread: ${tw.isThread ? 'YES' : 'NO'}\n`; + prompt += `\nOriginal tweet text:\n"${tw.text}"\n`; + + if (tw.media.length > 0) { + 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`; + photos.forEach((p, i) => { + prompt += ` Reference image ${i + 1}: ${p.url} (${p.width}x${p.height})\n`; + }); + } + } + + 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 += `═══════════════════════════════\n`; + } + + prompt += `\nGenerate the complete script now.`; + return prompt; + } + + /** + * Post-processing: Humanizer skill uygulaması + * AI yazım kalıplarını tespit edip düzeltir + */ + 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', + ]; + + 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', + ]; + + for (const scene of script.scenes) { + let 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}`); + } + } + + // 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}`); + } + } + + scene.narrationText = text; + } + + // SEO alanlarını doldur (eksikse) + if (!script.seo) { + script.seo = { + title: script.metadata.title, + description: script.metadata.description, + keywords: script.metadata.hashtags || [], + hashtags: script.metadata.hashtags || [], + schemaMarkup: { + '@type': 'VideoObject', + name: script.metadata.title, + description: script.metadata.description, + duration: `PT${script.metadata.totalDurationSeconds}S`, + }, + }; + } + + // Social content alanlarını doldur (eksikse) + if (!script.socialContent) { + script.socialContent = { + youtubeTitle: script.metadata.title, + youtubeDescription: script.metadata.description, + tiktokCaption: script.metadata.title, + instagramCaption: script.metadata.title, + twitterText: script.metadata.title, + }; + } + + return script; + } + + private parseAndValidateScript(rawText: string): GeneratedScript { + let parsed: GeneratedScript; + try { + let cleanText = rawText.trim(); + if (cleanText.startsWith('```json')) cleanText = cleanText.slice(7); + if (cleanText.startsWith('```')) cleanText = cleanText.slice(3); + if (cleanText.endsWith('```')) cleanText = cleanText.slice(0, -3); + cleanText = cleanText.trim(); + parsed = JSON.parse(cleanText); + } catch { + this.logger.error(`JSON parse hatası: ${rawText.substring(0, 500)}`); + throw new InternalServerErrorException( + 'AI yanıtı geçerli JSON formatında değil.', + ); + } + + if (!parsed.metadata || !parsed.scenes || !Array.isArray(parsed.scenes)) { + throw new InternalServerErrorException('AI yanıtı beklenen yapıda değil.'); + } + + if (parsed.scenes.length < 2) { + throw new InternalServerErrorException('AI en az 2 sahne üretmelidir.'); + } + + for (const scene of parsed.scenes) { + if (!scene.narrationText || !scene.visualPrompt) { + throw new InternalServerErrorException( + `Sahne ${scene.order}: narrationText ve visualPrompt zorunludur.`, + ); + } + 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'; + } + if (!parsed.musicStyle) { + parsed.musicStyle = 'cinematic-orchestral'; + } + if (!parsed.musicTechnical) { + parsed.musicTechnical = { + bpm: 80, + key: 'C minor', + instruments: ['strings', 'piano', 'brass'], + emotionalArc: 'calm-to-building-to-resolve', + }; + } + if (!parsed.ambientSoundPrompts) { + parsed.ambientSoundPrompts = []; + } + if (!parsed.voiceStyle) { + parsed.voiceStyle = 'Deep, authoritative male voice, warm tone, measured pacing for data, slight dramatic pauses for reveals'; + } + + return parsed; + } +} diff --git a/src/modules/video-queue/video-generation.producer.ts b/src/modules/video-queue/video-generation.producer.ts new file mode 100644 index 0000000..fd70520 --- /dev/null +++ b/src/modules/video-queue/video-generation.producer.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export interface VideoGenerationJobPayload { + projectId: string; + renderJobId: string; + scriptJson: unknown; + language: string; + aspectRatio: string; + videoStyle: string; + targetDuration: number; + scenes: Array<{ + id: string; + order: number; + narrationText: string; + visualPrompt: string; + subtitleText: string; + duration: number; + transitionType: string; + ambientSoundPrompt?: string; // AudioGen: sahne bazlı ortam sesi + }>; +} + +@Injectable() +export class VideoGenerationProducer { + private readonly logger = new Logger(VideoGenerationProducer.name); + private readonly redisClient: Redis; + private readonly workerQueueKey: string; + + constructor( + @InjectQueue('video-generation') + private readonly videoQueue: Queue, + private readonly configService: ConfigService, + ) { + this.redisClient = new Redis({ + host: this.configService.get('redis.host', 'localhost'), + port: this.configService.get('redis.port', 6379), + password: this.configService.get('redis.password') || undefined, + }); + + this.workerQueueKey = 'contgen:queue:video-generation'; + } + + /** + * Job'ı hem BullMQ'ya hem de Redis List'e ekler. + * 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, + }, + ); + + const workerPayload = JSON.stringify({ + jobId: bullJob.id, + ...payload, + timestamp: new Date().toISOString(), + }); + + await this.redisClient.lpush(this.workerQueueKey, workerPayload); + + this.logger.log( + `Job kuyruğa eklendi — BullMQ: ${bullJob.id}, Redis Key: ${this.workerQueueKey}`, + ); + + return bullJob.id || ''; + } + + async getQueueStats() { + const [waiting, active, completed, failed] = await Promise.all([ + this.videoQueue.getWaitingCount(), + this.videoQueue.getActiveCount(), + this.videoQueue.getCompletedCount(), + this.videoQueue.getFailedCount(), + ]); + + const workerQueueLength = await this.redisClient.llen(this.workerQueueKey); + + return { + bullmq: { waiting, active, completed, failed }, + workerQueue: { pending: workerQueueLength }, + }; + } +} diff --git a/src/modules/video-queue/video-queue.module.ts b/src/modules/video-queue/video-queue.module.ts new file mode 100644 index 0000000..7d3e151 --- /dev/null +++ b/src/modules/video-queue/video-queue.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { VideoGenerationProducer } from './video-generation.producer'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'video-generation', + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }, + }), + ], + providers: [VideoGenerationProducer], + exports: [VideoGenerationProducer], +}) +export class VideoQueueModule {} diff --git a/src/modules/x-twitter/dto/x-twitter.dto.ts b/src/modules/x-twitter/dto/x-twitter.dto.ts new file mode 100644 index 0000000..8cc4a08 --- /dev/null +++ b/src/modules/x-twitter/dto/x-twitter.dto.ts @@ -0,0 +1,111 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Matches } from 'class-validator'; + +/** + * X/Twitter URL'si ile tweet çekme DTO'su. + * Desteklenen URL formatları: + * - https://x.com/username/status/1234567890 + * - https://twitter.com/username/status/1234567890 + * - https://x.com/i/status/1234567890 + */ +export class FetchTweetDto { + @ApiProperty({ + description: 'X/Twitter tweet URL\'si', + example: 'https://x.com/elonmusk/status/1893456789012345678', + }) + @IsString() + @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' }, + ) + tweetUrl: string; +} + +/** + * FXTwitter API'den dönen ham tweet verisi. + */ +export interface FxTweetResponse { + code: number; + message: string; + tweet?: { + id: string; + url: string; + text: string; + created_at: string; + author: { + id: string; + name: string; + screen_name: string; + avatar_url: string; + followers_count: number; + following_count: number; + description: string; + verified?: boolean; + }; + replies: number; + retweets: number; + likes: number; + views: number; + media?: { + all: Array<{ + type: 'photo' | 'video' | 'gif'; + url: string; + thumbnail_url?: string; + width: number; + height: number; + }>; + }; + quote?: FxTweetResponse['tweet']; + // Thread bilgisi + replying_to?: string | null; + replying_to_status?: string | null; + }; +} + +/** + * Parse edilmiş ve normalize edilmiş tweet verisi. + */ +export interface ParsedTweet { + id: string; + url: string; + text: string; + createdAt: string; + author: { + id: string; + name: string; + username: string; + avatarUrl: string; + followersCount: number; + verified: boolean; + }; + metrics: { + replies: number; + retweets: number; + likes: number; + views: number; + engagementRate: number; // (likes + retweets + replies) / views * 100 + }; + media: Array<{ + type: 'photo' | 'video' | 'gif'; + url: string; + thumbnailUrl?: string; + width: number; + height: number; + }>; + quotedTweet?: ParsedTweet; + isThread: boolean; + threadTweets?: ParsedTweet[]; +} + +/** + * Tweet ön izleme API response'u. + */ +export interface TweetPreviewResponse { + tweet: ParsedTweet; + suggestedTitle: string; + suggestedPrompt: string; + viralScore: number; // 0-100 + contentType: 'tweet' | 'thread' | 'quote_tweet'; + estimatedDuration: number; // saniye +} diff --git a/src/modules/x-twitter/x-twitter.controller.ts b/src/modules/x-twitter/x-twitter.controller.ts new file mode 100644 index 0000000..229eef2 --- /dev/null +++ b/src/modules/x-twitter/x-twitter.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Post, + Body, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { XTwitterService } from './x-twitter.service'; +import { FetchTweetDto } from './dto/x-twitter.dto'; + +@ApiTags('x-twitter') +@ApiBearerAuth() +@Controller('x-twitter') +export class XTwitterController { + private readonly logger = new Logger(XTwitterController.name); + + constructor(private readonly xTwitterService: XTwitterService) {} + + /** + * Tweet URL'si ile ön izleme — tweet verisi, viral skor, + * önerilen başlık/prompt, tahmini süre döner. + */ + @Post('preview') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'X/Twitter tweet ön izleme' }) + @ApiResponse({ + status: 200, + description: 'Tweet verisi, viral skor ve önerilen başlık', + }) + @ApiResponse({ + status: 400, + description: 'Geçersiz URL veya tweet bulunamadı', + }) + async previewTweet(@Body() dto: FetchTweetDto) { + this.logger.log(`Tweet ön izleme: ${dto.tweetUrl}`); + return this.xTwitterService.previewTweet(dto.tweetUrl); + } + + /** + * Tweet ham verisi — sadece parse edilmiş tweet döner. + * Frontend'de detaylı gösterim için kullanılır. + */ + @Post('fetch') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'X/Twitter tweet verisi çek' }) + @ApiResponse({ status: 200, description: 'Parse edilmiş tweet verisi' }) + async fetchTweet(@Body() dto: FetchTweetDto) { + this.logger.log(`Tweet çekiliyor: ${dto.tweetUrl}`); + return this.xTwitterService.fetchTweet(dto.tweetUrl); + } +} diff --git a/src/modules/x-twitter/x-twitter.module.ts b/src/modules/x-twitter/x-twitter.module.ts new file mode 100644 index 0000000..e5298b2 --- /dev/null +++ b/src/modules/x-twitter/x-twitter.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { XTwitterService } from './x-twitter.service'; +import { XTwitterController } from './x-twitter.controller'; + +/** + * X/Twitter entegrasyon modülü. + * + * FXTwitter API ile tweet çekme, thread toplama, viral analiz. + * ProjectsModule tarafından kullanılır: tweet → video pipeline. + */ +@Module({ + controllers: [XTwitterController], + providers: [XTwitterService], + exports: [XTwitterService], +}) +export class XTwitterModule {} diff --git a/src/modules/x-twitter/x-twitter.service.ts b/src/modules/x-twitter/x-twitter.service.ts new file mode 100644 index 0000000..b866ea9 --- /dev/null +++ b/src/modules/x-twitter/x-twitter.service.ts @@ -0,0 +1,381 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { + FxTweetResponse, + ParsedTweet, + TweetPreviewResponse, +} from './dto/x-twitter.dto'; + +/** + * X/Twitter tweet çekme ve video pipeline'ına dönüştürme servisi. + * + * Neden FXTwitter API? + * - Tamamen ücretsiz, API key gerektirmez + * - Rate limit yeterli (shorts üretimi için ideal) + * - Tweet text, media, metrics, thread bilgisi döner + * - api.fxtwitter.com/{username}/status/{id} formatında çalışır + * + * İleride Xquik API'ye yükseltilebilir ($20/ay, 22 tool). + */ +@Injectable() +export class XTwitterService { + private readonly logger = new Logger(XTwitterService.name); + private readonly fxTwitterBaseUrl = 'https://api.fxtwitter.com'; + + /** + * X/Twitter URL'sinden tweet ID'sini parse eder. + * Desteklenen formatlar: + * - https://x.com/user/status/123 + * - https://twitter.com/user/status/123 + * - https://x.com/i/status/123 + */ + extractTweetId(url: string): { tweetId: string; username: string } { + const patterns = [ + /(?:x\.com|twitter\.com)\/(\w+)\/status\/(\d+)/, + /(?:x\.com|twitter\.com)\/i\/status\/(\d+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + if (match.length === 3) { + return { tweetId: match[2], username: match[1] }; + } + // /i/status/ formatı — username yok + return { tweetId: match[1], username: 'i' }; + } + } + + throw new BadRequestException( + `Geçersiz X/Twitter URL: ${url}. Beklenen format: https://x.com/user/status/123`, + ); + } + + /** + * FXTwitter API ile tweet verisini çeker. + * Thread desteği: Eğer tweet bir thread'in parçasıysa, tüm thread'i toplar. + */ + async fetchTweet(url: string): Promise { + const { tweetId, username } = this.extractTweetId(url); + + this.logger.log(`Tweet çekiliyor: @${username}/status/${tweetId}`); + + const apiUrl = `${this.fxTwitterBaseUrl}/${username}/status/${tweetId}`; + const response = await this.fetchWithRetry(apiUrl); + + if (!response.tweet) { + throw new BadRequestException( + `Tweet bulunamadı veya erişilemez: ${tweetId}`, + ); + } + + const parsed = this.parseFxTweet(response.tweet); + + // Thread tespiti ve toplama + const thread = await this.collectThread(parsed, username); + if (thread.length > 1) { + parsed.isThread = true; + parsed.threadTweets = thread; + this.logger.log( + `Thread tespit edildi: ${thread.length} tweet — @${username}`, + ); + } + + return parsed; + } + + /** + * Tweet verisini video üretimi için ön izleme formatına dönüştürür. + * Viral skor hesaplar, süre tahmin eder, başlık önerir. + */ + async previewTweet(url: string): Promise { + const tweet = await this.fetchTweet(url); + + const viralScore = this.calculateViralScore(tweet); + const contentType = tweet.isThread + ? 'thread' + : tweet.quotedTweet + ? 'quote_tweet' + : 'tweet'; + + // İçerik uzunluğuna göre süre tahmin et + const totalText = tweet.isThread + ? tweet.threadTweets!.map((t) => t.text).join(' ') + : 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 + 90, // Max 90sn + ); + + const suggestedTitle = this.generateTitle(tweet); + const suggestedPrompt = this.tweetToPrompt(tweet); + + return { + tweet, + suggestedTitle, + suggestedPrompt, + viralScore, + contentType, + estimatedDuration, + }; + } + + /** + * Tweet verisini AI prompt formatına dönüştürür. + * VideoAiService'in anlayacağı zenginleştirilmiş prompt. + */ + tweetToPrompt(tweet: ParsedTweet): string { + const parts: string[] = []; + + // Tweet kaynağı ve bağlam + parts.push(`[X/Twitter Kaynak İçerik]`); + parts.push(`Yazar: @${tweet.author.username} (${tweet.author.name})`); + parts.push( + `Etkileşim: ${this.formatNumber(tweet.metrics.likes)} beğeni, ${this.formatNumber(tweet.metrics.retweets)} retweet, ${this.formatNumber(tweet.metrics.views)} görüntülenme`, + ); + + // Thread içeriği + if (tweet.isThread && tweet.threadTweets) { + parts.push(`\nTweet Thread (${tweet.threadTweets.length} tweet):`); + tweet.threadTweets.forEach((t, i) => { + parts.push(`${i + 1}. ${t.text}`); + }); + } else { + parts.push(`\nTweet İçeriği:\n${tweet.text}`); + } + + // Quote tweet varsa + if (tweet.quotedTweet) { + parts.push( + `\nAlıntılanan Tweet (@${tweet.quotedTweet.author.username}):\n${tweet.quotedTweet.text}`, + ); + } + + // Medya bilgisi + if (tweet.media.length > 0) { + const photoCount = tweet.media.filter((m) => m.type === 'photo').length; + const videoCount = tweet.media.filter((m) => m.type === 'video').length; + const pieces: string[] = []; + if (photoCount > 0) pieces.push(`${photoCount} fotoğraf`); + if (videoCount > 0) pieces.push(`${videoCount} video`); + parts.push(`\nMedya: ${pieces.join(', ')}`); + + // Fotoğraf URL'leri referans olarak + const photos = tweet.media.filter((m) => m.type === 'photo'); + if (photos.length > 0) { + parts.push(`Referans Görseller (senaryoda kullanılabilir):`); + photos.forEach((p, i) => { + parts.push(` Görsel ${i + 1}: ${p.url}`); + }); + } + } + + // Video üretim talimatı + parts.push( + `\nBu tweet içeriğinden etkileyici bir shorts videosu senaryosu oluştur. Tweet'in viral olma nedenlerini analiz et ve izleyiciyi yakalayan bir anlatım kur. Tweet'teki görselleri referans olarak kullan, ayrıca AI ile yeni görseller de üret.`, + ); + + return parts.join('\n'); + } + + /** + * Thread tweet'lerini toplar. + * FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et. + */ + private async collectThread( + rootTweet: ParsedTweet, + username: string, + ): Promise { + const threadTweets: ParsedTweet[] = [rootTweet]; + + // Tweet'in reply olup olmadığını kontrol et + // FXTwitter sınırlı thread bilgisi verir, basit heuristik kullanıyoruz + try { + // Username'in son birkaç tweet'ini çekmek yerine, sadece mevcut tweet'i + // thread olarak işaretle (FXTwitter thread_extractor yok) + // İ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); + if (lines.length >= 3) { + // Uzun tek tweet — thread gibi ele alınabilir + return threadTweets; + } + + return threadTweets; + } catch (error) { + this.logger.warn( + `Thread toplama hatası: ${error instanceof Error ? error.message : 'Bilinmeyen'}`, + ); + return threadTweets; + } + } + + /** + * FXTwitter API response'unu ParsedTweet'e dönüştürür. + */ + private parseFxTweet(raw: NonNullable): ParsedTweet { + const views = raw.views || 1; + const engagement = raw.likes + raw.retweets + raw.replies; + + const parsed: ParsedTweet = { + id: raw.id, + url: raw.url, + text: raw.text, + createdAt: raw.created_at, + author: { + id: raw.author.id, + name: raw.author.name, + username: raw.author.screen_name, + avatarUrl: raw.author.avatar_url, + followersCount: raw.author.followers_count, + verified: raw.author.verified || false, + }, + metrics: { + replies: raw.replies, + retweets: raw.retweets, + likes: raw.likes, + views, + engagementRate: views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0, + }, + media: (raw.media?.all || []).map((m) => ({ + type: m.type, + url: m.url, + thumbnailUrl: m.thumbnail_url, + width: m.width, + height: m.height, + })), + isThread: false, + }; + + // Quote tweet + if (raw.quote) { + parsed.quotedTweet = this.parseFxTweet(raw.quote); + } + + return parsed; + } + + /** + * Viral skor hesaplama (0-100). + * Engagement rate, takipçi oranı ve toplam etkileşim bazlı. + */ + private calculateViralScore(tweet: ParsedTweet): number { + const { metrics, author } = tweet; + let score = 0; + + // Engagement rate katkısı (max 40 puan) + if (metrics.engagementRate >= 10) score += 40; + else if (metrics.engagementRate >= 5) score += 30; + else if (metrics.engagementRate >= 2) score += 20; + else if (metrics.engagementRate >= 0.5) score += 10; + + // Toplam beğeni katkısı (max 30 puan) + if (metrics.likes >= 100000) score += 30; + else if (metrics.likes >= 10000) score += 25; + else if (metrics.likes >= 1000) score += 15; + else if (metrics.likes >= 100) score += 8; + + // Görüntülenme katkısı (max 20 puan) + if (metrics.views >= 10000000) score += 20; + else if (metrics.views >= 1000000) score += 15; + else if (metrics.views >= 100000) score += 10; + else if (metrics.views >= 10000) score += 5; + + // Medya bonus (max 10 puan) + if (tweet.media.length > 0) score += 5; + if (tweet.isThread) score += 5; + + return Math.min(score, 100); + } + + /** + * Tweet'ten otomatik başlık önerisi üretir. + */ + private generateTitle(tweet: ParsedTweet): string { + const text = tweet.text; + + // İlk cümle veya satırı al + const firstLine = text.split(/[\n.!?]/)[0]?.trim(); + + if (firstLine && firstLine.length > 10 && firstLine.length <= 100) { + return firstLine; + } + + // Kısa tweet: tamamını kullan + if (text.length <= 100) { + return text.replace(/\n/g, ' ').trim(); + } + + // Uzun tweet: ilk 80 karakteri kes + return text.substring(0, 80).replace(/\n/g, ' ').trim() + '...'; + } + + /** + * HTTP fetch + retry (FXTwitter bazen yavaş olabiliyor). + */ + private async fetchWithRetry( + url: string, + maxRetries = 3, + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + const res = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'ContentGen-AI/1.0', + Accept: 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!res.ok) { + 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})`); + await this.sleep(delay); + continue; + } + throw new Error(`FXTwitter HTTP ${res.status}: ${res.statusText}`); + } + + const data = (await res.json()) as FxTweetResponse; + return data; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + this.logger.warn( + `FXTwitter fetch hatası (deneme ${attempt}/${maxRetries}): ${lastError.message}`, + ); + + if (attempt < maxRetries) { + await this.sleep(1000 * attempt); + } + } + } + + throw new BadRequestException( + `Tweet çekilemedi (${maxRetries} deneme): ${lastError?.message}`, + ); + } + + /** + * Sayı formatlama (1000 → 1K, 1000000 → 1M). + */ + private formatNumber(num: number): string { + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toString(); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +}