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);
}