Encoding added Requested Encoding to the request, made request async, some refactoring

This commit is contained in:
Samuele Lorefice
2025-12-15 19:00:31 +01:00
parent db20f5d54d
commit db6873c93c
6 changed files with 108 additions and 44 deletions

View File

@@ -7,14 +7,38 @@ Accept: application/json
POST {{Encoder_HostAddress}}/encode POST {{Encoder_HostAddress}}/encode
Content-Type: multipart/form-data; boundary=WebAppBoundary Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="encoder"
Content-Type: text/plain
HEVC
--WebAppBoundary --WebAppBoundary
Content-Disposition: form-data; name="video"; filename="testVid8k.mp4" Content-Disposition: form-data; name="video"; filename="testVid8k.mp4"
Content-Type: video/mp4
< ../EncodingSampleTest/testVid8k.mp4 < ../EncodingSampleTest/testVid8k.mp4
--WebAppBoundary-- --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 POST {{Encoder_HostAddress}}/encode
Content-Type: multipart/form-data; boundary=WebAppBoundary Content-Type: multipart/form-data; boundary=WebAppBoundary

View File

@@ -14,8 +14,8 @@ public class EncoderService : BackgroundService, IEncoderService {
public EncoderService(ILogger<EncoderService> logger, IOptions<FFmpegOptions> ffmpegOptions) { public EncoderService(ILogger<EncoderService> logger, IOptions<FFmpegOptions> ffmpegOptions) {
Logger = logger; Logger = logger;
options = ffmpegOptions.Value; options = ffmpegOptions.Value;
options.FfmpegPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), options.FfmpegPath)); options.FfmpegPath = options.FfmpegPath.AbsoluteOrProcessPath();
options.TemporaryFilesPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), options.TemporaryFilesPath)); options.TemporaryFilesPath = options.TemporaryFilesPath.AbsoluteOrProcessPath();
Directory.CreateDirectory(options.TemporaryFilesPath); // Ensure the temporary files directory exists Directory.CreateDirectory(options.TemporaryFilesPath); // Ensure the temporary files directory exists
JobQueue = new(); JobQueue = new();
@@ -63,7 +63,7 @@ public class EncoderService : BackgroundService, IEncoderService {
void ProcessJob(EncodingJob job) { void ProcessJob(EncodingJob job) {
job.Status = JobStatus.InProgress; job.Status = JobStatus.InProgress;
var file = job.OrigFilePath; var file = job.FilePath;
var mediaInfo = FFProbe.Analyse(file); var mediaInfo = FFProbe.Analyse(file);
if (mediaInfo.PrimaryVideoStream == null) { if (mediaInfo.PrimaryVideoStream == null) {
job.Status = JobStatus.Failed; job.Status = JobStatus.Failed;
@@ -71,7 +71,7 @@ public class EncoderService : BackgroundService, IEncoderService {
} }
var W = mediaInfo.PrimaryVideoStream.Width; var W = mediaInfo.PrimaryVideoStream.Width;
var H = mediaInfo.PrimaryVideoStream.Height; 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); int qp = Utils.ToQPValue(W, H);
var status = FFMpegArguments.FromFileInput(file, true, args => args.WithHardwareAcceleration()) var status = FFMpegArguments.FromFileInput(file, true, args => args.WithHardwareAcceleration())
.OutputToFile(outputPath, true, args => args .OutputToFile(outputPath, true, args => args
@@ -99,8 +99,11 @@ public class EncoderService : BackgroundService, IEncoderService {
if(status) { if(status) {
job.Status = JobStatus.Completed; job.Status = JobStatus.Completed;
job.EncodedFilePath = outputPath; job.EncodedFilePath = outputPath;
File.Delete(file); // Clean up original file
} else { } else {
job.Status = JobStatus.Failed; 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.CompletedAt = DateTime.Now;
job.Progress = 1.0f; job.Progress = 1.0f;

View File

@@ -1,9 +1,31 @@
namespace Encoder; 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 JobStatus Status { get; set; } = JobStatus.Pending;
public DateTime CreatedAt { get; init; } = DateTime.Now; public DateTime CreatedAt { get; init; } = DateTime.Now;
public DateTime? CompletedAt { get; set; } = null; public DateTime? CompletedAt { get; set; } = null;
public string FilePath { get; init; }
public EncoderType RequestedEncoding { get; init; }
public string EncodedFilePath { get; set; } = string.Empty; public string EncodedFilePath { get; set; } = string.Empty;
public float Progress { get; set; } = 0.0f; public float Progress { get; set; } = 0.0f;
public EncodingJob(Guid id, string filePath, EncoderType requestedEncoding) {
Id = id;
FilePath = filePath;
RequestedEncoding = requestedEncoding;
}
} }

View File

@@ -1,8 +0,0 @@
namespace Encoder;
public enum JobStatus {
Pending,
InProgress,
Completed,
Failed
}

View File

@@ -9,7 +9,7 @@ builder.Services.AddOpenApi();
builder.Services.AddLogging(); builder.Services.AddLogging();
string uploadsPath = builder.Configuration.GetSection("UploadsPath").Get<string>() ?? "./Uploads"; string uploadsPath = builder.Configuration.GetSection("UploadsPath").Get<string>() ?? "./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 Directory.CreateDirectory(uploadsPath); // Ensure the uploads directory exists
builder.Services.Configure<FFmpegOptions>(builder.Configuration.GetSection(FFmpegOptions.SectionName)); builder.Services.Configure<FFmpegOptions>(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 a video file as multipart form data and schedule it for encoding
// Get video encoding settings from query parameters // Get video encoding settings from query parameters
// Returns the ID of the job handling the encoding // Returns the ID of the job handling the encoding
app.MapPost("encode", context => app.MapPost("encode", async context => {
{
// Disable request size limit // Disable request size limit
context.Features.Get<IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize = null; context.Features.Get<IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize = null;
var request = context.Request; var request = context.Request;
if (!request.HasFormContentType) { if (!request.HasFormContentType) {
context.Response.StatusCode = 400; 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 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>(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) { if (file == null) {
context.Response.StatusCode = 400; 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 // Save the file to a temporary location
var jobGuid = Guid.NewGuid(); var jobGuid = Guid.NewGuid();
var tempFilePath = Path.GetFullPath(Path.Combine(uploadsPath, jobGuid.ToString("D")+Path.GetExtension(file.FileName))); var tempFilePath = Path.GetFullPath(Path.Combine(uploadsPath, jobGuid.ToString("D")+Path.GetExtension(file.FileName)));
using (var stream = File.Create(tempFilePath)) await using (var stream = File.Create(tempFilePath)) file.CopyTo(stream);
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<EncoderService>(); var encSrv = context.RequestServices.GetService<EncoderService>();
if (encSrv != null) encSrv.EnqueueJob(job); if (encSrv == null) {
else {
context.Response.StatusCode = 500; 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; context.Response.StatusCode = 200;
return context.Response.WriteAsJsonAsync(new { JobId = jobGuid }); await context.Response.WriteAsJsonAsync(new { JobId = jobGuid });
}).WithFormOptions(multipartBodyLengthLimit: 1024L*1024L*1024L*64L); }).WithFormOptions(multipartBodyLengthLimit: 1_073_741_824L*64L);
app.MapGet("status", (context) => { app.MapGet("status", (context) => {
var encSrv = context.RequestServices.GetService<EncoderService>(); var encSrv = context.RequestServices.GetService<EncoderService>();
@@ -77,8 +86,7 @@ app.MapGet("status", (context) => {
}); });
// Check the status of an encoding job by its ID // 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<EncoderService>(); var encSrv = context.RequestServices.GetService<EncoderService>();
if (encSrv == null) return Results.InternalServerError(); 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<EncoderService>(); var encSrv = context.RequestServices.GetService<EncoderService>();
if (encSrv == null) return Results.InternalServerError(); if (encSrv == null) return Results.InternalServerError();

View File

@@ -1,7 +1,10 @@
using System.Runtime.CompilerServices;
using Microsoft.Net.Http.Headers;
namespace Encoder; namespace Encoder;
public static class Utils { public static class Utils {
static Tuple<int, int>[] QPTable = new Tuple<int, int>[] { private static readonly Tuple<int, int>[] QPTable = new Tuple<int, int>[] {
new(1280*720, 64), new(1280*720, 64),
new(1920*1080, 96), new(1920*1080, 96),
new(3840*2160, 128), new(3840*2160, 128),
@@ -9,30 +12,43 @@ public static class Utils {
new(8128*4096, 120) //VR8K new(8128*4096, 120) //VR8K
}.OrderBy(t => t.Item1).ToArray(); }.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); return v0 + t * (v1 - v0);
} }
static float remap(float value, float from1, float to1, float from2, float to2) { public static float Remap(float value, float from1, float to1, float from2, float to2) => from2 + (value - from1) * (to2 - from2) / (to1 - from1);
return from2 + (value - from1) * (to2 - from2) / (to1 - from1);
}
public static int ToQPValue(int W, int H) { public static int ToQPValue(int W, int H) {
int pixels = W * H; int pixels = W * H;
for (var i = 0; i < QPTable.Length; i++) { for (var i = 0; i < QPTable.Length; i++) {
var t = QPTable[i]; if (pixels <= QPTable[i].Item1) {
if (pixels <= t.Item1) {
var minQP = QPTable[i - 1].Item2; var minQP = QPTable[i - 1].Item2;
var maxQP = QPTable[i].Item2; var maxQP = QPTable[i].Item2;
var minPixels = QPTable[i - 1].Item1; var minPixels = QPTable[i - 1].Item1;
var maxPixels = QPTable[i].Item1; var maxPixels = QPTable[i].Item1;
var tPixels = remap(pixels, minPixels, maxPixels, 0, 1);
return (int)lerp(minQP, maxQP, tPixels); 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 the highest QP for anything higher than 8K VR
return QPTable.Last().Item2; 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;
}
} }