diff --git a/Splitter-UI/GlobalUsing.cs b/Splitter-UI/GlobalUsing.cs index 4377859..c969fc5 100644 --- a/Splitter-UI/GlobalUsing.cs +++ b/Splitter-UI/GlobalUsing.cs @@ -2,7 +2,15 @@ global using System.Collections.Generic; global using System.Threading.Tasks; +global using OpenCvSharp; +global using Size = Avalonia.Size; +global using Rect = Avalonia.Rect; + global using splitter; +global using splitter.tui; +global using splitter.algo; +global using splitter.probe; + global using Splitter_UI.Models; global using Splitter_UI.Services; global using Splitter_UI.ViewModels; diff --git a/Splitter-UI/Models/PreviewData.cs b/Splitter-UI/Models/PreviewData.cs index dd7cd58..ad9a930 100644 --- a/Splitter-UI/Models/PreviewData.cs +++ b/Splitter-UI/Models/PreviewData.cs @@ -5,10 +5,10 @@ namespace Splitter_UI.Models; public class PreviewData { public Avalonia.Media.Imaging.Bitmap? Frame { get; } - public IReadOnlyList DetectedBoxes { get; } + public IReadOnlyList DetectedBoxes { get; } public Rect? CropRect { get; } - public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList boxes, Rect? crop) + public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList boxes, Rect? crop) { Frame = frame; DetectedBoxes = boxes; diff --git a/Splitter-UI/Program.cs b/Splitter-UI/Program.cs index 2607651..abfb923 100644 --- a/Splitter-UI/Program.cs +++ b/Splitter-UI/Program.cs @@ -1,6 +1,8 @@ using Avalonia; using Avalonia.Media; using Microsoft.Extensions.DependencyInjection; +using splitter.algo; +using splitter.tui; namespace Splitter_UI; @@ -45,7 +47,7 @@ internal sealed class Program _ => new DummyDetector() }; }); - services.AddSingleton(); + services.AddSingleton(); // Domain services (your pipeline) services.AddTransient(); diff --git a/Splitter-UI/Services/AutoDecisionService.cs b/Splitter-UI/Services/AutoDecisionService.cs index 2887eb6..084643f 100644 --- a/Splitter-UI/Services/AutoDecisionService.cs +++ b/Splitter-UI/Services/AutoDecisionService.cs @@ -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}"); + } } } diff --git a/Splitter-UI/Services/DummyDetector.cs b/Splitter-UI/Services/DummyDetector.cs index 2ddf70c..d7d26f5 100644 --- a/Splitter-UI/Services/DummyDetector.cs +++ b/Splitter-UI/Services/DummyDetector.cs @@ -1,9 +1,10 @@ using OpenCvSharp; +using splitter.algo; namespace Splitter_UI.Services; 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() {} } diff --git a/Splitter-UI/Services/FileProbeService.cs b/Splitter-UI/Services/FileProbeService.cs index fbb88d0..0f81954 100644 --- a/Splitter-UI/Services/FileProbeService.cs +++ b/Splitter-UI/Services/FileProbeService.cs @@ -1,10 +1,12 @@ -namespace Splitter_UI.Services; +using splitter.probe; + +namespace Splitter_UI.Services; public sealed class FileProbeService : IFileProbeService { - public async Task ProbeAsync(SingleJob job) + public async Task ProbeAsync(string inputFile) { - var res = await Task.Run(() =>ProbeVideo.Probe(job)); + var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false)); return res; } } diff --git a/Splitter-UI/Services/GlobalLogger.cs b/Splitter-UI/Services/GlobalLogger.cs index da5594b..81979f8 100644 --- a/Splitter-UI/Services/GlobalLogger.cs +++ b/Splitter-UI/Services/GlobalLogger.cs @@ -1,4 +1,6 @@ -namespace Splitter_UI.Services; +using splitter.tui; + +namespace Splitter_UI.Services; internal class GlobalLogger(ILogService _logService) : ILogger { diff --git a/Splitter-UI/Services/IAutoDecisionService.cs b/Splitter-UI/Services/IAutoDecisionService.cs index 5744321..5793c2c 100644 --- a/Splitter-UI/Services/IAutoDecisionService.cs +++ b/Splitter-UI/Services/IAutoDecisionService.cs @@ -2,5 +2,5 @@ public interface IAutoDecisionService { - void ApplyAutoDecisions(SingleJob job, VideoInfo probe); + void ApplyAutoDecisions(JobViewModel job); } diff --git a/Splitter-UI/Services/IFileProbeService.cs b/Splitter-UI/Services/IFileProbeService.cs index 459b08a..9fa5bbd 100644 --- a/Splitter-UI/Services/IFileProbeService.cs +++ b/Splitter-UI/Services/IFileProbeService.cs @@ -1,6 +1,8 @@ -namespace Splitter_UI.Services; +using splitter.probe; + +namespace Splitter_UI.Services; public interface IFileProbeService { - Task ProbeAsync(SingleJob job); + Task ProbeAsync(string inputFile); } diff --git a/Splitter-UI/Services/IThumbnailService.cs b/Splitter-UI/Services/IThumbnailService.cs index 787abb1..c7a7eee 100644 --- a/Splitter-UI/Services/IThumbnailService.cs +++ b/Splitter-UI/Services/IThumbnailService.cs @@ -1,4 +1,5 @@ using Avalonia.Media.Imaging; +using splitter.probe; namespace Splitter_UI.Services; diff --git a/Splitter-UI/Services/SingleThreadedDetector.cs b/Splitter-UI/Services/SingleThreadedDetector.cs index 0d5bc65..877cb11 100644 --- a/Splitter-UI/Services/SingleThreadedDetector.cs +++ b/Splitter-UI/Services/SingleThreadedDetector.cs @@ -1,4 +1,5 @@ using OpenCvSharp; +using splitter.algo; namespace Splitter_UI.Services; @@ -7,7 +8,7 @@ public class SingleThreadedDetector(IObjectDetector _detector) : IObjectDetec { 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) { diff --git a/Splitter-UI/Services/ThumbnailService.cs b/Splitter-UI/Services/ThumbnailService.cs index d8883bf..294b422 100644 --- a/Splitter-UI/Services/ThumbnailService.cs +++ b/Splitter-UI/Services/ThumbnailService.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Media.Imaging; using Avalonia.Platform; +using splitter.probe; namespace Splitter_UI.Services; diff --git a/Splitter-UI/ViewModels/FileListViewModel.cs b/Splitter-UI/ViewModels/FileListViewModel.cs index 5256e98..d969837 100644 --- a/Splitter-UI/ViewModels/FileListViewModel.cs +++ b/Splitter-UI/ViewModels/FileListViewModel.cs @@ -7,6 +7,7 @@ namespace Splitter_UI.ViewModels; public partial class FileListViewModel : ObservableObject { private readonly IFileJobFactory _factory; + private readonly IAutoDecisionService _autoDecisionService; public ObservableCollection Files { get; } = []; public ObservableCollection SelectedFiles { get; } = []; @@ -15,9 +16,10 @@ public partial class FileListViewModel : ObservableObject public event Action? SelectedFileChanged; - public FileListViewModel(IFileJobFactory factory) + public FileListViewModel(IFileJobFactory factory, IAutoDecisionService autoDecisionService) { _factory = factory; + _autoDecisionService = autoDecisionService; } partial void OnSelectedChanged(JobViewModel? value) @@ -32,6 +34,9 @@ public partial class FileListViewModel : ObservableObject var job = new SingleJob { InputFile = path }; var vm = _factory.Create(job); Files.Add(vm); + _autoDecisionService.ApplyAutoDecisions(vm); } + + Selected = Files.LastOrDefault(); } } diff --git a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs index bc0bf9e..ba0a093 100644 --- a/Splitter-UI/ViewModels/InspectorPaneViewModel.cs +++ b/Splitter-UI/ViewModels/InspectorPaneViewModel.cs @@ -37,10 +37,10 @@ public partial class InspectorPaneViewModel : ObservableObject private void AdjustRotation(int delta) { - if (Selected?.Job == null) + if ( Selected == null) return; - var r = Selected.Job.Rotate ?? 0; + var r = Selected.Rotate; r = (r + delta) % 360; if (r < 0) r += 360; diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index d854096..213a134 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -5,12 +5,15 @@ using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using splitter.algo; +using splitter.probe; +using splitter.tui; namespace Splitter_UI.ViewModels; public partial class JobViewModel : ObservableObject { - public SingleJob Job { get; } + private SingleJob Job { get; } [ObservableProperty] private VideoInfo? _probe; [ObservableProperty] private PreviewData? _preview = new(null, [], null); @@ -20,13 +23,13 @@ public partial class JobViewModel : ObservableObject [ObservableProperty] private double _sliderLiveValue; [ObservableProperty] private double _positionSeconds; + public string InputFile => Job.InputFile; public double DurationSeconds => Probe?.Duration ?? 0; - public IRelayCommand StepForwardCommand { get; } + public IRelayCommand StepForwardCommand { get; } public IRelayCommand StepBackwardCommand { get; } private readonly IThumbnailService _thumbnails; - private readonly IFileProbeService _fileProbe; private readonly DispatcherTimer _debounceTimer; private readonly Func _detectorFactory; private readonly ILogger _log; @@ -96,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 { get => Job.Rotate; @@ -107,11 +170,21 @@ public partial class JobViewModel : ObservableObject } } - public JobViewModel(SingleJob job, IThumbnailService thumbnails, IFileProbeService fileProbe, Func 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 detectorFactory, ILogger log) { Job = job; _thumbnails = thumbnails; - _fileProbe = fileProbe; _detectorFactory = detectorFactory; _log = log; @@ -139,27 +212,9 @@ public partial class JobViewModel : ObservableObject Interval = TimeSpan.FromSeconds(1) }; _debounceTimer.Tick += DebounceTimerTick; - - _ = Task.Run( LoadThumbnailAsync ); } - private async Task LoadThumbnailAsync() - { - 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() + public async Task CreatePreview() { if ( Probe == null) return; @@ -174,7 +229,7 @@ public partial class JobViewModel : ObservableObject var detector = _detectorFactory(Job.Detect ?? ""); 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); } catch (Exception ex) diff --git a/Splitter-UI/ViewModels/MainViewModel.cs b/Splitter-UI/ViewModels/MainViewModel.cs index 432f695..9f2c2ad 100644 --- a/Splitter-UI/ViewModels/MainViewModel.cs +++ b/Splitter-UI/ViewModels/MainViewModel.cs @@ -10,9 +10,9 @@ public partial class MainViewModel : ViewModelBase public StatusBarViewModel StatusBar { get; } = new StatusBarViewModel(); 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 FileList.SelectedFileChanged += file => { diff --git a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs index a9cdad6..399533b 100644 --- a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs +++ b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; +using splitter.algo; namespace Splitter_UI.ViewModels; @@ -9,8 +10,8 @@ public partial class PreviewPaneViewModel : ObservableObject private JobViewModel? _selected; public PreviewData? Preview => Selected?.Preview; - public Point2f? Sar => Selected?.Probe?.Sar; - public Point2f? Dar => Selected?.Probe?.Dar; + public Point2f? Sar => Selected?.Probe?.Sar; + public int Rotate => Selected?.Rotate ?? 0; partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue) { @@ -22,7 +23,7 @@ public partial class PreviewPaneViewModel : ObservableObject OnPropertyChanged(nameof(Preview)); OnPropertyChanged(nameof(Sar)); - OnPropertyChanged(nameof(Dar)); + OnPropertyChanged(nameof(Rotate)); } private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -33,7 +34,7 @@ public partial class PreviewPaneViewModel : ObservableObject if (e.PropertyName == nameof(JobViewModel.Probe)) { OnPropertyChanged(nameof(Sar)); - OnPropertyChanged(nameof(Dar)); + OnPropertyChanged(nameof(Rotate)); } } } diff --git a/Splitter-UI/Views/InspectorPane.axaml b/Splitter-UI/Views/InspectorPane.axaml index c457207..dbc714e 100644 --- a/Splitter-UI/Views/InspectorPane.axaml +++ b/Splitter-UI/Views/InspectorPane.axaml @@ -57,13 +57,13 @@ x:DataType="vm:InspectorPaneViewModel"> - + - + @@ -82,31 +82,23 @@ x:DataType="vm:InspectorPaneViewModel"> - + - - - - - - + IsChecked="{Binding Selected.ForceFixed}"/> + IsChecked="{Binding Selected.Debug}"/> diff --git a/Splitter-UI/Views/MainWindow.axaml.cs b/Splitter-UI/Views/MainWindow.axaml.cs index a214a7a..19ea835 100644 --- a/Splitter-UI/Views/MainWindow.axaml.cs +++ b/Splitter-UI/Views/MainWindow.axaml.cs @@ -2,7 +2,7 @@ using Avalonia.Controls; 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 MainWindow() diff --git a/Splitter-UI/Views/PreviewCanvas.cs b/Splitter-UI/Views/PreviewCanvas.cs index 0b646b3..9ddfde6 100644 --- a/Splitter-UI/Views/PreviewCanvas.cs +++ b/Splitter-UI/Views/PreviewCanvas.cs @@ -3,6 +3,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Threading; +using splitter.algo; namespace Splitter_UI.Views; @@ -12,8 +13,8 @@ public sealed class PreviewCanvas : Control AvaloniaProperty.Register(nameof(Preview)); public static readonly StyledProperty SarProperty = AvaloniaProperty.Register(nameof(Sar)); - public static readonly StyledProperty DarProperty = - AvaloniaProperty.Register(nameof(Dar)); + public static readonly StyledProperty RotateAngleProperty = + AvaloniaProperty.Register(nameof(RotateAngle)); public PreviewData? Preview { @@ -27,10 +28,10 @@ public sealed class PreviewCanvas : Control set => SetValue(SarProperty, value); } - public Point2f? Dar + public int RotateAngle { - get => GetValue(DarProperty); - set => SetValue(DarProperty, value); + get => GetValue(RotateAngleProperty); + set => SetValue(RotateAngleProperty, value); } static PreviewCanvas() @@ -83,10 +84,12 @@ public sealed class PreviewCanvas : Control if (dispW <= 0 || dispH <= 0) return; - // SAR + var rotate = RotateAngle; // 0, 90, 180, 270 + + // SAR (always original, never rotated) var sar = Sar ?? new Point2f(1, 1); - var sarX = (double)sar.X; - var sarY = (double)sar.Y; + var sarX = sar.X; + var sarY = sar.Y; if (sarX <= 0 || sarY <= 0) { @@ -94,22 +97,23 @@ public sealed class PreviewCanvas : Control sarY = 1; } - // DAR override (only if SAR missing or invalid) - if ((sarX == 1 && sarY == 1) && Dar is { } dar && dar.X > 0 && dar.Y > 0) - { - var darRatio = dar.X / dar.Y; - var encodedRatio = rawW / (double)rawH; - - // recompute SAR from DAR - sarX = darRatio / encodedRatio; - sarY = 1; - } - var pixelAspect = sarX / sarY; - // display size after SAR correction - var displayW = rawW * pixelAspect; - var displayH = rawH; + 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); @@ -132,11 +136,47 @@ public sealed class PreviewCanvas : Control 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( - offsetX + (r.X * pixelAspect) * scale, - offsetY + r.Y * scale, - (r.Width * pixelAspect) * scale, - r.Height * scale); + offsetX + x * scale, + offsetY + y * scale, + w * scale, + h * scale); context.DrawRectangle(null, pen, rr); } diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index 0f380a2..7518d0a 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -13,7 +13,7 @@ Grid.Row="0" Preview="{Binding Preview}" Sar="{Binding Sar}" - Dar="{Binding Dar}" /> + RotateAngle="{Binding Rotate}" />