From db6873c93c0d5b3dc96116e3fd6ca85b0c2eb39a Mon Sep 17 00:00:00 2001 From: Samuele Lorefice Date: Mon, 15 Dec 2025 19:00:31 +0100 Subject: [PATCH] Encoding added Requested Encoding to the request, made request async, some refactoring --- Encoder/Encoder.http | 24 ++++++++++++++++++++ Encoder/EncoderService.cs | 11 +++++---- Encoder/EncodingJob.cs | 24 +++++++++++++++++++- Encoder/JobStatus.cs | 8 ------- Encoder/Program.cs | 47 ++++++++++++++++++++++----------------- Encoder/Utils.cs | 38 ++++++++++++++++++++++--------- 6 files changed, 108 insertions(+), 44 deletions(-) delete mode 100644 Encoder/JobStatus.cs diff --git a/Encoder/Encoder.http b/Encoder/Encoder.http index 01df824..adb6b79 100644 --- a/Encoder/Encoder.http +++ b/Encoder/Encoder.http @@ -7,14 +7,38 @@ Accept: application/json POST {{Encoder_HostAddress}}/encode Content-Type: multipart/form-data; boundary=WebAppBoundary +--WebAppBoundary +Content-Disposition: form-data; name="encoder" +Content-Type: text/plain + +HEVC --WebAppBoundary Content-Disposition: form-data; name="video"; filename="testVid8k.mp4" +Content-Type: video/mp4 < ../EncodingSampleTest/testVid8k.mp4 --WebAppBoundary-- ### +# @timeout 5m (big ass file) +POST {{Encoder_HostAddress}}/encode +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="encoder" +Content-Type: text/plain + +AV1 +--WebAppBoundary +Content-Disposition: form-data; name="video"; filename="testBigVid.mp4" +Content-Type: video/mp4 + +< ../EncodingSampleTest/DBER-180.mp4 +--WebAppBoundary-- + +### + POST {{Encoder_HostAddress}}/encode Content-Type: multipart/form-data; boundary=WebAppBoundary diff --git a/Encoder/EncoderService.cs b/Encoder/EncoderService.cs index fabbd54..0c51b8a 100644 --- a/Encoder/EncoderService.cs +++ b/Encoder/EncoderService.cs @@ -14,8 +14,8 @@ public class EncoderService : BackgroundService, IEncoderService { 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)); + options.FfmpegPath = options.FfmpegPath.AbsoluteOrProcessPath(); + options.TemporaryFilesPath = options.TemporaryFilesPath.AbsoluteOrProcessPath(); Directory.CreateDirectory(options.TemporaryFilesPath); // Ensure the temporary files directory exists JobQueue = new(); @@ -63,7 +63,7 @@ public class EncoderService : BackgroundService, IEncoderService { void ProcessJob(EncodingJob job) { job.Status = JobStatus.InProgress; - var file = job.OrigFilePath; + var file = job.FilePath; var mediaInfo = FFProbe.Analyse(file); if (mediaInfo.PrimaryVideoStream == null) { job.Status = JobStatus.Failed; @@ -71,7 +71,7 @@ public class EncoderService : BackgroundService, IEncoderService { } var W = mediaInfo.PrimaryVideoStream.Width; var H = mediaInfo.PrimaryVideoStream.Height; - string outputPath = Path.Combine(options.TemporaryFilesPath, Path.GetFileName(job.OrigFilePath)); + string outputPath = Path.Combine(options.TemporaryFilesPath, Path.GetFileName(job.FilePath)); int qp = Utils.ToQPValue(W, H); var status = FFMpegArguments.FromFileInput(file, true, args => args.WithHardwareAcceleration()) .OutputToFile(outputPath, true, args => args @@ -99,8 +99,11 @@ public class EncoderService : BackgroundService, IEncoderService { if(status) { job.Status = JobStatus.Completed; job.EncodedFilePath = outputPath; + File.Delete(file); // Clean up original file } else { job.Status = JobStatus.Failed; + // Clean up any partially created output file. Leave original file for retry. + if (File.Exists(outputPath)) File.Delete(outputPath); } job.CompletedAt = DateTime.Now; job.Progress = 1.0f; diff --git a/Encoder/EncodingJob.cs b/Encoder/EncodingJob.cs index 95c74fe..a72e873 100644 --- a/Encoder/EncodingJob.cs +++ b/Encoder/EncodingJob.cs @@ -1,9 +1,31 @@ namespace Encoder; -public record EncodingJob(Guid Id, string OrigFilePath) { +public enum JobStatus { + Pending, + InProgress, + Completed, + Failed +} + + +public enum EncoderType { + AV1, + HEVC +} + +public record EncodingJob { + public Guid Id { get; init; } public JobStatus Status { get; set; } = JobStatus.Pending; public DateTime CreatedAt { get; init; } = DateTime.Now; public DateTime? CompletedAt { get; set; } = null; + public string FilePath { get; init; } + public EncoderType RequestedEncoding { get; init; } public string EncodedFilePath { get; set; } = string.Empty; public float Progress { get; set; } = 0.0f; + + public EncodingJob(Guid id, string filePath, EncoderType requestedEncoding) { + Id = id; + FilePath = filePath; + RequestedEncoding = requestedEncoding; + } } \ No newline at end of file diff --git a/Encoder/JobStatus.cs b/Encoder/JobStatus.cs deleted file mode 100644 index f82bd79..0000000 --- a/Encoder/JobStatus.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Encoder; - -public enum JobStatus { - Pending, - InProgress, - Completed, - Failed -} \ No newline at end of file diff --git a/Encoder/Program.cs b/Encoder/Program.cs index c672d4c..e834398 100644 --- a/Encoder/Program.cs +++ b/Encoder/Program.cs @@ -9,7 +9,7 @@ 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)); +uploadsPath = uploadsPath.AbsoluteOrProcessPath(); Directory.CreateDirectory(uploadsPath); // Ensure the uploads directory exists builder.Services.Configure(builder.Configuration.GetSection(FFmpegOptions.SectionName)); @@ -26,42 +26,51 @@ 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", async context => { // Disable request size limit context.Features.Get()?.MaxRequestBodySize = null; var request = context.Request; if (!request.HasFormContentType) { context.Response.StatusCode = 400; - return context.Response.WriteAsync("Invalid content type. Expected multipart/form-data."); + await context.Response.WriteAsync("Invalid content type. Expected multipart/form-data."); + return; } var form = request.Form; - var file = form.Files.GetFile("video"); // Contrary to what it seems, the "name" here is the form field name, not the file name - + var encoderType = form["encoder"].FirstOrDefault(); + if(encoderType == null || !Enum.TryParse(encoderType, true, out var parsedEncoderType)) { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Invalid or missing encoderType parameter."); + return; + } + + // Contrary to what it seems, the "name" here is the form field name, not the file name + var file = form.Files.GetFile("video"); if (file == null) { context.Response.StatusCode = 400; - return context.Response.WriteAsync("No video file provided."); + await context.Response.WriteAsync("No video file provided."); + return; } // 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); + await using (var stream = File.Create(tempFilePath)) file.CopyTo(stream); - var job = new EncodingJob(jobGuid, tempFilePath); + // Create and enqueue the encoding job + var job = new EncodingJob(jobGuid, tempFilePath, parsedEncoderType); var encSrv = context.RequestServices.GetService(); - if (encSrv != null) encSrv.EnqueueJob(job); - else { + if (encSrv == null) { context.Response.StatusCode = 500; - return context.Response.WriteAsync("Encoder service not available."); + await context.Response.WriteAsync("Encoder service not available."); + return; } - + + encSrv.EnqueueJob(job); context.Response.StatusCode = 200; - return context.Response.WriteAsJsonAsync(new { JobId = jobGuid }); -}).WithFormOptions(multipartBodyLengthLimit: 1024L*1024L*1024L*64L); + await context.Response.WriteAsJsonAsync(new { JobId = jobGuid }); +}).WithFormOptions(multipartBodyLengthLimit: 1_073_741_824L*64L); app.MapGet("status", (context) => { var encSrv = context.RequestServices.GetService(); @@ -77,8 +86,7 @@ app.MapGet("status", (context) => { }); // Check the status of an encoding job by its ID -app.MapGet("status/{jobId:guid}", (HttpContext context, Guid jobId) => -{ +app.MapGet("status/{jobId:guid}", (HttpContext context, Guid jobId) => { var encSrv = context.RequestServices.GetService(); if (encSrv == null) return Results.InternalServerError(); @@ -95,8 +103,7 @@ app.MapGet("status/{jobId:guid}", (HttpContext context, Guid jobId) => }); }); -app.MapGet("file/{jobId:guid}", (HttpContext context, Guid jobId) => -{ +app.MapGet("file/{jobId:guid}", (HttpContext context, Guid jobId) => { var encSrv = context.RequestServices.GetService(); if (encSrv == null) return Results.InternalServerError(); diff --git a/Encoder/Utils.cs b/Encoder/Utils.cs index ae0117c..f860896 100644 --- a/Encoder/Utils.cs +++ b/Encoder/Utils.cs @@ -1,7 +1,10 @@ +using System.Runtime.CompilerServices; +using Microsoft.Net.Http.Headers; + namespace Encoder; public static class Utils { - static Tuple[] QPTable = new Tuple[] { + private static readonly Tuple[] QPTable = new Tuple[] { new(1280*720, 64), new(1920*1080, 96), new(3840*2160, 128), @@ -9,30 +12,43 @@ public static class Utils { new(8128*4096, 120) //VR8K }.OrderBy(t => t.Item1).ToArray(); - static float lerp(float v0, float v1, float t) { + public 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 float Remap(float value, float from1, float to1, float from2, float to2) => 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) { + if (pixels <= QPTable[i].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); + + var tPixels = Remap(pixels, minPixels, maxPixels, 0, 1); - return (int)lerp(minQP, maxQP, tPixels); + return (int)Lerp(minQP, maxQP, tPixels); } } // Return the highest QP for anything higher than 8K VR return QPTable.Last().Item2; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string AbsoluteOrProcessPath(this string path) { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath)!, path)); + } + + public static bool IsMultipartContentType(string contentType) + => !string.IsNullOrEmpty(contentType) && contentType.Contains("multipart/form-data"); + + public static string? GetBoundary(string contentType) + { + var boundary = HeaderUtilities.RemoveQuotes( + MediaTypeHeaderValue.Parse(contentType).Boundary + ).Value; + return boundary; + } } \ No newline at end of file