Írjunk FFmpeg frontend-et – 5. rész
Az előző cikkben eljutottunk oda, hogy megkreáltuk a chain of responsibility implementációhoz az ősosztályunkat. Ezek után már „csak” az egyes lépések lekódolása maradt hátra.
Az első jó kérdés azonban, hogy mik is legyenek ezek? A lépéseket a programtól elvárt viselkedés alapján határoztam meg. Ez alapján a következő, jól elkülöníthető lépésekre biztos szükségünk lesz:
- Bemeneti fájlok meghatározása – Erre azért lesz szükségünk, mert a *.mp3 elfogadott fájlnév, de ez minden mp3 kiterjesztésű fájlt jelent az adott mappában
- Presethez tartozó paraméterek begyűjtése – Egy preset rendelkezhet ugyebár tetszőleges számú, a felhasználótól begyűjthető argumentummal, ami befolyásolja majd a generálandó parancsokat.
- Futtatandó parancsok legenerálása
- Parancsok futtatása
Ez a lista azonban nem teljes, mivel nem funkcionális lépéseket nem tartalmaz és ezekre is szükségünk lesz. Például a futtatás előtt nem ártana ellenőriznünk, hogy egyáltalán a beállított mappában megtalálható-e az ffmpeg és ffprobe, de ugyanilyen nem funkcionális lépés például a preset ellenőrzése.
Mivel a preset fájlokat a felhasználói is szerkesztheti, ezért mielőtt egyáltalán használatba vesszük, érdemes ellenőrizni, hogy megfelel-e bizonyos követelményeknek, hiszen ennek az elmulasztása a későbbiek során biztos, hogy visszaköszönne nem kezelt kivételek formájában.
Ezek alapján az alábbi sorrend alakult ki az elvégzendő lépésekben:
- FFmpeg telepítés ellenőrzése
- Bemeneti fájlok meghatározása
- A kiválasztott preset validációja
- A választott preset által meghatározott bemenetek begyűjtése
- Futtatandó parancsok generálása
- Konvertálás futtatása
A listában talán meglepő, hogy az FFmpeg ellenőrzés került az első helyre, holott csak a 6. lépésben fogjuk egyáltalán használni. Ennek felhasználói élmény szempontjából van jelentősége. Képzeljük el, hogy a preset, amit használni akarunk, 5 beállítással rendelkezik. Elég hülyén venné ki magát, hogy minden lépést végigcsináltat velünk a program, majd az utolsó lépés előtt közli, hogy amúgy nem tudom a műveletsort végrehajtani, mert hiányoznak fájlok.
FFmpeg telepítés ellenőrzése
Az első lépés nem bonyolult, lényegében megnézzük, hogy a megadott nevű fájlok léteznek-e a megadott mappában.
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.Properties;
namespace FFConvert.Steps;
internal class CheckFFmpegInstallation : BaseStep
{
public override bool TryExecute(State state)
{
if (!state.Configuration.TryGetFFmpeg(out string _))
AddIssue(Resources.ErrorFFmpegNotFound, state.Configuration.FFMpegDir);
if (!state.Configuration.TryGetFFProbe(out string _))
AddIssue(Resources.ErrorFFprobeNotFound, state.Configuration.FFMpegDir);
return AreNoIssues();
}
}
Az első dolog, ami feltűnhet, hogy a hibaüzenetek nincsenek beégetve az egyes lépésekbe, mivel ez sosem egy jó ötlet. Helyette egy Resource fájlban vannak, ami azzal az előnnyel jár, hogy egy helyen tudjuk kezelni az összes üzenetet, valamint későbbiek során akár le is fordíthatjuk ezeket más nyelvekre. Azonban nem ez az egyetlen „trükk” ebben a lépésben.
A „trükk” az, hogy az ellenőrzés és lekérdezés két DomainServices rétegbeli metódusba van elrejtve, mivel a tényleges FFmpeg használat előtt is ellenőrizni kell a fájlok meglétét. Ennek az oka az, hogy a tényleges ellenőrzés és a használat között van egy időrés, ami alatt akár ki is törölheti a fájlokat a felhasználó. Feltételezzük azonban, hogy ilyenre nem vetemedne, de elképzelhető, hogy egy túlbuzgó vírusirtó teszi meg ezt helyette.
Lényeg az, hogy mivel két lépésben is kelleni fog ez, ezért legyen egy közös helyük:
using System.Runtime.InteropServices;
using FFConvert.Domain;
namespace FFConvert.DomainServices;
internal static class ProgramConfigurationExtensions
{
private static bool IsWindows() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool TryGetFFmpeg(this ProgramConfiguration configuration, out string ffmpegPath)
{
string ffmpegName = IsWindows() ? "ffmpeg.exe" : "ffmpeg";
ffmpegPath = Path.Combine(configuration.FFMpegDir, ffmpegName);
return File.Exists(ffmpegPath);
}
public static bool TryGetFFProbe(this ProgramConfiguration configuration, out string ffprobePath)
{
string ffprobeName = IsWindows() ? "ffprobe.exe" : "ffprobe";
ffprobePath = Path.Combine(configuration.FFMpegDir, ffprobeName);
return File.Exists(ffprobePath);
}
}
A név meghatározásánál fontos, hogy platformfüggő módon történjen. Windows esetén a futtatandó fájloknak kötelező, hogy .exe kiterjesztéssel rendelkezzenek. Ez azonban nem kötelező Unix alapú rendszerek esetén. Ugyan nem volt követelmény, hogy a program működjön Linux alatt is, de erre mégis szükségünk van, mégpedig azért, mert a CI/CD megoldásunk Linux alapú docker image-ben fut.
Bemeneti fájlok meghatározása
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.Properties;
namespace FFConvert.Steps;
internal class CollectInputFiles : BaseStep
{
public override bool CanSkip(State state)
{
return !state.Arguments.InputFileContainsWildCard()
&& state.Arguments.IsSwitchPresent(Constants.SwitchInputList);
}
public override bool TryExecute(State state)
{
if (state.Arguments.InputFileContainsWildCard())
{
string directory = FileSystem.GetWorkingDirectoryFromInputFile(state.Arguments.FileName);
var files = FileSystem.GetFilesMatchingWildCard(directory, Path.GetFileName(state.Arguments.FileName));
state.AddFiles(files);
if (!state.HasInputFiles())
{
AddIssue(Resources.ErrorFilesNotFound);
}
}
else
{
var singleFile = Path.GetFullPath(state.Arguments.FileName);
if (File.Exists(singleFile))
{
state.InputFiles.Add(singleFile);
}
else
{
AddIssue(Resources.ErrorFileNotExists);
}
}
return AreNoIssues();
}
}
A bemeneti fájlok meghatározásánál a fő rendező elv, hogy a több fájlról beszélünk vagy csupán egyről. Ez a program argumentumokhoz tartozó InputFileContainsWildCard() extension metódussal könnyen meghatározható.
Az egyes fájlok és mappa meghatározásának logikája a FileSystem osztályban kapott helyet sok egyéb mással együtt:
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace FFConvert.DomainServices;
internal static class FileSystem
{
private static bool IsWindows() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
private static bool ContainsPathSeperator(string input)
{
if (IsWindows())
return input.Contains('\\');
else
return input.Contains('/');
}
private static bool HasRootDir(string input)
{
if (IsWindows())
return input.Contains(":\\");
else
return input.Contains('/');
}
private static string WildcardToRegex(string pattern)
{
return "^" + Regex.Escape(pattern).
Replace("\\*", ".*").
Replace("\\?", ".") + "$";
}
public static string GetWorkingDirectoryFromInputFile(string inputfile)
{
if (!ContainsPathSeperator(inputfile))
{
//it's in current dir
return Environment.CurrentDirectory;
}
else if (HasRootDir(inputfile))
{
//full path
return Path.GetDirectoryName(inputfile) ?? string.Empty;
}
else
{
//relatvie paths
string relative = Path.GetFullPath(inputfile);
return Path.GetDirectoryName(relative) ?? string.Empty;
}
}
public static IEnumerable<string> GetFilesMatchingWildCard(string directory, string filter)
{
Regex r = new(WildcardToRegex(filter), RegexOptions.Compiled);
string[] filters = Directory.GetFiles(directory);
foreach (string file in filters)
{
string fileName = Path.GetFileName(file);
if (r.IsMatch(fileName))
{
yield return file;
}
}
}
public static string CreateOutputFile(string inputfile, string targetExtension, string outputDirectory)
{
string fileName = Path.GetFileName(inputfile);
string targetName = Path.ChangeExtension(fileName, targetExtension);
return Path.Combine(outputDirectory, targetName);
}
}
Ez a lépés a State osztályhoz tartozó extension metódusokat is használ. Ezek definíciója a következő:
using FFConvert.Domain;
namespace FFConvert.DomainServices
{
internal static class StateExtensions
{
public static void AddFiles(this State state, IEnumerable<string> items)
{
if (state.InputFiles is List<string> list)
{
list.AddRange(items);
}
}
public static bool HasInputFiles(this State state)
{
return state.InputFiles.Count > 0;
}
}
}
Preset validáció
A preset validáció az első lépésünk, ahol külső függőségek jelennek meg, méghozzá az IimplementationsOf<T> típusok formájában. Ebből kettőt is vár ez a lépés. Egy a konvertereket, egy pedig a validátorokat tartalmazza.
Ez a lépés valójában két dolgot csinál. Megkeresi a presetek közül azt, ami a paraméterként megadott névben van és a state változóban beállítja azt jelenleg használtnak. Ezen felül ellenőrzi a beállított presetet. Ezt a korábban bemutatott extension metódusok segítségével végzi el.
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.Interfaces;
using FFConvert.Properties;
namespace FFConvert.Steps
{
internal class PresetValidation : BaseStep
{
private readonly IImplementationsOf<IConverter> _converters;
private readonly IImplementationsOf<IValidator> _validators;
public PresetValidation(IImplementationsOf<IConverter> converters,
IImplementationsOf<IValidator> validators)
{
_converters = converters;
_validators = validators;
}
public override bool TryExecute(State state)
{
var presetToUse = state.Presets.FirstOrDefault(x => x.ActivatorName == state.Arguments.PresetName);
if (presetToUse == null)
{
AddIssue(Resources.ErrorPresetNotFound, state.Arguments.PresetName);
}
else
{
state.CurrentPreset = presetToUse;
}
if (!state.CurrentPreset.IsValid())
{
AddIssue(Resources.ErrorInvalidPreset);
}
else
{
CheckConvertersNames(state.CurrentPreset);
CheckValidatorNames(state.CurrentPreset);
}
return AreNoIssues();
}
private void CheckValidatorNames(Preset currentPreset)
{
var validatorNames = currentPreset.ParametersToAsk
.Where(x => !string.IsNullOrEmpty(x.ValidatorName))
.Select(x => x.ValidatorName!);
foreach (var validatorName in validatorNames)
{
if (!_validators.Contains(validatorName))
AddIssue(Resources.ErrorUnknownValidator, validatorName);
}
}
private void CheckConvertersNames(Preset currentPreset)
{
var converterNames = currentPreset.ParametersToAsk
.Where(x => !string.IsNullOrEmpty(x.ConverterName))
.Select(x => x.ConverterName!);
foreach (var converterName in converterNames)
{
if (!_converters.Contains(converterName))
AddIssue(Resources.ErrorUnknownConverter, converterName);
}
}
}
}
Az ellenőrzés nem csak ennyiben merül ki. A kiválasztott preset esetén a paramétereknél ellenőrzésre kerül, hogy a megadott nevű konverter és validáló létezik-e a szoftverben. Ez az ellenőrzés azért kell, hogy a következő lépésben, mikor bekérjük az adatokat, ezzel már ne kelljen foglalkozni.
Ha szó szerint veszem a single responsibility elvet, akkor valószínűleg ennek a lépésnek az implementációja sérti az elvet, mivel saját elmondásom szerint is két dolgot csinál. Valószínűleg a jobb karbantarthatóság és egyszerűbb tesztelés miatt érdemes lenne ketté vágni, még az elején. Ezt fejlesztés közben azért nem tettem meg, mert egy nagyjából 5 sornyi logikának nem akartam külön osztályt létrehozni.
A választott preset által meghatározott bemenetek begyűjtése
Ez a lépés felelős a kiválasztott preset paramétereinek a feltöltéséért, ami azt jelenti, hogy az információkat a felhasználótól kell bekérnünk. A bemenet forrása a konzol lesz. Azonban a Console osztály direktben használata helyett ezt egy absztrakción keresztül tesszük meg, mégpedig azért, hogy ez a lépés is jól tesztelhető legyen.
Ugyan a Console osztály rendelkezik lehetőséggel arra, hogy a bemenetet és kimenetét átirányítsuk, de ez nem lesz elegendő a megfelelő teszteléshez. Éppen ezért a konzol absztrakciójáért az Iconsole interfész felel.
namespace FFConvert.Interfaces;
internal interface IConsole
{
int WindowHeight { get; }
int WindowWidth { get; }
void SetCursorPosition(int left, int top);
string ReadLine();
void WriteLine(string line);
void Write(string line);
void Error(params string[] errors);
event EventHandler? CancelEvent;
void Clear();
}
Ez az interfész nem hoz sok újdonságot, csupán a rendszer konzol absztrakciójáért felelős. Az Error metódusával hibaüzeneteket tudunk kiírni. A WindowHeight, WindowWidth tulajdonságok és a SetCursorPosition() metódus pedig pozicionálásért felelősek. Ezt majd egy másik komponens használja. A CancelEvent pedig akkor lesz ellőve, ha a felhasználó a CTRL+C kombinációt adja be a program futása közben. Ez ugye megszakítja az éppen aktuálisan futó folyamatot, de nekünk két folyamatunk lesz: egy frontendünk és egy háttérben futó FFmpeg példány. Ha éppen fut az FFmpeg és a felhasználó a folyamat megszakítását kéri, akkor erre nekünk reagálnunk kell, méghozzá úgy, hogy a háttérben futó FFmpeg futást is megszakítjuk, de erről majd részletesebben a futtatásnál lesz szó.
A lépés forráskódja:
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.Interfaces;
using FFConvert.Properties;
namespace FFConvert.Steps;
internal class GetPresetArguments : BaseStep
{
private readonly IImplementationsOf<IConverter> _converters;
private readonly IImplementationsOf<IValidator> _validators;
private readonly IConsole _console;
public GetPresetArguments(IImplementationsOf<IConverter> converters,
IImplementationsOf<IValidator> validators,
IConsole console)
{
_converters = converters;
_validators = validators;
_console = console;
}
public override bool TryExecute(State state)
{
if (!state.CurrentPreset.ParametersToAsk.Any())
return true;
foreach (var parameter in state.CurrentPreset.ParametersToAsk)
{
string input;
if (!string.IsNullOrEmpty(parameter.ValidatorName))
{
input = ReadPresetValueWithValidator(parameter);
}
else
{
input = ReadPresetValue(parameter);
}
parameter.Value = ConvertValue(input, parameter);
}
return AreNoIssues();
}
private string ConvertValue(string input, PresetParameter parameter)
{
if (string.IsNullOrEmpty(parameter.ConverterName))
return input;
IConverter converter = _converters.Get(parameter.ConverterName);
return converter.Convert(input);
}
private string ReadPresetValueWithValidator(PresetParameter parameter)
{
IDictionary<string, string> paramDictionary = new Dictionary<string, string>();
if (parameter.ValidatorParameters != null
&& !parameter.TryGetValidatorParamDictionary(out paramDictionary))
{
AddIssue(Resources.ErrorPresetParamTokens);
return string.Empty;
}
bool valid = false;
string input;
do
{
_console.Write(parameter.ParameterDescription);
_console.Write(" : ");
input = _console.ReadLine();
if (parameter.IsOptional && string.IsNullOrEmpty(input))
{
break;
}
else if (!parameter.IsOptional && string.IsNullOrEmpty(input))
{
continue;
}
var validator = _validators.Get(parameter.ValidatorName!);
var (status, errorMessage) = validator.Validate(input, paramDictionary);
if (!string.IsNullOrEmpty(errorMessage))
{
_console.Error(errorMessage);
}
valid = status;
}
while (!valid && !parameter.IsOptional);
return input;
}
private string ReadPresetValue(PresetParameter parameter)
{
string input;
do
{
_console.Write(parameter.ParameterDescription);
_console.Write(" : ");
input = _console.ReadLine();
}
while (string.IsNullOrEmpty(input) && !parameter.IsOptional);
return input;
}
}
A paraméterek beolvasása esetén két útvonal áll előttünk: ha a paraméter nem rendelkezik validálóval, akkor szimplán addig ismételjük a beolvasást, amíg a felhasználó üres szöveget adott meg. Abban az esetben, ha ez egy opcionális paraméter, akkor elfogadjuk az üres bemenetet is. Ezt valósítja meg a ReadPresetValue metódus.
A másik útvonal az, amikor a beolvasandó paraméter rendelkezik validálóval. Ebben az esetben ugyanazt csináljuk, mint a nem validált paramétereknél, kiegészítve azzal, hogy ha a validáció hibás, akkor is újra bekérjük az értéket. Ezért felelős a ReadPresetValueWithValidator metódus.
A beolvasás után a ConvertValue metódussal futtatjuk a paraméterhez rendelt konvertálót, ha ez beállításra került.
Parancssor generálás
A parancssor generálás viszonylag összetett, de lényegében arról van szó, hogy a preset parancssorába behelyettesítjük a felhasználó által megadott paramétereket. A paraméterek kapcsán azt a megközelítést alkalmaztam, hogy ezeknek a % szimbólummal kell kezdődniük és végződniük.
A rendszer három darab kitüntetett paramétert különböztet meg. Az első ilyen az %input% nevű. Ez a bemeneti fájl útvonalát határozza meg. A második a %output%, ami a kimeneti fájlt határozza meg. A harmadik pedig a %sourceext%, ami csak a kimeneti fájl kiterjesztéseként szerepelhet.
using System.Text;
using System.Text.RegularExpressions;
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.Properties;
namespace FFConvert.Steps;
internal class CreateCommandLines : BaseStep
{
private const string InputKey = "%input%";
private const string OutputKey = "%output%";
private const string SourceExtKey = "%sourceext%";
private readonly Regex _paramRegex = new(@"\%(\w+)\%", RegexOptions.Compiled);
private static Dictionary<ParameterKey, string> CreateParamDictionary(State currentState)
{
Dictionary<ParameterKey, string> parameters = new()
{
{ new ParameterKey(InputKey, false), "" },
{ new ParameterKey(OutputKey, false), "" },
};
foreach (var parameter in currentState.CurrentPreset.ParametersToAsk)
{
parameters.Add(new ParameterKey(parameter), parameter.Value);
}
return parameters;
}
public override bool TryExecute(State state)
{
Dictionary<ParameterKey, string> parameters = CreateParamDictionary(state);
if (!CheckIfParameterCountMatches(state.CurrentPreset, parameters))
{
AddIssue(Resources.ErrorParamCountMissmatch);
return false;
}
foreach (string inputfile in state.InputFiles)
{
parameters[new ParameterKey(InputKey, false)] = inputfile;
string extension = state.CurrentPreset.TargetExtension;
if (extension == SourceExtKey)
{
extension = Path.GetExtension(inputfile);
}
string outFile = FileSystem.CreateOutputFile(inputfile, extension, state.Arguments.OutputDirectory);
parameters[new ParameterKey(OutputKey, false)] = outFile;
if (state.CurrentPreset.TargetExtension == SourceExtKey)
{
outFile = Path.ChangeExtension(outFile, extension);
}
state.CreatedCommandLines.Add(new FFMpegCommand
{
CommandLine = FillParameters(state.CurrentPreset, parameters),
OutputFile = outFile,
InputFile = inputfile,
});
}
return AreNoIssues();
}
private static string EscapePathIfNeeded(string path)
{
return !path.Contains(' ') ? path : $"\"{path}\"";
}
private static string FillParameters(Preset preset, Dictionary<ParameterKey, string> parameters)
{
StringBuilder sb = new(preset.CommandLine);
foreach (var parameter in parameters)
{
if (!parameter.Key.IsOptional)
{
sb.Replace(parameter.Key.Name, EscapePathIfNeeded(parameter.Value));
}
else if (!string.IsNullOrEmpty(parameter.Value))
{
var optionalContent = preset.ParametersToAsk
.Where(p => p.ParameterName == parameter.Key.Name)
.Select(p => p.OptionalContent)
.FirstOrDefault();
if (!string.IsNullOrEmpty(optionalContent))
{
string value = optionalContent.Replace(parameter.Key.Name, parameter.Value);
sb.Replace(parameter.Key.Name, value);
}
}
else
{
sb.Replace(parameter.Key.Name, "");
}
//double space cleanup
sb.Replace(" ", "");
}
return sb.ToString().Trim();
}
private bool CheckIfParameterCountMatches(Preset currentPreset, Dictionary<ParameterKey, string> parameters)
{
MatchCollection matches = _paramRegex.Matches(currentPreset.CommandLine);
int count = 0;
foreach (Match match in matches)
{
if (parameters.Keys.Any(x => x.Name == match.Value))
++count;
else
--count;
}
return count == parameters.Count;
}
}
A CreateParamDictionary metódus első körben a speciális paramétereinket (%input% és %output%), illetve a preset paramétereinek nevét és értékét összerendeli egy asszociatív tömbbe.
Itt feltűnhet, hogy a Dictionary kulcs típusa egy ParameterKey osztály, ami a paraméter nevén kívül információt tárol arról, hogy opcionális volt-e vagy sem.
Utóbbira azért van szükségünk, mivel az opcionális paraméterek esetén, ha üresen van hagyva, akkor nem kell megjelennie a generált parancssorban az értéknek.
Ilyenre lehet példa, ha a hang mintavételezési frekvenciáját szeretnénk opcionálisan módosítani. Ezt a -ar [szám] FFmpeg argumentummal tudjuk megtenni, ahol a szám a frekvenciát jelöli Hz-ben. Ha ezt nem adjuk meg, akkor az eredeti mintavételezési frekvencia lesz használva.
Felmerülhet ezen a ponton a kérdés, hogy ha két tulajdonságból álló osztályt készítünk, akkor nem érné-e meg egy value tuple-t alkalmazni osztály helyett?
A válasz jelen esetben az, hogy nem. Value tuple esetén mind a két tulajdonság figyelembevételével történne az egyenlőség vizsgálata, ami nem lenne jó, mivel kétszer is szerepelhetne a tömbben ugyanaz a paraméternév eltérő opcionalitással. Egy megoldás az lehetne, hogy az értéket tároljuk value tuple-ben az opcionalitást leíró bool értékkel, de ebben nem sok kihívás van, illetve nem sokat tanulnánk belőle. Éppen ezért azt az irányt választottam, hogy készítek egy saját osztályt, a ParameterKey-t, aminek az Equals és GetHashCode metódusa úgy van felülírva, hogy csak a nevet veszi figyelembe.
Az osztály létrehozása nem igényelt különösebben logikát. A megfelelő adattagok és konstruktorok definiálása után az osztály nevén a CTRL+. előhozható menüből a Generate Equals and GetHashCode opcióval legeneráltattam a nekem megfelelő implementációt. Így a végleges implementáció így nézett ki:
namespace FFConvert.Domain;
internal sealed class ParameterKey : IEquatable<ParameterKey?>
{
public string Name { get; init; }
public bool IsOptional { get; init; }
public ParameterKey(string name, bool isOptional)
{
Name = name;
IsOptional = isOptional;
}
public ParameterKey(PresetParameter parameter)
{
Name = parameter.ParameterName;
IsOptional = parameter.IsOptional;
}
public override bool Equals(object? obj)
{
return Equals(obj as ParameterKey);
}
public bool Equals(ParameterKey? other)
{
return other != null &&
Name == other.Name;
}
public override int GetHashCode()
{
return HashCode.Combine(Name);
}
public static bool operator ==(ParameterKey? left, ParameterKey? right)
{
return EqualityComparer<ParameterKey>.Default.Equals(left, right);
}
public static bool operator !=(ParameterKey? left, ParameterKey? right)
{
return !(left == right);
}
}
Az egyes paraméterek számának ellenőrzését reguláris kifejezések használatával állapítom meg. Ezzel azt ellenőrzöm, hogy a CommandLine tulajdonságban megadott paraméterek és a behelyettesíthető paraméterek száma megegyezik-e. Ha nem, akkor azt azt jelenti, hogy valami félrement és a generálandó parancssor biztos, hogy nem lesz futtatható.
Ha minden rendben volt, akkor a paraméterek neveit az általuk tárolt értékre cseréljük, illetve külön eltároljuk a bemeneti fájl nevét és a kimeneti nevet is.
Kódolás
Elérkeztünk az utolsó és legfontosabb lépéshez, a kódolás futtatásához. A kódolás minden fájl esetén két program futtatását fogja jelenteni. Először az ffprobe segítségével lekérdezzük a fájl hosszát, majd ezután az ffmpeg-et futtatjuk. Az ffmpeg futtatása közben a kapott információkból és a fájl hosszából meg tudjuk állapítani, hogy hány %-nál jár a kódolás, és hogy mennyi idő van még hátra az adott fájl feldolgozásából.
A programok futtatásához készítettem egy külön komponenst, ami az FFMpegRunner nevet kapta. Ezt szintén leválasztottam egy interfésszel, hogy a lépés, amiben használva lesz, könnyen tesztelhető legyen. Az interfész definíciója:
using FFConvert.Domain;
namespace FFConvert.Interfaces;
internal interface IFFMpegRunner
{
event EventHandler<FFMpegOutput>? ProgressReporter;
Task<FFProbeResult> Probe(FFMpegCommand command, CancellationToken cancellationToken);
Task Run(FFMpegCommand command, CancellationToken cancellationToken);
}
Az interfész két Task-ot definiál. A Probe futtatja az ffprobe programot, míg a Run magát az ffmpeg-et. A Probe visszatérési értéke egy FFProbeResult osztály. Ez egy JSON dokumentumot ír le. Szerencsére az ffprobe utasítható, hogy az adatokat JSON formátumban írja ki. Az osztály definícióját lényegében úgy kaptam meg, hogy futtattam egy média fájlra a programot, majd a kapott JSON kimenetből Visual Studio segítségével (Edit menü → Paste Special → Paste JSON as classes) generáltattam ki. Mivel ez egy igen nagy osztály több altípussal együtt, és mi csak az időt használjuk belőle, ezért ennek a forráskódját nem szerepeltetem itt.
Ezzel a megoldással azonban az volt a probléma, hogy nem minden esetben működött. Ezt úgy oldottam meg, hogy az ffmpeg forráskód repó tartalmaz egy XSD fájlt, ami leírja, hogy milyen attribútumok és struktúrák jöhetnek vissza. Ebből az XSD dokumentumból a .NET Framework XSD.exe eszközével készítettem osztályokat és egy külön szerelvényt. Az így kiolvasott kimenet mindig működik 🙂
A ProgressReporter eseményben használt FFMpegOutput osztály viszont már érdekesebb. Ez az osztály saját gyártmány, az FFMpeg futása közbeni generált kimenet fontosabb adatait tartalmazza. A definíciója:
namespace FFConvert.Domain;
internal sealed class FFMpegOutput
{
public float Bitrate { get; set; }
public long FileSize { get; set; }
public TimeSpan Time { get; set; }
public float Speed { get; set; }
}
Az FFmpeg futás közben nem tud sajna JSON kimenetet produkálni. A legfeldolgozhatóbb kimenet formátum a -progress - kapcsolók megadásával érhető el. Ha ez meg van adva, akkor kulcs=érték formátumú sorokban periodikusan visszaad információkat. Egy adatcsomag mindig a progress kulccsal végződik. Nézzünk egy részletet példaként:
bitrate=170.42kbits/s
total_size=709965
out_time_us=35456479
out_time_ms=35456479
out_time=00:00:35.456479
dup_frames=0
drop_frames=0
speed=54.8x
progress=continue
Feltűnhet, hogy az out_time_us és az out_time_ms értéke azonos. Ez valószínűleg egy hiba, mivel az us végződésű helyes mikroszekundumban értelmezve. A total_size pedig a fájl méretét adja meg byte-ban kifejezve.
Ez viszonylag ez egyszerű adatszerkezet, ezért ha a kimenet sorai megvannak, akkor könnyen fel tudjuk dolgozni. Erre a célra készítettem egy külön komponenst, ami az FFMpegOutputParser nevet kapta:
using System.Globalization;
using FFConvert.Domain;
namespace FFConvert.DomainServices;
internal static class FFMpegOutputParser
{
private const string Bitrate = "bitrate=";
private const string Size = "total_size=";
private const string Time = "out_time_us=";
private const string Speed = "speed=";
private static bool TryGet(string line, string key, out string value)
{
if (line.StartsWith(key))
{
value = line[key.Length..];
return true;
}
value = string.Empty;
return false;
}
public static FFMpegOutput Parse(IEnumerable<string> packet)
{
FFMpegOutput result = new();
foreach (string? line in packet)
{
if (TryGet(line, Bitrate, out string rate) && rate != "N/A")
result.Bitrate = float.Parse(rate.Replace("kbits/s", ""), CultureInfo.InvariantCulture);
if (TryGet(line, Size, out string size))
result.FileSize = long.Parse(size, CultureInfo.InvariantCulture);
if (TryGet(line, Time, out string time))
result.Time = TimeSpan.FromMilliseconds(double.Parse(time, CultureInfo.InvariantCulture) / 1000);
if (TryGet(line, Speed, out string speed) && speed != "N/A")
result.Speed = float.Parse(speed.Replace("x", ""), CultureInfo.InvariantCulture);
}
return result;
}
}
A futtató forráskódja:
using System.Diagnostics;
using FFConvert.Domain;
using FFConvert.DomainServices;
using FFConvert.FFProbe;
using FFConvert.Interfaces;
namespace FFConvert.Infrastructure;
internal class FFMpegRunner : IFFMpegRunner
{
public event EventHandler<FFMpegOutput>? ProgressReporter;
private readonly List<string> _currentCapture;
private readonly string _ffmpegExe;
private readonly string _ffprobeExe;
private readonly bool _ffmpegInstallOk;
public FFMpegRunner(ProgramConfiguration configuration)
{
_currentCapture = new List<string>(15);
#pragma warning disable RCS1233
// Intentionally not &&, because both sides needs to be evaluated
// To not complain abut possible null for _ffprobeExe
_ffmpegInstallOk = configuration.TryGetFFmpeg(out _ffmpegExe)
& configuration.TryGetFFProbe(out _ffprobeExe);
#pragma warning restore RCS1233
}
public async Task<FfprobeType> Probe(FFMpegCommand command, CancellationToken cancellationToken)
{
if (!_ffmpegInstallOk)
throw new InvalidOperationException("Invalid config");
using Process process = new();
process.StartInfo = new ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
FileName = _ffprobeExe,
Arguments = $"-v quiet -show_format -print_format xml=fully_qualified=1 \"{command.InputFile}\""
};
process.Start();
string xml = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync(cancellationToken);
return FFProbeParser.Parse(xml);
}
public async Task Run(FFMpegCommand command, CancellationToken cancellationToken)
{
if (!_ffmpegInstallOk)
throw new InvalidOperationException("Invalid config");
using Process process = new();
cancellationToken.Register(() =>
{
if (!process.HasExited) process.Kill();
});
process.StartInfo = new ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
FileName = _ffmpegExe,
Arguments = $"-progress - -v fatal -hide_banner {command.CommandLine}"
};
process.EnableRaisingEvents = true;
process.OutputDataReceived += OnOutputDataRecieved;
_currentCapture.Clear();
process.Start();
process.BeginOutputReadLine();
await process.WaitForExitAsync(cancellationToken);
}
private void OnOutputDataRecieved(object sender, DataReceivedEventArgs e)
{
if (!string.IsNullOrEmpty(e.Data) && !e.Data.StartsWith("progress="))
{
_currentCapture.Add(e.Data);
}
else
{
FFMpegOutput output = FFMpegOutputParser.Parse(_currentCapture);
ProgressReporter?.Invoke(this, output);
_currentCapture.Clear();
}
}
}
A kódolás futtatásánál a -hide_banner opció elrejti a program indulásakor megjelenített információs szöveget, a -v fatal opció pedig a hibaüzenetek megjelenítését korlátozza a kritikus hibákra.
Mindkét program futtatása esetén aszinkron módon várakozunk a program futásának befejezésére, amit meg lehet szakítani egy CancellationToken segítségével.
A OnOutputDataRecieved eseménykezelő metódus akkor fog lefutni, amikor kódolás közben egy sort a kimenetre írna az FFmpeg. A metódus gyűjti a sorokat a _currentCapture listába, majd amikor egy progress= kezdetű sor érkezik, akkor a korábban bemutatott FFMpegOutputParser segítségével feldolgozza a kimenetet. Ezt a feldolgozott kimenetet pedig továbbítjuk a ProgressReporter eseményen keresztül a feliratkozóknak.
Folytatása következik…