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

This commit is contained in:
Harun CAN
2026-03-29 12:43:49 +03:00
parent 829413f05d
commit 85c35c73e8
41 changed files with 6127 additions and 36 deletions

View File

@@ -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";
}

28
media-worker/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,176 @@
using System.Text.Json.Serialization;
namespace SaasMediaWorker.Models;
/// <summary>
/// Redis kuyruğundan alınan video üretim job payload'ı.
/// NestJS VideoGenerationProducer tarafından JSON olarak yazılır.
/// </summary>
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<ScenePayload> 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<ScriptScene> 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<string> 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<string> 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<string> 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; }
}
/// <summary>
/// Render pipeline'ının ürettiği medya dosyası referansı.
/// </summary>
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
}

95
media-worker/Program.cs Normal file
View File

@@ -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<WorkerSettings>(
builder.Configuration.GetSection("WorkerSettings"));
builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis"));
builder.Services.Configure<S3Settings>(
builder.Configuration.GetSection("S3"));
builder.Services.Configure<ApiSettings>(
builder.Configuration.GetSection("ApiSettings"));
builder.Services.Configure<FFmpegSettings>(
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<HiggsFieldService>("HiggsField")
.AddPolicyHandler(combinedPolicy);
builder.Services.AddHttpClient<TtsService>("TTS")
.AddPolicyHandler(combinedPolicy);
builder.Services.AddHttpClient<SunoMusicService>("Suno")
.AddPolicyHandler(combinedPolicy);
builder.Services.AddHttpClient<ApiNotificationService>("CoreAPI")
.AddPolicyHandler(retryPolicy);
// Service registrations
builder.Services.AddSingleton<S3StorageService>();
builder.Services.AddSingleton<FFmpegService>();
builder.Services.AddSingleton<VideoRenderPipeline>();
builder.Services.AddSingleton<DatabaseService>();
// Background Service — Redis Queue Consumer
builder.Services.AddHostedService<QueueConsumerService>();
var host = builder.Build();
await host.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, "💀 Media Worker başlatılamadı!");
}
finally
{
await Log.CloseAndFlushAsync();
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SaasMediaWorker</RootNamespace>
<AssemblyName>SaasMediaWorker</AssemblyName>
<!-- ARM64 Raspberry Pi için Native AOT desteği -->
<RuntimeIdentifiers>linux-arm64;linux-x64;osx-arm64;osx-x64</RuntimeIdentifiers>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- Redis (BullMQ Interop via Redis List) -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<!-- AWS S3 / Cloudflare R2 -->
<PackageReference Include="AWSSDK.S3" Version="3.7.405.5" />
<!-- Polly Retry / Circuit Breaker -->
<PackageReference Include="Polly" Version="8.5.2" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.12" />
<!-- PostgreSQL (Direct DB updates) -->
<PackageReference Include="Npgsql" Version="8.0.6" />
<!-- JSON -->
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<!-- Hosting -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<!-- Logging -->
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// NestJS Core API'ye bildirim gönderen HttpClient servisi.
/// Worker işlem tamamlandığında veya hata oluştuğunda API'yi bilgilendirir.
/// </summary>
public class ApiNotificationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<ApiNotificationService> _logger;
private readonly ApiSettings _settings;
public ApiNotificationService(
HttpClient httpClient,
ILogger<ApiNotificationService> logger,
IOptions<ApiSettings> settings)
{
_httpClient = httpClient;
_logger = logger;
_settings = settings.Value;
_httpClient.BaseAddress = new Uri(_settings.CoreApiBaseUrl);
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Render tamamlandığında NestJS API'sine bildirim gönderir.
/// </summary>
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);
}
/// <summary>
/// Render başarısız olduğunda NestJS API'sine hata bildirimi gönderir.
/// </summary>
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);
}
}
}

View File

@@ -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;
/// <summary>
/// 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ı.
/// </summary>
public class AudioCraftService
{
private readonly HttpClient _httpClient;
private readonly ILogger<AudioCraftService> _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<AudioCraftService> logger,
IOptions<ApiSettings> settings)
{
_httpClient = httpClient;
_logger = logger;
_settings = settings.Value;
_httpClient.DefaultRequestHeaders.Add(
"Authorization", $"Bearer {_settings.HuggingFaceApiKey}");
_httpClient.Timeout = TimeSpan.FromMinutes(5);
}
/// <summary>
/// MusicGen ile text-to-music üretimi.
/// Proje için background müzik oluşturur.
/// </summary>
public async Task<GeneratedMediaFile> 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"
};
}
/// <summary>
/// AudioGen ile sahne bazlı ambient ses efekti üretimi.
/// Her sahne için farklı bir ortam sesi oluşturulabilir.
/// </summary>
public async Task<GeneratedMediaFile?> 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;
}
}
/// <summary>
/// Projenin tüm sahneleri için batch ambient ses üretimi.
/// Paralel çalışır — Raspberry Pi'da bellek dostu.
/// </summary>
public async Task<List<GeneratedMediaFile>> GenerateAllAmbientSoundsAsync(
List<ScenePayload> scenes,
string outputDirectory,
CancellationToken ct)
{
var results = new List<GeneratedMediaFile>();
// 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<byte[]?> 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}");
}
/// <summary>
/// MusicGen prompt'unu teknik parametrelerle zenginleştirir.
/// AudioCraft skill'den öğrenilen best practice'lere göre optimize eder.
/// </summary>
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<JsonElement>(json);
if (doc.TryGetProperty("estimated_time", out var time))
return Math.Max(10, (int)time.GetDouble());
}
catch { }
return 20; // Default: 20 saniye bekle
}
}
/// <summary>
/// MusicGen teknik parametreleri — AI senaryo çıktısından parse edilir.
/// </summary>
public class MusicTechnicalParams
{
public int Bpm { get; set; }
public string? Key { get; set; }
public List<string> Instruments { get; set; } = new();
public string EmotionalArc { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,140 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace SaasMediaWorker.Services;
/// <summary>
/// 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.
/// </summary>
public class DatabaseService
{
private readonly ILogger<DatabaseService> _logger;
private readonly string _connectionString;
public DatabaseService(
ILogger<DatabaseService> logger,
IConfiguration configuration)
{
_logger = logger;
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("DefaultConnection bağlantı dizesi bulunamadı");
}
/// <summary>
/// RenderJob tablosunun durumunu günceller.
/// </summary>
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);
}
/// <summary>
/// Project tablosunun durumunu günceller.
/// </summary>
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);
}
/// <summary>
/// Render log kaydı ekler.
/// </summary>
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();
}
}

View File

@@ -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;
/// <summary>
/// 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ı
/// </summary>
public class FFmpegService
{
private readonly ILogger<FFmpegService> _logger;
private readonly FFmpegSettings _settings;
public FFmpegService(
ILogger<FFmpegService> logger,
IOptions<FFmpegSettings> settings)
{
_logger = logger;
_settings = settings.Value;
// Temp dizinini oluştur
Directory.CreateDirectory(_settings.TempDirectory);
}
/// <summary>
/// Her sahnenin video klibine narration ses dosyasını overlay eder.
/// Çıktı: sync edilmiş video+narration dosyası
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Sahne videolarına altyazı ekler (burn-in).
/// ASS formatında dinamik altyazı dosyası oluşturur.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Tüm sahne videolarını sırayla birleştirir ve background müzik ekler.
/// Bu, render pipeline'ının son adımıdır.
/// </summary>
public async Task<string> ConcatenateAndFinalize(
List<string> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Video dosyasının süresini FFprobe ile ölçer.
/// </summary>
public async Task<double> 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;
}
/// <summary>
/// ASS formatında altyazı dosyası üretir.
/// Mobile-first: büyük font, ortalanmış, yarı-şeffaf arka plan.
/// </summary>
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<string>();
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}
";
}
/// <summary>
/// FFmpeg process'ini çalıştırır ve çıktısını izler.
/// ARM64 uyumlu — hardware acceleration kullanmaz.
/// </summary>
private async Task RunFFmpegAsync(string arguments, CancellationToken ct)
{
await RunProcessAsync(_settings.BinaryPath, arguments, ct);
}
private async Task<string> 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();
}
}

View File

@@ -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;
/// <summary>
/// Higgsfield AI API Client — Video klip üretimi.
/// Her sahnenin görsel prompt'unu Higgsfield'a gönderip video dosyası indirir.
/// </summary>
public class HiggsFieldService
{
private readonly HttpClient _httpClient;
private readonly ILogger<HiggsFieldService> _logger;
private readonly ApiSettings _settings;
public HiggsFieldService(
HttpClient httpClient,
ILogger<HiggsFieldService> logger,
IOptions<ApiSettings> 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);
}
/// <summary>
/// Bir sahne için video klip üretir ve dosyayı belirtilen dizine indirir.
/// </summary>
public async Task<GeneratedMediaFile> 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<JsonElement>(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<string> 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<JsonElement>(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"
};
}

View File

@@ -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;
/// <summary>
/// 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
/// </summary>
public class QueueConsumerService : BackgroundService
{
private readonly ILogger<QueueConsumerService> _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<QueueConsumerService> logger,
IOptions<RedisSettings> redisSettings,
IOptions<WorkerSettings> 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<VideoGenerationJob>(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<int, string, Task> 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);
}
}

View File

@@ -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;
/// <summary>
/// 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
/// </summary>
public class S3StorageService
{
private readonly ILogger<S3StorageService> _logger;
private readonly S3Settings _settings;
private readonly AmazonS3Client _s3Client;
public S3StorageService(
ILogger<S3StorageService> logger,
IOptions<S3Settings> 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);
}
/// <summary>
/// Tek bir dosyayı S3/R2'ye yükler.
/// Büyük dosyalar (>5MB) otomatik multipart upload kullanır.
/// </summary>
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);
}
/// <summary>
/// Render pipeline sonucu üretilen tüm medya dosyalarını toplu yükler.
/// </summary>
public async Task<List<GeneratedMediaFile>> UploadAllMediaAsync(
string projectId,
List<GeneratedMediaFile> mediaFiles,
CancellationToken ct)
{
var uploadedFiles = new List<GeneratedMediaFile>();
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;
}
/// <summary>
/// Final videoyu yükler ve public URL döner.
/// </summary>
public async Task<string> 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);
}
}

View File

@@ -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;
/// <summary>
/// Suno AI API Client — Background müzik üretimi.
/// Projenin musicPrompt'unu kullanarak AI müzik üretir.
/// </summary>
public class SunoMusicService
{
private readonly HttpClient _httpClient;
private readonly ILogger<SunoMusicService> _logger;
private readonly ApiSettings _settings;
public SunoMusicService(
HttpClient httpClient,
ILogger<SunoMusicService> logger,
IOptions<ApiSettings> 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);
}
/// <summary>
/// Proje için background müzik üretir ve dosyaya kaydeder.
/// </summary>
public async Task<GeneratedMediaFile> 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<JsonElement>(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<string> 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<JsonElement>(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);
}
}

View File

@@ -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;
/// <summary>
/// ElevenLabs TTS API Client — Metin → Ses dönüşümü.
/// Her sahnenin narrationText'ini sese çevirir.
/// </summary>
public class TtsService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TtsService> _logger;
private readonly ApiSettings _settings;
public TtsService(
HttpClient httpClient,
ILogger<TtsService> logger,
IOptions<ApiSettings> 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);
}
/// <summary>
/// Bir sahnenin narration metnini sese çevirir ve dosyaya kaydeder.
/// </summary>
public async Task<GeneratedMediaFile> 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"
};
}
}

View File

@@ -0,0 +1,323 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SaasMediaWorker.Configuration;
using SaasMediaWorker.Models;
namespace SaasMediaWorker.Services;
/// <summary>
/// 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
/// </summary>
public class VideoRenderPipeline
{
private readonly ILogger<VideoRenderPipeline> _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<VideoRenderPipeline> logger,
HiggsFieldService higgsField,
TtsService tts,
SunoMusicService sunoMusic,
AudioCraftService audioCraft,
FFmpegService ffmpeg,
S3StorageService s3,
IOptions<FFmpegSettings> ffmpegSettings)
{
_logger = logger;
_higgsField = higgsField;
_tts = tts;
_sunoMusic = sunoMusic;
_audioCraft = audioCraft;
_ffmpeg = ffmpeg;
_s3 = s3;
_ffmpegSettings = ffmpegSettings.Value;
}
/// <summary>
/// Tam render pipeline'ını çalıştırır.
/// Giriş: VideoGenerationJob (Redis'ten alınan)
/// Çıkış: Final video'nun S3 URL'i
/// </summary>
public async Task<string> ExecuteAsync(
VideoGenerationJob job,
Func<int, string, Task> progressCallback,
CancellationToken ct)
{
var projectDir = Path.Combine(_ffmpegSettings.TempDirectory, job.ProjectId);
Directory.CreateDirectory(projectDir);
var allMediaFiles = new List<GeneratedMediaFile>();
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<Task<GeneratedMediaFile>>();
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<Task<GeneratedMediaFile>>();
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<GeneratedMediaFile>();
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<string>();
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<GeneratedMediaFile> GenerateVideoClipWithProgress(
ScenePayload scene, string outputDir, string aspectRatio,
Func<Task> onComplete, CancellationToken ct)
{
var result = await _higgsField.GenerateVideoClipAsync(
scene, outputDir, aspectRatio, ct);
await onComplete();
return result;
}
private async Task<GeneratedMediaFile> GenerateTtsWithProgress(
ScenePayload scene, string outputDir, string voiceStyle,
Func<Task> 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);
}
}
}

View File

@@ -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"
}
}