From 4f83fc1dd2720778dfffaab014d6b79eadaf937f Mon Sep 17 00:00:00 2001 From: unclshura Date: Sat, 23 May 2026 12:47:34 +0100 Subject: [PATCH] Full ffprobe output parsing. No SaR/DaR support yet. --- Splitter-UI/Program.cs | 1 - Splitter-UI/Services/DummyDetector.cs | 5 +- Splitter-UI/Services/GlobalLogger.cs | 6 +- Splitter-UI/Services/IFileJobFactory.cs | 6 +- Splitter-UI/Services/IFileProbeService.cs | 4 +- Splitter-UI/Services/IProcessingService.cs | 5 +- Splitter-UI/Services/IThumbnailService.cs | 3 +- Splitter-UI/Services/LogService.cs | 5 +- Splitter-UI/Services/ProcessingService.cs | 6 +- .../ViewModels/InspectorPaneViewModel.cs | 4 +- Splitter-UI/ViewModels/JobViewModel.cs | 13 +- Splitter-UI/Views/FileListView.axaml.cs | 2 - Splitter-UI/Views/MainWindow.axaml.cs | 3 - Splitter-UI/Views/PreviewPane.axaml.cs | 4 - splitter-cli/FlexibleDoubleConverter.cs | 31 ++ splitter-cli/FlexibleIntConverter.cs | 31 ++ splitter-cli/FlexibleLongConverter.cs | 31 ++ splitter-cli/ProbeVideo.cs | 288 +++++++++++++++--- 18 files changed, 352 insertions(+), 96 deletions(-) create mode 100644 splitter-cli/FlexibleDoubleConverter.cs create mode 100644 splitter-cli/FlexibleIntConverter.cs create mode 100644 splitter-cli/FlexibleLongConverter.cs diff --git a/Splitter-UI/Program.cs b/Splitter-UI/Program.cs index 9764329..2607651 100644 --- a/Splitter-UI/Program.cs +++ b/Splitter-UI/Program.cs @@ -1,7 +1,6 @@ using Avalonia; using Avalonia.Media; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Splitter_UI; diff --git a/Splitter-UI/Services/DummyDetector.cs b/Splitter-UI/Services/DummyDetector.cs index 14f8285..2ddf70c 100644 --- a/Splitter-UI/Services/DummyDetector.cs +++ b/Splitter-UI/Services/DummyDetector.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using OpenCvSharp; +using OpenCvSharp; namespace Splitter_UI.Services; diff --git a/Splitter-UI/Services/GlobalLogger.cs b/Splitter-UI/Services/GlobalLogger.cs index 531e1b6..da5594b 100644 --- a/Splitter-UI/Services/GlobalLogger.cs +++ b/Splitter-UI/Services/GlobalLogger.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; internal class GlobalLogger(ILogService _logService) : ILogger { diff --git a/Splitter-UI/Services/IFileJobFactory.cs b/Splitter-UI/Services/IFileJobFactory.cs index 58c4a0b..a667877 100644 --- a/Splitter-UI/Services/IFileJobFactory.cs +++ b/Splitter-UI/Services/IFileJobFactory.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; public interface IFileJobFactory { diff --git a/Splitter-UI/Services/IFileProbeService.cs b/Splitter-UI/Services/IFileProbeService.cs index 33a284c..459b08a 100644 --- a/Splitter-UI/Services/IFileProbeService.cs +++ b/Splitter-UI/Services/IFileProbeService.cs @@ -1,6 +1,4 @@ -using System.Threading.Tasks; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; public interface IFileProbeService { diff --git a/Splitter-UI/Services/IProcessingService.cs b/Splitter-UI/Services/IProcessingService.cs index 49f9e93..f49b62f 100644 --- a/Splitter-UI/Services/IProcessingService.cs +++ b/Splitter-UI/Services/IProcessingService.cs @@ -1,7 +1,4 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; public interface IProcessingService { diff --git a/Splitter-UI/Services/IThumbnailService.cs b/Splitter-UI/Services/IThumbnailService.cs index 7429925..787abb1 100644 --- a/Splitter-UI/Services/IThumbnailService.cs +++ b/Splitter-UI/Services/IThumbnailService.cs @@ -1,5 +1,4 @@ -using System.Threading.Tasks; -using Avalonia.Media.Imaging; +using Avalonia.Media.Imaging; namespace Splitter_UI.Services; diff --git a/Splitter-UI/Services/LogService.cs b/Splitter-UI/Services/LogService.cs index f29382a..e19f1e1 100644 --- a/Splitter-UI/Services/LogService.cs +++ b/Splitter-UI/Services/LogService.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; -using Avalonia; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; public sealed class LogService : ILogService { diff --git a/Splitter-UI/Services/ProcessingService.cs b/Splitter-UI/Services/ProcessingService.cs index 8fe779d..54e1213 100644 --- a/Splitter-UI/Services/ProcessingService.cs +++ b/Splitter-UI/Services/ProcessingService.cs @@ -1,8 +1,4 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Splitter_UI.Services; +namespace Splitter_UI.Services; public sealed class ProcessingService : IProcessingService { diff --git a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs index 22506a5..bc0bf9e 100644 --- a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs +++ b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs @@ -1,6 +1,4 @@ -using System.Collections.ObjectModel; -using Avalonia.Controls; -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace Splitter_UI.ViewModels; diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 5b019ea..300244f 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -155,9 +155,16 @@ public partial class JobViewModel : ObservableObject private async Task LoadThumbnailAsync() { - Probe = await _fileProbe.ProbeAsync(Job); - Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, rotateDegree: Job.Rotate); - SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate"; + try + { + Probe = await _fileProbe.ProbeAsync(Job); + Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, rotateDegree: Job.Rotate); + SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate"; + } + catch (Exception ex) + { + _log.LogError($"Error creating thumbnail for {FileName}: {ex.Message}"); + } await CreatePreview(); } diff --git a/Splitter-UI/Views/FileListView.axaml.cs b/Splitter-UI/Views/FileListView.axaml.cs index 8b12827..92223b3 100644 --- a/Splitter-UI/Views/FileListView.axaml.cs +++ b/Splitter-UI/Views/FileListView.axaml.cs @@ -1,9 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.Platform.Storage; -using Splitter_UI.ViewModels; namespace Splitter_UI.Views; diff --git a/Splitter-UI/Views/MainWindow.axaml.cs b/Splitter-UI/Views/MainWindow.axaml.cs index 49ff881..a214a7a 100644 --- a/Splitter-UI/Views/MainWindow.axaml.cs +++ b/Splitter-UI/Views/MainWindow.axaml.cs @@ -1,7 +1,4 @@ -using Avalonia; using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Platform; namespace Splitter_UI.Views; diff --git a/Splitter-UI/Views/PreviewPane.axaml.cs b/Splitter-UI/Views/PreviewPane.axaml.cs index f59da58..066ce77 100644 --- a/Splitter-UI/Views/PreviewPane.axaml.cs +++ b/Splitter-UI/Views/PreviewPane.axaml.cs @@ -1,8 +1,4 @@ -using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Input; -using Avalonia.VisualTree; namespace Splitter_UI.Views; diff --git a/splitter-cli/FlexibleDoubleConverter.cs b/splitter-cli/FlexibleDoubleConverter.cs new file mode 100644 index 0000000..5622071 --- /dev/null +++ b/splitter-cli/FlexibleDoubleConverter.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace splitter; + +public sealed class FlexibleDoubleConverter : JsonConverter +{ + public override double? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + return reader.GetDouble(); + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) + return v; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, double? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteNumberValue(value.Value); + else + writer.WriteNullValue(); + } +} diff --git a/splitter-cli/FlexibleIntConverter.cs b/splitter-cli/FlexibleIntConverter.cs new file mode 100644 index 0000000..ba29204 --- /dev/null +++ b/splitter-cli/FlexibleIntConverter.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace splitter; + +public sealed class FlexibleIntConverter : JsonConverter +{ + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + return reader.GetInt32(); + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) + return v; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteNumberValue(value.Value); + else + writer.WriteNullValue(); + } +} diff --git a/splitter-cli/FlexibleLongConverter.cs b/splitter-cli/FlexibleLongConverter.cs new file mode 100644 index 0000000..a0c2423 --- /dev/null +++ b/splitter-cli/FlexibleLongConverter.cs @@ -0,0 +1,31 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace splitter; + +public sealed class FlexibleLongConverter : JsonConverter +{ + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + return reader.GetInt64(); + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) + return v; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteNumberValue(value.Value); + else + writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/splitter-cli/ProbeVideo.cs b/splitter-cli/ProbeVideo.cs index 87b14e9..701f408 100644 --- a/splitter-cli/ProbeVideo.cs +++ b/splitter-cli/ProbeVideo.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace splitter; @@ -9,11 +11,20 @@ public record VideoInfo( int Height, double Fps, double Bitrate, + double SarX, + double SarY, int Rotation = 0 ); public static class ProbeVideo { + static ProbeVideo() + { + _ffprobeJsonOptions.Converters.Add(new FlexibleDoubleConverter()); + _ffprobeJsonOptions.Converters.Add(new FlexibleIntConverter()); + _ffprobeJsonOptions.Converters.Add(new FlexibleLongConverter()); + } + public static async Task Probe(SingleJob job) { var info = ProbeSize(job.InputFile); @@ -32,15 +43,188 @@ public static class ProbeVideo return rotation; } + public sealed class FfprobeResult + { + public List? Streams { get; set; } + public FfprobeFormat? Format { get; set; } + } + + public sealed class FfprobeFormat + { + public string? Filename { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Nb_streams { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Nb_programs { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Nb_stream_groups { get; set; } + + public string? Format_name { get; set; } + public string? Format_long_name { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Start_time { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Duration { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Size { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Bit_rate { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Probe_score { get; set; } + + public Dictionary? Tags { get; set; } + } + + + public sealed class FfprobeStream + { + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Index { get; set; } + + public string? Codec_name { get; set; } + public string? Codec_long_name { get; set; } + public string? Profile { get; set; } + public string? Codec_type { get; set; } + public string? Codec_tag_string { get; set; } + public string? Codec_tag { get; set; } + public string? Mime_codec_string { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Width { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Height { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Coded_width { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Coded_height { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Closed_captions { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Film_grain { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Has_b_frames { get; set; } + + public string? Sample_aspect_ratio { get; set; } + public string? Display_aspect_ratio { get; set; } + + public string? Pix_fmt { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Level { get; set; } + + public string? Color_range { get; set; } + public string? Color_space { get; set; } + public string? Color_transfer { get; set; } + public string? Color_primaries { get; set; } + public string? Chroma_location { get; set; } + public string? Field_order { get; set; } + + public string? Is_avc { get; set; } + public string? Nal_length_size { get; set; } + + public string? Id { get; set; } + public string? R_frame_rate { get; set; } + public string? Avg_frame_rate { get; set; } + public string? Time_base { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Start_pts { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Start_time { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Duration_ts { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Duration { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Bit_rate { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Max_bit_rate { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Bits_per_raw_sample { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Bits_per_sample { get; set; } + + [JsonConverter(typeof(FlexibleLongConverter))] + public long? Nb_frames { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Extradata_size { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Channels { get; set; } + + public string? Channel_layout { get; set; } + public string? Sample_fmt { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Sample_rate { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Initial_padding { get; set; } + + public string? Disposition_raw { get; set; } + public Dictionary? Disposition { get; set; } + + public Dictionary? Tags { get; set; } + + public string? Language { get; set; } + public string? Title { get; set; } + + [JsonConverter(typeof(FlexibleIntConverter))] + public int? Bits_per_coded_sample { get; set; } + + public string? Codec_time_base { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Start_pts_time { get; set; } + + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double? Duration_time { get; set; } + + public string? Extradata { get; set; } + public string? Default { get; set; } + public string? Forced { get; set; } + } + + private static readonly JsonSerializerOptions _ffprobeJsonOptions = + new () + { + PropertyNameCaseInsensitive = true, + IgnoreReadOnlyProperties = false, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement + }; + private static VideoInfo ProbeSize(string inputFile) { var args = - "-v error " + - "-select_streams v:0 " + - "-show_entries format=duration " + - "-show_entries stream=width,height,avg_frame_rate,bit_rate " + - "-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys - $"\"{inputFile}\""; + "-v error " + + "-show_streams " + + "-show_format " + + "-of json " + + $"\"{inputFile}\""; var psi = new ProcessStartInfo { @@ -55,54 +239,62 @@ public static class ProbeVideo using var p = new Process { StartInfo = psi }; p.Start(); - var duration = -1.0; - var width = 0; - var height = 0; - var fps = 0.0; - var bitrate = 0.0; + var json = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); - while (!p.StandardOutput.EndOfStream) + var result = JsonSerializer.Deserialize(json, _ffprobeJsonOptions); + var stream = result?.Streams?.FirstOrDefault(); + var format = result?.Format; + + var duration = format?.Duration ?? 0.0; + var width = stream?.Width ?? 0; + var height = stream?.Height ?? 0; + + double fps = 0.0; + if (!string.IsNullOrWhiteSpace(stream?.Avg_frame_rate)) { - var line = p.StandardOutput.ReadLine()?.Trim(); - if (string.IsNullOrWhiteSpace(line)) - continue; - - if (line.StartsWith("duration=")) + var parts = stream.Avg_frame_rate.Split('/'); + if (parts.Length == 2 && + double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) && + double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) && + den != 0) { - var v = line.Substring("duration=".Length); - double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration); - } - else if (line.StartsWith("width=")) - { - var v = line.Substring("width=".Length); - int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width); - } - else if (line.StartsWith("bit_rate=")) - { - var v = line.Substring("bit_rate=".Length); - double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out bitrate); - } - else if (line.StartsWith("height=")) - { - var v = line.Substring("height=".Length); - int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height); - } - else if (line.StartsWith("avg_frame_rate=")) - { - var v = line.Substring("avg_frame_rate=".Length); - var parts = v.Split('/'); - if (parts.Length == 2 && - double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) && - double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) && - den != 0) - { - fps = num / den; - } + fps = num / den; } } - p.WaitForExit(); + var bitrate = stream?.Bit_rate ?? 0.0; - return new(duration, width, height, fps, bitrate); + var (sarX, sarY) = ParseSar(stream?.Sample_aspect_ratio); + + return new VideoInfo(duration, width, height, fps, bitrate, sarX, sarY); } + + static double ParseDouble(string? s) + { + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) + return v; + + return 0.0; + } + + private static (double x, double y) ParseSar(string? sar) + { + if (string.IsNullOrWhiteSpace(sar)) + return (1.0, 1.0); + + var parts = sar.Split(':'); + if (parts.Length != 2) + return (1.0, 1.0); + + if (double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) && + double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) && + den != 0) + { + return (num, den); + } + + return (1.0, 1.0); + } + }