mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Compare commits
3 Commits
4f83fc1dd2
...
61c94d4661
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c94d4661 | |||
| 417d511bc8 | |||
| a408d43b61 |
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
public interface IAutoDecisionService
|
public interface IAutoDecisionService
|
||||||
{
|
{
|
||||||
void ApplyAutoDecisions(SingleJob job, VideoInfo probe);
|
void ApplyAutoDecisions(JobViewModel job);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using splitter.probe;
|
||||||
|
|
||||||
namespace Splitter_UI.Services;
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"/>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
5
splitter-cli/GlobalUsing.cs
Normal file
5
splitter-cli/GlobalUsing.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
global using OpenCvSharp;
|
||||||
|
global using splitter.algo;
|
||||||
|
global using splitter.probe;
|
||||||
|
global using splitter.tui;
|
||||||
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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
119
splitter-cli/SingleJob.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
using OpenCvSharp;
|
using OpenCvSharp;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public enum TrackState
|
public enum TrackState
|
||||||
{
|
{
|
||||||
@ -1,6 +1,6 @@
|
|||||||
using OpenCvSharp;
|
using OpenCvSharp;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public interface IObjectDetector : IDisposable
|
public interface IObjectDetector : IDisposable
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public interface ISegmentProcessor
|
public interface ISegmentProcessor
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class KalmanTracker
|
public sealed class KalmanTracker
|
||||||
{
|
{
|
||||||
13
splitter-cli/algo/Point2f.cs
Normal file
13
splitter-cli/algo/Point2f.cs
Normal 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;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
40
splitter-cli/probe/FfprobeFormat.cs
Normal file
40
splitter-cli/probe/FfprobeFormat.cs
Normal 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; }
|
||||||
|
}
|
||||||
12
splitter-cli/probe/FfprobeResult.cs
Normal file
12
splitter-cli/probe/FfprobeResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
129
splitter-cli/probe/FfprobeStream.cs
Normal file
129
splitter-cli/probe/FfprobeStream.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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?>
|
||||||
{
|
{
|
||||||
@ -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?>
|
||||||
{
|
{
|
||||||
@ -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?>
|
||||||
{
|
{
|
||||||
@ -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)
|
||||||
116
splitter-cli/probe/ProbeVideo.cs
Normal file
116
splitter-cli/probe/ProbeVideo.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
15
splitter-cli/probe/VideoInfo.cs
Normal file
15
splitter-cli/probe/VideoInfo.cs
Normal 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
|
||||||
|
);
|
||||||
@ -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;
|
||||||
@ -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.");
|
||||||
|
|||||||
@ -50,7 +50,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Update="ThisAssembly.g.cs" />
|
|
||||||
|
|
||||||
<Content Include="models/*.*">
|
<Content Include="models/*.*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
public interface ILogger
|
public interface ILogger
|
||||||
{
|
{
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
@ -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>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
public class TextLogger() : ILogger
|
public class TextLogger() : ILogger
|
||||||
{
|
{
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.util;
|
||||||
|
|
||||||
public static class FileMaskExpander
|
public static class FileMaskExpander
|
||||||
{
|
{
|
||||||
@ -1,4 +1,6 @@
|
|||||||
using splitter;
|
using splitter;
|
||||||
|
using splitter.algo;
|
||||||
|
using splitter.probe;
|
||||||
|
|
||||||
public record SingleTask(
|
public record SingleTask(
|
||||||
SingleJob Job,
|
SingleJob Job,
|
||||||
Loading…
x
Reference in New Issue
Block a user