mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Compare commits
No commits in common. "master" and "v0.1.0" have entirely different histories.
@ -1,298 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Point = Avalonia.Point;
|
|
||||||
|
|
||||||
namespace Splitter_UI.Controls
|
|
||||||
{
|
|
||||||
public sealed class PreviewSlider : Control
|
|
||||||
{
|
|
||||||
public static readonly StyledProperty<double> MinimumProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(Minimum), 0d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> MaximumProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(Maximum), 100d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> ValueProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(
|
|
||||||
nameof(Value), 0d,
|
|
||||||
coerce: (o, v) =>
|
|
||||||
{
|
|
||||||
var slider = (PreviewSlider)o;
|
|
||||||
if (v < slider.Minimum) return slider.Minimum;
|
|
||||||
if (v > slider.Maximum) return slider.Maximum;
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> SegmentDurationProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(SegmentDuration), 1d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> TrackThicknessProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(TrackThickness), 4d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> ThumbRadiusProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(ThumbRadius), 8d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush> TrackBrushProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(TrackBrush), Brushes.Gray);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush> TrackFillBrushProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(TrackFillBrush), Brushes.DodgerBlue);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush> ThumbBrushProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(ThumbBrush), Brushes.White);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush> ThumbBorderBrushProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(ThumbBorderBrush), Brushes.DodgerBlue);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> ThumbBorderThicknessProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(ThumbBorderThickness), 1d);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush> SegmentLineBrushProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(SegmentLineBrush), Brushes.LightSalmon);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> SegmentLineThicknessProperty =
|
|
||||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(SegmentLineThickness), 1d);
|
|
||||||
|
|
||||||
private bool _isDragging;
|
|
||||||
|
|
||||||
public double Minimum
|
|
||||||
{
|
|
||||||
get => GetValue(MinimumProperty);
|
|
||||||
set => SetValue(MinimumProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double Maximum
|
|
||||||
{
|
|
||||||
get => GetValue(MaximumProperty);
|
|
||||||
set => SetValue(MaximumProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double Value
|
|
||||||
{
|
|
||||||
get => GetValue(ValueProperty);
|
|
||||||
set => SetValue(ValueProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double SegmentDuration
|
|
||||||
{
|
|
||||||
get => GetValue(SegmentDurationProperty);
|
|
||||||
set => SetValue(SegmentDurationProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double TrackThickness
|
|
||||||
{
|
|
||||||
get => GetValue(TrackThicknessProperty);
|
|
||||||
set => SetValue(TrackThicknessProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double ThumbRadius
|
|
||||||
{
|
|
||||||
get => GetValue(ThumbRadiusProperty);
|
|
||||||
set => SetValue(ThumbRadiusProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IBrush TrackBrush
|
|
||||||
{
|
|
||||||
get => GetValue(TrackBrushProperty);
|
|
||||||
set => SetValue(TrackBrushProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IBrush TrackFillBrush
|
|
||||||
{
|
|
||||||
get => GetValue(TrackFillBrushProperty);
|
|
||||||
set => SetValue(TrackFillBrushProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IBrush ThumbBrush
|
|
||||||
{
|
|
||||||
get => GetValue(ThumbBrushProperty);
|
|
||||||
set => SetValue(ThumbBrushProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IBrush ThumbBorderBrush
|
|
||||||
{
|
|
||||||
get => GetValue(ThumbBorderBrushProperty);
|
|
||||||
set => SetValue(ThumbBorderBrushProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double ThumbBorderThickness
|
|
||||||
{
|
|
||||||
get => GetValue(ThumbBorderThicknessProperty);
|
|
||||||
set => SetValue(ThumbBorderThicknessProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IBrush SegmentLineBrush
|
|
||||||
{
|
|
||||||
get => GetValue(SegmentLineBrushProperty);
|
|
||||||
set => SetValue(SegmentLineBrushProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double SegmentLineThickness
|
|
||||||
{
|
|
||||||
get => GetValue(SegmentLineThicknessProperty);
|
|
||||||
set => SetValue(SegmentLineThicknessProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
static PreviewSlider()
|
|
||||||
{
|
|
||||||
FocusableProperty.OverrideDefaultValue<PreviewSlider>(true);
|
|
||||||
|
|
||||||
ValueProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
|
||||||
MinimumProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
|
||||||
MaximumProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
|
||||||
SegmentDurationProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public PreviewSlider()
|
|
||||||
{
|
|
||||||
ClipToBounds = true;
|
|
||||||
|
|
||||||
AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
|
||||||
AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
|
|
||||||
AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
|
|
||||||
AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost, RoutingStrategies.Tunnel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Render(DrawingContext context)
|
|
||||||
{
|
|
||||||
base.Render(context);
|
|
||||||
|
|
||||||
var bounds = Bounds;
|
|
||||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var centerY = bounds.Height / 2.0;
|
|
||||||
var left = ThumbRadius;
|
|
||||||
var right = bounds.Width - ThumbRadius;
|
|
||||||
|
|
||||||
var trackThickness = TrackThickness;
|
|
||||||
var trackRect = new Rect(left, centerY - trackThickness / 2.0, right - left, trackThickness);
|
|
||||||
|
|
||||||
context.FillRectangle(TrackBrush, trackRect);
|
|
||||||
|
|
||||||
var range = Maximum - Minimum;
|
|
||||||
if (SegmentDuration > 0 && range > 0 && SegmentLineBrush != null && SegmentLineThickness > 0)
|
|
||||||
{
|
|
||||||
var pen = new Pen(SegmentLineBrush, SegmentLineThickness);
|
|
||||||
var totalSegments = (int)Math.Floor(range / SegmentDuration);
|
|
||||||
|
|
||||||
for (var i = 1; i <= totalSegments; i++)
|
|
||||||
{
|
|
||||||
var segmentValue = Minimum + i * SegmentDuration;
|
|
||||||
var tSeg = (segmentValue - Minimum) / range;
|
|
||||||
var xSeg = left + tSeg * (right - left);
|
|
||||||
|
|
||||||
var p1 = new Point(xSeg, centerY - trackThickness);
|
|
||||||
var p2 = new Point(xSeg, centerY + trackThickness);
|
|
||||||
context.DrawLine(pen, p1, p2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var t = (range <= 0) ? 0.0 : (Value - Minimum) / range;
|
|
||||||
t = Math.Clamp(t, 0.0, 1.0);
|
|
||||||
|
|
||||||
var thumbX = left + t * (right - left);
|
|
||||||
|
|
||||||
var fillRect = new Rect(left, centerY - trackThickness / 2.0, thumbX - left, trackThickness);
|
|
||||||
context.FillRectangle(TrackFillBrush, fillRect);
|
|
||||||
|
|
||||||
var thumbRadius = ThumbRadius;
|
|
||||||
var thumbCenter = new Point(thumbX, centerY);
|
|
||||||
|
|
||||||
var ellipse = new EllipseGeometry(new Rect(
|
|
||||||
thumbCenter.X - thumbRadius,
|
|
||||||
thumbCenter.Y - thumbRadius,
|
|
||||||
thumbRadius * 2,
|
|
||||||
thumbRadius * 2));
|
|
||||||
|
|
||||||
context.DrawGeometry(ThumbBrush, null, ellipse);
|
|
||||||
|
|
||||||
if (ThumbBorderThickness > 0 && ThumbBorderBrush != null)
|
|
||||||
{
|
|
||||||
var pen = new Pen(ThumbBorderBrush, ThumbBorderThickness);
|
|
||||||
context.DrawGeometry(null, pen, ellipse);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnPointerWheelChanged(e);
|
|
||||||
|
|
||||||
var delta = e.Delta.Y;
|
|
||||||
if (delta == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var step = (Maximum - Minimum) / 100.0;
|
|
||||||
if (step <= 0)
|
|
||||||
step = 1.0;
|
|
||||||
|
|
||||||
if (delta > 0)
|
|
||||||
Value = Math.Clamp(Value - step, Minimum, Maximum);
|
|
||||||
else
|
|
||||||
Value = Math.Clamp(Value + step, Minimum, Maximum);
|
|
||||||
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!IsEnabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
|
||||||
return;
|
|
||||||
|
|
||||||
e.Pointer.Capture(this);
|
|
||||||
UpdateValueFromPoint(e.GetPosition(this));
|
|
||||||
_isDragging = true;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_isDragging)
|
|
||||||
return;
|
|
||||||
|
|
||||||
UpdateValueFromPoint(e.GetPosition(this));
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_isDragging)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_isDragging = false;
|
|
||||||
e.Pointer.Capture(null);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
|
||||||
{
|
|
||||||
_isDragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateValueFromPoint(Point point)
|
|
||||||
{
|
|
||||||
var bounds = Bounds;
|
|
||||||
var left = ThumbRadius;
|
|
||||||
var right = bounds.Width - ThumbRadius;
|
|
||||||
|
|
||||||
if (right <= left)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var x = Math.Clamp(point.X, left, right);
|
|
||||||
var t = (x - left) / (right - left);
|
|
||||||
|
|
||||||
var newValue = Minimum + t * (Maximum - Minimum);
|
|
||||||
Value = newValue;
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,957 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Globalization;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Point = Avalonia.Point;
|
|
||||||
|
|
||||||
namespace Splitter_UI.Controls;
|
|
||||||
|
|
||||||
public class TimelinePreviewSlider : Control, IDisposable
|
|
||||||
{
|
|
||||||
// Public properties
|
|
||||||
public static readonly StyledProperty<JobViewModel?> ViewModelProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, JobViewModel?>(nameof(ViewModel));
|
|
||||||
|
|
||||||
public JobViewModel? ViewModel
|
|
||||||
{
|
|
||||||
get => GetValue(ViewModelProperty);
|
|
||||||
set => SetValue(ViewModelProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double PixelsPerSecond
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0 || Bounds.Width <= 0)
|
|
||||||
return 10000; // fallback value
|
|
||||||
|
|
||||||
// Full control width maps to full video duration
|
|
||||||
return Bounds.Width / vm.DurationSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush?> SegmentFillProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(SegmentFill), Brushes.DimGray);
|
|
||||||
|
|
||||||
public IBrush? SegmentFill
|
|
||||||
{
|
|
||||||
get => GetValue(SegmentFillProperty);
|
|
||||||
set => SetValue(SegmentFillProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush?> MarkerStrokeProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(MarkerStroke), Brushes.White);
|
|
||||||
|
|
||||||
public IBrush? MarkerStroke
|
|
||||||
{
|
|
||||||
get => GetValue(MarkerStrokeProperty);
|
|
||||||
set => SetValue(MarkerStrokeProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visual constants
|
|
||||||
private const double _timelineHeight = 80;
|
|
||||||
private const double _markerLineHeight = 36;
|
|
||||||
private const double _markerLineWidth = 2;
|
|
||||||
private const double _markerTriangleSize = 8;
|
|
||||||
private const double _segmentBarHeight = 40;
|
|
||||||
private const int _maxPreviewCacheItems = 128;
|
|
||||||
|
|
||||||
// Internal state
|
|
||||||
private readonly LruCache<string, Bitmap> _previewCache = new(_maxPreviewCacheItems);
|
|
||||||
private readonly Dictionary<string, CancellationTokenSource> _previewLoadCts = new();
|
|
||||||
private readonly object _cacheLock = new();
|
|
||||||
|
|
||||||
private IDisposable? _segmentsSubscription;
|
|
||||||
private bool _isInternalSliderUpdate;
|
|
||||||
private JobViewModel? _currentVm;
|
|
||||||
|
|
||||||
// Interaction state
|
|
||||||
private bool _isPointerCaptured;
|
|
||||||
private Point _lastPointerPoint;
|
|
||||||
private DragMode _dragMode = DragMode.None;
|
|
||||||
private int _activeSegmentIndex = -1;
|
|
||||||
private bool _isSplitModifierActive;
|
|
||||||
|
|
||||||
// Throttle invalidation during drag
|
|
||||||
private DateTime _lastInvalidate = DateTime.MinValue;
|
|
||||||
private readonly TimeSpan _invalidateThrottle = TimeSpan.FromMilliseconds(16); // ~60Hz
|
|
||||||
|
|
||||||
public TimelinePreviewSlider()
|
|
||||||
{
|
|
||||||
Focusable = true;
|
|
||||||
Height = _timelineHeight;
|
|
||||||
ClipToBounds = true;
|
|
||||||
|
|
||||||
// Use property change override instead of GetObservable.Subscribe to avoid IObserver compile issues.
|
|
||||||
PointerPressed += OnPointerPressed;
|
|
||||||
PointerMoved += OnPointerMoved;
|
|
||||||
PointerReleased += OnPointerReleased;
|
|
||||||
PointerCaptureLost += OnPointerCaptureLost;
|
|
||||||
KeyDown += OnKeyDown;
|
|
||||||
KeyUp += OnKeyUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override to detect ViewModel property changes
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
||||||
{
|
|
||||||
base.OnPropertyChanged(change);
|
|
||||||
|
|
||||||
if (change.Property == ViewModelProperty)
|
|
||||||
{
|
|
||||||
OnViewModelChanged((JobViewModel?)change.NewValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnViewModelChanged(JobViewModel? vm)
|
|
||||||
{
|
|
||||||
UnsubscribeFromViewModel();
|
|
||||||
_previewCache.Clear();
|
|
||||||
CancelAllPreviewLoads();
|
|
||||||
|
|
||||||
if (vm != null)
|
|
||||||
{
|
|
||||||
_segmentsSubscription = SubscribeToSegments(vm.Segments);
|
|
||||||
vm.PropertyChanged += OnVmPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName == nameof(JobViewModel.SliderLiveValue))
|
|
||||||
{
|
|
||||||
if (_isInternalSliderUpdate)
|
|
||||||
return;
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IDisposable SubscribeToSegments(ObservableCollection<Segment> segments)
|
|
||||||
{
|
|
||||||
NotifyCollectionChangedEventHandler handler = (s, e) =>
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(() => {
|
|
||||||
InvalidateVisual();
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
};
|
|
||||||
segments.CollectionChanged += handler;
|
|
||||||
return Disposable.Create(() => segments.CollectionChanged -= handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnsubscribeFromViewModel()
|
|
||||||
{
|
|
||||||
if (_currentVm != null)
|
|
||||||
_currentVm.PropertyChanged -= OnVmPropertyChanged;
|
|
||||||
|
|
||||||
_currentVm = null;
|
|
||||||
|
|
||||||
_segmentsSubscription?.Dispose();
|
|
||||||
_segmentsSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelAllPreviewLoads()
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
foreach (var cts in _previewLoadCts.Values)
|
|
||||||
{
|
|
||||||
try { cts.Cancel(); } catch { }
|
|
||||||
}
|
|
||||||
_previewLoadCts.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Render(DrawingContext dc)
|
|
||||||
{
|
|
||||||
base.Render(dc);
|
|
||||||
|
|
||||||
var vm = ViewModel;
|
|
||||||
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
dc.FillRectangle(Brushes.Black, bounds);
|
|
||||||
|
|
||||||
DrawContinuousPreviewStrip(dc);
|
|
||||||
DrawGapOverlays(dc);
|
|
||||||
DrawOverlongSegmentOverlays(dc);
|
|
||||||
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0 || vm.Segments.Count == 0)
|
|
||||||
{
|
|
||||||
// draw empty ruler
|
|
||||||
DrawRuler(dc, 0, vm?.DurationSeconds ?? 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw segments and previews
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
var segRect = SegmentRectFor(seg);
|
|
||||||
if (segRect.Width <= 0) continue;
|
|
||||||
|
|
||||||
// Segment background
|
|
||||||
var segBrush = new Pen(SegmentFill ?? Brushes.DimGray, 1);
|
|
||||||
var segRoundedRect = new Rect(segRect.X, segRect.Y + (Bounds.Height - _segmentBarHeight) / 2, segRect.Width, _segmentBarHeight);
|
|
||||||
var geom = new StreamGeometry();
|
|
||||||
using (var ctx = geom.Open())
|
|
||||||
{
|
|
||||||
ctx.BeginFigure(new Point(segRoundedRect.X, segRoundedRect.Y), true);
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y));
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y + segRoundedRect.Height));
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X, segRoundedRect.Y + segRoundedRect.Height));
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
dc.DrawGeometry(null, segBrush, geom);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw markers on top
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
DrawMarker(dc, seg.Start, true);
|
|
||||||
DrawMarker(dc, seg.End, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current position indicator
|
|
||||||
DrawPositionIndicator(dc, vm.SliderLiveValue);
|
|
||||||
|
|
||||||
// Draw ruler
|
|
||||||
DrawRuler(dc, 0, vm.DurationSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawRuler(DrawingContext dc, double startSec, double endSec)
|
|
||||||
{
|
|
||||||
var height = Bounds.Height;
|
|
||||||
var y = height - 18;
|
|
||||||
var pen = new Pen(Brushes.Gray, 1);
|
|
||||||
dc.DrawLine(pen, new Point(0, y), new Point(Bounds.Width, y));
|
|
||||||
|
|
||||||
if (ViewModel == null || ViewModel.DurationSeconds <= 0) return;
|
|
||||||
|
|
||||||
var totalSec = ViewModel.DurationSeconds;
|
|
||||||
var approxTicks = Math.Max(2, (int)(Bounds.Width / 100));
|
|
||||||
var tickSec = Math.Max(1.0, totalSec / approxTicks);
|
|
||||||
|
|
||||||
for (double t = 0; t <= totalSec; t += tickSec)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(t);
|
|
||||||
dc.DrawLine(pen, new Point(x, y), new Point(x, y - 6));
|
|
||||||
var text = FormatTime(t);
|
|
||||||
var textBrush = Brushes.LightGray; // or new SolidColorBrush(Color.Parse("#FFCCCCCC"));
|
|
||||||
var ft = new FormattedText(
|
|
||||||
text,
|
|
||||||
CultureInfo.CurrentUICulture,
|
|
||||||
FlowDirection.LeftToRight,
|
|
||||||
Typeface.Default,
|
|
||||||
12,
|
|
||||||
textBrush);
|
|
||||||
|
|
||||||
dc.DrawText(ft, new Point(x + 2, y - 18));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawContinuousPreviewStrip(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
double currentX = 0;
|
|
||||||
double endX = Bounds.Width;
|
|
||||||
|
|
||||||
var bmp = GetPreview(0);
|
|
||||||
var noPreviewAvailable = bmp == null;
|
|
||||||
if (bmp == null)
|
|
||||||
{
|
|
||||||
StartPreviewLoad(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var previewScale = (double)stripHeight / bmp.PixelSize.Height;
|
|
||||||
var previewTileWidth = bmp.PixelSize.Width * previewScale;
|
|
||||||
|
|
||||||
using (dc.PushClip(new Rect(0, stripY, Bounds.Width, stripHeight)))
|
|
||||||
{
|
|
||||||
while (currentX < endX)
|
|
||||||
{
|
|
||||||
double posSec = PixelToSeconds(currentX);
|
|
||||||
if (posSec < 0) posSec = 0;
|
|
||||||
if (posSec > vm.DurationSeconds) posSec = vm.DurationSeconds;
|
|
||||||
|
|
||||||
bmp = GetPreview(posSec);
|
|
||||||
|
|
||||||
if (bmp == null)
|
|
||||||
{
|
|
||||||
StartPreviewLoad(posSec);
|
|
||||||
|
|
||||||
// advance by estimated width
|
|
||||||
currentX += previewTileWidth;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// scale full frame to strip height
|
|
||||||
double scale = stripHeight / bmp.PixelSize.Height;
|
|
||||||
double tileWidth = bmp.PixelSize.Width * scale;
|
|
||||||
|
|
||||||
var src = new Rect(0, 0, bmp.PixelSize.Width, bmp.PixelSize.Height);
|
|
||||||
var dst = new Rect(currentX, stripY, tileWidth, stripHeight);
|
|
||||||
|
|
||||||
dc.DrawImage(bmp, src, dst);
|
|
||||||
|
|
||||||
currentX += tileWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawGapOverlays(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.Segments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
var gapBrush = new SolidColorBrush(Color.FromArgb(190, 80, 80, 80));
|
|
||||||
|
|
||||||
double lastEnd = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
|
|
||||||
if (seg.Start > lastEnd)
|
|
||||||
{
|
|
||||||
double gapLeft = SecondsToPixel(lastEnd);
|
|
||||||
double gapRight = SecondsToPixel(seg.Start);
|
|
||||||
double w = gapRight - gapLeft;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
lastEnd = seg.End;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tail gap
|
|
||||||
if (lastEnd < vm.DurationSeconds)
|
|
||||||
{
|
|
||||||
double gapLeft = SecondsToPixel(lastEnd);
|
|
||||||
double gapRight = SecondsToPixel(vm.DurationSeconds);
|
|
||||||
double w = gapRight - gapLeft;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawOverlongSegmentOverlays(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.Segments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (vm.OverrideTargetDuration <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
var overBrush = new SolidColorBrush(Color.FromArgb(128, 255, 0, 0)); // 50% red
|
|
||||||
|
|
||||||
foreach (var seg in vm.Segments)
|
|
||||||
{
|
|
||||||
double length = seg.End - seg.Start;
|
|
||||||
if (length <= vm.OverrideTargetDuration)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
double left = SecondsToPixel(seg.Start);
|
|
||||||
double right = SecondsToPixel(seg.End);
|
|
||||||
double w = right - left;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(overBrush, new Rect(left, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Bitmap? GetPreview(double pos)
|
|
||||||
{
|
|
||||||
var key = PreviewCacheKey(pos);
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewCache.TryGet(key, out var bmp);
|
|
||||||
return bmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartPreviewLoad(double pos)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var key = PreviewCacheKey(pos);
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_previewLoadCts.ContainsKey(key) || _previewCache.ContainsKey(key))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
_previewLoadCts[key] = cts;
|
|
||||||
|
|
||||||
// Run an async loader on threadpool
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// call host-provided async GetPreview
|
|
||||||
var bmp = await vm.GetThumbnail(pos).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (bmp != null && !cts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewCache.Add(key, bmp);
|
|
||||||
_previewLoadCts.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify UI thread to redraw
|
|
||||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewLoadCts.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
|
||||||
}
|
|
||||||
}, cts.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private string PreviewCacheKey(double pos) => $"{pos:F3}";
|
|
||||||
|
|
||||||
private Rect SegmentRectFor(Segment seg)
|
|
||||||
{
|
|
||||||
var left = SecondsToPixel(seg.Start);
|
|
||||||
var right = SecondsToPixel(seg.End);
|
|
||||||
var y = 0;
|
|
||||||
return new Rect(left, y, Math.Max(0, right - left), Bounds.Height);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double SecondsToPixel(double seconds)
|
|
||||||
{
|
|
||||||
return seconds * PixelsPerSecond;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double PixelToSeconds(double px)
|
|
||||||
{
|
|
||||||
return px / PixelsPerSecond;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawMarker(DrawingContext dc, double seconds, bool isStart)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(seconds);
|
|
||||||
var top = (Bounds.Height - _segmentBarHeight) / 2 - _markerTriangleSize - 2;
|
|
||||||
var lineTop = top + _markerTriangleSize + 2;
|
|
||||||
var lineBottom = lineTop + _markerLineHeight;
|
|
||||||
|
|
||||||
double midY = top + _markerTriangleSize / 2.0;
|
|
||||||
|
|
||||||
var tri = new StreamGeometry();
|
|
||||||
using (var ctx = tri.Open())
|
|
||||||
{
|
|
||||||
if (isStart)
|
|
||||||
{
|
|
||||||
// segment is to the right -> triangle points right
|
|
||||||
var vTop = new Point(x, top);
|
|
||||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
|
||||||
var point = new Point(x + _markerTriangleSize, midY);
|
|
||||||
|
|
||||||
ctx.BeginFigure(point, true);
|
|
||||||
ctx.LineTo(vBottom);
|
|
||||||
ctx.LineTo(vTop);
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// segment is to the left -> triangle points left
|
|
||||||
var vTop = new Point(x, top);
|
|
||||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
|
||||||
var point = new Point(x - _markerTriangleSize, midY);
|
|
||||||
|
|
||||||
ctx.BeginFigure(point, true);
|
|
||||||
ctx.LineTo(vTop);
|
|
||||||
ctx.LineTo(vBottom);
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.DrawGeometry(MarkerStroke ?? Brushes.White, null, tri);
|
|
||||||
|
|
||||||
var pen = new Pen(MarkerStroke ?? Brushes.White, _markerLineWidth);
|
|
||||||
dc.DrawLine(pen, new Point(x, lineTop), new Point(x, lineBottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawPositionIndicator(DrawingContext dc, double seconds)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(seconds);
|
|
||||||
var pen = new Pen(Brushes.Red, 1.5);
|
|
||||||
dc.DrawLine(pen, new Point(x, 0), new Point(x, Bounds.Height));
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatTime(double seconds)
|
|
||||||
{
|
|
||||||
var ts = TimeSpan.FromSeconds(Math.Max(0, seconds));
|
|
||||||
if (ts.TotalHours >= 1)
|
|
||||||
return $"{(int)ts.TotalHours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}";
|
|
||||||
return $"{ts.Minutes:D2}:{ts.Seconds:D2}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interaction
|
|
||||||
|
|
||||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
Focus();
|
|
||||||
|
|
||||||
var p = e.GetPosition(this);
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
_isSplitModifierActive = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
|
||||||
|
|
||||||
var hit = HitTestAtPoint(p);
|
|
||||||
if (hit.Type == HitType.StartMarker)
|
|
||||||
{
|
|
||||||
BeginDrag(DragMode.DragStartMarker, hit.SegmentIndex, e);
|
|
||||||
}
|
|
||||||
else if (hit.Type == HitType.EndMarker)
|
|
||||||
{
|
|
||||||
BeginDrag(DragMode.DragEndMarker, hit.SegmentIndex, e);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// any other hit just moves playhead
|
|
||||||
SetPlayheadFromPoint(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isSplitModifierActive && hit.Type == HitType.SegmentBody)
|
|
||||||
{
|
|
||||||
var sec = PixelToSeconds(p.X);
|
|
||||||
TrySplitSegmentAt(hit.SegmentIndex, sec);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Pointer.Capture(this);
|
|
||||||
_isPointerCaptured = true;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_isPointerCaptured) return;
|
|
||||||
var p = e.GetPosition(this);
|
|
||||||
|
|
||||||
if (_dragMode == DragMode.None)
|
|
||||||
{
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
|
|
||||||
var sec = PixelToSeconds(p.X);
|
|
||||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
|
||||||
|
|
||||||
switch (_dragMode)
|
|
||||||
{
|
|
||||||
case DragMode.DragStartMarker:
|
|
||||||
MoveSegmentStart(_activeSegmentIndex, sec);
|
|
||||||
break;
|
|
||||||
case DragMode.DragEndMarker:
|
|
||||||
MoveSegmentEnd(_activeSegmentIndex, sec);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isInternalSliderUpdate = true;
|
|
||||||
vm.SliderLiveValue = sec;
|
|
||||||
_isInternalSliderUpdate = false;
|
|
||||||
|
|
||||||
ThrottledInvalidate();
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_isPointerCaptured)
|
|
||||||
{
|
|
||||||
e.Pointer.Capture(null);
|
|
||||||
_isPointerCaptured = false;
|
|
||||||
}
|
|
||||||
_dragMode = DragMode.None;
|
|
||||||
_activeSegmentIndex = -1;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
|
||||||
{
|
|
||||||
_isPointerCaptured = false;
|
|
||||||
_dragMode = DragMode.None;
|
|
||||||
_activeSegmentIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
|
||||||
{
|
|
||||||
_isSplitModifierActive = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.Key == Key.Delete)
|
|
||||||
{
|
|
||||||
TryDeleteCurrentSegment();
|
|
||||||
e.Handled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
|
||||||
_isSplitModifierActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryDeleteCurrentSegment()
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double pos = vm.SliderLiveValue;
|
|
||||||
|
|
||||||
int idx = -1;
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var s = vm.Segments[i];
|
|
||||||
if (pos >= s.Start && pos <= s.End)
|
|
||||||
{
|
|
||||||
idx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idx == -1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
if (idx >= 0 && idx < vm.Segments.Count)
|
|
||||||
vm.Segments.RemoveAt(idx);
|
|
||||||
|
|
||||||
if (vm.Segments.Count == 0)
|
|
||||||
vm.GenerateSegments();
|
|
||||||
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void BeginDrag(DragMode mode, int segmentIndex, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
_dragMode = mode;
|
|
||||||
_activeSegmentIndex = segmentIndex;
|
|
||||||
_lastPointerPoint = e.GetPosition(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ThrottledInvalidate()
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
if (now - _lastInvalidate > _invalidateThrottle)
|
|
||||||
{
|
|
||||||
_lastInvalidate = now;
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetPlayheadFromPoint(Point p)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double sec = PixelToSeconds(p.X);
|
|
||||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
|
||||||
|
|
||||||
_isInternalSliderUpdate = true;
|
|
||||||
vm.SliderLiveValue = sec;
|
|
||||||
_isInternalSliderUpdate = false;
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentStart(int index, double newStart)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var min = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
|
||||||
var max = seg.End - 0.001;
|
|
||||||
var clamped = Math.Max(min, Math.Min(max, newStart));
|
|
||||||
if (Math.Abs(clamped - seg.Start) < 1e-6) return;
|
|
||||||
|
|
||||||
var newSeg = seg with { Start = clamped };
|
|
||||||
vm.Segments[index] = newSeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentEnd(int index, double newEnd)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var min = seg.Start + 0.001;
|
|
||||||
var max = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
|
||||||
var clamped = Math.Max(min, Math.Min(max, newEnd));
|
|
||||||
if (Math.Abs(clamped - seg.End) < 1e-6) return;
|
|
||||||
|
|
||||||
var newSeg = seg with { End = clamped };
|
|
||||||
vm.Segments[index] = newSeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentByDelta(int index, double deltaSec)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var leftLimit = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
|
||||||
var rightLimit = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
|
||||||
|
|
||||||
var newStart = seg.Start + deltaSec;
|
|
||||||
var newEnd = seg.End + deltaSec;
|
|
||||||
|
|
||||||
// clamp so segment stays within neighbors
|
|
||||||
var segLength = seg.End - seg.Start;
|
|
||||||
if (newStart < leftLimit)
|
|
||||||
{
|
|
||||||
newStart = leftLimit;
|
|
||||||
newEnd = newStart + segLength;
|
|
||||||
}
|
|
||||||
if (newEnd > rightLimit)
|
|
||||||
{
|
|
||||||
newEnd = rightLimit;
|
|
||||||
newStart = newEnd - segLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
// apply
|
|
||||||
vm.Segments[index] = seg with { Start = newStart, End = newEnd };
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TrySplitSegmentAt(int index, double sec)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
if (sec <= seg.Start + 0.001 || sec >= seg.End - 0.001) return;
|
|
||||||
|
|
||||||
var left = seg with { End = sec };
|
|
||||||
var right = seg with { Start = sec };
|
|
||||||
vm.Segments[index] = left;
|
|
||||||
vm.Segments.Insert(index + 1, right);
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
private HitResult HitTestAtPoint(Point p)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return new HitResult(HitType.None, -1);
|
|
||||||
|
|
||||||
double topRegion = Bounds.Height / 4.0;
|
|
||||||
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
double startX = SecondsToPixel(seg.Start);
|
|
||||||
double endX = SecondsToPixel(seg.End);
|
|
||||||
|
|
||||||
// marker hit only in top 1/4
|
|
||||||
if (p.Y >= 0 && p.Y <= topRegion)
|
|
||||||
{
|
|
||||||
// start marker triangle footprint: [startX .. startX + size]
|
|
||||||
if (p.X >= startX && p.X <= startX + _markerTriangleSize)
|
|
||||||
return new HitResult(HitType.StartMarker, i);
|
|
||||||
|
|
||||||
// end marker triangle footprint: [endX - size .. endX]
|
|
||||||
if (p.X >= endX - _markerTriangleSize && p.X <= endX)
|
|
||||||
return new HitResult(HitType.EndMarker, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// segment body (no drag, only click/split)
|
|
||||||
if (p.X >= startX && p.X <= endX && p.Y >= 0 && p.Y <= Bounds.Height)
|
|
||||||
return new HitResult(HitType.SegmentBody, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HitResult(HitType.Gap, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IDisposable
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
UnsubscribeFromViewModel();
|
|
||||||
CancelAllPreviewLoads();
|
|
||||||
_previewCache.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers and small types
|
|
||||||
|
|
||||||
private enum DragMode
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
DragStartMarker,
|
|
||||||
DragEndMarker
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum HitType
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
StartMarker,
|
|
||||||
EndMarker,
|
|
||||||
SegmentBody,
|
|
||||||
Gap
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly struct HitResult
|
|
||||||
{
|
|
||||||
public HitType Type { get; }
|
|
||||||
public int SegmentIndex { get; }
|
|
||||||
public HitResult(HitType type, int idx) { Type = type; SegmentIndex = idx; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple LRU cache for Bitmaps
|
|
||||||
private class LruCache<TKey, TValue> where TKey : notnull where TValue : class
|
|
||||||
{
|
|
||||||
private readonly int _capacity;
|
|
||||||
private readonly Dictionary<TKey, LinkedListNode<(TKey key, TValue value)>> _map;
|
|
||||||
private readonly LinkedList<(TKey key, TValue value)> _list;
|
|
||||||
private readonly object _sync = new();
|
|
||||||
|
|
||||||
public LruCache(int capacity)
|
|
||||||
{
|
|
||||||
_capacity = Math.Max(1, capacity);
|
|
||||||
_map = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>();
|
|
||||||
_list = new LinkedList<(TKey, TValue)>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryGet(TKey key, out TValue? value)
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
if (_map.TryGetValue(key, out var node))
|
|
||||||
{
|
|
||||||
value = node.Value.value;
|
|
||||||
_list.Remove(node);
|
|
||||||
_list.AddFirst(node);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
value = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Add(TKey key, TValue value)
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
if (_map.TryGetValue(key, out var node))
|
|
||||||
{
|
|
||||||
_list.Remove(node);
|
|
||||||
_map.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newNode = new LinkedListNode<(TKey, TValue)>((key, value));
|
|
||||||
_list.AddFirst(newNode);
|
|
||||||
_map[key] = newNode;
|
|
||||||
|
|
||||||
if (_map.Count > _capacity)
|
|
||||||
{
|
|
||||||
var last = _list.Last!;
|
|
||||||
_map.Remove(last.Value.key);
|
|
||||||
_list.RemoveLast();
|
|
||||||
if (last.Value.value is IDisposable d)
|
|
||||||
{
|
|
||||||
try { d.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool ContainsKey(TKey key)
|
|
||||||
{
|
|
||||||
lock (_sync) return _map.ContainsKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
foreach (var node in _list)
|
|
||||||
{
|
|
||||||
if (node.value is IDisposable d)
|
|
||||||
{
|
|
||||||
try { d.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_map.Clear();
|
|
||||||
_list.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disposable helper
|
|
||||||
private static class Disposable
|
|
||||||
{
|
|
||||||
public static IDisposable Create(Action dispose)
|
|
||||||
{
|
|
||||||
return new AnonymousDisposable(dispose);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class AnonymousDisposable : IDisposable
|
|
||||||
{
|
|
||||||
private Action? _dispose;
|
|
||||||
public AnonymousDisposable(Action dispose) { _dispose = dispose; }
|
|
||||||
public void Dispose() { var d = Interlocked.Exchange(ref _dispose, null); d?.Invoke(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,10 @@ internal sealed class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
BuildAvaloniaApp()
|
var services = ConfigureServices();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
BuildAvaloniaApp(provider)
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,11 +37,8 @@ internal sealed class Program
|
|||||||
// splitter services
|
// splitter services
|
||||||
services.AddSingleton<UltraFaceDetector>();
|
services.AddSingleton<UltraFaceDetector>();
|
||||||
services.AddSingleton<YoloV10ObjectDetector>();
|
services.AddSingleton<YoloV10ObjectDetector>();
|
||||||
services.AddSingleton<DummyDetector>();
|
|
||||||
services.AddSingleton<OSNetEmbeddingExtractor>();
|
services.AddSingleton<OSNetEmbeddingExtractor>();
|
||||||
services.AddSingleton<IObjectTracker, ObjectTracker>();
|
services.AddSingleton<IObjectTracker, ObjectTracker>();
|
||||||
services.AddSingleton<IBufferPool, BufferPool>();
|
|
||||||
services.AddSingleton<IMatToBitmapConverter, MatToBitmapConverter>();
|
|
||||||
services.AddKeyedSingleton<IObjectDetector>("face", (x,_) => new SingleThreadedDetector<UltraFaceDetector>(x.GetRequiredService<UltraFaceDetector>()));
|
services.AddKeyedSingleton<IObjectDetector>("face", (x,_) => new SingleThreadedDetector<UltraFaceDetector>(x.GetRequiredService<UltraFaceDetector>()));
|
||||||
services.AddKeyedSingleton<IObjectDetector>("body", (x,_) => new SingleThreadedDetector<YoloV10ObjectDetector>(x.GetRequiredService<YoloV10ObjectDetector>()));
|
services.AddKeyedSingleton<IObjectDetector>("body", (x,_) => new SingleThreadedDetector<YoloV10ObjectDetector>(x.GetRequiredService<YoloV10ObjectDetector>()));
|
||||||
services.AddKeyedSingleton<IObjectDetector>("none", (x,_) => new SingleThreadedDetector<DummyDetector>(x.GetRequiredService<DummyDetector>()));
|
services.AddKeyedSingleton<IObjectDetector>("none", (x,_) => new SingleThreadedDetector<DummyDetector>(x.GetRequiredService<DummyDetector>()));
|
||||||
@ -64,12 +64,8 @@ internal sealed class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp(ServiceProvider provider)
|
||||||
{
|
=> AppBuilder.Configure<App>(() => new App(provider))
|
||||||
var services = ConfigureServices();
|
|
||||||
var provider = services.BuildServiceProvider();
|
|
||||||
|
|
||||||
return AppBuilder.Configure<App>(() => new App(provider))
|
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.With(new FontManagerOptions
|
.With(new FontManagerOptions
|
||||||
{
|
{
|
||||||
@ -85,4 +81,3 @@ internal sealed class Program
|
|||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
namespace Splitter_UI.Services;
|
|
||||||
|
|
||||||
public sealed class BufferPool : IBufferPool
|
|
||||||
{
|
|
||||||
private readonly int _capacity;
|
|
||||||
|
|
||||||
public sealed class Entry
|
|
||||||
{
|
|
||||||
public readonly int Width;
|
|
||||||
public readonly int Height;
|
|
||||||
public readonly byte[] Bgr;
|
|
||||||
public readonly byte[] Bgra;
|
|
||||||
|
|
||||||
public Entry(int w, int h)
|
|
||||||
{
|
|
||||||
Width = w;
|
|
||||||
Height = h;
|
|
||||||
Bgr = new byte[w * h * 3];
|
|
||||||
Bgra = new byte[w * h * 4];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Dictionary<(int w, int h), LinkedListNode<Entry>> _map;
|
|
||||||
private readonly LinkedList<Entry> _lru;
|
|
||||||
|
|
||||||
public BufferPool()
|
|
||||||
{
|
|
||||||
_capacity = 8;
|
|
||||||
_map = new Dictionary<(int w, int h), LinkedListNode<Entry>>(_capacity);
|
|
||||||
_lru = new LinkedList<Entry>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Entry Get(int w, int h)
|
|
||||||
{
|
|
||||||
var key = (w, h);
|
|
||||||
|
|
||||||
if (_map.TryGetValue(key, out var node))
|
|
||||||
{
|
|
||||||
_lru.Remove(node);
|
|
||||||
_lru.AddLast(node);
|
|
||||||
return node.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var created = new Entry(w, h);
|
|
||||||
var newNode = new LinkedListNode<Entry>(created);
|
|
||||||
|
|
||||||
_lru.AddLast(newNode);
|
|
||||||
_map[key] = newNode;
|
|
||||||
|
|
||||||
if (_lru.Count > _capacity)
|
|
||||||
{
|
|
||||||
var first = _lru.First!;
|
|
||||||
_lru.RemoveFirst();
|
|
||||||
_map.Remove((first.Value.Width, first.Value.Height));
|
|
||||||
}
|
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
namespace Splitter_UI.Services;
|
|
||||||
|
|
||||||
public interface IBufferPool
|
|
||||||
{
|
|
||||||
BufferPool.Entry Get(int w, int h);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
using Avalonia.Media.Imaging;
|
|
||||||
|
|
||||||
namespace Splitter_UI.Services;
|
|
||||||
|
|
||||||
public interface IMatToBitmapConverter
|
|
||||||
{
|
|
||||||
Bitmap Convert(Mat mat, Bitmap? existing = null);
|
|
||||||
Bitmap Convert(byte[] bgr, int width, int height, Bitmap? existing = null);
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using Avalonia.Platform;
|
|
||||||
|
|
||||||
namespace Splitter_UI.Services;
|
|
||||||
|
|
||||||
public sealed class MatToBitmapConverter(IBufferPool _pool) : IMatToBitmapConverter
|
|
||||||
{
|
|
||||||
private readonly object _sync = new();
|
|
||||||
|
|
||||||
public Bitmap Convert(Mat mat, Bitmap? existing = null)
|
|
||||||
{
|
|
||||||
if (mat.Empty())
|
|
||||||
throw new ArgumentException("Mat is empty.", nameof(mat));
|
|
||||||
|
|
||||||
var w = mat.Width;
|
|
||||||
var h = mat.Height;
|
|
||||||
var channels = mat.Channels();
|
|
||||||
|
|
||||||
if (channels != 3 && channels != 4)
|
|
||||||
throw new NotSupportedException($"Only 3 or 4 channel Mats are supported. Got {channels}.");
|
|
||||||
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
var entry = _pool.Get(w, h);
|
|
||||||
|
|
||||||
var src = mat;
|
|
||||||
if (!src.IsContinuous())
|
|
||||||
src = src.Clone();
|
|
||||||
|
|
||||||
unsafe
|
|
||||||
{
|
|
||||||
var srcPtr = (byte*)src.DataPointer;
|
|
||||||
var totalBytes = w * h * channels;
|
|
||||||
|
|
||||||
if (channels == 3)
|
|
||||||
{
|
|
||||||
fixed (byte* dstBgr = entry.Bgr)
|
|
||||||
{
|
|
||||||
Buffer.MemoryCopy(srcPtr, dstBgr, entry.Bgr.Length, totalBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConvertBgrToBgra(entry.Bgr, entry.Bgra, w, h);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fixed (byte* dstBgra = entry.Bgra)
|
|
||||||
{
|
|
||||||
Buffer.MemoryCopy(srcPtr, dstBgra, entry.Bgra.Length, totalBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing is WriteableBitmap wb &&
|
|
||||||
wb.PixelSize.Width == w &&
|
|
||||||
wb.PixelSize.Height == h)
|
|
||||||
{
|
|
||||||
UpdateWriteableBitmap(wb, entry.Bgra, w, h);
|
|
||||||
return wb;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateBitmap(entry.Bgra, w, h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Bitmap Convert(byte[] bgr, int width, int height, Bitmap? existing = null)
|
|
||||||
{
|
|
||||||
var entry = _pool.Get(width, height);
|
|
||||||
ConvertBgrToBgra(bgr, entry.Bgra, width, height);
|
|
||||||
|
|
||||||
if (existing is WriteableBitmap wb &&
|
|
||||||
wb.PixelSize.Width == width &&
|
|
||||||
wb.PixelSize.Height == height)
|
|
||||||
{
|
|
||||||
UpdateWriteableBitmap(wb, entry.Bgra, width, height);
|
|
||||||
return wb;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateBitmap(entry.Bgra, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static void ConvertBgrToBgra(byte[] bgr, byte[] bgra, int width, int height)
|
|
||||||
{
|
|
||||||
var si = 0;
|
|
||||||
var di = 0;
|
|
||||||
var totalPixels = width * height;
|
|
||||||
|
|
||||||
for (var i = 0; i < totalPixels; i++)
|
|
||||||
{
|
|
||||||
bgra[di + 0] = bgr[si + 0];
|
|
||||||
bgra[di + 1] = bgr[si + 1];
|
|
||||||
bgra[di + 2] = bgr[si + 2];
|
|
||||||
bgra[di + 3] = 255;
|
|
||||||
|
|
||||||
si += 3;
|
|
||||||
di += 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe void UpdateWriteableBitmap(WriteableBitmap wb, byte[] bgra, int width, int height)
|
|
||||||
{
|
|
||||||
using var fb = wb.Lock();
|
|
||||||
|
|
||||||
var dstPtr = (byte*)fb.Address;
|
|
||||||
var dstStride = fb.RowBytes;
|
|
||||||
var srcStride = width * 4;
|
|
||||||
|
|
||||||
fixed (byte* srcPtr = bgra)
|
|
||||||
{
|
|
||||||
for (var y = 0; y < height; y++)
|
|
||||||
{
|
|
||||||
var srcRow = srcPtr + y * srcStride;
|
|
||||||
var dstRow = dstPtr + y * dstStride;
|
|
||||||
|
|
||||||
Buffer.MemoryCopy(srcRow, dstRow, dstStride, srcStride);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height)
|
|
||||||
{
|
|
||||||
var stride = width * 4;
|
|
||||||
|
|
||||||
fixed (byte* p = bgra)
|
|
||||||
{
|
|
||||||
return new WriteableBitmap(
|
|
||||||
PixelFormat.Bgra8888,
|
|
||||||
AlphaFormat.Premul,
|
|
||||||
(nint)p,
|
|
||||||
new PixelSize(width, height),
|
|
||||||
new Vector(96, 96),
|
|
||||||
stride);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,19 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
|
||||||
namespace Splitter_UI.Services;
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
public sealed class ThumbnailService : IThumbnailService
|
public sealed class ThumbnailService : IThumbnailService
|
||||||
{
|
{
|
||||||
public const int ThumbWidth = 160;
|
private const int _thumbWidth = 160;
|
||||||
public const int ThumbHeight = 90;
|
private const int _thumbHeight = 90;
|
||||||
|
|
||||||
private readonly IMatToBitmapConverter _converter;
|
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
|
||||||
private readonly IBufferPool _pool;
|
private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
|
||||||
|
|
||||||
private SemaphoreSlim _lock = new(1,1);
|
public SemaphoreSlim _lock = new(1,1);
|
||||||
|
|
||||||
public ThumbnailService(
|
|
||||||
IMatToBitmapConverter converter,
|
|
||||||
IBufferPool pool)
|
|
||||||
{
|
|
||||||
_converter = converter;
|
|
||||||
_pool = pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Bitmap?> CreateThumbnailAsync(
|
public async Task<Bitmap?> CreateThumbnailAsync(
|
||||||
string file,
|
string file,
|
||||||
@ -55,37 +49,36 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
int? height = null,
|
int? height = null,
|
||||||
int? rotateDegree = null)
|
int? rotateDegree = null)
|
||||||
{
|
{
|
||||||
width ??= ThumbWidth;
|
width ??= _thumbWidth;
|
||||||
height ??= ThumbHeight;
|
height ??= _thumbHeight;
|
||||||
skip ??= TimeSpan.Zero;
|
skip ??= TimeSpan.Zero;
|
||||||
|
|
||||||
var entry = _pool.Get(width.Value, height.Value);
|
// buffer for BGR24 → 3 bytes per pixel
|
||||||
|
|
||||||
var ok = await DecodeFrameAsync(
|
var canUseStaticBuffers =
|
||||||
entry.Bgr,
|
width.Value == _thumbWidth &&
|
||||||
file,
|
height.Value == _thumbHeight;
|
||||||
skip.Value,
|
|
||||||
width.Value,
|
|
||||||
height.Value,
|
|
||||||
rotateDegree
|
|
||||||
);
|
|
||||||
|
|
||||||
|
var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3];
|
||||||
|
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
||||||
|
|
||||||
|
// Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer
|
||||||
|
var ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value, rotateDegree);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return _converter.Convert(entry.Bgr, width.Value, height.Value);
|
// Convert BGR24 → BGRA32
|
||||||
|
ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
|
||||||
|
|
||||||
|
// Create Avalonia Bitmap
|
||||||
|
return CreateBitmap(bgraBuffer, width.Value, height.Value, rotateDegree == 90 || rotateDegree == 270);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> DecodeFrameAsync(
|
private static async Task<bool> DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height, int? rotateDegree)
|
||||||
byte[] bgrBuffer,
|
|
||||||
string file,
|
|
||||||
TimeSpan skip,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
int? rotateDegree)
|
|
||||||
{
|
{
|
||||||
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
|
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
|
||||||
|
|
||||||
|
// ffmpeg command: decode one frame, resize, output raw BGR24
|
||||||
var args =
|
var args =
|
||||||
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
|
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
|
||||||
"-an -sn " +
|
"-an -sn " +
|
||||||
@ -130,4 +123,45 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
{
|
{
|
||||||
try { p.Kill(); } catch { }
|
try { p.Kill(); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConvertBgrToBgra(byte[] bgr, byte[] bgra, int width, int height)
|
||||||
|
{
|
||||||
|
var si = 0;
|
||||||
|
var di = 0;
|
||||||
|
|
||||||
|
var totalPixels = width * height;
|
||||||
|
|
||||||
|
for (var i = 0; i < totalPixels; i++)
|
||||||
|
{
|
||||||
|
bgra[di + 0] = bgr[si + 0]; // B
|
||||||
|
bgra[di + 1] = bgr[si + 1]; // G
|
||||||
|
bgra[di + 2] = bgr[si + 2]; // R
|
||||||
|
bgra[di + 3] = 255; // A
|
||||||
|
|
||||||
|
si += 3;
|
||||||
|
di += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height, bool isRotated)
|
||||||
|
{
|
||||||
|
if (isRotated)
|
||||||
|
{
|
||||||
|
(height, width) = (width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stride = width * 4;
|
||||||
|
|
||||||
|
fixed (byte* p = bgra)
|
||||||
|
{
|
||||||
|
return new Bitmap(
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Premul,
|
||||||
|
(nint)p,
|
||||||
|
new PixelSize(width, height),
|
||||||
|
new Vector(96, 96),
|
||||||
|
stride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,17 +18,17 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
<PackageReference Include="Avalonia" Version="12.0.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.3" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -10,8 +10,7 @@ namespace Splitter_UI.ViewModels;
|
|||||||
|
|
||||||
public partial class JobViewModel : ObservableObject
|
public partial class JobViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private SingleJob Job => _f.Model;
|
private SingleJob Job { get; }
|
||||||
private ViewModelForwarder<SingleJob> _f;
|
|
||||||
|
|
||||||
public SingleJob GetJob() => Job;
|
public SingleJob GetJob() => Job;
|
||||||
|
|
||||||
@ -23,38 +22,9 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
public string InputFile => Job.InputFile;
|
public string InputFile => Job.InputFile;
|
||||||
public double DurationSeconds => Probe?.Duration ?? 0;
|
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 StepForwardCommand { get; }
|
||||||
public IRelayCommand StepBackwardCommand { get; }
|
public IRelayCommand StepBackwardCommand { get; }
|
||||||
public IRelayCommand PlayPreviewCommand { get; }
|
|
||||||
|
|
||||||
private readonly IThumbnailService _thumbnails;
|
private readonly IThumbnailService _thumbnails;
|
||||||
private readonly DispatcherTimer _debounceTimer;
|
private readonly DispatcherTimer _debounceTimer;
|
||||||
@ -72,8 +42,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
||||||
= new();
|
= new();
|
||||||
|
|
||||||
public ObservableCollection<Segment> Segments { get; } = new();
|
|
||||||
|
|
||||||
public string CropText
|
public string CropText
|
||||||
{
|
{
|
||||||
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
|
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
|
||||||
@ -129,6 +97,115 @@ public partial class JobViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? Detect
|
||||||
|
{
|
||||||
|
get => Job.Detect;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.Detect == value)
|
||||||
|
return;
|
||||||
|
Job.Detect = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public float ScoreThreshold
|
||||||
|
{
|
||||||
|
get => Job.ScoreThreshold;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Math.Abs(Job.ScoreThreshold - value) < 0.001)
|
||||||
|
return;
|
||||||
|
Job.ScoreThreshold = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public float IdentityThreshold
|
||||||
|
{
|
||||||
|
get => Job.IdentityThreshold;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Math.Abs(Job.IdentityThreshold - value) < 0.001)
|
||||||
|
return;
|
||||||
|
Job.IdentityThreshold = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bool Enhance
|
||||||
|
{
|
||||||
|
get => Job.Enhance;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.Enhance == value)
|
||||||
|
return;
|
||||||
|
Job.Enhance = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? Rotate
|
||||||
|
{
|
||||||
|
get => Job.Rotate;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Job.Rotate = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Point2f GravitateTo
|
public Point2f GravitateTo
|
||||||
{
|
{
|
||||||
get => Job.GravitateTo;
|
get => Job.GravitateTo;
|
||||||
@ -143,22 +220,47 @@ public partial class JobViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? Detect { get => Job.Detect; set => _f.Forward(value); }
|
public float DetectAbove
|
||||||
public string? Mask { get => Job.Mask; set => _f.Forward(value); }
|
{
|
||||||
public string OutputFolder { get => Job.OutputFolder; set => _f.Forward(value); }
|
get => Job.DetectAbove;
|
||||||
public bool ForceFixed { get => Job.ForceFixed; set => _f.Forward(value); }
|
set
|
||||||
public bool Debug { get => Job.Debug; set => _f.Forward(value); }
|
{
|
||||||
public bool Enhance { get => Job.Enhance; set => _f.Forward(value); }
|
if (Math.Abs(Job.DetectAbove - value) < 0.001 )
|
||||||
public double? OverrideTargetDuration { get => Job.OverrideTargetDuration; set => _f.Forward(value); }
|
return;
|
||||||
public float ScoreThreshold { get => Job.ScoreThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
Job.DetectAbove = value;
|
||||||
public float IdentityThreshold { get => Job.IdentityThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
OnPropertyChanged();
|
||||||
public int? Rotate { get => Job.Rotate; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
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 ulong? DetectId
|
||||||
|
{
|
||||||
|
get => Job.DetectId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (DetectId == value)
|
||||||
|
return;
|
||||||
|
Job.DetectId = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, IObjectTracker> trackerFactory, ILogger log)
|
public JobViewModel(SingleJob job, IThumbnailService thumbnails, Func<string, IObjectTracker> trackerFactory, ILogger log)
|
||||||
{
|
{
|
||||||
_f = new ViewModelForwarder<SingleJob>(job, this.OnPropertyChanged);
|
Job = job;
|
||||||
_thumbnails = thumbnails;
|
_thumbnails = thumbnails;
|
||||||
_trackerFactory = trackerFactory;
|
_trackerFactory = trackerFactory;
|
||||||
_log = log;
|
_log = log;
|
||||||
@ -181,8 +283,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(Probe))
|
if (e.PropertyName == nameof(Probe))
|
||||||
{
|
{
|
||||||
if (Segments.Count == 0)
|
|
||||||
GenerateSegments();
|
|
||||||
OnPropertyChanged(nameof(DurationSeconds));
|
OnPropertyChanged(nameof(DurationSeconds));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -190,7 +290,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
StepForwardCommand = new RelayCommand(StepForward);
|
StepForwardCommand = new RelayCommand(StepForward);
|
||||||
StepBackwardCommand = new RelayCommand(StepBackward);
|
StepBackwardCommand = new RelayCommand(StepBackward);
|
||||||
PlayPreviewCommand = new RelayCommand(PlayPreview);
|
|
||||||
|
|
||||||
_debounceTimer = new DispatcherTimer
|
_debounceTimer = new DispatcherTimer
|
||||||
{
|
{
|
||||||
@ -199,21 +298,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
_debounceTimer.Tick += DebounceTimerTick;
|
_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)
|
public void CopyFrom(JobViewModel src)
|
||||||
{
|
{
|
||||||
Job.CopyFrom(src.Job);
|
Job.CopyFrom(src.Job);
|
||||||
@ -270,7 +354,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
crop = ClampCrop(r, w, h);
|
crop = ClampCrop(r, w, h);
|
||||||
|
|
||||||
Preview = new PreviewData(frame, detections ?? [], crop, Job.GravitateTo, pos, Job.Rotate);
|
Preview = new PreviewData(frame, detections ?? [], crop, Job.GravitateTo, pos, Job.Rotate);
|
||||||
OnPropertyChanged(nameof(SegmentDuration));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -329,53 +412,24 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
private void StepForward()
|
private void StepForward()
|
||||||
{
|
{
|
||||||
if (Segments.Count <= 1)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var current = GetCurrentSegment();
|
var step = DurationSeconds * 0.1; // 10% of total duration
|
||||||
if ( current < 0 || current >= Segments.Count - 1 )
|
|
||||||
return;
|
|
||||||
|
|
||||||
SliderLiveValue = Segments[current + 1].Start;
|
SliderLiveValue = Math.Min(DurationSeconds, SliderLiveValue + step);
|
||||||
|
// trigger seek in your playback pipeline here
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StepBackward()
|
private void StepBackward()
|
||||||
{
|
{
|
||||||
if (Segments.Count <= 0)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var current = GetCurrentSegment();
|
var step = DurationSeconds * 0.1; // 10% of total duration
|
||||||
if (current <= 0)
|
|
||||||
{
|
|
||||||
SliderLiveValue = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SliderLiveValue > Segments[current].Start)
|
SliderLiveValue = Math.Max(0, SliderLiveValue - step);
|
||||||
SliderLiveValue = Segments[current].Start;
|
// trigger seek in your playback pipeline here
|
||||||
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)
|
partial void OnSliderLiveValueChanged(double value)
|
||||||
@ -398,15 +452,4 @@ public partial class JobViewModel : ObservableObject
|
|||||||
Task.Run(CreatePreview);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, file.Segments, _cancellationTokenSource.Token);
|
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, _cancellationTokenSource.Token);
|
||||||
jobs.AddRange(fileJobs);
|
jobs.AddRange(fileJobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
|
|
||||||
namespace Splitter_UI.ViewModels;
|
|
||||||
|
|
||||||
internal class ViewModelForwarder<TModel>
|
|
||||||
{
|
|
||||||
public readonly TModel Model;
|
|
||||||
private readonly Action<string> _onPropertyChanged;
|
|
||||||
|
|
||||||
public ViewModelForwarder(TModel model, Action<string> onPropertyChanged)
|
|
||||||
{
|
|
||||||
Model = model;
|
|
||||||
_onPropertyChanged = onPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Forward<T>(
|
|
||||||
T newValue,
|
|
||||||
[CallerMemberName] string? propertyName = null)
|
|
||||||
{
|
|
||||||
var modelType = typeof(TModel);
|
|
||||||
var prop = modelType.GetProperty(propertyName!, BindingFlags.Public | BindingFlags.Instance);
|
|
||||||
if (prop == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var oldValue = (T)prop.GetValue(Model)!;
|
|
||||||
|
|
||||||
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
|
|
||||||
return;
|
|
||||||
|
|
||||||
prop.SetValue(Model, newValue);
|
|
||||||
_onPropertyChanged(propertyName!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,12 +3,11 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
xmlns:local="clr-namespace:Splitter_UI.Views"
|
xmlns:local="clr-namespace:Splitter_UI.Views"
|
||||||
xmlns:controls="clr-namespace:Splitter_UI.Controls"
|
|
||||||
x:Class="Splitter_UI.Views.PreviewPane"
|
x:Class="Splitter_UI.Views.PreviewPane"
|
||||||
x:DataType="vm:PreviewPaneViewModel">
|
x:DataType="vm:PreviewPaneViewModel">
|
||||||
|
|
||||||
<Border Background="#202020" Padding="10">
|
<Border Background="#202020" Padding="10">
|
||||||
<Grid RowDefinitions="*,Auto,Auto">
|
<Grid RowDefinitions="*,Auto">
|
||||||
|
|
||||||
<local:PreviewCanvas
|
<local:PreviewCanvas
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
@ -21,24 +20,6 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid Grid.Row="1"
|
<Grid Grid.Row="1"
|
||||||
ColumnDefinitions="Auto"
|
|
||||||
Margin="0,10,0,0">
|
|
||||||
<Button Grid.Column="1"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Width="24" Height="24"
|
|
||||||
Padding="0"
|
|
||||||
Margin="0,0,5,0"
|
|
||||||
Command="{Binding Selected.PlayPreviewCommand}">
|
|
||||||
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
|
||||||
Text=""
|
|
||||||
FontSize="12"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
HorizontalAlignment="Center" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid Grid.Row="2"
|
|
||||||
ColumnDefinitions="Auto,*,Auto"
|
ColumnDefinitions="Auto,*,Auto"
|
||||||
Margin="0,10,0,0">
|
Margin="0,10,0,0">
|
||||||
|
|
||||||
@ -55,8 +36,10 @@
|
|||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<controls:TimelinePreviewSlider Grid.Column="1"
|
<Slider Grid.Column="1"
|
||||||
ViewModel="{Binding Selected}"
|
Minimum="0"
|
||||||
|
Maximum="{Binding Selected.DurationSeconds}"
|
||||||
|
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
||||||
Margin="5,0,5,0" />
|
Margin="5,0,5,0" />
|
||||||
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
namespace splitter;
|
|
||||||
|
|
||||||
public static class DebugOverlay
|
|
||||||
{
|
|
||||||
public static void DrawDebug(
|
|
||||||
Mat frame,
|
|
||||||
List<DetectedPerson> objects,
|
|
||||||
CameraController camera,
|
|
||||||
KalmanTracker kalman)
|
|
||||||
{
|
|
||||||
if (camera.ObjectBox.HasValue)
|
|
||||||
{
|
|
||||||
var fb = camera.ObjectBox.Value;
|
|
||||||
Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
Cv2.Circle(frame,
|
|
||||||
new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y),
|
|
||||||
6, Scalar.LimeGreen, -1);
|
|
||||||
|
|
||||||
Cv2.Rectangle(frame, camera.Roi,
|
|
||||||
camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3);
|
|
||||||
|
|
||||||
DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White);
|
|
||||||
DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White);
|
|
||||||
DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White);
|
|
||||||
DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void DrawText(Mat img, string text, int x, int y, Scalar color)
|
|
||||||
{
|
|
||||||
Cv2.PutText(img, text, new Point(x, y),
|
|
||||||
HersheyFonts.HersheySimplex, 0.6, color, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
public interface IJobProcessor
|
public interface IJobProcessor
|
||||||
{
|
{
|
||||||
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token);
|
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token);
|
||||||
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
|
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
|
||||||
}
|
}
|
||||||
@ -5,7 +5,7 @@ namespace splitter;
|
|||||||
|
|
||||||
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
|
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
|
||||||
{
|
{
|
||||||
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token)
|
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token)
|
||||||
{
|
{
|
||||||
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
|
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
|
||||||
|
|
||||||
@ -78,31 +78,24 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso
|
|||||||
processorFactory = i => new SimpleSplitter(i, _logger);
|
processorFactory = i => new SimpleSplitter(i, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
var segmentsToUse = predefinedSegments;
|
var jobs = Enumerable.Range(0, segments)
|
||||||
|
.Select(i => new SingleTask
|
||||||
if (predefinedSegments.Count == 0)
|
|
||||||
{
|
|
||||||
segmentsToUse = Enumerable.Range(0, segments).Select(i => new Segment
|
|
||||||
(
|
|
||||||
Start: i * segmentLength,
|
|
||||||
End : (i == segments - 1)
|
|
||||||
? Math.Max(0.1, info.Duration)
|
|
||||||
: (i + 1) * segmentLength
|
|
||||||
)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return segmentsToUse.Select((s, i) => new SingleTask
|
|
||||||
(
|
(
|
||||||
Job : job,
|
Job : job,
|
||||||
Info: info,
|
Info: info,
|
||||||
OutputFileName : BuildOutputFileName(job, i),
|
OutputFileName : BuildOutputFileName(job, i),
|
||||||
SegmentIndex : i,
|
SegmentIndex : i,
|
||||||
TotalSegments : predefinedSegments.Count,
|
TotalSegments : segments,
|
||||||
SegmentStart : s.Start,
|
SegmentStart : i * segmentLength,
|
||||||
SegmentLength : s.End - s.Start,
|
SegmentLength : (i == segments - 1)
|
||||||
|
? Math.Max(0.1, info.Duration - i * segmentLength)
|
||||||
|
: segmentLength,
|
||||||
ProcessorFactory : processorFactory
|
ProcessorFactory : processorFactory
|
||||||
)
|
)
|
||||||
).ToList();
|
)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token)
|
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token)
|
||||||
|
|||||||
@ -3,189 +3,9 @@ using System.Globalization;
|
|||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
||||||
{
|
{
|
||||||
// ------------------------------------------------------------
|
|
||||||
// Internal state (opaque to caller)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private sealed class State : IFrameProcessingState
|
|
||||||
{
|
|
||||||
public Process? DecodeProcess { get; set; }
|
|
||||||
public Stream? DecodeStdout { get; set; }
|
|
||||||
|
|
||||||
public string InputFile { get; }
|
|
||||||
public double Start { get; }
|
|
||||||
public double Length { get; }
|
|
||||||
public int? Rotate { get; }
|
|
||||||
public string[] Passthrough { get; }
|
|
||||||
public VideoInfo Info { get; }
|
|
||||||
public bool PlainText { get; }
|
|
||||||
|
|
||||||
public State(SingleTask job)
|
|
||||||
{
|
|
||||||
InputFile = job.Job.InputFile;
|
|
||||||
Start = job.SegmentStart;
|
|
||||||
Length = job.SegmentLength;
|
|
||||||
Rotate = job.Job.Rotate;
|
|
||||||
Passthrough = job.Job.Passthrough;
|
|
||||||
Info = job.Info;
|
|
||||||
PlainText = job.Job.PlainText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public SimpleSplitter(int segmentNo, ILogger logger)
|
|
||||||
: base(logger, segmentNo)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// InitSegment
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = new State(job);
|
|
||||||
|
|
||||||
var decode = StartDecode(job, token);
|
|
||||||
state.DecodeProcess = decode;
|
|
||||||
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// GetNextProcessedFrame
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public Mat? GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (State)processorState;
|
|
||||||
|
|
||||||
if (state.DecodeStdout == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// SimpleSplitter does not modify frames; it only copies or rotates.
|
|
||||||
// For preview, we decode raw frames and return them as-is.
|
|
||||||
|
|
||||||
// Determine expected frame size
|
|
||||||
var w = state.Info.Width;
|
|
||||||
var h = state.Info.Height;
|
|
||||||
var bytes = w * h * 3;
|
|
||||||
|
|
||||||
var buffer = new byte[bytes];
|
|
||||||
var read = state.DecodeStdout.Read(buffer, 0, bytes);
|
|
||||||
if (read != bytes)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var mat = new Mat(h, w, MatType.CV_8UC3);
|
|
||||||
System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.Data, bytes);
|
|
||||||
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FinishSegment
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public void FinishSegment(IFrameProcessingState processorState)
|
|
||||||
{
|
|
||||||
var state = (State)processorState;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ProcessSegment (now uses preview API)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
|
||||||
var state = (State)InitSegment(job, token);
|
|
||||||
|
|
||||||
var encode = StartEncode(job);
|
|
||||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
|
||||||
|
|
||||||
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var frame = GetNextProcessedFrame(state, token);
|
|
||||||
if (frame == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Write raw frame to encoder
|
|
||||||
var bytes = frame.Width * frame.Height * 3;
|
|
||||||
var buffer = new byte[bytes];
|
|
||||||
System.Runtime.InteropServices.Marshal.Copy(frame.Data, buffer, 0, bytes);
|
|
||||||
encodeStdin.Write(buffer, 0, bytes);
|
|
||||||
|
|
||||||
frame.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
encodeStdin.Flush();
|
|
||||||
encodeStdin.Close();
|
|
||||||
|
|
||||||
await encode.WaitForExitAsync(token);
|
|
||||||
|
|
||||||
FinishSegment(state);
|
|
||||||
|
|
||||||
ClearProgress(name);
|
|
||||||
|
|
||||||
if (encode.ExitCode != 0)
|
|
||||||
LogError($"Segment {name} FFmpeg encoding failed");
|
|
||||||
else
|
|
||||||
LogInfo($"Segment {name} processing completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FFmpeg helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private Process StartDecode(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var ss = job.SegmentStart.ToString("0.###", CultureInfo.InvariantCulture);
|
|
||||||
var t = job.SegmentLength.ToString("0.###", CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
var rotate = GetRotationFilter(job.Job.Rotate);
|
|
||||||
var vf = rotate != null ? $"-vf format=bgr24,{rotate}" : "-vf format=bgr24";
|
|
||||||
|
|
||||||
var args =
|
|
||||||
$"-i \"{job.Job.InputFile}\" -ss {ss} -t {t} " +
|
|
||||||
"-an -sn " +
|
|
||||||
$"{vf} " +
|
|
||||||
"-f rawvideo -";
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "ffmpeg",
|
|
||||||
Arguments = args,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var p = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg decode.");
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Process StartEncode(SingleTask job)
|
|
||||||
{
|
{
|
||||||
var inputFile = job.Job.InputFile;
|
var inputFile = job.Job.InputFile;
|
||||||
var outputFile = job.OutputFileName;
|
var outputFile = job.OutputFileName;
|
||||||
@ -198,6 +18,7 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
|||||||
|
|
||||||
if (rotation == null)
|
if (rotation == null)
|
||||||
{
|
{
|
||||||
|
// Copy path: keep original SAR/DAR exactly as in source
|
||||||
args =
|
args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-i \"{inputFile}\" " +
|
$"-i \"{inputFile}\" " +
|
||||||
@ -210,27 +31,33 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
|||||||
var sarArg = "";
|
var sarArg = "";
|
||||||
var darArg = "";
|
var darArg = "";
|
||||||
|
|
||||||
var sar = job.Info.SampleAspectRatio;
|
var sar = job.Info.SampleAspectRatio; // e.g. "4:3"
|
||||||
if (sar != null)
|
if (sar != null)
|
||||||
{
|
{
|
||||||
|
// Rotation path: must re-encode and recompute DAR
|
||||||
|
|
||||||
var sarNum = Convert.ToInt64(job.Info.Sar.X);
|
var sarNum = Convert.ToInt64(job.Info.Sar.X);
|
||||||
var sarDen = Convert.ToInt64(job.Info.Sar.Y);
|
var sarDen = Convert.ToInt64(job.Info.Sar.Y);
|
||||||
|
|
||||||
|
// After rotation, width/height swap
|
||||||
var w = job.Info.Width;
|
var w = job.Info.Width;
|
||||||
var h = job.Info.Height;
|
var h = job.Info.Height;
|
||||||
|
|
||||||
if (job.Job.Rotate == 90 || job.Job.Rotate == 270)
|
if (job.Job.Rotate == 90 || job.Job.Rotate == 270)
|
||||||
|
{
|
||||||
(w, h) = (h, w);
|
(w, h) = (h, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute DAR = (w * sarNum) : (h * sarDen)
|
||||||
var darNum = w * sarNum;
|
var darNum = w * sarNum;
|
||||||
var darDen = h * sarDen;
|
var darDen = h * sarDen;
|
||||||
|
|
||||||
|
// Reduce fraction
|
||||||
long Gcd(long a, long b)
|
long Gcd(long a, long b)
|
||||||
{
|
{
|
||||||
while (b != 0) (a, b) = (b, a % b);
|
while (b != 0) (a, b) = (b, a % b);
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
var g = Gcd(darNum, darDen);
|
var g = Gcd(darNum, darDen);
|
||||||
darNum /= g;
|
darNum /= g;
|
||||||
darDen /= g;
|
darDen /= g;
|
||||||
@ -251,21 +78,32 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
|||||||
$"{string.Join(" ", job.Job.Passthrough)} " +
|
$"{string.Join(" ", job.Job.Passthrough)} " +
|
||||||
$"\"{outputFile}\" -y";
|
$"\"{outputFile}\" -y";
|
||||||
}
|
}
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
Arguments = args,
|
Arguments = args,
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
|
|
||||||
return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode.");
|
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||||
|
await ShowFFMpegProgress(length, proc, name, token);
|
||||||
|
|
||||||
|
await proc.WaitForExitAsync(token);
|
||||||
|
|
||||||
|
ClearProgress(name);
|
||||||
|
|
||||||
|
if (proc.ExitCode != 0)
|
||||||
|
LogError($"Segment {name} FFmpeg encoding failed");
|
||||||
|
else
|
||||||
|
LogInfo($"Segment {name} processing completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetRotationFilter(int? degrees) =>
|
|
||||||
|
string? GetRotationFilter(int? degrees) =>
|
||||||
degrees switch
|
degrees switch
|
||||||
{
|
{
|
||||||
90 => "transpose=1",
|
90 => "transpose=1",
|
||||||
@ -273,4 +111,80 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
|||||||
270 => "transpose=2",
|
270 => "transpose=2",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static long Gcd(long a, long b)
|
||||||
|
{
|
||||||
|
a = Math.Abs(a);
|
||||||
|
b = Math.Abs(b);
|
||||||
|
|
||||||
|
while (b != 0)
|
||||||
|
{
|
||||||
|
var t = b;
|
||||||
|
b = a % b;
|
||||||
|
a = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowFFMpegProgress(double length, Process proc, string name, CancellationToken token)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await proc.StandardError.ReadLineAsync(token)) != null)
|
||||||
|
{
|
||||||
|
// Look for "time=00:00:03.52"
|
||||||
|
var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var timeStr = ExtractTimestamp(line, idx + 5);
|
||||||
|
if (timeStr == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!TryParseFfmpegTime(timeStr, out var current))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var progress = current.TotalSeconds / length;
|
||||||
|
if (progress < 0) progress = 0;
|
||||||
|
if (progress > 1) progress = 1;
|
||||||
|
|
||||||
|
var elapsed = sw.Elapsed;
|
||||||
|
var speed = current.TotalSeconds > 0
|
||||||
|
? current.TotalSeconds / elapsed.TotalSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var remaining = length - current.TotalSeconds;
|
||||||
|
var etaSeconds = speed > 0 ? remaining / speed : remaining;
|
||||||
|
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||||
|
|
||||||
|
DrawProgress(name, progress, eta, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractTimestamp(string line, int startIndex)
|
||||||
|
{
|
||||||
|
// FFmpeg formats: HH:MM:SS.xx
|
||||||
|
// We read until whitespace
|
||||||
|
var end = startIndex;
|
||||||
|
while (end < line.Length && !char.IsWhiteSpace(line[end]))
|
||||||
|
end++;
|
||||||
|
|
||||||
|
if (end <= startIndex)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return line[startIndex..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseFfmpegTime(string s, out TimeSpan ts)
|
||||||
|
{
|
||||||
|
// FFmpeg uses "00:00:03.52"
|
||||||
|
return TimeSpan.TryParseExact(
|
||||||
|
s,
|
||||||
|
@"hh\:mm\:ss\.ff",
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out ts);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public record Segment(double Start, double End);
|
|
||||||
|
|
||||||
public class SingleJob
|
public class SingleJob
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -4,58 +4,10 @@ using System.Runtime.InteropServices;
|
|||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
public class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||||
{
|
{
|
||||||
private readonly IObjectTracker _tracker;
|
private readonly IObjectTracker _tracker;
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// Internal state (never exposed)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private sealed class FrameProcessingState : IFrameProcessingState
|
|
||||||
{
|
|
||||||
public SingleTask Job { get; }
|
|
||||||
public KalmanTracker Kalman { get; }
|
|
||||||
public CameraController Camera { get; }
|
|
||||||
|
|
||||||
public Mat FrameMat { get; }
|
|
||||||
public Mat OutMat { get; }
|
|
||||||
public byte[] InBuffer { get; }
|
|
||||||
public byte[] OutBuffer { get; }
|
|
||||||
|
|
||||||
public IVideoEnhancer? Enhancer { get; }
|
|
||||||
|
|
||||||
public int InBytes { get; }
|
|
||||||
public int OutBytes { get; }
|
|
||||||
|
|
||||||
public Process? DecodeProcess { get; set; }
|
|
||||||
public Stream? DecodeStdout { get; set; }
|
|
||||||
|
|
||||||
public FrameProcessingState(
|
|
||||||
SingleTask job,
|
|
||||||
KalmanTracker kalman,
|
|
||||||
CameraController camera,
|
|
||||||
Mat frameMat,
|
|
||||||
Mat outMat,
|
|
||||||
byte[] inBuffer,
|
|
||||||
byte[] outBuffer,
|
|
||||||
IVideoEnhancer? enhancer,
|
|
||||||
int inBytes,
|
|
||||||
int outBytes)
|
|
||||||
{
|
|
||||||
Job = job;
|
|
||||||
Kalman = kalman;
|
|
||||||
Camera = camera;
|
|
||||||
FrameMat = frameMat;
|
|
||||||
OutMat = outMat;
|
|
||||||
InBuffer = inBuffer;
|
|
||||||
OutBuffer = outBuffer;
|
|
||||||
Enhancer = enhancer;
|
|
||||||
InBytes = inBytes;
|
|
||||||
OutBytes = outBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public TrackingSplitter(
|
public TrackingSplitter(
|
||||||
int progressLine,
|
int progressLine,
|
||||||
IObjectTracker tracker,
|
IObjectTracker tracker,
|
||||||
@ -66,131 +18,144 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
_tracker = tracker;
|
_tracker = tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// PUBLIC PREVIEW API
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// InitSegment
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (FrameProcessingState)CreateFrameState(job);
|
|
||||||
|
|
||||||
if (state.Enhancer != null)
|
|
||||||
state.Enhancer.InitializeAsync(
|
|
||||||
state.OutMat.Width,
|
|
||||||
state.OutMat.Height,
|
|
||||||
5,
|
|
||||||
token).Wait(token);
|
|
||||||
|
|
||||||
var decode = StartFfmpegDecode(
|
|
||||||
job.Job.InputFile,
|
|
||||||
job.SegmentStart,
|
|
||||||
job.SegmentLength,
|
|
||||||
job.Job.Rotate,
|
|
||||||
job.Job.PlainText,
|
|
||||||
token).Result;
|
|
||||||
|
|
||||||
state.DecodeProcess = decode;
|
|
||||||
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// GetNextProcessedFrame
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public Mat? GetNextProcessedFrame(
|
|
||||||
IFrameProcessingState processorState,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (FrameProcessingState)processorState;
|
|
||||||
|
|
||||||
if (state.DecodeStdout == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (!TryReadNextFrame(state.DecodeStdout, state, token))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return ProcessFrame(state.FrameMat, state, state.Job, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// FinishSegment
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public void FinishSegment(IFrameProcessingState processorState)
|
|
||||||
{
|
|
||||||
var state = (FrameProcessingState)processorState;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
if (state.Enhancer is IAsyncDisposable ad)
|
|
||||||
ad.DisposeAsync().AsTask().Wait();
|
|
||||||
else if (state.Enhancer is IDisposable d)
|
|
||||||
d.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// PROCESSSEGMENT (full pipeline)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
{
|
||||||
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
var inputFile = job.Job.InputFile;
|
||||||
|
var outputFile = job.OutputFileName;
|
||||||
|
var start = job.SegmentStart;
|
||||||
|
var length = job.SegmentLength;
|
||||||
|
var videoWidth = job.Info.Width;
|
||||||
|
var videoHeight = job.Info.Height;
|
||||||
var fps = job.Info.Fps;
|
var fps = job.Info.Fps;
|
||||||
|
var bitrate = job.Info.Bitrate;
|
||||||
|
var ffmpegPassthroughParameters = job.Job.Passthrough;
|
||||||
|
|
||||||
var state = (FrameProcessingState)InitSegment(job, token);
|
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||||
|
|
||||||
|
if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0)
|
||||||
|
{
|
||||||
|
LogError($"{name}: ffprobe failed to get metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.Job.Crop == null)
|
||||||
|
{
|
||||||
|
LogError($"{name}: Crop parameters are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing size (what you crop / feed into enhancer)
|
||||||
|
var procWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width;
|
||||||
|
var procHeight = job.Job.Debug ? videoHeight : job.Job.Crop.Value.height;
|
||||||
|
|
||||||
|
IVideoEnhancer? enhancer = null;
|
||||||
|
|
||||||
|
const int window = 5;
|
||||||
|
|
||||||
|
if (job.Job.Enhance)
|
||||||
|
{
|
||||||
|
enhancer = new RealBasicVsr2xDmlEnhancer();
|
||||||
|
await enhancer.InitializeAsync(procWidth, procHeight, window, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoding size (what FFmpeg encoder expects)
|
||||||
|
var encWidth = enhancer != null ? procWidth * enhancer.ResolutionMultiplier : procWidth;
|
||||||
|
var encHeight = enhancer != null ? procHeight * enhancer.ResolutionMultiplier : procHeight;
|
||||||
|
|
||||||
|
LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] proc={procWidth}x{procHeight} enc={encWidth}x{encHeight}");
|
||||||
|
|
||||||
|
var decode = await StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText, token);
|
||||||
|
using var decodeStdout = decode.StandardOutput.BaseStream;
|
||||||
|
|
||||||
var encode = await StartFfmpegEncode(
|
var encode = await StartFfmpegEncode(
|
||||||
job.Job.InputFile,
|
inputFile,
|
||||||
job.OutputFileName,
|
outputFile,
|
||||||
job.SegmentStart,
|
start,
|
||||||
job.SegmentLength,
|
length,
|
||||||
state.OutMat.Width,
|
encWidth,
|
||||||
state.OutMat.Height,
|
encHeight,
|
||||||
job.Info,
|
job.Info,
|
||||||
job.Job.Passthrough,
|
ffmpegPassthroughParameters,
|
||||||
job.Job.PlainText,
|
job.Job.PlainText,
|
||||||
token);
|
token);
|
||||||
|
|
||||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
using var encodeStdin = encode.StandardInput.BaseStream;
|
||||||
|
|
||||||
var totalFrames = (int)Math.Round(job.SegmentLength * fps);
|
// Input: always full frame
|
||||||
var frameIndex = 0;
|
var inBytes = videoWidth * videoHeight * 3;
|
||||||
var startTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
|
// Output: encoded frame size (may be 4x if enhancement enabled)
|
||||||
|
var outBytes = encWidth * encHeight * 3;
|
||||||
|
|
||||||
|
var inBuffer = new byte[inBytes];
|
||||||
|
var outBuffer = new byte[outBytes];
|
||||||
|
|
||||||
|
using var frameMat = new Mat(videoHeight, videoWidth, MatType.CV_8UC3);
|
||||||
|
|
||||||
|
// outMat is processing size (crop), not necessarily encoding size
|
||||||
|
using var outMat = new Mat(procHeight, procWidth, MatType.CV_8UC3);
|
||||||
|
|
||||||
|
var kalman = new KalmanTracker();
|
||||||
|
var camera = new CameraController(
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
job.Job.Crop.Value.width,
|
||||||
|
job.Job.Crop.Value.height,
|
||||||
|
kalman,
|
||||||
|
job.Job);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
var totalFrames = (int)Math.Round(length * fps);
|
||||||
|
var frameIndex = 0;
|
||||||
|
|
||||||
|
var enhancedOutput = new Mat[window];
|
||||||
|
//totalFrames = 10;
|
||||||
while (frameIndex < totalFrames)
|
while (frameIndex < totalFrames)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var frame = GetNextProcessedFrame(state, token);
|
|
||||||
if (frame == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
frameIndex++;
|
frameIndex++;
|
||||||
|
|
||||||
EncodeFrame(frame, state, encodeStdin);
|
var read = await ReadExact(decodeStdout, inBuffer, 0, inBytes, token);
|
||||||
|
if (read != inBytes)
|
||||||
|
break;
|
||||||
|
|
||||||
|
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
|
||||||
|
|
||||||
|
var (objects, primary) = _tracker.SelectTrackedObject(job, frameMat, kalman.LastMeasurement);
|
||||||
|
|
||||||
|
camera.Update(primary);
|
||||||
|
var roi = camera.Roi;
|
||||||
|
|
||||||
|
if (job.Job.Debug)
|
||||||
|
{
|
||||||
|
DrawDebug(frameMat, objects, camera, kalman);
|
||||||
|
frameMat.CopyTo(outMat); // outMat: procWidth x procHeight == full frame in debug
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var cropped = new Mat(frameMat, roi);
|
||||||
|
cropped.CopyTo(outMat); // outMat: procWidth x procHeight == crop
|
||||||
|
}
|
||||||
|
|
||||||
|
Mat frameToWrite = outMat;
|
||||||
|
|
||||||
|
if (enhancer != null)
|
||||||
|
{
|
||||||
|
if (enhancer.TryProcessFrame(outMat, out var enhanced, token))
|
||||||
|
frameToWrite = enhanced; // enhanced: encWidth x encHeight
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Marshal.Copy(frameToWrite.Data, outBuffer, 0, outBytes);
|
||||||
|
encodeStdin.Write(outBuffer, 0, outBytes);
|
||||||
|
|
||||||
var elapsed = DateTime.UtcNow - startTime;
|
var elapsed = DateTime.UtcNow - startTime;
|
||||||
var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0;
|
var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0;
|
||||||
var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0;
|
var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0;
|
||||||
|
|
||||||
var remainingFrames = Math.Max(totalFrames - frameIndex, 0);
|
var remainingFrames = Math.Max(totalFrames - frameIndex, 0);
|
||||||
var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0;
|
var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0;
|
||||||
var eta = TimeSpan.FromSeconds(etaSeconds);
|
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||||
@ -198,10 +163,32 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
DrawProgress(name, progress, eta, speed);
|
DrawProgress(name, progress, eta, speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (enhancer != null)
|
||||||
|
{
|
||||||
|
int count = enhancer.Flush(enhancedOutput, token);
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var mat = enhancedOutput[i]; // encWidth x encHeight
|
||||||
|
Marshal.Copy(mat.Data, outBuffer, 0, outBytes);
|
||||||
|
encodeStdin.Write(outBuffer, 0, outBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
encodeStdin.Flush();
|
encodeStdin.Flush();
|
||||||
encodeStdin.Close();
|
encodeStdin.Close();
|
||||||
|
|
||||||
await encode.WaitForExitAsync();
|
await encode.WaitForExitAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (enhancer is IAsyncDisposable asyncDisp)
|
||||||
|
await asyncDisp.DisposeAsync();
|
||||||
|
else if (enhancer is IDisposable disp)
|
||||||
|
disp?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
||||||
|
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
||||||
|
|
||||||
ClearProgress(name);
|
ClearProgress(name);
|
||||||
|
|
||||||
@ -209,123 +196,12 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
LogError($"{name}: FFmpeg encoding failed");
|
LogError($"{name}: FFmpeg encoding failed");
|
||||||
else
|
else
|
||||||
LogInfo($"{name}: Segment processing completed");
|
LogInfo($"{name}: Segment processing completed");
|
||||||
|
|
||||||
FinishSegment(state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// INTERNAL HELPERS
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private object CreateFrameState(SingleTask job)
|
// ---------- FFmpeg decode / encode ----------
|
||||||
{
|
|
||||||
var w = job.Info.Width;
|
|
||||||
var h = job.Info.Height;
|
|
||||||
var cw = job.Job.Debug ? w : job.Job.Crop!.Value.width;
|
|
||||||
var ch = job.Job.Debug ? h : job.Job.Crop!.Value.height;
|
|
||||||
|
|
||||||
var kalman = new KalmanTracker();
|
private async Task<Process> StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText, CancellationToken token)
|
||||||
var camera = new CameraController(w, h, cw, ch, kalman, job.Job);
|
|
||||||
|
|
||||||
var frameMat = new Mat(h, w, MatType.CV_8UC3);
|
|
||||||
var outMat = new Mat(ch, cw, MatType.CV_8UC3);
|
|
||||||
|
|
||||||
var inBytes = w * h * 3;
|
|
||||||
var outBytes = cw * ch * 3;
|
|
||||||
|
|
||||||
var inBuffer = new byte[inBytes];
|
|
||||||
var outBuffer = new byte[outBytes];
|
|
||||||
|
|
||||||
IVideoEnhancer? enhancer = job.Job.Enhance
|
|
||||||
? new RealBasicVsr2xDmlEnhancer()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return new FrameProcessingState(
|
|
||||||
job,
|
|
||||||
kalman,
|
|
||||||
camera,
|
|
||||||
frameMat,
|
|
||||||
outMat,
|
|
||||||
inBuffer,
|
|
||||||
outBuffer,
|
|
||||||
enhancer,
|
|
||||||
inBytes,
|
|
||||||
outBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryReadNextFrame(
|
|
||||||
Stream decodeStdout,
|
|
||||||
FrameProcessingState state,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var read = ReadExact(
|
|
||||||
decodeStdout,
|
|
||||||
state.InBuffer,
|
|
||||||
0,
|
|
||||||
state.InBytes,
|
|
||||||
token).Result;
|
|
||||||
|
|
||||||
if (read != state.InBytes)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
Marshal.Copy(state.InBuffer, 0, state.FrameMat.Data, state.InBytes);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mat ProcessFrame(
|
|
||||||
Mat inputFrame,
|
|
||||||
FrameProcessingState state,
|
|
||||||
SingleTask job,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var (objects, primary) =
|
|
||||||
_tracker.SelectTrackedObject(job, inputFrame, state.Kalman.LastMeasurement);
|
|
||||||
|
|
||||||
state.Camera.Update(primary);
|
|
||||||
var roi = state.Camera.Roi;
|
|
||||||
|
|
||||||
if (job.Job.Debug)
|
|
||||||
{
|
|
||||||
DebugOverlay.DrawDebug(inputFrame, objects, state.Camera, state.Kalman);
|
|
||||||
inputFrame.CopyTo(state.OutMat);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
using var cropped = new Mat(inputFrame, roi);
|
|
||||||
cropped.CopyTo(state.OutMat);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.Enhancer != null)
|
|
||||||
{
|
|
||||||
if (state.Enhancer.TryProcessFrame(state.OutMat, out var enhanced, token))
|
|
||||||
return enhanced;
|
|
||||||
|
|
||||||
return state.OutMat;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.OutMat;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EncodeFrame(
|
|
||||||
Mat frame,
|
|
||||||
FrameProcessingState state,
|
|
||||||
Stream encodeStdin)
|
|
||||||
{
|
|
||||||
Marshal.Copy(frame.Data, state.OutBuffer, 0, state.OutBytes);
|
|
||||||
encodeStdin.Write(state.OutBuffer, 0, state.OutBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// FFmpeg helpers
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private async Task<Process> StartFfmpegDecode(
|
|
||||||
string inputFile,
|
|
||||||
double start,
|
|
||||||
double length,
|
|
||||||
int? rotate,
|
|
||||||
bool plainText,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
@ -380,6 +256,7 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
case 270: rotateStr = ",transpose=2"; break;
|
case 270: rotateStr = ",transpose=2"; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rotateStr;
|
return rotateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,8 +265,7 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
string outputFile,
|
string outputFile,
|
||||||
double start,
|
double start,
|
||||||
double length,
|
double length,
|
||||||
int width,
|
int width, int height,
|
||||||
int height,
|
|
||||||
VideoInfo info,
|
VideoInfo info,
|
||||||
string[] passthrough,
|
string[] passthrough,
|
||||||
bool plainText,
|
bool plainText,
|
||||||
@ -399,17 +275,19 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio)
|
var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio)
|
||||||
? $"-vf setsar={info.SampleAspectRatio} "
|
? $"-vf setsar={info.SampleAspectRatio} "
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
var darArg = "";
|
var darArg = "";
|
||||||
|
|
||||||
if (info.Sar is { } s)
|
if (info.Sar is { } s)
|
||||||
{
|
{
|
||||||
|
// compute DAR from output size and SAR
|
||||||
var darNum = width * s.X;
|
var darNum = width * s.X;
|
||||||
var darDen = height * s.Y;
|
var darDen = height * s.Y;
|
||||||
|
|
||||||
|
// clamp to int and reduce
|
||||||
var dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum));
|
var dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum));
|
||||||
var dd = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darDen));
|
var dd = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darDen));
|
||||||
ReduceFraction(ref dn, ref dd);
|
ReduceFraction(ref dn, ref dd);
|
||||||
@ -428,6 +306,9 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
"-c:a copy " +
|
"-c:a copy " +
|
||||||
pass + $" \"{outputFile}\"";
|
pass + $" \"{outputFile}\"";
|
||||||
|
|
||||||
|
// "-c:a aac -b:a 192k " +
|
||||||
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
@ -449,15 +330,19 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||||
|
{
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch { }
|
catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
private static void ReduceFraction(ref int num, ref int den)
|
private static void ReduceFraction(ref int num, ref int den)
|
||||||
{
|
{
|
||||||
int Gcd(int a, int b)
|
int Gcd(int a, int b)
|
||||||
@ -478,13 +363,7 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
den /= g;
|
den /= g;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private static async Task<int> ReadExact(Stream s, byte[] buffer, int offset, int count, CancellationToken token)
|
||||||
private static async Task<int> ReadExact(
|
|
||||||
Stream s,
|
|
||||||
byte[] buffer,
|
|
||||||
int offset,
|
|
||||||
int count,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
while (total < count)
|
while (total < count)
|
||||||
@ -497,5 +376,35 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawDebug(
|
||||||
|
Mat frame,
|
||||||
|
List<DetectedPerson> objects,
|
||||||
|
CameraController camera,
|
||||||
|
KalmanTracker kalman)
|
||||||
|
{
|
||||||
|
if (camera.ObjectBox.HasValue)
|
||||||
|
{
|
||||||
|
var fb = camera.ObjectBox.Value;
|
||||||
|
Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cv2.Circle(frame,
|
||||||
|
new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y),
|
||||||
|
6, Scalar.LimeGreen, -1);
|
||||||
|
|
||||||
|
Cv2.Rectangle(frame, camera.Roi,
|
||||||
|
camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3);
|
||||||
|
|
||||||
|
DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White);
|
||||||
|
DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White);
|
||||||
|
DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White);
|
||||||
|
DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawText(Mat img, string text, int x, int y, Scalar color)
|
||||||
|
{
|
||||||
|
Cv2.PutText(img, text, new Point(x, y),
|
||||||
|
HersheyFonts.HersheySimplex, 0.6, color, 2);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
namespace splitter.algo;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public interface IFrameProcessingState
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface ISegmentProcessor
|
public interface ISegmentProcessor
|
||||||
{
|
{
|
||||||
IFrameProcessingState InitSegment(SingleTask job, CancellationToken token);
|
|
||||||
Mat? GetNextProcessedFrame( IFrameProcessingState processorState, CancellationToken token);
|
|
||||||
void FinishSegment(IFrameProcessingState processorState);
|
|
||||||
|
|
||||||
Task ProcessSegment( SingleTask job, CancellationToken token);
|
Task ProcessSegment( SingleTask job, CancellationToken token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
namespace splitter.algo;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class IdentityCache
|
public sealed class IdentityCache
|
||||||
{
|
{
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public sealed class YoloV10ObjectDetector : LoggingBase, IObjectDetector, IDispo
|
|||||||
|
|
||||||
private const int _inputWidth = 640;
|
private const int _inputWidth = 640;
|
||||||
private const int _inputHeight = 640;
|
private const int _inputHeight = 640;
|
||||||
|
private const float _scoreThreshold = 0.35f;
|
||||||
private const float _nmsThreshold = 0.45f;
|
private const float _nmsThreshold = 0.45f;
|
||||||
private const int _personClassIndex = 0;
|
private const int _personClassIndex = 0;
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,7 @@ static partial class Program
|
|||||||
var allJobs = new List<SingleTask>();
|
var allJobs = new List<SingleTask>();
|
||||||
foreach ( var job in cmd.Jobs )
|
foreach ( var job in cmd.Jobs )
|
||||||
{
|
{
|
||||||
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, [], CancellationToken.None);
|
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, CancellationToken.None);
|
||||||
allJobs.AddRange(jobs);
|
allJobs.AddRange(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,10 +59,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
|
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
|
||||||
<PackageReference Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
|
<PackageReference Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
|
||||||
<PackageReference Include="Onnxify" Version="0.3.4" />
|
<PackageReference Include="Onnxify" Version="0.1.4" />
|
||||||
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260602" />
|
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260602" />
|
||||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260602" />
|
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260602" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.57.0" />
|
<PackageReference Include="Spectre.Console" Version="0.56.0" />
|
||||||
<PackageReference Include="UltraFaceDotNet" Version="1.0.0.2" />
|
<PackageReference Include="UltraFaceDotNet" Version="1.0.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@ -375,7 +375,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
return new Measurement(width, width);
|
return new Measurement(width, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<Spectre.Console.Rendering.Segment> Render(RenderOptions options, int maxWidth)
|
public IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
|
||||||
{
|
{
|
||||||
var width = Math.Max(1, maxWidth);
|
var width = Math.Max(1, maxWidth);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user