diff --git a/Splitter-UI/Models/PreviewData.cs b/Splitter-UI/Models/PreviewData.cs index 23af86d..dd7cd58 100644 --- a/Splitter-UI/Models/PreviewData.cs +++ b/Splitter-UI/Models/PreviewData.cs @@ -2,10 +2,17 @@ namespace Splitter_UI.Models; -public sealed class PreviewData +public class PreviewData { - public Avalonia.Media.Imaging.Bitmap? Frame { get; init; } - public IReadOnlyList FaceBoxes { get; init; } = []; - public IReadOnlyList BodyBoxes { get; init; } = []; - public Rect? CropRect { get; init; } -} + public Avalonia.Media.Imaging.Bitmap? Frame { get; } + public IReadOnlyList DetectedBoxes { get; } + public Rect? CropRect { get; } + + public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList boxes, Rect? crop) + { + Frame = frame; + DetectedBoxes = boxes; + CropRect = crop; + } + +} \ No newline at end of file diff --git a/Splitter-UI/Services/IThumbnailService.cs b/Splitter-UI/Services/IThumbnailService.cs index 6c5ee75..84c1cb9 100644 --- a/Splitter-UI/Services/IThumbnailService.cs +++ b/Splitter-UI/Services/IThumbnailService.cs @@ -5,5 +5,5 @@ namespace Splitter_UI.Services; public interface IThumbnailService { - Task CreateThumbnailAsync(string file, VideoInfo probe); + Task CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null); } diff --git a/Splitter-UI/Services/ThumbnailService.cs b/Splitter-UI/Services/ThumbnailService.cs index 86443a8..427a077 100644 --- a/Splitter-UI/Services/ThumbnailService.cs +++ b/Splitter-UI/Services/ThumbnailService.cs @@ -7,41 +7,47 @@ namespace Splitter_UI.Services; public sealed class ThumbnailService : IThumbnailService { - private readonly int _thumbWidth = 160; - private readonly int _thumbHeight = 90; + private const int _thumbWidth = 160; + private const int _thumbHeight = 90; - // Reusable buffer for BGR24 → 3 bytes per pixel - private readonly byte[] _bgrBuffer; - private readonly byte[] _bgraBuffer; + private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3]; + private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4]; - public ThumbnailService() + public async Task CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null) { - _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3]; - _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4]; - } + width ??= _thumbWidth; + height ??= _thumbHeight; + skip ??= TimeSpan.Zero; + + // buffer for BGR24 → 3 bytes per pixel + + var canUseStaticBuffers = + width.Value == _thumbWidth && + height.Value == _thumbHeight; + + var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3]; + var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4]; - public async Task CreateThumbnailAsync(string file, VideoInfo probe) - { // Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer - bool ok = await DecodeFrameAsync(file); + bool ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value); if (!ok) return null; // Convert BGR24 → BGRA32 - ConvertBgrToBgra(_bgrBuffer, _bgraBuffer, _thumbWidth, _thumbHeight); + ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value); // Create Avalonia Bitmap - return CreateBitmap(_bgraBuffer, _thumbWidth, _thumbHeight); + return CreateBitmap(bgraBuffer, width.Value, height.Value); } - private async Task DecodeFrameAsync(string file) + private static async Task DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height) { // ffmpeg command: decode one frame, resize, output raw BGR24 var args = - $"-ss 0 -t 0.1 -i \"{file}\" " + + $"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " + "-an -sn " + - $"-vf \"scale={_thumbWidth}:{_thumbHeight}:force_original_aspect_ratio=decrease," + - $"pad={_thumbWidth}:{_thumbHeight}:(ow-iw)/2:(oh-ih)/2,format=bgr24\" " + + $"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," + + $"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24\" " + "-f rawvideo -"; var psi = new ProcessStartInfo @@ -57,14 +63,14 @@ public sealed class ThumbnailService : IThumbnailService var p = new Process { StartInfo = psi }; p.Start(); - int needed = _bgrBuffer.Length; + int needed = bgrBuffer.Length; int read = 0; using var stdout = p.StandardOutput.BaseStream; while (read < needed) { - int r = await stdout.ReadAsync(_bgrBuffer, read, needed - read); + int r = await stdout.ReadAsync(bgrBuffer, read, needed - read); if (r == 0) { TryKill(p); diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 5b92c3f..620acfa 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -2,7 +2,9 @@ using System.Collections.Specialized; using System.ComponentModel; using Avalonia.Media.Imaging; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; namespace Splitter_UI.ViewModels; @@ -10,7 +12,8 @@ public partial class JobViewModel : ObservableObject { public SingleJob Job { get; } public VideoInfo? Probe { get; set; } - public PreviewData? Preview { get; set; } + [ObservableProperty] + private PreviewData? _preview = new(null, [], null); public ProgressInfo? Progress { get; set; } [ObservableProperty] @@ -19,8 +22,22 @@ public partial class JobViewModel : ObservableObject [ObservableProperty] private string _suggestedAction = ""; + // This updates continuously + [ObservableProperty] + private double _sliderLiveValue; + + // This updates only on release + [ObservableProperty] + private double _positionSeconds; + + public double DurationSeconds => Probe?.Duration ?? 0; + + public IRelayCommand StepForwardCommand { get; } + public IRelayCommand StepBackwardCommand { get; } + private readonly IThumbnailService _thumbnails; private readonly IFileProbeService _fileProbe; + private readonly DispatcherTimer _debounceTimer; public string FileName => Path.GetFileName(Job.InputFile); @@ -113,6 +130,14 @@ public partial class JobViewModel : ObservableObject ParametersList.CollectionChanged += OnParametersCollectionChanged; + StepForwardCommand = new RelayCommand(StepForward); + StepBackwardCommand = new RelayCommand(StepBackward); + + _debounceTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _debounceTimer.Tick += DebounceTimerTick; _ = Task.Run( LoadThumbnailAsync ); } @@ -122,6 +147,23 @@ public partial class JobViewModel : ObservableObject Probe = await _fileProbe.ProbeAsync(Job); Thumbnail = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe); SuggestedAction = Probe.Rotation == 0 ? "crop" : "rotate"; + + await CreatePreview(); + } + + private async Task CreatePreview() + { + if ( Probe == null) + return; + try + { + var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, TimeSpan.FromSeconds(PositionSeconds), Probe.Width, Probe.Height); + Preview = new PreviewData(frame, [], null); + OnPropertyChanged(nameof(Preview)); + } + catch (Exception ex) + { + } } private void OnParameterChanged(object? sender, PropertyChangedEventArgs e) @@ -153,4 +195,43 @@ public partial class JobViewModel : ObservableObject } } + private void StepForward() + { + var step = 10.0; // seconds + if (DurationSeconds <= 0) + return; + + PositionSeconds = Math.Min(DurationSeconds, PositionSeconds + step); + // trigger seek in your playback pipeline here + } + + private void StepBackward() + { + var step = 10.0; // seconds + if (DurationSeconds <= 0) + return; + + PositionSeconds = Math.Max(0, PositionSeconds - step); + // trigger seek in your playback pipeline here + } + + partial void OnSliderLiveValueChanged(double value) + { + // Restart debounce timer on every slider update + _debounceTimer.Stop(); + _debounceTimer.Start(); + } + + private void DebounceTimerTick(object? sender, EventArgs e) + { + _debounceTimer.Stop(); + + // Commit the final value + PositionSeconds = SliderLiveValue; + } + + partial void OnPositionSecondsChanged(double value) + { + Task.Run(CreatePreview); + } } diff --git a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs index e29d1b4..301044c 100644 --- a/Splitter-UI/ViewModels/PreviewPaneViewModel.cs +++ b/Splitter-UI/ViewModels/PreviewPaneViewModel.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace Splitter_UI.ViewModels; @@ -7,7 +8,23 @@ public partial class PreviewPaneViewModel : ObservableObject [ObservableProperty] private JobViewModel? _selected; - public PreviewPaneViewModel() + public PreviewData? Preview => Selected?.Preview; + + partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue) { + if (oldValue != null) + oldValue.PropertyChanged -= SelectedPropertyChanged; + + if (newValue != null) + newValue.PropertyChanged += SelectedPropertyChanged; + + OnPropertyChanged(nameof(Preview)); + } + + private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(JobViewModel.Preview)) + OnPropertyChanged(nameof(Preview)); } } + diff --git a/Splitter-UI/Views/PreviewCanvas.cs b/Splitter-UI/Views/PreviewCanvas.cs new file mode 100644 index 0000000..8ea8e8d --- /dev/null +++ b/Splitter-UI/Views/PreviewCanvas.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace Splitter_UI.Views; + +public sealed class PreviewCanvas : Control +{ + public static readonly StyledProperty PreviewProperty = + AvaloniaProperty.Register(nameof(Preview)); + + public PreviewData? Preview + { + get => GetValue(PreviewProperty); + set => SetValue(PreviewProperty, value); + } + + static PreviewCanvas() + { + PreviewProperty.Changed.AddClassHandler( + (canvas, args) => + canvas.OnPreviewChanged(args.OldValue as PreviewData, + args.NewValue as PreviewData)); + } + + private void OnPreviewChanged(PreviewData? oldValue, PreviewData? newValue) + { + if (oldValue is INotifyPropertyChanged oldNotify) + oldNotify.PropertyChanged -= PreviewPropertyChanged; + + if (newValue is INotifyPropertyChanged newNotify) + newNotify.PropertyChanged += PreviewPropertyChanged; + + // Always marshal to UI thread + Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render); + } + + private void PreviewPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PreviewData.Frame) || + e.PropertyName == nameof(PreviewData.DetectedBoxes) || + e.PropertyName == nameof(PreviewData.CropRect)) + { + // Always marshal to UI thread + Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render); + } + } + + protected override Size MeasureOverride(Size availableSize) => availableSize; + protected override Size ArrangeOverride(Size finalSize) => finalSize; + + public override void Render(DrawingContext context) + { + var preview = Preview; + if (preview?.Frame is null) + return; + + var frame = preview.Frame; + var rawW = frame.PixelSize.Width; + var rawH = frame.PixelSize.Height; + + var dispW = Bounds.Width; + var dispH = Bounds.Height; + + if (dispW <= 0 || dispH <= 0) + return; + + var scale = Math.Min(dispW / rawW, dispH / rawH); + + var scaledW = rawW * scale; + var scaledH = rawH * scale; + + var offsetX = (dispW - scaledW) / 2; + var offsetY = (dispH - scaledH) / 2; + + // draw frame + context.DrawImage(frame, + new Rect(0, 0, rawW, rawH), + new Rect(offsetX, offsetY, scaledW, scaledH)); + + // draw overlays + if (preview.DetectedBoxes is { Count: > 0 }) + { + var pen = new Pen(Brushes.Lime, 2); + + foreach (var r in preview.DetectedBoxes ) + { + var rr = new Rect( + offsetX + r.X * scale, + offsetY + r.Y * scale, + r.Width * scale, + r.Height * scale); + + context.DrawRectangle(null, pen, rr); + } + } + } +} diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index 15d7c76..5f0e5b9 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -1,24 +1,57 @@ - - + + + + + + + + + + + + + - - - - - - - - + diff --git a/Splitter-UI/Views/PreviewPane.axaml.cs b/Splitter-UI/Views/PreviewPane.axaml.cs index 8852e0b..f59da58 100644 --- a/Splitter-UI/Views/PreviewPane.axaml.cs +++ b/Splitter-UI/Views/PreviewPane.axaml.cs @@ -1,4 +1,8 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.VisualTree; namespace Splitter_UI.Views; @@ -8,4 +12,5 @@ public partial class PreviewPane : UserControl { InitializeComponent(); } + }