splitter/Splitter-UI/ViewModels/JobViewModel.cs

413 lines
14 KiB
C#

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<SingleJob> _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<string, IObjectTracker> _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<ParameterEntry> ParametersList { get; }
= new();
public ObservableCollection<Segment> 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<string>()
: 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<string, IObjectTracker> trackerFactory, ILogger log)
{
_f = new ViewModelForwarder<SingleJob>(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<Avalonia.Media.Imaging.Bitmap?> 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;
}
}