Compare commits

...

3 Commits

53 changed files with 775 additions and 553 deletions

View File

@ -2,7 +2,15 @@
global using System.Collections.Generic; global using System.Collections.Generic;
global using System.Threading.Tasks; global using System.Threading.Tasks;
global using OpenCvSharp;
global using Size = Avalonia.Size;
global using Rect = Avalonia.Rect;
global using splitter; global using splitter;
global using splitter.tui;
global using splitter.algo;
global using splitter.probe;
global using Splitter_UI.Models; global using Splitter_UI.Models;
global using Splitter_UI.Services; global using Splitter_UI.Services;
global using Splitter_UI.ViewModels; global using Splitter_UI.ViewModels;

View File

@ -5,10 +5,10 @@ namespace Splitter_UI.Models;
public class PreviewData public class PreviewData
{ {
public Avalonia.Media.Imaging.Bitmap? Frame { get; } public Avalonia.Media.Imaging.Bitmap? Frame { get; }
public IReadOnlyList<Rect> DetectedBoxes { get; } public IReadOnlyList<OpenCvSharp.Rect> DetectedBoxes { get; }
public Rect? CropRect { get; } public Rect? CropRect { get; }
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<Rect> boxes, Rect? crop) public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<OpenCvSharp.Rect> boxes, Rect? crop)
{ {
Frame = frame; Frame = frame;
DetectedBoxes = boxes; DetectedBoxes = boxes;

View File

@ -1,6 +1,8 @@
using Avalonia; using Avalonia;
using Avalonia.Media; using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using splitter.algo;
using splitter.tui;
namespace Splitter_UI; namespace Splitter_UI;
@ -45,7 +47,7 @@ internal sealed class Program
_ => new DummyDetector() _ => new DummyDetector()
}; };
}); });
services.AddSingleton<splitter.ILogger, GlobalLogger>(); services.AddSingleton<ILogger, GlobalLogger>();
// Domain services (your pipeline) // Domain services (your pipeline)
services.AddTransient<IFileProbeService, FileProbeService>(); services.AddTransient<IFileProbeService, FileProbeService>();

View File

@ -1,8 +1,33 @@
namespace Splitter_UI.Services; using NcnnDotNet.Layers;
using OpenCvSharp;
using splitter.tui;
public sealed class AutoDecisionService : IAutoDecisionService namespace Splitter_UI.Services;
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
{ {
public void ApplyAutoDecisions(SingleJob job, VideoInfo probe) public void ApplyAutoDecisions(JobViewModel job)
{ {
Task.Run(() => Detect(job));
}
private async Task Detect(JobViewModel job)
{
try
{
job.Probe = await _fileProbe.ProbeAsync(job.InputFile);
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
var sampler = new VideoRotationSampler(null);
job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration);
job.SuggestedAction = job.Rotate == 0 ? "crop" : "rotate";
if (job.SuggestedAction == "crop")
job.Detect = "body";
}
catch (Exception ex)
{
_log.LogError($"Error creating thumbnail for {Path.GetFileName(job.InputFile)}: {ex.Message}");
}
} }
} }

View File

@ -1,9 +1,10 @@
using OpenCvSharp; using OpenCvSharp;
using splitter.algo;
namespace Splitter_UI.Services; namespace Splitter_UI.Services;
internal class DummyDetector : IObjectDetector internal class DummyDetector : IObjectDetector
{ {
public List<(Rect box, splitter.Point2f center)> DetectAll(Mat frameCont) => []; public List<(OpenCvSharp.Rect box, Point2f center)> DetectAll(Mat frameCont) => [];
public void Dispose() {} public void Dispose() {}
} }

View File

@ -1,10 +1,12 @@
namespace Splitter_UI.Services; using splitter.probe;
namespace Splitter_UI.Services;
public sealed class FileProbeService : IFileProbeService public sealed class FileProbeService : IFileProbeService
{ {
public async Task<VideoInfo> ProbeAsync(SingleJob job) public async Task<VideoInfo> ProbeAsync(string inputFile)
{ {
var res = await Task.Run(() =>ProbeVideo.Probe(job)); var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false));
return res; return res;
} }
} }

View File

@ -1,4 +1,6 @@
namespace Splitter_UI.Services; using splitter.tui;
namespace Splitter_UI.Services;
internal class GlobalLogger(ILogService _logService) : ILogger internal class GlobalLogger(ILogService _logService) : ILogger
{ {

View File

@ -2,5 +2,5 @@
public interface IAutoDecisionService public interface IAutoDecisionService
{ {
void ApplyAutoDecisions(SingleJob job, VideoInfo probe); void ApplyAutoDecisions(JobViewModel job);
} }

View File

@ -1,6 +1,8 @@
namespace Splitter_UI.Services; using splitter.probe;
namespace Splitter_UI.Services;
public interface IFileProbeService public interface IFileProbeService
{ {
Task<VideoInfo> ProbeAsync(SingleJob job); Task<VideoInfo> ProbeAsync(string inputFile);
} }

View File

@ -1,4 +1,5 @@
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using splitter.probe;
namespace Splitter_UI.Services; namespace Splitter_UI.Services;

View File

@ -1,4 +1,5 @@
using OpenCvSharp; using OpenCvSharp;
using splitter.algo;
namespace Splitter_UI.Services; namespace Splitter_UI.Services;
@ -7,7 +8,7 @@ public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetec
{ {
private Lock _lock = new(); private Lock _lock = new();
public List<(Rect box, splitter.Point2f center)> DetectAll(Mat frameCont) public List<(OpenCvSharp.Rect box, Point2f center)> DetectAll(Mat frameCont)
{ {
lock (_lock) lock (_lock)
{ {

View File

@ -2,6 +2,7 @@
using Avalonia; using Avalonia;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform; using Avalonia.Platform;
using splitter.probe;
namespace Splitter_UI.Services; namespace Splitter_UI.Services;

View File

@ -7,6 +7,7 @@ namespace Splitter_UI.ViewModels;
public partial class FileListViewModel : ObservableObject public partial class FileListViewModel : ObservableObject
{ {
private readonly IFileJobFactory _factory; private readonly IFileJobFactory _factory;
private readonly IAutoDecisionService _autoDecisionService;
public ObservableCollection<JobViewModel> Files { get; } = []; public ObservableCollection<JobViewModel> Files { get; } = [];
public ObservableCollection<JobViewModel> SelectedFiles { get; } = []; public ObservableCollection<JobViewModel> SelectedFiles { get; } = [];
@ -15,9 +16,10 @@ public partial class FileListViewModel : ObservableObject
public event Action<JobViewModel?>? SelectedFileChanged; public event Action<JobViewModel?>? SelectedFileChanged;
public FileListViewModel(IFileJobFactory factory) public FileListViewModel(IFileJobFactory factory, IAutoDecisionService autoDecisionService)
{ {
_factory = factory; _factory = factory;
_autoDecisionService = autoDecisionService;
} }
partial void OnSelectedChanged(JobViewModel? value) partial void OnSelectedChanged(JobViewModel? value)
@ -32,6 +34,9 @@ public partial class FileListViewModel : ObservableObject
var job = new SingleJob { InputFile = path }; var job = new SingleJob { InputFile = path };
var vm = _factory.Create(job); var vm = _factory.Create(job);
Files.Add(vm); Files.Add(vm);
_autoDecisionService.ApplyAutoDecisions(vm);
} }
Selected = Files.LastOrDefault();
} }
} }

View File

@ -37,10 +37,10 @@ public partial class InspectorPaneViewModel : ObservableObject
private void AdjustRotation(int delta) private void AdjustRotation(int delta)
{ {
if (Selected?.Job == null) if ( Selected == null)
return; return;
var r = Selected.Job.Rotate ?? 0; var r = Selected.Rotate;
r = (r + delta) % 360; r = (r + delta) % 360;
if (r < 0) r += 360; if (r < 0) r += 360;

View File

@ -5,38 +5,31 @@ using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using splitter.algo;
using splitter.probe;
using splitter.tui;
namespace Splitter_UI.ViewModels; namespace Splitter_UI.ViewModels;
public partial class JobViewModel : ObservableObject public partial class JobViewModel : ObservableObject
{ {
public SingleJob Job { get; } private SingleJob Job { get; }
public VideoInfo? Probe { get; set; }
[ObservableProperty]
private PreviewData? _preview = new(null, [], null);
public ProgressInfo? Progress { get; set; }
[ObservableProperty] [ObservableProperty] private VideoInfo? _probe;
private Bitmap? _thumbnail; [ObservableProperty] private PreviewData? _preview = new(null, [], null);
[ObservableProperty] private ProgressInfo? _progress;
[ObservableProperty] [ObservableProperty] private Bitmap? _thumbnail;
private string _suggestedAction = ""; [ObservableProperty] private string _suggestedAction = "";
[ObservableProperty] private double _sliderLiveValue;
// This updates continuously [ObservableProperty] private double _positionSeconds;
[ObservableProperty]
private double _sliderLiveValue;
// This updates only on release
[ObservableProperty]
private double _positionSeconds;
public string InputFile => Job.InputFile;
public double DurationSeconds => Probe?.Duration ?? 0; public double DurationSeconds => Probe?.Duration ?? 0;
public IRelayCommand StepForwardCommand { get; } public IRelayCommand StepForwardCommand { get; }
public IRelayCommand StepBackwardCommand { get; } public IRelayCommand StepBackwardCommand { get; }
private readonly IThumbnailService _thumbnails; private readonly IThumbnailService _thumbnails;
private readonly IFileProbeService _fileProbe;
private readonly DispatcherTimer _debounceTimer; private readonly DispatcherTimer _debounceTimer;
private readonly Func<string, IObjectDetector> _detectorFactory; private readonly Func<string, IObjectDetector> _detectorFactory;
private readonly ILogger _log; private readonly ILogger _log;
@ -106,6 +99,66 @@ public partial class JobViewModel : ObservableObject
} }
} }
public string? Detect
{
get => Job.Detect;
set
{
if (Job.Detect == value)
return;
Job.Detect = value;
OnPropertyChanged();
}
}
public string? Mask
{
get => Job.Mask;
set
{
if (Job.Mask == value)
return;
Job.Mask = value;
OnPropertyChanged();
}
}
public string OutputFolder
{
get => Job.OutputFolder;
set
{
if (Job.OutputFolder == value)
return;
Job.OutputFolder = value;
OnPropertyChanged();
}
}
public bool ForceFixed
{
get => Job.ForceFixed;
set
{
if (Job.ForceFixed == value)
return;
Job.ForceFixed = value;
OnPropertyChanged();
}
}
public bool Debug
{
get => Job.Debug;
set
{
if (Job.Debug == value)
return;
Job.Debug = value;
OnPropertyChanged();
}
}
public int? Rotate public int? Rotate
{ {
get => Job.Rotate; get => Job.Rotate;
@ -117,11 +170,21 @@ public partial class JobViewModel : ObservableObject
} }
} }
public JobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe, Func<string, IObjectDetector> detectorFactory, ILogger log) public double? OverrideTargetDuration
{
get => Job.OverrideTargetDuration;
set
{
if (Job.OverrideTargetDuration != null && value != null && Math.Abs(Job.OverrideTargetDuration.Value - value.Value) < 0.01)
return;
Job.OverrideTargetDuration = value;
OnPropertyChanged();
}
}
public JobViewModel(SingleJob job, IThumbnailService thumbnails, Func<string, IObjectDetector> detectorFactory, ILogger log)
{ {
Job = job; Job = job;
_thumbnails = thumbnails; _thumbnails = thumbnails;
_fileProbe = fileProbe;
_detectorFactory = detectorFactory; _detectorFactory = detectorFactory;
_log = log; _log = log;
@ -149,27 +212,9 @@ public partial class JobViewModel : ObservableObject
Interval = TimeSpan.FromSeconds(1) Interval = TimeSpan.FromSeconds(1)
}; };
_debounceTimer.Tick += DebounceTimerTick; _debounceTimer.Tick += DebounceTimerTick;
_ = Task.Run( LoadThumbnailAsync );
} }
private async Task LoadThumbnailAsync() public async Task CreatePreview()
{
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();
}
private async Task CreatePreview()
{ {
if ( Probe == null) if ( Probe == null)
return; return;
@ -184,7 +229,7 @@ public partial class JobViewModel : ObservableObject
var detector = _detectorFactory(Job.Detect ?? ""); var detector = _detectorFactory(Job.Detect ?? "");
var detections = detector.DetectAll(frame.ToMatContinuous()); var detections = detector.DetectAll(frame.ToMatContinuous());
var boxes = detections.Select(x => new Avalonia.Rect(x.box.X, x.box.Y, x.box.Width, x.box.Height)).ToList(); var boxes = detections.Select(x => new OpenCvSharp.Rect(x.box.X, x.box.Y, x.box.Width, x.box.Height)).ToList();
Preview = new PreviewData(frame, boxes, null); Preview = new PreviewData(frame, boxes, null);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -10,9 +10,9 @@ public partial class MainViewModel : ViewModelBase
public StatusBarViewModel StatusBar { get; } = new StatusBarViewModel(); public StatusBarViewModel StatusBar { get; } = new StatusBarViewModel();
public LogPaneViewModel LogPane { get; } = new LogPaneViewModel(); public LogPaneViewModel LogPane { get; } = new LogPaneViewModel();
public MainViewModel(IFileJobFactory fileJobFactory) public MainViewModel(IFileJobFactory fileJobFactory, IAutoDecisionService autoDecisionService)
{ {
FileList = new FileListViewModel(fileJobFactory); FileList = new FileListViewModel(fileJobFactory, autoDecisionService);
// Wire selection → preview + inspector // Wire selection → preview + inspector
FileList.SelectedFileChanged += file => FileList.SelectedFileChanged += file =>
{ {

View File

@ -1,5 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using splitter.algo;
namespace Splitter_UI.ViewModels; namespace Splitter_UI.ViewModels;
@ -9,6 +10,8 @@ public partial class PreviewPaneViewModel : ObservableObject
private JobViewModel? _selected; private JobViewModel? _selected;
public PreviewData? Preview => Selected?.Preview; public PreviewData? Preview => Selected?.Preview;
public Point2f? Sar => Selected?.Probe?.Sar;
public int Rotate => Selected?.Rotate ?? 0;
partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue) partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue)
{ {
@ -19,12 +22,20 @@ public partial class PreviewPaneViewModel : ObservableObject
newValue.PropertyChanged += SelectedPropertyChanged; newValue.PropertyChanged += SelectedPropertyChanged;
OnPropertyChanged(nameof(Preview)); OnPropertyChanged(nameof(Preview));
OnPropertyChanged(nameof(Sar));
OnPropertyChanged(nameof(Rotate));
} }
private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e) private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(JobViewModel.Preview)) if (e.PropertyName == nameof(JobViewModel.Preview))
OnPropertyChanged(nameof(Preview)); OnPropertyChanged(nameof(Preview));
if (e.PropertyName == nameof(JobViewModel.Probe))
{
OnPropertyChanged(nameof(Sar));
OnPropertyChanged(nameof(Rotate));
}
} }
} }

View File

@ -57,13 +57,13 @@ x:DataType="vm:InspectorPaneViewModel">
<!-- Mask --> <!-- Mask -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Mask" Width="120"/> <TextBlock Text="Mask" Width="120"/>
<TextBox Text="{Binding Selected.Job.Mask}" Width="260"/> <TextBox Text="{Binding Selected.Mask}" Width="260"/>
</StackPanel> </StackPanel>
<!-- OutputFolder --> <!-- OutputFolder -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Output Folder" Width="120"/> <TextBlock Text="Output Folder" Width="120"/>
<TextBox Text="{Binding Selected.Job.OutputFolder}" Width="260"/> <TextBox Text="{Binding Selected.OutputFolder}" Width="260"/>
</StackPanel> </StackPanel>
<!-- Crop --> <!-- Crop -->
@ -82,31 +82,23 @@ x:DataType="vm:InspectorPaneViewModel">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Detect" Width="120"/> <TextBlock Text="Detect" Width="120"/>
<ComboBox ItemsSource="{Binding DetectModes}" <ComboBox ItemsSource="{Binding DetectModes}"
SelectedItem="{Binding Selected.Job.Detect}" SelectedItem="{Binding Selected.Detect}"
Width="160"/> Width="160"/>
</StackPanel> </StackPanel>
<!-- OverrideTargetDuration --> <!-- OverrideTargetDuration -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Target Duration" Width="120"/> <TextBlock Text="Target Duration" Width="120"/>
<NumericUpDown Value="{Binding Selected.Job.OverrideTargetDuration}" Width="120"/> <NumericUpDown Value="{Binding Selected.OverrideTargetDuration}" Width="120"/>
</StackPanel> </StackPanel>
<!-- RotateAuto -->
<CheckBox Content="Rotate Auto"
IsChecked="{Binding Selected.Job.RotateAuto}"/>
<!-- ForceFixed --> <!-- ForceFixed -->
<CheckBox Content="Force Fixed Duration" <CheckBox Content="Force Fixed Duration"
IsChecked="{Binding Selected.Job.ForceFixed}"/> IsChecked="{Binding Selected.ForceFixed}"/>
<!-- SingleThreaded -->
<CheckBox Content="Single Threaded"
IsChecked="{Binding Selected.Job.SingleThreaded}"/>
<!-- Debug --> <!-- Debug -->
<CheckBox Content="Debug Mode" <CheckBox Content="Debug Mode"
IsChecked="{Binding Selected.Job.Debug}"/> IsChecked="{Binding Selected.Debug}"/>
<!-- Parameters dictionary --> <!-- Parameters dictionary -->
<TextBlock Text="Advanced Parameters" FontSize="10" Margin="0,10,0,0" FontWeight="Bold"/> <TextBlock Text="Advanced Parameters" FontSize="10" Margin="0,10,0,0" FontWeight="Bold"/>

View File

@ -2,7 +2,7 @@ using Avalonia.Controls;
namespace Splitter_UI.Views; namespace Splitter_UI.Views;
public partial class MainWindow : Window public partial class MainWindow : Avalonia.Controls.Window
{ {
public MainViewModel Data { get; } = null!; // set by DI public MainViewModel Data { get; } = null!; // set by DI
public MainWindow() public MainWindow()

View File

@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using splitter.algo;
namespace Splitter_UI.Views; namespace Splitter_UI.Views;
@ -10,6 +11,10 @@ public sealed class PreviewCanvas : Control
{ {
public static readonly StyledProperty<PreviewData?> PreviewProperty = public static readonly StyledProperty<PreviewData?> PreviewProperty =
AvaloniaProperty.Register<PreviewCanvas, PreviewData?>(nameof(Preview)); AvaloniaProperty.Register<PreviewCanvas, PreviewData?>(nameof(Preview));
public static readonly StyledProperty<Point2f?> SarProperty =
AvaloniaProperty.Register<PreviewCanvas, Point2f?>(nameof(Sar));
public static readonly StyledProperty<int> RotateAngleProperty =
AvaloniaProperty.Register<PreviewCanvas, int>(nameof(RotateAngle));
public PreviewData? Preview public PreviewData? Preview
{ {
@ -17,6 +22,18 @@ public sealed class PreviewCanvas : Control
set => SetValue(PreviewProperty, value); set => SetValue(PreviewProperty, value);
} }
public Point2f? Sar
{
get => GetValue(SarProperty);
set => SetValue(SarProperty, value);
}
public int RotateAngle
{
get => GetValue(RotateAngleProperty);
set => SetValue(RotateAngleProperty, value);
}
static PreviewCanvas() static PreviewCanvas()
{ {
PreviewProperty.Changed.AddClassHandler<PreviewCanvas>( PreviewProperty.Changed.AddClassHandler<PreviewCanvas>(
@ -67,34 +84,103 @@ public sealed class PreviewCanvas : Control
if (dispW <= 0 || dispH <= 0) if (dispW <= 0 || dispH <= 0)
return; return;
var scale = Math.Min(dispW / rawW, dispH / rawH); var rotate = RotateAngle; // 0, 90, 180, 270
var scaledW = rawW * scale; // SAR (always original, never rotated)
var scaledH = rawH * scale; var sar = Sar ?? new Point2f(1, 1);
var sarX = sar.X;
var sarY = sar.Y;
if (sarX <= 0 || sarY <= 0)
{
sarX = 1;
sarY = 1;
}
var pixelAspect = sarX / sarY;
double displayW;
double displayH;
if (rotate == 0 || rotate == 180)
{
// encoded horizontal axis = rawW
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
// encoded horizontal axis = rawH (bitmap already rotated)
displayW = rawW;
displayH = rawH * pixelAspect;
}
var scale = Math.Min(dispW / displayW, dispH / displayH);
var scaledW = displayW * scale;
var scaledH = displayH * scale;
var offsetX = (dispW - scaledW) / 2; var offsetX = (dispW - scaledW) / 2;
var offsetY = (dispH - scaledH) / 2; var offsetY = (dispH - scaledH) / 2;
// draw frame // draw frame
context.DrawImage(frame, context.DrawImage(
frame,
new Rect(0, 0, rawW, rawH), new Rect(0, 0, rawW, rawH),
new Rect(offsetX, offsetY, scaledW, scaledH)); new Rect(offsetX, offsetY, scaledW, scaledH));
// draw overlays // overlays
if (preview.DetectedBoxes is { Count: > 0 }) if (preview.DetectedBoxes is { Count: > 0 })
{ {
var pen = new Pen(Brushes.Lime, 2); var pen = new Pen(Brushes.Lime, 2);
foreach (var r in preview.DetectedBoxes ) foreach (var r in preview.DetectedBoxes)
{ {
double x = r.X;
double y = r.Y;
double w = r.Width;
double h = r.Height;
// rotate overlay coordinates (still using your existing logic)
switch (rotate)
{
case 90:
(x, y) = (rawH - (y + h), x);
(w, h) = (h, w);
break;
case 180:
x = rawW - (x + w);
y = rawH - (y + h);
break;
case 270:
(x, y) = (y, rawW - (x + w));
(w, h) = (h, w);
break;
}
// apply SAR to the axis that originated from encoded width
if (rotate == 0 || rotate == 180)
{
x *= pixelAspect;
w *= pixelAspect;
}
else
{
y *= pixelAspect;
h *= pixelAspect;
}
var rr = new Rect( var rr = new Rect(
offsetX + r.X * scale, offsetX + x * scale,
offsetY + r.Y * scale, offsetY + y * scale,
r.Width * scale, w * scale,
r.Height * scale); h * scale);
context.DrawRectangle(null, pen, rr); context.DrawRectangle(null, pen, rr);
} }
} }
} }
} }

View File

@ -11,7 +11,9 @@
<local:PreviewCanvas <local:PreviewCanvas
Grid.Row="0" Grid.Row="0"
Preview="{Binding Preview}" /> Preview="{Binding Preview}"
Sar="{Binding Sar}"
RotateAngle="{Binding Rotate}" />
<Grid Grid.Row="1" <Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto" ColumnDefinitions="Auto,*,Auto"

View File

@ -1,122 +1,9 @@
using System.Globalization; using System.Globalization;
using splitter.algo;
using splitter.util;
namespace splitter; namespace splitter;
public class SingleJob
{
/// <summary>
/// File path of the input video. This is required for each job and should be
/// set to a valid video file path. The splitter will read this file, analyze it,
/// and split it into segments based on the specified parameters.
/// The output segments will be saved in the OutputFolder with names
/// derived from this input file and the Mask pattern if provided.
/// </summary>
public string InputFile { get; set; } = null!;
/// <summary>
/// Output folder where the split segments will be saved. This should be set
/// to a valid directory path.
/// </summary>
public string OutputFolder { get; set; } = null!;
/// <summary>
/// Crop parameters. Width and height for cropping the video. If set, the
/// splitter will crop the video to the specified dimensions while tracking the subject.
/// </summary>
public (int width, int height)? Crop { get; set; }
/// <summary>
/// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0).
/// By default , the splitter gravitates towards the center of the frame (0.5, 0.5).
/// Setting this allows you to bias the tracking towards a specific area of the frame,
/// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for
/// videos where the subject tends to be off-center or for creative framing choices.
/// </summary>
public Point2f? GravitateTo { get; set; }
/// <summary>
/// Destination file mask.
/// </summary>
public string? Mask { get; set; }
/// <summary>
/// Instead of producing the output, just generate debug frames with tracking
/// overlay to visually verify that the tracking is working correctly.
/// </summary>
public bool Debug { get; set; }
/// <summary>
/// Type of detector to use for tracking. Supported values are: face (UltraFace),
/// body (YoloOnnx, default), none (no tracking, just a center point).
/// </summary>
public string? Detect { get; set; }
/// <summary>
/// Set starget segments length explicitly. By default, the splitter calculates segment
/// lengths to be equal and not exceed 58 seconds.
/// </summary>
public double? OverrideTargetDuration { get; set; }
/// <summary>
/// Parameters to pass thru to ffmpeg. These are specified after "--" in the command
/// line and are passed directly to the ffmpeg command line for each segment.
/// </summary>
public string[] Passthrough { get; set; } = [];
/// <summary>
/// Debugging parameter. Instead of text UI putput lines in plain text.
/// This is useful when the output is being piped to a file or another program,
/// or when the user prefers a simpler log format without progress bars and dynamic updates.
/// </summary>
public bool PlainText { get; set; }
/// <summary>
/// Debugging parameter. Just show estimated segments length, count, and other info
/// without actually performing the splitting.
/// </summary>
public bool EstimateOnly { get; set; }
/// <summary>
/// Do not adapt segment length. When set, the splitter will use the exact
/// segment duration specified by --duration for all segments except possibly
/// the last one, which may be shorter.
/// </summary>
public bool ForceFixed { get; set; }
/// <summary>
/// Use single thread for operations. When set, the splitter will not run
/// multiple ffmpeg processes in parallel.
/// </summary>
public bool SingleThreaded { get; set; }
/// <summary>
/// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that
/// have incorrect orientation metadata.
/// </summary>
public int? Rotate { get; set; }
/// <summary>
/// Autodetect if rotation is needed. Not very reliable but can work for some videos.
/// Uses edge orientation statistics to determine if the video is rotated and
/// applies the appropriate rotation if needed.
/// </summary>
public bool RotateAuto { get; set; }
/// <summary>
/// Override internal parameters. This allows you to set custom parameters for the
/// object detector or rotation detector.
/// </summary>
public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name)
{
if (!Parameters.TryGetValue(name, out var raw))
return;
try
{
// Convert.ChangeType handles int, float, double, etc.
var converted = (T)Convert.ChangeType(
raw,
typeof(T),
CultureInfo.InvariantCulture
);
member = converted;
}
catch
{
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
}
}
}
public sealed class CommandLine public sealed class CommandLine
{ {
public SingleJob Master { get; } = new SingleJob(); public SingleJob Master { get; } = new SingleJob();

View File

@ -0,0 +1,5 @@
global using OpenCvSharp;
global using splitter.algo;
global using splitter.probe;
global using splitter.tui;

View File

@ -1,13 +0,0 @@
namespace splitter;
public struct Point2f
{
public float X;
public float Y;
public Point2f(float x, float y)
{
X = x;
Y = y;
}
}

View File

@ -1,300 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace splitter;
public record VideoInfo(
double Duration,
int Width,
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<VideoInfo> Probe(SingleJob job)
{
var info = ProbeSize(job.InputFile);
if ( job.RotateAuto)
{
var rotation = await ProbeRotation(job, info.Duration);
info = info with { Rotation = rotation };
}
return info;
}
private static async Task<int> ProbeRotation(SingleJob job, double duration)
{
var rotation = await new VideoRotationSampler(job).DetectRotationAsync(job.InputFile, duration);
return rotation;
}
public sealed class FfprobeResult
{
public List<FfprobeStream>? 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<string, string>? 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<string, int>? Disposition { get; set; }
public Dictionary<string, string>? 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 " +
"-show_streams " +
"-show_format " +
"-of json " +
$"\"{inputFile}\"";
var psi = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = new Process { StartInfo = psi };
p.Start();
var json = p.StandardOutput.ReadToEnd();
p.WaitForExit();
var result = JsonSerializer.Deserialize<FfprobeResult>(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 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)
{
fps = num / den;
}
}
var bitrate = stream?.Bit_rate ?? 0.0;
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);
}
}

View File

@ -1,5 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using splitter.algo;
using splitter.tui;
namespace splitter; namespace splitter;

119
splitter-cli/SingleJob.cs Normal file
View File

@ -0,0 +1,119 @@
using System.Globalization;
using splitter.algo;
namespace splitter;
public class SingleJob
{
/// <summary>
/// File path of the input video. This is required for each job and should be
/// set to a valid video file path. The splitter will read this file, analyze it,
/// and split it into segments based on the specified parameters.
/// The output segments will be saved in the OutputFolder with names
/// derived from this input file and the Mask pattern if provided.
/// </summary>
public string InputFile { get; set; } = null!;
/// <summary>
/// Output folder where the split segments will be saved. This should be set
/// to a valid directory path.
/// </summary>
public string OutputFolder { get; set; } = null!;
/// <summary>
/// Crop parameters. Width and height for cropping the video. If set, the
/// splitter will crop the video to the specified dimensions while tracking the subject.
/// </summary>
public (int width, int height)? Crop { get; set; }
/// <summary>
/// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0).
/// By default , the splitter gravitates towards the center of the frame (0.5, 0.5).
/// Setting this allows you to bias the tracking towards a specific area of the frame,
/// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for
/// videos where the subject tends to be off-center or for creative framing choices.
/// </summary>
public Point2f? GravitateTo { get; set; }
/// <summary>
/// Destination file mask.
/// </summary>
public string? Mask { get; set; }
/// <summary>
/// Instead of producing the output, just generate debug frames with tracking
/// overlay to visually verify that the tracking is working correctly.
/// </summary>
public bool Debug { get; set; }
/// <summary>
/// Type of detector to use for tracking. Supported values are: face (UltraFace),
/// body (YoloOnnx, default), none (no tracking, just a center point).
/// </summary>
public string? Detect { get; set; }
/// <summary>
/// Set starget segments length explicitly. By default, the splitter calculates segment
/// lengths to be equal and not exceed 58 seconds.
/// </summary>
public double? OverrideTargetDuration { get; set; }
/// <summary>
/// Parameters to pass thru to ffmpeg. These are specified after "--" in the command
/// line and are passed directly to the ffmpeg command line for each segment.
/// </summary>
public string[] Passthrough { get; set; } = [];
/// <summary>
/// Debugging parameter. Instead of text UI putput lines in plain text.
/// This is useful when the output is being piped to a file or another program,
/// or when the user prefers a simpler log format without progress bars and dynamic updates.
/// </summary>
public bool PlainText { get; set; }
/// <summary>
/// Debugging parameter. Just show estimated segments length, count, and other info
/// without actually performing the splitting.
/// </summary>
public bool EstimateOnly { get; set; }
/// <summary>
/// Do not adapt segment length. When set, the splitter will use the exact
/// segment duration specified by --duration for all segments except possibly
/// the last one, which may be shorter.
/// </summary>
public bool ForceFixed { get; set; }
/// <summary>
/// Use single thread for operations. When set, the splitter will not run
/// multiple ffmpeg processes in parallel.
/// </summary>
public bool SingleThreaded { get; set; }
/// <summary>
/// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that
/// have incorrect orientation metadata.
/// </summary>
public int? Rotate { get; set; }
/// <summary>
/// Autodetect if rotation is needed. Not very reliable but can work for some videos.
/// Uses edge orientation statistics to determine if the video is rotated and
/// applies the appropriate rotation if needed.
/// </summary>
public bool RotateAuto { get; set; }
/// <summary>
/// Override internal parameters. This allows you to set custom parameters for the
/// object detector or rotation detector.
/// </summary>
public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name)
{
if (!Parameters.TryGetValue(name, out var raw))
return;
try
{
// Convert.ChangeType handles int, float, double, etc.
var converted = (T)Convert.ChangeType(
raw,
typeof(T),
CultureInfo.InvariantCulture
);
member = converted;
}
catch
{
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
}
}
}

View File

@ -2,6 +2,8 @@
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using OpenCvSharp; using OpenCvSharp;
using splitter.algo;
using splitter.tui;
namespace splitter; namespace splitter;

View File

@ -1,6 +1,6 @@
using OpenCvSharp; using OpenCvSharp;
namespace splitter; namespace splitter.algo;
public enum TrackState public enum TrackState
{ {

View File

@ -1,6 +1,6 @@
using OpenCvSharp; using OpenCvSharp;
namespace splitter; namespace splitter.algo;
public interface IObjectDetector : IDisposable public interface IObjectDetector : IDisposable
{ {

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.algo;
public interface ISegmentProcessor public interface ISegmentProcessor
{ {

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.algo;
public sealed class KalmanTracker public sealed class KalmanTracker
{ {

View File

@ -0,0 +1,13 @@
//namespace splitter.algo;
//public struct Point2f
//{
// public float X;
// public float Y;
// public Point2f(float x, float y)
// {
// X = x;
// Y = y;
// }
//}

View File

@ -1,8 +1,9 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using OpenCvSharp; using OpenCvSharp;
using splitter.tui;
using UltraFaceDotNet; using UltraFaceDotNet;
namespace splitter; namespace splitter.algo;
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
{ {

View File

@ -2,8 +2,9 @@
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors; using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp; using OpenCvSharp;
using splitter.tui;
namespace splitter; namespace splitter.algo;
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
{ {

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace splitter.probe;
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<string, string>? Tags { get; set; }
}

View File

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
using static splitter.probe.ProbeVideo;
namespace splitter.probe;
public sealed class FfprobeResult
{
public List<FfprobeStream>? Streams { get; set; }
public FfprobeFormat? Format { get; set; }
}

View File

@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace splitter.probe;
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<string, int>? Disposition { get; set; }
public Dictionary<string, string>? 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; }
}

View File

@ -2,7 +2,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace splitter; namespace splitter.probe;
public sealed class FlexibleDoubleConverter : JsonConverter<double?> public sealed class FlexibleDoubleConverter : JsonConverter<double?>
{ {

View File

@ -2,7 +2,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace splitter; namespace splitter.probe;
public sealed class FlexibleIntConverter : JsonConverter<int?> public sealed class FlexibleIntConverter : JsonConverter<int?>
{ {

View File

@ -2,7 +2,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace splitter; namespace splitter.probe;
public sealed class FlexibleLongConverter : JsonConverter<long?> public sealed class FlexibleLongConverter : JsonConverter<long?>
{ {

View File

@ -1,6 +1,6 @@
using OpenCvSharp; using OpenCvSharp;
namespace splitter; namespace splitter.probe;
public sealed class FrameRotationDetector public sealed class FrameRotationDetector
{ {
@ -18,17 +18,17 @@ public sealed class FrameRotationDetector
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36) public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
{ {
_w = width; _w = width;
_h = height; _h = height;
_bins = bins; _bins = bins;
_gray = new Mat(height, width, MatType.CV_8UC1); _gray = new Mat(height, width, MatType.CV_8UC1);
_gx = new Mat(height, width, MatType.CV_32F); _gx = new Mat(height, width, MatType.CV_32F);
_gy = new Mat(height, width, MatType.CV_32F); _gy = new Mat(height, width, MatType.CV_32F);
_mag = new Mat(height, width, MatType.CV_32F); _mag = new Mat(height, width, MatType.CV_32F);
_angle = new Mat(height, width, MatType.CV_32F); _angle = new Mat(height, width, MatType.CV_32F);
_hist = new float[bins]; // allocated once _hist = new float[bins]; // allocated once
} }
public int GetRotation(Mat frame) public int GetRotation(Mat frame)

View File

@ -0,0 +1,116 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using splitter.algo;
namespace splitter.probe;
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<VideoInfo> Probe(string inputFile, bool detectRotation)
{
var info = ProbeSize(inputFile);
if (detectRotation)
{
var rotation = await ProbeRotation(inputFile, info.Duration);
info = info with { Rotation = rotation };
}
return info;
}
private static async Task<int> ProbeRotation(string inputFile, double duration)
=> await new VideoRotationSampler(null).DetectRotationAsync(inputFile, duration);
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 " +
"-show_streams " +
"-show_format " +
"-of json " +
$"\"{inputFile}\"";
var psi = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = new Process { StartInfo = psi };
p.Start();
var json = p.StandardOutput.ReadToEnd();
p.WaitForExit();
var result = JsonSerializer.Deserialize<FfprobeResult>(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 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)
{
fps = num / den;
}
}
var bitrate = stream?.Bit_rate ?? 0.0;
var sar = ParseAspectRatio(stream?.Sample_aspect_ratio);
var dar = ParseAspectRatio(stream?.Display_aspect_ratio);
return new VideoInfo(duration, width, height, fps, bitrate, sar, dar);
}
private static Point2f ParseAspectRatio(string? sar)
{
if (string.IsNullOrWhiteSpace(sar))
return new Point2f(1.0f, 1.0f);
var parts = sar.Split(':');
if (parts.Length != 2)
return new(1.0f, 1.0f);
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) &&
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) &&
den != 0)
{
return new(num, den);
}
return new(1.0f, 1.0f);
}
}

View File

@ -0,0 +1,15 @@
using OpenCvSharp;
using splitter.algo;
namespace splitter.probe;
public record VideoInfo(
double Duration,
int Width,
int Height,
double Fps,
double Bitrate,
Point2f Sar,
Point2f Dar,
int Rotation = 0
);

View File

@ -1,7 +1,7 @@
using OpenCvSharp; using OpenCvSharp;
using System.Diagnostics; using System.Diagnostics;
namespace splitter; namespace splitter.probe;
public sealed class VideoRotationSampler public sealed class VideoRotationSampler
{ {
@ -16,16 +16,19 @@ public sealed class VideoRotationSampler
private readonly byte[] _buffer; private readonly byte[] _buffer;
private readonly Mat _frameMat; private readonly Mat _frameMat;
public VideoRotationSampler(SingleJob _master) public VideoRotationSampler(IDictionary<string, string>? overrides)
{ {
if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s)) if (overrides != null)
RotationDetectorSampleCount = int.Parse(s); {
if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s)) if (overrides.TryGetValue("RotationDetectorSampleCount", out var s))
RotationDetectorSampleLength = double.Parse(s); RotationDetectorSampleCount = int.Parse(s);
if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s)) if (overrides.TryGetValue("RotationDetectorSampleLength", out s))
RotationDetectorFrameWidth = int.Parse(s); RotationDetectorSampleLength = double.Parse(s);
if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s)) if (overrides.TryGetValue("RotationDetectorFrameWidth", out s))
RotationDetectorFrameHeight = int.Parse(s); RotationDetectorFrameWidth = int.Parse(s);
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
RotationDetectorFrameHeight = int.Parse(s);
}
int w = RotationDetectorFrameWidth; int w = RotationDetectorFrameWidth;
int h = RotationDetectorFrameHeight; int h = RotationDetectorFrameHeight;

View File

@ -2,6 +2,9 @@ using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using Spectre.Console; using Spectre.Console;
using splitter; using splitter;
using splitter.algo;
using splitter.probe;
using splitter.tui;
static partial class Program static partial class Program
{ {
@ -76,7 +79,7 @@ static partial class Program
if (!Directory.Exists(job.OutputFolder)) if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder); Directory.CreateDirectory(job.OutputFolder);
var info = await ProbeVideo.Probe(job); var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto);
if (info.Duration <= 0) if (info.Duration <= 0)
{ {
LogError($"{baseName}: Could not read duration."); LogError($"{baseName}: Could not read duration.");

View File

@ -50,7 +50,6 @@
<ItemGroup> <ItemGroup>
<Compile Update="ThisAssembly.g.cs" />
<Content Include="models/*.*"> <Content Include="models/*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.tui;
public interface ILogger public interface ILogger
{ {

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.tui;
public abstract class LoggingBase(ILogger _logger, int _progressLine) public abstract class LoggingBase(ILogger _logger, int _progressLine)
{ {

View File

@ -2,7 +2,7 @@
using Spectre.Console; using Spectre.Console;
using Spectre.Console.Rendering; using Spectre.Console.Rendering;
namespace splitter; namespace splitter.tui;
/// <summary> /// <summary>

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.tui;
public class TextLogger() : ILogger public class TextLogger() : ILogger
{ {

View File

@ -1,4 +1,4 @@
namespace splitter; namespace splitter.util;
public static class FileMaskExpander public static class FileMaskExpander
{ {

View File

@ -1,4 +1,6 @@
using splitter; using splitter;
using splitter.algo;
using splitter.probe;
public record SingleTask( public record SingleTask(
SingleJob Job, SingleJob Job,