Implement media storage and processing features: update configuration in .env.example and appsettings files, enhance Docker setup for media storage, and add media-related services and database entities. Include ffmpeg for media handling and adjust Kestrel settings for large file uploads.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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,
|
||||
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",
|
||||
};
|
||||
|
||||
if (padding)
|
||||
{
|
||||
args.Add("-af");
|
||||
args.Add($"apad=pad_dur={Fmt(pad)}");
|
||||
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,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user