generated from fahricansecer/boilerplate-be
328 lines
12 KiB
C#
328 lines
12 KiB
C#
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();
|
||
}
|
||
}
|