├── icon.ico ├── screenshots ├── screenshot.0.8.0.png ├── screenshot.0.9.0.png └── screenshot.0.9.4.png ├── Properties ├── launchSettings.json ├── Settings.settings ├── Resources.Designer.cs ├── Settings.Designer.cs └── Resources.resx ├── App.xaml.cs ├── AssemblyInfo.cs ├── Icons ├── Stop_16x.xaml ├── Run_16x.xaml ├── DeleteAllRows_16x.xaml ├── Save_16x.xaml ├── Refresh_16x.xaml ├── SaveTable_16x.xaml ├── Exit_16x.xaml ├── OpenFolder_16x.xaml ├── CopyToClipboard_16x.xaml ├── NewFileCollection_16x.xaml ├── SaveAs_16x.xaml ├── DeleteTableRow_16x.xaml ├── OpenFile_16x.xaml ├── CleanData_16x.xaml ├── CopyTheme_16x.xaml └── ImportTheme_16x.xaml ├── BindingProxy.cs ├── UserControls ├── MediaInfoBox.xaml.cs └── MediaInfoBox.xaml ├── Json.cs ├── LICENSE.txt ├── FFBitrateViewer.sln ├── App.config ├── ProgramInfo.cs ├── Utilities.cs ├── RelayCommand.cs ├── Global.cs ├── DataDictionary.cs ├── Log.cs ├── Bindable.cs ├── Windows ├── AboutWindow.xaml.cs └── AboutWindow.xaml ├── App.xaml ├── OverallProgress.cs ├── CommandLine.cs ├── .gitattributes ├── FFBitrateViewer.csproj ├── README.md ├── ProgramConfig.cs ├── Logger.cs ├── FileItem.cs ├── .gitignore ├── Execute.cs ├── ProgramOptions.cs ├── Helpers.cs ├── FFProbe.cs ├── MyPlotModel.cs ├── MainWindow.xaml.cs └── Frames.cs /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifonik/FFBitrateViewer/HEAD/icon.ico -------------------------------------------------------------------------------- /screenshots/screenshot.0.8.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifonik/FFBitrateViewer/HEAD/screenshots/screenshot.0.8.0.png -------------------------------------------------------------------------------- /screenshots/screenshot.0.9.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifonik/FFBitrateViewer/HEAD/screenshots/screenshot.0.9.0.png -------------------------------------------------------------------------------- /screenshots/screenshot.0.9.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifonik/FFBitrateViewer/HEAD/screenshots/screenshot.0.9.4.png -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "FFBitrateViewer": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--log-level=debug" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Configuration; 4 | using System.Data; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using System.Windows; 8 | 9 | namespace FFBitrateViewer 10 | { 11 | /// 12 | /// Interaction logic for App.xaml 13 | /// 14 | public partial class App : Application 15 | { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | 3 | [assembly: ThemeInfo( 4 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located 5 | //(used if a resource is not found in the page, 6 | // or application resource dictionaries) 7 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located 8 | //(used if a resource is not found in the page, 9 | // app, or any theme specific resource dictionaries) 10 | )] 11 | -------------------------------------------------------------------------------- /Icons/Stop_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Properties/Settings.settings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | False 10 | 11 | 12 | True 13 | 14 | 15 | frame 16 | 17 | 18 | -------------------------------------------------------------------------------- /Icons/Run_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /BindingProxy.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | 3 | 4 | namespace FFBitrateViewer 5 | { 6 | // Cannot bind to VM from within GridView so use this technique 7 | // https://stackoverflow.com/questions/15494226/cannot-find-source-for-binding-with-reference-relativesource-findancestor 8 | public class BindingProxy : Freezable 9 | { 10 | protected override Freezable CreateInstanceCore() 11 | { 12 | return new BindingProxy(); 13 | } 14 | 15 | public object Data 16 | { 17 | get { return (object)GetValue(DataProperty); } 18 | set { SetValue(DataProperty, value); } 19 | } 20 | 21 | // Using a DependencyProperty as the backing store for Data. 22 | // This enables animation, styling, binding, etc... 23 | public static readonly DependencyProperty DataProperty = DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null)); 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Icons/DeleteAllRows_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /UserControls/MediaInfoBox.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | using System.Windows.Controls; 3 | 4 | 5 | namespace FFBitrateViewer.UserControls 6 | { 7 | public partial class MediaInfoBox : UserControl 8 | { 9 | public MediaInfo MediaInfo 10 | { 11 | get { return (MediaInfo)GetValue(InfoProperty); } 12 | set { SetValue(InfoProperty, value); } 13 | } 14 | public Frames Frames 15 | { 16 | get { return (Frames)GetValue(FramesProperty); } 17 | set { SetValue(FramesProperty, value); } 18 | } 19 | public static readonly DependencyProperty InfoProperty = DependencyProperty.Register("MediaInfo", typeof(MediaInfo), typeof(MediaInfoBox)); 20 | public static readonly DependencyProperty FramesProperty = DependencyProperty.Register("Frames", typeof(Frames), typeof(MediaInfoBox)); 21 | 22 | public MediaInfoBox() 23 | { 24 | InitializeComponent(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Json.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using Newtonsoft.Json; 3 | 4 | 5 | namespace FFBitrateViewer 6 | { 7 | public class Json 8 | { 9 | public static T? FileRead(string fs) 10 | { 11 | // todo@ read from stream (as in IsValid) 12 | string? text = File.ReadAllText(fs); 13 | return JsonConvert.DeserializeObject(text); // todo@ add options 14 | } 15 | 16 | 17 | public static void FileWrite(string fs, T obj) 18 | { 19 | File.WriteAllText(fs, JsonConvert.SerializeObject(obj, Formatting.Indented)); 20 | } 21 | 22 | 23 | public static bool IsValid(string fs) 24 | { 25 | try 26 | { 27 | using StreamReader file = File.OpenText(fs); 28 | var serializer = new JsonSerializer(); 29 | var data = (T?)serializer.Deserialize(file, typeof(T?)); 30 | return data != null; 31 | } catch 32 | { 33 | // just catch any error 34 | } 35 | return false; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Fifonik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Icons/Save_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /FFBitrateViewer.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.4.33110.190 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FFBitrateViewer", "FFBitrateViewer.csproj", "{8D618B0D-D9F3-4F33-966A-1C648020CD3A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {8D618B0D-D9F3-4F33-966A-1C648020CD3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {8D618B0D-D9F3-4F33-966A-1C648020CD3A}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {8D618B0D-D9F3-4F33-966A-1C648020CD3A}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {8D618B0D-D9F3-4F33-966A-1C648020CD3A}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {991EF20F-1DA0-4C87-9DEE-198031A244A8} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | False 15 | 16 | 17 | True 18 | 19 | 20 | frame 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ProgramInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Globalization; 3 | using System.Reflection; 4 | 5 | 6 | 7 | namespace FFBitrateViewer 8 | { 9 | public static class ProgramInfo 10 | { 11 | public static string? Name { get; private set; } 12 | public static string? Version { get; private set; } 13 | public static FileVersionInfo? VersionInfo { get; private set; } 14 | 15 | 16 | static ProgramInfo() 17 | { 18 | var assembly = Assembly.GetExecutingAssembly(); 19 | Name = assembly.GetName().Name; 20 | 21 | var VersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); 22 | Version = string.Format(CultureInfo.InvariantCulture, @"{0}.{1}", VersionInfo.ProductMajorPart, VersionInfo.ProductMinorPart); 23 | if (VersionInfo.ProductBuildPart > 0) Version += "." + VersionInfo.ProductBuildPart.ToString(CultureInfo.InvariantCulture); 24 | if (VersionInfo.ProductPrivatePart > 0) 25 | { 26 | Version += "b"; 27 | if (VersionInfo.ProductPrivatePart > 1) Version += VersionInfo.ProductPrivatePart.ToString(CultureInfo.InvariantCulture); 28 | } 29 | } 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Icons/Refresh_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Utilities.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | 3 | namespace Utilities 4 | { 5 | 6 | public class DragDropHighlighter 7 | { 8 | public static readonly DependencyProperty IsDroppingAboveProperty = DependencyProperty.RegisterAttached("IsDroppingAbove", typeof(bool), typeof(DragDropHighlighter), new UIPropertyMetadata(false)); 9 | public static readonly DependencyProperty IsDroppingBelowProperty = DependencyProperty.RegisterAttached("IsDroppingBelow", typeof(bool), typeof(DragDropHighlighter), new UIPropertyMetadata(false)); 10 | 11 | 12 | public static bool GetIsDroppingAbove(DependencyObject source) 13 | { 14 | return (bool)source.GetValue(IsDroppingAboveProperty); 15 | } 16 | 17 | 18 | public static void SetIsDroppingAbove(DependencyObject target, bool value) 19 | { 20 | target.SetValue(IsDroppingAboveProperty, value); 21 | } 22 | 23 | 24 | public static bool GetIsDroppingBelow(DependencyObject source) 25 | { 26 | return (bool)source.GetValue(IsDroppingBelowProperty); 27 | } 28 | 29 | 30 | public static void SetIsDroppingBelow(DependencyObject target, bool value) 31 | { 32 | target.SetValue(IsDroppingBelowProperty, value); 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /RelayCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Windows.Input; 4 | 5 | 6 | namespace FFBitrateViewer 7 | { 8 | // Source: https://docs.microsoft.com/en-us/archive/msdn-magazine/2009/february/patterns-wpf-apps-with-the-model-view-viewmodel-design-pattern#id0090030 9 | public class RelayCommand : ICommand 10 | { 11 | readonly Action _execute; 12 | readonly Predicate? _canExecute; 13 | 14 | public RelayCommand(Action execute) : this(execute, null) { } 15 | 16 | public RelayCommand(Action execute, Predicate? canExecute) 17 | { 18 | if (execute == null) throw new ArgumentNullException("execute"); 19 | _execute = execute; 20 | _canExecute = canExecute; 21 | } 22 | 23 | [DebuggerStepThrough] 24 | public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; 25 | 26 | public event EventHandler? CanExecuteChanged 27 | { 28 | add { CommandManager.RequerySuggested += value; } 29 | remove { CommandManager.RequerySuggested -= value; } 30 | } 31 | 32 | public void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested(); 33 | 34 | public void Execute(object? parameter) => _execute(parameter); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Global.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Windows; 4 | 5 | namespace FFBitrateViewer 6 | { 7 | class Global 8 | { 9 | private static string _tempDir = Helpers.NormalizeDirSpec(Path.GetTempPath()); 10 | public static string TempDir { get => _tempDir; } 11 | 12 | public static void SetTempDir(string tempDir) 13 | { 14 | string dir = Helpers.NormalizeDirSpec(tempDir); 15 | 16 | if (!Directory.Exists(dir)) 17 | { 18 | try 19 | { 20 | Directory.CreateDirectory(dir); 21 | } 22 | catch (Exception) 23 | { 24 | MessageBox.Show("FFBitrateViewer unable to create temp directory:\n" + dir, "Error", MessageBoxButton.OK, MessageBoxImage.Error); 25 | Environment.Exit(0); 26 | } 27 | } 28 | 29 | // Checking write permissions 30 | try 31 | { 32 | using (FileStream fs = File.Create(Path.Combine(dir, Path.GetRandomFileName()), 1, FileOptions.DeleteOnClose)) { } 33 | } 34 | catch (Exception) 35 | { 36 | MessageBox.Show("FFBitrateViewer unable to create file in temp directory:\n" + dir, "Error", MessageBoxButton.OK, MessageBoxImage.Error); 37 | Environment.Exit(0); 38 | } 39 | 40 | _tempDir = dir; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Icons/SaveTable_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Icons/Exit_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Icons/OpenFolder_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Icons/CopyToClipboard_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /DataDictionary.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | 5 | namespace FFBitrateViewer 6 | { 7 | // Used for storing values 8 | // Key -- case sensitive string 9 | // Value -- null, int or double 10 | // On adding a new item to the dictionary the value is automatically converted from string to null/int/double 11 | public class DataDictionary : Dictionary 12 | { 13 | public new void Add(string key, object value) 14 | { 15 | if (value == null) base.Add(key, null); 16 | else if (value is string @string) 17 | { 18 | if (int.TryParse(@string, out int @int)) base.Add(key, @int); 19 | else if (Helpers.TryParseDouble(@string, out double @double, true/*withInfinity*/)) base.Add(key, @double); 20 | else base.Add(key, null); 21 | } 22 | else if (Helpers.IsFloatingPointNumber(ref value)) base.Add(key, Convert.ToDouble(value)); 23 | else if (Helpers.IsIntegralNumber(ref value)) base.Add(key, Convert.ToInt32(value)); 24 | else throw new System.InvalidOperationException("Value must be numeric, string or null"); 25 | } 26 | 27 | 28 | public void Add(Dictionary pairs) 29 | { 30 | if(pairs != null) foreach(var pair in pairs) Add(pair.Key, pair.Value); 31 | } 32 | 33 | 34 | public void Add(Dictionary pairs) 35 | { 36 | if (pairs != null) foreach (var pair in pairs) Add(pair.Key, pair.Value); 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows; 3 | 4 | namespace FFBitrateViewer 5 | { 6 | public static class Log 7 | { 8 | private static Logger? Logger = null; 9 | public static bool IsLogCommands = false; 10 | 11 | 12 | public static void Init(Logger logger, bool isLogCommands = false) 13 | { 14 | Logger = logger; 15 | IsLogCommands = isLogCommands; 16 | } 17 | 18 | 19 | public static void Close() 20 | { 21 | Logger?.Close(); 22 | } 23 | 24 | 25 | public static bool LogLevelIs(LogLevel logLevel) 26 | { 27 | return Logger?.MinLevel == logLevel; 28 | } 29 | 30 | 31 | public static void Write(LogLevel logLevel, string line) 32 | { 33 | try 34 | { 35 | Logger?.Log(logLevel, line); 36 | } 37 | catch (Exception) 38 | { 39 | MessageBox.Show("FFBitrateViewer unable to write into file:\n" + Logger?.FileName + "\n\nLogging is disabled.", "Warning", MessageBoxButton.OK, MessageBoxImage.Warning); 40 | Logger?.Disable(); 41 | } 42 | } 43 | 44 | 45 | public static void Write(LogLevel logLevel, params string[] values) 46 | { 47 | Write(logLevel, string.Join(", ", values)); 48 | } 49 | 50 | 51 | public static void WriteCommand(string executable, string? args) 52 | { 53 | if(IsLogCommands) Write(LogLevel.INFO, executable + " " + args); 54 | } 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Icons/NewFileCollection_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Icons/SaveAs_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Icons/DeleteTableRow_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Icons/OpenFile_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Icons/CleanData_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Bindable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Diagnostics; 5 | using System.Linq.Expressions; 6 | using System.Runtime.CompilerServices; 7 | 8 | 9 | namespace FFBitrateViewer 10 | { 11 | // Source: https://timoch.com/blog/2013/08/annoyed-with-inotifypropertychange/ (with some modifications) 12 | 13 | /// 14 | /// Base class to implement object that can be bound to 15 | /// 16 | public class Bindable : INotifyPropertyChanged 17 | { 18 | private readonly Dictionary _properties = new(); 19 | 20 | 21 | /// 22 | /// Gets the value of a property 23 | /// 24 | /// 25 | /// 26 | /// 27 | protected T? Get([CallerMemberName] string? name = null) 28 | { 29 | Debug.Assert(name != null, "name != null"); 30 | if (_properties.TryGetValue(name, out object? value)) return value == null ? default : (T)value; 31 | return default; 32 | } 33 | 34 | 35 | /// 36 | /// Sets the value of a property 37 | /// 38 | /// 39 | /// 40 | /// 41 | protected void Set(T value, [CallerMemberName] string? name = null) 42 | { 43 | Debug.Assert(name != null, "name != null"); 44 | if (Equals(value, Get(name))) return; 45 | _properties[name] = value; 46 | OnPropertyChanged(name); 47 | } 48 | 49 | 50 | public event PropertyChangedEventHandler? PropertyChanged; 51 | 52 | 53 | protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) 54 | { 55 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); 56 | } 57 | 58 | 59 | protected virtual void OnPropertyChanged(Expression> raiser) 60 | { 61 | var propertyName = ((MemberExpression)raiser.Body).Member.Name; 62 | OnPropertyChanged(propertyName); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Windows/AboutWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Windows; 3 | using System.Windows.Navigation; 4 | 5 | 6 | namespace FFBitrateViewer 7 | { 8 | public class LinkItem 9 | { 10 | public string? Name { get; set; } 11 | public string? Link { get; set; } 12 | } 13 | 14 | public class AboutViewModel : Bindable 15 | { 16 | public string? ProgramAuthor { get { return Get(); } private set { Set(value); } } 17 | public string? ProgramDesc { get { return Get(); } private set { Set(value); } } 18 | public LinkItem? ProgramHome { get { return Get(); } private set { Set(value); } } 19 | public string? ProgramTitle { get { return Get(); } private set { Set(value); } } 20 | public string? ProgramVersion { get { return Get(); } private set { Set(value); } } 21 | public string? WindowTitle { get { return Get(); } private set { Set(value); } } 22 | 23 | public AboutViewModel() 24 | { 25 | ProgramHome = new LinkItem 26 | { 27 | Link = "https://github.com/fifonik/FFBitrateViewer", 28 | Name = "github.com/fifonik/FFBitrateViewer" 29 | }; 30 | 31 | 32 | ProgramAuthor = "fifonik"; 33 | ProgramDesc = "FFBitrateViewer allows you to see video stream bit rate distribution."; 34 | ProgramTitle = ProgramInfo.Name + " – another FFProbe GUI"; 35 | ProgramVersion = ProgramInfo.Version; 36 | 37 | WindowTitle = "About " + ProgramInfo.Name; 38 | } 39 | } 40 | 41 | 42 | 43 | public partial class AboutWindow : Window 44 | { 45 | public AboutWindow() 46 | { 47 | InitializeComponent(); 48 | DataContext = new AboutViewModel(); 49 | } 50 | 51 | 52 | private void BtnClose_Click(object sender, RoutedEventArgs e) 53 | { 54 | Close(); 55 | } 56 | 57 | 58 | private void ProgramHome_RequestNavigate(object sender, RequestNavigateEventArgs e) 59 | { 60 | Helpers.RunAssociatedSystemProgram(e.Uri.AbsoluteUri); 61 | e.Handled = true; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /App.xaml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /OverallProgress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | 4 | namespace FFBitrateViewer 5 | { 6 | public class OverallProgress : Bindable 7 | { 8 | public bool IsActive { get { return Get(); } private set { Set(value); } } 9 | private bool IsIndeterminate { get { return Get(); } set { Set(value); } } 10 | public bool IsIndeterminateXAML { get { return IsIndeterminate && IsActive; } } 11 | public int Current { get { return Get(); } set { Set(value); /* PercentUpdate(); */ } } 12 | public int Max { get { return Get(); } set { Set(value); /* PercentUpdate(); */ } } 13 | public double CurrentPercent { get { return Get(); } private set { Set(value); } } 14 | public string? Text { get { return Get(); } private set { Set(value); } } 15 | public double ProcessedDuration { get { return Get(); } set { Set(value); } } 16 | 17 | 18 | public OverallProgress(bool indeterminate = false) { 19 | IsIndeterminate = indeterminate; 20 | Max = 100; 21 | Current = 0; 22 | ProcessedDuration = 0; 23 | //IsActive = true; 24 | //Text = "123"; 25 | } 26 | 27 | 28 | public void Start() 29 | { 30 | IsActive = true; 31 | } 32 | 33 | 34 | public void Stop() 35 | { 36 | IsActive = false; 37 | } 38 | 39 | 40 | public void Show(string text) 41 | { 42 | Text = text; 43 | IsIndeterminate = true; 44 | Start(); 45 | } 46 | 47 | 48 | public void Hide() 49 | { 50 | Text = null; 51 | IsIndeterminate = false; 52 | Stop(); 53 | } 54 | 55 | 56 | public void Reset() 57 | { 58 | Current = 0; 59 | ProcessedDuration = 0; 60 | } 61 | 62 | 63 | public void FileProgressSet(int fileIndex, Frame? frame = null) 64 | { 65 | string s = "Processing file: " + (fileIndex + 1); 66 | if (frame != null) 67 | { 68 | s += ", time: " + TimeSpan.FromSeconds(frame.StartTime).ToString(@"hh\:mm\:ss"); 69 | Current = (int)(ProcessedDuration + frame.StartTime); 70 | } 71 | Text = s; 72 | } 73 | } 74 | 75 | 76 | } 77 | -------------------------------------------------------------------------------- /CommandLine.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Text.RegularExpressions; 5 | 6 | 7 | namespace FFBitrateViewer 8 | { 9 | // Source: http://csharptest.net/529/how-to-correctly-escape-command-line-arguments-in-c/ 10 | public class CommandLine 11 | { 12 | private static readonly Regex invalidChar = new Regex("[\x00\x0a\x0d]"); // these can not be escaped 13 | private static readonly Regex needsQuotes = new Regex(@"\s|"""); // contains whitespace or two quote characters 14 | private static readonly Regex escapeQuote = new Regex(@"(\\*)(""|$)"); // one or more '\' followed with a quote or end of string 15 | 16 | public static string EscapeArgument(string? arg, string argNameForExceptions) 17 | { 18 | if (arg == null) throw new ArgumentNullException(argNameForExceptions); 19 | if (invalidChar.IsMatch(arg)) throw new ArgumentOutOfRangeException(argNameForExceptions); 20 | 21 | if (arg == string.Empty) return "\"\""; 22 | else if (!needsQuotes.IsMatch(arg)) return arg; 23 | else return '"' + (escapeQuote.Replace(arg, m => m.Groups[1].Value + m.Groups[1].Value + (m.Groups[2].Value == "\"" ? "\\\"" : ""))) + '"'; 24 | } 25 | 26 | 27 | public static string EscapeArguments(params string[] args) 28 | { 29 | StringBuilder arguments = new(); 30 | for (int i = 0; args != null && i < args.Length; ++i) 31 | { 32 | if (args[i] == null) throw new ArgumentNullException("args[" + i + "]"); 33 | if (invalidChar.IsMatch(args[i])) throw new ArgumentOutOfRangeException("args[" + i + "]"); 34 | 35 | if (args[i] == string.Empty) { arguments.Append("\"\""); } 36 | else if (!needsQuotes.IsMatch(args[i])) { arguments.Append(args[i]); } 37 | else 38 | { 39 | arguments.Append('"'); 40 | arguments.Append(escapeQuote.Replace(args[i], m => m.Groups[1].Value + m.Groups[1].Value + (m.Groups[2].Value == "\"" ? "\\\"" : ""))); 41 | arguments.Append('"'); 42 | } 43 | if (i + 1 < args.Length) arguments.Append(' '); 44 | } 45 | return arguments.ToString(); 46 | } 47 | 48 | 49 | public static string EscapeArguments(List args) 50 | { 51 | return EscapeArguments(args.ToArray()); 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /FFBitrateViewer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net8.0-windows8.0 6 | enable 7 | true 8 | FFBitrateViewer.App 9 | 0.9.7.1 10 | icon.ico 11 | FFBitrateViewer -- FFProbe GUI 12 | Copyright © 2025 / Fifonik 13 | https://github.com/fifonik/FFBitrateViewer 14 | README.md 15 | https://github.com/fifonik/FFBitrateViewer 16 | FFBitrateViewer -- FFProbe GUI 17 | 8.0 18 | x64 19 | False 20 | 21 | 22 | 23 | full 24 | 25 | 26 | 27 | full 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | True 44 | True 45 | Resources.resx 46 | 47 | 48 | True 49 | True 50 | Settings.settings 51 | 52 | 53 | 54 | 55 | 56 | ResXFileCodeGenerator 57 | Resources.Designer.cs 58 | 59 | 60 | 61 | 62 | 63 | SettingsSingleFileGenerator 64 | Settings.Designer.cs 65 | 66 | 67 | True 68 | \ 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace FFBitrateViewer.Properties { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("FFBitrateViewer.Properties.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace FFBitrateViewer.Properties { 12 | 13 | 14 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 15 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.12.0.0")] 16 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { 17 | 18 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 19 | 20 | public static Settings Default { 21 | get { 22 | return defaultInstance; 23 | } 24 | } 25 | 26 | [global::System.Configuration.UserScopedSettingAttribute()] 27 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 28 | [global::System.Configuration.DefaultSettingValueAttribute("")] 29 | public string Files { 30 | get { 31 | return ((string)(this["Files"])); 32 | } 33 | set { 34 | this["Files"] = value; 35 | } 36 | } 37 | 38 | [global::System.Configuration.UserScopedSettingAttribute()] 39 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 40 | [global::System.Configuration.DefaultSettingValueAttribute("False")] 41 | public bool LogCommands { 42 | get { 43 | return ((bool)(this["LogCommands"])); 44 | } 45 | set { 46 | this["LogCommands"] = value; 47 | } 48 | } 49 | 50 | [global::System.Configuration.UserScopedSettingAttribute()] 51 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 52 | [global::System.Configuration.DefaultSettingValueAttribute("True")] 53 | public bool AdjustStartTimeOnPlot { 54 | get { 55 | return ((bool)(this["AdjustStartTimeOnPlot"])); 56 | } 57 | set { 58 | this["AdjustStartTimeOnPlot"] = value; 59 | } 60 | } 61 | 62 | [global::System.Configuration.UserScopedSettingAttribute()] 63 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 64 | [global::System.Configuration.DefaultSettingValueAttribute("frame")] 65 | public string PlotViewType { 66 | get { 67 | return ((string)(this["PlotViewType"])); 68 | } 69 | set { 70 | this["PlotViewType"] = value; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Windows/AboutWindow.xaml: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Icons/CopyTheme_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## FFBitrateViewer — yet another program for video file bitrate visualization 2 | 3 | FFBitrateViewer is a FFProbe GUI that purpose is to visualize frames` bitrate extracted by FFProbe. 4 | It is inspired by [Bitrate Viewer](https://web.archive.org/web/20160730053853/http://www.winhoros.de/docs/bitrate-viewer/) (link to Web Archive as the program's web-site and forum are long dead). 5 | FFBitrateViewer allows you to select multiple files without dealing with command line and get “per frame”, “per second” or “per GOP” info for all of them in one go. 6 | 7 | Well, and play with interactive graphs (powered by OxyPlot): 8 |

9 | 10 | 11 | ## Features 12 | - “Per frame”, “per second” or “per GOP” graphs 13 | - Processing up to 12 files in one go; 14 | - Brief media info for all files (hover mouse over media info to see more details); 15 | - Easy to use UI: files can be added with file chooser or dropped from Windows Explorer, files can be re-ordered using Drag & Drop; 16 | - Graphs can be zoomed in/out with mouse wheel (try it over graph and/or axes), panned with right mouse button and saved as SVG or PNG; 17 | - FFProbe commands issued by FFBitrateViewer can be saved to log file (`FFBitrateViewer.log`); 18 | - No registration, banners, tracking etc; 19 | - Free; 20 | - **Open Source** ([MIT License](LICENSE.txt)). 21 | 22 | 23 | ## Latest version 24 | - Latest Beta: [0.9.6 beta 1](https://github.com/fifonik/FFBitrateViewer/releases/tag/v0.9.6-beta.1) 25 | 26 | 27 | ## Requirements 28 | - Windows OS 29 | - .NET 8.0. The program should ask you to download and install it if required. 30 | - FFProbe.exe (a part of FFMpeg package). You have to download it from [official ffmpeg web site](https://ffmpeg.org/download.html). 31 | You can use single-file static build for simplicity, however, for real usage I'd recommend to make shared build accessible in %PATH%. 32 | 33 | 34 | ## How to use 35 | - Unpack into a folder; 36 | - Put FFProbe.exe (and accompanied dll files if you use shared build) into the program folder or make it available through system %PATH%; 37 | - Run the program; 38 | - Use UI to add files; 39 | - Click “Start” button. 40 | 41 | 42 | ## How to run with command line options 43 | **FFBitrateViewer.exe \[options\] c:\path\to\file1.mp4 \[c:\path\to\file2.mp4\] \[c:\path\to\file3.mp4\] \[...\]** 44 | 45 | ### Accepted options 46 | -adjust-start-time-on-plot Default: false 47 | -log-commands Log FFProbe commands 48 | -log-level=DEBUG|ERROR|INFO Default: INFO 49 | -plot-view-type=FRAME|SECOND|GOP Default: FRAME 50 | -run Run calculation when program started 51 | -temp-dir=dirspec Default: default user temporary directory 52 | 53 | All options can be provided using single leading dash (-option) or double leading dash (--option). 54 | 55 | #### Examples 56 | `FFBitrateViewer.exe c:\path\to\file.mp4`
57 | `FFBitrateViewer.exe -adjust-start-time-on-plot -plot-view-type=SECOND "c:\path\to\my file.mp4"`
58 | 59 | 60 | ## Troubleshooting 61 | - Close FFBitrateViewer and delete `FFBitrateViewer.log`; 62 | - Run the program with option `-log-level=debug`; 63 | - Add file; 64 | - Click “Start” button; 65 | - Take screenshot (Alt+PrnScr or Win+Shift+S and paste it into image editor and save as PNG); 66 | - Close the program; 67 | - Analyze `FFBitrateViewer.log`. You can try to run the ffprobe command directly; 68 | - Upload archived `FFBitrateViewer.log` with screenshot to dropbox (or similar) and share the link. 69 | 70 | 71 | ## Author 72 | fifonik 73 | 74 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/fifonik) 75 | -------------------------------------------------------------------------------- /Icons/ImportTheme_16x.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ProgramConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace FFBitrateViewer 4 | { 5 | public class FFProbeFramesInfoConfig 6 | { 7 | // -select_streams {{stream}} -show_entries frame=pkt_pos,best_effort_timestamp_time,pict_type,pkt_dts_time,pkt_duration_time,pkt_pts_time,pkt_size 8 | // While getting information about frames/packets using compact format instead of json as it is: 9 | // - Smaller; 10 | // - Easier to parse while reading stdout. 11 | public string Template { get; set; } = "-hide_banner -threads {{threads}} -print_format compact -loglevel fatal -show_error -select_streams v:{{stream}} -show_entries packet=pos,dts_time,duration_time,pts_time,size,flags {{src}}"; 12 | 13 | public int Timeout { get; set; } = 1800_000; // milliseconds 14 | } 15 | 16 | 17 | public class FFProbeMediaInfoConfig 18 | { 19 | // ? "-show_entries stream_tags=duration" is required as without this the duration is not always returned 20 | // "-count_frames -count_packets" -- calculate frames & packets counts per stream and (returned in nb_frames/nb_packets), but this is much slower. 21 | // "-find_stream_info" -- fill-in missing information by actually read the streams instead of just parsing the header(s). It helps with corrupted files. 22 | // "-probesize 10000000" and "-analyzeduration 2000000" cab be used with -find_stream_info 23 | public string Template { get; set; } = "-hide_banner -threads {{threads}} -probesize 20M -print_format json=compact=1 -loglevel fatal -show_error -show_format -show_streams -show_entries stream_tags=duration {{src}}"; 24 | 25 | public int Timeout { get; set; } = 30_000; // milliseconds 26 | } 27 | 28 | 29 | public class FFProbeVersionInfoConfig 30 | { 31 | // -hide_banner does not work with -version, also the version info is in the banner 32 | public string Template { get; set; } = "-version"; 33 | public int Timeout { get; set; } = 30_000; // milliseconds 34 | } 35 | 36 | 37 | public class ProgramConfig 38 | { 39 | private static readonly string DefaultVideoFilesList = "*.264;*.avi;*.avs;*.h264;*.hevc;*.m2ts;*.m4v;*.mkv;*.mov;*.mp4;*.mpeg;*.mpg;*.mts;*.mxf;*.ts;*.webm"; 40 | private static readonly Regex VideoFilesListRegexp = new Regex(@"^(\*\.[a-z0-9]{1,}(?:$|;))+", RegexOptions.IgnoreCase | RegexOptions.Singleline); 41 | private string _videoFilesList = DefaultVideoFilesList; 42 | public FFProbeFramesInfoConfig FramesInfo { get; set; } = new(); 43 | public FFProbeMediaInfoConfig MediaInfo { get; set; } = new(); 44 | //public PlotParams Plots { get; set; } = new(); 45 | public FFProbeVersionInfoConfig VersionInfo { get; set; } = new(); 46 | public string VideoFilesList { get { return _videoFilesList; } set { _videoFilesList = string.IsNullOrEmpty(value) || !IsVideoFilesListValid(value) ? DefaultVideoFilesList : value; } } 47 | public string? TempDir { get; set; } 48 | 49 | 50 | public static ProgramConfig LoadFromFile(string fs) 51 | { 52 | var result = Json.FileRead(fs) ?? new ProgramConfig(); 53 | 54 | result.FramesInfo ??= new FFProbeFramesInfoConfig(); 55 | result.MediaInfo ??= new FFProbeMediaInfoConfig(); 56 | //result.Plots ??= new PlotParams(); 57 | result.VersionInfo ??= new FFProbeVersionInfoConfig(); 58 | 59 | return result; 60 | } 61 | 62 | public static bool IsVideoFilesListValid(string videoFilesList) 63 | { 64 | Match match = VideoFilesListRegexp.Match(videoFilesList); 65 | if(!match.Success) Log.Write(LogLevel.WARNING, "VideoFilesList specified in conf has invalid format and ignored"); 66 | return match.Success; 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Properties/Resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | text/microsoft-resx 91 | 92 | 93 | 1.3 94 | 95 | 96 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 97 | 98 | 99 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 100 | 101 | -------------------------------------------------------------------------------- /Logger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | 5 | 6 | namespace FFBitrateViewer 7 | { 8 | public enum LogLevel 9 | { 10 | DEBUG = 0, 11 | INFO = 1, 12 | WARNING = 2, 13 | ERROR = 3 14 | } 15 | 16 | 17 | public class Logger 18 | { 19 | private string FS; 20 | private StreamWriter? Stream; 21 | private readonly bool AddTimestamp; 22 | private readonly bool Append; 23 | private readonly bool AutoFlush; 24 | private bool IsOpened = false; 25 | private bool IsDisabled = false; 26 | public LogLevel MinLevel { get; private set; } 27 | public string FileName { get { return Path.GetFileName(FS); } } 28 | 29 | 30 | public Logger(LogLevel minLogLevel, string fs, bool append = false, bool timestamp = false, bool autoflush = false) 31 | { 32 | MinLevel = minLogLevel; 33 | FS = fs; 34 | Append = append; 35 | AddTimestamp = timestamp; 36 | AutoFlush = autoflush; 37 | } 38 | 39 | 40 | public void Disable() 41 | { 42 | IsDisabled = true; 43 | } 44 | 45 | 46 | public bool Open(string? fs = null) 47 | { 48 | if (IsDisabled) return false; 49 | if (!string.IsNullOrEmpty(fs) && !string.Equals(fs, FS, StringComparison.InvariantCultureIgnoreCase)) 50 | { 51 | FS = fs; 52 | if (IsOpened) Close(); 53 | } 54 | if (!IsOpened && !string.IsNullOrEmpty(FS)) 55 | { 56 | Stream = new StreamWriter(FS, Append); 57 | IsOpened = true; 58 | } 59 | return IsOpened; 60 | } 61 | 62 | 63 | public void Log(LogLevel level, string line) 64 | { 65 | if (IsDisabled || string.IsNullOrEmpty(line)) return; 66 | if (level < MinLevel) return; 67 | 68 | string logPrefix = ""; 69 | string debugPrefix = ""; 70 | 71 | switch (level) 72 | { 73 | case LogLevel.DEBUG: 74 | logPrefix = "DEBUG: "; 75 | debugPrefix = logPrefix; 76 | break; 77 | case LogLevel.INFO: 78 | debugPrefix = "INFO: "; 79 | break; 80 | case LogLevel.WARNING: 81 | logPrefix = "WARNING: "; 82 | debugPrefix = logPrefix; 83 | break; 84 | case LogLevel.ERROR: 85 | logPrefix = "ERROR: "; 86 | debugPrefix = logPrefix; 87 | break; 88 | } 89 | Debug.WriteLine(debugPrefix + line); 90 | Log(logPrefix + line); 91 | } 92 | 93 | 94 | //public void Log(List line, char separator = '\t', bool? addTimestamp = null) 95 | //{ 96 | // Log(string.Join(separator.ToString(), line), addTimestamp); 97 | //} 98 | 99 | 100 | public void Log(string line, bool? addTimestamp = null) 101 | { 102 | if (!Open()) return; 103 | if ((addTimestamp ?? AddTimestamp) == true) 104 | { 105 | DateTime now = DateTime.Now; 106 | Stream?.WriteLine($"{now:yyyy-MM-dd HH:mm:ss}\t" + line); 107 | } 108 | else 109 | { 110 | Stream?.WriteLine(line); 111 | } 112 | if (AutoFlush) Stream?.Flush(); 113 | } 114 | 115 | 116 | //public void LogCSV(DataDictionary pairs, int frame, string? exclude = null, char separator = '\t') 117 | //{ 118 | // if (!Open()) return; 119 | 120 | // if (frame == 0) 121 | // { 122 | // string header = "frame"; 123 | // foreach (string s in pairs.Keys) 124 | // { 125 | // if (string.IsNullOrEmpty(exclude) || !exclude.Equals(s)) 126 | // { 127 | // if (header != "") header += separator; 128 | // header += s; 129 | // } 130 | // } 131 | // Log(header); 132 | // } 133 | 134 | // string line = "" + frame; 135 | // foreach (object? o in pairs.Values) 136 | // { 137 | // string? s = o?.ToString(); 138 | // if (string.IsNullOrEmpty(exclude) || !exclude.Equals(s)) 139 | // { 140 | // if (line != "") line += separator; 141 | // line += s; 142 | // } 143 | // } 144 | // Log(line); 145 | //} 146 | 147 | 148 | //public void LogCSV(List data, string? frameNoKey = null, char separator = '\t') 149 | //{ 150 | // for(int frame = 0; frame < data.Count; ++frame) LogCSV(data[frame], frame, frameNoKey, separator); 151 | //} 152 | 153 | 154 | public void Close() 155 | { 156 | if (!IsOpened || Stream == null) return; 157 | IsOpened = false; 158 | Stream.Flush(); 159 | Stream.Close(); 160 | Stream.Dispose(); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /FileItem.cs: -------------------------------------------------------------------------------- 1 | using OxyPlot; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Threading; 6 | 7 | namespace FFBitrateViewer 8 | { 9 | public class FileItem : Bindable 10 | { 11 | public BitRate? BitRateAvg { get { return Get(); } private set { Set(value); } } 12 | public BitRate? BitRateMax { get { return Get(); } private set { Set(value); } } 13 | public BitRate? BitRateMin { get { return Get(); } private set { Set(value); } } 14 | public string? FS { // File Spec 15 | get { return Get(); } 16 | set { 17 | Set(value); 18 | IsExists = !string.IsNullOrEmpty(value) && File.Exists(value); 19 | FN = Path.GetFileName(FS); 20 | var ext = Path.GetExtension(value); 21 | IsPreviewable = !string.IsNullOrEmpty(ext) && !string.IsNullOrEmpty(Helpers.GetAssociatedProgram(ext)); 22 | } 23 | } 24 | public string? FN { get { return Get(); } private set { Set(value); } } // File Name 25 | public Frames? Frames { get { return Get(); } private set { Set(value); OnPropertyChanged(nameof(Duration)); } } 26 | //public int? DropTarget { get { return Get(); } set { Set(value); } } // 1 -- top, 2 - bottom 27 | public double? Duration { get { return MediaInfo?.Video0?.Duration ?? MediaInfo?.Duration ?? Frames?.Duration ?? Frames?.FramesDuration; } } 28 | public bool IsAdjustStartTime { get { return Get(); } set { Set(value); FramesIsAdjustStartTimeSet(value); } } 29 | public bool IsExists { get { return Get(); } private set { Set(value); OnPropertyChanged(nameof(IsReady)); } } 30 | public bool IsEnabled { get { return Get(); } set { Set(value); OnPropertyChanged(nameof(IsReady)); } } 31 | public bool IsMediaInfoLoading { get { return Get(); } private set { Set(value); } } 32 | public bool IsFramesLoading { get { return Get(); } private set { Set(value); } } 33 | public bool IsFramesLoaded { get { return Get(); } private set { Set(value); } } 34 | public bool IsPreviewable { get; private set; } 35 | public bool IsReady { get { return IsExists && IsEnabled && MediaInfo != null; } } 36 | public MediaInfo? MediaInfo { get { return Get(); } private set { Set(value); OnPropertyChanged(nameof(IsReady)); OnPropertyChanged(nameof(Duration)); } } 37 | 38 | public FileItem(string? fs = null, bool enabled = true) 39 | { 40 | Frames = null; 41 | IsEnabled = enabled; 42 | MediaInfo = null; 43 | if (!string.IsNullOrEmpty(fs)) FS = fs; 44 | 45 | } 46 | 47 | 48 | private void BitRatesCalc(Frames frames) 49 | { 50 | var bitRates = frames.BitRatesCals(); 51 | BitRateAvg = bitRates.Avg; 52 | BitRateMax = bitRates.Max; 53 | BitRateMin = bitRates.Min; 54 | } 55 | 56 | 57 | public void FramesClear() 58 | { 59 | BitRateAvg = null; 60 | BitRateMax = null; 61 | BitRateMin = null; 62 | Frames = null; 63 | IsFramesLoaded = false; 64 | } 65 | 66 | 67 | public List FramesDataPointsGet(string? plotViewType) 68 | { 69 | return Frames == null ? new List() : Frames.DataPointsGet(plotViewType); 70 | } 71 | 72 | 73 | public void FramesGet(CancellationToken cancellationToken, Action? action = null) 74 | { 75 | // Cannot just clear & reload Frames as it will not updated in MediaInfoBox 76 | if (!IsReady || string.IsNullOrEmpty(FS)) return; 77 | IsFramesLoading = true; 78 | IsFramesLoaded = false; 79 | 80 | var frames = new Frames(); 81 | frames.IsAdjustStartTimeSet(IsAdjustStartTime); 82 | if (MediaInfo != null) frames.StartTime = MediaInfo.StartTime; 83 | 84 | int line = 0; 85 | ExecStatus status = FF.FramesGet(FS, cancellationToken, o => { 86 | ++line; 87 | if (o is Frame frame) 88 | { 89 | var pos = frames.Add(frame); 90 | if(pos != null) action?.Invoke(pos, frame, line); 91 | } 92 | }); 93 | 94 | if (status.Code == 0) 95 | { 96 | frames.Analyze(); 97 | BitRatesCalc(frames); 98 | Frames = frames; 99 | IsFramesLoaded = true; 100 | } 101 | 102 | IsFramesLoading = false; 103 | } 104 | 105 | 106 | private void FramesIsAdjustStartTimeSet(bool isAdjustStartTime) 107 | { 108 | if (Frames != null) 109 | { 110 | if (isAdjustStartTime == Frames.IsAdjustStartTime) return; 111 | Frames.IsAdjustStartTimeSet(isAdjustStartTime); 112 | BitRatesCalc(Frames); 113 | } 114 | } 115 | 116 | 117 | public double? FramesMaxXGet(string? plotViewType) 118 | { 119 | return Frames?.MaxXGet(plotViewType); 120 | } 121 | 122 | 123 | public int FramesMaxYGet(string? plotViewType) 124 | { 125 | return Frames?.MaxYGet(plotViewType) ?? 0; 126 | } 127 | 128 | 129 | public void MediaInfoClear() 130 | { 131 | MediaInfo = null; 132 | } 133 | 134 | 135 | public void MediaInfoGet() 136 | { 137 | if (!string.IsNullOrEmpty(FS) && IsExists) 138 | { 139 | IsMediaInfoLoading = true; 140 | // Cannot just clear & reload MediaInfo as it will not updated in MediaInfoBox 141 | MediaInfo = new MediaInfo(FS); 142 | IsMediaInfoLoading = false; 143 | if (Frames != null) Frames.StartTime = MediaInfo.StartTime; 144 | } 145 | } 146 | 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd -------------------------------------------------------------------------------- /Execute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Text; 4 | using System.Threading; 5 | 6 | 7 | namespace FFBitrateViewer 8 | { 9 | public class ExecStatus 10 | { 11 | public int Code = 0; 12 | public string StdErr = ""; 13 | public string StdOut = ""; 14 | } 15 | 16 | public enum ExecStop 17 | { 18 | None = 0, 19 | Timeout = 1, 20 | Cancel = 2 21 | } 22 | 23 | 24 | class Execute 25 | { 26 | private readonly static int TimeoutDefault = 5000; // milliseconds 27 | private readonly static int TimeoutNoOutput = 15_000; // milliseconds 28 | private readonly static int TimeoutStep = 200; // milliseconds 29 | 30 | 31 | public static ExecStatus Exec(string executable, string args, int? timeout = null, CancellationToken? cancellationToken = null, Action? stdoutAction = null, Action? stderrAction = null) 32 | { 33 | string func = "Execute.Exec"; 34 | 35 | Log.WriteCommand(executable, args); 36 | 37 | Log.Write(LogLevel.DEBUG, func + ": Started", executable, args); 38 | var result = new ExecStatus(); 39 | var stdout = new StringBuilder(); 40 | var stderr = new StringBuilder(); 41 | int time1 = 0; 42 | int time2 = 0; 43 | int timeout1 = timeout ?? TimeoutDefault; 44 | int timeout2 = TimeoutNoOutput; 45 | 46 | using (var stdoutWaitHandle = new AutoResetEvent(false)) 47 | using (var stderrWaitHandle = new AutoResetEvent(false)) 48 | { 49 | using (Process process = new()) 50 | { 51 | process.StartInfo.UseShellExecute = false; 52 | process.StartInfo.CreateNoWindow = true; 53 | process.EnableRaisingEvents = false; 54 | process.StartInfo.FileName = executable; 55 | process.StartInfo.Arguments = args; 56 | process.StartInfo.WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory; 57 | 58 | // To prevent deadlock, at least one stream (stdout or stderr) must be redirected (read async, I'm redirecting both): 59 | // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.standardoutput?view=netframework-4.7.2 60 | 61 | process.StartInfo.RedirectStandardOutput = true; 62 | process.StartInfo.RedirectStandardError = true; 63 | 64 | try 65 | { 66 | process.OutputDataReceived += (sender, e) => 67 | { 68 | time2 = 0; 69 | if (string.IsNullOrEmpty(e.Data)) stdoutWaitHandle.Set(); 70 | else 71 | { 72 | #if DEBUG 73 | // Debug.WriteLine("StdOut: " + e.Data); // very slow 74 | #endif 75 | if (stdoutAction == null) stdout.AppendLine(e.Data); 76 | else stdoutAction(e.Data); 77 | } 78 | }; 79 | 80 | process.ErrorDataReceived += (sender, e) => 81 | { 82 | time2 = 0; 83 | if (string.IsNullOrEmpty(e.Data)) stderrWaitHandle.Set(); 84 | else 85 | { 86 | #if DEBUG 87 | // Debug.WriteLine("StdErr: " + e.Data); // very slow 88 | #endif 89 | if (stderrAction == null) stderr.AppendLine(e.Data); 90 | else stderrAction(e.Data); 91 | } 92 | }; 93 | 94 | process.Start(); 95 | //process.Refresh(); 96 | process.PriorityClass = ProcessPriorityClass.BelowNormal; 97 | process.BeginOutputReadLine(); 98 | process.BeginErrorReadLine(); 99 | 100 | ExecStop stopped = ExecStop.None; 101 | 102 | do 103 | { 104 | time1 += TimeoutStep; 105 | time2 += TimeoutStep; 106 | if (time1 > timeout1 || time2 > timeout2) 107 | { 108 | Log.Write(LogLevel.DEBUG, func + ": Timed out"); 109 | stopped = ExecStop.Timeout; 110 | result.Code = -2; 111 | result.StdErr = "Timed out"; 112 | break; 113 | } 114 | if (cancellationToken != null && ((CancellationToken)cancellationToken).IsCancellationRequested) // todo@ cancellationToken.ThrowIfCancellationRequested() 115 | { 116 | Log.Write(LogLevel.DEBUG, func + ": Cancellation request received"); 117 | stopped = ExecStop.Cancel; 118 | result.Code = -4; 119 | result.StdErr = "Cancelled"; 120 | break; 121 | } 122 | } while (!process.WaitForExit(TimeoutStep)); 123 | 124 | if (stopped == ExecStop.None) 125 | { 126 | process.WaitForExit(); // double checking 127 | process.Refresh(); 128 | 129 | result.Code = process.HasExited ? process.ExitCode : -3; 130 | Log.Write(LogLevel.DEBUG, func + ": Exited (" + result.Code + ")"); 131 | 132 | // Sometimes ExitCode = -1073741819 (caused by LAVSplitter -- check windows Application Log) 133 | //if (result.Code != 0) throw new InvalidOperationException(); 134 | 135 | result.StdOut = stdout.ToString(); 136 | Log.Write(LogLevel.DEBUG, func + ": StdOut=" + result.StdOut); 137 | result.StdErr = stderr.ToString(); 138 | Log.Write(LogLevel.DEBUG, func + ": StdErr=" + result.StdErr); 139 | if (result.Code != 0 && string.IsNullOrEmpty(result.StdErr)) result.StdErr = "Could not get any output"; 140 | } 141 | else 142 | { 143 | process.CancelOutputRead(); 144 | process.CancelErrorRead(); 145 | stdoutWaitHandle.Set(); 146 | stderrWaitHandle.Set(); 147 | Log.Write(LogLevel.DEBUG, func + ": Closing external program"); 148 | if (process.CloseMainWindow()) 149 | { 150 | Log.Write(LogLevel.DEBUG, func + ": External program closed successfully"); 151 | } 152 | else 153 | { 154 | Log.Write(LogLevel.DEBUG, func + ": Unable to close external program. Killing it"); 155 | process.Kill(); 156 | } 157 | process.WaitForExit(); 158 | process.Refresh(); 159 | } 160 | } 161 | catch (Exception e) 162 | { 163 | Log.Write(LogLevel.ERROR, func + ": exception", e.Message); 164 | result.Code = -1; 165 | result.StdErr = e.Message; 166 | stdoutWaitHandle.Set(); 167 | stderrWaitHandle.Set(); 168 | } 169 | finally 170 | { 171 | stdoutWaitHandle.WaitOne(timeout1); 172 | stderrWaitHandle.WaitOne(timeout1); 173 | } 174 | } 175 | } 176 | 177 | Log.Write(LogLevel.DEBUG, func + ": Finished. stdout=" + result.StdOut + ", stderr=" + result.StdErr + " (" + result.Code + ")"); 178 | return result; 179 | } 180 | 181 | 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /UserControls/MediaInfoBox.xaml: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 46 | 47 | 54 | 55 | 56 | 57 | 58 | File 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Video Stream 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Frame 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | Frames based info 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /ProgramOptions.cs: -------------------------------------------------------------------------------- 1 | //using Newtonsoft.Json; 2 | using System; 3 | using System.IO; 4 | using System.Collections.Generic; 5 | 6 | 7 | namespace FFBitrateViewer 8 | { 9 | public class FileItemPO 10 | { 11 | //[JsonProperty("enabled")] 12 | public bool IsEnabled { get; set; } = true; 13 | //[JsonProperty("filespec")] 14 | public string? FS { get; set; } 15 | 16 | public FileItemPO() { } 17 | public FileItemPO(string fs, bool enabled = true) 18 | { 19 | FS = fs; 20 | IsEnabled = enabled; 21 | } 22 | public FileItemPO(FileItem file) 23 | { 24 | FS = file.FS; 25 | IsEnabled = file.IsEnabled; 26 | } 27 | } 28 | 29 | 30 | public class ProgramOptions 31 | { 32 | private static readonly char Separator = '|'; // For serializing lists (Files) 33 | 34 | public bool? AdjustStartTimeOnPlot { get; set; } 35 | public bool? LogCommands { get; set; } 36 | public string? PlotViewType { get; set; } = "frame"; 37 | public string? TempDir { get; set; } 38 | //[JsonProperty("files")] 39 | public List Files { get; set; } 40 | 41 | public ProgramOptions() 42 | { 43 | Files = new List(); 44 | } 45 | 46 | 47 | public void Add(ProgramOptions options) 48 | { 49 | if (options.Files.Count > 0) 50 | { 51 | Files.Clear(); 52 | Files.AddRange(options.Files); 53 | } 54 | if (options.AdjustStartTimeOnPlot != null) AdjustStartTimeOnPlot = options.AdjustStartTimeOnPlot; 55 | if (options.LogCommands != null) LogCommands = options.LogCommands; 56 | if (options.PlotViewType != null) PlotViewType = options.PlotViewType; 57 | } 58 | 59 | 60 | public static ProgramOptions LoadFromSettings() 61 | { 62 | var result = new ProgramOptions(); 63 | 64 | var source = Properties.Settings.Default; 65 | 66 | result.AdjustStartTimeOnPlot = string.Equals(source[nameof(AdjustStartTimeOnPlot)]?.ToString(), "True", StringComparison.OrdinalIgnoreCase); 67 | result.LogCommands = string.Equals(source[nameof(LogCommands)]?.ToString(), "True", StringComparison.OrdinalIgnoreCase); 68 | var plotViewType = NormalizePlotViewType(source[nameof(PlotViewType)].ToString()); 69 | if (!string.IsNullOrEmpty(plotViewType)) result.PlotViewType = plotViewType; 70 | 71 | result.Files.Clear(); 72 | result.Files.AddRange(FilesDeserialize(source[nameof(Files)]?.ToString())); 73 | 74 | return result; 75 | } 76 | 77 | 78 | public void SaveToSettings() 79 | { 80 | var target = Properties.Settings.Default; 81 | 82 | target[nameof(AdjustStartTimeOnPlot)] = AdjustStartTimeOnPlot == true; 83 | target[nameof(LogCommands)] = LogCommands == true; 84 | target[nameof(PlotViewType)] = PlotViewType; 85 | 86 | target[nameof(Files)] = FilesSerialize(Files); 87 | 88 | target.Save(); 89 | } 90 | 91 | 92 | private static string FilesSerialize(List files) 93 | { 94 | var items = new List(); 95 | foreach (var file in files) items.Add((file.IsEnabled ? "1" : "0") + file.FS); 96 | return string.Join(Separator.ToString(), items); 97 | } 98 | 99 | 100 | private static List FilesDeserialize(string? serialized) 101 | { 102 | var result = new List(); 103 | if (serialized != null) 104 | { 105 | string[] lines = serialized.Split(Separator); 106 | foreach (var line in lines) if (!string.IsNullOrEmpty(line)) result.Add(new FileItemPO(line[1..], line[0] == '1')); 107 | } 108 | return result; 109 | } 110 | 111 | 112 | protected static string? NormalizePlotViewType(string? value) 113 | { 114 | value = string.IsNullOrEmpty(value) ? null : value.ToLower(); 115 | switch (value) 116 | { 117 | case "frame": 118 | case "second": 119 | return value; 120 | case "gop": 121 | return "GOP"; 122 | } 123 | return null; 124 | } 125 | } 126 | 127 | 128 | 129 | // FFBitrateViewer.exe 130 | // [-adjust-start-time-on-plot] 131 | // [-exit] 132 | // [-log-commands] 133 | // [-log-level=(DEBUG|ERROR|INFO|WARNING)] 134 | // [-plot-view-type=(frame|second|gop)] 135 | // [-run] 136 | // [-temp-dir=] 137 | // /path/to/file1.mp4 [/path/to/file2.mp4] [...] 138 | 139 | public class ArgsOptions : ProgramOptions 140 | { 141 | public bool IsFilled { get { return Files.Count > 0; } } 142 | public LogLevel LogLevel { get; set; } = LogLevel.INFO; 143 | public bool Exit { get; set; } 144 | public bool Run { get; set; } 145 | 146 | public ArgsOptions(string[] args) : base() 147 | { 148 | int count = args.Length; 149 | int i = 1; /*skipping executable path in 0*/ 150 | for (; i < count; ++i) 151 | { 152 | string s = args[i]; 153 | if (s.StartsWith("--")) 154 | { 155 | s = s[2..]; 156 | } 157 | else if (s[0] == '-' || s[0] == '/') 158 | { 159 | s = s[1..]; 160 | } else { 161 | break; // Not an option -- exiting options processing loop. The next parameters will be treated as file name 162 | } 163 | 164 | var parts = s.Split('='); 165 | string? svalue = (parts.Length > 1) ? parts[1].Trim() : null; 166 | bool bvalue = string.IsNullOrEmpty(svalue) || string.Equals(svalue, "1") || string.Equals(svalue, "true", StringComparison.OrdinalIgnoreCase); 167 | 168 | switch (parts[0].ToUpper()) 169 | { 170 | case "ADJUST-START-TIME-ON-PLOT": 171 | AdjustStartTimeOnPlot = bvalue; 172 | continue; 173 | 174 | case "EXIT": 175 | Exit = bvalue; 176 | continue; 177 | 178 | case "LOG-COMMANDS": 179 | LogCommands = bvalue; 180 | continue; 181 | 182 | case "LOG-LEVEL": 183 | if (!string.IsNullOrEmpty(svalue)) 184 | { 185 | switch (svalue.ToUpper()) 186 | { 187 | case "DEBUG": 188 | LogLevel = LogLevel.DEBUG; 189 | continue; 190 | case "ERROR": 191 | LogLevel = LogLevel.ERROR; 192 | continue; 193 | case "INFO": 194 | LogLevel = LogLevel.INFO; 195 | continue; 196 | case "WARNING": 197 | LogLevel = LogLevel.WARNING; 198 | break; 199 | } 200 | } 201 | // todo@ warning? 202 | break; 203 | 204 | case "PLOT-VIEW-TYPE": 205 | var plotViewType = NormalizePlotViewType(svalue); 206 | if (!string.IsNullOrEmpty(plotViewType)) 207 | { 208 | PlotViewType = plotViewType; 209 | continue; 210 | } 211 | break; // warning? 212 | 213 | case "RUN": 214 | Run = bvalue; 215 | continue; 216 | 217 | case "TEMP-DIR": 218 | if (!string.IsNullOrEmpty(svalue)) 219 | { 220 | svalue = svalue.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; 221 | if (!Directory.Exists(svalue)) Directory.CreateDirectory(svalue); 222 | TempDir = svalue; 223 | continue; 224 | } 225 | break; // warning? 226 | } 227 | } 228 | 229 | while (i < count) Files.Add(new FileItemPO(args[i++])); 230 | } 231 | } 232 | 233 | 234 | 235 | } 236 | -------------------------------------------------------------------------------- /Helpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Runtime.InteropServices; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Windows; 10 | using System.Windows.Media; 11 | 12 | namespace FFBitrateViewer 13 | { 14 | public enum SubstType 15 | { 16 | CMD, 17 | NONE 18 | }; 19 | 20 | 21 | class Helpers 22 | { 23 | private static readonly NumberStyles DoubleStyle = NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowLeadingWhite; 24 | private static readonly Regex RemoveUnusedRegex2 = new Regex(@"(?:^| )-[a-zA-Z_:-]+ ('?)\{\{[^\}]+\}\}\1(?=$| )", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); 25 | //private static readonly Regex CommaSplitRegex = new Regex(@"((?:[^,\(]+(?:\([^\)]*\))?)+)(?:,|$)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); 26 | private static readonly char[] DirSeparators = { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; 27 | private static readonly Regex RemoveZeroes = new(@"\.?0+$"); 28 | public static readonly int Threads = (Environment.ProcessorCount > 2) ? Environment.ProcessorCount - 1 : 0; 29 | 30 | 31 | public static string Subst(string template, Dictionary pairs, SubstType substType) 32 | { 33 | string s = template; 34 | 35 | foreach (var pair in pairs) s = s.Replace("{{" + pair.Key + "}}", pair.Value); 36 | 37 | switch (substType) 38 | { 39 | case SubstType.CMD: 40 | s = RemoveUnusedRegex2.Replace(s, ""); 41 | s = s.Replace(",null", "").Replace("null,", ""); 42 | break; 43 | 44 | case SubstType.NONE: 45 | // Do nothing 46 | break; 47 | } 48 | 49 | return s.Trim(); 50 | } 51 | 52 | 53 | public static string NormalizeDirSpec(string ds) 54 | { 55 | return string.IsNullOrEmpty(ds) ? "" : ds.TrimEnd(DirSeparators) + Path.DirectorySeparatorChar; 56 | } 57 | 58 | 59 | public static string WindowsPath2UnixPath(string? fs, string? escape = null) 60 | { 61 | // fs = @"C:\path\to\file.ext"; 62 | // fs = @"\\bin\shared\path\to\file.txt"; 63 | if (string.IsNullOrEmpty(fs)) return ""; 64 | string? root = Path.GetPathRoot(fs)?.TrimEnd(DirSeparators).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // => "C:" 65 | string? path = (root == null) ? fs : fs[root.Length..]; // => "/path/to/file.ext" 66 | if (root != null && !string.IsNullOrEmpty(escape)) root = root.Replace(@":", escape + @":"); 67 | return root + path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // => C:/path/to/file.txt 68 | } 69 | 70 | 71 | public static Dictionary ParseLine(string line, char pairsSeparator = ' ', char kvSeparator = ':', bool uppercaseKey = false) 72 | { 73 | // Not StringDictionary as it does not preserve keys' case 74 | var result = new Dictionary(); 75 | 76 | string l = line.Trim(); 77 | if (!string.IsNullOrEmpty(l)) 78 | { 79 | string[] pairs = line.Split(pairsSeparator); 80 | 81 | foreach (string pair in pairs) 82 | { 83 | string s = pair.Trim(); 84 | if (string.IsNullOrEmpty(s)) continue; 85 | 86 | int p = s.IndexOf(kvSeparator); 87 | if (p > 0 && p < s.Length - 1) 88 | { 89 | string k = s.Substring(0, p).Trim(); 90 | string v = s[(p + 1)..].Trim(); 91 | result.Add(uppercaseKey ? k.ToUpper() : k, v); 92 | } 93 | } 94 | } 95 | 96 | return result; 97 | } 98 | 99 | 100 | public static bool TryParseDouble(string s, out double result, bool withInfinity = false) 101 | { 102 | if (withInfinity) 103 | { 104 | if (s.StartsWith("inf", StringComparison.OrdinalIgnoreCase) || s.StartsWith("+inf", StringComparison.OrdinalIgnoreCase)) 105 | { 106 | result = double.PositiveInfinity; 107 | return true; 108 | } 109 | if (s.StartsWith("-inf", StringComparison.OrdinalIgnoreCase)) 110 | { 111 | result = double.NegativeInfinity; 112 | return true; 113 | } 114 | } 115 | 116 | if (double.TryParse(s, DoubleStyle, CultureInfo.CurrentCulture, out double d)) 117 | { 118 | result = d; 119 | return true; 120 | } 121 | 122 | if (double.TryParse(s, DoubleStyle, CultureInfo.InvariantCulture, out d)) 123 | { 124 | result = d; 125 | return true; 126 | } 127 | 128 | result = 0; 129 | return false; 130 | } 131 | 132 | 133 | // https://blog.codinghorror.com/shortening-long-file-paths/ 134 | [DllImport("shlwapi.dll", CharSet = CharSet.Auto)] 135 | private static extern bool PathCompactPathEx([Out] StringBuilder pszOut, string szPath, int cchMax, int dwFlags); 136 | 137 | public static string PathShortener(string path, int length) 138 | { 139 | StringBuilder sb = new(length + 1); 140 | PathCompactPathEx(sb, path, length, 0); 141 | return sb.ToString(); 142 | } 143 | 144 | 145 | public static List Split(string s, char separator = ',') 146 | { 147 | var result = new List(); 148 | foreach (var x in s.Split(separator)) result.Add(x.Trim()); 149 | return result; 150 | } 151 | 152 | 153 | public static bool IsIntegralNumber(ref object value) 154 | { 155 | return value is sbyte 156 | || value is byte 157 | || value is short 158 | || value is ushort 159 | || value is int 160 | || value is uint 161 | || value is long 162 | || value is ulong 163 | ; 164 | } 165 | 166 | 167 | public static bool IsFloatingPointNumber(ref object value) 168 | { 169 | return value is float 170 | || value is double 171 | || value is decimal 172 | ; 173 | } 174 | 175 | public static bool IsNumber(ref object value) 176 | { 177 | return IsIntegralNumber(ref value) || IsFloatingPointNumber(ref value); 178 | } 179 | 180 | 181 | [DllImport("Shlwapi.dll", CharSet = CharSet.Unicode)] 182 | public static extern uint AssocQueryString( 183 | AssocF flags, 184 | AssocStr str, 185 | string pszAssoc, 186 | string? pszExtra, 187 | [Out] StringBuilder? pszOut, 188 | ref uint pcchOut 189 | ); 190 | 191 | [Flags] 192 | public enum AssocF 193 | { 194 | None = 0, 195 | Init_NoRemapCLSID = 0x1, 196 | Init_ByExeName = 0x2, 197 | Open_ByExeName = 0x2, 198 | Init_DefaultToStar = 0x4, 199 | Init_DefaultToFolder = 0x8, 200 | NoUserSettings = 0x10, 201 | NoTruncate = 0x20, 202 | Verify = 0x40, 203 | RemapRunDll = 0x80, 204 | NoFixUps = 0x100, 205 | IgnoreBaseClass = 0x200, 206 | Init_IgnoreUnknown = 0x400, 207 | Init_Fixed_ProgId = 0x800, 208 | Is_Protocol = 0x1000, 209 | Init_For_File = 0x2000 210 | } 211 | 212 | public enum AssocStr 213 | { 214 | Command = 1, 215 | Executable, 216 | FriendlyDocName, 217 | FriendlyAppName, 218 | NoOpen, 219 | ShellNewValue, 220 | DDECommand, 221 | DDEIfExec, 222 | DDEApplication, 223 | DDETopic, 224 | InfoTip, 225 | QuickTip, 226 | TileInfo, 227 | ContentType, 228 | DefaultIcon, 229 | ShellExtension, 230 | DropTarget, 231 | DelegateExecute, 232 | Supported_Uri_Protocols, 233 | ProgID, 234 | AppID, 235 | AppPublisher, 236 | AppIconReference, 237 | Max 238 | } 239 | 240 | public static string? GetAssociatedProgram(string extension) 241 | { 242 | const int S_OK = 0; 243 | const int S_FALSE = 1; 244 | 245 | try 246 | { 247 | uint length = 0; 248 | uint ret = AssocQueryString(AssocF.None, AssocStr.Executable, extension, null, null, ref length); 249 | if (ret != S_FALSE) throw new InvalidOperationException("Could not determine associated string"); 250 | 251 | var sb = new StringBuilder((int)length); // (length-1) will probably work too as the marshaller adds null termination 252 | ret = AssocQueryString(AssocF.None, AssocStr.Executable, extension, null, sb, ref length); 253 | if (ret != S_OK) throw new InvalidOperationException("Could not determine associated string"); 254 | return sb.ToString(); 255 | } 256 | catch (Exception) { } 257 | return null; 258 | } 259 | 260 | 261 | public static void RunAssociatedSystemProgram(string? fs) 262 | { 263 | if (string.IsNullOrEmpty(fs)) return; 264 | Process.Start(new ProcessStartInfo(fs) { UseShellExecute = true }); 265 | } 266 | 267 | public static string RemoveTrailingZeroes(string s) 268 | { 269 | return RemoveZeroes.Replace(s, ""); 270 | } 271 | 272 | } 273 | 274 | 275 | public static class Extensions 276 | { 277 | public static T? GetProperty(this object obj, string propName) 278 | { 279 | return (T?)obj.GetType().GetProperty(propName)?.GetValue(obj, null); 280 | } 281 | 282 | 283 | public static void SetProperty(this object obj, string propName, object? value) 284 | { 285 | obj.GetType().GetProperty(propName)?.SetValue(obj, value); 286 | } 287 | 288 | 289 | public static void AddRange(this IDictionary source, IDictionary collection) 290 | { 291 | if (collection == null) throw new ArgumentNullException("Collection is null"); // todo@ should we silently return 292 | 293 | foreach (var item in collection) if (!source.ContainsKey(item.Key)) source.Add(item.Key, item.Value); 294 | } 295 | 296 | 297 | // Helper to search up the VisualTree 298 | public static T? FindAncestor(this DependencyObject current) where T : DependencyObject 299 | { 300 | do 301 | { 302 | if (current is T t) return t; 303 | current = VisualTreeHelper.GetParent(current); 304 | } 305 | while (current != null); 306 | return null; 307 | } 308 | 309 | 310 | // Helper to search value for element up the VisualTree 311 | public static void SetAncestorValue(this DependencyObject child, DependencyProperty property, object value) where TParent : DependencyObject 312 | { 313 | TParent? parent = child.FindAncestor(); 314 | parent?.SetValue(property, value); 315 | } 316 | 317 | } 318 | 319 | } 320 | -------------------------------------------------------------------------------- /FFProbe.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Globalization; 5 | 6 | namespace FFBitrateViewer 7 | { 8 | public class FFProbeFormat 9 | { 10 | [JsonProperty("bit_rate")] 11 | public long? BitRate { get; set; } 12 | 13 | /// Approximate duration in seconds (stream can start *after* the 00:00:00 timecode). 14 | [JsonProperty("duration")] 15 | public double? Duration { get; set; } 16 | 17 | [JsonProperty("filename")] 18 | public string? FileName { get; set; } 19 | 20 | [JsonProperty("format_long_name")] 21 | public string? FormatLongName { get; set; } 22 | 23 | [JsonProperty("format_name")] 24 | public string? FormatName { get; set; } 25 | 26 | [JsonProperty("probe_score")] 27 | public int? ProbeScore { get; set; } 28 | 29 | [JsonProperty("nb_programs")] 30 | public int? ProgramsCount { get; set; } 31 | 32 | [JsonProperty("size")] 33 | public long? Size { get; set; } 34 | 35 | [JsonProperty("start_time")] 36 | public double? StartTime { get; set; } 37 | 38 | [JsonProperty("nb_streams")] 39 | public int? StreamsCount { get; set; } 40 | 41 | /// Container and format tags/metadata, not stream-specific tags. 42 | [JsonProperty("tags")] 43 | public Dictionary? Tags { get; set; } 44 | 45 | [JsonExtensionData] 46 | public Dictionary? ExtensionData { get; set; } 47 | } 48 | 49 | 50 | public class FFProbeFrame 51 | { 52 | [JsonProperty("best_effort_timestamp")] 53 | public int? BestEffortTimestamp { get; set; } 54 | 55 | [JsonProperty("best_effort_timestamp_time")] 56 | public double? BestEffortTimestampTime { get; set; } 57 | 58 | [JsonProperty("channel_layout")] 59 | public string? ChannelLayout { get; set; } 60 | 61 | [JsonProperty("channels")] 62 | public int? Channels { get; set; } 63 | 64 | [JsonProperty("coded_picture_number")] 65 | public int? CodedPictureNumber { get; set; } 66 | 67 | [JsonProperty("chroma_location")] 68 | public string? ChromaLocation { get; set; } 69 | 70 | [JsonProperty("display_picture_number")] 71 | public int? DisplayPictureNumber { get; set; } 72 | 73 | [JsonProperty("pkt_dts")] 74 | public int? DTS { get; set; } 75 | 76 | [JsonProperty("pkt_dts_time")] 77 | public double? DTSTime { get; set; } 78 | 79 | [JsonProperty("pkt_duration")] 80 | public int? Duration { get; set; } 81 | 82 | [JsonProperty("pkt_duration_time")] 83 | public double? DurationTime { get; set; } 84 | 85 | [JsonProperty("height")] 86 | public int? Height { get; set; } 87 | 88 | [JsonProperty("interlaced_frame")] 89 | public bool? InterlacedFrame { get; set; } // todo@ type 90 | 91 | [JsonProperty("key_frame")] 92 | public bool? IsKeyFrame { get; set; } // todo@ type 93 | 94 | [JsonProperty("media_type")] 95 | public string? MediaType { get; set; } 96 | 97 | [JsonProperty("nb_samples")] 98 | public int? NBSamples { get; set; } 99 | 100 | [JsonProperty("pict_type")] 101 | public string? PictType { get; set; } // I, P, B 102 | 103 | [JsonProperty("pix_fmt")] 104 | public string? PixFmt { get; set; } 105 | 106 | // Can be ordered by the field if no time 107 | [JsonProperty("pkt_pos")] 108 | public long? Pos { get; set; } 109 | 110 | [JsonProperty("pkt_pts")] 111 | public int? PTS { get; set; } 112 | 113 | [JsonProperty("pkt_pts_time")] 114 | public double? PTSTime { get; set; } 115 | 116 | [JsonProperty("repeat_pict")] 117 | public bool? RepeatPict { get; set; } // todo@ type 118 | 119 | [JsonProperty("sample_aspect_ratio")] 120 | public string? SAR { get; set; } 121 | 122 | [JsonProperty("pkt_size")] 123 | public int? Size { get; set; } 124 | 125 | [JsonProperty("sample_fmt")] 126 | public string? SampleFmt { get; set; } 127 | 128 | [JsonProperty("stream_index")] 129 | public int? StreamIndex { get; set; } 130 | 131 | [JsonProperty("top_field_first")] 132 | public bool? TopFieldFirst { get; set; } // todo@ type 133 | 134 | [JsonProperty("width")] 135 | public int? Width { get; set; } 136 | 137 | [JsonExtensionData] 138 | public Dictionary? ExtensionData { get; set; } 139 | } 140 | 141 | 142 | public class FFProbePacket 143 | { 144 | [JsonProperty("codec_type")] 145 | public string? CodecType { get; set; } 146 | 147 | // decoding time stamp -- how packets stored in stream 148 | [JsonProperty("dts")] 149 | public int? DTS { get; set; } 150 | 151 | [JsonProperty("dts_time")] 152 | public double? DTSTime { get; set; } 153 | 154 | [JsonProperty("duration")] 155 | public int? Duration { get; set; } 156 | 157 | [JsonProperty("duration_time")] 158 | public double? DurationTime { get; set; } 159 | 160 | [JsonProperty("flags")] 161 | public string? Flags { get; set; } 162 | 163 | // Can be ordered by the field if no time 164 | [JsonProperty("pos")] 165 | public long? Pos { get; set; } 166 | 167 | // presentation time stamp -- how packets should be displayed 168 | [JsonProperty("pts")] 169 | public int? PTS { get; set; } 170 | 171 | [JsonProperty("pts_time")] 172 | public double? PTSTime { get; set; } 173 | 174 | [JsonProperty("size")] 175 | public int? Size { get; set; } 176 | 177 | [JsonProperty("stream_index")] 178 | public int? StreamIndex { get; set; } 179 | 180 | [JsonExtensionData] 181 | public Dictionary? ExtensionData { get; set; } 182 | } 183 | 184 | 185 | public class FFProbeStream 186 | { 187 | [JsonProperty("bit_rate")] 188 | public long? BitRate { get; set; } 189 | 190 | [JsonProperty("bits_per_sample")] 191 | public int? BitsPerSample { get; set; } 192 | 193 | [JsonProperty("bits_per_raw_sample")] 194 | public string? BitsPerSampleRaw { get; set; } 195 | 196 | [JsonProperty("channel_layout")] 197 | public string? ChannelLayout { get; set; } 198 | 199 | [JsonProperty("channels")] 200 | public int? Channels { get; set; } 201 | 202 | [JsonProperty("chroma_location")] 203 | public string? ChromaLocation { get; set; } 204 | 205 | [JsonProperty("codec_name")] 206 | public string? CodecName { get; set; } 207 | 208 | /// H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 209 | [JsonProperty("codec_long_name")] 210 | public string? CodecLongName { get; set; } 211 | 212 | [JsonProperty("codec_type")] 213 | public string? CodecType { get; set; } 214 | 215 | [JsonProperty("codec_tag")] 216 | public string? CodecTag { get; set; } 217 | 218 | /// Video codec's FourCC or audio codec's TwoCC 219 | [JsonProperty("codec_tag_string")] 220 | public string? CodecTagString { get; set; } 221 | 222 | [JsonProperty("coded_height")] 223 | public int? CodedHeight { get; set; } 224 | 225 | [JsonProperty("coded_width")] 226 | public int? CodedWidth { get; set; } 227 | 228 | [JsonProperty("color_range")] 229 | public string? ColorRange { get; set; } 230 | 231 | [JsonProperty("display_aspect_ratio")] 232 | public string? DAR { get; set; } 233 | 234 | [JsonProperty("duration")] 235 | public double? Duration { get; set; } 236 | 237 | /// Duration expressed in integer time-base units (https://video.stackexchange.com/questions/27546/difference-between-duration-ts-and-duration-in-ffprobe-output 238 | [JsonProperty("duration_ts")] 239 | public long? DurationTS { get; set; } 240 | 241 | [JsonProperty("field_order")] 242 | public string? FieldOrder { get; set; } 243 | 244 | [JsonProperty("nb_frames")] 245 | public int? FramesCount { get; set; } 246 | 247 | [JsonProperty("r_frame_rate")] 248 | public string? FrameRateR { get; set; } 249 | 250 | [JsonProperty("avg_frame_rate")] 251 | public string? FrameRateAvg { get; set; } 252 | 253 | [JsonProperty("height")] 254 | public int? Height { get; set; } 255 | 256 | [JsonProperty("id")] 257 | public string? Id { get; set; } 258 | 259 | [JsonProperty("index")] 260 | public int Index { get; set; } 261 | 262 | [JsonProperty("is_avc")] 263 | public bool? IsAVC { get; set; } 264 | 265 | [JsonProperty("has_b_frames")] 266 | public bool? IsHasBFrames { get; set; } // todo@ type 267 | 268 | [JsonProperty("level")] 269 | public int? Level { get; set; } 270 | 271 | [JsonProperty("nb_packets")] 272 | public int? PacketsCount { get; set; } 273 | 274 | [JsonProperty("pix_fmt")] 275 | public string? PixFmt { get; set; } 276 | 277 | [JsonProperty("profile")] 278 | public string? Profile { get; set; } 279 | 280 | [JsonProperty("refs")] 281 | public int? Refs { get; set; } 282 | 283 | [JsonProperty("sample_fmt")] 284 | public string? SampleFormat { get; set; } 285 | 286 | [JsonProperty("sample_rate")] 287 | public int? SampleRate { get; set; } 288 | 289 | [JsonProperty("sample_aspect_ratio")] 290 | public string? SAR { get; set; } 291 | 292 | [JsonProperty("start_pts")] 293 | public long? StartPTS { get; set; } 294 | 295 | [JsonProperty("start_time")] 296 | public double? StartTime { get; set; } 297 | 298 | /// Stream-specific tags/metadata. See . 299 | [JsonProperty("tags")] 300 | public Dictionary? Tags { get; set; } 301 | 302 | /// Values like "1/600". See https://stackoverflow.com/questions/43333542/what-is-video-timescale-timebase-or-timestamp-in-ffmpeg 303 | [JsonProperty("time_base")] 304 | public string? TimeBase { get; set; } 305 | 306 | [JsonProperty("width")] 307 | public int? Width { get; set; } 308 | 309 | [JsonExtensionData] 310 | public Dictionary? ExtensionData { get; set; } 311 | } 312 | 313 | 314 | /// Known tag-names for 's . 315 | public static class KnownFFProbeVideoStreamTags 316 | { 317 | /// Tag value is ISO 8601 datetime 318 | public const string CreationTime = "creation_time"; 319 | 320 | /// Values like H.264 321 | public const string Encoder = "encoder"; 322 | 323 | /// Tag value is a decimal integer value in degrees, e.g. "90". 324 | public const string Rotate = "rotate"; 325 | } 326 | 327 | 328 | public class FFProbeJsonOutput 329 | { 330 | /// Information about the container 331 | [JsonProperty("format")] 332 | public FFProbeFormat? Format { get; set; } 333 | 334 | /// Information about frames 335 | //[JsonProperty("frames")] 336 | //public List? Frames { get; set; } 337 | 338 | /// Information about packets 339 | [JsonProperty("packets")] 340 | public List? Packets { get; set; } 341 | 342 | /// Information about streams 343 | [JsonProperty("streams")] 344 | public List? Streams { get; set; } 345 | 346 | [JsonExtensionData] 347 | public Dictionary? ExtensionData { get; set; } 348 | } 349 | 350 | 351 | public static class FFProbeOutputExtensions 352 | { 353 | public static FFProbeStream? GetFirstVideoStream(this FFProbeJsonOutput ffProbeOutput) 354 | { 355 | return GetFirstStreamByType(ffProbeOutput, "video"); 356 | } 357 | 358 | 359 | public static FFProbeStream? GetFirstAudioStream(this FFProbeJsonOutput ffProbeOutput) 360 | { 361 | return GetFirstStreamByType(ffProbeOutput, "audio"); 362 | } 363 | 364 | 365 | public static FFProbeStream? GetFirstStreamByType(this FFProbeJsonOutput ffProbeOutput, string type) 366 | { 367 | if (ffProbeOutput.Streams != null) 368 | { 369 | foreach (var stream in ffProbeOutput.Streams) 370 | { 371 | if (string.Equals(stream.CodecType, type, StringComparison.OrdinalIgnoreCase)) return stream; 372 | } 373 | } 374 | return null; 375 | } 376 | 377 | 378 | public static int? GetRotation(this FFProbeStream ffProbeStream) 379 | { 380 | if (ffProbeStream.Tags != null) 381 | { 382 | if (ffProbeStream.Tags.TryGetValue(KnownFFProbeVideoStreamTags.Rotate, out string? rotateTagValue)) 383 | { 384 | if (int.TryParse(rotateTagValue, NumberStyles.Any, CultureInfo.InvariantCulture, out int rotateTagValueInt)) return rotateTagValueInt; 385 | } 386 | } 387 | 388 | return null; 389 | } 390 | 391 | 392 | /// If the has a duration, that's returned - otherwise max first stream duration is returned. Returns null if none of the streams (nor the ) has a duration value set. 393 | public static double? GetDuration(this FFProbeJsonOutput ffProbeOutput) 394 | { 395 | double? result = null; 396 | if (ffProbeOutput.Format != null && ffProbeOutput.Format.Duration.HasValue) result = ffProbeOutput.Format.Duration.Value; 397 | 398 | if (result == null && ffProbeOutput.Streams != null) 399 | { 400 | foreach (FFProbeStream stream in ffProbeOutput.Streams) 401 | { 402 | double? duration = stream.Duration; 403 | double start = stream.StartTime.HasValue ? (double)stream.StartTime : 0; 404 | // todo@ should start is taken into consideration? 405 | if (duration.HasValue && (result == null && (duration + start) > result)) result = duration + start; 406 | } 407 | } 408 | 409 | return result; 410 | } 411 | } 412 | 413 | } 414 | -------------------------------------------------------------------------------- /MyPlotModel.cs: -------------------------------------------------------------------------------- 1 | using OxyPlot; 2 | using OxyPlot.Annotations; 3 | using OxyPlot.Axes; 4 | using OxyPlot.Series; 5 | using OxyPlot.Legends; 6 | using System.Collections.Generic; 7 | 8 | namespace FFBitrateViewer 9 | { 10 | /* 11 | public class CustomDataPoint : IDataPointProvider 12 | { 13 | public double X { get; set; } 14 | public double Y { get; set; } 15 | public string FrameType { get; set; } 16 | public DataPoint GetDataPoint() => new(X, Y); 17 | 18 | public CustomDataPoint(double x, double y) 19 | { 20 | X = x; 21 | Y = y; 22 | FrameType = "X"; 23 | } 24 | } 25 | */ 26 | 27 | public class SerieStyle 28 | { 29 | public OxyColor Color = OxyColors.Black; 30 | public LineStyle LineStyle = LineStyle.Solid; 31 | public MarkerType MarkerType = MarkerType.None; 32 | 33 | public SerieStyle() { } 34 | 35 | 36 | public SerieStyle(OxyColor color, LineStyle? style = null) 37 | { 38 | Color = color; 39 | if(style != null) LineStyle = (LineStyle)style; 40 | } 41 | 42 | 43 | public SerieStyle(string color, string? style = null) 44 | { 45 | Color = OxyColor.Parse(color); 46 | if(style != null) LineStyleSet(style); 47 | } 48 | 49 | 50 | public string ColorToString() 51 | { 52 | return Color.ToString(); 53 | } 54 | 55 | 56 | public void ColorSet(string color) 57 | { 58 | Color = OxyColor.Parse(color); 59 | } 60 | 61 | 62 | public string LineStyleToString() 63 | { 64 | return LineStyle.ToString(); 65 | } 66 | 67 | 68 | public void LineStyleSet(string style) 69 | { 70 | switch (style.ToUpper()) 71 | { 72 | case "DASH": 73 | LineStyle = LineStyle.Dash; 74 | break; 75 | case "DOT": 76 | LineStyle = LineStyle.Dot; 77 | break; 78 | case "SOLID": 79 | LineStyle = LineStyle.Solid; 80 | break; 81 | default: 82 | LineStyle = LineStyle.Solid; // todo@ exception 83 | break; 84 | } 85 | } 86 | } 87 | 88 | 89 | public class SeriesParams 90 | { 91 | public SerieStyle SerieStyle = new(); 92 | public string Color { 93 | get { return SerieStyle.ColorToString(); } 94 | set { SerieStyle.ColorSet(value); } 95 | } 96 | public string Style { 97 | get { return SerieStyle.LineStyleToString(); } 98 | set { SerieStyle.LineStyleSet(value); } 99 | } 100 | } 101 | 102 | 103 | public class PlotParams 104 | { 105 | public List Series { get; set; } = new List(); 106 | } 107 | 108 | 109 | public class MyPlotModel : PlotModel 110 | { 111 | /* Named colors to consider: 112 | 000000 Black 113 | 0000FF Blue 114 | 8A2BE2 BlueViolet 115 | A52A2A Brown 116 | 7FFF00 Charteuse 117 | D2691E Chocolate 118 | DC143C Crimson 119 | 00FFFF Aqua/Cyan 120 | 00008B DarkBlue 121 | 008B8B DarkCyan 122 | B8860B DarkGoldenrod 123 | A9A9A9 DarkGray 124 | 006400 DarkGreen 125 | 8B008B DarkMagenta 126 | FF8C00 DarkOrange 127 | 9932CC DarkOrchid 128 | 8B0000 DarkRed 129 | 00CED1 DarkTurquoise - 130 | 9400D3 DarkViolet 131 | FF1493 DeepPink 132 | 00BFFF DeepSkyBlue 133 | 696969 DimGray 134 | 1E90FF DodgerBlue 135 | B22222 Firebrick 136 | 228B22 ForestGreen 137 | FF00FF Magenta/Fuchsia 138 | FFD700 Gold 139 | DAA520 Goldenrod 140 | 808080 Gray 141 | 008000 Green 142 | 4B0082 Indigo 143 | 7CFC00 LawnGreen 144 | D3D3D3 LightGray 145 | 20B2AA LightSeaGreen 146 | 00FF00 Lime 147 | 32CD32 LimeGreen 148 | 0000CD MediumBlue 149 | C71585 MediumVioletRed 150 | 000080 Navy 151 | 808000 Olive 152 | FFA500 Orange 153 | FF4500 OrangeRed 154 | CD853F Peru 155 | 800080 Purple 156 | FF0000 Red 157 | 4169E1 RoyalBlue 158 | 8B4513 SaddleBrown 159 | 2E8B57 SeaGreen 160 | A0522D Sienna 161 | 00FF7F SpringGreen 162 | D2B48C Tan 163 | 008080 Teal 164 | 40E0D0 Turquoise 165 | EE82EE Violet 166 | FFFF00 Yellow 167 | 9ACD32 YellowGreen 168 | */ 169 | 170 | public static List PlotStyles = new() { 171 | new SerieStyle(OxyColors.ForestGreen), 172 | new SerieStyle(OxyColors.Red), 173 | new SerieStyle(OxyColors.Blue), 174 | 175 | new SerieStyle(OxyColors.Orange), 176 | new SerieStyle(OxyColors.Magenta), 177 | new SerieStyle(OxyColors.DarkTurquoise), 178 | 179 | new SerieStyle(OxyColors.Chocolate), 180 | new SerieStyle(OxyColors.DarkViolet), 181 | new SerieStyle(OxyColors.DodgerBlue), 182 | new SerieStyle(OxyColors.Lime), 183 | 184 | new SerieStyle(OxyColors.DarkGray), 185 | new SerieStyle(OxyColors.Black), 186 | /* 187 | new SerieStyle(OxyColors.ForestGreen, LineStyle.Dot), 188 | new SerieStyle(OxyColors.Red, LineStyle.Dot), 189 | new SerieStyle(OxyColors.Blue, LineStyle.Dot), 190 | 191 | new SerieStyle(OxyColors.Orange, LineStyle.Dot), 192 | new SerieStyle(OxyColors.Magenta, LineStyle.Dot), 193 | new SerieStyle(OxyColors.DarkTurquoise, LineStyle.Dot), 194 | 195 | new SerieStyle(OxyColors.Chocolate, LineStyle.Dot), 196 | new SerieStyle(OxyColors.DarkViolet, LineStyle.Dot), 197 | new SerieStyle(OxyColors.DodgerBlue, LineStyle.Dot), 198 | new SerieStyle(OxyColors.Lime, LineStyle.Dot), 199 | 200 | new SerieStyle(OxyColors.DarkGray, LineStyle.Dot), 201 | new SerieStyle(OxyColors.Black, LineStyle.Dot) 202 | */ 203 | }; 204 | 205 | public string Name { get; set; } 206 | public PlotController Controller { get; private set; } 207 | public string PlotViewType { get; set; } 208 | 209 | public MyPlotModel(string name) : base() 210 | { 211 | Name = name; 212 | PlotViewType = "frame"; 213 | PlotMargins = new OxyThickness(45, 10, 8, 35); 214 | 215 | // Setting white background as with transparent (default) image cannot be copied to Clipboard bacause of WPF bug: https://github.com/oxyplot/oxyplot/issues/17 216 | Background = OxyColors.White; 217 | 218 | Legends.Add(new Legend 219 | { 220 | LegendPosition = LegendPosition.BottomRight, 221 | LegendPlacement = LegendPlacement.Outside, 222 | LegendOrientation = LegendOrientation.Horizontal, 223 | LegendMargin = 0, 224 | LegendPadding = 0, 225 | LegendBackground = OxyColor.FromAColor(200, OxyColors.White), 226 | LegendBorder = OxyColors.Black, 227 | LegendBorderThickness = 0, 228 | ShowInvisibleSeries = false, 229 | //LegendFont = "Arial" // todo@ Chineese characters not rendered correctly 230 | }); 231 | 232 | // 0 -- X (time) 233 | Axes.Add(new TimeSpanAxis 234 | { 235 | AbsoluteMaximum = 1, // Will be adjusted automatically while getting data 236 | AbsoluteMinimum = 0, 237 | Maximum = 1, // Will be adjusted automatically while getting data 238 | Minimum = 0, 239 | MaximumPadding = 0, 240 | MinimumPadding = 0, 241 | MinimumRange = 0.5, 242 | MajorGridlineColor = OxyColor.FromArgb(40, 0, 0, 139), 243 | MajorGridlineStyle = LineStyle.Solid, 244 | MinorGridlineColor = OxyColor.FromArgb(20, 0, 0, 139), 245 | MinorGridlineStyle = LineStyle.Dot, 246 | Position = AxisPosition.Bottom, 247 | StringFormat = "h:mm:ss" 248 | }); 249 | 250 | // 1 -- Y (size/bitrate) 251 | Axes.Add(new LinearAxis 252 | { 253 | AbsoluteMaximum = 1, // Will be adjusted automatically while getting data 254 | AbsoluteMinimum = 0, 255 | Angle = -90, 256 | Maximum = 1, // Will be adjusted automatically while getting data 257 | Minimum = 0, 258 | MaximumPadding = 0, 259 | MinimumPadding = 0, 260 | MaximumDataMargin = 5, // Distance above max Y-value to plot's edge 261 | MinimumRange = 1, 262 | MajorGridlineColor = OxyColor.FromArgb(40, 0, 0, 139), 263 | MajorGridlineStyle = LineStyle.Solid, 264 | MinorGridlineColor = OxyColor.FromArgb(20, 0, 0, 139), 265 | MinorGridlineStyle = LineStyle.Dot, 266 | Position = AxisPosition.Left 267 | }); 268 | 269 | // Customizing controller to show Graph ToolTip on mouse hover instead of mouse click 270 | // https://stackoverflow.com/a/34899746/4655944 271 | var controller = new PlotController(); 272 | controller.UnbindMouseDown(OxyMouseButton.Left); 273 | controller.BindMouseEnter(PlotCommands.HoverSnapTrack); 274 | Controller = controller; 275 | } 276 | 277 | 278 | public void Redraw() 279 | { 280 | InvalidatePlot(true); 281 | } 282 | 283 | 284 | public void LineAnnotationAdd(double maxX, int y, string text, OxyColor color) 285 | { 286 | var annotation = new LineAnnotation(); 287 | annotation.Type = LineAnnotationType.Horizontal; 288 | annotation.TextVerticalAlignment = VerticalAlignment.Bottom; 289 | annotation.TextLinePosition = 0.04; 290 | annotation.Y = y; 291 | annotation.MaximumX = maxX; 292 | annotation.Color = color; 293 | annotation.Text = text; 294 | Annotations.Add(annotation); 295 | } 296 | 297 | 298 | public void LineAnnotationsClear() 299 | { 300 | Annotations.Clear(); 301 | } 302 | 303 | 304 | public bool AxisMaximumSet(int index, double? value = null) 305 | { 306 | if (index < 0 || index >= Axes.Count) return false; 307 | if (value == null) 308 | { 309 | Axes[index].Maximum = (index == 0 ? 10 : 1); 310 | Axes[index].AbsoluteMaximum = Axes[index].Maximum; 311 | if (index == 0) AxisXStringFormatSet(); 312 | return true; 313 | } 314 | else 315 | { 316 | if (value > Axes[index].Maximum) 317 | { 318 | Axes[index].Maximum = (double)value; 319 | Axes[index].AbsoluteMaximum = Axes[index].Maximum; 320 | if (index == 0) AxisXStringFormatSet(); 321 | return true; 322 | } 323 | } 324 | return false; 325 | } 326 | 327 | 328 | public static string AxisXStringFormatBuild(double? duration) 329 | { 330 | return (duration == null || (double)duration < 60) ? "m:ss" : (((double)duration < 60 * 60) ? "mm:ss" : "h:mm:ss"); 331 | } 332 | 333 | 334 | public void AxisXStringFormatSet() 335 | { 336 | Axes[0].StringFormat = AxisXStringFormatBuild(Axes[0].Maximum); 337 | } 338 | 339 | 340 | public bool AxisYTitleAndUnitSet(string? plotViewType) 341 | { 342 | if (Axes.Count < 2) return false; 343 | 344 | string? title = AxisYTitleBuild(plotViewType); 345 | string? unit = AxisYUnitBuild(plotViewType); 346 | 347 | var result = false; 348 | if (title != Axes[1].Title) { 349 | Axes[1].Title = title; 350 | result = true; 351 | } 352 | if (unit != Axes[1].Unit) 353 | { 354 | Axes[1].Unit = unit; 355 | result = true; 356 | } 357 | return result; 358 | } 359 | 360 | 361 | public static string? AxisYTitleBuild(string? plotType) 362 | { 363 | return (plotType?.ToUpper() ?? "") switch 364 | { 365 | "FRAME" => "Frame size", 366 | "GOP" => "Bit rate", 367 | "SECOND" => "Bit rate", 368 | _ => "" 369 | }; 370 | } 371 | 372 | 373 | public static string? AxisYUnitBuild(string? plotType) 374 | { 375 | return (plotType?.ToUpper() ?? "") switch 376 | { 377 | "FRAME" => "kB", 378 | "GOP" => "kb/GOP", 379 | "SECOND" => "kb/s", 380 | _ => null 381 | }; 382 | } 383 | 384 | 385 | public static string? AxisYAdditionalInfo(string? plotType) 386 | { 387 | switch (plotType?.ToUpper()) 388 | { 389 | case "FRAME": 390 | { 391 | return System.Environment.NewLine + "Frame type={FrameType}"; 392 | } 393 | } 394 | return null; 395 | } 396 | 397 | 398 | //public void AxesRedraw() 399 | //{ 400 | // for (int axisIndex = 0; axisIndex < Axes.Count; ++axisIndex) AxisRedraw(axisIndex); 401 | //} 402 | 403 | 404 | //public void AxisRedraw(int idx) 405 | //{ 406 | // ((MyPlotModel)Axes[idx].PlotModel).Redraw(); 407 | //} 408 | 409 | 410 | public bool IsEmpty() 411 | { 412 | foreach (var serie in Series) if (!IsSerieEmpty(serie)) return false; 413 | return true; 414 | } 415 | 416 | 417 | private bool IsSerieEmpty(Series serie) 418 | { 419 | return ((StairStepSeries)serie).Points.Count == 0; 420 | } 421 | 422 | 423 | public StairStepSeries SerieCreate(FileItem file, int? idx) 424 | { 425 | var serie = new StairStepSeries 426 | { 427 | IsVisible = file.IsExists && file.IsEnabled, 428 | StrokeThickness = 1.5, 429 | Title = file.FN, 430 | TrackerFormatString = TrackerFormatStringBuild(), 431 | Decimator = Decimator.Decimate, 432 | LineJoin = LineJoin.Miter, 433 | VerticalStrokeThickness = 0.5, 434 | VerticalLineStyle = LineStyle.Dash 435 | }; 436 | if (idx != null) SerieStyleApply(serie, PlotStyles[(int)idx]); 437 | return serie; 438 | } 439 | 440 | 441 | public StairStepSeries? SerieGet(int idx) 442 | { 443 | //return idx >=0 && idx < Series.Count ? (LineSeries)Series[idx] : null; 444 | return idx >=0 && idx < Series.Count ? (StairStepSeries)Series[idx] : null; 445 | } 446 | 447 | 448 | public void SerieSet(int? idx, StairStepSeries? serie) 449 | { 450 | if (serie == null) return; 451 | if (idx == null) 452 | { 453 | Series.Add(serie); 454 | idx = Series.Count - 1; 455 | } 456 | else 457 | { 458 | Series[(int)idx] = serie; 459 | } 460 | SerieStyleApply((int)idx); 461 | } 462 | 463 | 464 | public void SerieRedraw(int idx, bool? visible = null, bool force = false) 465 | { 466 | var serie = SerieGet(idx); 467 | if (serie == null) return; 468 | bool changed = visible != null && serie.IsVisible != (bool)visible; 469 | if (changed) serie.IsVisible = (visible == true); 470 | if (changed || force) ((MyPlotModel)serie.PlotModel).Redraw(); 471 | } 472 | 473 | 474 | public void SeriePointsAdd(int idx, List dataPoints) 475 | { 476 | SerieGet(idx)?.Points.AddRange(dataPoints); 477 | } 478 | 479 | 480 | public void SeriePointsClear(int idx) 481 | { 482 | SerieGet(idx)?.Points.Clear(); 483 | } 484 | 485 | 486 | private static void SerieStyleApply(StairStepSeries serie, SerieStyle style) 487 | { 488 | serie.Color = style.Color; 489 | serie.LineStyle = style.LineStyle; 490 | serie.MarkerType = style.MarkerType; 491 | //serie.MarkerStroke = style.Color; 492 | //serie.MarkerFill = style.Color; 493 | //serie.MarkerResolution = 10; 494 | } 495 | 496 | 497 | public void SerieStyleApply(int idx) 498 | { 499 | var serie = SerieGet(idx); 500 | if(serie != null) SerieStyleApply(serie, PlotStyles[idx]); // todo@ check idx range 501 | } 502 | 503 | 504 | public void PlotViewTypeSet(string plotViewType) 505 | { 506 | PlotViewType = plotViewType; 507 | foreach (var serie in Series) serie.TrackerFormatString = TrackerFormatStringBuild(); 508 | } 509 | 510 | 511 | private string TrackerFormatStringBuild() 512 | { 513 | return "{0}" 514 | + System.Environment.NewLine + "Time={2:hh\\:mm\\:ss\\.fff}" 515 | + System.Environment.NewLine + "{3}={4:0} " + AxisYUnitBuild(PlotViewType) 516 | //+ AxisYAdditionalInfo(PlotViewType) 517 | ; 518 | } 519 | 520 | 521 | public static IExporter GetExporter(string? ext = null) 522 | { 523 | // todo@ Rid off the OxyPlot.Wpf.PngExporter by implementing the required ExportToBitmap functionality 524 | return (ext?.ToUpper()) switch 525 | { 526 | "SVG" => new OxyPlot.SkiaSharp.SvgExporter { Width = 2400, Height = 600 }, 527 | "PDF" => new OxyPlot.SkiaSharp.PdfExporter { Width = 3200, Height = 800 }, 528 | "PNG" => new OxyPlot.SkiaSharp.PngExporter { Width = 3200, Height = 800 }, 529 | _ => new OxyPlot.Wpf.PngExporter { Width = 3200, Height = 800 } // OxyPlot.SkiaSharp.PngExporter does not have ExportToBitmap that needed to export to Clipboard 530 | }; 531 | } 532 | 533 | } 534 | } 535 | -------------------------------------------------------------------------------- /MainWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Windows; 7 | using System.Windows.Controls; 8 | using System.Windows.Input; 9 | using Utilities; 10 | 11 | namespace FFBitrateViewer 12 | { 13 | public class ListViewItemDropTarget 14 | { 15 | public int? Index { get; set; } = null; 16 | public ListViewItem Item { get; set; } 17 | public ListViewItemDropTarget(ListViewItem item) { Item = item; } 18 | } 19 | 20 | 21 | /// 22 | /// Interaction logic for MainWindow.xaml 23 | /// 24 | public partial class MainWindow : Window 25 | { 26 | private ArgsOptions argsOptions = new(Environment.GetCommandLineArgs()); 27 | private int? dragFileSrcIndex = null; 28 | private ProgramConfig programConfig = new(); 29 | private ProgramOptions programOptions = new(); 30 | private Point startPoint = new(); 31 | public MainViewModel vm; 32 | 33 | public MainWindow() 34 | { 35 | Initialize(); 36 | 37 | InitializeComponent(); 38 | 39 | Dispatcher.ShutdownStarted += new EventHandler(Dispatcher_ShutdownStarted); 40 | 41 | vm = new MainViewModel 42 | { 43 | IsAutoRun = argsOptions.IsFilled && argsOptions.Run 44 | }; 45 | DataContext = vm; 46 | 47 | InitializeCommands(); 48 | } 49 | 50 | 51 | private void Initialize() 52 | { 53 | bool logCommands; 54 | if (argsOptions.IsFilled) 55 | { 56 | programOptions.Add(argsOptions); 57 | logCommands = argsOptions.LogCommands == true; 58 | } 59 | else 60 | { 61 | programOptions = ProgramOptions.LoadFromSettings(); 62 | logCommands = programOptions.LogCommands == true; 63 | } 64 | 65 | Log.Init(new Logger(argsOptions.LogLevel, FileSpecBuild("log"), true/*append*/, true/*add timestamp*/, true/*auto flush*/), logCommands || argsOptions.LogLevel == LogLevel.DEBUG); 66 | 67 | Log.Write(LogLevel.DEBUG, "Started"); 68 | 69 | // To debug non-english culture 70 | //CultureInfo ci = new CultureInfo("fr-FR"); 71 | //Thread.CurrentThread.CurrentCulture = ci; 72 | //Thread.CurrentThread.CurrentUICulture = ci; 73 | 74 | Log.Write(LogLevel.DEBUG, "System culture:" + Thread.CurrentThread.CurrentCulture.ToString()); 75 | 76 | string fs = FileSpecBuild("conf"); 77 | if (File.Exists(fs)) 78 | { 79 | try 80 | { 81 | programConfig = ProgramConfig.LoadFromFile(fs); 82 | if (!string.IsNullOrEmpty(programConfig.TempDir) && string.IsNullOrEmpty(argsOptions?.TempDir)) Global.SetTempDir(programConfig.TempDir); 83 | } 84 | catch (Exception ex) 85 | { 86 | Log.Write(LogLevel.ERROR, "Cannot process configuration file " + fs + ": " + ex.Message); 87 | Log.Close(); 88 | } 89 | } 90 | 91 | if (!string.IsNullOrEmpty(argsOptions.TempDir)) Global.SetTempDir(argsOptions.TempDir); 92 | 93 | FF.Init(programConfig); 94 | 95 | /* 96 | if (ProgramConfig.Plots.Series.Count > 0) 97 | { 98 | var serieStyleList = new List(); 99 | foreach (var item in ProgramConfig.Plots.Series) serieStyleList.Add(item.SerieStyle); 100 | MyPlotModel.PlotStyles = serieStyleList; 101 | } 102 | */ 103 | 104 | Log.Write(LogLevel.DEBUG, "Initialize finished"); 105 | } 106 | 107 | 108 | private void InitializeCommands() 109 | { 110 | Log.Write(LogLevel.DEBUG, "InitializeCommands started"); 111 | 112 | vm.AboutShowCmd = new RelayCommand( 113 | param => 114 | { 115 | var about = new AboutWindow(); 116 | about.ShowDialog(); 117 | }, 118 | param => { return true; } 119 | ); 120 | 121 | vm.ExecStartCmd = new RelayCommand( 122 | param => 123 | { 124 | if (!vm.IsRunning && vm.IsReady && vm.IsFilesReady()) 125 | { 126 | vm.FilesProcess(); 127 | } 128 | if (argsOptions.IsFilled && argsOptions.Run && argsOptions.Exit) 129 | { 130 | Application.Current.Dispatcher.Invoke(delegate 131 | { 132 | vm.ExitCmd.Execute(null); 133 | }); 134 | } 135 | }, 136 | param => { return !vm.IsRunning && vm.IsReady && vm.IsFilesReady(); } 137 | ); 138 | 139 | vm.ExecStopCmd = new RelayCommand( 140 | param => 141 | { 142 | if (vm.IsRunning) 143 | { 144 | vm.FilesProcessCancel(); 145 | } 146 | }, 147 | param => { return vm.IsRunning; } 148 | ); 149 | 150 | vm.ExecToggleCmd = new RelayCommand( 151 | param => 152 | { 153 | if (vm.ExecStopCmd?.CanExecute(null) == true) 154 | { 155 | vm.FilesProcessCancel(); 156 | } 157 | else if (vm.ExecStartCmd?.CanExecute(null) == true) 158 | { 159 | vm.FilesProcess(); 160 | } 161 | }, 162 | param => { return vm.ExecStartCmd?.CanExecute(null) == true || vm.ExecStopCmd?.CanExecute(null) == true; } 163 | ); 164 | 165 | vm.ExitCmd = new RelayCommand( 166 | param => { Application.Current.Shutdown(); }, 167 | param => { return true; } 168 | ); 169 | 170 | vm.FilesAddCmd = new RelayCommand( 171 | param => { if (vm.IsReady && vm.IsFileCanBeAdded()) MediaFilesOpenDialog("Select Distorted Files", true); }, 172 | param => { return vm.IsReady && vm.IsFileCanBeAdded(); } 173 | ); 174 | 175 | vm.FilesClearCmd = new RelayCommand( 176 | param => { if (vm.IsReady && vm.Files.Count > 0) vm.FilesClear(); }, 177 | param => { return vm.IsReady && vm.Files.Count > 0; } 178 | ); 179 | 180 | vm.FilesRemoveCmd = new RelayCommand( 181 | param => 182 | { 183 | if (vm.IsReady && listviewFiles != null && listviewFiles.SelectedItems.Count > 0) 184 | { 185 | var selected = new FileItem[listviewFiles.SelectedItems.Count]; 186 | listviewFiles.SelectedItems.CopyTo(selected, 0); 187 | foreach (var file in selected) vm.FileRemove(file); 188 | } 189 | }, 190 | param => { return vm.IsReady && listviewFiles != null && listviewFiles.SelectedItems.Count > 0; } 191 | ); 192 | 193 | vm.MediaInfoReloadCmd = new RelayCommand( 194 | param => 195 | { 196 | if (vm.IsReady && !vm.IsMediaInfoGetRunning && vm.Files.Count > 0) vm.MediaInfoRefresh(); 197 | }, 198 | param => { return vm.IsReady && !vm.IsMediaInfoGetRunning && vm.Files.Count > 0; } 199 | ); 200 | 201 | vm.PlayMediaCmd = new RelayCommand( 202 | param => { Helpers.RunAssociatedSystemProgram((string?)param); }, 203 | param => { return !string.IsNullOrEmpty((string?)param); } 204 | ); 205 | 206 | vm.PlotExportToClipboardCmd = new RelayCommand( 207 | param => 208 | { 209 | if (vm.IsReady && !vm.IsPlotEmpty()) vm.PlotExport(); 210 | }, 211 | param => { return vm.IsReady && !vm.IsPlotEmpty(); } 212 | ); 213 | 214 | vm.PlotExportToFileCmd = new RelayCommand( 215 | param => 216 | { 217 | if (vm.IsReady && !vm.IsPlotEmpty()) ImageFileSaveDialog("Save Image", vm.PlotExport, vm.PlotFileNameGet("svg")); 218 | }, 219 | param => { return vm.IsReady && !vm.IsPlotEmpty(); } 220 | ); 221 | 222 | vm.ResetAllCmd = new RelayCommand( 223 | param => { if (vm.IsReady && vm.FilesCountWithFrames() > 0) vm.FilesFramesClear(); }, 224 | param => { return vm.IsReady && vm.FilesCountWithFrames() > 0; } 225 | ); 226 | 227 | Log.Write(LogLevel.DEBUG, "InitializeCommands finished"); 228 | } 229 | 230 | 231 | private static string? DirGet(string? fs = null) 232 | { 233 | if (string.IsNullOrEmpty(fs)) fs = Process.GetCurrentProcess().MainModule?.FileName; 234 | return Path.GetDirectoryName(fs); 235 | } 236 | 237 | 238 | 239 | private static string FileSpecBuild(string ext) 240 | { 241 | string? fs = Process.GetCurrentProcess().MainModule?.FileName; 242 | return DirGet(fs) + Path.DirectorySeparatorChar + Path.GetFileNameWithoutExtension(fs) + "." + ext; 243 | } 244 | 245 | 246 | private void ImageFileSaveDialog(string title, Action fnSave, string? fs = null) 247 | { 248 | string ext = string.IsNullOrEmpty(fs) ? "png" : Path.GetExtension(fs).TrimStart('.'); 249 | var filter = "PNG files|*.png|SVG files|*.svg|All files|*.*"; 250 | var filterIndex = 1; 251 | var parts = filter.Split("|"); 252 | var index = 1; 253 | for (int i = 0; i < parts.Length; i++) 254 | { 255 | if (i % 2 == 0) continue; // Ignore filter labels such as "PNG Files" 256 | if (parts[i][^ext.Length..].Equals(ext, StringComparison.CurrentCultureIgnoreCase)) 257 | { 258 | filterIndex = index; 259 | break; 260 | } 261 | ++index; 262 | } 263 | var dlg = new Microsoft.Win32.SaveFileDialog 264 | { 265 | Filter = filter, 266 | RestoreDirectory = true, 267 | OverwritePrompt = true, 268 | FilterIndex = filterIndex, 269 | DefaultExt = ext, 270 | Title = title 271 | }; 272 | if (!string.IsNullOrEmpty(fs)) dlg.FileName = Path.GetFileName(fs); 273 | 274 | if (dlg.ShowDialog() == true) 275 | { 276 | try 277 | { 278 | fnSave(dlg.FileName); 279 | } 280 | catch (Exception ex) 281 | { 282 | Log.Write(LogLevel.ERROR, "Cannot save image file " + dlg.FileName + ": " + ex.Message); 283 | } 284 | } 285 | } 286 | 287 | 288 | private void MediaFilesOpenDialog(string title, bool multiple = false) 289 | { 290 | var dlg = new Microsoft.Win32.OpenFileDialog 291 | { 292 | Filter = "Video Files|" + programConfig.VideoFilesList + "|All files|*.*", 293 | RestoreDirectory = true, 294 | Multiselect = multiple, 295 | Title = title 296 | }; 297 | 298 | if (dlg.ShowDialog() == true) 299 | { 300 | if (multiple) 301 | { 302 | foreach (var filename in dlg.FileNames) if (!string.IsNullOrEmpty(filename)) vm.FileAdd(filename); 303 | } 304 | else 305 | { 306 | if (!string.IsNullOrEmpty(dlg.FileName)) vm.FileAdd(dlg.FileName); 307 | } 308 | } 309 | } 310 | 311 | 312 | private void Window_Loaded(object sender, RoutedEventArgs e) 313 | { 314 | Application.Current.Dispatcher.Invoke(delegate 315 | { 316 | vm.OverallProgress?.Show("Getting FFProbe version info"); 317 | vm.VersionInfo?.Load(); 318 | vm.OverallProgress?.Hide(); 319 | 320 | vm.OptionsSet(programOptions); 321 | }); 322 | } 323 | 324 | 325 | private void Dispatcher_ShutdownStarted(object? sender, EventArgs e) 326 | { 327 | try 328 | { 329 | vm.OptionsGet().SaveToSettings(); 330 | } 331 | catch (Exception ex) 332 | { 333 | Log.Write(LogLevel.ERROR, "Cannot save program settings: " + ex.Message); 334 | } 335 | 336 | Log.Write(LogLevel.DEBUG, "Finished"); 337 | Log.Close(); 338 | } 339 | 340 | 341 | // Drag & Drop 342 | // https://docs.microsoft.com/en-us/dotnet/desktop/wpf/advanced/drag-and-drop-overview 343 | private void Control_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) 344 | { 345 | if (sender is not ListView || vm.Files.Count < 2) return; 346 | startPoint = e.GetPosition(null); 347 | } 348 | 349 | 350 | private void Control_MouseMove(object? sender, MouseEventArgs e) 351 | { 352 | if (sender is not ListView view) return; 353 | 354 | var diff = startPoint - e.GetPosition(null); 355 | if (Math.Abs(diff.X) < SystemParameters.MinimumHorizontalDragDistance && Math.Abs(diff.Y) < SystemParameters.MinimumVerticalDragDistance) return; 356 | 357 | if (e.LeftButton == MouseButtonState.Pressed) 358 | { 359 | // Get the dragged ListViewItem 360 | var item = ((DependencyObject)e.OriginalSource).FindAncestor(); 361 | if (item == null) return; 362 | 363 | // Find the data behind the ListViewItem 364 | var file = (FileItem)(view.ItemContainerGenerator.ItemFromContainer(item)); 365 | if (file == null) return; 366 | 367 | dragFileSrcIndex = listviewFiles.SelectedIndex; 368 | DragDrop.DoDragDrop(item, new DataObject("FileItem", file), DragDropEffects.Move); 369 | } 370 | else 371 | { 372 | // Moved over ListView with LMB released 373 | dragFileSrcIndex = null; 374 | } 375 | } 376 | 377 | 378 | private void Control_PreviewDragEnter(object sender, DragEventArgs e) 379 | { 380 | if (sender is not ListView view || !e.Data.GetDataPresent("FileItem")) return; 381 | 382 | DropPositionSet(view, e, true); 383 | } 384 | 385 | 386 | private void Control_PreviewDragOver(object? sender, DragEventArgs e) 387 | { 388 | if (sender is not ListView view) return; 389 | 390 | if (e.Data.GetDataPresent("FileItem")) 391 | { 392 | // Dropping FileItem 393 | if (dragFileSrcIndex == null) return; 394 | var dest = DropPositionSet(view, e, true); 395 | e.Handled = true; 396 | e.Effects = dest == null || dest.Index == null ? DragDropEffects.None : DragDropEffects.Move; 397 | } 398 | else if (e.Data.GetDataPresent(DataFormats.FileDrop)) 399 | { 400 | // Dropping file(s) from Explorer 401 | e.Handled = true; 402 | e.Effects = DragDropEffects.Copy; 403 | } 404 | } 405 | 406 | 407 | private void Control_PreviewDragLeave(object sender, DragEventArgs e) 408 | { 409 | if (sender is not ListView view || !e.Data.GetDataPresent("FileItem")) return; 410 | 411 | DropPositionSet(view, e, false); 412 | } 413 | 414 | 415 | private void Control_PreviewDrop(object? sender, DragEventArgs e) 416 | { 417 | if (sender is not ListView view) return; 418 | 419 | if (e.Data.GetDataPresent("FileItem")) 420 | { 421 | // Dropping FileItem 422 | if (dragFileSrcIndex == null) return; 423 | 424 | var dest = DropPositionSet(view, e, false); 425 | if (dest != null && dest.Index != null) _ = vm.FileMove((int)dragFileSrcIndex, (int)dest.Index); 426 | dragFileSrcIndex = null; 427 | } 428 | else if (e.Data.GetDataPresent(DataFormats.FileDrop)) 429 | { 430 | // Dropping file(s) from Explorer 431 | if (e.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0) 432 | { 433 | for (int i = 0; i < files.Length; ++i) vm.FileAdd(files[i]); 434 | } 435 | } 436 | return; 437 | } 438 | 439 | 440 | private ListViewItemDropTarget? DropPositionSet(ListView view, DragEventArgs e, bool show) 441 | { 442 | if (e.OriginalSource is not DependencyObject source) return null; 443 | 444 | var item = source.FindAncestor(); 445 | if (item == null) return null; 446 | 447 | var target = new ListViewItemDropTarget(item); 448 | bool isBelow = (e.GetPosition(item).Y > (item.ActualHeight / 2)); 449 | if (dragFileSrcIndex != null && view.ItemContainerGenerator.ItemFromContainer(target.Item) is FileItem file) 450 | { 451 | var srcIndex = (int)dragFileSrcIndex; 452 | var destIndex = vm.Files.IndexOf(file) + (isBelow ? 1 : 0); 453 | target.Index = (destIndex >= 0 && destIndex <= vm.Files.Count && (srcIndex != destIndex && srcIndex != (destIndex - 1))) ? destIndex : null; 454 | } 455 | 456 | target.Item.SetValue(DragDropHighlighter.IsDroppingAboveProperty, show == true && target.Index != null && !isBelow); 457 | target.Item.SetValue(DragDropHighlighter.IsDroppingBelowProperty, show == true && target.Index != null && isBelow); 458 | 459 | return target; 460 | } 461 | 462 | 463 | private void TextBox_GotFocus(object sender, RoutedEventArgs e) 464 | { 465 | if (Mouse.LeftButton == MouseButtonState.Released) 466 | { 467 | var tb = sender as TextBox; 468 | if (tb == null) return; 469 | tb.SelectAll(); 470 | tb.Tag = true; // Use the tag propety to signal that the box is already focused 471 | } 472 | } 473 | 474 | 475 | private void TextBox_LostFocus(object sender, RoutedEventArgs e) 476 | { 477 | var tb = sender as TextBox; 478 | if (tb == null) return; 479 | tb.SelectionLength = 0; 480 | tb.Tag = false; // Use the tag propety to signal that the box is already focused 481 | 482 | } 483 | 484 | 485 | private void TextBox_PreviewMouseUp(object sender, MouseButtonEventArgs e) 486 | { 487 | // If a user clicked in, want to select all text, unless they made a different selection... 488 | // So select all only if the textbox isn't already focused, and the user hasn't selected any text. 489 | var tb = sender as TextBox; 490 | if(tb == null) return; 491 | if ((tb.Tag == null || (bool)tb.Tag == false) && tb.SelectionLength == 0) 492 | { 493 | tb.SelectAll(); 494 | tb.Tag = true; // Use the tag propety to signal that the box is already focused 495 | } 496 | } 497 | 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /Frames.cs: -------------------------------------------------------------------------------- 1 | using OxyPlot; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace FFBitrateViewer 6 | { 7 | public enum FramePictType 8 | { 9 | I, 10 | P, 11 | B 12 | }; 13 | 14 | public class FramesBitRates 15 | { 16 | public BitRate? Avg { get; set; } 17 | public BitRate? Avg2 { get; set; } 18 | public BitRate? Max { get; set; } 19 | public BitRate? Min { get; set; } 20 | } 21 | 22 | 23 | public class Frame 24 | { 25 | public double Duration { get; set; } 26 | public double EndTime { get { return StartTime + Duration; } } 27 | public FramePictType? FrameType { get; set; } // I, P, B 28 | public bool IsOrdered { get; set; } 29 | public long? Pos { get; set; } 30 | public int Size { get; set; } // Frame size in bytes 31 | public double StartTime { get { return StartTimeRaw ?? 0; } } 32 | public double? StartTimeRaw { get; set; } 33 | 34 | 35 | public static Frame? CreateFrame(FFProbePacket packet) 36 | { 37 | if (packet.DurationTime == null || packet.Size == null) return null; 38 | return new Frame() 39 | { 40 | Duration = (double)packet.DurationTime, 41 | FrameType = packet.Flags?.IndexOf('K') >= 0 ? FramePictType.I : null, 42 | IsOrdered = false, // 'Packets' returned by FFProbe are ordered by DTS, not PTS so will need to order them later when adding onto list 43 | Pos = packet.Pos, 44 | Size = (int)packet.Size, 45 | StartTimeRaw = packet.PTSTime 46 | }; 47 | } 48 | 49 | 50 | public static Frame? CreateFrame(FFProbeFrame frame) 51 | { 52 | if (frame.DurationTime == null || frame.Size == null) return null; 53 | 54 | FramePictType? pictType = frame.PictType?[0] switch 55 | { 56 | 'I' => FramePictType.I, 57 | 'P' => FramePictType.P, 58 | 'B' => FramePictType.B, 59 | _ => null 60 | }; 61 | 62 | return new Frame() 63 | { 64 | Duration = (double)frame.DurationTime, 65 | FrameType = pictType, 66 | IsOrdered = true, // 'Frames' returned by FFProbe are already ordered by BestEffortTimestampTime 67 | Pos = frame.Pos, 68 | Size = (int)frame.Size, 69 | StartTimeRaw = frame.BestEffortTimestampTime ?? frame.PTSTime 70 | }; 71 | } 72 | 73 | 74 | // Calculate what size of the frame accounts for specified interval 75 | public int GetSize(double? intervalStartTime = null, double? intervalEndTime = null) 76 | { 77 | // No interval specified 78 | if (intervalStartTime == null || intervalEndTime == null) return Size; 79 | 80 | // The frame is outside of the interval so none of its size is taken into account 81 | if (EndTime <= (double)intervalStartTime || StartTime >= (double)intervalEndTime) return 0; 82 | 83 | // The frame is fully inside of the interval so all of its size is taken into account 84 | if (StartTime >= (double)intervalStartTime && EndTime <= (double)intervalEndTime) return Size; 85 | 86 | // Only a part of the frame is inside the interval, so calculating what part of its size accounts for the size of the interval 87 | var start = double.Max(StartTime, (double)intervalStartTime); 88 | var end = double.Min(EndTime, (double)intervalEndTime); 89 | 90 | return (int)double.Round(Size * ((end - start) / Duration)); 91 | } 92 | 93 | 94 | public List DataPointGet(double startTimeOffset, bool isLast = false, int sizeDivider = 1000 /* kilo */) 95 | { 96 | List data = []; 97 | data.Add(new DataPoint(StartTime - startTimeOffset, Size / sizeDivider)); 98 | if(isLast) data.Add(new DataPoint(EndTime - startTimeOffset, Size / sizeDivider)); 99 | return data; 100 | } 101 | } 102 | 103 | public class GOP 104 | { 105 | private List Frames { get; set; } = []; 106 | public bool IsEmpty { get { return Frames.Count == 0; } } 107 | public bool IsRealGOP { get { return FixedTimeDuration == null; } } 108 | public int Size { get; private set; } = 0; 109 | public int BitRate { get { return Duration == 0 ? 0 : (int)double.Round(Size / (double)Duration); } } 110 | public double StartTimeRaw { get; private set; } = 0; 111 | public double StartTimeOffset { get; private set; } = 0; 112 | public double StartTime { get { return IsRealGOP ? FramesStartTime - StartTimeOffset : Math.Max(FixedTimeStartTime, FramesStartTime - StartTimeOffset); } } 113 | public double EndTime { get { return IsRealGOP ? FramesEndTime - StartTimeOffset : Math.Min(FixedTimeEndTime, FramesEndTime - StartTimeOffset); } } 114 | public double Duration { get { return EndTime - StartTime; } } 115 | public double? FixedTimeDuration { get; private set; } // NULL -- for real GOPs, GOP duration (in seconds) for fixed length GOPs as 'per second' 116 | public double FixedTimeStartTime { get { return StartTimeRaw; } } 117 | public double FixedTimeEndTime { get { return StartTimeRaw + (FixedTimeDuration ?? 0); } } 118 | public double FramesDuration { get { return FramesEndTime - FramesStartTime; } } 119 | public double FramesStartTime { get { return IsEmpty ? StartTimeRaw : Frames[0].StartTime; } } 120 | public double FramesEndTime { get { return IsEmpty ? StartTimeRaw : Frames[^1].EndTime; } } 121 | 122 | public GOP(double startTimeOffset, Frame? frame, double? startTime = null, double? duration = null) 123 | { 124 | if (duration < 0) throw new ArgumentException("Must be greater then 0", nameof(duration)); // todo@ can it be 0? 125 | FixedTimeDuration = duration; 126 | StartTimeOffset = startTimeOffset; 127 | StartTimeRaw = startTime ?? frame?.StartTime ?? 0; 128 | if (frame != null) Add(frame); 129 | } 130 | 131 | 132 | public void Clear() 133 | { 134 | Frames.Clear(); 135 | Size = 0; 136 | } 137 | 138 | 139 | public void Add(Frame frame) 140 | { 141 | //if (IsEmpty) StartTimeRaw = frame?.StartTime ?? 0; 142 | 143 | if (FixedTimeDuration == null) // Not SECOND based 144 | { 145 | if (frame.FrameType == FramePictType.I) 146 | { 147 | if (!IsEmpty) throw new ArgumentException("I-frame can only be the first in GOP"); 148 | } 149 | else 150 | { 151 | // No exception as frames could be added out of order 152 | // So it is possible that P-frame will be added 1st and then I-frame with smaller start time will be added 153 | } 154 | } 155 | Frames.Add(frame); 156 | SizeAdd(frame); 157 | } 158 | 159 | 160 | private void SizeAdd(Frame frame) 161 | { 162 | Size += frame.GetSize(StartTime + StartTimeOffset, StartTime + StartTimeOffset + FixedTimeDuration); 163 | } 164 | 165 | 166 | public List DataPointsGet(bool isLast = false, int sizeDivider = 1000 /* kilo */) 167 | { 168 | List data = []; 169 | if (IsEmpty) return data; 170 | 171 | var bitRate = (int)double.Round(8 /* Byte => bit */ * BitRate / sizeDivider); 172 | 173 | data.Add(new DataPoint(StartTime, bitRate)); 174 | if(isLast) data.Add(new DataPoint(EndTime, bitRate)); 175 | 176 | return data; 177 | } 178 | } 179 | 180 | 181 | public class GOPsBy 182 | { 183 | protected List Frames { get; private set; } = []; 184 | protected List GOPs { get; set; } = []; 185 | public int? MaxSize { get; private set; } 186 | public int? MinSize { get; private set; } 187 | public ulong? TotalSize { get; private set; } 188 | public double StartTimeOffset { get; protected set; } = 0; 189 | 190 | 191 | public GOPsBy() { } 192 | 193 | 194 | protected void CalcMinMax() 195 | { 196 | int? max = null; 197 | int? min = null; 198 | ulong? total = null; 199 | foreach (var gop in GOPs) 200 | { 201 | if (total == null) 202 | { 203 | total = (ulong)gop.Size; 204 | } 205 | else 206 | { 207 | total += (ulong)gop.Size; 208 | } 209 | if (max == null || gop.BitRate > max) max = gop.BitRate; 210 | if (min == null || gop.BitRate < min) min = gop.BitRate; 211 | } 212 | MaxSize = max; 213 | MinSize = min; 214 | TotalSize = total; 215 | } 216 | 217 | 218 | public void Clear() 219 | { 220 | GOPs.Clear(); 221 | MaxSize = null; 222 | MinSize = null; 223 | TotalSize = null; 224 | StartTimeOffset = 0; 225 | } 226 | 227 | 228 | public List DataPointsGet(int sizeDivider = 1000 /* kilo */) 229 | { 230 | List data = []; 231 | for (var idx = 0; idx < GOPs.Count; ++idx) data.AddRange(GOPs[idx].DataPointsGet(idx == GOPs.Count - 1, sizeDivider)); 232 | return data; 233 | } 234 | 235 | 236 | public void SetFrames(List frames) 237 | { 238 | Frames = frames; 239 | } 240 | } 241 | 242 | 243 | public class GOPsByGOP : GOPsBy 244 | { 245 | public GOPsByGOP() : base() { } 246 | 247 | 248 | public bool Calc(double startTimeOffset) 249 | { 250 | if (Frames.Count == 0 || (GOPs.Count > 0 && StartTimeOffset == startTimeOffset)) return false; // no data or calculated already 251 | 252 | StartTimeOffset = startTimeOffset; 253 | 254 | GOP? gop = null; 255 | 256 | foreach (var frame in Frames) 257 | { 258 | if (gop == null) 259 | { 260 | gop = new(StartTimeOffset, frame); 261 | continue; 262 | } 263 | 264 | // On every I-frame finalyzing current GOP and creating a new one 265 | if (frame.FrameType == FramePictType.I) 266 | { 267 | GOPs.Add(gop); 268 | gop = new(StartTimeOffset, frame); 269 | continue; 270 | } 271 | 272 | gop.Add(frame); 273 | } 274 | 275 | if (gop != null) GOPs.Add(gop); 276 | 277 | CalcMinMax(); 278 | 279 | return true; 280 | } 281 | } 282 | 283 | 284 | public class GOPsByTime : GOPsBy 285 | { 286 | public double IntervalDuration { get; private set; } = 1; 287 | 288 | 289 | public GOPsByTime(double intervalDuration) : base() { 290 | IntervalDuration = intervalDuration; 291 | } 292 | 293 | 294 | public bool Calc(double startTimeOffset) 295 | { 296 | if (Frames.Count == 0 || (GOPs.Count > 0 && StartTimeOffset == startTimeOffset)) return false; // no data or calculated already 297 | 298 | StartTimeOffset = startTimeOffset; 299 | 300 | GOP gop = new(StartTimeOffset, null, 0, IntervalDuration); 301 | 302 | foreach (var frame in Frames) 303 | { 304 | // The frame is started in one of the next GOP, so finallizing current GOP and creating a new one 305 | // It is possible that the frame if far away from prev GOP, so adding a number of GOPs if needed 306 | while ((frame.StartTime - StartTimeOffset) >= gop.FixedTimeEndTime) 307 | { 308 | GOPs.Add(gop); 309 | gop = new(StartTimeOffset, null, gop.FixedTimeEndTime, IntervalDuration); 310 | } 311 | 312 | gop.Add(frame); 313 | 314 | // The frame is ended in one of the next GOPs, so finallizing current GOP and creating a new one 315 | while ((frame.EndTime - StartTimeOffset) > gop.FixedTimeEndTime) 316 | { 317 | GOPs.Add(gop); 318 | gop = new(StartTimeOffset, frame, gop.FixedTimeEndTime, IntervalDuration); 319 | } 320 | } 321 | 322 | if (gop != null) GOPs.Add(gop); 323 | 324 | CalcMinMax(); 325 | 326 | return true; 327 | } 328 | } 329 | 330 | 331 | public class Frames 332 | { 333 | public int Count { get { return FramesList.Count; } } 334 | public double? Duration { get { return FramesList.Count > 0 ? (FramesList[^1].EndTime - (IsAdjustStartTime ? StartTime : 0)) : null; } } 335 | private List FramesList { get; set; } = []; 336 | private GOPsByGOP FramesByGOP { get; set; } = new(); 337 | private GOPsByTime FramesByTime { get; set; } = new(1); 338 | public double? FramesDuration { get { return FramesList.Count > 0 ? (FramesList[^1].EndTime - FramesList[0].StartTime) : null; } } 339 | public double? FramesEndTime { get { return FramesList.Count > 0 ? FramesList[^1].EndTime : null; } } 340 | public double? FramesStartTime { get { return FramesList.Count > 0 ? FramesList[0].StartTime : null; } } 341 | public bool IsAdjustStartTime { get; private set; } = true; 342 | private bool IsCalcStartTime { get; set; } = false; 343 | private int MaxFrameSize { get; set; } = 0; 344 | public double StartTime { get; set; } = 0; 345 | 346 | 347 | public int? Add(Frame frame, bool? isForceOrder = null) 348 | { 349 | var isOrder = isForceOrder == true || !frame.IsOrdered; 350 | if (frame.Size > MaxFrameSize) MaxFrameSize = frame.Size; 351 | if (isOrder) 352 | { 353 | var pos = PosFind(frame); 354 | if(pos != null) FramesList.Insert((int)pos, frame); 355 | return pos; 356 | } 357 | else 358 | { 359 | FramesList.Add(frame); 360 | return FramesList.Count - 1; 361 | } 362 | } 363 | 364 | 365 | public void Analyze() 366 | { 367 | if (IsCalcStartTime) FillFramesStartTime(StartTime); 368 | FramesByGOP.SetFrames(FramesList); 369 | FramesByTime.SetFrames(FramesList); 370 | } 371 | 372 | 373 | // todo@ caching? 374 | public List DataPointsGet(string? plotViewType, int sizeDivider = 1000/* kilo */) 375 | { 376 | List data = []; 377 | var startTimeOffset = IsAdjustStartTime ? StartTime : 0; 378 | switch (plotViewType?.ToUpper() ?? "") 379 | { 380 | case "FRAME": 381 | for (var idx = 0; idx < FramesList.Count; ++idx) data.AddRange(FramesList[idx].DataPointGet(startTimeOffset, idx == FramesList.Count - 1, sizeDivider)); 382 | break; 383 | case "GOP": 384 | FramesByGOP.Calc(startTimeOffset); 385 | data.AddRange(FramesByGOP.DataPointsGet(sizeDivider)); 386 | break; 387 | case "SECOND": 388 | FramesByTime.Calc(startTimeOffset); 389 | data.AddRange(FramesByTime.DataPointsGet(sizeDivider)); 390 | break; 391 | } 392 | return data; 393 | } 394 | 395 | 396 | public void IsAdjustStartTimeSet(bool isAdjustStartTime) 397 | { 398 | if (isAdjustStartTime != IsAdjustStartTime) 399 | { 400 | IsAdjustStartTime = isAdjustStartTime; 401 | FramesByGOP.Clear(); 402 | FramesByTime.Clear(); 403 | } 404 | } 405 | 406 | 407 | public double? MaxXGet(string? plotViewType) 408 | { 409 | switch (plotViewType?.ToUpper() ?? "") 410 | { 411 | case "SECOND": 412 | return Duration == null ? null : Math.Ceiling((double)Duration); 413 | default: 414 | return Duration; 415 | } 416 | } 417 | 418 | 419 | public int MaxYGet(string? plotViewType, int sizeDivider = 1000/* kilo */) 420 | { 421 | var value = 0; 422 | var startTimeOffset = (IsAdjustStartTime ? StartTime : 0); 423 | switch (plotViewType?.ToUpper() ?? "") 424 | { 425 | case "FRAME": 426 | value = MaxFrameSize; 427 | break; 428 | case "GOP": 429 | FramesByGOP.Calc(startTimeOffset); 430 | value = (FramesByGOP.MaxSize ?? 0) * 8 /* Byte/s => bit/s */; 431 | break; 432 | case "SECOND": 433 | FramesByTime.Calc(startTimeOffset); 434 | value = (FramesByTime.MaxSize ?? 0) * 8 /* Byte/s => bit/s */; 435 | break; 436 | } 437 | return (int)double.Round(value / sizeDivider); 438 | } 439 | 440 | 441 | public FramesBitRates BitRatesCals() 442 | { 443 | if (Duration == null || Duration <= 0) return new(); 444 | 445 | var startTimeOffset = (IsAdjustStartTime ? StartTime : 0); 446 | FramesByTime.Calc(startTimeOffset); 447 | 448 | return new FramesBitRates 449 | { 450 | Avg = (FramesByTime.TotalSize == null) ? null : new BitRate((int)double.Round((double)FramesByTime.TotalSize * 8/* Byte/s => bit/s */ / (double)Duration)), 451 | Max = (FramesByTime.MaxSize == null) ? null : new BitRate((int)FramesByTime.MaxSize * 8/* Byte/s => bit/s */), 452 | Min = (FramesByTime.MinSize == null) ? null : new BitRate((int)FramesByTime.MinSize * 8/* Byte/s => bit/s */) 453 | }; 454 | } 455 | 456 | 457 | private int? PosFind(Frame frame) 458 | { 459 | // Searching position from the end as usually the frame that we are adding will be somewhere close to the end (but not always the last) 460 | if (frame.StartTimeRaw == null) 461 | { 462 | // Frame does not have StartTime, use Pos instead to order and we will re-calculate all frames StartTime late 463 | IsCalcStartTime = true; 464 | for (int idx = FramesList.Count - 1; idx >= 0; --idx) 465 | { 466 | if (frame.Pos == FramesList[idx].Pos) return null; 467 | if (frame.Pos > FramesList[idx].Pos) return idx + 1; 468 | } 469 | } 470 | else 471 | { 472 | for (int idx = FramesList.Count - 1; idx >= 0; --idx) 473 | { 474 | if (frame.StartTime == FramesList[idx].StartTime) return null; 475 | if (frame.StartTime > FramesList[idx].StartTime) return idx + 1; 476 | } 477 | } 478 | return 0; 479 | } 480 | 481 | 482 | private void FillFramesStartTime(double startTime = 0) 483 | { 484 | foreach(var frame in FramesList) 485 | { 486 | if (frame.StartTimeRaw == null) 487 | { 488 | frame.StartTimeRaw = startTime; 489 | startTime += frame.Duration; 490 | } 491 | else 492 | { 493 | startTime = frame.EndTime; 494 | } 495 | } 496 | } 497 | } 498 | } --------------------------------------------------------------------------------