using System.Diagnostics; using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SaasMediaWorker.Configuration; using SaasMediaWorker.Models; namespace SaasMediaWorker.Services; /// /// FFmpeg ARM64 Wrapper — Video render, birleştirme ve altyazı ekleme. /// /// Neden FFmpeg (Canva API yerine)? /// - Canva API rate-limit'li ve ücretli. FFmpeg sıfır maliyet. /// - ARM64 Raspberry Pi'da native çalışır (Alpine paketi ile). /// - Text overlay, audio merge, transition'lar için endüstri standardı. /// - Offline çalışır — dış API bağımlılığı yok. /// /// ARM64 önemli notlar: /// - Hardware acceleration yok (RPi'da GPU desteği kısıtlı) → software encode /// - libx264 + aac codec kullanılıyor (ARM64'te stabil) /// - Memory sınırı: maxmuxqueuesize ile OOM koruması /// public class FFmpegService { private readonly ILogger _logger; private readonly FFmpegSettings _settings; public FFmpegService( ILogger logger, IOptions settings) { _logger = logger; _settings = settings.Value; // Temp dizinini oluştur Directory.CreateDirectory(_settings.TempDirectory); } /// /// Her sahnenin video klibine narration ses dosyasını overlay eder. /// Çıktı: sync edilmiş video+narration dosyası /// public async Task MergeVideoWithNarrationAsync( string videoPath, string narrationPath, int sceneOrder, string outputDirectory, CancellationToken ct) { var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_merged.mp4"); // Video'ya narration ses'i ekle, video ses'i kapat var args = new StringBuilder(); args.Append($"-y -i \"{videoPath}\" -i \"{narrationPath}\" "); args.Append("-map 0:v:0 -map 1:a:0 "); args.Append("-c:v libx264 -preset fast -crf 23 "); args.Append("-c:a aac -b:a 128k "); args.Append("-movflags +faststart "); args.Append("-shortest "); // Kısa olan medyaya göre kes args.Append($"\"{outputPath}\""); await RunFFmpegAsync(args.ToString(), ct); _logger.LogInformation("Video+Narration merge — Sahne {Order}: {Path}", sceneOrder, outputPath); return outputPath; } /// /// Sahne videolarına altyazı ekler (burn-in). /// ASS formatında dinamik altyazı dosyası oluşturur. /// public async Task AddSubtitlesAsync( string videoPath, string subtitleText, int sceneOrder, string outputDirectory, CancellationToken ct) { // ASS altyazı dosyası oluştur var assPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_sub.ass"); var assContent = GenerateAssSubtitle(subtitleText, 0, 60); await File.WriteAllTextAsync(assPath, assContent, ct); var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_subtitled.mp4"); var escapedAssPath = assPath.Replace(":", "\\:").Replace("'", "\\'"); var args = new StringBuilder(); args.Append($"-y -i \"{videoPath}\" "); args.Append($"-vf \"ass={escapedAssPath}\" "); args.Append("-c:v libx264 -preset fast -crf 23 "); args.Append("-c:a copy "); args.Append("-movflags +faststart "); args.Append($"\"{outputPath}\""); await RunFFmpegAsync(args.ToString(), ct); return outputPath; } /// /// Tüm sahne videolarını sırayla birleştirir ve background müzik ekler. /// Bu, render pipeline'ının son adımıdır. /// public async Task ConcatenateAndFinalize( List sceneVideoPaths, string? musicPath, string outputDirectory, string projectId, double targetDuration, CancellationToken ct) { _logger.LogInformation( "🎬 Final render başlıyor — {Count} sahne birleştiriliyor", sceneVideoPaths.Count); // 1. Concat dosyası oluştur (FFmpeg concat demuxer) var concatListPath = Path.Combine(outputDirectory, "concat_list.txt"); var concatContent = string.Join("\n", sceneVideoPaths.Select(p => $"file '{p}'")); await File.WriteAllTextAsync(concatListPath, concatContent, ct); // 2. Önce videoları birleştir var concatenatedPath = Path.Combine(outputDirectory, "concatenated.mp4"); var concatArgs = new StringBuilder(); concatArgs.Append($"-y -f concat -safe 0 -i \"{concatListPath}\" "); concatArgs.Append("-c:v libx264 -preset fast -crf 22 "); concatArgs.Append("-c:a aac -b:a 128k "); concatArgs.Append("-movflags +faststart "); concatArgs.Append($"\"{concatenatedPath}\""); await RunFFmpegAsync(concatArgs.ToString(), ct); // 3. Background müzik varsa ekle var finalPath = Path.Combine(outputDirectory, $"final_{projectId}.mp4"); if (!string.IsNullOrEmpty(musicPath) && File.Exists(musicPath)) { // Narration sesi (stream 0:a) + müzik (stream 1:a) mix // Müzik %20 volume (narration'ın altında kalması için) var musicArgs = new StringBuilder(); musicArgs.Append($"-y -i \"{concatenatedPath}\" -i \"{musicPath}\" "); musicArgs.Append("-filter_complex \""); musicArgs.Append("[1:a]volume=0.20[music];"); musicArgs.Append("[0:a][music]amix=inputs=2:duration=first:dropout_transition=3[aout]\" "); musicArgs.Append("-map 0:v:0 -map \"[aout]\" "); musicArgs.Append("-c:v copy "); musicArgs.Append("-c:a aac -b:a 192k "); musicArgs.Append("-movflags +faststart "); musicArgs.Append($"-t {targetDuration.ToString("F1", CultureInfo.InvariantCulture)} "); musicArgs.Append($"\"{finalPath}\""); await RunFFmpegAsync(musicArgs.ToString(), ct); } else { // Müzik yoksa sadece süreyi kes var trimArgs = $"-y -i \"{concatenatedPath}\" -c copy " + $"-t {targetDuration.ToString("F1", CultureInfo.InvariantCulture)} " + $"-movflags +faststart \"{finalPath}\""; await RunFFmpegAsync(trimArgs, ct); } // Dosya boyutunu kontrol et var fileInfo = new FileInfo(finalPath); _logger.LogInformation( "✅ Final render tamamlandı — {Path} ({Size:F1} MB)", finalPath, fileInfo.Length / (1024.0 * 1024.0)); return finalPath; } /// /// Sahne videosuna AudioGen ambient ses efektini overlay eder. /// Volume: -22dB (narration ve müziğin altında, atmosferik katman). /// AudioCraft AudioGen'den gelen ses dosyası ile birleştirir. /// public async Task OverlayAmbientAudioAsync( string videoPath, string ambientAudioPath, int sceneOrder, string outputDirectory, CancellationToken ct) { var outputPath = Path.Combine(outputDirectory, $"scene_{sceneOrder:D2}_ambient.mp4"); var args = new StringBuilder(); args.Append($"-y -i \"{videoPath}\" -i \"{ambientAudioPath}\" "); args.Append("-filter_complex \""); // Ambient ses: %8 volume (-22dB) — çok hafif arka plan katmanı args.Append("[1:a]volume=0.08,aformat=sample_rates=44100:channel_layouts=mono[amb];"); // Mevcut ses + ambient mix args.Append("[0:a][amb]amix=inputs=2:duration=first:dropout_transition=2[aout]\" "); args.Append("-map 0:v:0 -map \"[aout]\" "); args.Append("-c:v copy "); args.Append("-c:a aac -b:a 128k "); args.Append("-movflags +faststart "); args.Append($"\"{outputPath}\""); await RunFFmpegAsync(args.ToString(), ct); _logger.LogInformation( "🔊 Ambient overlay — Sahne {Order}: {Path}", sceneOrder, outputPath); return outputPath; } /// /// Video dosyasının süresini FFprobe ile ölçer. /// public async Task GetVideoDurationAsync(string videoPath, CancellationToken ct) { var args = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{videoPath}\""; var result = await RunProcessAsync(_settings.FfprobePath, args, ct); if (double.TryParse(result.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var duration)) { return duration; } return 0; } /// /// ASS formatında altyazı dosyası üretir. /// Mobile-first: büyük font, ortalanmış, yarı-şeffaf arka plan. /// private static string GenerateAssSubtitle(string text, double startSeconds, double endSeconds) { var startTime = TimeSpan.FromSeconds(startSeconds).ToString(@"h\:mm\:ss\.ff"); var endTime = TimeSpan.FromSeconds(endSeconds).ToString(@"h\:mm\:ss\.ff"); // Satır uzunluğunu sınırla (mobil okunabilirlik) var words = text.Split(' '); var lines = new List(); var currentLine = new StringBuilder(); foreach (var word in words) { if (currentLine.Length + word.Length + 1 > 30) { lines.Add(currentLine.ToString().Trim()); currentLine.Clear(); } currentLine.Append(word).Append(' '); } if (currentLine.Length > 0) lines.Add(currentLine.ToString().Trim()); var formattedText = string.Join("\\N", lines); return $@"[Script Info] Title: ContentGen AI Subtitles ScriptType: v4.00+ PlayResX: 1080 PlayResY: 1920 WrapStyle: 0 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Default,DejaVu Sans,56,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,40,40,120,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text Dialogue: 0,{startTime},{endTime},Default,,0,0,0,,{formattedText} "; } /// /// FFmpeg process'ini çalıştırır ve çıktısını izler. /// ARM64 uyumlu — hardware acceleration kullanmaz. /// private async Task RunFFmpegAsync(string arguments, CancellationToken ct) { await RunProcessAsync(_settings.BinaryPath, arguments, ct); } private async Task RunProcessAsync(string executable, string arguments, CancellationToken ct) { _logger.LogDebug("Process: {Exe} {Args}", executable, arguments); using var process = new Process { StartInfo = new ProcessStartInfo { FileName = executable, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, } }; var stdout = new StringBuilder(); var stderr = new StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(ct); if (process.ExitCode != 0) { var errorOutput = stderr.ToString(); _logger.LogError("FFmpeg hata (exit: {Code}): {Error}", process.ExitCode, errorOutput[..Math.Min(500, errorOutput.Length)]); throw new InvalidOperationException( $"FFmpeg başarısız (exit: {process.ExitCode}): {errorOutput[..Math.Min(200, errorOutput.Length)]}"); } return stdout.ToString(); } }