diff --git a/Encoder/Dockerfile b/Encoder/Dockerfile index 4a61aba..2851404 100644 --- a/Encoder/Dockerfile +++ b/Encoder/Dockerfile @@ -4,6 +4,14 @@ WORKDIR /app EXPOSE 8080 EXPOSE 8081 +FROM debian:stable-slim AS ffmpeg +RUN apt-get update && apt-get install -y unzip +WORKDIR /ffmpeg +ADD "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-64.zip" ./ffmpeg.zip +ADD "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-64.zip" ./ffprobe.zip +RUN unzip ./ffmpeg.zip -d ./bin/ +RUN unzip ./ffprobe.zip -d ./bin/ + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src @@ -20,4 +28,5 @@ RUN dotnet publish "./Encoder.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +COPY --from=ffmpeg /ffmpeg/bin/ /app/ffmpeg/ ENTRYPOINT ["dotnet", "Encoder.dll"] diff --git a/Encoder/Encoder.http b/Encoder/Encoder.http index 27b6c79..01df824 100644 --- a/Encoder/Encoder.http +++ b/Encoder/Encoder.http @@ -1,6 +1,42 @@ @Encoder_HostAddress = http://localhost:5257 -GET {{Encoder_HostAddress}}/weatherforecast/ +GET {{Encoder_HostAddress}}/status Accept: application/json +### + +POST {{Encoder_HostAddress}}/encode +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="video"; filename="testVid8k.mp4" + +< ../EncodingSampleTest/testVid8k.mp4 +--WebAppBoundary-- ### + +POST {{Encoder_HostAddress}}/encode +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="video"; filename="testVid6k.mp4" + +< ../EncodingSampleTest/testVid6K.mp4 +--WebAppBoundary-- + +### + +POST {{Encoder_HostAddress}}/encode +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="video"; filename="testVid.mp4" + +< ../EncodingSampleTest/testVid.mp4 +--WebAppBoundary-- + +### + +GET {{Encoder_HostAddress}}/file/e897d62f-1134-4017-b8a2-c3d1c4994bc6 +Accept: video/mp4 +### \ No newline at end of file diff --git a/Encoder/EncoderService.cs b/Encoder/EncoderService.cs index a0b7206..fabbd54 100644 --- a/Encoder/EncoderService.cs +++ b/Encoder/EncoderService.cs @@ -1,9 +1,51 @@ +using System.Numerics; +using FFMpegCore; +using FFMpegCore.Enums; +using Microsoft.Extensions.Options; + namespace Encoder; -public class EncoderService : IEncoderService { +public class EncoderService : BackgroundService, IEncoderService { Queue JobQueue; - public EncoderService(EncoderServiceOptions options) { - JobQueue = new Queue(); + List Jobs = new(); + ILogger Logger; + FFmpegOptions options; + + public EncoderService(ILogger logger, IOptions ffmpegOptions) { + Logger = logger; + options = ffmpegOptions.Value; + options.FfmpegPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), options.FfmpegPath)); + options.TemporaryFilesPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), options.TemporaryFilesPath)); + Directory.CreateDirectory(options.TemporaryFilesPath); // Ensure the temporary files directory exists + JobQueue = new(); + + logger.Log(LogLevel.Information, + $""" + Starting Encoder Service with options: + TemporaryFilesPath: {options.TemporaryFilesPath} + FfmpegPath: {options.FfmpegPath}" + """); + + GlobalFFOptions.Configure(ffOptions => { + ffOptions.BinaryFolder = options.FfmpegPath; + ffOptions.TemporaryFilesFolder = options.TemporaryFilesPath; + }); + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) => ProcessJobs(stoppingToken); + + Task ProcessJobs(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + if (JobQueue.Count > 0) { + // Grab a reference to the next job in queue + var job = JobQueue.Peek(); + ProcessJob(job); // Process the job + JobQueue.Dequeue(); // Remove it from the queue + Jobs.Add(job); // Add it to the completed jobs list + } + Thread.Sleep(5); // Prevent tight loop + } + return Task.CompletedTask; } public Guid EnqueueJob(EncodingJob job) { @@ -12,28 +54,55 @@ public class EncoderService : IEncoderService { } public EncodingJob? GetJobStatus(Guid jobId) { - return JobQueue.FirstOrDefault(j => j.Id == jobId); + return Jobs.FirstOrDefault(j => j.Id == jobId); } - public void RemoveJob(Guid jobId) { - var job = JobQueue.FirstOrDefault(j => j.Id == jobId); - if (job != null) { - var tempQueue = new Queue(); - while (JobQueue.Count > 0) { - var currentJob = JobQueue.Dequeue(); - if (currentJob.Id != jobId) { - tempQueue.Enqueue(currentJob); - } - } - JobQueue = tempQueue; + public IEnumerable GetJobs() { + return JobQueue.Concat(Jobs); + } + + void ProcessJob(EncodingJob job) { + job.Status = JobStatus.InProgress; + var file = job.OrigFilePath; + var mediaInfo = FFProbe.Analyse(file); + if (mediaInfo.PrimaryVideoStream == null) { + job.Status = JobStatus.Failed; + return; } - } - - public Task ProcessNextJob() { - var job = JobQueue.Dequeue(); - // Encode.... - - - return Task.CompletedTask; + var W = mediaInfo.PrimaryVideoStream.Width; + var H = mediaInfo.PrimaryVideoStream.Height; + string outputPath = Path.Combine(options.TemporaryFilesPath, Path.GetFileName(job.OrigFilePath)); + int qp = Utils.ToQPValue(W, H); + var status = FFMpegArguments.FromFileInput(file, true, args => args.WithHardwareAcceleration()) + .OutputToFile(outputPath, true, args => args + .CopyChannel(Channel.Audio) + .CopyChannel(Channel.Subtitle) + .WithVideoCodec("av1_nvenc") + .WithArgument(new NvencSpeedPreset(NvencSpeed.p2)) + .WithArgument(new NvencTuneArgument(NvencTune.hq)) + .WithArgument(new NvencHighBitDepthArgument(true)) + .WithArgument(new NvencQPArgument((byte)qp)) + .WithFastStart() + ) + .NotifyOnProgress(progress => { + Logger.Log(LogLevel.Information, + $""" + Job {job.Id}: {progress / mediaInfo.Duration:P} + Processed {progress:g} | Total {mediaInfo.Duration:g} + Using AV1 NVENC with QP={qp} for {W}x{H}@{mediaInfo.PrimaryVideoStream.FrameRate}. + In path: {file} + Output path: {outputPath} + """); + job.Progress = (float)(progress / mediaInfo.Duration); + }) + .ProcessSynchronously(); + if(status) { + job.Status = JobStatus.Completed; + job.EncodedFilePath = outputPath; + } else { + job.Status = JobStatus.Failed; + } + job.CompletedAt = DateTime.Now; + job.Progress = 1.0f; } } \ No newline at end of file diff --git a/Encoder/EncoderServiceOptions.cs b/Encoder/EncoderServiceOptions.cs deleted file mode 100644 index 930e339..0000000 --- a/Encoder/EncoderServiceOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Encoder; - -public record class EncoderServiceOptions { - public string OutputPath { get; init; } = Path.GetTempPath(); - public string FfmpegPath { get; init; } = Path.Combine(Environment.ProcessPath, "ffmpeg"); -} \ No newline at end of file diff --git a/Encoder/EncodingJob.cs b/Encoder/EncodingJob.cs index 6b1bc2f..95c74fe 100644 --- a/Encoder/EncodingJob.cs +++ b/Encoder/EncodingJob.cs @@ -1,9 +1,9 @@ namespace Encoder; -public record EncodingJob(Guid Id) { +public record EncodingJob(Guid Id, string OrigFilePath) { public JobStatus Status { get; set; } = JobStatus.Pending; public DateTime CreatedAt { get; init; } = DateTime.Now; public DateTime? CompletedAt { get; set; } = null; - public string OrigFilePath { get; init; } = string.Empty; public string EncodedFilePath { get; set; } = string.Empty; + public float Progress { get; set; } = 0.0f; } \ No newline at end of file diff --git a/Encoder/FfmpegOptions.cs b/Encoder/FfmpegOptions.cs new file mode 100644 index 0000000..d905f47 --- /dev/null +++ b/Encoder/FfmpegOptions.cs @@ -0,0 +1,7 @@ +namespace Encoder; + +public class FFmpegOptions { + public const string SectionName = "FFmpeg"; + public string TemporaryFilesPath { get; set; } = Path.GetTempPath(); + public string FfmpegPath { get; set; } = Path.Combine(Environment.ProcessPath!, "ffmpeg"); +} \ No newline at end of file diff --git a/Encoder/IEncoderService.cs b/Encoder/IEncoderService.cs index 984a003..f25415e 100644 --- a/Encoder/IEncoderService.cs +++ b/Encoder/IEncoderService.cs @@ -3,5 +3,4 @@ namespace Encoder; public interface IEncoderService { public Guid EnqueueJob(EncodingJob job); public EncodingJob? GetJobStatus(Guid jobId); - public void RemoveJob(Guid jobId); } \ No newline at end of file diff --git a/Encoder/NvencExt.cs b/Encoder/NvencExt.cs new file mode 100644 index 0000000..274af92 --- /dev/null +++ b/Encoder/NvencExt.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace Encoder; + +public enum NvencSpeed { + Default=0, + Slow=1, + Medium=2, + Fast=3, + p1=12, + p2=13, + p3=14, + p4=15, + p5=16, + p6=17, + p7=18, +} + +public enum NvencTune { + hq=1, + uhq=5, + ll=2, + ull=3, + lossless=4, +} + +class NvencSpeedPreset(NvencSpeed speed) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-preset {speed.ToString().ToLower()}"; } } +} + +class NvencTuneArgument(NvencTune tune) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-tune {tune.ToString().ToLower()}"; } } +} + +class NvencHighBitDepthArgument(bool enable) : FFMpegCore.Arguments.IArgument { + public string Text { get { return enable ? "-highbitdepth true" : string.Empty; } } +} + +class NvencQPArgument([Range(-1, 255)]byte qp) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-qp {qp}"; } } +} \ No newline at end of file diff --git a/Encoder/OpenApi.http b/Encoder/OpenApi.http new file mode 100644 index 0000000..2a53f4f --- /dev/null +++ b/Encoder/OpenApi.http @@ -0,0 +1,5 @@ +### GET request to example server +GET https://examples.http-client.intellij.net/get + ?generated-in=JetBrains Rider + +### \ No newline at end of file diff --git a/Encoder/Program.cs b/Encoder/Program.cs index 0e2b02e..c672d4c 100644 --- a/Encoder/Program.cs +++ b/Encoder/Program.cs @@ -1,15 +1,21 @@ using Encoder; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); -//Settings -string? tmpFilePath = builder.Configuration.GetValue("TempFilePath"); -string? ffmpegPath = builder.Configuration.GetValue("FfmpegPath"); //Services builder.Services.AddOpenApi(); +builder.Services.AddLogging(); + +string uploadsPath = builder.Configuration.GetSection("UploadsPath").Get() ?? "./Uploads"; +if(!Path.IsPathRooted(uploadsPath)) uploadsPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), uploadsPath)); +Directory.CreateDirectory(uploadsPath); // Ensure the uploads directory exists + +builder.Services.Configure(builder.Configuration.GetSection(FFmpegOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(p => p.GetRequiredService()); -var encoderOptions = new EncoderServiceOptions { OutputPath = tmpFilePath }; -builder.Services.AddSingleton(_ => new (encoderOptions)); var app = builder.Build(); @@ -20,8 +26,11 @@ app.UseHttpsRedirection(); // Get a video file as multipart form data and schedule it for encoding // Get video encoding settings from query parameters // Returns the ID of the job handling the encoding -app.MapPost("encode", context => +app.MapPost("encode", context => { + // Disable request size limit + context.Features.Get()?.MaxRequestBodySize = null; + var request = context.Request; if (!request.HasFormContentType) { context.Response.StatusCode = 400; @@ -29,14 +38,20 @@ app.MapPost("encode", context => } var form = request.Form; - var file = form.Files.GetFile("video"); - + var file = form.Files.GetFile("video"); // Contrary to what it seems, the "name" here is the form field name, not the file name + if (file == null) { context.Response.StatusCode = 400; return context.Response.WriteAsync("No video file provided."); } - var job = new EncodingJob(Guid.NewGuid()); + // Save the file to a temporary location + var jobGuid = Guid.NewGuid(); + var tempFilePath = Path.GetFullPath(Path.Combine(uploadsPath, jobGuid.ToString("D")+Path.GetExtension(file.FileName))); + using (var stream = File.Create(tempFilePath)) + file.CopyTo(stream); + + var job = new EncodingJob(jobGuid, tempFilePath); var encSrv = context.RequestServices.GetService(); if (encSrv != null) encSrv.EnqueueJob(job); else { @@ -45,7 +60,20 @@ app.MapPost("encode", context => } context.Response.StatusCode = 200; - return context.Response.WriteAsJsonAsync(new { JobId = job.Id }); + return context.Response.WriteAsJsonAsync(new { JobId = jobGuid }); +}).WithFormOptions(multipartBodyLengthLimit: 1024L*1024L*1024L*64L); + +app.MapGet("status", (context) => { + var encSrv = context.RequestServices.GetService(); + if (encSrv == null) { + context.Response.StatusCode = 500; + return context.Response.WriteAsync("Encoder service not available."); + } + + var jobs = encSrv.GetJobs().ToArray(); + + context.Response.StatusCode = 200; + return context.Response.WriteAsJsonAsync(jobs); }); // Check the status of an encoding job by its ID @@ -84,4 +112,6 @@ app.MapGet("file/{jobId:guid}", (HttpContext context, Guid jobId) => return Results.File(fileBytes, "video/mp4", Path.GetFileName(filePath), enableRangeProcessing:true); }); +app.MapOpenApi("openapi.json"); + app.Run(); \ No newline at end of file diff --git a/Encoder/Utils.cs b/Encoder/Utils.cs new file mode 100644 index 0000000..ae0117c --- /dev/null +++ b/Encoder/Utils.cs @@ -0,0 +1,38 @@ +namespace Encoder; + +public static class Utils { + static Tuple[] QPTable = new Tuple[] { + new(1280*720, 64), + new(1920*1080, 96), + new(3840*2160, 128), + new(5760*2880, 96), //VR6K + new(8128*4096, 120) //VR8K + }.OrderBy(t => t.Item1).ToArray(); + + static float lerp(float v0, float v1, float t) { + return v0 + t * (v1 - v0); + } + + static float remap(float value, float from1, float to1, float from2, float to2) { + return from2 + (value - from1) * (to2 - from2) / (to1 - from1); + } + + public static int ToQPValue(int W, int H) { + int pixels = W * H; + for (var i = 0; i < QPTable.Length; i++) { + var t = QPTable[i]; + if (pixels <= t.Item1) { + var minQP = QPTable[i - 1].Item2; + var maxQP = QPTable[i].Item2; + + var minPixels = QPTable[i - 1].Item1; + var maxPixels = QPTable[i].Item1; + var tPixels = remap(pixels, minPixels, maxPixels, 0, 1); + + return (int)lerp(minQP, maxQP, tPixels); + } + } + // Return the highest QP for anything higher than 8K VR + return QPTable.Last().Item2; + } +} \ No newline at end of file diff --git a/Encoder/appsettings.json b/Encoder/appsettings.json index 6fef078..ccfd3f1 100644 --- a/Encoder/appsettings.json +++ b/Encoder/appsettings.json @@ -6,6 +6,9 @@ } }, "AllowedHosts": "*", - "TemporaryFilesPath": "./Temp", - "FfmpegPath": "./ffmpeg" + "FFmpeg": { + "TemporaryFilesPath": "./Temp", + "FfmpegPath": "./ffmpeg" + }, + "UploadsPath": "./Uploads" } diff --git a/EncodingSampleTest/EncodingSampleTest.csproj b/EncodingSampleTest/EncodingSampleTest.csproj new file mode 100644 index 0000000..faf544f --- /dev/null +++ b/EncodingSampleTest/EncodingSampleTest.csproj @@ -0,0 +1,32 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + Always + + + Always + + + Always + + + Always + + + + diff --git a/EncodingSampleTest/EncodingSampleTest.csproj.DotSettings b/EncodingSampleTest/EncodingSampleTest.csproj.DotSettings new file mode 100644 index 0000000..e789070 --- /dev/null +++ b/EncodingSampleTest/EncodingSampleTest.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/EncodingSampleTest/NvencExt.cs b/EncodingSampleTest/NvencExt.cs new file mode 100644 index 0000000..b758a64 --- /dev/null +++ b/EncodingSampleTest/NvencExt.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace EncodingSampleTest; + +public enum NvencSpeed { + Default=0, + Slow=1, + Medium=2, + Fast=3, + p1=12, + p2=13, + p3=14, + p4=15, + p5=16, + p6=17, + p7=18, +} + +public enum NvencTune { + hq=1, + uhq=5, + ll=2, + ull=3, + lossless=4, +} + +class NvencSpeedPreset(NvencSpeed speed) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-preset {speed.ToString().ToLower()}"; } } +} + +class NvencTuneArgument(NvencTune tune) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-tune {tune.ToString().ToLower()}"; } } +} + +class NvencHighBitDepthArgument(bool enable) : FFMpegCore.Arguments.IArgument { + public string Text { get { return enable ? "-highbitdepth true" : string.Empty; } } +} + +class NvencQPArgument([Range(-1, 255)]byte qp) : FFMpegCore.Arguments.IArgument { + public string Text { get { return $"-qp {qp}"; } } +} \ No newline at end of file diff --git a/EncodingSampleTest/Program.cs b/EncodingSampleTest/Program.cs new file mode 100644 index 0000000..258bd64 --- /dev/null +++ b/EncodingSampleTest/Program.cs @@ -0,0 +1,65 @@ +using System; +using EncodingSampleTest; +using FFMpegCore; +using FFMpegCore.Enums; + +string ffmpegPath = Path.Combine(Environment.CurrentDirectory, "vendor"); +string tempPath = Path.Combine(Environment.CurrentDirectory, "tmp"); +string outputDir = Path.Combine(Environment.CurrentDirectory, "output"); +Directory.CreateDirectory(outputDir); +Directory.CreateDirectory(tempPath); +const string inputFile = "testVid8k.mp4"; + +GlobalFFOptions.Configure(options => { + options.BinaryFolder = ffmpegPath; + options.TemporaryFilesFolder = tempPath; +}); + +if(!File.Exists(inputFile)) return; + +var mediaInfo = FFProbe.Analyse(inputFile); +if (mediaInfo.PrimaryVideoStream == null) { + Console.WriteLine("No video stream found."); + return; +} +var W = mediaInfo.PrimaryVideoStream.Width; +var H = mediaInfo.PrimaryVideoStream.Height; + +for (int qp = 32; qp <= 160; qp += 32) { + await AV1Encode(W, H, qp); +} + +/* +for (int qp = 32; qp <= 250; qp += 32) { + await AV1Encode(1920, 1080, qp); +}*/ + + + +Task AV1Encode(int W = -1, int H = -1, int QP = 23) { // AV1 is visually lossless at QP 23 + var outputFile = Path.Combine(outputDir, $"output_av1-{W}x{H}_qp{QP}.mp4"); + var ffmpegArgs = FFMpegArguments + .FromFileInput(inputFile, true, options => options + .WithHardwareAcceleration() + ) + .OutputToFile(outputFile, true, options => options + .CopyChannel(Channel.Audio) + .CopyChannel(Channel.Subtitle) + .WithVideoCodec("hevc_nvenc") + .WithArgument(new NvencSpeedPreset(NvencSpeed.p4)) + .WithArgument(new NvencTuneArgument(NvencTune.hq)) + .WithArgument(new NvencHighBitDepthArgument(true)) + .WithArgument(new NvencQPArgument((byte)QP)) + .WithVideoFilters(filterOptions => { + if (W > 0 && H > 0) filterOptions.Scale(W, H); + }) + .WithFastStart() + ) + .WithLogLevel(FFMpegLogLevel.Info) + .NotifyOnProgress(progress => Console.WriteLine($"Encoding {outputFile}: {progress:g}/{mediaInfo.Duration:g} {progress/mediaInfo.Duration:P}")); + + Console.WriteLine(ffmpegArgs.Arguments); + + var res = ffmpegArgs.ProcessSynchronously(); + return Task.CompletedTask; +} diff --git a/VidEncSrv.slnx b/VidEncSrv.slnx index 23b87a1..874fbbc 100644 --- a/VidEncSrv.slnx +++ b/VidEncSrv.slnx @@ -3,4 +3,5 @@ + diff --git a/compose.yaml b/compose.yaml index 9d70ac8..b1d93f2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,4 +4,10 @@ build: context: . dockerfile: Encoder/Dockerfile - + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] \ No newline at end of file