Compare commits

..

No commits in common. "master" and "v0.0.7" have entirely different histories.

124 changed files with 1102 additions and 7626 deletions

View File

@ -3,7 +3,7 @@ name: Build and Publish
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
permissions: permissions:
contents: write contents: write
@ -22,19 +22,19 @@ jobs:
dotnet-version: 10.0.x dotnet-version: 10.0.x
- name: Restore - name: Restore
run: dotnet restore Splitter-UI/Splitter-UI.csproj -r win-x64 run: dotnet restore -r win-x64
- name: Get Version - name: 'Get Version'
id: version id: version
uses: battila7/get-version-action@v2 uses: battila7/get-version-action@v2
- name: Publish Release - name: Publish Release
run: dotnet publish Splitter-UI/Splitter-UI.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }} run: dotnet publish splitter-cli/splitter.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
- name: Create ZIP - name: Create ZIP
shell: pwsh shell: pwsh
run: | run: |
$publish = "Splitter-UI/bin/Release/net10.0/win-x64/publish" $publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
$version = "${{ steps.version.outputs.version-without-v }}" $version = "${{ steps.version.outputs.version-without-v }}"
$zip = "splitter-win-x64-$version.zip" $zip = "splitter-win-x64-$version.zip"
@ -51,3 +51,5 @@ jobs:
files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,19 +0,0 @@
You are c# programmer. I'm senior c# programmer with 30+ years of experience.
Do not be overconfident about your answers - they are 70% incorrect.
Do not say "final solution". Do not start every reply with my name.
Do not use emoji or non-ascii symbols. Do not explain "why it work".
I have C#. .NET 10 Avalonia 12 UI for ffmpeg/OpenCV video app. All packages are of very latest versions.
Use namespace splitter for splitter-cli and Splitter_UI for Splitter-UI.
Splitter pipeline is:
* FFProbe extracting all video meta to VideoInfo
* FFMpeg used to decode video frames into OpenCVSharp.Mat
* One of detectors used:
- For face detection: [opencv_zoo/models/face_detection_yunet at main opencv/opencv_zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
- For body detection: [THU-MIG/yolov10: YOLOv10: Real-Time End-to-End Object Detection [NeurIPS 2024]](https://github.com/THU-MIG/yolov10/tree/main)
* Camera control aplied (CameraControl class)
* Final video frames are encoded back to video file using FFMpeg

222
README.md
View File

@ -1,50 +1,212 @@
# Splitter # Splitter
This application was built to help me with maintaining my YouTube channel - [UnclShura](https://www.youtube.com/@UnclShura). Splitter is a highperformance command line tool for cutting one or more video files into equal or fixedlength segments using multithreaded FFmpeg execution.
It supports batch input, flexible duration formats, rotation, smart face/bodyaware cropping, ETA and speed reporting, and both rich and plaintext terminal output.
Splitter is a high-performance command line tool for cutting one or more video files into equal or ![Splitter](splitter.png)
fixedlength segments using multithreaded FFmpeg execution. It supports batch input, flexible
duration formats, rotation, smart face/bodyaware cropping, ETA and speed reporting, with nice GUI
or both rich and plain-text terminal output.
The intended primary use case is for content creators who need to split large video files into smaller
segments for platforms like TikTok, Instagram Reels, YouTube Shorts, or similar. The smart
cropping feature allows the tool to automatically detect and keep faces or bodies in the frame
when splitting, ensuring that important content is not cut off.
Splitter uses cutting-edge body-detection CV models to analyze the video and determine optimal
cropping regions for each segment. Smooth tracking and gravitation bias ensure that the cropping remains
stable and focused on the subject without excessive jitter or erratic movements.
The tool can also correct for rotation metadata to ensure proper orientation in the output segments.
Splitter uses FFmpeg for the actual splitting and encoding, with multi-threading to maximize performance.
## Features ## Features
- Human face or body detection with smart cropping - Multithreaded FFmpeg splitting for maximum throughput
- Multi-threaded FFmpeg splitting for maximum throughput
- Equal or fixedlength segmentation - Equal or fixedlength segmentation
- Batch input via file masks or list files - Batch input via file masks or list files
- Smart cropping with face/body tracking - Smart cropping with face/body tracking
- Rotation correction - Rotation correction
- ETA, speed, and progress display - ETA, speed, and progress display
- FFmpeg passthrough for advanced control - FFmpeg passthrough for advanced control
- [Potentially] Cross-platform (.NET 10) - [Potentially] Crossplatform (.NET 10)
## Screenshots
### Command line interface
![Splitter](splitter-cli/splitter.png)
### Graphical user interface
![Splitter UI](Splitter-UI/screenshot.png)
## Requirements ## Requirements
- FFmpeg and FFprobe available in system PATH - FFmpeg and FFprobe available in system PATH
- .NET 10 Runtime or newer - .NET 10 Runtime or newer
## More info If you want to update model:
[Command line tool](splitter-cli/README.md) - For face detection: [opencv_zoo/models/face_detection_yunet at main · opencv/opencv_zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
- For body detection: [yolov8s.pt · Ultralytics/YOLOv8 at main](https://huggingface.co/Ultralytics/YOLOv8/blob/main/yolov8s.pt)
To convert models from PyTorch to ONNX, you can use the following command:
```python
from ultralytics import YOLO
model = YOLO("yolov8x.pt")
model.export(format="onnx", opset=12, half=False) # FP32 ONNX
```
## How It Works
1. Reads total duration using ffprobe
2. Parses target duration
3. Computes number of segments
4. If not forced, equalizes segment lengths
5. Runs multiple FFmpeg processes in parallel
6. Applies rotation, crop, and tracking if enabled
7. Displays progress, ETA, and speed
## Face Tracking vs Body Tracking
Face tracking and body tracking serve different purposes, and Splitter supports both because each
excels in different recording environments. When converting horizontal footage into vertical clips,
the choice of detector determines how stable, reliable, and natural the automated camera motion will be.
![Face vs Body Tracking](tracking.png)
### Face Tracking Using UltraFace 320
Splitter uses the UltraFace 320 ONNX model to perform lightweight, realtime face detection on each
frame of the input video. The detector produces bounding boxes for visible faces, and the tracking
system maintains a stable, smoothed target region across time. This is achieved by combining perframe
detections with temporal smoothing (EMA), dropout tolerance, and camera easing. The result is a
continuous, stable crop window that follows the performer even when the face is partially occluded,
briefly lost, or moving rapidly.
During segmentation, the crop window is recalculated for every frame, ensuring that each output
segment inherits the same smooth camera motion. This makes the vertical clips appear as if they
were recorded with a dedicated portraitoriented camera operator. The UltraFace 320 model is
fast enough to run alongside multithreaded FFmpeg splitting without becoming a bottleneck,
making it suitable for long recordings and batch processing.
### Benefits of FullBody Detection Using YOLOv8s for Live Gig Recordings
When recording concerts or live gigs, performers often move unpredictably, turn away from the
camera, or become partially obscured by lighting, instruments, or stage effects.
Fullbody detection using a YOLOv8s ONNX model provides a more reliable tracking anchor than
face detection alone. Because YOLOv8s can detect the entire human silhouette, the tracker
maintains stable framing even when the face is not visible, when the performer is far from
the camera, or when stage lighting makes facial features hard to detect. This produces vertical
clips that feel intentional and professionally framed, with fewer sudden jumps or losttracking
moments. For creators converting horizontal gig footage into short vertical clips for YouTube
Shorts or TikTok, bodybased tracking significantly improves consistency, reduces manual editing,
and preserves the energy and motion of the performance.
### Automated Camera Control
Splitter includes an automated camera control system that simulates the behavior of a virtual
camera operator when generating vertical crops from horizontal footage. The goal is to maintain
smooth, intentional framing around the tracked subject, even when detections are noisy, intermittent,
or temporarily lost.
The controller receives object detections (face or body) and converts them into a stable crop
window using a combination of Kalman filtering, exponential smoothing, dropout tolerance,
and a threestate tracking model. The Kalman filter provides predictive motion smoothing,
while the EMA factor blends the predicted position with the previous camera center to avoid jitter.
The camera easing value controls how quickly the virtual camera follows the subject, producing
naturallooking motion rather than abrupt jumps.
When detections disappear, the controller enters one of two fallback modes. In LostFreeze mode,
the camera holds its last known position for a configurable number of frames, preventing sudden
jumps during brief occlusions. If the subject remains lost beyond that threshold, the controller
transitions to LostDrift mode, slowly drifting the camera back toward a neutral center position.
This prevents the crop from drifting offscreen and ensures that the output remains usable even
when tracking fails. All positions are clamped to valid bounds, guaranteeing that the crop window
never leaves the video frame.
### Automatic rotation detection
The rotationestimation method is based on analyzing the distribution of gradient orientations within
a video frame. After converting the frame to grayscale, the algorithm computes horizontal and vertical
image gradients using Sobel operators and derives perpixel gradient magnitudes and orientations.
These orientations are folded into the range [0, 180) and accumulated into a fixedsize,
magnitudeweighted histogram. The histogram represents the structural edge distribution of the frame,
independent of brightness fluctuations or local lighting artifacts. By comparing the total gradient
energy concentrated near 0 degrees (vertical edges) with the energy near 90 degrees (horizontal edges),
the method determines whether the frame is more consistent with an upright or sideways orientation.
This approach is designed for environments where brightnessbased cues are unreliable, such as
live concerts with strobe lights, LED walls, haze, and crowd movement. It relies solely on geometric
edge structure, which remains stable even under extreme lighting variation. The implementation is
optimized for highthroughput video processing: all intermediate Mats, buffers, and histograms are
preallocated, and pixel data is accessed directly through pointers to avoid perframe memory
allocation. The method is intentionally biased toward the upright orientation, returning a sideways
classification only when the horizontaledge energy significantly exceeds the verticaledge energy.
## Usage
```
splitter [<input.mp4> ...] [options] [--] <ffmpeg passthrough>
```
Inputs may be provided directly, via `--file=...`, or using file masks such as `videos/*.mp4`.
## Options
Below is a clean, ASCIIonly **options table** version of your content.
All option names are preserved exactly, and descriptions are consolidated for clarity.
---
## Options
| Option | Description |
|--------|-------------|
| **--out=<folder>** | Output folder for generated segments. Default: `<input folder>/Splitter`. |
| **--file=<path>** | Input file list or file mask. If omitted, the first nonoption argument is used as input. Examples: `--file=videos/*.mp4`, `--file=file_list.txt`. |
| **--mask=<pattern>** | Custom output filename pattern. Default: `[NAME]_seg[NN].[EXT]`. Supports `[NAME]`, `[N]`, `[NN]`, `[NNN]`, `[NNNN]`, `[EXT]`. Example: `--mask="[NAME]_[NNNN].mp4"`. |
| **--duration=<value>** | Override target segment duration. Formats: `Ns`, `NmMs`, `N`. Examples: `--duration=90s`, `--duration=2m30s`, `--duration=45`. Without `--force`: max 58 seconds, equalized across segments. |
| **--force** | Use the duration exactly as provided. Last segment may be shorter. |
| **--rotate=<degrees>** | Rotate video by 90, 180, or 270 degrees. Useful for correcting orientation metadata. |
| **--rotate-auto** | Use automatic rotation detection. |
| **--estimate** | Print calculated segment information and exit. No splitting is performed. |
| **--crop[=<w:h>]** | Crop video to a target width and height with face/body tracking. Default: 607x1080. Ideal for Shorts, TikTok, Reels. |
| **--detect=<name>** | Object detector for tracking. Values: `face` (UltraFace), `body` (YoloOnnx, default), `none` (center crop). |
| **--gravitate=<x:y>** | Bias the crop window toward a normalized point in the frame. Example: `--gravitate=0.2:0.5`. |
| **--text** | Use plaintext logging instead of the rich terminal UI. |
| **--single-thread** | Disable parallel FFmpeg execution. Useful for debugging or lowresource systems. |
| **--debug** | Show debug overlay during tracking. No cropping performed, but crop region shown. |
| **-p:<name>=<value>** | Set custom parameters for the object detector. Example: `-p:confidence=0.5`. Defaults: DropoutToleranceFrames=20, EmaFactor=0.65, CameraEasing=0.03, LostFreezeFrames=60. |
## FFmpeg Passthrough
Anything after `--` is passed directly to FFmpeg.
Example:
```
splitter video.mp4 --force --duration=45 -- -an -sn
```
## Input and Output Behavior
- `input.mp4` may be a file mask (`videos/*.mp4`)
- Output filenames follow the `--mask` pattern
- Output folder defaults to `<input folder>/Splitter` unless overridden
## Examples
Split into equal 60second segments:
```
splitter vertical-video.mp4
```
Split into equal 90second segments:
```
splitter vertical-video.mp4 --duration=90s
```
Custom naming:
```
splitter vertical-video.mp4 --duration=2m30s --mask="[NAME]_[NNNN].mp4"
```
Estimate only:
```
splitter vertical-video.mp4 --estimate
```
Fixed 45second segments with passthrough:
```
splitter vertical-video.mp4 --force --duration=45 -- -an -sn
```
Smart crop for Shorts:
```
splitter horizontal-video.mp4 --out=Cropped/ --crop
```
Batch processing with body tracking:
```
splitter --file=file_names.txt --out=Cropped/ --crop --detect=body
```
[GUI tool](Splitter-UI/README.md)

View File

@ -1,23 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.App"
xmlns:local="using:Splitter_UI"
xmlns:cnv="using:Splitter_UI.Converters"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<cnv:ActionToIconConverter x:Key="ActionToIconConverter"/>
<FontFamily x:Key="FontAwesome">avares://Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf#Font Awesome 7 Free Solid</FontFamily>
</Application.Resources>
<Application.Styles>
<FluentTheme/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>

View File

@ -1,39 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using Splitter_UI.Views;
namespace Splitter_UI;
public partial class App : Application
{
private readonly ServiceProvider _provider = null!;
public App() { }
public App(ServiceProvider provider)
{
_provider = provider;
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var vm = _provider.GetRequiredService<MainViewModel>();
desktop.MainWindow = new MainWindow
{
DataContext = vm
};
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,100 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#111827"/>
<stop offset="100%" stop-color="#020617"/>
</linearGradient>
<linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#22C55E"/>
<stop offset="100%" stop-color="#0EA5E9"/>
</linearGradient>
<linearGradient id="videoGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#F97316"/>
<stop offset="100%" stop-color="#EC4899"/>
</linearGradient>
<linearGradient id="cropGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#38BDF8"/>
<stop offset="100%" stop-color="#6366F1"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bgGrad)" />
<!-- Split timeline bar -->
<g transform="translate(32,188)">
<rect x="0" y="-10" width="192" height="20" rx="10" fill="#020617" />
<rect x="0" y="-10" width="192" height="20" rx="10" fill="url(#accentGrad)" opacity="0.18" />
<!-- Segment markers -->
<rect x="32" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
<rect x="80" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
<rect x="128" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
<rect x="176" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
</g>
<!-- Video tile (left) -->
<g transform="translate(26,52)">
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.9" />
<!-- Play triangle -->
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.9" />
<!-- Small split indicator -->
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
<rect x="36" y="58" width="2" height="8" rx="1" fill="#FACC15" />
<rect x="60" y="58" width="2" height="8" rx="1" fill="#FACC15" />
</g>
<!-- Video tile (right, shifted to suggest batch / multi-thread) -->
<g transform="translate(134,40)">
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.75" />
<!-- Play triangle -->
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.85" />
<!-- Small split indicator -->
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
<rect x="30" y="58" width="2" height="8" rx="1" fill="#FACC15" />
<rect x="54" y="58" width="2" height="8" rx="1" fill="#FACC15" />
<rect x="78" y="58" width="2" height="8" rx="1" fill="#FACC15" />
</g>
<!-- Smart crop frame with body/face hint -->
<g transform="translate(76,86)">
<!-- Outer crop frame -->
<rect x="0" y="0" width="104" height="104" rx="18" fill="#020617" opacity="0.9" />
<rect x="0" y="0" width="104" height="104" rx="18" fill="url(#cropGrad)" opacity="0.25" />
<!-- Corner crop brackets -->
<path d="M10 30 V14 A4 4 0 0 1 14 10 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
<path d="M74 10 H90 A4 4 0 0 1 94 14 V30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
<path d="M10 74 V90 A4 4 0 0 0 14 94 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
<path d="M74 94 H90 A4 4 0 0 0 94 90 V74" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
<!-- Body / face glyph -->
<!-- Head -->
<circle cx="52" cy="36" r="11" fill="#F9FAFB" />
<!-- Torso -->
<path d="M32 72 C34 56 42 48 52 48 C62 48 70 56 72 72 Z"
fill="#F9FAFB" />
<!-- Gravitation / tracking bias arc -->
<path d="M30 82 A24 24 0 0 0 74 82"
stroke="url(#accentGrad)" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.9" />
</g>
<!-- Subtle multi-thread / performance hint (three vertical bars) -->
<g transform="translate(210,188)">
<rect x="-10" y="-18" width="4" height="24" rx="2" fill="#22C55E" opacity="0.9" />
<rect x="-2" y="-22" width="4" height="28" rx="2" fill="#4ADE80" opacity="0.9" />
<rect x="6" y="-16" width="4" height="22" rx="2" fill="#16A34A" opacity="0.9" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -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();
}
}
}

View File

@ -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(); }
}
}
}

View File

@ -1,23 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class ActionToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value == null)
return null;
var p = System.Convert.ToInt32(value);
return p == 0
? "\uf125" // FA7 crop
: "\uf2f1" // FA7 rotate
;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -1,15 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class BoolInvertConverter : IValueConverter
{
public static readonly BoolInvertConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b ? !b : value;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b ? !b : value;
}

View File

@ -1,39 +0,0 @@
using Avalonia.Data.Converters;
using Avalonia.Media;
using System.Globalization;
namespace Splitter_UI.Converters;
public sealed class ConsoleColorToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ConsoleColor c)
return new SolidColorBrush(ToColor(c));
return Brushes.White;
}
private static Color ToColor(ConsoleColor c) =>
c switch
{
ConsoleColor.Black => Colors.Black,
ConsoleColor.DarkBlue => Colors.DarkBlue,
ConsoleColor.DarkGreen => Colors.DarkGreen,
ConsoleColor.DarkCyan => Colors.DarkCyan,
ConsoleColor.DarkRed => Colors.DarkRed,
ConsoleColor.DarkMagenta => Colors.DarkMagenta,
ConsoleColor.DarkYellow => Colors.Olive,
ConsoleColor.Gray => Colors.Gray,
ConsoleColor.DarkGray => Colors.DarkGray,
ConsoleColor.Blue => Colors.Blue,
ConsoleColor.Green => Colors.Green,
ConsoleColor.Cyan => Colors.Cyan,
ConsoleColor.Red => Colors.Red,
ConsoleColor.Magenta => Colors.Magenta,
ConsoleColor.Yellow => Colors.Yellow,
ConsoleColor.White => Colors.White,
_ => Colors.White
};
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
}

View File

@ -1,21 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class RotationAngleToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
90 => "\uf2f9", // FA7 (fa-rotate-left / fa-arrow-rotate-left / fa-undo)
180 => "\uf2f1", // FA7 (fa-sync-alt)
270 => "\uf2ea", // FA7 (fa-rotate-right / fa-arrow-rotate-right / fa-redo)
_ => null
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -1,13 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace Splitter_UI.Converters;
public sealed class ZeroToBoolConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> (value is int i && i == 0);
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -1,16 +0,0 @@
global using System;
global using System.Collections.Generic;
global using System.Threading.Tasks;
global using OpenCvSharp;
global using Size = Avalonia.Size;
global using Rect = Avalonia.Rect;
global using splitter;
global using splitter.tui;
global using splitter.algo;
global using splitter.probe;
global using Splitter_UI.Models;
global using Splitter_UI.Services;
global using Splitter_UI.ViewModels;

View File

@ -1,15 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.Models;
public partial class ParameterEntry : ObservableObject
{
public string Key { get; }
[ObservableProperty] private string _value;
public ParameterEntry(string key, string value)
{
Key = key;
Value = value;
}
}

View File

@ -1,22 +0,0 @@
namespace Splitter_UI.Models;
public class PreviewData
{
public Avalonia.Media.Imaging.Bitmap? Frame { get; }
public IReadOnlyList<DetectedPerson> DetectedBoxes { get; }
public Rect? CropRect { get; }
public Point2f GravitateTo { get; }
public TimeSpan Position { get; }
public int? Rotate { get; }
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<DetectedPerson> boxes, Rect? crop, Point2f gravitateTo, TimeSpan position, int? rotate)
{
Frame = frame;
DetectedBoxes = boxes;
CropRect = crop;
GravitateTo = gravitateTo;
Position = position;
Rotate = rotate;
}
}

View File

@ -1,88 +0,0 @@
using Avalonia;
using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection;
namespace Splitter_UI;
internal sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
private static ServiceCollection ConfigureServices()
{
var services = new ServiceCollection();
var logPaveVM = new LogPaneViewModel();
// ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<FileListViewModel>();
services.AddTransient<PreviewPaneViewModel>();
services.AddTransient<InspectorPaneViewModel>();
services.AddSingleton<StatusBarViewModel>();
services.AddSingleton<ProgressViewModel>();
services.AddSingleton<LogPaneViewModel>(logPaveVM);
services.AddSingleton<ILogService>(logPaveVM);
// splitter services
services.AddSingleton<UltraFaceDetector>();
services.AddSingleton<YoloV10ObjectDetector>();
services.AddSingleton<DummyDetector>();
services.AddSingleton<OSNetEmbeddingExtractor>();
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>("body", (x,_) => new SingleThreadedDetector<YoloV10ObjectDetector>(x.GetRequiredService<YoloV10ObjectDetector>()));
services.AddKeyedSingleton<IObjectDetector>("none", (x,_) => new SingleThreadedDetector<DummyDetector>(x.GetRequiredService<DummyDetector>()));
services.AddSingleton<IEmbeddingExtractor>(x => new SingleThreadedEmbeddingExtractor<OSNetEmbeddingExtractor>(x.GetRequiredService<OSNetEmbeddingExtractor>()));
services.AddSingleton<Func<string, IObjectDetector>>(x => detectorName => x.GetKeyedService<IObjectDetector>(detectorName) ?? new DummyDetector());
services.AddSingleton<Func<string, IObjectTracker>>(x => detectorName =>
{
var detectorFactory = x.GetRequiredService<Func<string, IObjectDetector>>();
var extractor = x.GetRequiredService<IEmbeddingExtractor>();
return new ObjectTracker(detectorFactory(detectorName), extractor);
});
services.AddSingleton<ILogger, GlobalLogger>();
services.AddSingleton<IJobProcessor, JobProcessor>();
// Domain services (your pipeline)
services.AddTransient<IFileProbeService, FileProbeService>();
services.AddTransient<IThumbnailService, ThumbnailService>();
services.AddSingleton<IAutoDecisionService, AutoDecisionService>();
services.AddSingleton<IFileJobFactory, FileJobFactory>();
return services;
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
{
var services = ConfigureServices();
var provider = services.BuildServiceProvider();
return AppBuilder.Configure<App>(() => new App(provider))
.UsePlatformDetect()
.With(new FontManagerOptions
{
FontFallbacks = new[]
{
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free") },
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free Solid") }
}
})
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
}

View File

@ -1,59 +0,0 @@
# Splitter-UI
A compact, modern desktop front-end for Splitter (the high-performance FFmpeg-based video splitter). Built with Avalonia 12 and
targeting .NET 10, this project provides a native-feeling cross-platform UI to configure splitting jobs, preview smart
crops, and drive the Splitter CLI backend.
## Overview
Splitter-UI wraps the core Splitter pipeline (the referenced splitter-cli project) and exposes common workflow tasks
through an accessible interface: input selection, output naming, duration and crop controls, rotation options, detector settings,
and a job monitor with progress and ETA. For the full command-line feature set and the implementation rationale, see the
repository root README (../README.md).
## Screenshots
![Main window with job list and settings](screenshot.png)
## Getting started
Requirements: .NET 10 runtime and FFmpeg/FFprobe available on PATH. The UI references the splitter-cli project;
build the solution to ensure the CLI is available to the UI during development.
To build and run locally:
1. From the solution root run: dotnet build
2. Start the UI project: dotnet run --project Splitter-UI
## Packaging
The csproj is configured for a win-x64 self-contained runtime identifier. Use dotnet publish with the desired
configuration and runtime identifier to produce distributable artifacts.
## Configuration
Settings exposed in the UI map closely to the CLI options: output folder, filename mask, segment duration and
force mode, rotation, crop and detector choices, gravitation bias and detector parameters. Advanced passthrough
arguments can still be supplied to FFmpeg via the CLI passthrough field.
## Developer notes
- Project: Splitter-UI (Avalonia 12, net10.0)
- Key packages: Avalonia, Avalonia.Controls.DataGrid, Avalonia.Desktop, Avalonia.Themes.Fluent, CommunityToolkit.Mvvm
- The UI project references the splitter-cli project for tight integration during development.
## Troubleshooting
If FFmpeg or FFprobe are not found, the app will be unable to probe media or run splits. Verify tools are on the
system PATH and that the runtime matches the built RID.
## Contributing and License
Contributions follow the main repository guidelines. See the root README for contributor and license information.
## Contact
For issues or questions, open an issue on the project repository or contact the
maintainer listed in the main README.

View File

@ -1,75 +0,0 @@
namespace Splitter_UI.Services;
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
{
public void ApplyAutoDecisions(JobViewModel job, CancellationToken token)
{
Task.Run(() => Detect(job, token));
}
private async Task Detect(JobViewModel job, CancellationToken token)
{
try
{
job.GravitateTo = new(0.5f, 0.5f);
job.OverrideTargetDuration = 58.0;
job.Mask = "[NAME]_seg[NN].[EXT]";
job.OutputFolder = Path.Combine(Path.GetDirectoryName(job.InputFile)!, "splitter");
job.Probe = await _fileProbe.ProbeAsync(job.InputFile, token);
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
if (job.Probe.Width > job.Probe.Height)
{
job.Detect = "body";
job.Rotate = 0;
CalculateCrop(job);
}
//else
//{
// var sampler = new VideoRotationSampler(null);
// job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration, token);
// job.Detect = job.Rotate == 0 ? null : "body";
//}
_log.LogInfo(job.ToString());
}
catch (Exception ex)
{
_log.LogError($"Error creating thumbnail for {Path.GetFileName(job.InputFile)}: {ex.Message}");
}
}
private static void CalculateCrop(JobViewModel job)
{
var targetAR = (float)CommandLine.DefaultW / CommandLine.DefaultH;
var pixelAspect = job.Probe!.Sar.X / job.Probe.Sar.Y;
var srcW = job.Probe.Width * pixelAspect;
float srcH = job.Probe.Height;
var srcAR = srcW / srcH;
var cropH = srcH;
var cropW = cropH * targetAR;
if (cropW > srcW)
{
cropW = srcW;
cropH = cropW / targetAR;
}
var x = (srcW - cropW) * 0.5f;
var y = (srcH - cropH) * 0.5f;
var invPixelAspect = 1f / pixelAspect;
var cropW_px = cropW * invPixelAspect;
var cropH_px = cropH;
var x_px = x * invPixelAspect;
var y_px = y;
job.CropText = $"{(int)MathF.Round(cropW_px)},{(int)MathF.Round(cropH_px)}";
}
}

View File

@ -1,42 +0,0 @@
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
namespace Splitter_UI.Services;
public static class AvaloniaBitmapExtensions
{
public static Mat ToMatContinuous(this Bitmap bmp)
{
var w = bmp.PixelSize.Width;
var h = bmp.PixelSize.Height;
var stride = w * 4;
var size = h * stride;
var buffer = new byte[size];
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
bmp.CopyPixels(
new PixelRect(0, 0, w, h),
handle.AddrOfPinnedObject(),
size,
stride);
return Mat.FromPixelData(h, w, MatType.CV_8UC4, buffer);
}
finally
{
handle.Free();
}
}
public static Mat ToMatBgrContinuous(this Bitmap bmp)
{
using var bgra = bmp.ToMatContinuous();
var bgr = new Mat();
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
return bgr;
}
}

View File

@ -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;
}
}

View File

@ -1,17 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
public sealed class FileJobFactory : IFileJobFactory
{
private readonly IServiceProvider _services;
public FileJobFactory(IServiceProvider services)
{
_services = services;
}
public JobViewModel Create(SingleJob job)
{
// Resolve a fresh VM + fresh services
return ActivatorUtilities.CreateInstance<JobViewModel>(_services, job);
}
}

View File

@ -1,10 +0,0 @@
namespace Splitter_UI.Services;
public sealed class FileProbeService : IFileProbeService
{
public async Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token)
{
var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false, token), token);
return res;
}
}

View File

@ -1,24 +0,0 @@
namespace Splitter_UI.Services;
internal class GlobalLogger(ILogService _logService, StatusBarViewModel _statusBar, ProgressViewModel _progress) : ILogger
{
public void ClearProgress(string name, int progressLine)
{
if (progressLine == 0)
_statusBar.Percent = 0;
else
_progress.ClearProgress(name, progressLine-1);
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
{
if (progressLine == 0)
_statusBar.Percent = progress;
else
_progress.DrawProgress(name, progressLine - 1, progress, eta, speed);
}
public void Log(string prefix, ConsoleColor color, string msg)
{
_logService.Log(prefix, color, msg);
}
}

View File

@ -1,6 +0,0 @@
namespace Splitter_UI.Services;
public interface IAutoDecisionService
{
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
}

View File

@ -1,6 +0,0 @@
namespace Splitter_UI.Services;
public interface IBufferPool
{
BufferPool.Entry Get(int w, int h);
}

View File

@ -1,6 +0,0 @@
namespace Splitter_UI.Services;
public interface IFileJobFactory
{
JobViewModel Create(SingleJob job);
}

View File

@ -1,6 +0,0 @@
namespace Splitter_UI.Services;
public interface IFileProbeService
{
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
}

View File

@ -1,7 +0,0 @@

namespace Splitter_UI.Services;
public interface ILogService
{
void Log(string prefix, ConsoleColor color, string msg);
}

View File

@ -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);
}

View File

@ -1,8 +0,0 @@
using Avalonia.Media.Imaging;
namespace Splitter_UI.Services;
public interface IThumbnailService
{
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null, int? rotateDegree = null);
}

View File

@ -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);
}
}
}

View File

@ -1,42 +0,0 @@
namespace Splitter_UI.Services;
public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetector
where T : IObjectDetector
{
private Lock _lock = new();
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
{
lock (_lock)
{
return _detector.DetectAll(job, frameCont);
}
}
public void Dispose()
{
if ( _detector is IDisposable d )
d.Dispose();
}
}
public class SingleThreadedEmbeddingExtractor<T>(IEmbeddingExtractor _extractor) : IEmbeddingExtractor
where T : IEmbeddingExtractor
{
private Lock _lock = new();
public float[] Extract(Mat frame, OpenCvSharp.Rect box)
{
lock (_lock)
{
return _extractor.Extract(frame, box);
}
}
public void Dispose()
{
if (_extractor is IDisposable d)
d.Dispose();
}
}

View File

@ -1,133 +0,0 @@
using System.Diagnostics;
using Avalonia.Media.Imaging;
namespace Splitter_UI.Services;
public sealed class ThumbnailService : IThumbnailService
{
public const int ThumbWidth = 160;
public const int ThumbHeight = 90;
private readonly IMatToBitmapConverter _converter;
private readonly IBufferPool _pool;
private SemaphoreSlim _lock = new(1,1);
public ThumbnailService(
IMatToBitmapConverter converter,
IBufferPool pool)
{
_converter = converter;
_pool = pool;
}
public async Task<Bitmap?> CreateThumbnailAsync(
string file,
VideoInfo probe,
TimeSpan? skip = null,
int? width = null,
int? height = null,
int? rotateDegree = null)
{
await _lock.WaitAsync();
try
{
return await CreateThumbnailInternal(
file,
probe,
skip,
width,
height,
rotateDegree
);
}
finally
{
_lock.Release();
}
}
private async Task<Bitmap?> CreateThumbnailInternal(
string file,
VideoInfo probe,
TimeSpan? skip = null,
int? width = null,
int? height = null,
int? rotateDegree = null)
{
width ??= ThumbWidth;
height ??= ThumbHeight;
skip ??= TimeSpan.Zero;
var entry = _pool.Get(width.Value, height.Value);
var ok = await DecodeFrameAsync(
entry.Bgr,
file,
skip.Value,
width.Value,
height.Value,
rotateDegree
);
if (!ok)
return null;
return _converter.Convert(entry.Bgr, width.Value, height.Value);
}
private static async Task<bool> DecodeFrameAsync(
byte[] bgrBuffer,
string file,
TimeSpan skip,
int width,
int height,
int? rotateDegree)
{
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
var args =
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
"-an -sn " +
$"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," +
$"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24{rotationStr}\" " +
"-f rawvideo -";
var psi = new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
var p = new Process { StartInfo = psi };
p.Start();
var needed = bgrBuffer.Length;
var read = 0;
using var stdout = p.StandardOutput.BaseStream;
while (read < needed)
{
var r = await stdout.ReadAsync(bgrBuffer, read, needed - read);
if (r == 0)
{
TryKill(p);
return false;
}
read += r;
}
TryKill(p);
return true;
}
private static void TryKill(Process p)
{
try { p.Kill(); } catch { }
}
}

View File

@ -1,37 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets/splitter.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\splitter-cli\splitter.csproj" />
</ItemGroup>
</Project>

View File

@ -1,34 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
namespace Splitter_UI;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}

View File

@ -1,59 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Splitter_UI.ViewModels;
public partial class FileListViewModel : ObservableObject
{
private readonly IFileJobFactory _factory;
private readonly IAutoDecisionService _autoDecisionService;
public ObservableCollection<JobViewModel> Files { get; } = [];
public ObservableCollection<JobViewModel> SelectedFiles { get; } = [];
[ObservableProperty]
private JobViewModel? _selected;
public event Action<JobViewModel?>? SelectedFileChanged;
public FileListViewModel(IFileJobFactory factory, IAutoDecisionService autoDecisionService)
{
_factory = factory;
_autoDecisionService = autoDecisionService;
}
partial void OnSelectedChanged(JobViewModel? value)
=> SelectedFileChanged?.Invoke(value);
[RelayCommand]
private void AddFiles(IEnumerable<string> paths)
{
foreach (var path in paths)
{
// Probe + auto-detect + thumbnail
var job = new SingleJob { InputFile = path };
var vm = _factory.Create(job);
Files.Add(vm);
_autoDecisionService.ApplyAutoDecisions(vm, CancellationToken.None);
}
Selected = Files.LastOrDefault();
}
internal void DeleteSelected()
{
if (SelectedFiles.Any())
{
var toDelete = SelectedFiles.ToList();
foreach (var item in toDelete)
Files.Remove(item);
}
else if ( Selected != null)
{
var sel = Selected;
Files.Remove(sel);
}
Selected = Files.LastOrDefault();
}
}

View File

@ -1,61 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Splitter_UI.ViewModels;
public partial class InspectorPaneViewModel : ObservableObject
{
[ObservableProperty]
private JobViewModel? _selected;
public ObservableCollection<JobViewModel> Files { get; set; } = [];
public List<string> DetectModes =>
[
"face", "body", "none"
];
[RelayCommand]
private void TransformAll()
{
_ = _main.Start();
}
[RelayCommand]
private void ApplyOverrides()
{
if (Selected is null)
return;
foreach (JobViewModel job in Files.Where(x => !ReferenceEquals(x, Selected)))
job.CopyFrom(Selected);
}
public IRelayCommand RotateLeftCommand { get; }
public IRelayCommand RotateRightCommand { get; }
private MainViewModel _main = null!;
public InspectorPaneViewModel()
{
RotateLeftCommand = new RelayCommand(() => AdjustRotation(-90));
RotateRightCommand = new RelayCommand(() => AdjustRotation(+90));
}
public void SetMain(MainViewModel main) => _main = main;
private void AdjustRotation(int delta)
{
if ( Selected == null)
return;
var r = Selected.Rotate;
r = (r + delta) % 360;
if (r < 0) r += 360;
Selected.Rotate = r;
}
}

View File

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

View File

@ -1,36 +0,0 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace Splitter_UI.ViewModels;
public sealed record LogEntry(string Prefix, ConsoleColor Color, string Message);
public partial class LogPaneViewModel : ObservableObject, ILogService
{
public ObservableCollection<LogEntry> Logs { get; } = [];
public void Log(string prefix, ConsoleColor color, string msg)
{
Add(new LogEntry(prefix.Replace("[", "").Replace("]", ""), color, msg));
}
private void Add(LogEntry entry)
{
if (Dispatcher.UIThread.CheckAccess())
{
AddInternal(entry);
}
else
{
Dispatcher.UIThread.Post(() => AddInternal(entry));
}
}
private void AddInternal(LogEntry entry)
{
Logs.Add(entry);
if (Logs.Count > 5000)
Logs.RemoveAt(0);
}
}

View File

@ -1,94 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class MainViewModel : ViewModelBase
{
public FileListViewModel FileList { get; }
public PreviewPaneViewModel Preview { get; }
public InspectorPaneViewModel Inspector { get; }
public StatusBarViewModel StatusBar { get; }
public LogPaneViewModel LogPane { get; }
public ProgressViewModel Progress { get; }
private IJobProcessor _processor = null!;
[ObservableProperty] private bool _transformMode = false;
private ILogger _logger;
private CancellationTokenSource? _cancellationTokenSource;
public MainViewModel(
FileListViewModel fileListVM,
PreviewPaneViewModel ppVM,
InspectorPaneViewModel iVM,
LogPaneViewModel lpVM,
StatusBarViewModel sbVM,
ProgressViewModel pVM,
IJobProcessor processor,
ILogger logger
)
{
FileList = fileListVM;
Preview = ppVM;
Inspector = iVM;
LogPane = lpVM;
StatusBar = sbVM;
Progress = pVM;
_processor = processor;
_logger = logger;
// Wire selection -> preview + inspector
FileList.SelectedFileChanged += file =>
{
Preview.Selected = file;
Inspector.Selected = file;
};
Progress.SetMain(this);
Inspector.SetMain(this);
Inspector.Files = FileList.Files;
}
public void Cancel()
{
_cancellationTokenSource?.Cancel();
}
public Task Start() => Task.Run(async () =>
{
_cancellationTokenSource = new CancellationTokenSource();
try
{
StatusBar.StatusText = "Processing…";
StatusBar.Percent = 0;
TransformMode = true;
var files = FileList.Files.ToList();
var jobs = new List<SingleTask>();
foreach (var file in files)
{
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, file.Segments, _cancellationTokenSource.Token);
jobs.AddRange(fileJobs);
}
await _processor.ProcessJobs(jobs, jobs.First().Job.Enhance, _cancellationTokenSource.Token);
}
catch (Exception ex)
{
// Handle exception
StatusBar.StatusText = "Error occurred…";
_logger.LogError($"Error: {ex.Message}");
_cancellationTokenSource.Cancel();
}
finally
{
StatusBar.StatusText = "Ready…";
StatusBar.Percent = 0;
TransformMode = false;
_cancellationTokenSource?.Dispose();
}
});
}

View File

@ -1,77 +0,0 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class PreviewPaneViewModel : ObservableObject
{
[ObservableProperty]
private JobViewModel? _selected;
public PreviewData? Preview => Selected?.Preview;
public Point2f? Sar => Selected?.Probe?.Sar;
public int Rotate => Selected?.Rotate ?? 0;
public Point2f GravitateTo
{
get => Selected?.GravitateTo ?? new Point2f(0.5f, 0.5f);
set
{
if (Selected == null)
return;
Selected.GravitateTo = value;
OnPropertyChanged(nameof(GravitateTo));
}
}
public float DetectAbove
{
get => Selected?.DetectAbove ?? 0.7f;
set
{
if (Selected == null)
return;
Selected.DetectAbove = value;
OnPropertyChanged(nameof(DetectAbove));
}
}
public ulong? TrackedId
{
get => Selected?.DetectId;
set
{
if (Selected == null)
return;
Selected.DetectId = value;
OnPropertyChanged(nameof(TrackedId));
}
}
partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue)
{
if (oldValue != null)
oldValue.PropertyChanged -= SelectedPropertyChanged;
if (newValue != null)
newValue.PropertyChanged += SelectedPropertyChanged;
OnPropertyChanged(nameof(Preview));
OnPropertyChanged(nameof(Sar));
OnPropertyChanged(nameof(Rotate));
OnPropertyChanged(nameof(TrackedId));
OnPropertyChanged(nameof(DetectAbove));
}
private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(JobViewModel.Preview))
OnPropertyChanged(nameof(Preview));
if (e.PropertyName == nameof(JobViewModel.Probe))
{
OnPropertyChanged(nameof(Sar));
OnPropertyChanged(nameof(Rotate));
}
}
}

View File

@ -1,72 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Splitter_UI.ViewModels;
public record ProgressInfo(string Name, int ProgressLine, double Progress, TimeSpan Eta, double Speed);
public partial class ProgressViewModel : ObservableObject
{
[ObservableProperty] private int _numberOfProcesses = 0;
public ObservableCollection<ProgressInfo> Processes { get; } = [];
private Lock _lock = new();
private MainViewModel _mainModel = null!;
[RelayCommand]
private void Cancel()
{
_mainModel.Cancel();
}
public void SetMain(MainViewModel mainModel)
{
_mainModel = mainModel;
}
public void ClearProgress(string name, int progressLine) => Dispatch(() =>
{
lock (_lock)
{
if (progressLine < 0 || progressLine > Processes.Count)
return;
NumberOfProcesses -= 1;
Processes[progressLine] = new ProgressInfo("", progressLine, 0, TimeSpan.Zero, 0);
}
});
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) => Dispatch(() =>
{
lock (_lock)
{
if (progressLine < 0)
return;
while (Processes.Count <= progressLine)
{
Processes.Add(new ProgressInfo("", Processes.Count, 0, TimeSpan.Zero, 0));
}
if (Processes[progressLine].Name == "")
NumberOfProcesses += 1;
Processes[progressLine] = new ProgressInfo(name, progressLine, progress, eta, speed);
}
});
private void Dispatch(Action action)
{
if (Dispatcher.UIThread.CheckAccess())
{
action();
}
else
{
Dispatcher.UIThread.Post(() => action());
}
}
}

View File

@ -1,13 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public partial class StatusBarViewModel : ObservableObject
{
[ObservableProperty]
private string _statusText = "Ready";
[ObservableProperty]
private double _percent;
}

View File

@ -1,7 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View File

@ -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!);
}
}

View File

@ -1,80 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.FileListView"
x:DataType="vm:FileListViewModel"
KeyDown="OnKeyDown"
Focusable="True">
<UserControl.Resources>
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="views|FileListView Border#DropZone">
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<Style Selector="views|FileListView[IsDragActive=true] Border#DropZone">
<Setter Property="BorderBrush" Value="Red"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</UserControl.Styles>
<Border x:Name="DropZone"
Background="#1E1E1E"
Padding="10"
DragDrop.AllowDrop="True"
DragDrop.Drop="OnDrop"
DragDrop.DragOver="OnDragOver"
DragDrop.DragEnter="OnDragEnter"
DragDrop.DragLeave="OnDragLeave">
<Grid>
<!-- Empty message -->
<TextBlock Text="Drag files here"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
Foreground="#666"
IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
<!-- File list -->
<ScrollViewer>
<ListBox ItemsSource="{Binding Files}"
SelectedItems="{Binding SelectedFiles}"
SelectedItem="{Binding Selected}"
SelectionMode="Multiple"
BorderThickness="0"
Background="Transparent">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#9A9A9A"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:JobViewModel">
<views:JobListItemView/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@ -1,78 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform.Storage;
namespace Splitter_UI.Views;
public partial class FileListView : UserControl
{
public static readonly StyledProperty<bool> IsDragActiveProperty =
AvaloniaProperty.Register<FileListView, bool>(nameof(IsDragActive));
public bool IsDragActive
{
get => GetValue(IsDragActiveProperty);
set => SetValue(IsDragActiveProperty, value);
}
public FileListView()
{
InitializeComponent();
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Delete)
{
if (DataContext is FileListViewModel vm)
vm.DeleteSelected();
}
}
private void OnDragEnter(object? sender, DragEventArgs e)
{
IsDragActive = true;
}
private void OnDragLeave(object? sender, DragEventArgs e)
{
IsDragActive = false;
}
private void OnDragOver(object? sender, DragEventArgs e)
{
// Avalonia 12:
// e.Data is IDataObject, but it has NO strongly typed formats.
if (e.DataTransfer.Contains(DataFormat.File))
e.DragEffects = DragDropEffects.Copy;
else
e.DragEffects = DragDropEffects.None;
e.Handled = true;
}
private async void OnDrop(object? sender, DragEventArgs e)
{
IsDragActive = false;
if (DataContext is not FileListViewModel vm)
return;
if (!e.DataTransfer.Contains(DataFormat.File))
return;
// Avalonia 12:
// This is the ONLY correct way to get dropped files.
var items = e.DataTransfer.TryGetFiles();
if (items is null)
return;
var paths = items
.OfType<IStorageFile>()
.Select(f => f.Path.LocalPath)
.ToList();
if (paths.Count > 0)
vm.AddFilesCommand.Execute(paths);
}
}

View File

@ -1,228 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.InspectorPane"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:InspectorPaneViewModel">
<Border Background="#252525" Padding="12">
<ScrollViewer>
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,0,10,0">
<Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"/>
<Button Content="Transform all"
Background="#AA0000"
Foreground="White"
Command="{Binding TransformAllCommand}"/>
</StackPanel>
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
<!-- InputFile -->
<StackPanel Orientation="Vertical" Spacing="8">
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,5" FontStyle="Italic"/>
<TextBlock Text="{Binding Selected.TextDesc}" Width="360" FontSize="10" Margin="0,0,0,10" FontWeight="Bold" Foreground="#676767"/>
</StackPanel>
<!-- Rotate -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Rotate" Width="120"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Width="24" Height="24"
Padding="0"
Command="{Binding RotateLeftCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf2ea;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="0,-1,0,0"/>
</Button>
<Button Width="24" Height="24"
Padding="0"
Command="{Binding RotateRightCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf2f9;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="0,-1,0,0"/>
</Button>
<!-- Angle display -->
<TextBlock Text="{Binding Selected.Rotate}"
Width="40"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- Mask -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Mask" Width="120"/>
<TextBox Text="{Binding Selected.Mask}" Width="260"/>
</StackPanel>
<!-- OutputFolder -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Output Folder" Width="120"/>
<TextBox Text="{Binding Selected.OutputFolder}" Width="260"/>
</StackPanel>
<!-- Crop -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Crop (w,h)" Width="120"/>
<TextBox Text="{Binding Selected.CropText}" Width="160"/>
</StackPanel>
<!-- GravitateTo -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="GravitateTo" Width="120"/>
<TextBox Text="{Binding Selected.GravitateText}" Width="160"/>
</StackPanel>
<!-- Detect -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Detect" Width="120"/>
<ComboBox ItemsSource="{Binding DetectModes}"
SelectedItem="{Binding Selected.Detect}"
Width="160"/>
</StackPanel>
<!-- ScoreThreshold -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Score threshold" Width="120"/>
<StackPanel Orientation="Vertical" Spacing="4" Width="260">
<Slider Minimum="0"
Maximum="1"
SmallChange="0.01"
LargeChange="0.1"
TickFrequency="0.05"
IsSnapToTickEnabled="False"
Value="{Binding Selected.ScoreThreshold, Mode=TwoWay}"/>
<TextBlock Text="{Binding Selected.ScoreThreshold, StringFormat='0.00'}"
FontSize="10"
HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
<!-- ScoreThreshold -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Identity matching threshold" Width="120"/>
<StackPanel Orientation="Vertical" Spacing="4" Width="260">
<Slider Minimum="0"
Maximum="1"
SmallChange="0.01"
LargeChange="0.1"
TickFrequency="0.05"
IsSnapToTickEnabled="False"
Value="{Binding Selected.IdentityThreshold, Mode=TwoWay}"/>
<TextBlock Text="{Binding Selected.IdentityThreshold, StringFormat='0.00'}"
FontSize="10"
HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
<!-- DetectAbove -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Detect Above" Width="120"/>
<TextBox Text="{Binding Selected.DetectAbove}" Width="160"/>
</StackPanel>
<!-- DetectId -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Object to track" Width="120"/>
<TextBox Text="{Binding Selected.DetectId}" Width="160"/>
</StackPanel>
<!-- OverrideTargetDuration -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Target Duration" Width="120"/>
<NumericUpDown Value="{Binding Selected.OverrideTargetDuration}" Width="120"/>
</StackPanel>
<!-- Enhance -->
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox Content="Enhance resolution x2"
IsChecked="{Binding Selected.Enhance}"/>
<TextBlock Text="(Very slow and not worth it!)"
Foreground="#FFFF80FF"
FontSize="10"
Margin="0,12,0,0"/>
</StackPanel>
<!-- ForceFixed -->
<CheckBox Content="Force Fixed Duration"
IsChecked="{Binding Selected.ForceFixed}"/>
<!-- Debug -->
<CheckBox Content="Debug Mode"
IsChecked="{Binding Selected.Debug}"/>
<!-- Parameters dictionary -->
<TextBlock Text="Advanced Parameters" FontSize="10" Margin="0,10,0,0" FontWeight="Bold"/>
<DataGrid ItemsSource="{Binding Selected.ParametersList}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
Margin="0,0,20,0"
Height="160">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Key" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Key}"
FontSize="10"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Key}">
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Value" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Value}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
<DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<TextBox Text="{Binding Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Passthrough -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Passthrough" Width="120"/>
<TextBox Text="{Binding Selected.PassthroughText}" Width="260"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>

View File

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class InspectorPane : UserControl
{
public InspectorPane()
{
InitializeComponent();
}
}

View File

@ -1,50 +0,0 @@
<UserControl
x:Class="Splitter_UI.Views.JobListItemView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:DataType="vm:JobViewModel">
<UserControl.Resources>
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
<conv:ActionToIconConverter x:Key="ActionToIconConverter"/>
</UserControl.Resources>
<Border Margin="0"
Padding="0"
CornerRadius="4"
Background="#2A2A2A">
<StackPanel MinWidth="160" MaxWidth="160">
<Border Width="160" Height="90" ClipToBounds="True">
<Grid>
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource ActionToIconConverter}}"
FontSize="12"
HorizontalAlignment="Right"
Foreground="LimeGreen"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
FontSize="12"
HorizontalAlignment="Left"
Foreground="LimeGreen"/>
</Grid>
</Border>
<TextBlock Text="{Binding FileName}"
TextWrapping="Wrap"
Margin="0,6,0,0"
FontSize="10"/>
</StackPanel>
</Border>
</UserControl>

View File

@ -1,12 +0,0 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class JobListItemView : UserControl
{
public JobListItemView()
{
InitializeComponent();
}
}

View File

@ -1,34 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.LogPane"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:LogPaneViewModel">
<UserControl.Resources>
<conv:ConsoleColorToBrushConverter x:Key="ConsoleColorToBrushConverter"/>
</UserControl.Resources>
<Border Background="#111" Padding="8">
<ScrollViewer x:Name="Scroller">
<ItemsControl ItemsSource="{Binding Logs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LogEntry">
<StackPanel Orientation="Horizontal">
<TextBlock Text="[" FontFamily="Consolas" FontSize="12"/>
<TextBlock Text="{Binding Prefix}"
FontFamily="Consolas"
FontSize="12"
Foreground="{Binding Color, Converter={StaticResource ConsoleColorToBrushConverter}}"/>
<TextBlock Text="] " FontFamily="Consolas" FontSize="12"/>
<TextBlock Text="{Binding Message}"
FontFamily="Consolas"
FontSize="12"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</UserControl>

View File

@ -1,31 +0,0 @@
using Avalonia.Controls;
using Avalonia.Threading;
namespace Splitter_UI.Views;
public partial class LogPane : UserControl
{
public LogPane()
{
InitializeComponent();
// When DataContext changes, subscribe to collection changes
this.DataContextChanged += (_, _) =>
{
if (DataContext is LogPaneViewModel vm)
{
vm.Logs.CollectionChanged += (_, _) => ScrollToEnd();
}
};
}
private void ScrollToEnd()
{
// Must run after layout pass
Dispatcher.UIThread.Post(() =>
{
if (Scroller != null)
Scroller.ScrollToEnd();
}, DispatcherPriority.Background);
}
}

View File

@ -1,57 +0,0 @@
<Window
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:Splitter_UI.Views"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:conv="clr-namespace:Splitter_UI.Converters"
x:Class="Splitter_UI.Views.MainWindow"
x:DataType="vm:MainViewModel"
x:Name="Root"
Width="1800"
Height="870"
Title="Splitter UI"
Icon="avares://Splitter-UI/Assets/splitter.png">
<Window.Resources>
<conv:BoolInvertConverter x:Key="BoolInvertConverter"/>
</Window.Resources>
<DockPanel>
<!-- Status Bar -->
<views:StatusBarView DockPanel.Dock="Bottom"
DataContext="{Binding StatusBar}" />
<Grid ColumnDefinitions="220,Auto,*,430"
IsVisible="{Binding TransformMode, Converter={StaticResource BoolInvertConverter}}">
<!-- File List -->
<views:FileListView Grid.Column="0"
DataContext="{Binding FileList}" />
<!-- Splitter -->
<GridSplitter Grid.Column="1"
Width="6"
Background="#404040"
ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"
ShowsPreview="True" />
<!-- Preview -->
<views:PreviewPane Grid.Column="2"
DataContext="{Binding Preview}" />
<!-- Inspector -->
<views:InspectorPane Grid.Column="3"
DataContext="{Binding Inspector}" />
</Grid>
<Grid ColumnDefinitions="*"
IsVisible="{Binding TransformMode}">
<views:ProgressView DataContext="{Binding Progress}"/>
</Grid>
</DockPanel>
</Window>

View File

@ -1,10 +0,0 @@
namespace Splitter_UI.Views;
public partial class MainWindow : Avalonia.Controls.Window
{
public MainViewModel Data { get; } = null!; // set by DI
public MainWindow()
{
InitializeComponent();
}
}

View File

@ -1,670 +0,0 @@
using System.ComponentModel;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
namespace Splitter_UI.Views;
public sealed class PreviewCanvas : Control
{
public static readonly StyledProperty<PreviewData?> PreviewProperty =
AvaloniaProperty.Register<PreviewCanvas, PreviewData?>(nameof(Preview));
public static readonly StyledProperty<Point2f?> SarProperty =
AvaloniaProperty.Register<PreviewCanvas, Point2f?>(nameof(Sar));
public static readonly StyledProperty<int> RotateAngleProperty =
AvaloniaProperty.Register<PreviewCanvas, int>(nameof(RotateAngle));
public static readonly StyledProperty<Point2f> GravitateToProperty =
AvaloniaProperty.Register<PreviewCanvas, Point2f>(nameof(GravitateTo));
public static readonly StyledProperty<float> DetectAboveProperty =
AvaloniaProperty.Register<PreviewCanvas, float>(nameof(DetectAbove), 0.2f);
public static readonly StyledProperty<ulong?> DetectIdProperty =
AvaloniaProperty.Register<PreviewCanvas, ulong?>(nameof(DetectId));
public PreviewData? Preview
{
get => GetValue(PreviewProperty);
set => SetValue(PreviewProperty, value);
}
public Point2f? Sar
{
get => GetValue(SarProperty);
set => SetValue(SarProperty, value);
}
public int RotateAngle
{
get => GetValue(RotateAngleProperty);
set => SetValue(RotateAngleProperty, value);
}
// GravitateTo is normalized (0..1)
public Point2f GravitateTo
{
get => GetValue(GravitateToProperty);
set => SetValue(GravitateToProperty, value);
}
public ulong? DetectId
{
get => GetValue(DetectIdProperty);
set => SetValue(DetectIdProperty, value);
}
// DetectAbove is normalized (0..1) from top
public float DetectAbove
{
get => GetValue(DetectAboveProperty);
set => SetValue(DetectAboveProperty, value);
}
private bool _draggingGravitate;
private Avalonia.Point _dragStartCanvas;
private Point2f _dragStartValue;
private bool _draggingDetectAbove;
private double _dragStartDetectAbove; // normalized 0..1
static PreviewCanvas()
{
PreviewProperty.Changed.AddClassHandler<PreviewCanvas>(
(canvas, args) =>
canvas.OnPreviewChanged(args.OldValue as PreviewData,
args.NewValue as PreviewData));
}
public PreviewCanvas()
{
PointerPressed += OnPointerPressed;
PointerMoved += OnPointerMoved;
PointerReleased += OnPointerReleased;
}
private void OnPreviewChanged(PreviewData? oldValue, PreviewData? newValue)
{
if (oldValue is INotifyPropertyChanged oldNotify)
oldNotify.PropertyChanged -= PreviewPropertyChanged;
if (newValue is INotifyPropertyChanged newNotify)
newNotify.PropertyChanged += PreviewPropertyChanged;
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
private void PreviewPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PreviewData.Frame) ||
e.PropertyName == nameof(PreviewData.DetectedBoxes) ||
e.PropertyName == nameof(PreviewData.CropRect))
{
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
}
protected override Size MeasureOverride(Size availableSize) => availableSize;
protected override Size ArrangeOverride(Size finalSize) => finalSize;
// ------------------------------------------------------------
// Unified transform helpers
// ------------------------------------------------------------
private (double X, double Y) TransformPoint(
double x, double y,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
switch (rotate)
{
case 90:
(x, y) = (rawH - y, x);
break;
case 180:
x = rawW - x;
y = rawH - y;
break;
case 270:
(x, y) = (y, rawW - x);
break;
}
if (rotate == 0 || rotate == 180)
x *= pixelAspect;
else
y *= pixelAspect;
var sx = offsetX + x * scale;
var sy = offsetY + y * scale;
return (sx, sy);
}
private Rect TransformRect(
double x, double y, double w, double h,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
switch (rotate)
{
case 90:
(x, y) = (rawH - (y + h), x);
(w, h) = (h, w);
break;
case 180:
x = rawW - (x + w);
y = rawH - (y + h);
break;
case 270:
(x, y) = (y, rawW - (x + w));
(w, h) = (h, w);
break;
}
if (rotate == 0 || rotate == 180)
{
x *= pixelAspect;
w *= pixelAspect;
}
else
{
y *= pixelAspect;
h *= pixelAspect;
}
return new Rect(
offsetX + x * scale,
offsetY + y * scale,
w * scale,
h * scale);
}
private void GetAspects(
PreviewData preview,
out int rawW,
out int rawH,
out int rotate,
out float pixelAspect,
out double scale,
out double offsetX,
out double offsetY)
{
rawW = preview.Frame!.PixelSize.Width;
rawH = preview.Frame.PixelSize.Height;
rotate = RotateAngle;
var sar = Sar ?? new Point2f(1, 1);
pixelAspect = sar.X / sar.Y;
var dispW = Bounds.Width;
var dispH = Bounds.Height;
double displayW, displayH;
if (rotate == 0 || rotate == 180)
{
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
displayW = rawW;
displayH = rawH * pixelAspect;
}
scale = Math.Min(dispW / displayW, dispH / displayH);
offsetX = (dispW - displayW * scale) / 2;
offsetY = (dispH - displayH * scale) / 2;
}
// ------------------------------------------------------------
// Hit test for gravitate point (normalized)
// ------------------------------------------------------------
private bool HitGravitate(Avalonia.Point p, out Point2f value)
{
value = default;
var preview = Preview;
if (preview?.Frame is null)
return false;
var g = GravitateTo;
int rawW, rawH, rotate;
float pixelAspect;
double scale, offsetX, offsetY;
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
double px = g.X * rawW;
double py = g.Y * rawH;
var (cx, cy) = TransformPoint(
px, py,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
const double radius = 10;
var hit = (p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy) <= radius * radius;
if (hit)
value = g;
return hit;
}
// ------------------------------------------------------------
// Hit test for DetectAbove knob (normalized)
// ------------------------------------------------------------
private bool HitDetectAbove(Avalonia.Point p, out double value)
{
value = default;
var preview = Preview;
if (preview?.Frame is null)
return false;
int rawW, rawH, rotate;
float pixelAspect;
double scale, offsetX, offsetY;
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
var da = DetectAbove;
var py = da * rawH;
var px = rawW / 2.0;
var (cx, cy) = TransformPoint(
px, py,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
const double radius = 10;
var hit = (p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy) <= radius * radius;
if (hit)
value = da;
return hit;
}
// ------------------------------------------------------------
// Hit test for detected boxes
// ------------------------------------------------------------
private bool HitDetectedBox(Avalonia.Point p, out ulong? value)
{
value = null;
var preview = Preview;
if (preview?.Frame is null)
return false;
int rawW, rawH, rotate;
float pixelAspect;
double scale, offsetX, offsetY;
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
var frame = preview.Frame;
foreach (var box in preview.DetectedBoxes)
{
var rect = TransformRect(
box.Box.X, box.Box.Y, box.Box.Width, box.Box.Height,
frame.PixelSize.Width, frame.PixelSize.Height,
offsetX, offsetY, scale,
RotateAngle,
Sar?.X / Sar?.Y ?? 1);
if (rect.Contains(p))
{
value = box.Id;
return true;
}
}
return false;
}
// ------------------------------------------------------------
// Pointer events
// ------------------------------------------------------------
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
var p = e.GetPosition(this);
if (HitGravitate(p, out var g))
{
_draggingGravitate = true;
_dragStartCanvas = p;
_dragStartValue = g; // normalized
e.Pointer.Capture(this);
return;
}
if (HitDetectAbove(p, out var da))
{
_draggingDetectAbove = true;
_dragStartCanvas = p;
_dragStartDetectAbove = da; // normalized
e.Pointer.Capture(this);
return;
}
if (HitDetectedBox(p, out var id))
{
DetectId = id;
}
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
var preview = Preview;
if (preview?.Frame is null)
return;
var p = e.GetPosition(this);
var dxCanvas = p.X - _dragStartCanvas.X;
var dyCanvas = p.Y - _dragStartCanvas.Y;
int rawW, rawH, rotate;
float pixelAspect;
double scale, offsetX, offsetY;
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
var dx = dxCanvas / scale;
var dy = dyCanvas / scale;
if (rotate == 0 || rotate == 180)
dx /= pixelAspect;
else
dy /= pixelAspect;
if (_draggingGravitate)
{
var gx = _dragStartValue.X * rawW + dx;
var gy = _dragStartValue.Y * rawH + dy;
switch (rotate)
{
case 90:
(gx, gy) = (gy, rawH - gx);
break;
case 180:
gx = rawW - gx;
gy = rawH - gy;
break;
case 270:
(gx, gy) = (rawW - gy, gx);
break;
}
var nx = (float)(gx / rawW);
var ny = (float)(gy / rawH);
if (nx < 0) nx = 0;
if (ny < 0) ny = 0;
if (nx > 1) nx = 1;
if (ny > 1) ny = 1;
GravitateTo = new Point2f(nx, ny);
}
else if (_draggingDetectAbove)
{
var gx = rawW / 2.0;
var gy = _dragStartDetectAbove * rawH + dy;
switch (rotate)
{
case 90:
(gx, gy) = (gy, rawH - gx);
break;
case 180:
gx = rawW - gx;
gy = rawH - gy;
break;
case 270:
(gx, gy) = (rawW - gy, gx);
break;
}
var ny = gy / rawH;
if (ny < 0) ny = 0;
if (ny > 1) ny = 1;
DetectAbove = (float)ny;
}
else
{
return;
}
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_draggingGravitate || _draggingDetectAbove)
{
_draggingGravitate = false;
_draggingDetectAbove = false;
e.Pointer.Capture(null);
}
}
// ------------------------------------------------------------
// Overlay renderers
// ------------------------------------------------------------
private void RenderCropRectangle(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
if (preview.CropRect is not { } crop)
return;
var rr = TransformRect(
crop.X, crop.Y, crop.Width, crop.Height,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
var pen = new Pen(Brushes.Yellow, 2);
context.DrawRectangle(null, pen, rr);
}
private void RenderGravitateTo(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
var g = GravitateTo;
var px = g.X * rawW;
var py = g.Y * rawH;
var (sx, sy) = TransformPoint(
px, py,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
const double radius = 10;
var circle = new EllipseGeometry(
new Rect(sx - radius, sy - radius, radius * 2, radius * 2));
var pen = new Pen(Brushes.Yellow, 2);
var brush = Brushes.Yellow;
context.DrawGeometry(brush, pen, circle);
}
private void RenderDetectedBoxes(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
if (preview.DetectedBoxes is not { Count: > 0 })
return;
var pen = new Pen(Brushes.Lime, 2);
var selectedPen = new Pen(Brushes.Magenta, 2);
var detected = preview.DetectedBoxes.ToList();
foreach (var r in detected)
{
var rr = TransformRect(
r.Box.X, r.Box.Y, r.Box.Width, r.Box.Height,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
context.DrawRectangle(null, r.Id == DetectId ? selectedPen : pen, rr);
context.DrawText(
new FormattedText($"ID: {r.Id}", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 12, r.Id == DetectId ? Brushes.Magenta : Brushes.Lime),
new Avalonia.Point(rr.X + 5, rr.Y + 5));
}
}
private void RenderDetectAbove(
DrawingContext context,
PreviewData preview,
double rawW, double rawH,
double offsetX, double offsetY,
double scale,
int rotate,
double pixelAspect)
{
var da = DetectAbove;
var rawY = da * rawH;
var (x1, y1) = TransformPoint(
0, rawY,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
var (x2, y2) = TransformPoint(
rawW, rawY,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
var pen = new Pen(Brushes.Lime, 2);
context.DrawLine(pen, new Avalonia.Point(x1, y1), new Avalonia.Point(x2, y2));
const double radius = 10;
var (kx, ky) = TransformPoint(
rawW / 2.0, rawY,
rawW, rawH,
offsetX, offsetY,
scale,
rotate,
pixelAspect);
var knob = new EllipseGeometry(
new Rect(kx - radius, ky - radius, radius * 2, radius * 2));
var knobPen = new Pen(Brushes.Lime, 2);
var knobBrush = Brushes.Lime;
context.DrawGeometry(knobBrush, knobPen, knob);
}
// ------------------------------------------------------------
// Main Render
// ------------------------------------------------------------
public override void Render(DrawingContext context)
{
var preview = Preview;
if (preview?.Frame is null)
return;
var frame = preview.Frame;
var rawW = frame.PixelSize.Width;
var rawH = frame.PixelSize.Height;
var dispW = Bounds.Width;
var dispH = Bounds.Height;
if (dispW <= 0 || dispH <= 0)
return;
var rotate = RotateAngle;
var sar = Sar ?? new Point2f(1, 1);
var sarX = sar.X <= 0 ? 1 : sar.X;
var sarY = sar.Y <= 0 ? 1 : sar.Y;
var pixelAspect = sarX / sarY;
double displayW, displayH;
if (rotate == 0 || rotate == 180)
{
displayW = rawW * pixelAspect;
displayH = rawH;
}
else
{
displayW = rawW;
displayH = rawH * pixelAspect;
}
var scale = Math.Min(dispW / displayW, dispH / displayH);
var scaledW = displayW * scale;
var scaledH = displayH * scale;
var offsetX = (dispW - scaledW) / 2;
var offsetY = (dispH - scaledH) / 2;
context.DrawImage(
frame,
new Rect(0, 0, rawW, rawH),
new Rect(offsetX, offsetY, scaledW, scaledH));
RenderDetectedBoxes(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
RenderCropRectangle(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
RenderGravitateTo(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
RenderDetectAbove(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
}
}

View File

@ -1,80 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
xmlns:local="clr-namespace:Splitter_UI.Views"
xmlns:controls="clr-namespace:Splitter_UI.Controls"
x:Class="Splitter_UI.Views.PreviewPane"
x:DataType="vm:PreviewPaneViewModel">
<Border Background="#202020" Padding="10">
<Grid RowDefinitions="*,Auto,Auto">
<local:PreviewCanvas
Grid.Row="0"
Preview="{Binding Preview}"
Sar="{Binding Sar}"
RotateAngle="{Binding Rotate}"
GravitateTo="{Binding GravitateTo, Mode=TwoWay}"
DetectAbove="{Binding DetectAbove, Mode=TwoWay}"
DetectId="{Binding Selected.DetectId, Mode=TwoWay}"
/>
<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="&#xf04b;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
</Grid>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,*,Auto"
Margin="0,10,0,0">
<Button Grid.Column="0"
HorizontalAlignment="Left"
Width="24" Height="24"
Padding="0"
Margin="0,0,5,0"
Command="{Binding Selected.StepBackwardCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf048;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
<controls:TimelinePreviewSlider Grid.Column="1"
ViewModel="{Binding Selected}"
Margin="5,0,5,0" />
<Button Grid.Column="2"
HorizontalAlignment="Right"
Width="24" Height="24"
Padding="0"
Margin="5,0,0,0"
Command="{Binding Selected.StepForwardCommand}">
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="&#xf051;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@ -1,12 +0,0 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class PreviewPane : UserControl
{
public PreviewPane()
{
InitializeComponent();
}
}

View File

@ -1,63 +0,0 @@
<UserControl
x:Class="Splitter_UI.Views.ProgressView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:ProgressViewModel">
<Grid RowDefinitions="*,Auto" Background="#111">
<!-- Processes list -->
<ItemsControl Grid.Row="0" ItemsSource="{Binding Processes}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ProgressInfo">
<Grid ColumnDefinitions="2*,3*,Auto,Auto"
Margin="0,2">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
FontSize="12"/>
<ProgressBar Grid.Column="1"
Height="12"
Minimum="0"
Maximum="1"
Value="{Binding Progress}"
Margin="8,0"/>
<TextBlock Grid.Column="2"
Width="70"
Text="{Binding Eta, StringFormat={}{0:hh\\:mm\\:ss}}"
VerticalAlignment="Center"
Margin="12,0"
FontSize="12"/>
<TextBlock Grid.Column="3"
Width="70"
Text="{Binding Speed, StringFormat={}{0:0.00}}"
VerticalAlignment="Center"
Margin="12,0"
FontSize="12"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Bottom-right Cancel button -->
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="Cancel"
Background="#700000"
Foreground="White"
Padding="12,6"
Margin="0,0,10,10"
Command="{Binding CancelCommand}"/>
</StackPanel>
</Grid>
</UserControl>

View File

@ -1,12 +0,0 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class ProgressView : UserControl
{
public ProgressView()
{
InitializeComponent();
}
}

View File

@ -1,24 +0,0 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Splitter_UI.Views.StatusBarView"
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
x:DataType="vm:StatusBarViewModel">
<Border Padding="4" Background="{DynamicResource ThemeBackgroundBrush}">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="{Binding StatusText}" />
<ProgressBar Grid.Column="1"
Width="200"
Height="16"
Minimum="0"
Maximum="1"
VerticalAlignment="Center"
Value="{Binding Percent}" />
</Grid>
</Border>
</UserControl>

View File

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class StatusBarView : UserControl
{
public StatusBarView()
{
InitializeComponent();
}
}

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Splitter_UI.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

View File

@ -1,4 +1,6 @@
namespace splitter.algo; using OpenCvSharp;
namespace splitter;
public enum TrackState public enum TrackState
{ {
@ -58,7 +60,7 @@ public sealed class CameraController
_kalman.Reset(_cameraCenter); _kalman.Reset(_cameraCenter);
} }
private Point2f DefaultCenter => _cmd.GravitateTo; private Point2f DefaultCenter => _cmd.GravitateTo ?? new Point2f(_videoWidth / 2f, _videoHeight / 2f);
public int LostFrames => _lostFrames; public int LostFrames => _lostFrames;
public Point2f CameraCenter => _cameraCenter; public Point2f CameraCenter => _cameraCenter;
@ -68,15 +70,15 @@ public sealed class CameraController
public Point2f? ObjectCenter => _objectCenter; public Point2f? ObjectCenter => _objectCenter;
public Rect Roi => _roi; public Rect Roi => _roi;
public void Update(DetectedPerson? primary) public void Update((Rect box, Point2f center)? primary)
{ {
Rect? objectBox = null; Rect? objectBox = null;
Point2f? objectCenter = null; Point2f? objectCenter = null;
if (primary.HasValue) if (primary.HasValue)
{ {
objectCenter = primary.Value.Center; objectCenter = primary.Value.center;
objectBox = primary.Value.Box; objectBox = primary.Value.box;
} }
// --------------------------------------------------------- // ---------------------------------------------------------
@ -95,7 +97,7 @@ public sealed class CameraController
_dropoutCounter = 0; _dropoutCounter = 0;
} }
var isLost = !objectCenter.HasValue; bool isLost = !objectCenter.HasValue;
// LOST / REACQUIRE STATE MACHINE // LOST / REACQUIRE STATE MACHINE
if (isLost) if (isLost)
@ -147,7 +149,7 @@ public sealed class CameraController
{ {
smoothedCenter = _kalman.Update(objectCenter); smoothedCenter = _kalman.Update(objectCenter);
var driftEasing = 0.01f; float driftEasing = 0.01f;
var fallbackCenter = new Point2f(_videoWidth / 2f, _videoHeight / 2f); var fallbackCenter = new Point2f(_videoWidth / 2f, _videoHeight / 2f);
_cameraCenter = new Point2f( _cameraCenter = new Point2f(

View File

@ -1,14 +1,52 @@
using System.Globalization; using System.Globalization;
using splitter.util;
namespace splitter; namespace splitter;
public class SingleJob
{
public string InputFile { get; set; } = null!;
public string OutputFolder { get; set; } = null!;
public (int width, int height)? Crop { get; set; }
public Point2f? GravitateTo { get; set; }
public string? Mask { get; set; }
public bool Debug { get; set; }
public string? Detect { get; set; }
public double? OverrideTargetDuration { get; set; }
public string[] Passthrough { get; set; } = [];
public bool PlainText { get; set; }
public bool EstimateOnly { get; set; }
public bool ForceFixed { get; set; }
public bool SingleThreaded { get; set; }
public int? Rotate { get; set; }
public bool RotateAuto { get; set; }
public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name)
{
if (!Parameters.TryGetValue(name, out var raw))
return;
try
{
// Convert.ChangeType handles int, float, double, etc.
var converted = (T)Convert.ChangeType(
raw,
typeof(T),
CultureInfo.InvariantCulture
);
member = converted;
}
catch
{
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
}
}
}
public sealed class CommandLine public sealed class CommandLine
{ {
// Default vertical Full HD for YouTube Shorts
public const int DefaultW = 607;
public const int DefaultH = 1080;
public SingleJob Master { get; } = new SingleJob(); public SingleJob Master { get; } = new SingleJob();
public SingleJob[] Jobs { get; } public SingleJob[] Jobs { get; }
@ -74,10 +112,6 @@ public sealed class CommandLine
{ {
Master.Rotate = 90; Master.Rotate = 90;
} }
else if (arg == "--enhance")
{
Master.Enhance = true;
}
else if (arg.StartsWith("--rotate=")) else if (arg.StartsWith("--rotate="))
{ {
var val = arg.Substring("--rotate=".Length); var val = arg.Substring("--rotate=".Length);
@ -86,34 +120,10 @@ public sealed class CommandLine
else else
throw new FormatException($"Invalid --rotate value: {val}"); throw new FormatException($"Invalid --rotate value: {val}");
} }
else if (arg.StartsWith("--detect-id="))
{
var val = arg.Substring("--detect-id=".Length);
if (ulong.TryParse(val, out var detectId))
Master.DetectId = detectId;
else
throw new FormatException($"Invalid --detect-id value: {val}");
}
else if (arg.StartsWith("--crop=")) else if (arg.StartsWith("--crop="))
{ {
Master.Crop = ParseCrop(arg.Substring("--crop=".Length)); Master.Crop = ParseCrop(arg.Substring("--crop=".Length));
} }
else if (arg.StartsWith("--detect-above="))
{
var val = arg.Substring("--detect-above=".Length);
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var detectAbove) && detectAbove >= 0.0f && detectAbove <= 1.0f)
Master.DetectAbove = detectAbove;
else
Master.DetectAbove = 0.7f;
}
else if (arg.StartsWith("--score-threshold="))
{
var val = arg.Substring("--score-threshold=".Length);
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var scoreThreshold) && scoreThreshold >= 0.0f && scoreThreshold <= 1.0f)
Master.ScoreThreshold = scoreThreshold;
else
Master.ScoreThreshold = 0.25f;
}
else if (arg == "--crop") else if (arg == "--crop")
{ {
Master.Crop = ParseCrop(""); Master.Crop = ParseCrop("");
@ -172,11 +182,24 @@ public sealed class CommandLine
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x)); var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
Jobs = files.Select(x => Jobs = files.Select(x => new SingleJob
{ {
var job = new SingleJob { InputFile = x }; InputFile = x,
Master.CopyTo(job); OutputFolder = Master.OutputFolder,
return job; Crop = Master.Crop,
GravitateTo = Master.GravitateTo,
Mask = Master.Mask,
Debug = Master.Debug,
Detect = Master.Detect,
OverrideTargetDuration = Master.OverrideTargetDuration,
Passthrough = Master.Passthrough,
PlainText = Master.PlainText,
EstimateOnly = Master.EstimateOnly,
ForceFixed = Master.ForceFixed,
SingleThreaded = Master.SingleThreaded,
Rotate = Master.Rotate,
RotateAuto = Master.RotateAuto,
Parameters = new Dictionary<string, string>(Master.Parameters)
}).ToArray(); }).ToArray();
if ( Jobs.Length == 0) if ( Jobs.Length == 0)
@ -227,31 +250,35 @@ public sealed class CommandLine
return key.Length > 0; return key.Length > 0;
} }
private static Point2f ParseGravitate(string value) private static Point2f? ParseGravitate(string value)
{ {
// Expected format: "<x>:<y>" // Expected format: "<x>:<y>"
var parts = value.Split(':'); var parts = value.Split(':');
if (parts.Length != 2) if (parts.Length != 2)
return new Point2f(0.5f, 0.5f); return null;
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
return new Point2f(0.5f, 0.5f); return null;
if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
return new Point2f(0.5f, 0.5f); return null;
// Normalized range check (0.01.0) // Normalized range check (0.01.0)
if (x < 0f || x > 1f || y < 0f || y > 1f) if (x < 0f || x > 1f || y < 0f || y > 1f)
return new Point2f(0.5f, 0.5f); return null;
return new Point2f(x, y); return new Point2f(x, y);
} }
private static (int width, int height)? ParseCrop(string v) private static (int width, int height)? ParseCrop(string v)
{ {
// Default vertical Full HD for YouTube Shorts
const int defaultW = 607;
const int defaultH = 1080;
// Empty or whitespace → default crop // Empty or whitespace → default crop
if (string.IsNullOrWhiteSpace(v)) if (string.IsNullOrWhiteSpace(v))
return (DefaultW, DefaultH); return (defaultW, defaultH);
var s = v.Trim().ToLowerInvariant(); var s = v.Trim().ToLowerInvariant();
@ -343,9 +370,6 @@ Options:
Last segment may be shorter. Last segment may be shorter.
Default: OFF Default: OFF
--enhance Enable video enhancement.
Increases output resolution x4 Using RealBasicVSR_x4 model.
--rotate=<degrees> Rotate video by specified degrees (90, 180, 270). --rotate=<degrees> Rotate video by specified degrees (90, 180, 270).
Useful for videos with incorrect orientation metadata. Useful for videos with incorrect orientation metadata.
@ -362,14 +386,6 @@ Options:
--detect=<name> Object detector to use for tracking. --detect=<name> Object detector to use for tracking.
Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center) Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center)
--detect-above=<0-1> Face or human detectors should only report detections if their upper bound starts below this threshold.
This is a value between 0.0 and 1.0 mapped to 0..Height.
--detect-id=<hex> Object ID to track. This is a hexadecimal string that identifies a specific face or
person to track across segments. This is useful when you want to consistently track the same person
across all segments of a video, even if there are multiple people present.
The ID can be obtained when running with --debug or from the debug overlay.
--gravitate=<x:y> Gravitate towards a specific point (x, y) in the video frame when tracking. --gravitate=<x:y> Gravitate towards a specific point (x, y) in the video frame when tracking.
Coordinates are normalized (0.0 to 1.0). Coordinates are normalized (0.0 to 1.0).
Example: --gravitate=0.2:0.5 (gravitate towards left-center) Example: --gravitate=0.2:0.5 (gravitate towards left-center)

View File

@ -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);
}
}

View File

@ -1,4 +1,4 @@
namespace splitter.util; namespace splitter;
public static class FileMaskExpander public static class FileMaskExpander
{ {
@ -8,8 +8,8 @@ public static class FileMaskExpander
if (!HasMask(input)) if (!HasMask(input))
return [Path.GetFullPath(input)]; return [Path.GetFullPath(input)];
var directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory(); string directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
var pattern = Path.GetFileName(input); string pattern = Path.GetFileName(input);
if (string.IsNullOrEmpty(directory)) if (string.IsNullOrEmpty(directory))
directory = Directory.GetCurrentDirectory(); directory = Directory.GetCurrentDirectory();

View File

@ -1,4 +1,6 @@
namespace splitter.probe; using OpenCvSharp;
namespace splitter;
public sealed class FrameRotationDetector public sealed class FrameRotationDetector
{ {
@ -16,17 +18,17 @@ public sealed class FrameRotationDetector
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36) public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
{ {
_w = width; _w = width;
_h = height; _h = height;
_bins = bins; _bins = bins;
_gray = new Mat(height, width, MatType.CV_8UC1); _gray = new Mat(height, width, MatType.CV_8UC1);
_gx = new Mat(height, width, MatType.CV_32F); _gx = new Mat(height, width, MatType.CV_32F);
_gy = new Mat(height, width, MatType.CV_32F); _gy = new Mat(height, width, MatType.CV_32F);
_mag = new Mat(height, width, MatType.CV_32F); _mag = new Mat(height, width, MatType.CV_32F);
_angle = new Mat(height, width, MatType.CV_32F); _angle = new Mat(height, width, MatType.CV_32F);
_hist = new float[bins]; // allocated once _hist = new float[bins]; // allocated once
} }
public int GetRotation(Mat frame) public int GetRotation(Mat frame)
@ -42,28 +44,28 @@ public sealed class FrameRotationDetector
Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true); Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true);
// 4. Clear histogram // 4. Clear histogram
for (var i = 0; i < _bins; i++) for (int i = 0; i < _bins; i++)
_hist[i] = 0; _hist[i] = 0;
var binSize = 180f / _bins; float binSize = 180f / _bins;
unsafe unsafe
{ {
var anglePtr = (float*)_angle.Data; float* anglePtr = (float*)_angle.Data;
var magPtr = (float*)_mag.Data; float* magPtr = (float*)_mag.Data;
var total = _w * _h; int total = _w * _h;
for (var i = 0; i < total; i++) for (int i = 0; i < total; i++)
{ {
var m = magPtr[i]; float m = magPtr[i];
if (m < 5f) continue; // ignore weak gradients if (m < 5f) continue; // ignore weak gradients
var a = anglePtr[i]; float a = anglePtr[i];
if (a < 0) a += 360f; if (a < 0) a += 360f;
a = a % 180f; a = a % 180f;
var bin = (int)(a / binSize); int bin = (int)(a / binSize);
if (bin < 0) bin = 0; if (bin < 0) bin = 0;
if (bin >= _bins) bin = _bins - 1; if (bin >= _bins) bin = _bins - 1;
@ -73,12 +75,12 @@ public sealed class FrameRotationDetector
// 5. Energy around 0° vs 90° // 5. Energy around 0° vs 90°
float e0 = 0, e90 = 0; float e0 = 0, e90 = 0;
var window = 3; int window = 3;
var bin0 = 0; int bin0 = 0;
var bin90 = _bins / 2; int bin90 = _bins / 2;
for (var i = -window; i <= window; i++) for (int i = -window; i <= window; i++)
{ {
e0 += _hist[Wrap(bin0 + i)]; e0 += _hist[Wrap(bin0 + i)];
e90 += _hist[Wrap(bin90 + i)]; e90 += _hist[Wrap(bin90 + i)];

View File

@ -1,5 +0,0 @@
global using OpenCvSharp;
global using splitter.algo;
global using splitter.probe;
global using splitter.tui;

View File

@ -1,7 +0,0 @@
namespace splitter;
public interface IJobProcessor
{
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token);
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
}

View File

@ -1,8 +1,8 @@
namespace splitter.tui; namespace splitter;
public interface ILogger public interface ILogger
{ {
void ClearProgress(string name, int progressLine); void ClearProgress(int progressLevel);
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed); void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
void Log(string prefix, ConsoleColor color, string msg); void Log(string prefix, ConsoleColor color, string msg);

View File

@ -0,0 +1,8 @@
using OpenCvSharp;
namespace splitter;
public interface IObjectDetector : IDisposable
{
List<(Rect box, Point2f center)> DetectAll(Mat frameCont);
}

View File

@ -0,0 +1,6 @@
namespace splitter;
public interface ISegmentProcessor
{
Task ProcessSegment( SingleTask job );
}

View File

@ -1,237 +0,0 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace splitter;
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
{
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token)
{
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
if (!File.Exists(job.InputFile))
{
LogError($"{baseName}: Input file not found.");
return [];
}
if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder);
if (token.IsCancellationRequested)
return [];
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto, token);
if (token.IsCancellationRequested)
return [];
if (info.Duration <= 0)
{
LogError($"{baseName}: Could not read duration.");
return [];
}
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(info.Duration / target);
segmentLength = target;
}
else
{
// Equalized segments
segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = info.Duration / segments;
}
LogInfo($"{baseName}: Duration {info.Duration:F2}s, {info.Width}x{info.Height} @ {info.Fps:F3}fps {info.Bitrate / 1024:F0}kbps," +
$" Target duration: {target:F2}s Segments: {segments} segment length: {segmentLength:F2}s {(job.ForceFixed ? " fixed" : "")}");
if (estimateOnly)
return [];
Func<int, ISegmentProcessor> processorFactory;
if (job.Crop != null)
{
processorFactory = i =>
{
IObjectDetector detector = job.Detect switch
{
"face" => new UltraFaceDetector(_logger),
"body" => new YoloV10ObjectDetector(_logger),
"none" => new DummyDetector(),
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
};
var osnet = new OSNetEmbeddingExtractor();
var tracker = new ObjectTracker(detector, osnet);
return new TrackingSplitter(i, tracker, job, _logger);
};
}
else
{
processorFactory = i => new SimpleSplitter(i, _logger);
}
var segmentsToUse = predefinedSegments;
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,
Info : info,
OutputFileName : BuildOutputFileName(job, i),
SegmentIndex : i,
TotalSegments : predefinedSegments.Count,
SegmentStart : s.Start,
SegmentLength : s.End - s.Start,
ProcessorFactory: processorFactory
)
).ToList();
}
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token)
{
if (singleThreaded)
{
LogInfo("Starting single-threaded splitting...");
await RunSingleThreaded(tasks, token);
}
else
{
LogInfo("Starting multi-threaded splitting...");
await RunMultiThreaded(tasks, token);
}
LogInfo("Done.");
return true;
}
private void LogProgress(double progress, TimeSpan eta, double speed) => _logger.DrawProgress("Total", 0, progress, eta, speed);
// -----------------------------
// ffprobe
// -----------------------------
// -----------------------------
// Multi-threaded splitting
// -----------------------------
private async Task RunMultiThreaded(List<SingleTask> jobs, CancellationToken token)
{
LogProgress(0.0, TimeSpan.Zero, 0.0);
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
using var sem = new SemaphoreSlim(maxDegree);
var tasks = new List<Task>();
// Slot pool: 0..maxDegree-1
var freeSlots = new ConcurrentQueue<int>(Enumerable.Range(0, maxDegree));
var totalSegments = jobs.Count;
var processedSegments = 0;
var totalDuration = jobs.Sum(j => j.SegmentLength);
var sw = Stopwatch.StartNew();
foreach (var job in jobs)
{
await sem.WaitAsync(token);
tasks.Add(Task.Run(async () =>
{
var slot = -1;
try
{
// Acquire a slot ID
while (!freeSlots.TryDequeue(out slot))
{
if ( token.IsCancellationRequested)
return;
await Task.Yield();
}
await ProcessSegment(job, slot + 1, token);
var processed = Interlocked.Increment(ref processedSegments);
var elapsed = sw.Elapsed;
var eta = TimeSpan.FromTicks(elapsed.Ticks * (totalSegments - processed) / processed);
var speed = (processed * totalDuration) / elapsed.TotalSeconds;
LogProgress((double)processed / totalSegments, eta, speed);
}
finally
{
// Return slot to pool
if (slot >= 0)
freeSlots.Enqueue(slot);
sem.Release();
}
}));
}
await Task.WhenAll(tasks);
}
// -----------------------------
// Single-threaded splitting
// -----------------------------
private async Task RunSingleThreaded(List<SingleTask> jobs, CancellationToken token)
{
foreach (var job in jobs)
{
await ProcessSegment(job, 0, token);
}
}
private async Task ProcessSegment(SingleTask t, int slot, CancellationToken token)
{
var processor = t.ProcessorFactory(slot);
try
{
await processor.ProcessSegment(t, token);
}
finally
{
if (processor is IDisposable disposable)
disposable.Dispose();
}
}
private static string BuildOutputFileName(SingleJob job, int index)
{
string fileName;
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
.Replace("[N]", index.ToString())
.Replace("[NN]", index.ToString("00"))
.Replace("[NNN]", index.ToString("000"))
.Replace("[NNNN]", index.ToString("0000"))
.Replace("[EXT]", Path.GetExtension(job.InputFile).TrimStart('.'))
;
return Path.Combine(job.OutputFolder, fileName);
}
}

View File

@ -1,4 +1,4 @@
namespace splitter.algo; namespace splitter;
public sealed class KalmanTracker public sealed class KalmanTracker
{ {
@ -35,8 +35,8 @@ public sealed class KalmanTracker
_state[3] = 0; _state[3] = 0;
// Large initial uncertainty // Large initial uncertainty
for (var i = 0; i < 4; i++) for (int i = 0; i < 4; i++)
for (var j = 0; j < 4; j++) for (int j = 0; j < 4; j++)
_p[i, j] = (i == j) ? 1f : 0f; _p[i, j] = (i == j) ? 1f : 0f;
} }
@ -63,16 +63,16 @@ public sealed class KalmanTracker
var z = measurement.Value; var z = measurement.Value;
// Innovation y = z - Hx // Innovation y = z - Hx
var yx = z.X - _state[0]; float yx = z.X - _state[0];
var yy = z.Y - _state[1]; float yy = z.Y - _state[1];
// Innovation covariance S = P + R // Innovation covariance S = P + R
var Sx = _p[0, 0] + _r; float Sx = _p[0, 0] + _r;
var Sy = _p[1, 1] + _r; float Sy = _p[1, 1] + _r;
// Kalman gain K = P / S // Kalman gain K = P / S
var Kx0 = _p[0, 0] / Sx; float Kx0 = _p[0, 0] / Sx;
var Kx1 = _p[1, 1] / Sy; float Kx1 = _p[1, 1] / Sy;
// Update state // Update state
_state[0] += Kx0 * yx; _state[0] += Kx0 * yx;

View File

@ -1,11 +1,7 @@
namespace splitter.tui; namespace splitter;
public abstract class LoggingBase(ILogger logger, int _progressLine) public abstract class LoggingBase(ILogger _logger, int _progressLine)
{ {
#pragma warning disable IDE1006 // Naming Styles
protected ILogger _logger = logger;
#pragma warning restore IDE1006 // Naming Styles
protected void Log(string level, ConsoleColor color, string message) protected void Log(string level, ConsoleColor color, string message)
=> _logger.Log(level, color, message); => _logger.Log(level, color, message);
@ -21,6 +17,6 @@ public abstract class LoggingBase(ILogger logger, int _progressLine)
protected void DrawProgress(string name, double percent, TimeSpan eta, double fps) protected void DrawProgress(string name, double percent, TimeSpan eta, double fps)
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps); => _logger.DrawProgress(name, _progressLine, percent, eta, fps);
protected void ClearProgress(string name) protected void ClearProgress()
=> _logger.ClearProgress(name,_progressLine); => _logger.ClearProgress(_progressLine);
} }

13
splitter-cli/Point2f.cs Normal file
View File

@ -0,0 +1,13 @@
namespace splitter;
public struct Point2f
{
public float X;
public float Y;
public Point2f(float x, float y)
{
X = x;
Y = y;
}
}

108
splitter-cli/ProbeVideo.cs Normal file
View File

@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Globalization;
namespace splitter;
public record VideoInfo(
double Duration,
int Width,
int Height,
double Fps,
double Bitrate,
int Rotation = 0
);
public static class ProbeVideo
{
public static async Task<VideoInfo> Probe(SingleJob job)
{
var info = ProbeSize(job.InputFile);
if ( job.RotateAuto)
{
var rotation = await ProbeRotation(job, info.Duration);
info = info with { Rotation = rotation };
}
return info;
}
private static async Task<int> ProbeRotation(SingleJob job, double duration)
{
var rotation = await new VideoRotationSampler(job).DetectRotationAsync(job.InputFile, duration);
return rotation;
}
private static VideoInfo ProbeSize(string inputFile)
{
var args =
"-v error " +
"-select_streams v:0 " +
"-show_entries format=duration " +
"-show_entries stream=width,height,avg_frame_rate,bit_rate " +
"-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys
$"\"{inputFile}\"";
var psi = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var p = new Process { StartInfo = psi };
p.Start();
var duration = -1.0;
var width = 0;
var height = 0;
var fps = 0.0;
var bitrate = 0.0;
while (!p.StandardOutput.EndOfStream)
{
var line = p.StandardOutput.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(line))
continue;
if (line.StartsWith("duration="))
{
var v = line.Substring("duration=".Length);
double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration);
}
else if (line.StartsWith("width="))
{
var v = line.Substring("width=".Length);
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
}
else if (line.StartsWith("bit_rate="))
{
var v = line.Substring("bit_rate=".Length);
double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out bitrate);
}
else if (line.StartsWith("height="))
{
var v = line.Substring("height=".Length);
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
}
else if (line.StartsWith("avg_frame_rate="))
{
var v = line.Substring("avg_frame_rate=".Length);
var parts = v.Split('/');
if (parts.Length == 2 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) &&
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) &&
den != 0)
{
fps = num / den;
}
}
}
p.WaitForExit();
return new(duration, width, height, fps, bitrate);
}
}

View File

@ -1,230 +0,0 @@
# Splitter
Splitter is a high-performance command line tool for cutting one or more video files into equal or fixed-length segments using multi-threaded FFmpeg execution.
It supports batch input, flexible duration formats, rotation, smart face/body-aware cropping, ETA and speed reporting, and both rich and plain-text terminal output.
![Splitter](splitter.png)
## Features
- Multi-threaded FFmpeg splitting for maximum throughput
- Equal or fixed-length segmentation
- Batch input via file masks or list files
- Smart cropping with face/body tracking
- Rotation correction
- ETA, speed, and progress display
- FFmpeg passthrough for advanced control
- [Potentially] Cross-platform (.NET 10)
## Requirements
- FFmpeg and FFprobe available in system PATH
- .NET 10 Runtime or newer
If you want to update model:
- For face detection: [opencv_zoo/models/face_detection_yunet at main · opencv/opencv_zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
- For body detection: [yolov8s.pt · Ultralytics/YOLOv8 at main](https://huggingface.co/Ultralytics/YOLOv8/blob/main/yolov8s.pt)
To convert models from PyTorch to ONNX, you can use the following command:
```python
from ultralytics import YOLO
model = YOLO("yolov8x.pt")
model.export(format="onnx", opset=12, half=False) # FP32 ONNX
```
## How It Works
1. Reads total duration using ffprobe
2. Parses target duration
3. Computes number of segments
4. If not forced, equalizes segment lengths
5. Runs multiple FFmpeg processes in parallel
6. Applies rotation, crop, and tracking if enabled
7. Displays progress, ETA, and speed
## Face Tracking vs Body Tracking
Face tracking and body tracking serve different purposes, and Splitter supports both because each
excels in different recording environments. When converting horizontal footage into vertical clips,
the choice of detector determines how stable, reliable, and natural the automated camera motion will be.
![Face vs Body Tracking](tracking.png)
### Face Tracking Using UltraFace 320
Splitter uses the UltraFace 320 ONNX model to perform lightweight, real-time face detection on each
frame of the input video. The detector produces bounding boxes for visible faces, and the tracking
system maintains a stable, smoothed target region across time. This is achieved by combining per-frame
detections with temporal smoothing (EMA), dropout tolerance, and camera easing. The result is a
continuous, stable crop window that follows the performer even when the face is partially occluded,
briefly lost, or moving rapidly.
During segmentation, the crop window is recalculated for every frame, ensuring that each output
segment inherits the same smooth camera motion. This makes the vertical clips appear as if they
were recorded with a dedicated portrait-oriented camera operator. The UltraFace 320 model is
fast enough to run alongside multi-threaded FFmpeg splitting without becoming a bottleneck,
making it suitable for long recordings and batch processing.
### Benefits of Full-Body Detection Using YOLOv8s for Live Gig Recordings
When recording concerts or live gigs, performers often move unpredictably, turn away from the
camera, or become partially obscured by lighting, instruments, or stage effects.
Full-body detection using a YOLOv8s ONNX model provides a more reliable tracking anchor than
face detection alone. Because YOLOv8s can detect the entire human silhouette, the tracker
maintains stable framing even when the face is not visible, when the performer is far from
the camera, or when stage lighting makes facial features hard to detect. This produces vertical
clips that feel intentional and professionally framed, with fewer sudden jumps or lost-tracking
moments. For creators converting horizontal gig footage into short vertical clips for YouTube
Shorts or TikTok, body-based tracking significantly improves consistency, reduces manual editing,
and preserves the energy and motion of the performance.
### Automated Camera Control
Splitter includes an automated camera control system that simulates the behavior of a virtual
camera operator when generating vertical crops from horizontal footage. The goal is to maintain
smooth, intentional framing around the tracked subject, even when detections are noisy, intermittent,
or temporarily lost.
The controller receives object detections (face or body) and converts them into a stable crop
window using a combination of Kalman filtering, exponential smoothing, dropout tolerance,
and a three-state tracking model. The Kalman filter provides predictive motion smoothing,
while the EMA factor blends the predicted position with the previous camera center to avoid jitter.
The camera easing value controls how quickly the virtual camera follows the subject, producing
natural-looking motion rather than abrupt jumps.
When detections disappear, the controller enters one of two fallback modes. In LostFreeze mode,
the camera holds its last known position for a configurable number of frames, preventing sudden
jumps during brief occlusions. If the subject remains lost beyond that threshold, the controller
transitions to LostDrift mode, slowly drifting the camera back toward a neutral center position.
This prevents the crop from drifting off-screen and ensures that the output remains usable even
when tracking fails. All positions are clamped to valid bounds, guaranteeing that the crop window
never leaves the video frame.
### Automatic rotation detection
The rotation-estimation method is based on analyzing the distribution of gradient orientations within
a video frame. After converting the frame to grayscale, the algorithm computes horizontal and vertical
image gradients using Sobel operators and derives per-pixel gradient magnitudes and orientations.
These orientations are folded into the range [0, 180) and accumulated into a fixed-size,
magnitude-weighted histogram. The histogram represents the structural edge distribution of the frame,
independent of brightness fluctuations or local lighting artifacts. By comparing the total gradient
energy concentrated near 0 degrees (vertical edges) with the energy near 90 degrees (horizontal edges),
the method determines whether the frame is more consistent with an upright or sideways orientation.
This approach is designed for environments where brightness-based cues are unreliable, such as
live concerts with strobe lights, LED walls, haze, and crowd movement. It relies solely on geometric
edge structure, which remains stable even under extreme lighting variation. The implementation is
optimized for high-throughput video processing: all intermediate Mats, buffers, and histograms are
preallocated, and pixel data is accessed directly through pointers to avoid per-frame memory
allocation. The method is intentionally biased toward the upright orientation, returning a sideways
classification only when the horizontal-edge energy significantly exceeds the vertical-edge energy.
## Usage
```
splitter [<input.mp4> ...] [options] [--] <ffmpeg passthrough>
```
Inputs may be provided directly, via `--file=...`, or using file masks such as `videos/*.mp4`.
## Options
Below is a clean, ASCII-only **options table** version of your content.
All option names are preserved exactly, and descriptions are consolidated for clarity.
---
## Options
| Parameter | Description |
|----------|-------------|
| --out=&lt;folder&gt; | Output folder for segments. Default: same folder as input video + "Splitter". |
| --file=&lt;path&gt; | Input names or file masks (e.g. "videos/*.mp4"). If not specified, the first non-option argument is used as input. |
| --mask=&lt;pattern&gt; | Output filename pattern. Default: [NAME]_seg[NN].[EXT]. Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders. |
| --duration=&lt;value&gt; | Override target segment duration. Formats: Ns, NmMs, N. Examples: 90s, 2m30s, 45. Default (without --force): max 58s, equalized segment lengths. |
| --force | Use fixed segment duration exactly as given. Last segment may be shorter. Default OFF. |
| --enhance | Enable video enhancement. Output resolution x4 using RealBasicVSR_x4 model. |
| --rotate=&lt;degrees&gt; | Rotate video by 90, 180, or 270 degrees. |
| --rotate-auto | Auto-detect rotation using edge orientation statistics. |
| --estimate | Print calculated segment information and exit. No splitting performed. |
| --crop[=&lt;w:h&gt;] | Crop video to width w and height h with face tracking. Default: 607x1080. |
| --detect=&lt;name&gt; | Object detector: face (UltraFace), body (YoloOnnx, default), none. |
| --detect-above=&lt;0-1&gt; | Report detections only if upper bound starts below this threshold (0.01.0 mapped to 0..Height). |
| --detect-id=&lt;hex&gt; | Hexadecimal ID of face/person to track across segments. Obtained via --debug overlay. |
| --gravitate=&lt;x:y&gt; | Gravitate tracking toward normalized point (0.01.0). Example: 0.2:0.5. |
| --text | Display log in plain text. |
| --single-thread | Run in single-threaded mode. Useful for debugging or constrained systems. |
| --debug | Show debug overlay during face tracking. |
| -p:&lt;name&gt;=&lt;value&gt; | Set custom detector parameter. Example: -p:EmaFactor=0.65. |
Tracking splitter defaults:
DropoutToleranceFrames = 20;
EmaFactor = 0.65;
CameraEasing = 0.03;
LostFreezeFrames = 60;
Rotation detector defaults:
RotationDetectorSampleCount = 5;
RotationDetectorSampleLength = 0.15;
RotationDetectorFrameWidth = 320;
RotationDetectorFrameHeight = 180;
## FFmpeg Passthrough
Anything after `--` is passed directly to FFmpeg.
Example:
```
splitter video.mp4 --force --duration=45 -- -an -sn
```
## Input and Output Behavior
- `input.mp4` may be a file mask (`videos/*.mp4`)
- Output filenames follow the `--mask` pattern
- Output folder defaults to `<input folder>/Splitter` unless overridden
## Examples
Split into equal 60-second segments:
```
splitter vertical-video.mp4
```
Split into equal 90-second segments:
```
splitter vertical-video.mp4 --duration=90s
```
Custom naming:
```
splitter vertical-video.mp4 --duration=2m30s --mask="[NAME]_[NNNN].mp4"
```
Estimate only:
```
splitter vertical-video.mp4 --estimate
```
Fixed 45-second segments with passthrough:
```
splitter vertical-video.mp4 --force --duration=45 -- -an -sn
```
Smart crop for Shorts:
```
splitter horizontal-video.mp4 --out=Cropped/ --crop
```
Batch processing with body tracking:
```
splitter --file=file_names.txt --out=Cropped/ --crop --detect=body
```

View File

@ -3,269 +3,69 @@ using System.Globalization;
namespace splitter; namespace splitter;
public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
{ {
// ------------------------------------------------------------ public async Task ProcessSegment(SingleTask job)
// Internal state (opaque to caller)
// ------------------------------------------------------------
private sealed class State : IFrameProcessingState
{ {
public Process? DecodeProcess { get; set; } string inputFile = job.Job.InputFile;
public Stream? DecodeStdout { get; set; } string outputFile = job.OutputFileName;
double start = job.SegmentStart;
double length = job.SegmentLength;
int videoWidth = job.Info.Width;
int videoHeight = job.Info.Height;
double fps = job.Info.Fps;
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
public string InputFile { get; } var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
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)
{
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 outputFile = job.OutputFileName;
var start = job.SegmentStart;
var length = job.SegmentLength;
var rotation = GetRotationFilter(job.Job.Rotate);
string args; string args;
var rotation = GetRotationFilter(job.Job.Rotate);
if (rotation == null) if (rotation == null)
{ {
args = args =
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
$"-i \"{inputFile}\" " + $"-i \"{inputFile}\" " +
$"-t {length.ToString(CultureInfo.InvariantCulture)} " + $"-t {length.ToString(CultureInfo.InvariantCulture)} " +
$"-c copy {string.Join(" ", job.Job.Passthrough)} " + $"-c copy {pass} \"{outputFile}\" -y";
$"\"{outputFile}\" -y";
} }
else else
{ {
var sarArg = ""; // Rotation → must re-encode
var darArg = "";
var sar = job.Info.SampleAspectRatio;
if (sar != null)
{
var sarNum = Convert.ToInt64(job.Info.Sar.X);
var sarDen = Convert.ToInt64(job.Info.Sar.Y);
var w = job.Info.Width;
var h = job.Info.Height;
if (job.Job.Rotate == 90 || job.Job.Rotate == 270)
(w, h) = (h, w);
var darNum = w * sarNum;
var darDen = h * sarDen;
long Gcd(long a, long b)
{
while (b != 0) (a, b) = (b, a % b);
return a;
}
var g = Gcd(darNum, darDen);
darNum /= g;
darDen /= g;
sarArg = $"-vf \"{rotation},setsar={sarNum}:{sarDen}\" ";
darArg = $"-aspect {darNum}:{darDen} ";
}
else
sarArg = $"-vf \"{rotation}\" ";
args = args =
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
$"-i \"{inputFile}\" " + $"-i \"{inputFile}\" " +
$"-t {length.ToString(CultureInfo.InvariantCulture)} " + $"-t {length.ToString(CultureInfo.InvariantCulture)} " +
sarArg + darArg + $"-vf \"{rotation}\" " +
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
"-c:a copy " + "-c:a copy " +
$"{string.Join(" ", job.Job.Passthrough)} " + $"{pass} \"{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);
ShowFFMpegProgress(length, proc, name);
proc.WaitForExit();
ClearProgress();
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 +73,66 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
270 => "transpose=2", 270 => "transpose=2",
_ => null _ => null
}; };
private void ShowFFMpegProgress(double length, Process proc, string name)
{
var sw = Stopwatch.StartNew();
string? line;
while ((line = proc.StandardError.ReadLine()) != 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
int 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);
}
} }

View File

@ -1,169 +0,0 @@
using System.Globalization;
namespace splitter;
public record Segment(double Start, double End);
public class SingleJob
{
/// <summary>
/// File path of the input video. This is required for each job and should be
/// set to a valid video file path. The splitter will read this file, analyze it,
/// and split it into segments based on the specified parameters.
/// The output segments will be saved in the OutputFolder with names
/// derived from this input file and the Mask pattern if provided.
/// </summary>
public string InputFile { get; set; } = null!;
/// <summary>
/// Output folder where the split segments will be saved. This should be set
/// to a valid directory path.
/// </summary>
public string OutputFolder { get; set; } = null!;
/// <summary>
/// Crop parameters. Width and height for cropping the video. If set, the
/// splitter will crop the video to the specified dimensions while tracking the subject.
/// </summary>
public (int width, int height)? Crop { get; set; }
/// <summary>
/// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0).
/// By default , the splitter gravitates towards the center of the frame (0.5, 0.5).
/// Setting this allows you to bias the tracking towards a specific area of the frame,
/// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for
/// videos where the subject tends to be off-center or for creative framing choices.
/// </summary>
public Point2f GravitateTo { get; set; } = new Point2f(0.5f, 0.5f);
/// <summary>
/// Destination file mask.
/// </summary>
public string? Mask { get; set; }
/// <summary>
/// Instead of producing the output, just generate debug frames with tracking
/// overlay to visually verify that the tracking is working correctly.
/// </summary>
public bool Debug { get; set; }
/// <summary>
/// Type of detector to use for tracking. Supported values are: face (UltraFace),
/// body (YoloOnnx, default), none (no tracking, just a center point).
/// </summary>
public string? Detect { get; set; }
/// <summary>
/// Detection confidence threshold. This is a value between 0.0 and 1.0 that sets the minimum confidence
/// </summary>
public float ScoreThreshold { get; set; } = 0.25f;
/// <summary>
/// Identity matching confidence threshold. This is a value between 0.0 and 1.0 that sets the minimum confidence
/// </summary>
public float IdentityThreshold { get; set; } = 0.25f;
/// <summary>
/// Face or human detectors should only report detections if their upper bound starts below this threshold.
/// This is a value between 0.0 and 1.0 mapped to 0..Height.
/// </summary>
public float DetectAbove { get; set; } = 0.7f;
/// <summary>
/// Object ID to track. This is a hexadecimal string that identifies a specific face or
/// person to track across segments. This is useful when you want to consistently track the same person
/// publacross all segments of a video, even if there are multiple people present
/// The ID can be obtained when running with --debug or from the debug overlay.
/// </summary>
public ulong? DetectId { get; set; }
/// <summary>
/// Set starget segments length explicitly. By default, the splitter calculates segment
/// lengths to be equal and not exceed 58 seconds.
/// </summary>
public double? OverrideTargetDuration { get; set; }
/// <summary>
/// Parameters to pass thru to ffmpeg. These are specified after "--" in the command
/// line and are passed directly to the ffmpeg command line for each segment.
/// </summary>
public string[] Passthrough { get; set; } = [];
/// <summary>
/// Debugging parameter. Instead of text UI putput lines in plain text.
/// This is useful when the output is being piped to a file or another program,
/// or when the user prefers a simpler log format without progress bars and dynamic updates.
/// </summary>
public bool PlainText { get; set; }
/// <summary>
/// Debugging parameter. Just show estimated segments length, count, and other info
/// without actually performing the splitting.
/// </summary>
public bool EstimateOnly { get; set; }
/// <summary>
/// Do not adapt segment length. When set, the splitter will use the exact
/// segment duration specified by --duration for all segments except possibly
/// the last one, which may be shorter.
/// </summary>
public bool ForceFixed { get; set; }
/// <summary>
/// Use single thread for operations. When set, the splitter will not run
/// multiple ffmpeg processes in parallel.
/// </summary>
public bool SingleThreaded { get; set; }
/// <summary>
/// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that
/// have incorrect orientation metadata.
/// </summary>
public int? Rotate { get; set; }
/// <summary>
/// Autodetect if rotation is needed. Not very reliable but can work for some videos.
/// Uses edge orientation statistics to determine if the video is rotated and
/// applies the appropriate rotation if needed.
/// </summary>
public bool RotateAuto { get; set; }
/// <summary>
/// Override internal parameters. This allows you to set custom parameters for the
/// object detector or rotation detector.
/// </summary>
public Dictionary<string, string> Parameters { get; set; } = [];
/// <summary>
/// Increase output resolution by x4 using super-resolution RealBasicVSR_x4 model.
/// </summary>
public bool Enhance { get; set; }
public void Override<T>(ref T member, string name)
{
if (!Parameters.TryGetValue(name, out var raw))
return;
try
{
// Convert.ChangeType handles int, float, double, etc.
var converted = (T)Convert.ChangeType(
raw,
typeof(T),
CultureInfo.InvariantCulture
);
member = converted;
}
catch
{
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
}
}
public void CopyTo(SingleJob target)
{
target.OutputFolder = OutputFolder;
target.Crop = Crop;
target.GravitateTo = GravitateTo;
target.Mask = Mask;
target.Debug = Debug;
target.Detect = Detect;
target.ScoreThreshold = ScoreThreshold;
target.IdentityThreshold = IdentityThreshold;
target.DetectAbove = DetectAbove;
target.DetectId = DetectId;
target.OverrideTargetDuration = OverrideTargetDuration;
target.Passthrough = Passthrough.ToArray();
target.PlainText = PlainText;
target.EstimateOnly = EstimateOnly;
target.ForceFixed = ForceFixed;
target.SingleThreaded = SingleThreaded;
target.Rotate = Rotate;
target.RotateAuto = RotateAuto;
target.Parameters = new Dictionary<string, string>(Parameters);
target.Enhance = Enhance;
}
public void CopyFrom(SingleJob source) => source.CopyTo(this);
}

View File

@ -2,7 +2,7 @@
using Spectre.Console; using Spectre.Console;
using Spectre.Console.Rendering; using Spectre.Console.Rendering;
namespace splitter.tui; namespace splitter;
/// <summary> /// <summary>
@ -39,7 +39,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
lock (_sync) lock (_sync)
{ {
_numberOfProcesses = Math.Max(1, value); _numberOfProcesses = Math.Max(1, value);
for (var i = 0; i < _numberOfProcesses; i++) for (int i = 0; i < _numberOfProcesses; i++)
{ {
if (!_progress.ContainsKey(i)) if (!_progress.ContainsKey(i))
_progress[i] = ProgressEntry.Empty; _progress[i] = ProgressEntry.Empty;
@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
// ---- ILogger ---- // ---- ILogger ----
public void ClearProgress(string name, int progressLine) public void ClearProgress(int progressLevel)
{ {
lock (_sync) lock (_sync)
{ {
_progress[progressLine] = ProgressEntry.Empty; _progress[progressLevel] = ProgressEntry.Empty;
} }
} }
@ -282,17 +282,17 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
if (width <= 0) if (width <= 0)
return string.Empty; return string.Empty;
var filled = (int)Math.Round(progress * width); int filled = (int)Math.Round(progress * width);
var empty = width - filled; int empty = width - filled;
if (filled <= 0) if (filled <= 0)
return $"[grey]{new string('─', width)}[/]"; return $"[grey]{new string('─', width)}[/]";
// Split filled part into three segments: blue / yellow / green // Split filled part into three segments: blue / yellow / green
// low progress: mostly blue; mid: yellow; high: green // low progress: mostly blue; mid: yellow; high: green
var blueCount = (int)Math.Round(filled * 0.33); int blueCount = (int)Math.Round(filled * 0.33);
var yellowCount = (int)Math.Round(filled * 0.34); int yellowCount = (int)Math.Round(filled * 0.34);
var greenCount = filled - blueCount - yellowCount; int greenCount = filled - blueCount - yellowCount;
var sb = new StringBuilder(); var sb = new StringBuilder();
@ -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);

View File

@ -1,4 +1,4 @@
namespace splitter.tui; namespace splitter;
public class TextLogger() : ILogger public class TextLogger() : ILogger
{ {
@ -13,6 +13,6 @@ public class TextLogger() : ILogger
} }
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {} public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
public void ClearProgress(string name, int progressLine) {} public void ClearProgress(int progressLevel){}
} }

View File

@ -1,196 +1,138 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using OpenCvSharp;
namespace splitter; namespace splitter;
public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
{ {
private readonly IObjectTracker _tracker; private readonly IObjectDetector _detector;
// ------------------------------------------------------------
// 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, IObjectDetector detector,
SingleJob cmd, SingleJob cmd,
ILogger logger) ILogger logger)
: base(logger, progressLine) : base(logger, progressLine)
{ {
_tracker = tracker; _detector = detector;
} }
// ============================================================ public void Dispose()
// PUBLIC PREVIEW API
// ============================================================
// ------------------------------------------------------------
// InitSegment
// ------------------------------------------------------------
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
{ {
var state = (FrameProcessingState)CreateFrameState(job); if (_detector is IDisposable d)
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(); d.Dispose();
} }
// ============================================================ public async Task ProcessSegment(SingleTask job)
// PROCESSSEGMENT (full pipeline)
// ============================================================
public async Task ProcessSegment(SingleTask job, CancellationToken token)
{ {
var name = Path.GetFileNameWithoutExtension(job.OutputFileName); string inputFile = job.Job.InputFile;
var fps = job.Info.Fps; string outputFile = job.OutputFileName;
double start = job.SegmentStart;
double length = job.SegmentLength;
int videoWidth = job.Info.Width;
int videoHeight = job.Info.Height;
double fps = job.Info.Fps;
double bitrate = job.Info.Bitrate;
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
var state = (FrameProcessingState)InitSegment(job, token); var name = Path.GetFileNameWithoutExtension(outputFile);
var encode = await StartFfmpegEncode( // 1) Probe source video
job.Job.InputFile, if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0)
job.OutputFileName, {
job.SegmentStart, LogError($"{name}: ffprobe failed to get metadata");
job.SegmentLength, return;
state.OutMat.Width, }
state.OutMat.Height,
job.Info, if (job.Job.Crop == null)
job.Job.Passthrough, {
job.Job.PlainText, LogError($"{name}: Crop parameters are required");
token); return;
}
var encWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width;
var encHeight = job.Job.Debug ? videoHeight : job.Job.Crop.Value.height;
LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] enc={encWidth}x{encHeight}");
// 2) Start FFmpeg decode (video only → raw BGR24 to stdout)
var decode = StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText);
using var decodeStdout = decode.StandardOutput.BaseStream;
// 3) Start FFmpeg encode (video from stdin + audio from original)
var encode = StartFfmpegEncode(
inputFile,
outputFile,
start,
length,
encWidth,
encHeight,
fps,
ffmpegPassthroughParameters,
job.Job.PlainText);
using var encodeStdin = encode.StandardInput.BaseStream; using var encodeStdin = encode.StandardInput.BaseStream;
var totalFrames = (int)Math.Round(job.SegmentLength * fps); // Separate input/output sizes and buffers
var frameIndex = 0; var inBytes = videoWidth * videoHeight * 3;
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);
using var outMat = new Mat(encHeight, encWidth, 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);
var startTime = DateTime.UtcNow; var startTime = DateTime.UtcNow;
var totalFrames = (int)Math.Round(length * fps);
var frameIndex = 0;
while (frameIndex < totalFrames) while (frameIndex < totalFrames)
{ {
token.ThrowIfCancellationRequested();
var frame = GetNextProcessedFrame(state, token);
if (frame == null)
break;
frameIndex++; frameIndex++;
EncodeFrame(frame, state, encodeStdin); var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
if (read != inBytes)
break;
// input frame → Mat
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
var objects = _detector.DetectAll(frameMat);
var primary = SelectTrackedObject(objects, kalman.LastMeasurement);
camera.Update(primary);
var roi = camera.Roi;
if (job.Job.Debug)
{
DrawDebug(frameMat, objects, camera, kalman);
frameMat.CopyTo(outMat);
}
else
{
using var cropped = new Mat(frameMat, roi);
cropped.CopyTo(outMat);
}
// output Mat → outBuffer
Marshal.Copy(outMat.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);
@ -199,144 +141,51 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
} }
encodeStdin.Flush(); encodeStdin.Flush();
encodeStdin.Close();
// loop finished
encodeStdin.Flush();
encodeStdin.Close(); // must happen before waiting encode
await encode.WaitForExitAsync(); await encode.WaitForExitAsync();
ClearProgress(name); // belt-and-braces: if decode is still alive, kill it
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
ClearProgress();
if (encode.ExitCode != 0) if (encode.ExitCode != 0)
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 Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
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);
var rotateStr = GetRorationArg(rotate); var rotateStr = "";
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
var args = var args =
$"-i \"{inputFile}\" -ss {ss} -t {t} " + $"-i \"{inputFile}\" -ss {ss} -t {t} " +
"-an -sn " + "-an -sn " +
$"-vf format=bgr24{rotateStr} " + $"-vf format=bgr24{rotateStr} " +
"-f rawvideo -"; "-f rawvideo -";
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
@ -353,12 +202,12 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
var fileName = Path.GetFileName(inputFile); var fileName = Path.GetFileName(inputFile);
_ = Task.Run(async () => _ = Task.Run(() =>
{ {
try try
{ {
string? line; string? line;
while ((line = await p.StandardError.ReadLineAsync(token)) != null) while ((line = p.StandardError.ReadLine()) != null)
if (plainText) if (plainText)
LogInfo($"[ffmpeg-decode] {fileName}: {line}"); LogInfo($"[ffmpeg-decode] {fileName}: {line}");
} }
@ -368,66 +217,34 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
return p; return p;
} }
public static string GetRorationArg(int? rotate) private Process StartFfmpegEncode(
{
var rotateStr = "";
if (rotate != null)
{
switch (rotate.Value)
{
case 90: rotateStr = ",transpose=1"; break;
case 180: rotateStr = ",transpose=PI"; break;
case 270: rotateStr = ",transpose=2"; break;
}
}
return rotateStr;
}
private async Task<Process> StartFfmpegEncode(
string inputFile, string inputFile,
string outputFile, string outputFile,
double start, double start,
double length, double length,
int width, int width,
int height, int height,
VideoInfo info, double fps,
string[] passthrough, string[] passthrough,
bool plainText, bool plainText)
CancellationToken token)
{ {
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture); var fpsStr = 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)
? $"-vf setsar={info.SampleAspectRatio} "
: "";
var darArg = "";
if (info.Sar is { } s)
{
var darNum = width * s.X;
var darDen = height * s.Y;
var dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum));
var dd = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darDen));
ReduceFraction(ref dn, ref dd);
if (dn > 0 && dd > 0)
darArg = $"-aspect {dn}:{dd} ";
}
var args = var args =
"-y " + "-y " +
$"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " + $"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " +
$"-ss {ss} -i \"{inputFile}\" " + $"-ss {ss} -i \"{inputFile}\" " +
"-map 0:v:0 -map 1:a:0? -shortest " + "-map 0:v:0 -map 1:a:0? -shortest " +
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
sarArg + darArg +
"-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",
@ -443,14 +260,16 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
var fileName = Path.GetFileName(outputFile); var fileName = Path.GetFileName(outputFile);
_ = Task.Run(async () => _ = Task.Run(() =>
{ {
try try
{ {
string? line; string? line;
while ((line = await p.StandardError.ReadLineAsync(token)) != null) while ((line = p.StandardError.ReadLine()) != null)
{
if (plainText) if (plainText)
LogInfo($"[ffmpeg-encode] {fileName}: {line}"); LogInfo($"[ffmpeg-encode] {fileName}: {line}");
}
} }
catch { } catch { }
}); });
@ -458,38 +277,14 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
return p; return p;
} }
private static void ReduceFraction(ref int num, ref int den) // ---------- helpers ----------
{
int Gcd(int a, int b)
{
while (b != 0)
{
var t = b;
b = a % b;
a = t;
}
return a;
}
var g = Gcd(Math.Abs(num), Math.Abs(den)); private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
if (g > 1)
{
num /= g;
den /= g;
}
}
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)
{ {
var read = await s.ReadAsync(buffer, offset + total, count - total, token); var read = s.Read(buffer, offset + total, count - total);
if (read <= 0) if (read <= 0)
break; break;
total += read; total += read;
@ -497,5 +292,83 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
return total; return total;
} }
private void DrawDebug(
Mat frame,
System.Collections.Generic.List<(Rect box, Point2f center)> 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);
}
private (Rect box, Point2f center)? SelectTrackedObject(
List<(Rect box, Point2f center)> foundObjects,
Point2f? previousCenter)
{
if (foundObjects == null || foundObjects.Count == 0)
return null;
if (!previousCenter.HasValue)
{
var bestIndex = 0;
var bestArea = float.MinValue;
for (int i = 0; i < foundObjects.Count; i++)
{
var f = foundObjects[i];
var area = f.box.Width * f.box.Height;
if (area > bestArea)
{
bestArea = area;
bestIndex = i;
}
}
return foundObjects[bestIndex];
}
else
{
var prev = previousCenter.Value;
var bestIndex = 0;
var bestDist2 = float.MaxValue;
for (int i = 0; i < foundObjects.Count; i++)
{
var f = foundObjects[i];
var dx = f.center.X - prev.X;
var dy = f.center.Y - prev.Y;
var d2 = dx * dx + dy * dy;
if (d2 < bestDist2)
{
bestDist2 = d2;
bestIndex = i;
}
}
return foundObjects[bestIndex];
}
}
} }

View File

@ -1,7 +1,8 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using OpenCvSharp;
using UltraFaceDotNet; using UltraFaceDotNet;
namespace splitter.algo; namespace splitter;
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
{ {
@ -23,14 +24,14 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
_ultraFace = UltraFace.Create(param); _ultraFace = UltraFace.Create(param);
} }
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont) public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
{ {
// Convert to byte[] for UltraFace // Convert to byte[] for UltraFace
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize(); var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
var bgr = new byte[bytesFull]; var bgr = new byte[bytesFull];
Marshal.Copy(frameCont.Data, bgr, 0, bytesFull); Marshal.Copy(frameCont.Data, bgr, 0, bytesFull);
var results = new List<DetectedPerson>(); var results = new List<(Rect box, Point2f center)>();
if (bgr == null || bgr.Length == 0) if (bgr == null || bgr.Length == 0)
return results; return results;
@ -51,10 +52,10 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
foreach (var f in faces) foreach (var f in faces)
{ {
var x1 = (int)f.X1; int x1 = (int)f.X1;
var y1 = (int)f.Y1; int y1 = (int)f.Y1;
var x2 = (int)f.X2; int x2 = (int)f.X2;
var y2 = (int)f.Y2; int y2 = (int)f.Y2;
var rect = new Rect( var rect = new Rect(
x1, x1,
@ -69,7 +70,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
rect.X + rect.Width / 2f, rect.X + rect.Width / 2f,
rect.Y + rect.Height / 2f); rect.Y + rect.Height / 2f);
results.Add(new DetectedPerson{ Box = rect, Center = center }); results.Add((rect, center));
} }
} }
} }

View File

@ -1,12 +1,13 @@
using System.Diagnostics; using OpenCvSharp;
using System.Diagnostics;
namespace splitter.probe; namespace splitter;
public sealed class VideoRotationSampler public sealed class VideoRotationSampler
{ {
private readonly FrameRotationDetector _detector = new FrameRotationDetector(); private readonly FrameRotationDetector _detector = new FrameRotationDetector();
public static int RotationDetectorSampleCount = 10; public static int RotationDetectorSampleCount = 20;
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
public static int RotationDetectorFrameWidth = 320; public static int RotationDetectorFrameWidth = 320;
public static int RotationDetectorFrameHeight = 180; public static int RotationDetectorFrameHeight = 180;
@ -15,22 +16,19 @@ public sealed class VideoRotationSampler
private readonly byte[] _buffer; private readonly byte[] _buffer;
private readonly Mat _frameMat; private readonly Mat _frameMat;
public VideoRotationSampler(IDictionary<string, string>? overrides) public VideoRotationSampler(SingleJob _master)
{ {
if (overrides != null) if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s))
{ RotationDetectorSampleCount = int.Parse(s);
if (overrides.TryGetValue("RotationDetectorSampleCount", out var s)) if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s))
RotationDetectorSampleCount = int.Parse(s); RotationDetectorSampleLength = double.Parse(s);
if (overrides.TryGetValue("RotationDetectorSampleLength", out s)) if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s))
RotationDetectorSampleLength = double.Parse(s); RotationDetectorFrameWidth = int.Parse(s);
if (overrides.TryGetValue("RotationDetectorFrameWidth", out s)) if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s))
RotationDetectorFrameWidth = int.Parse(s); RotationDetectorFrameHeight = int.Parse(s);
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
RotationDetectorFrameHeight = int.Parse(s);
}
var w = RotationDetectorFrameWidth; int w = RotationDetectorFrameWidth;
var h = RotationDetectorFrameHeight; int h = RotationDetectorFrameHeight;
_buffer = new byte[w * h * 3]; // raw BGR24 buffer _buffer = new byte[w * h * 3]; // raw BGR24 buffer
_frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer _frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer
@ -38,29 +36,27 @@ public sealed class VideoRotationSampler
public async Task<int> DetectRotationAsync( public async Task<int> DetectRotationAsync(
string inputFile, string inputFile,
double videoLengthSeconds, double videoLengthSeconds)
CancellationToken token)
{ {
if (videoLengthSeconds <= 0) if (videoLengthSeconds <= 0)
return 0; return 0;
var rotations = new List<int>(); var rotations = new List<int>();
for (var i = 0; i < RotationDetectorSampleCount; i++) for (int i = 0; i < RotationDetectorSampleCount; i++)
{ {
var t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1); double t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
var frame = await DecodeSingleFrameAsync( var frame = await DecodeSingleFrameAsync(
inputFile, inputFile,
t, t,
RotationDetectorSampleLength, RotationDetectorSampleLength,
RotationDetectorFrameWidth, RotationDetectorFrameWidth,
RotationDetectorFrameHeight, RotationDetectorFrameHeight);
token);
if (frame != null && !frame.Empty()) if (frame != null && !frame.Empty())
{ {
var rot = _detector.GetRotation(frame); int rot = _detector.GetRotation(frame);
rotations.Add(rot); rotations.Add(rot);
} }
} }
@ -80,8 +76,8 @@ public sealed class VideoRotationSampler
counts[v]++; counts[v]++;
} }
var best = 0; int best = 0;
var bestCount = 0; int bestCount = 0;
foreach (var kv in counts) foreach (var kv in counts)
{ {
@ -100,21 +96,18 @@ public sealed class VideoRotationSampler
double start, double start,
double length, double length,
int width, int width,
int height, int height)
CancellationToken token)
{ {
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false); var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
var needed = _buffer.Length; int needed = _buffer.Length;
var read = 0; int read = 0;
using var stdout = p.StandardOutput.BaseStream; using var stdout = p.StandardOutput.BaseStream;
while (read < needed) while (read < needed)
{ {
token.ThrowIfCancellationRequested(); int r = await stdout.ReadAsync(_buffer, read, needed - read);
var r = await stdout.ReadAsync(_buffer, read, needed - read, token);
if (r == 0) if (r == 0)
return null; return null;
read += r; read += r;

View File

@ -1,10 +1,11 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors; using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;
namespace splitter.algo; namespace splitter;
public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDisposable public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
{ {
private readonly InferenceSession _session; private readonly InferenceSession _session;
private readonly string _inputName; private readonly string _inputName;
@ -32,7 +33,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
private readonly List<Detection> _nmsBuffer = new(256); private readonly List<Detection> _nmsBuffer = new(256);
// Reusable result list // Reusable result list
private readonly List<DetectedPerson> _results = new(64); private readonly List<(Rect box, Point2f center)> _results = new(64);
private readonly float _inv255 = 1f / 255f; private readonly float _inv255 = 1f / 255f;
@ -54,7 +55,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
} }
} }
public YoloV8ObjectDetector(ILogger logger) : base(logger, -1) public YoloOnnxObjectDetector(ILogger logger) : base(logger, -1)
{ {
var options = new SessionOptions(); var options = new SessionOptions();
options.AppendExecutionProvider_DML(); options.AppendExecutionProvider_DML();
@ -78,7 +79,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor)); _inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
} }
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont) public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
{ {
if (frameCont.Empty()) if (frameCont.Empty())
{ {
@ -125,24 +126,28 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
// Build reusable result list // Build reusable result list
_results.Clear(); _results.Clear();
for (var i = 0; i < final.Count; i++) for (int i = 0; i < final.Count; i++)
{ {
var d = final[i]; var d = final[i];
var x = (int)d.X; int x = (int)d.X;
var y = (int)d.Y; int y = (int)d.Y;
var w = (int)d.Width; int w = (int)d.Width;
var h = (int)d.Height; int h = (int)d.Height;
x = Math.Clamp(x, 0, frameCont.Width - 1); x = Math.Clamp(x, 0, frameCont.Width - 1);
y = Math.Clamp(y, 0, frameCont.Height - 1); y = Math.Clamp(y, 0, frameCont.Height - 1);
w = Math.Clamp(w, 1, frameCont.Width - x); w = Math.Clamp(w, 1, frameCont.Width - x);
h = Math.Clamp(h, 1, frameCont.Height - y); h = Math.Clamp(h, 1, frameCont.Height - y);
// Ignore detections starting in the lower 1/2 of the frame
if (y > frameCont.Height * 0.5f)
continue;
var rect = new Rect(x, y, w, h); var rect = new Rect(x, y, w, h);
var center = new Point2f(x + w / 2f, y + h / 2f); var center = new Point2f(x + w / 2f, y + h / 2f);
_results.Add(new DetectedPerson{ Box = rect, Center = center }); _results.Add((rect, center));
} }
return _results; return _results;
@ -151,30 +156,30 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void FillInputTensor(Mat rgb) private void FillInputTensor(Mat rgb)
{ {
var height = _inputHeight; int height = _inputHeight;
var width = _inputWidth; int width = _inputWidth;
// NCHW: [1, 3, H, W] // NCHW: [1, 3, H, W]
var planeSize = height * width; int planeSize = height * width;
Span<float> dst = _inputBuffer.AsSpan(); Span<float> dst = _inputBuffer.AsSpan();
unsafe unsafe
{ {
for (var y = 0; y < height; y++) for (int y = 0; y < height; y++)
{ {
var rowPtr = (byte*)rgb.Ptr(y).ToPointer(); byte* rowPtr = (byte*)rgb.Ptr(y).ToPointer();
var rowSpan = new Span<byte>(rowPtr, width * 3); var rowSpan = new Span<byte>(rowPtr, width * 3);
var srcIndex = 0; int srcIndex = 0;
for (var x = 0; x < width; x++) for (int x = 0; x < width; x++)
{ {
var r = rowSpan[srcIndex + 0]; byte r = rowSpan[srcIndex + 0];
var g = rowSpan[srcIndex + 1]; byte g = rowSpan[srcIndex + 1];
var b = rowSpan[srcIndex + 2]; byte b = rowSpan[srcIndex + 2];
var offset = y * width + x; int offset = y * width + x;
// channel 0: R // channel 0: R
dst[offset] = r * _inv255; dst[offset] = r * _inv255;
@ -201,27 +206,27 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
detections.Clear(); detections.Clear();
// YOLOv8 output: [1, 84, 8400] // YOLOv8 output: [1, 84, 8400]
var channels = output.Dimensions[1]; // 84 int channels = output.Dimensions[1]; // 84
var count = output.Dimensions[2]; // 8400 int count = output.Dimensions[2]; // 8400
var xScale = (float)originalWidth / 640f; float xScale = (float)originalWidth / 640f;
var yScale = (float)originalHeight / 640f; float yScale = (float)originalHeight / 640f;
for (var i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var x = output[0, 0, i]; float x = output[0, 0, i];
var y = output[0, 1, i]; float y = output[0, 1, i];
var w = output[0, 2, i]; float w = output[0, 2, i];
var h = output[0, 3, i]; float h = output[0, 3, i];
var classScore = output[0, 4 + classIndex, i]; float classScore = output[0, 4 + classIndex, i];
if (classScore < scoreThreshold) if (classScore < scoreThreshold)
continue; continue;
var left = (x - w / 2f) * xScale; float left = (x - w / 2f) * xScale;
var top = (y - h / 2f) * yScale; float top = (y - h / 2f) * yScale;
var width = w * xScale; float width = w * xScale;
var height = h * yScale; float height = h * yScale;
detections.Add(new Detection detections.Add(new Detection
( (
@ -248,12 +253,12 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
// Sort in-place by score descending // Sort in-place by score descending
detections.Sort(static (a, b) => b.Score.CompareTo(a.Score)); detections.Sort(static (a, b) => b.Score.CompareTo(a.Score));
for (var i = 0; i < detections.Count; i++) for (int i = 0; i < detections.Count; i++)
{ {
var candidate = detections[i]; var candidate = detections[i];
var keep = true; bool keep = true;
for (var j = 0; j < nmsBuffer.Count; j++) for (int j = 0; j < nmsBuffer.Count; j++)
{ {
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold) if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
{ {
@ -272,23 +277,23 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float IoU(in Detection a, in Detection b) private static float IoU(in Detection a, in Detection b)
{ {
var x1 = MathF.Max(a.X, b.X); float x1 = MathF.Max(a.X, b.X);
var y1 = MathF.Max(a.Y, b.Y); float y1 = MathF.Max(a.Y, b.Y);
var x2 = MathF.Min(a.X + a.Width, b.X + b.Width); float x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
var y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height); float y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
var interW = x2 - x1; float interW = x2 - x1;
if (interW <= 0f) return 0f; if (interW <= 0f) return 0f;
var interH = y2 - y1; float interH = y2 - y1;
if (interH <= 0f) return 0f; if (interH <= 0f) return 0f;
var interArea = interW * interH; float interArea = interW * interH;
var areaA = a.Width * a.Height; float areaA = a.Width * a.Height;
var areaB = b.Width * b.Height; float areaB = b.Width * b.Height;
var union = areaA + areaB - interArea; float union = areaA + areaB - interArea;
if (union <= 0f) return 0f; if (union <= 0f) return 0f;
return interArea / union; return interArea / union;

View File

@ -1,8 +0,0 @@
namespace splitter.algo;
public struct DetectedPerson
{
public ulong Id;
public Rect Box;
public Point2f Center;
}

View File

@ -1,21 +0,0 @@
namespace splitter.algo;
public sealed class DummyDetector : IObjectDetector
{
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
{
var h = job.Info.Height;
var w = job.Info.Width;
var c = job.Job.GravitateTo;
var x = (int)(c.X * w);
var y = (int)(c.Y * h);
var center = new Point2f(x, y);
var rect = new Rect(x - 1, y - 1, 2, 2);
return [new DetectedPerson { Box = rect, Center = center }];
}
public void Dispose() {}
}

View File

@ -1,6 +0,0 @@
namespace splitter.algo;
public interface IEmbeddingExtractor : IDisposable
{
float[] Extract(Mat frame, Rect box);
}

View File

@ -1,6 +0,0 @@
namespace splitter.algo;
public interface IObjectDetector : IDisposable
{
List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont);
}

View File

@ -1,6 +0,0 @@
namespace splitter.algo;
public interface IObjectTracker
{
(List<DetectedPerson> objects, DetectedPerson? primary) SelectTrackedObject(SingleTask job, Mat frameMat, Point2f? lastMeasurement);
}

View File

@ -1,14 +0,0 @@
namespace splitter.algo;
public interface IFrameProcessingState
{
}
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);
}

Some files were not shown because too many files have changed in this diff Show More