Files
TeleWave/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs
T

245 lines
8.4 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.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность
/// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной
/// математики (см. docs/media-storage-and-streaming.md).
/// </summary>
public sealed class FfmpegMediaProcessor(
MediaPathResolver paths,
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions
) : IMediaProcessor
{
private const int MaxWidth = 1920;
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
public async Task<MediaProcessingResult> 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<string> 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<string>
{
"-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<string>();
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<ProbeInfo> 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);
}