Files
ContentGen_BE/media-worker/Services/FFmpegService.cs
Harun CAN 85c35c73e8
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
main
2026-03-29 12:43:49 +03:00

328 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}