using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Options; using TeleWave.Application.Common.Interfaces; namespace TeleWave.Infrastructure.Media; /// /// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность /// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной /// математики (см. docs/media-storage-and-streaming.md). /// public sealed class FfmpegMediaProcessor( MediaPathResolver paths, IOptions storageOptions, IOptions mediaOptions ) : IMediaProcessor { private const int MaxWidth = 1920; private readonly StorageOptions _storage = storageOptions.Value; private readonly MediaOptions _media = mediaOptions.Value; public async Task ProcessAsync( Guid assetId, string extension, CancellationToken cancellationToken ) { var originalPath = paths.OriginalPath(assetId, extension); if (!File.Exists(originalPath)) throw new FileNotFoundException("Исходник для обработки не найден.", originalPath); var probe = await ProbeAsync(originalPath, cancellationToken); var segmentSeconds = _storage.SegmentSeconds; // Целевая длительность — вверх до кратности длине сегмента. var target = Math.Ceiling(probe.Duration / segmentSeconds) * segmentSeconds; var pad = target - probe.Duration; var assetDir = paths.AssetDir(assetId); if (Directory.Exists(assetDir)) Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); var (outWidth, outHeight) = ScaleDown(probe.Width, probe.Height); var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad); var result = await ProcessRunner.RunAsync( _media.FfmpegPath, args, lowPriority: true, TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds), cancellationToken ); if (result.ExitCode != 0) throw new InvalidOperationException( $"ffmpeg завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}" ); var playlist = Path.Combine(assetDir, "index.m3u8"); if (!File.Exists(playlist)) throw new InvalidOperationException("ffmpeg не создал плейлист index.m3u8."); var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length; if (segmentCount == 0) throw new InvalidOperationException("ffmpeg не создал ни одного сегмента."); return new MediaProcessingResult( TimeSpan.FromSeconds(target), segmentSeconds, segmentCount, outWidth, outHeight, "h264", "aac", paths.AssetRelativePath(assetId) ); } private List BuildFfmpegArgs( string input, string assetDir, int segmentSeconds, double target, double pad ) { var padding = pad > 0.001; var vfilter = $"scale='min({MaxWidth},iw)':-2"; if (padding) vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black"; var args = new List { "-hide_banner", "-nostdin", "-y", "-i", input, "-threads", _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture), "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-maxrate", "4500k", "-bufsize", "9000k", "-vf", vfilter, "-force_key_frames", $"expr:gte(t,n_forced*{segmentSeconds.ToString(CultureInfo.InvariantCulture)})", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-ar", "48000", }; // Аудиофильтры: нормализация громкости (EBU R128, чтобы все ролики звучали одинаково) и, // при добивке хвоста, тишина apad. Порядок: сначала loudnorm по реальному звуку, потом apad. var afilters = new List(); if (_media.NormalizeLoudness) afilters.Add( $"loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11" ); if (padding) afilters.Add($"apad=pad_dur={Fmt(pad)}"); if (afilters.Count > 0) { args.Add("-af"); args.Add(string.Join(",", afilters)); } if (padding) { args.Add("-t"); args.Add(Fmt(target)); } args.AddRange([ "-f", "hls", "-hls_time", segmentSeconds.ToString(CultureInfo.InvariantCulture), "-hls_playlist_type", "vod", "-hls_list_size", "0", "-hls_segment_filename", Path.Combine(assetDir, "seg%05d.ts"), Path.Combine(assetDir, "index.m3u8"), ]); return args; } private async Task ProbeAsync(string path, CancellationToken cancellationToken) { var result = await ProcessRunner.RunAsync( _media.FfprobePath, ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path], lowPriority: false, TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds), cancellationToken ); if (result.ExitCode != 0) throw new InvalidOperationException( $"ffprobe завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}" ); using var doc = JsonDocument.Parse(result.StdOut); var root = doc.RootElement; double duration = 0; if ( root.TryGetProperty("format", out var format) && format.TryGetProperty("duration", out var durEl) && double.TryParse( durEl.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) ) duration = parsed; int width = 0, height = 0; var hasVideo = false; if (root.TryGetProperty("streams", out var streams)) { foreach (var stream in streams.EnumerateArray()) { if (!stream.TryGetProperty("codec_type", out var typeEl)) continue; if (typeEl.GetString() == "video" && !hasVideo) { hasVideo = true; width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0; height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0; } } } if (duration <= 0) throw new InvalidOperationException("Не удалось определить длительность файла."); if (!hasVideo || width <= 0 || height <= 0) throw new InvalidOperationException("В файле не найдена видеодорожка."); return new ProbeInfo(duration, width, height); } private static (int Width, int Height) ScaleDown(int width, int height) { if (width <= MaxWidth) return (width, height); var scaledHeight = (int)Math.Round((double)height * MaxWidth / width); if (scaledHeight % 2 != 0) scaledHeight++; return (MaxWidth, scaledHeight); } private static string Fmt(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); private static string Tail(string text) { text = text.Trim(); const int max = 500; return text.Length <= max ? text : text[^max..]; } private sealed record ProbeInfo(double Duration, int Width, int Height); }