Skip to content

Commit

Permalink
Adaptive Retry Mechanism (#7)
Browse files Browse the repository at this point in the history
* chore(Settings): expanded description

- Added video codec information
- Added resolution information

* feat(Globals): add ValidResolutions array

* refactor(RootCommand): use globals

- Use ValidResolutions to check resoluions
- Use VideoCodecs to check video codecs

* chore: add EOF

* refactor: simplify start and end time parsing

* refactor: improve conversion process configuration and handling

- Now we take `IList<IStream>` instead of `IEnumerable<IStream>`, eliminating the need to convert to a list within the method
- Fix inappopriate usage of output file extension for VP9 & AV1
- AV1 using `yuv420p10le` instead of `yuv420`

* feat: introduce adaptive retry mechanism for compression

- Implement an interactive retry mechanism that activates when output video is larger than the original
- Make resolution and Constant Rate Factor (CRF) adjustable within the retry workflow
- Include user prompts to enter custom resolution and CRF values during retries

* chore: version bump
  • Loading branch information
DontEatOreo committed Feb 7, 2024
1 parent fcdd417 commit 40759b0
Show file tree
Hide file tree
Showing 10 changed files with 241 additions and 119 deletions.
227 changes: 177 additions & 50 deletions CommandLineApp/Conversion/Converter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,61 +6,76 @@

namespace dis.CommandLineApp.Conversion;

public sealed class Converter(PathHandler pathHandler, ProcessHandler processHandler, ILogger logger)
public sealed class Converter(PathHandler pathHandler, Globals globals, ProcessHandler processHandler, ILogger logger)
{
/// <summary>
/// Converts a video file to a specified format using FFmpeg.
/// </summary>
/// <param name="file">The path to the input video file.</param>
/// <param name="dateTime">The optional date and time to set for the output file.</param>
/// <param name="o">The options to use for the conversion.</param>
/// <param name="s">The options to use for the conversion.</param>
/// <returns>A task that represents the asynchronous conversion operation.</returns>
public async Task ConvertVideo(string file, DateTime? dateTime, Settings o)
public async Task ConvertVideo(string file, DateTime? dateTime, Settings s)
{
Console.CancelKeyPress += HandleCancellation;
while (true)
{
Console.CancelKeyPress += HandleCancellation;

var cmpPath = pathHandler.GetCompressPath(file, o.VideoCodec);
var outP = pathHandler.ConstructFilePath(o, cmpPath);
var cmpPath = pathHandler.GetCompressPath(file, s.VideoCodec);
var outP = pathHandler.ConstructFilePath(s, cmpPath);

var mediaInfo = await FFmpeg.GetMediaInfo(file);
var streams = mediaInfo.Streams;
var mediaInfo = await FFmpeg.GetMediaInfo(file);
var streams = mediaInfo.Streams.ToList();

var conversion = processHandler.ConfigureConversion(o, streams, outP);
if (conversion is null)
{
logger.Error("Could not configure conversion");
return;
}
var conversion = processHandler.ConfigureConversion(s, streams, outP);
if (conversion is null)
{
logger.Error("Could not configure conversion");
return;
}

try
{
await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx =>
try
{
ctx.Spinner(Spinner.Known.Arrow);
conversion.OnProgress += (_, args) =>
{
var percent = (int)Math.Round(args.Duration.TotalSeconds / args.TotalLength.TotalSeconds * 100);
if (percent is 0)
return;
ctx.Status($"[green]Conversion progress: {percent}%[/]");
ctx.Refresh();
};
await conversion.Start();
});
if (dateTime.HasValue)
processHandler.SetTimeStamps(outP, dateTime.Value);
}
catch (Exception)
{
logger.Error("Conversion failed");
logger.Error("FFmpeg args: {Conversion}", $"ffmpeg {conversion.Build().Trim()}");
return;
}
await AnsiConsole.Status()
.StartAsync("Starting conversion...", async ctx =>
{
ctx.Spinner(Spinner.Known.Arrow);
conversion.OnProgress += (_, args) =>
{
var percent = (int)Math.Round(args.Duration.TotalSeconds / args.TotalLength.TotalSeconds * 100);
if (percent is 0) return;
ctx.Status($"[green]Conversion progress: {percent}%[/]");
ctx.Refresh();
};
await conversion.Start();
});
if (dateTime.HasValue) processHandler.SetTimeStamps(outP, dateTime.Value);
}
catch (Exception)
{
logger.Error("Conversion failed");
logger.Error("FFmpeg args: {Conversion}", $"ffmpeg {conversion.Build().Trim()}");
return;
}

AnsiConsole.MarkupLine($"Converted video saved at: [green]{outP}[/]");
AnsiConsole.MarkupLine($"Converted video saved at: [green]{outP}[/]");

var (originalSize, compressedSize) = ResultsTable(file, outP);
if (compressedSize > originalSize)
{
var videoStream = streams.OfType<IVideoStream>().FirstOrDefault();
if (videoStream is not null)
if (ShouldRetry(s, outP, streams)) continue;
}

break;
}
}

private static (double, double) ResultsTable(string file, string outP)
{
/*
* File sizes are read and compared to calculate the difference and percentage saved.
*
Expand All @@ -71,19 +86,18 @@ await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx =>
* The results are presented in a table with columns "Original", "Compressed", and "Saved".
*
* For example:
* ┌─────────────────────────┬─────────────────────────────┬────────────────────────────┐
* │ Original │ Compressed │ Saved │
* ├─────────────────────────┼─────────────────────────────┼────────────────────────────┤
* │ Original size: 1.10 MiB │ Compressed size: 417.28 KiB │ Saved: 709.82 KiB (62.98%) │
* └─────────────────────────┴─────────────────────────────┴────────────────────────────
* ┌───────────────────────────┬─────────────────────────────┬────────────────────────────┐
* │ Original │ Compressed │ Saved
* ├───────────────────────────┼─────────────────────────────┼────────────────────────────┤
* │ Original size: 710.86 KiB │ Compressed size: 467.02 KiB │ Saved: -243.84 KiB (-34.3%) │
* └───────────────────────────┴─────────────────────────────┴─────────────────────────────┘
*/

var originalSize = (double)new FileInfo(file).Length;
var compressedSize = (double)new FileInfo(outP).Length;

var saved = originalSize - compressedSize;
var savedPercent = saved / originalSize * 100;
var savedPercentRounded = Math.Round(savedPercent, 2)
var savedPercentRounded = (savedPercent > 0 ? "-" : "+") + Math.Round(Math.Abs(savedPercent), 2)
.ToString(CultureInfo.InvariantCulture);

var originalMiB = originalSize / 1024.0 / 1024.0;
Expand All @@ -109,21 +123,134 @@ await AnsiConsole.Status().StartAsync("Starting conversion...", async ctx =>
: $"{savedSymbol}{Math.Round(savedMiB, 2):F2} MiB";
var savedString = $"{savedChange}: [{savedColor}]{savedSizeString} ({savedPercentRounded}%)[/]";


var table = new Table();
table.AddColumn("Original");
table.AddColumn("Compressed");
table.AddColumn(savedChange);
table.AddRow(originalSizeString, compressedSizeString, savedString);

AnsiConsole.Write(table);
return (originalSize, compressedSize);
}

private bool ShouldRetry(Settings s, string outP, List<IStream> enumerable)
{
AnsiConsole.MarkupLine("[yellow]The resulting file is larger than the original.[/]");

var deleteAndRetry = AskForRetry();
if (deleteAndRetry is false) return false;

var resolutionChanged = AskForResolutionChange(enumerable, s);
var crfChanged = AskForCrfChange(s);

if (resolutionChanged || crfChanged)
return true;

return DeleteConvertedVideo(outP);
}

private static bool AskForRetry()
{
var deleteAndRetry = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Do you want to delete the converted video and try again with a better setting?")
.AddChoices(["Yes", "No"]));

return deleteAndRetry is "Yes";
}

private bool AskForResolutionChange(List<IStream> enumerable, Settings s)
{
var resolutionChanged = false;
var changeResolution = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Would you like to change the resolution?")
.AddChoices(["Yes", "No"]));

if (changeResolution is "Yes") resolutionChanged = ChangeResolution(enumerable, s);
return resolutionChanged;
}

private void HandleCancellation(object? sender, ConsoleCancelEventArgs e)
private bool ChangeResolution(List<IStream> enumerable, Settings s)
{
var width = enumerable.OfType<IVideoStream>().First().Width;
var height = enumerable.OfType<IVideoStream>().First().Height;

var maxDimension = Math.Max(width, height);
var currentResolution = $"{maxDimension}p";

var resolutionList = globals.ValidResolutions.ToList();
var currentResolutionIndex = resolutionList.FindIndex(res => res == currentResolution);

if (currentResolutionIndex <= 0) return false;

var lowerResolutions = resolutionList.GetRange(0, currentResolutionIndex);
var chosenResolution = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Please select a lower resolution for the conversion.")
.AddChoices(lowerResolutions));

s.Resolution = chosenResolution;
return true;
}

private static bool AskForCrfChange(Settings s)
{
var crfChanged = false;
var crfAnswer = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Would you like to enter a new crf value?")
.AddChoices(["Yes", "No"]));

if (crfAnswer is "Yes") crfChanged = ChangeCrfValue(s);

return crfChanged;
}

private static bool ChangeCrfValue(Settings s)
{
bool crfChanged;

while (true)
{
var crfAnswer = AnsiConsole.Ask<string>("Please enter new value");
var crfValue = int.Parse(new string(crfAnswer.Where(char.IsDigit).ToArray()));

if (crfValue <= s.Crf)
{
AnsiConsole.WriteLine("Please enter a value higher than the current CRF.");
continue;
}

s.Crf = crfValue;
crfChanged = true;
break;
}

return crfChanged;
}

private static bool DeleteConvertedVideo(string path)
{
var deleteAnswer = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Do you want to delete the converted video?")
.AddChoices(["Yes", "No"]));
if (deleteAnswer is "No")
return false;

File.Delete(path);
AnsiConsole.MarkupLine("[green]Deleted the converted video.[/]");

return false;
}


private static void HandleCancellation(object? sender, ConsoleCancelEventArgs e)
{
if (e.SpecialKey is not ConsoleSpecialKey.ControlC)
return;
AnsiConsole.WriteLine();

AnsiConsole.WriteLine("Canceled");
}
}
35 changes: 23 additions & 12 deletions CommandLineApp/Conversion/ProcessHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ public void SetTimeStamps(string path, DateTime date)
File.SetLastAccessTime(path, date);
}

public IConversion? ConfigureConversion(Settings o, IEnumerable<IStream> streams, string outP)
public IConversion? ConfigureConversion(Settings o, IList<IStream> streams, string outP)
{
var listOfStreams = streams.ToList();
var videoStream = listOfStreams.OfType<IVideoStream>().FirstOrDefault();
var audioStream = listOfStreams.OfType<IAudioStream>().FirstOrDefault();
var videoStream = streams.OfType<IVideoStream>().FirstOrDefault();
var audioStream = streams.OfType<IAudioStream>().FirstOrDefault();

if (videoStream is null && audioStream is null)
{
Expand All @@ -29,11 +28,9 @@ public void SetTimeStamps(string path, DateTime date)

var parameters = $"-crf {o.Crf}";
var conversion = FFmpeg.Conversions.New()
.SetPreset(ConversionPreset.VerySlow)
.AddParameter(parameters)
.SetPixelFormat(PixelFormat.yuv420p)
.SetOutput(outP)
.UseMultiThread(o.MultiThread)
.AddParameter(parameters);
.SetPreset(ConversionPreset.VerySlow);

var videoCodec = codecParser.GetCodec(o.VideoCodec);

Expand All @@ -44,17 +41,31 @@ public void SetTimeStamps(string path, DateTime date)
switch (videoCodec)
{
case VideoCodec.vp9:
configurator.SetVp9Args(conversion);
break;
{
configurator.SetVp9Args(conversion);
outP = Path.ChangeExtension(outP, "webm");
break;
}
case VideoCodec.av1:
configurator.SetCpuForAv1(conversion, videoStream.Framerate);
break;
{
configurator.SetCpuForAv1(conversion, videoStream.Framerate);
outP = Path.ChangeExtension(outP, "webm");
conversion.SetPixelFormat(PixelFormat.yuv420p10le);
break;
}
default:
{
conversion.UseMultiThread(o.MultiThread);
break;
}
}

if (string.IsNullOrEmpty(o.Resolution) is false)
configurator.SetResolution(videoStream, o.Resolution);
}

conversion.SetOutput(outP);

if (audioStream is null)
return conversion;

Expand Down
2 changes: 1 addition & 1 deletion CommandLineApp/Conversion/StreamConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ public void SetCpuForAv1(IConversion conversion, double framerate)

public void SetVp9Args(IConversion conversion)
=> conversion.AddParameter(string.Join(" ", _vp9Args));
}
}
29 changes: 2 additions & 27 deletions CommandLineApp/Downloaders/VideoDownloaderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ private bool ValidTimeRange(RunResult<VideoData> fetch)
{
// check if the time range is beyond the video length
var split = Query.OptionSet.DownloadSections!.Values[0];
var (start, end) = ParseStartAndEndTime(split);
var start = float.Parse(split.Split('-')[0].TrimStart('*'));
var end = float.Parse(split.Split('-')[1]);

var duration = fetch.Data.Duration;

Expand All @@ -132,30 +133,4 @@ private bool ValidTimeRange(RunResult<VideoData> fetch)
_logger.Error(TrimTimeError);
return false;
}

/// <summary>
/// Extracts the start and end times from a given download section string.
/// </summary>
/// <param name="downloadSection">The string containing the download section details.</param>
/// <returns>A tuple containing the start and end times as floats.</returns>
private static (float, float) ParseStartAndEndTime(string downloadSection)
{
/*
* The download section string is split into two parts using '-' as a separator.
* The '*' character, which is used as a regex symbol for "yt-dlp",
* it is not relevant for our parsing and is therefore removed.
* The start time is always on the left side of the split, and the end time is always on the right side.
*/

var span = downloadSection.AsSpan();
var separatorIndex = span.IndexOf('-');

var startSpan = span[..separatorIndex].Trim('*');
var endSpan = span[(separatorIndex + 1)..];

var start = float.Parse(startSpan);
var end = float.Parse(endSpan);

return (start, end);
}
}
2 changes: 1 addition & 1 deletion CommandLineApp/Models/DownloadResult.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
namespace dis.CommandLineApp.Models;

public sealed record DownloadResult(string? OutPath, DateTime? Date);
public sealed record DownloadResult(string? OutPath, DateTime? Date);
Loading

0 comments on commit 40759b0

Please sign in to comment.