| | | 1 | | using Anichron.Worker.Settings; |
| | | 2 | | using Microsoft.Extensions.Options; |
| | | 3 | | |
| | | 4 | | namespace Anichron.Worker.Ingestion.Proxy; |
| | | 5 | | |
| | | 6 | | internal interface IVideoProcessor |
| | | 7 | | { |
| | | 8 | | Task TranscodeAsync(string sourcePath, string outputPath, CancellationToken ct); |
| | | 9 | | } |
| | | 10 | | |
| | 8 | 11 | | internal sealed partial class FfmpegVideoProcessor( |
| | 8 | 12 | | IProcessLauncher processLauncher, |
| | 8 | 13 | | IOptions<WorkerSettings> options, |
| | 8 | 14 | | ILogger<FfmpegVideoProcessor> logger) : IVideoProcessor, IDisposable |
| | | 15 | | { |
| | 1 | 16 | | private static readonly string[] encoderPriority = |
| | 1 | 17 | | [ |
| | 1 | 18 | | H264Encoder.QuickSync, |
| | 1 | 19 | | H264Encoder.Nvenc, |
| | 1 | 20 | | H264Encoder.Amf, |
| | 1 | 21 | | H264Encoder.Software, |
| | 1 | 22 | | ]; |
| | | 23 | | |
| | 8 | 24 | | private readonly SemaphoreSlim encoderDetectionLock = new(1, 1); |
| | | 25 | | private string? detectedEncoder; |
| | | 26 | | |
| | | 27 | | public async Task TranscodeAsync(string sourcePath, string outputPath, CancellationToken ct) |
| | 8 | 28 | | { |
| | | 29 | | string encoder; |
| | 8 | 30 | | await encoderDetectionLock.WaitAsync(ct); |
| | | 31 | | try |
| | 8 | 32 | | { |
| | 8 | 33 | | detectedEncoder ??= await DetectEncoderAsync(ct); |
| | 8 | 34 | | encoder = detectedEncoder; |
| | 8 | 35 | | } |
| | | 36 | | finally |
| | 8 | 37 | | { |
| | 8 | 38 | | encoderDetectionLock.Release(); |
| | 8 | 39 | | } |
| | | 40 | | |
| | 8 | 41 | | await RunFfmpegAsync(sourcePath, outputPath, encoder, ct); |
| | 7 | 42 | | } |
| | | 43 | | |
| | | 44 | | private async Task<string> DetectEncoderAsync(CancellationToken ct) |
| | 7 | 45 | | { |
| | 37 | 46 | | foreach (var encoder in encoderPriority) |
| | 11 | 47 | | { |
| | 11 | 48 | | if (await ProbeEncoderAsync(encoder, ct)) |
| | 6 | 49 | | { |
| | 6 | 50 | | Log.EncoderSelected(logger, encoder); |
| | 6 | 51 | | return encoder; |
| | | 52 | | } |
| | | 53 | | |
| | 5 | 54 | | if (encoder != H264Encoder.Software) |
| | 4 | 55 | | Log.EncoderUnavailable(logger, encoder); |
| | 5 | 56 | | } |
| | | 57 | | |
| | 1 | 58 | | return H264Encoder.Software; |
| | 7 | 59 | | } |
| | | 60 | | |
| | | 61 | | private async Task<bool> ProbeEncoderAsync(string encoder, CancellationToken ct) |
| | 11 | 62 | | { |
| | | 63 | | // Encode a tiny synthetic clip to /dev/null — success means the encoder is available. |
| | | 64 | | // /dev/null is Linux-only; the deployment target is linux/amd64 + linux/arm64 (see Dockerfile). |
| | 11 | 65 | | string[] arguments = |
| | 11 | 66 | | [ |
| | 11 | 67 | | "-f", "lavfi", |
| | 11 | 68 | | "-i", "color=black:s=64x64:r=1:d=0.1", |
| | 11 | 69 | | "-c:v", encoder, |
| | 11 | 70 | | "-f", "mp4", |
| | 11 | 71 | | "/dev/null", |
| | 11 | 72 | | "-y", |
| | 11 | 73 | | ]; |
| | 11 | 74 | | var result = await processLauncher.RunAsync(options.Value.FfmpegPath, arguments, ct); |
| | 11 | 75 | | return result.ExitCode == 0; |
| | 11 | 76 | | } |
| | | 77 | | |
| | | 78 | | private async Task RunFfmpegAsync(string sourcePath, string outputPath, string encoder, CancellationToken ct) |
| | 8 | 79 | | { |
| | | 80 | | // Scale to max height, encode H.264 High/4.0 with yuv420p (universal browser compat), |
| | | 81 | | // target bitrate, AAC audio, faststart for progressive web loading. |
| | 8 | 82 | | string[] arguments = |
| | 8 | 83 | | [ |
| | 8 | 84 | | "-i", sourcePath, |
| | 8 | 85 | | "-vf", $"scale=-2:'min({options.Value.VideoMaxHeight},ih)'", |
| | 8 | 86 | | "-c:v", encoder, |
| | 8 | 87 | | "-pix_fmt", "yuv420p", |
| | 8 | 88 | | "-profile:v", "high", |
| | 8 | 89 | | "-level:v", "4.0", |
| | 8 | 90 | | "-b:v", $"{options.Value.VideoBitrateKbps}k", |
| | 8 | 91 | | "-c:a", "aac", |
| | 8 | 92 | | "-b:a", "128k", |
| | 8 | 93 | | "-movflags", "+faststart", |
| | 8 | 94 | | "-y", |
| | 8 | 95 | | outputPath, |
| | 8 | 96 | | ]; |
| | 8 | 97 | | var result = await processLauncher.RunAsync(options.Value.FfmpegPath, arguments, ct); |
| | 8 | 98 | | if (result.ExitCode != 0) |
| | 1 | 99 | | throw new FfmpegException(options.Value.FfmpegPath, string.Join(" ", arguments), result.ExitCode, result.Sta |
| | 7 | 100 | | } |
| | | 101 | | |
| | 1 | 102 | | public void Dispose() => encoderDetectionLock.Dispose(); |
| | | 103 | | |
| | | 104 | | private static partial class Log |
| | | 105 | | { |
| | | 106 | | [LoggerMessage(Level = LogLevel.Information, Message = "Selected H.264 encoder: {Encoder}.")] |
| | | 107 | | public static partial void EncoderSelected(ILogger logger, string encoder); |
| | | 108 | | |
| | | 109 | | [LoggerMessage(Level = LogLevel.Debug, Message = "H.264 encoder {Encoder} is unavailable; trying next.")] |
| | | 110 | | public static partial void EncoderUnavailable(ILogger logger, string encoder); |
| | | 111 | | } |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | internal static class H264Encoder |
| | | 115 | | { |
| | | 116 | | internal const string QuickSync = "h264_qsv"; |
| | | 117 | | internal const string Nvenc = "h264_nvenc"; |
| | | 118 | | internal const string Amf = "h264_amf"; |
| | | 119 | | internal const string Software = "libx264"; |
| | | 120 | | } |
| | | 121 | | |
| | | 122 | | public sealed class FfmpegException : Exception |
| | | 123 | | { |
| | | 124 | | public int ExitCode { get; } |
| | | 125 | | public string Stderr { get; } = string.Empty; |
| | | 126 | | |
| | | 127 | | public FfmpegException() { } |
| | | 128 | | public FfmpegException(string message) : base(message) { } |
| | | 129 | | public FfmpegException(string message, Exception innerException) : base(message, innerException) { } |
| | | 130 | | |
| | | 131 | | public FfmpegException(string ffmpegPath, string arguments, int exitCode, string stderr) |
| | | 132 | | : base($"FFmpeg exited with code {exitCode}. Args: {ffmpegPath} {arguments}\nStderr: {stderr}") |
| | | 133 | | { |
| | | 134 | | ExitCode = exitCode; |
| | | 135 | | Stderr = stderr; |
| | | 136 | | } |
| | | 137 | | } |