generated from fahricansecer/boilerplate-be
This commit is contained in:
54
media-worker/Configuration/WorkerSettings.cs
Normal file
54
media-worker/Configuration/WorkerSettings.cs
Normal 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
28
media-worker/Dockerfile
Normal 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"]
|
||||
176
media-worker/Models/VideoGenerationJob.cs
Normal file
176
media-worker/Models/VideoGenerationJob.cs
Normal 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
95
media-worker/Program.cs
Normal 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();
|
||||
}
|
||||
40
media-worker/SaasMediaWorker.csproj
Normal file
40
media-worker/SaasMediaWorker.csproj
Normal 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>
|
||||
110
media-worker/Services/ApiNotificationService.cs
Normal file
110
media-worker/Services/ApiNotificationService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
302
media-worker/Services/AudioCraftService.cs
Normal file
302
media-worker/Services/AudioCraftService.cs
Normal 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;
|
||||
}
|
||||
140
media-worker/Services/DatabaseService.cs
Normal file
140
media-worker/Services/DatabaseService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
327
media-worker/Services/FFmpegService.cs
Normal file
327
media-worker/Services/FFmpegService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
154
media-worker/Services/HiggsFieldService.cs
Normal file
154
media-worker/Services/HiggsFieldService.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
255
media-worker/Services/QueueConsumerService.cs
Normal file
255
media-worker/Services/QueueConsumerService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
172
media-worker/Services/S3StorageService.cs
Normal file
172
media-worker/Services/S3StorageService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
137
media-worker/Services/SunoMusicService.cs
Normal file
137
media-worker/Services/SunoMusicService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
media-worker/Services/TtsService.cs
Normal file
99
media-worker/Services/TtsService.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
323
media-worker/Services/VideoRenderPipeline.cs
Normal file
323
media-worker/Services/VideoRenderPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
media-worker/appsettings.json
Normal file
49
media-worker/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user