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

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

View File

@@ -0,0 +1,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();
}
}