using System.Collections.ObjectModel; 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; public partial class JobViewModel : ObservableObject { private SingleJob Job => _f.Model; private ViewModelForwarder _f; public SingleJob GetJob() => Job; [ObservableProperty] private VideoInfo? _probe; [ObservableProperty] private PreviewData? _preview = new(null, [], null, new(0.5f, 0.5f), TimeSpan.Zero, null); [ObservableProperty] private Bitmap? _thumbnail; [ObservableProperty] private double _sliderLiveValue; [ObservableProperty] private double _positionSeconds; public string InputFile => Job.InputFile; public double DurationSeconds => Probe?.Duration ?? 0; public double SegmentDuration { get { if (Probe == null || Probe.Duration <= 0) return 58.0; var target = Job.OverrideTargetDuration ?? 58.0; int segments; double segmentLength; if (Job.ForceFixed) { // Fixed chunk size, last one may be shorter segments = (int)Math.Ceiling(Probe.Duration / target); segmentLength = target; } else { // Equalized segments segments = (int)Math.Ceiling(Probe.Duration / target); segmentLength = Probe.Duration / segments; } return segmentLength; } } public IRelayCommand StepForwardCommand { get; } public IRelayCommand StepBackwardCommand { get; } public IRelayCommand PlayPreviewCommand { get; } private readonly IThumbnailService _thumbnails; private readonly DispatcherTimer _debounceTimer; private readonly Func _trackerFactory; private readonly ILogger _log; public string FileName => Path.GetFileName(Job.InputFile); public string TextDesc => Probe != null ? $"{Probe.Width}x{Probe.Height}, {TimeSpan.FromSeconds(Probe.Duration).ToString(@"hh\:mm\:ss")}), FPS: {Probe.Fps:F2}, Bitrate: {Probe.Bitrate/1024/1024:F2} MB/s" : ""; public override string ToString() => $"{FileName}: {TextDesc}"; public ObservableCollection ParametersList { get; } = new(); public ObservableCollection Segments { get; } = new(); public string CropText { get => Job.Crop is { } c ? $"{c.width},{c.height}" : ""; set { if (string.IsNullOrWhiteSpace(value)) { Job.Crop = null; } else { var parts = value.Split(','); if (parts.Length == 2 && int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h)) Job.Crop = (w, h); } OnPropertyChanged(); } } public string GravitateText { get => Job.GravitateTo is { } p ? $"{p.X:F3},{p.Y:F3}" : ""; set { if (string.IsNullOrWhiteSpace(value)) { Job.GravitateTo = new Point2f(0.5f, 0.5f); } else { var parts = value.Split(','); if (parts.Length == 2 && float.TryParse(parts[0], out var x) && float.TryParse(parts[1], out var y)) Job.GravitateTo = new Point2f(x, y); } OnPropertyChanged(); OnPropertyChanged(nameof(GravitateTo)); } } public string PassthroughText { get => string.Join(' ', Job.Passthrough); set { Job.Passthrough = string.IsNullOrWhiteSpace(value) ? Array.Empty() : value.Split(' ', StringSplitOptions.RemoveEmptyEntries); OnPropertyChanged(); } } public Point2f GravitateTo { get => Job.GravitateTo; set { if (Math.Abs(Job.GravitateTo.X - value.X) < 0.001 && Math.Abs(Job.GravitateTo.Y - value.Y) < 0.001) return; Job.GravitateTo = value; OnPropertyChanged(); OnPropertyChanged(nameof(GravitateText)); } } public string? Detect { get => Job.Detect; set => _f.Forward(value); } public string? Mask { get => Job.Mask; set => _f.Forward(value); } public string OutputFolder { get => Job.OutputFolder; set => _f.Forward(value); } public bool ForceFixed { get => Job.ForceFixed; set => _f.Forward(value); } public bool Debug { get => Job.Debug; set => _f.Forward(value); } public bool Enhance { get => Job.Enhance; set => _f.Forward(value); } public double? OverrideTargetDuration { get => Job.OverrideTargetDuration; set => _f.Forward(value); } public float ScoreThreshold { get => Job.ScoreThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } } public float IdentityThreshold { get => Job.IdentityThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } } public int? Rotate { get => Job.Rotate; set { _f.Forward(value); Task.Run(CreatePreview); } } public float DetectAbove { get => Job.DetectAbove; set { _f.Forward(value); Task.Run(CreatePreview); } } public ulong? DetectId { get => Job.DetectId; set { _f.Forward(value); Task.Run(CreatePreview); } } public JobViewModel(SingleJob job, IThumbnailService thumbnails, Func trackerFactory, ILogger log) { _f = new ViewModelForwarder(job, this.OnPropertyChanged); _thumbnails = thumbnails; _trackerFactory = trackerFactory; _log = log; ParametersList.Add(new ParameterEntry("DropoutToleranceFrames" , "")); ParametersList.Add(new ParameterEntry("EmaFactor" , "")); ParametersList.Add(new ParameterEntry("CameraEasing" , "")); ParametersList.Add(new ParameterEntry("LostFreezeFrames" , "")); ParametersList.Add(new ParameterEntry("RotationDetectorSampleCount" , "")); ParametersList.Add(new ParameterEntry("RotationDetectorSampleLength", "")); ParametersList.Add(new ParameterEntry("RotationDetectorFrameWidth" , "")); ParametersList.Add(new ParameterEntry("RotationDetectorFrameHeight" , "")); foreach (var entry in ParametersList) { entry.PropertyChanged += OnParameterChanged; } PropertyChanged += (sender, e) => { if (e.PropertyName == nameof(Probe)) { if (Segments.Count == 0) GenerateSegments(); OnPropertyChanged(nameof(DurationSeconds)); } }; ParametersList.CollectionChanged += OnParametersCollectionChanged; StepForwardCommand = new RelayCommand(StepForward); StepBackwardCommand = new RelayCommand(StepBackward); PlayPreviewCommand = new RelayCommand(PlayPreview); _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _debounceTimer.Tick += DebounceTimerTick; } public void GenerateSegments() { Segments.Clear(); if (Probe == null || Probe.Duration <= 0) return; var duration = SegmentDuration; var segments = (int)Math.Ceiling(Probe.Duration / duration); for (int i = 0; i < segments; i++) { var start = i * duration; var end = Math.Min(start + duration, Probe.Duration); Segments.Add(new Segment(start, end)); } } public void CopyFrom(JobViewModel src) { Job.CopyFrom(src.Job); OnPropertyChanged(string.Empty); // Refresh all properties } public async Task CreatePreview() { if ( Probe == null) return; try { var pos = TimeSpan.FromSeconds(PositionSeconds); Bitmap? frame; if (Preview?.Frame == null || Preview.Position != pos) frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, pos, Probe.Width, Probe.Height, Job.Rotate); else frame = Preview.Frame; if ( frame == null ) return; Preview = new PreviewData(frame, [], null, Job.GravitateTo, pos, Job.Rotate); var tracker = _trackerFactory(Job.Detect ?? ""); var j = new SingleTask ( Job : Job, Info : Probe, OutputFileName : "preview.jpg", SegmentIndex : 0, TotalSegments : 1, SegmentStart : PositionSeconds, SegmentLength : 1, // 1 second segment for detection ProcessorFactory: _ => throw new NotImplementedException() ); var (detections, primaryDetection) = tracker.SelectTrackedObject(j, frame.ToMatContinuous(), j.Job.GravitateTo); Rect? crop = null; var w = Probe.Width; var h = Probe.Height; var cropWidth = Job.Crop?.width ?? CommandLine.DefaultW; var cropHeight = Job.Crop?.height ?? CommandLine.DefaultH; var p = primaryDetection?.Center ?? new Point2f(w * Job.GravitateTo.X, h * Job.GravitateTo.Y); var cx = p.X - cropWidth / 2f; var cy = p.Y - cropHeight / 2f; var r = new Rect(cx, cy, cropWidth, cropHeight); crop = ClampCrop(r, w, h); Preview = new PreviewData(frame, detections ?? [], crop, Job.GravitateTo, pos, Job.Rotate); OnPropertyChanged(nameof(SegmentDuration)); } catch (Exception ex) { _log.LogError($"Error creating preview for {FileName}: {ex.Message}"); } } private static Rect ClampCrop(Rect r, float w, float h) { var x = r.X; var y = r.Y; var cw = r.Width; var ch = r.Height; if (x < 0) x = 0; if (y < 0) y = 0; if (x + cw > w) x = w - cw; if (y + ch > h) y = h - ch; if (x < 0) x = 0; if (y < 0) y = 0; return new Rect(x, y, cw, ch); } private void OnParameterChanged(object? sender, PropertyChangedEventArgs e) { if (sender is ParameterEntry p && e.PropertyName == nameof(ParameterEntry.Value)) { Job.Parameters[p.Key] = p.Value; } } private void OnParametersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems != null) { foreach (ParameterEntry p in e.NewItems) { Job.Parameters[p.Key] = p.Value; p.PropertyChanged += OnParameterChanged; } } if (e.OldItems != null) { foreach (ParameterEntry p in e.OldItems) { Job.Parameters.Remove(p.Key); p.PropertyChanged -= OnParameterChanged; } } } private void StepForward() { if (Segments.Count <= 1) return; var current = GetCurrentSegment(); if ( current < 0 || current >= Segments.Count - 1 ) return; SliderLiveValue = Segments[current + 1].Start; } private void StepBackward() { if (Segments.Count <= 0) return; var current = GetCurrentSegment(); if (current <= 0) { SliderLiveValue = 0; return; } if (SliderLiveValue > Segments[current].Start) SliderLiveValue = Segments[current].Start; else SliderLiveValue = Segments[current - 1].Start; } private void PlayPreview() { // Implementation for playing preview } private int GetCurrentSegment() { double pos = SliderLiveValue; for (int i = 0; i < Segments.Count; i++) { var s = Segments[i]; if (pos < s.Start) return i - 1; if (pos == s.Start) return i; } return -1; } 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); } public async Task GetThumbnail(double positionSec) { if (Probe == null) return null; var pos = TimeSpan.FromSeconds(positionSec); var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, pos, ThumbnailService.ThumbWidth, ThumbnailService.ThumbHeight, Job.Rotate).ConfigureAwait(false); //frame.Save($"c:\\temp\\thmb-{positionSec:N4}.png"); return frame; } }