mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36b9343269 | |||
| 9646527948 | |||
| 5d9530382d | |||
| fec13d0d07 | |||
| 2058ae0f7e | |||
| f412db219f | |||
| ddafb40ca7 | |||
| 9760fbc2e6 | |||
| e5a9a04265 | |||
| f2493c1709 | |||
| 6ebeccd761 | |||
| 78c9713425 | |||
| d3c82ce924 | |||
| 8c611e31d7 | |||
| 4bc4b02007 | |||
| de0d0c77fc | |||
| 9496d46411 | |||
| fd75af7f99 | |||
| 0359d61ae0 | |||
| 05d203c446 | |||
| 093c7c7803 | |||
| 23bfdc8452 | |||
| af363ebb9a | |||
| 9cdf611ec8 | |||
| 2dc7b050c8 | |||
| c6ca4fcbb6 | |||
| 61c94d4661 | |||
| 417d511bc8 | |||
| a408d43b61 | |||
| 4f83fc1dd2 | |||
| 18928a23f9 | |||
| 42408bba38 | |||
| e566bb6137 | |||
| e18d043b78 | |||
| ad418e18a9 | |||
| 3f1924a429 | |||
| 1f93eba839 | |||
| 93de483bc6 |
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@ -22,19 +22,19 @@ jobs:
|
|||||||
dotnet-version: 10.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore -r win-x64
|
run: dotnet restore Splitter-UI/Splitter-UI.csproj -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-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 }}
|
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 }}
|
||||||
|
|
||||||
- name: Create ZIP
|
- name: Create ZIP
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
|
$publish = "Splitter-UI/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,5 +51,3 @@ 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 }}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
19
AGENTS.md
Normal file
19
AGENTS.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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
222
README.md
@ -1,212 +1,50 @@
|
|||||||
# Splitter
|
# 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.
|
This application was built to help me with maintaining my YouTube channel - [UnclShura](https://www.youtube.com/@UnclShura).
|
||||||
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 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, 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
|
||||||
|
|
||||||
- Multi‑threaded FFmpeg splitting for maximum throughput
|
- Human face or body detection with smart cropping
|
||||||
|
- Multi-threaded FFmpeg splitting for maximum throughput
|
||||||
- Equal or fixed‑length segmentation
|
- Equal or fixed‑length 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] Cross-platform (.NET 10)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Command line interface
|
||||||
|

|
||||||
|
### Graphical user interface
|
||||||
|

|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
If you want to update model:
|
## More info
|
||||||
|
|
||||||
- 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)
|
[Command line tool](splitter-cli/README.md)
|
||||||
- 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 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
|
|
||||||
|
|
||||||
| 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 non‑option 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 plain‑text logging instead of the rich terminal UI. |
|
|
||||||
| **--single-thread** | Disable parallel FFmpeg execution. Useful for debugging or low‑resource 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 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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
[GUI tool](Splitter-UI/README.md)
|
||||||
|
|||||||
23
Splitter-UI/App.axaml
Normal file
23
Splitter-UI/App.axaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
39
Splitter-UI/App.axaml.cs
Normal file
39
Splitter-UI/App.axaml.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Regular-400.otf
Normal file
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Regular-400.otf
Normal file
Binary file not shown.
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf
Normal file
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf
Normal file
Binary file not shown.
BIN
Splitter-UI/Assets/splitter.ico
Normal file
BIN
Splitter-UI/Assets/splitter.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
Splitter-UI/Assets/splitter.png
Normal file
BIN
Splitter-UI/Assets/splitter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
100
Splitter-UI/Assets/splitter.svg
Normal file
100
Splitter-UI/Assets/splitter.svg
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
298
Splitter-UI/Controls/PreviewSlider.cs
Normal file
298
Splitter-UI/Controls/PreviewSlider.cs
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
957
Splitter-UI/Controls/TimelinePreviewSlider.cs
Normal file
957
Splitter-UI/Controls/TimelinePreviewSlider.cs
Normal file
@ -0,0 +1,957 @@
|
|||||||
|
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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Splitter-UI/Converters/ActionToIconConverter.cs
Normal file
23
Splitter-UI/Converters/ActionToIconConverter.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
15
Splitter-UI/Converters/BoolInvertConverter.cs
Normal file
15
Splitter-UI/Converters/BoolInvertConverter.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
39
Splitter-UI/Converters/ConsoleColorToBrushConverter.cs
Normal file
39
Splitter-UI/Converters/ConsoleColorToBrushConverter.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
21
Splitter-UI/Converters/RotationAngleToIconConverter.cs
Normal file
21
Splitter-UI/Converters/RotationAngleToIconConverter.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
13
Splitter-UI/Converters/ZeroToBoolConverter.cs
Normal file
13
Splitter-UI/Converters/ZeroToBoolConverter.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
16
Splitter-UI/GlobalUsing.cs
Normal file
16
Splitter-UI/GlobalUsing.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
15
Splitter-UI/Models/ParameterEntry.cs
Normal file
15
Splitter-UI/Models/ParameterEntry.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Splitter-UI/Models/PreviewData.cs
Normal file
22
Splitter-UI/Models/PreviewData.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
88
Splitter-UI/Program.cs
Normal file
88
Splitter-UI/Program.cs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Splitter-UI/README.md
Normal file
59
Splitter-UI/README.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
75
Splitter-UI/Services/AutoDecisionService.cs
Normal file
75
Splitter-UI/Services/AutoDecisionService.cs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Splitter-UI/Services/AvaloniaBitmapExtensions.cs
Normal file
42
Splitter-UI/Services/AvaloniaBitmapExtensions.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Splitter-UI/Services/BufferPool.cs
Normal file
59
Splitter-UI/Services/BufferPool.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Splitter-UI/Services/FileJobFactory.cs
Normal file
17
Splitter-UI/Services/FileJobFactory.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Splitter-UI/Services/FileProbeService.cs
Normal file
10
Splitter-UI/Services/FileProbeService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Splitter-UI/Services/GlobalLogger.cs
Normal file
24
Splitter-UI/Services/GlobalLogger.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IAutoDecisionService.cs
Normal file
6
Splitter-UI/Services/IAutoDecisionService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IAutoDecisionService
|
||||||
|
{
|
||||||
|
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IBufferPool.cs
Normal file
6
Splitter-UI/Services/IBufferPool.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IBufferPool
|
||||||
|
{
|
||||||
|
BufferPool.Entry Get(int w, int h);
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IFileJobFactory.cs
Normal file
6
Splitter-UI/Services/IFileJobFactory.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IFileJobFactory
|
||||||
|
{
|
||||||
|
JobViewModel Create(SingleJob job);
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IFileProbeService.cs
Normal file
6
Splitter-UI/Services/IFileProbeService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IFileProbeService
|
||||||
|
{
|
||||||
|
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
|
||||||
|
}
|
||||||
7
Splitter-UI/Services/ILogService.cs
Normal file
7
Splitter-UI/Services/ILogService.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface ILogService
|
||||||
|
{
|
||||||
|
void Log(string prefix, ConsoleColor color, string msg);
|
||||||
|
}
|
||||||
9
Splitter-UI/Services/IMatToBitmapConverter.cs
Normal file
9
Splitter-UI/Services/IMatToBitmapConverter.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
8
Splitter-UI/Services/IThumbnailService.cs
Normal file
8
Splitter-UI/Services/IThumbnailService.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
136
Splitter-UI/Services/MatToBitmapConverter.cs
Normal file
136
Splitter-UI/Services/MatToBitmapConverter.cs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Splitter-UI/Services/SingleThreadedDetector.cs
Normal file
42
Splitter-UI/Services/SingleThreadedDetector.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
133
Splitter-UI/Services/ThumbnailService.cs
Normal file
133
Splitter-UI/Services/ThumbnailService.cs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Splitter-UI/Splitter-UI.csproj
Normal file
37
Splitter-UI/Splitter-UI.csproj
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<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>
|
||||||
34
Splitter-UI/ViewLocator.cs
Normal file
34
Splitter-UI/ViewLocator.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Splitter-UI/ViewModels/FileListViewModel.cs
Normal file
59
Splitter-UI/ViewModels/FileListViewModel.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Splitter-UI/ViewModels/InspectorPaneViewModel.cs
Normal file
61
Splitter-UI/ViewModels/InspectorPaneViewModel.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
412
Splitter-UI/ViewModels/JobViewModel.cs
Normal file
412
Splitter-UI/ViewModels/JobViewModel.cs
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
36
Splitter-UI/ViewModels/LogPaneViewModel.cs
Normal file
36
Splitter-UI/ViewModels/LogPaneViewModel.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Splitter-UI/ViewModels/MainViewModel.cs
Normal file
94
Splitter-UI/ViewModels/MainViewModel.cs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
77
Splitter-UI/ViewModels/PreviewPaneViewModel.cs
Normal file
77
Splitter-UI/ViewModels/PreviewPaneViewModel.cs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
72
Splitter-UI/ViewModels/ProgressViewModel.cs
Normal file
72
Splitter-UI/ViewModels/ProgressViewModel.cs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
Splitter-UI/ViewModels/StatusBarViewModel.cs
Normal file
13
Splitter-UI/ViewModels/StatusBarViewModel.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public partial class StatusBarViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusText = "Ready";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _percent;
|
||||||
|
|
||||||
|
}
|
||||||
7
Splitter-UI/ViewModels/ViewModelBase.cs
Normal file
7
Splitter-UI/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
34
Splitter-UI/ViewModels/ViewModelForwarder.cs
Normal file
34
Splitter-UI/ViewModels/ViewModelForwarder.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
Splitter-UI/Views/FileListView.axaml
Normal file
80
Splitter-UI/Views/FileListView.axaml
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<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>
|
||||||
78
Splitter-UI/Views/FileListView.axaml.cs
Normal file
78
Splitter-UI/Views/FileListView.axaml.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
Splitter-UI/Views/InspectorPane.axaml
Normal file
228
Splitter-UI/Views/InspectorPane.axaml
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<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=""
|
||||||
|
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=""
|
||||||
|
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>
|
||||||
11
Splitter-UI/Views/InspectorPane.axaml.cs
Normal file
11
Splitter-UI/Views/InspectorPane.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class InspectorPane : UserControl
|
||||||
|
{
|
||||||
|
public InspectorPane()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Splitter-UI/Views/JobListItemView.axaml
Normal file
50
Splitter-UI/Views/JobListItemView.axaml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<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>
|
||||||
12
Splitter-UI/Views/JobListItemView.axaml.cs
Normal file
12
Splitter-UI/Views/JobListItemView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class JobListItemView : UserControl
|
||||||
|
{
|
||||||
|
public JobListItemView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
34
Splitter-UI/Views/LogPane.axaml
Normal file
34
Splitter-UI/Views/LogPane.axaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<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>
|
||||||
31
Splitter-UI/Views/LogPane.axaml.cs
Normal file
31
Splitter-UI/Views/LogPane.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Splitter-UI/Views/MainWindow.axaml
Normal file
57
Splitter-UI/Views/MainWindow.axaml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
10
Splitter-UI/Views/MainWindow.axaml.cs
Normal file
10
Splitter-UI/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Avalonia.Controls.Window
|
||||||
|
{
|
||||||
|
public MainViewModel Data { get; } = null!; // set by DI
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
670
Splitter-UI/Views/PreviewCanvas.cs
Normal file
670
Splitter-UI/Views/PreviewCanvas.cs
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
Splitter-UI/Views/PreviewPane.axaml
Normal file
80
Splitter-UI/Views/PreviewPane.axaml
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<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=""
|
||||||
|
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=""
|
||||||
|
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=""
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
|
||||||
12
Splitter-UI/Views/PreviewPane.axaml.cs
Normal file
12
Splitter-UI/Views/PreviewPane.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class PreviewPane : UserControl
|
||||||
|
{
|
||||||
|
public PreviewPane()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
63
Splitter-UI/Views/ProgressView.axaml
Normal file
63
Splitter-UI/Views/ProgressView.axaml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<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>
|
||||||
12
Splitter-UI/Views/ProgressView.axaml.cs
Normal file
12
Splitter-UI/Views/ProgressView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class ProgressView : UserControl
|
||||||
|
{
|
||||||
|
public ProgressView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
Splitter-UI/Views/StatusBarView.axaml
Normal file
24
Splitter-UI/Views/StatusBarView.axaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
11
Splitter-UI/Views/StatusBarView.axaml.cs
Normal file
11
Splitter-UI/Views/StatusBarView.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class StatusBarView : UserControl
|
||||||
|
{
|
||||||
|
public StatusBarView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Splitter-UI/app.manifest
Normal file
18
Splitter-UI/app.manifest
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?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>
|
||||||
BIN
Splitter-UI/screenshot.png
Normal file
BIN
Splitter-UI/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
@ -1,52 +1,14 @@
|
|||||||
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; }
|
||||||
|
|
||||||
@ -112,6 +74,10 @@ 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);
|
||||||
@ -120,10 +86,34 @@ 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("");
|
||||||
@ -182,24 +172,11 @@ public sealed class CommandLine
|
|||||||
|
|
||||||
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
|
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
|
||||||
|
|
||||||
Jobs = files.Select(x => new SingleJob
|
Jobs = files.Select(x =>
|
||||||
{
|
{
|
||||||
InputFile = x,
|
var job = new SingleJob { InputFile = x };
|
||||||
OutputFolder = Master.OutputFolder,
|
Master.CopyTo(job);
|
||||||
Crop = Master.Crop,
|
return job;
|
||||||
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)
|
||||||
@ -250,35 +227,31 @@ 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 null;
|
return new Point2f(0.5f, 0.5f);
|
||||||
|
|
||||||
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 null;
|
return new Point2f(0.5f, 0.5f);
|
||||||
|
|
||||||
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 null;
|
return new Point2f(0.5f, 0.5f);
|
||||||
|
|
||||||
// Normalized range check (0.0–1.0)
|
// Normalized range check (0.0–1.0)
|
||||||
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
||||||
return null;
|
return new Point2f(0.5f, 0.5f);
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@ -370,6 +343,9 @@ 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.
|
||||||
|
|
||||||
@ -386,6 +362,14 @@ 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)
|
||||||
|
|||||||
35
splitter-cli/DebugOverlay.cs
Normal file
35
splitter-cli/DebugOverlay.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
splitter-cli/GlobalUsing.cs
Normal file
5
splitter-cli/GlobalUsing.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
global using OpenCvSharp;
|
||||||
|
global using splitter.algo;
|
||||||
|
global using splitter.probe;
|
||||||
|
global using splitter.tui;
|
||||||
|
|
||||||
7
splitter-cli/IJobProcessor.cs
Normal file
7
splitter-cli/IJobProcessor.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -1,8 +0,0 @@
|
|||||||
using OpenCvSharp;
|
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public interface IObjectDetector : IDisposable
|
|
||||||
{
|
|
||||||
List<(Rect box, Point2f center)> DetectAll(Mat frameCont);
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
namespace splitter;
|
|
||||||
|
|
||||||
public interface ISegmentProcessor
|
|
||||||
{
|
|
||||||
Task ProcessSegment( SingleTask job );
|
|
||||||
}
|
|
||||||
237
splitter-cli/JobProcessor.cs
Normal file
237
splitter-cli/JobProcessor.cs
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,13 +0,0 @@
|
|||||||
namespace splitter;
|
|
||||||
|
|
||||||
public struct Point2f
|
|
||||||
{
|
|
||||||
public float X;
|
|
||||||
public float Y;
|
|
||||||
|
|
||||||
public Point2f(float x, float y)
|
|
||||||
{
|
|
||||||
X = x;
|
|
||||||
Y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
230
splitter-cli/README.md
Normal file
230
splitter-cli/README.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 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=<folder> | Output folder for segments. Default: same folder as input video + "Splitter". |
|
||||||
|
| --file=<path> | Input names or file masks (e.g. "videos/*.mp4"). If not specified, the first non-option argument is used as input. |
|
||||||
|
| --mask=<pattern> | Output filename pattern. Default: [NAME]_seg[NN].[EXT]. Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders. |
|
||||||
|
| --duration=<value> | 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=<degrees> | 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[=<w:h>] | Crop video to width w and height h with face tracking. Default: 607x1080. |
|
||||||
|
| --detect=<name> | Object detector: face (UltraFace), body (YoloOnnx, default), none. |
|
||||||
|
| --detect-above=<0-1> | Report detections only if upper bound starts below this threshold (0.0–1.0 mapped to 0..Height). |
|
||||||
|
| --detect-id=<hex> | Hexadecimal ID of face/person to track across segments. Obtained via --debug overlay. |
|
||||||
|
| --gravitate=<x:y> | Gravitate tracking toward normalized point (0.0–1.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:<name>=<value> | 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
|
||||||
|
```
|
||||||
|
|
||||||
@ -3,69 +3,269 @@ using System.Globalization;
|
|||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
||||||
{
|
{
|
||||||
public async Task ProcessSegment(SingleTask job)
|
// ------------------------------------------------------------
|
||||||
{
|
// Internal state (opaque to caller)
|
||||||
string inputFile = job.Job.InputFile;
|
// ------------------------------------------------------------
|
||||||
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;
|
|
||||||
|
|
||||||
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
|
private sealed class State : IFrameProcessingState
|
||||||
|
{
|
||||||
|
public Process? DecodeProcess { get; set; }
|
||||||
|
public Stream? DecodeStdout { get; set; }
|
||||||
|
|
||||||
|
public string InputFile { get; }
|
||||||
|
public double Start { get; }
|
||||||
|
public double Length { get; }
|
||||||
|
public int? Rotate { get; }
|
||||||
|
public string[] Passthrough { get; }
|
||||||
|
public VideoInfo Info { get; }
|
||||||
|
public bool PlainText { get; }
|
||||||
|
|
||||||
|
public State(SingleTask job)
|
||||||
|
{
|
||||||
|
InputFile = job.Job.InputFile;
|
||||||
|
Start = job.SegmentStart;
|
||||||
|
Length = job.SegmentLength;
|
||||||
|
Rotate = job.Job.Rotate;
|
||||||
|
Passthrough = job.Job.Passthrough;
|
||||||
|
Info = job.Info;
|
||||||
|
PlainText = job.Job.PlainText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimpleSplitter(int segmentNo, ILogger logger)
|
||||||
|
: base(logger, segmentNo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// InitSegment
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
||||||
|
{
|
||||||
|
var state = new State(job);
|
||||||
|
|
||||||
|
var decode = StartDecode(job, token);
|
||||||
|
state.DecodeProcess = decode;
|
||||||
|
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GetNextProcessedFrame
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public Mat? GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token)
|
||||||
|
{
|
||||||
|
var state = (State)processorState;
|
||||||
|
|
||||||
|
if (state.DecodeStdout == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// SimpleSplitter does not modify frames; it only copies or rotates.
|
||||||
|
// For preview, we decode raw frames and return them as-is.
|
||||||
|
|
||||||
|
// Determine expected frame size
|
||||||
|
var w = state.Info.Width;
|
||||||
|
var h = state.Info.Height;
|
||||||
|
var bytes = w * h * 3;
|
||||||
|
|
||||||
|
var buffer = new byte[bytes];
|
||||||
|
var read = state.DecodeStdout.Read(buffer, 0, bytes);
|
||||||
|
if (read != bytes)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var mat = new Mat(h, w, MatType.CV_8UC3);
|
||||||
|
System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.Data, bytes);
|
||||||
|
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FinishSegment
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public void FinishSegment(IFrameProcessingState processorState)
|
||||||
|
{
|
||||||
|
var state = (State)processorState;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
||||||
|
state.DecodeProcess.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
||||||
|
state.DecodeProcess.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ProcessSegment (now uses preview API)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
|
{
|
||||||
|
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 {pass} \"{outputFile}\" -y";
|
$"-c copy {string.Join(" ", job.Job.Passthrough)} " +
|
||||||
|
$"\"{outputFile}\" -y";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Rotation → must re-encode
|
var sarArg = "";
|
||||||
|
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)} " +
|
||||||
$"-vf \"{rotation}\" " +
|
sarArg + darArg +
|
||||||
"-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 " +
|
||||||
$"{pass} \"{outputFile}\" -y";
|
$"{string.Join(" ", job.Job.Passthrough)} " +
|
||||||
|
$"\"{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
|
||||||
};
|
};
|
||||||
|
|
||||||
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode.");
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string? GetRotationFilter(int? degrees) =>
|
private string? GetRotationFilter(int? degrees) =>
|
||||||
degrees switch
|
degrees switch
|
||||||
{
|
{
|
||||||
90 => "transpose=1",
|
90 => "transpose=1",
|
||||||
@ -73,66 +273,4 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
169
splitter-cli/SingleJob.cs
Normal file
169
splitter-cli/SingleJob.cs
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -1,138 +1,196 @@
|
|||||||
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 class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||||
{
|
{
|
||||||
private readonly IObjectDetector _detector;
|
private readonly IObjectTracker _tracker;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Internal state (never exposed)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private sealed class FrameProcessingState : IFrameProcessingState
|
||||||
|
{
|
||||||
|
public SingleTask Job { get; }
|
||||||
|
public KalmanTracker Kalman { get; }
|
||||||
|
public CameraController Camera { get; }
|
||||||
|
|
||||||
|
public Mat FrameMat { get; }
|
||||||
|
public Mat OutMat { get; }
|
||||||
|
public byte[] InBuffer { get; }
|
||||||
|
public byte[] OutBuffer { get; }
|
||||||
|
|
||||||
|
public IVideoEnhancer? Enhancer { get; }
|
||||||
|
|
||||||
|
public int InBytes { get; }
|
||||||
|
public int OutBytes { get; }
|
||||||
|
|
||||||
|
public Process? DecodeProcess { get; set; }
|
||||||
|
public Stream? DecodeStdout { get; set; }
|
||||||
|
|
||||||
|
public FrameProcessingState(
|
||||||
|
SingleTask job,
|
||||||
|
KalmanTracker kalman,
|
||||||
|
CameraController camera,
|
||||||
|
Mat frameMat,
|
||||||
|
Mat outMat,
|
||||||
|
byte[] inBuffer,
|
||||||
|
byte[] outBuffer,
|
||||||
|
IVideoEnhancer? enhancer,
|
||||||
|
int inBytes,
|
||||||
|
int outBytes)
|
||||||
|
{
|
||||||
|
Job = job;
|
||||||
|
Kalman = kalman;
|
||||||
|
Camera = camera;
|
||||||
|
FrameMat = frameMat;
|
||||||
|
OutMat = outMat;
|
||||||
|
InBuffer = inBuffer;
|
||||||
|
OutBuffer = outBuffer;
|
||||||
|
Enhancer = enhancer;
|
||||||
|
InBytes = inBytes;
|
||||||
|
OutBytes = outBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public TrackingSplitter(
|
public TrackingSplitter(
|
||||||
int progressLine,
|
int progressLine,
|
||||||
IObjectDetector detector,
|
IObjectTracker tracker,
|
||||||
SingleJob cmd,
|
SingleJob cmd,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
: base(logger, progressLine)
|
: base(logger, progressLine)
|
||||||
{
|
{
|
||||||
_detector = detector;
|
_tracker = tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
// ============================================================
|
||||||
|
// PUBLIC PREVIEW API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// InitSegment
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (_detector is IDisposable d)
|
var state = (FrameProcessingState)CreateFrameState(job);
|
||||||
|
|
||||||
|
if (state.Enhancer != null)
|
||||||
|
state.Enhancer.InitializeAsync(
|
||||||
|
state.OutMat.Width,
|
||||||
|
state.OutMat.Height,
|
||||||
|
5,
|
||||||
|
token).Wait(token);
|
||||||
|
|
||||||
|
var decode = StartFfmpegDecode(
|
||||||
|
job.Job.InputFile,
|
||||||
|
job.SegmentStart,
|
||||||
|
job.SegmentLength,
|
||||||
|
job.Job.Rotate,
|
||||||
|
job.Job.PlainText,
|
||||||
|
token).Result;
|
||||||
|
|
||||||
|
state.DecodeProcess = decode;
|
||||||
|
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// GetNextProcessedFrame
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
public Mat? GetNextProcessedFrame(
|
||||||
|
IFrameProcessingState processorState,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var state = (FrameProcessingState)processorState;
|
||||||
|
|
||||||
|
if (state.DecodeStdout == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!TryReadNextFrame(state.DecodeStdout, state, token))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return ProcessFrame(state.FrameMat, state, state.Job, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// FinishSegment
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
public void FinishSegment(IFrameProcessingState processorState)
|
||||||
|
{
|
||||||
|
var state = (FrameProcessingState)processorState;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
||||||
|
state.DecodeProcess.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
||||||
|
state.DecodeProcess.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
if (state.Enhancer is IAsyncDisposable ad)
|
||||||
|
ad.DisposeAsync().AsTask().Wait();
|
||||||
|
else if (state.Enhancer is IDisposable d)
|
||||||
d.Dispose();
|
d.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job)
|
// ============================================================
|
||||||
|
// PROCESSSEGMENT (full pipeline)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
{
|
||||||
string inputFile = job.Job.InputFile;
|
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
||||||
string outputFile = job.OutputFileName;
|
var fps = job.Info.Fps;
|
||||||
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 name = Path.GetFileNameWithoutExtension(outputFile);
|
var state = (FrameProcessingState)InitSegment(job, token);
|
||||||
|
|
||||||
// 1) Probe source video
|
var encode = await StartFfmpegEncode(
|
||||||
if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0)
|
job.Job.InputFile,
|
||||||
{
|
job.OutputFileName,
|
||||||
LogError($"{name}: ffprobe failed to get metadata");
|
job.SegmentStart,
|
||||||
return;
|
job.SegmentLength,
|
||||||
}
|
state.OutMat.Width,
|
||||||
|
state.OutMat.Height,
|
||||||
if (job.Job.Crop == null)
|
job.Info,
|
||||||
{
|
job.Job.Passthrough,
|
||||||
LogError($"{name}: Crop parameters are required");
|
job.Job.PlainText,
|
||||||
return;
|
token);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
// Separate input/output sizes and buffers
|
var totalFrames = (int)Math.Round(job.SegmentLength * fps);
|
||||||
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 totalFrames = (int)Math.Round(length * fps);
|
|
||||||
var frameIndex = 0;
|
var frameIndex = 0;
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
|
||||||
while (frameIndex < totalFrames)
|
while (frameIndex < totalFrames)
|
||||||
{
|
{
|
||||||
frameIndex++;
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
|
var frame = GetNextProcessedFrame(state, token);
|
||||||
if (read != inBytes)
|
if (frame == null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// input frame → Mat
|
frameIndex++;
|
||||||
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
|
|
||||||
|
|
||||||
var objects = _detector.DetectAll(frameMat);
|
EncodeFrame(frame, state, encodeStdin);
|
||||||
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);
|
||||||
@ -141,45 +199,138 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
encodeStdin.Flush();
|
encodeStdin.Flush();
|
||||||
|
encodeStdin.Close();
|
||||||
// loop finished
|
|
||||||
|
|
||||||
encodeStdin.Flush();
|
|
||||||
encodeStdin.Close(); // must happen before waiting encode
|
|
||||||
|
|
||||||
await encode.WaitForExitAsync();
|
await encode.WaitForExitAsync();
|
||||||
|
|
||||||
// belt-and-braces: if decode is still alive, kill it
|
ClearProgress(name);
|
||||||
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
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// ---------- FFmpeg decode / encode ----------
|
private object CreateFrameState(SingleTask job)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
|
var kalman = new KalmanTracker();
|
||||||
|
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 = "";
|
var rotateStr = GetRorationArg(rotate);
|
||||||
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} " +
|
||||||
@ -202,12 +353,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(inputFile);
|
var fileName = Path.GetFileName(inputFile);
|
||||||
|
|
||||||
_ = Task.Run(() =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = p.StandardError.ReadLine()) != null)
|
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
@ -217,34 +368,66 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Process StartFfmpegEncode(
|
public static string GetRorationArg(int? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
double fps,
|
VideoInfo info,
|
||||||
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 = fps.ToString("0.###", CultureInfo.InvariantCulture);
|
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio)
|
||||||
|
? $"-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",
|
||||||
@ -260,31 +443,53 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(outputFile);
|
var fileName = Path.GetFileName(outputFile);
|
||||||
|
|
||||||
_ = Task.Run(() =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = p.StandardError.ReadLine()) != null)
|
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||||
{
|
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch { }
|
catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
private static void ReduceFraction(ref int num, ref int den)
|
||||||
|
{
|
||||||
|
int Gcd(int a, int b)
|
||||||
|
{
|
||||||
|
while (b != 0)
|
||||||
|
{
|
||||||
|
var t = b;
|
||||||
|
b = a % b;
|
||||||
|
a = t;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
|
var g = Gcd(Math.Abs(num), Math.Abs(den));
|
||||||
|
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 = s.Read(buffer, offset + total, count - total);
|
var read = await s.ReadAsync(buffer, offset + total, count - total, token);
|
||||||
if (read <= 0)
|
if (read <= 0)
|
||||||
break;
|
break;
|
||||||
total += read;
|
total += read;
|
||||||
@ -292,83 +497,5 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
using OpenCvSharp;
|
namespace splitter.algo;
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public enum TrackState
|
public enum TrackState
|
||||||
{
|
{
|
||||||
@ -60,7 +58,7 @@ public sealed class CameraController
|
|||||||
_kalman.Reset(_cameraCenter);
|
_kalman.Reset(_cameraCenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Point2f DefaultCenter => _cmd.GravitateTo ?? new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
private Point2f DefaultCenter => _cmd.GravitateTo;
|
||||||
|
|
||||||
public int LostFrames => _lostFrames;
|
public int LostFrames => _lostFrames;
|
||||||
public Point2f CameraCenter => _cameraCenter;
|
public Point2f CameraCenter => _cameraCenter;
|
||||||
@ -70,15 +68,15 @@ public sealed class CameraController
|
|||||||
public Point2f? ObjectCenter => _objectCenter;
|
public Point2f? ObjectCenter => _objectCenter;
|
||||||
public Rect Roi => _roi;
|
public Rect Roi => _roi;
|
||||||
|
|
||||||
public void Update((Rect box, Point2f center)? primary)
|
public void Update(DetectedPerson? 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@ -97,7 +95,7 @@ public sealed class CameraController
|
|||||||
_dropoutCounter = 0;
|
_dropoutCounter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isLost = !objectCenter.HasValue;
|
var isLost = !objectCenter.HasValue;
|
||||||
|
|
||||||
// LOST / REACQUIRE STATE MACHINE
|
// LOST / REACQUIRE STATE MACHINE
|
||||||
if (isLost)
|
if (isLost)
|
||||||
@ -149,7 +147,7 @@ public sealed class CameraController
|
|||||||
{
|
{
|
||||||
smoothedCenter = _kalman.Update(objectCenter);
|
smoothedCenter = _kalman.Update(objectCenter);
|
||||||
|
|
||||||
float driftEasing = 0.01f;
|
var 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(
|
||||||
8
splitter-cli/algo/DetectedPerson.cs
Normal file
8
splitter-cli/algo/DetectedPerson.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public struct DetectedPerson
|
||||||
|
{
|
||||||
|
public ulong Id;
|
||||||
|
public Rect Box;
|
||||||
|
public Point2f Center;
|
||||||
|
}
|
||||||
21
splitter-cli/algo/DummyDetector.cs
Normal file
21
splitter-cli/algo/DummyDetector.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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() {}
|
||||||
|
}
|
||||||
6
splitter-cli/algo/IEmbeddingExtractor.cs
Normal file
6
splitter-cli/algo/IEmbeddingExtractor.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public interface IEmbeddingExtractor : IDisposable
|
||||||
|
{
|
||||||
|
float[] Extract(Mat frame, Rect box);
|
||||||
|
}
|
||||||
6
splitter-cli/algo/IObjectDetector.cs
Normal file
6
splitter-cli/algo/IObjectDetector.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public interface IObjectDetector : IDisposable
|
||||||
|
{
|
||||||
|
List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont);
|
||||||
|
}
|
||||||
6
splitter-cli/algo/IObjectTracker.cs
Normal file
6
splitter-cli/algo/IObjectTracker.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public interface IObjectTracker
|
||||||
|
{
|
||||||
|
(List<DetectedPerson> objects, DetectedPerson? primary) SelectTrackedObject(SingleTask job, Mat frameMat, Point2f? lastMeasurement);
|
||||||
|
}
|
||||||
14
splitter-cli/algo/ISegmentProcessor.cs
Normal file
14
splitter-cli/algo/ISegmentProcessor.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
14
splitter-cli/algo/IVideoEnhancer.cs
Normal file
14
splitter-cli/algo/IVideoEnhancer.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public interface IVideoEnhancer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
int ResolutionMultiplier { get; }
|
||||||
|
|
||||||
|
Task InitializeAsync(int width, int height, int window, CancellationToken token);
|
||||||
|
|
||||||
|
// Returns true when an enhanced frame is ready
|
||||||
|
bool TryProcessFrame(Mat input, out Mat output, CancellationToken token);
|
||||||
|
|
||||||
|
// Flush remaining frames after input is finished
|
||||||
|
int Flush(Span<Mat> outputFrames, CancellationToken token);
|
||||||
|
}
|
||||||
73
splitter-cli/algo/IdentityCache.cs
Normal file
73
splitter-cli/algo/IdentityCache.cs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public sealed class IdentityCache
|
||||||
|
{
|
||||||
|
private sealed class Identity
|
||||||
|
{
|
||||||
|
public ulong Id;
|
||||||
|
public float[] Embedding = null!; // EMA
|
||||||
|
public int Samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<Identity> _ids = new();
|
||||||
|
private ulong _nextId = 1;
|
||||||
|
|
||||||
|
private const float _emaAlpha = 0.2f;
|
||||||
|
|
||||||
|
public ulong ResolveId(float[] embedding, float threshold)
|
||||||
|
{
|
||||||
|
if (_ids.Count == 0)
|
||||||
|
return CreateNew(embedding);
|
||||||
|
|
||||||
|
int bestIndex = -1;
|
||||||
|
float bestDist = float.MaxValue;
|
||||||
|
|
||||||
|
for (int i = 0; i < _ids.Count; i++)
|
||||||
|
{
|
||||||
|
float d = CosineDistance(_ids[i].Embedding, embedding);
|
||||||
|
if (d < bestDist)
|
||||||
|
{
|
||||||
|
bestDist = d;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestDist <= threshold)
|
||||||
|
{
|
||||||
|
UpdateEma(_ids[bestIndex].Embedding, embedding);
|
||||||
|
_ids[bestIndex].Samples++;
|
||||||
|
return _ids[bestIndex].Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateNew(embedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong CreateNew(float[] embedding)
|
||||||
|
{
|
||||||
|
var id = _nextId++;
|
||||||
|
|
||||||
|
_ids.Add(new Identity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Embedding = embedding.ToArray(),
|
||||||
|
Samples = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float CosineDistance(float[] a, float[] b)
|
||||||
|
{
|
||||||
|
float dot = 0f;
|
||||||
|
for (int i = 0; i < a.Length; i++)
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
|
||||||
|
return 1f - dot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateEma(float[] ema, float[] v)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < ema.Length; i++)
|
||||||
|
ema[i] = ema[i] * (1 - _emaAlpha) + v[i] * _emaAlpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
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 (int i = 0; i < 4; i++)
|
for (var i = 0; i < 4; i++)
|
||||||
for (int j = 0; j < 4; j++)
|
for (var 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
|
||||||
float yx = z.X - _state[0];
|
var yx = z.X - _state[0];
|
||||||
float yy = z.Y - _state[1];
|
var yy = z.Y - _state[1];
|
||||||
|
|
||||||
// Innovation covariance S = P + R
|
// Innovation covariance S = P + R
|
||||||
float Sx = _p[0, 0] + _r;
|
var Sx = _p[0, 0] + _r;
|
||||||
float Sy = _p[1, 1] + _r;
|
var Sy = _p[1, 1] + _r;
|
||||||
|
|
||||||
// Kalman gain K = P / S
|
// Kalman gain K = P / S
|
||||||
float Kx0 = _p[0, 0] / Sx;
|
var Kx0 = _p[0, 0] / Sx;
|
||||||
float Kx1 = _p[1, 1] / Sy;
|
var Kx1 = _p[1, 1] / Sy;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_state[0] += Kx0 * yx;
|
_state[0] += Kx0 * yx;
|
||||||
127
splitter-cli/algo/OSNetEmbeddingExtractor.cs
Normal file
127
splitter-cli/algo/OSNetEmbeddingExtractor.cs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.ML.OnnxRuntime;
|
||||||
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
|
|
||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public sealed class OSNetEmbeddingExtractor : IDisposable, IEmbeddingExtractor
|
||||||
|
{
|
||||||
|
private readonly InferenceSession _session;
|
||||||
|
private readonly string _inputName;
|
||||||
|
private readonly string _outputName;
|
||||||
|
|
||||||
|
private const int _batchSize = 16;
|
||||||
|
private const int _inputHeight = 256;
|
||||||
|
private const int _inputWidth = 128;
|
||||||
|
private const int _channels = 3;
|
||||||
|
|
||||||
|
private readonly float[] _inputBuffer;
|
||||||
|
private readonly DenseTensor<float> _inputTensor;
|
||||||
|
private readonly List<NamedOnnxValue> _inputs = new(1);
|
||||||
|
|
||||||
|
private readonly float[] _embedding;
|
||||||
|
|
||||||
|
private readonly Mat _resizeMat = new();
|
||||||
|
private readonly Mat _rgbMat = new();
|
||||||
|
|
||||||
|
private readonly float _inv255 = 1f / 255f;
|
||||||
|
|
||||||
|
public OSNetEmbeddingExtractor()
|
||||||
|
{
|
||||||
|
var opt = new SessionOptions();
|
||||||
|
opt.AppendExecutionProvider_DML();
|
||||||
|
|
||||||
|
var modelPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "models", "osnet_x0_25_msmt17.onnx");
|
||||||
|
_session = new InferenceSession(modelPath, opt);
|
||||||
|
|
||||||
|
_inputName = _session.InputMetadata.Keys.First();
|
||||||
|
_outputName = _session.OutputMetadata.Keys.First();
|
||||||
|
|
||||||
|
int inputSize = _batchSize * _channels * _inputHeight * _inputWidth;
|
||||||
|
_inputBuffer = new float[inputSize];
|
||||||
|
|
||||||
|
_inputTensor = new DenseTensor<float>(
|
||||||
|
_inputBuffer,
|
||||||
|
new[] { _batchSize, _channels, _inputHeight, _inputWidth }
|
||||||
|
);
|
||||||
|
|
||||||
|
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
||||||
|
|
||||||
|
int outDim = _session.OutputMetadata[_outputName].Dimensions[1];
|
||||||
|
_embedding = new float[outDim];
|
||||||
|
}
|
||||||
|
|
||||||
|
public float[] Extract(Mat frame, Rect box)
|
||||||
|
{
|
||||||
|
// Clear all batches
|
||||||
|
Array.Clear(_inputBuffer, 0, _inputBuffer.Length);
|
||||||
|
|
||||||
|
// Extract ROI
|
||||||
|
var roi = new Mat(frame, box);
|
||||||
|
|
||||||
|
Cv2.Resize(roi, _resizeMat, new Size(_inputWidth, _inputHeight));
|
||||||
|
Cv2.CvtColor(_resizeMat, _rgbMat, ColorConversionCodes.BGR2RGB);
|
||||||
|
|
||||||
|
FillBatch0(_rgbMat);
|
||||||
|
|
||||||
|
using var results = _session.Run(_inputs);
|
||||||
|
|
||||||
|
var output = results.First(v => v.Name == _outputName).AsTensor<float>();
|
||||||
|
|
||||||
|
// Read embedding from batch 0
|
||||||
|
for (int i = 0; i < _embedding.Length; i++)
|
||||||
|
_embedding[i] = output[0, i];
|
||||||
|
|
||||||
|
NormalizeL2(_embedding);
|
||||||
|
|
||||||
|
return _embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private void FillBatch0(Mat rgb)
|
||||||
|
{
|
||||||
|
int plane = _inputHeight * _inputWidth;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
for (int y = 0; y < _inputHeight; y++)
|
||||||
|
{
|
||||||
|
var rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||||
|
var rowSpan = new Span<byte>(rowPtr, _inputWidth * 3);
|
||||||
|
|
||||||
|
int src = 0;
|
||||||
|
|
||||||
|
for (int x = 0; x < _inputWidth; x++)
|
||||||
|
{
|
||||||
|
int off = y * _inputWidth + x;
|
||||||
|
|
||||||
|
_inputBuffer[off] = rowSpan[src + 0] * _inv255; // R
|
||||||
|
_inputBuffer[plane + off] = rowSpan[src + 1] * _inv255; // G
|
||||||
|
_inputBuffer[2 * plane + off] = rowSpan[src + 2] * _inv255; // B
|
||||||
|
|
||||||
|
src += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static void NormalizeL2(float[] v)
|
||||||
|
{
|
||||||
|
float sum = 0f;
|
||||||
|
for (int i = 0; i < v.Length; i++)
|
||||||
|
sum += v[i] * v[i];
|
||||||
|
|
||||||
|
float inv = 1f / MathF.Sqrt(sum);
|
||||||
|
|
||||||
|
for (int i = 0; i < v.Length; i++)
|
||||||
|
v[i] *= inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_session?.Dispose();
|
||||||
|
_resizeMat?.Dispose();
|
||||||
|
_rgbMat?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
splitter-cli/algo/ObjectTracker.cs
Normal file
96
splitter-cli/algo/ObjectTracker.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public class ObjectTracker(IObjectDetector _detector, IEmbeddingExtractor _embeddingExtractor) : IObjectTracker
|
||||||
|
{
|
||||||
|
private readonly IdentityCache _identityCache = new();
|
||||||
|
|
||||||
|
public (List<DetectedPerson> objects, DetectedPerson? primary) SelectTrackedObject(SingleTask job, Mat frameMat, Point2f? lastMeasurement)
|
||||||
|
{
|
||||||
|
var objects = _detector.DetectAll(job, frameMat) ?? [];
|
||||||
|
|
||||||
|
// filter by DetectAbove
|
||||||
|
objects = objects
|
||||||
|
.Where(o => o.Center.Y <= frameMat.Height * job.Job.DetectAbove)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// attach embeddings
|
||||||
|
for (int i = 0; i < objects.Count; i++)
|
||||||
|
{
|
||||||
|
var p = objects[i];
|
||||||
|
|
||||||
|
var rect = p.Box;
|
||||||
|
|
||||||
|
rect.X = Math.Clamp(rect.X, 0, frameMat.Width - 1);
|
||||||
|
rect.Y = Math.Clamp(rect.Y, 0, frameMat.Height - 1);
|
||||||
|
rect.Width = Math.Clamp(rect.Width, 1, frameMat.Width - rect.X);
|
||||||
|
rect.Height = Math.Clamp(rect.Height, 1, frameMat.Height - rect.Y);
|
||||||
|
|
||||||
|
var embedding = _embeddingExtractor.Extract(frameMat, rect).ToArray(); // make a copy of the embedding array
|
||||||
|
p.Id = _identityCache.ResolveId(embedding, job.Job.IdentityThreshold);
|
||||||
|
|
||||||
|
objects[i] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeek tracker assigns stable IDs
|
||||||
|
var primary = SelectPrimaryObject(objects, lastMeasurement, job.Job.DetectId);
|
||||||
|
return (objects, primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetectedPerson? SelectPrimaryObject(
|
||||||
|
List<DetectedPerson> foundObjects,
|
||||||
|
Point2f? previousCenter,
|
||||||
|
ulong? detectId)
|
||||||
|
{
|
||||||
|
if (foundObjects == null || foundObjects.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (detectId != null)
|
||||||
|
{
|
||||||
|
var match = foundObjects.FirstOrDefault(o => o.Id == detectId.Value);
|
||||||
|
if (match.Id != 0) // default struct has Id=0, so this means we found a match
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousCenter.HasValue)
|
||||||
|
{
|
||||||
|
var bestIndex = 0;
|
||||||
|
var bestArea = float.MinValue;
|
||||||
|
|
||||||
|
for (var 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 (var 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
85
splitter-cli/algo/OnnxInspector.cs
Normal file
85
splitter-cli/algo/OnnxInspector.cs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Onnxify;
|
||||||
|
|
||||||
|
public static class OnnxInspector
|
||||||
|
{
|
||||||
|
public static string GetOnnxInfo(string modelPath)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(4096);
|
||||||
|
|
||||||
|
if (!File.Exists(modelPath))
|
||||||
|
{
|
||||||
|
sb.Append("File not found: ").Append(modelPath);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load ONNX model
|
||||||
|
var model = OnnxModel.FromFile(modelPath);
|
||||||
|
|
||||||
|
sb.AppendLine("=== MODEL METADATA ===");
|
||||||
|
sb.Append("IR Version: ").AppendLine(model.IrVersion.ToString());
|
||||||
|
sb.Append("Producer Name: ").AppendLine(model.ProducerName);
|
||||||
|
sb.Append("Producer Version: ").AppendLine(model.ProducerVersion);
|
||||||
|
sb.Append("Domain: ").AppendLine(model.Domain);
|
||||||
|
sb.Append("Model Version: ").AppendLine(model.ModelVersion.ToString());
|
||||||
|
sb.Append("Doc String: ").AppendLine(model.Document);
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("=== OPSET IMPORTS ===");
|
||||||
|
foreach (var opset in model.OpsetImport)
|
||||||
|
{
|
||||||
|
sb.Append("Domain: ").Append(opset.Domain)
|
||||||
|
.Append(" Version: ").AppendLine(opset.Version.ToString());
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
var graph = model.Graph;
|
||||||
|
|
||||||
|
sb.AppendLine("=== GRAPH INPUTS ===");
|
||||||
|
foreach (var input in graph.Inputs)
|
||||||
|
{
|
||||||
|
sb.Append("Name: ").AppendLine(input.Name);
|
||||||
|
if (input.Type?.Denotation != null)
|
||||||
|
{
|
||||||
|
sb.Append(" Denotation: ").AppendLine(input.Type?.Denotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("=== GRAPH OUTPUTS ===");
|
||||||
|
foreach (var output in graph.Outputs)
|
||||||
|
{
|
||||||
|
sb.Append("Name: ").AppendLine(output.Name);
|
||||||
|
if (output.Type?.Denotation != null)
|
||||||
|
{
|
||||||
|
sb.Append(" Denotation: ").AppendLine(output.Type?.Denotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("=== INITIALIZERS ===");
|
||||||
|
foreach (var init in graph.Initializers)
|
||||||
|
{
|
||||||
|
sb.Append("Name: ").AppendLine(init.Name);
|
||||||
|
sb.Append(" DataType: ").AppendLine(init.DataType.ToString());
|
||||||
|
sb.Append(" Dims: ").AppendLine(string.Join("x", init.Shape));
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("=== NODES ===");
|
||||||
|
foreach (var node in graph.Nodes)
|
||||||
|
{
|
||||||
|
sb.Append("OpType: ").AppendLine(node.OpType);
|
||||||
|
sb.Append(" Name: ").AppendLine(node.Name);
|
||||||
|
sb.Append(" Inputs: ").AppendLine(string.Join(", ", node.Inputs));
|
||||||
|
sb.Append(" Outputs: ").AppendLine(string.Join(", ", node.Outputs));
|
||||||
|
|
||||||
|
foreach (var attr in node.Attributes)
|
||||||
|
{
|
||||||
|
sb.Append(" Attr: ").Append(attr.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
splitter-cli/algo/Point2f.cs
Normal file
13
splitter-cli/algo/Point2f.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//namespace splitter.algo;
|
||||||
|
|
||||||
|
//public struct Point2f
|
||||||
|
//{
|
||||||
|
// public float X;
|
||||||
|
// public float Y;
|
||||||
|
|
||||||
|
// public Point2f(float x, float y)
|
||||||
|
// {
|
||||||
|
// X = x;
|
||||||
|
// Y = y;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
215
splitter-cli/algo/RealBasicVsr2xDmlEnhancer.cs
Normal file
215
splitter-cli/algo/RealBasicVsr2xDmlEnhancer.cs
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
using Microsoft.ML.OnnxRuntime;
|
||||||
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
|
|
||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public sealed unsafe class RealBasicVsr2xDmlEnhancer : IVideoEnhancer
|
||||||
|
{
|
||||||
|
public int ResolutionMultiplier => 2;
|
||||||
|
|
||||||
|
private InferenceSession _session = null!;
|
||||||
|
private SessionOptions _options = null!;
|
||||||
|
|
||||||
|
private int _inW;
|
||||||
|
private int _inH;
|
||||||
|
private int _window;
|
||||||
|
|
||||||
|
private readonly Queue<Mat> _frames = new Queue<Mat>(32);
|
||||||
|
|
||||||
|
private float[] _inputBuffer = null!;
|
||||||
|
private float[] _outputBuffer = null!;
|
||||||
|
|
||||||
|
private DenseTensor<float> _inputTensor = null!;
|
||||||
|
private DenseTensor<float> _outputTensor = null!;
|
||||||
|
|
||||||
|
private Mat _outputMat = null!;
|
||||||
|
|
||||||
|
private readonly List<NamedOnnxValue> _inputList = new List<NamedOnnxValue>(1);
|
||||||
|
|
||||||
|
public Task InitializeAsync(int width, int height, int window, CancellationToken token)
|
||||||
|
{
|
||||||
|
_inW = width;
|
||||||
|
_inH = height;
|
||||||
|
_window = window;
|
||||||
|
|
||||||
|
var basePath = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
var modelPath = System.IO.Path.Combine(basePath, "models", "realbasicvsr_x2.onnx");
|
||||||
|
|
||||||
|
_options = new SessionOptions();
|
||||||
|
_options.AppendExecutionProvider_DML();
|
||||||
|
|
||||||
|
_session = new InferenceSession(modelPath, _options);
|
||||||
|
|
||||||
|
int inputSize = window * 3 * width * height;
|
||||||
|
int outW = width * 2;
|
||||||
|
int outH = height * 2;
|
||||||
|
int outputSize = 3 * outW * outH;
|
||||||
|
|
||||||
|
_inputBuffer = new float[inputSize];
|
||||||
|
_outputBuffer = new float[outputSize];
|
||||||
|
|
||||||
|
_inputTensor = new DenseTensor<float>(_inputBuffer, new[] { 1, window, 3, height, width });
|
||||||
|
_outputTensor = new DenseTensor<float>(_outputBuffer, new[] { 1, 3, outH, outW });
|
||||||
|
|
||||||
|
_outputMat = new Mat(outH, outW, MatType.CV_8UC3);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe bool TryProcessFrame(Mat input, out Mat output, CancellationToken token)
|
||||||
|
{
|
||||||
|
output = null!;
|
||||||
|
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_frames.Count == _window)
|
||||||
|
{
|
||||||
|
var old = _frames.Dequeue();
|
||||||
|
old.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_frames.Enqueue(input.Clone());
|
||||||
|
|
||||||
|
if (_frames.Count < _window)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int T = _window;
|
||||||
|
int H = _inH;
|
||||||
|
int W = _inW;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// INPUT: CV_8UC3 BGR -> normalized RGB, channels-first [1,T,3,H,W]
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
int t = 0;
|
||||||
|
|
||||||
|
foreach (var f in _frames)
|
||||||
|
{
|
||||||
|
byte* src = (byte*)f.Data;
|
||||||
|
int stride = (int)f.Step();
|
||||||
|
|
||||||
|
for (int y = 0; y < H; y++)
|
||||||
|
{
|
||||||
|
byte* row = src + y * stride;
|
||||||
|
|
||||||
|
for (int x = 0; x < W; x++)
|
||||||
|
{
|
||||||
|
int p = x * 3;
|
||||||
|
|
||||||
|
byte b = row[p + 0];
|
||||||
|
byte g = row[p + 1];
|
||||||
|
byte r = row[p + 2];
|
||||||
|
|
||||||
|
float rN = r * (1.0f / 255.0f);
|
||||||
|
float gN = g * (1.0f / 255.0f);
|
||||||
|
float bN = b * (1.0f / 255.0f);
|
||||||
|
|
||||||
|
int idxR = ((((0 * T) + t) * 3 + 0) * H + y) * W + x;
|
||||||
|
int idxG = ((((0 * T) + t) * 3 + 1) * H + y) * W + x;
|
||||||
|
int idxB = ((((0 * T) + t) * 3 + 2) * H + y) * W + x;
|
||||||
|
|
||||||
|
_inputBuffer[idxR] = rN;
|
||||||
|
_inputBuffer[idxG] = gN;
|
||||||
|
_inputBuffer[idxB] = bN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_inputList.Clear();
|
||||||
|
_inputList.Add(NamedOnnxValue.CreateFromTensor("input", _inputTensor));
|
||||||
|
|
||||||
|
using var results = _session.Run(_inputList);
|
||||||
|
|
||||||
|
var outTensor = results[0].AsTensor<float>();
|
||||||
|
var dims = outTensor.Dimensions; // [1, T, 3, H2, W2]
|
||||||
|
|
||||||
|
int outT = dims[1];
|
||||||
|
int outH = dims[3];
|
||||||
|
int outW = dims[4];
|
||||||
|
|
||||||
|
int last = outT - 1;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// STEP 1: Bicubic upscale input to x2
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
using var upBgr = new Mat();
|
||||||
|
Cv2.Resize(input, upBgr, new Size(outW, outH), 0, 0, InterpolationFlags.Cubic);
|
||||||
|
|
||||||
|
using var upRgb = new Mat();
|
||||||
|
Cv2.CvtColor(upBgr, upRgb, ColorConversionCodes.BGR2RGB);
|
||||||
|
|
||||||
|
using var baseFloat = new Mat();
|
||||||
|
upRgb.ConvertTo(baseFloat, MatType.CV_32FC3, 1.0 / 255.0);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// STEP 2: Add residual from model output
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
float* basePtr = (float*)baseFloat.Data;
|
||||||
|
int baseStride = (int)(baseFloat.Step() / sizeof(float));
|
||||||
|
|
||||||
|
for (int y = 0; y < outH; y++)
|
||||||
|
{
|
||||||
|
float* row = basePtr + y * baseStride;
|
||||||
|
|
||||||
|
for (int x = 0; x < outW; x++)
|
||||||
|
{
|
||||||
|
int p = x * 3;
|
||||||
|
|
||||||
|
float rBase = row[p + 0];
|
||||||
|
float gBase = row[p + 1];
|
||||||
|
float bBase = row[p + 2];
|
||||||
|
|
||||||
|
float rRes = outTensor[0, last, 0, y, x];
|
||||||
|
float gRes = outTensor[0, last, 1, y, x];
|
||||||
|
float bRes = outTensor[0, last, 2, y, x];
|
||||||
|
|
||||||
|
float r = Math.Clamp(rBase + rRes, 0f, 1f);
|
||||||
|
float g = Math.Clamp(gBase + gRes, 0f, 1f);
|
||||||
|
float b = Math.Clamp(bBase + bRes, 0f, 1f);
|
||||||
|
|
||||||
|
row[p + 0] = r;
|
||||||
|
row[p + 1] = g;
|
||||||
|
row[p + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// STEP 3: Convert back to BGR 8-bit for FFmpeg
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
using var outRgb8 = new Mat();
|
||||||
|
baseFloat.ConvertTo(outRgb8, MatType.CV_8UC3, 255.0);
|
||||||
|
|
||||||
|
Cv2.CvtColor(outRgb8, _outputMat, ColorConversionCodes.RGB2BGR);
|
||||||
|
|
||||||
|
output = _outputMat;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Flush(Span<Mat> outputFrames, CancellationToken token)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var f in _frames)
|
||||||
|
f.Dispose();
|
||||||
|
|
||||||
|
_frames.Clear();
|
||||||
|
|
||||||
|
_session?.Dispose();
|
||||||
|
_options?.Dispose();
|
||||||
|
_outputMat?.Dispose();
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using OpenCvSharp;
|
|
||||||
using UltraFaceDotNet;
|
using UltraFaceDotNet;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||||
{
|
{
|
||||||
@ -24,14 +23,14 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
|||||||
_ultraFace = UltraFace.Create(param);
|
_ultraFace = UltraFace.Create(param);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
public List<DetectedPerson> DetectAll(SingleTask job, 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<(Rect box, Point2f center)>();
|
var results = new List<DetectedPerson>();
|
||||||
|
|
||||||
if (bgr == null || bgr.Length == 0)
|
if (bgr == null || bgr.Length == 0)
|
||||||
return results;
|
return results;
|
||||||
@ -52,10 +51,10 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
|||||||
|
|
||||||
foreach (var f in faces)
|
foreach (var f in faces)
|
||||||
{
|
{
|
||||||
int x1 = (int)f.X1;
|
var x1 = (int)f.X1;
|
||||||
int y1 = (int)f.Y1;
|
var y1 = (int)f.Y1;
|
||||||
int x2 = (int)f.X2;
|
var x2 = (int)f.X2;
|
||||||
int y2 = (int)f.Y2;
|
var y2 = (int)f.Y2;
|
||||||
|
|
||||||
var rect = new Rect(
|
var rect = new Rect(
|
||||||
x1,
|
x1,
|
||||||
@ -70,7 +69,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((rect, center));
|
results.Add(new DetectedPerson{ Box = rect, Center = center });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
277
splitter-cli/algo/YoloV10ObjectDetector.cs
Normal file
277
splitter-cli/algo/YoloV10ObjectDetector.cs
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.ML.OnnxRuntime;
|
||||||
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
|
|
||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public sealed class YoloV10ObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||||
|
{
|
||||||
|
private readonly InferenceSession _session;
|
||||||
|
private readonly string _inputName;
|
||||||
|
private readonly string _outputName;
|
||||||
|
|
||||||
|
private const int _inputWidth = 640;
|
||||||
|
private const int _inputHeight = 640;
|
||||||
|
private const float _nmsThreshold = 0.45f;
|
||||||
|
private const int _personClassIndex = 0;
|
||||||
|
|
||||||
|
private readonly Mat _resizeMat = new();
|
||||||
|
private readonly Mat _rgbMat = new();
|
||||||
|
|
||||||
|
private readonly float[] _inputBuffer;
|
||||||
|
private readonly DenseTensor<float> _inputTensor;
|
||||||
|
|
||||||
|
private readonly List<NamedOnnxValue> _inputs = new(1);
|
||||||
|
|
||||||
|
private readonly List<Detection> _detections = new(256);
|
||||||
|
private readonly List<Detection> _nmsBuffer = new(256);
|
||||||
|
|
||||||
|
private readonly List<DetectedPerson> _results = new(64);
|
||||||
|
|
||||||
|
private readonly float _inv255 = 1f / 255f;
|
||||||
|
|
||||||
|
private readonly struct Detection
|
||||||
|
{
|
||||||
|
public readonly float X;
|
||||||
|
public readonly float Y;
|
||||||
|
public readonly float Width;
|
||||||
|
public readonly float Height;
|
||||||
|
public readonly float Score;
|
||||||
|
|
||||||
|
public Detection(float x, float y, float w, float h, float score)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
Width = w;
|
||||||
|
Height = h;
|
||||||
|
Score = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public YoloV10ObjectDetector(ILogger logger) : base(logger, -1)
|
||||||
|
{
|
||||||
|
var options = new SessionOptions();
|
||||||
|
options.AppendExecutionProvider_DML();
|
||||||
|
|
||||||
|
var basePath = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
var modelPath = Path.Combine(basePath, "models", "yolov10m.onnx");
|
||||||
|
|
||||||
|
_session = new InferenceSession(modelPath, options);
|
||||||
|
|
||||||
|
_inputName = _session.InputMetadata.Keys.First();
|
||||||
|
_outputName = _session.OutputMetadata.Keys.First();
|
||||||
|
|
||||||
|
_inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth];
|
||||||
|
_inputTensor = new DenseTensor<float>(_inputBuffer, new[] { 1, 3, _inputHeight, _inputWidth });
|
||||||
|
|
||||||
|
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||||
|
{
|
||||||
|
if (frameCont.Empty())
|
||||||
|
{
|
||||||
|
_results.Clear();
|
||||||
|
return _results;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cv2.Resize(frameCont, _resizeMat, new Size(_inputWidth, _inputHeight));
|
||||||
|
Cv2.CvtColor(_resizeMat, _rgbMat, ColorConversionCodes.BGR2RGB);
|
||||||
|
|
||||||
|
FillInputTensor(_rgbMat);
|
||||||
|
|
||||||
|
using var results = _session.Run(_inputs);
|
||||||
|
|
||||||
|
Tensor<float>? output = null;
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
if (r.Name == _outputName)
|
||||||
|
{
|
||||||
|
output = r.AsTensor<float>();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output is null)
|
||||||
|
{
|
||||||
|
_results.Clear();
|
||||||
|
return _results;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseYoloV10(
|
||||||
|
output,
|
||||||
|
frameCont.Width,
|
||||||
|
frameCont.Height,
|
||||||
|
job.Job.ScoreThreshold,
|
||||||
|
_personClassIndex,
|
||||||
|
_detections);
|
||||||
|
|
||||||
|
var final = ApplyNms(_detections, _nmsThreshold, _nmsBuffer);
|
||||||
|
|
||||||
|
_results.Clear();
|
||||||
|
for (var i = 0; i < final.Count; i++)
|
||||||
|
{
|
||||||
|
var d = final[i];
|
||||||
|
|
||||||
|
var x = (int)d.X;
|
||||||
|
var y = (int)d.Y;
|
||||||
|
var w = (int)d.Width;
|
||||||
|
var h = (int)d.Height;
|
||||||
|
|
||||||
|
x = Math.Clamp(x, 0, frameCont.Width - 1);
|
||||||
|
y = Math.Clamp(y, 0, frameCont.Height - 1);
|
||||||
|
w = Math.Clamp(w, 1, frameCont.Width - x);
|
||||||
|
h = Math.Clamp(h, 1, frameCont.Height - y);
|
||||||
|
|
||||||
|
var rect = new Rect(x, y, w, h);
|
||||||
|
var center = new Point2f(x + w / 2f, y + h / 2f);
|
||||||
|
|
||||||
|
_results.Add(new DetectedPerson{ Box = rect, Center = center });
|
||||||
|
}
|
||||||
|
|
||||||
|
return _results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private void FillInputTensor(Mat rgb)
|
||||||
|
{
|
||||||
|
var height = _inputHeight;
|
||||||
|
var width = _inputWidth;
|
||||||
|
|
||||||
|
var planeSize = height * width;
|
||||||
|
|
||||||
|
Span<float> dst = _inputBuffer.AsSpan();
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
var rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||||
|
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
||||||
|
|
||||||
|
var srcIndex = 0;
|
||||||
|
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
var r = rowSpan[srcIndex + 0];
|
||||||
|
var g = rowSpan[srcIndex + 1];
|
||||||
|
var b = rowSpan[srcIndex + 2];
|
||||||
|
|
||||||
|
var offset = y * width + x;
|
||||||
|
|
||||||
|
dst[offset] = r * _inv255;
|
||||||
|
dst[planeSize + offset] = g * _inv255;
|
||||||
|
dst[2 * planeSize + offset] = b * _inv255;
|
||||||
|
|
||||||
|
srcIndex += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YOLOv10 parser: [1, 300, 6] => x1, y1, x2, y2, score, class_id
|
||||||
|
private static void ParseYoloV10(
|
||||||
|
Tensor<float> output,
|
||||||
|
int originalWidth,
|
||||||
|
int originalHeight,
|
||||||
|
float scoreThreshold,
|
||||||
|
int classIndex,
|
||||||
|
List<Detection> detections)
|
||||||
|
{
|
||||||
|
detections.Clear();
|
||||||
|
|
||||||
|
// dims: [1, 300, 6]
|
||||||
|
var count = output.Dimensions[1];
|
||||||
|
|
||||||
|
var xScale = (float)originalWidth / 640f;
|
||||||
|
var yScale = (float)originalHeight / 640f;
|
||||||
|
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var x1 = output[0, i, 0];
|
||||||
|
var y1 = output[0, i, 1];
|
||||||
|
var x2 = output[0, i, 2];
|
||||||
|
var y2 = output[0, i, 3];
|
||||||
|
var score = output[0, i, 4];
|
||||||
|
var cls = (int)output[0, i, 5];
|
||||||
|
|
||||||
|
if (cls != classIndex)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (score < scoreThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var left = x1 * xScale;
|
||||||
|
var top = y1 * yScale;
|
||||||
|
var width = (x2 - x1) * xScale;
|
||||||
|
var height = (y2 - y1) * yScale;
|
||||||
|
|
||||||
|
detections.Add(new Detection(left, top, width, height, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Detection> ApplyNms(
|
||||||
|
List<Detection> detections,
|
||||||
|
float nmsThreshold,
|
||||||
|
List<Detection> nmsBuffer)
|
||||||
|
{
|
||||||
|
nmsBuffer.Clear();
|
||||||
|
|
||||||
|
if (detections.Count == 0)
|
||||||
|
return nmsBuffer;
|
||||||
|
|
||||||
|
detections.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
||||||
|
|
||||||
|
for (var i = 0; i < detections.Count; i++)
|
||||||
|
{
|
||||||
|
var candidate = detections[i];
|
||||||
|
var keep = true;
|
||||||
|
|
||||||
|
for (var j = 0; j < nmsBuffer.Count; j++)
|
||||||
|
{
|
||||||
|
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
||||||
|
{
|
||||||
|
keep = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keep)
|
||||||
|
nmsBuffer.Add(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nmsBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static float IoU(in Detection a, in Detection b)
|
||||||
|
{
|
||||||
|
var x1 = MathF.Max(a.X, b.X);
|
||||||
|
var y1 = MathF.Max(a.Y, b.Y);
|
||||||
|
var x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
||||||
|
var y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
||||||
|
|
||||||
|
var interW = x2 - x1;
|
||||||
|
if (interW <= 0f) return 0f;
|
||||||
|
|
||||||
|
var interH = y2 - y1;
|
||||||
|
if (interH <= 0f) return 0f;
|
||||||
|
|
||||||
|
var interArea = interW * interH;
|
||||||
|
|
||||||
|
var areaA = a.Width * a.Height;
|
||||||
|
var areaB = b.Width * b.Height;
|
||||||
|
|
||||||
|
var union = areaA + areaB - interArea;
|
||||||
|
if (union <= 0f) return 0f;
|
||||||
|
|
||||||
|
return interArea / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_session?.Dispose();
|
||||||
|
_resizeMat?.Dispose();
|
||||||
|
_rgbMat?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
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;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||||
{
|
{
|
||||||
private readonly InferenceSession _session;
|
private readonly InferenceSession _session;
|
||||||
private readonly string _inputName;
|
private readonly string _inputName;
|
||||||
@ -33,7 +32,7 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
private readonly List<Detection> _nmsBuffer = new(256);
|
private readonly List<Detection> _nmsBuffer = new(256);
|
||||||
|
|
||||||
// Reusable result list
|
// Reusable result list
|
||||||
private readonly List<(Rect box, Point2f center)> _results = new(64);
|
private readonly List<DetectedPerson> _results = new(64);
|
||||||
|
|
||||||
private readonly float _inv255 = 1f / 255f;
|
private readonly float _inv255 = 1f / 255f;
|
||||||
|
|
||||||
@ -55,7 +54,7 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public YoloOnnxObjectDetector(ILogger logger) : base(logger, -1)
|
public YoloV8ObjectDetector(ILogger logger) : base(logger, -1)
|
||||||
{
|
{
|
||||||
var options = new SessionOptions();
|
var options = new SessionOptions();
|
||||||
options.AppendExecutionProvider_DML();
|
options.AppendExecutionProvider_DML();
|
||||||
@ -79,7 +78,7 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||||
{
|
{
|
||||||
if (frameCont.Empty())
|
if (frameCont.Empty())
|
||||||
{
|
{
|
||||||
@ -126,28 +125,24 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
|
|
||||||
// Build reusable result list
|
// Build reusable result list
|
||||||
_results.Clear();
|
_results.Clear();
|
||||||
for (int i = 0; i < final.Count; i++)
|
for (var i = 0; i < final.Count; i++)
|
||||||
{
|
{
|
||||||
var d = final[i];
|
var d = final[i];
|
||||||
|
|
||||||
int x = (int)d.X;
|
var x = (int)d.X;
|
||||||
int y = (int)d.Y;
|
var y = (int)d.Y;
|
||||||
int w = (int)d.Width;
|
var w = (int)d.Width;
|
||||||
int h = (int)d.Height;
|
var 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((rect, center));
|
_results.Add(new DetectedPerson{ Box = rect, Center = center });
|
||||||
}
|
}
|
||||||
|
|
||||||
return _results;
|
return _results;
|
||||||
@ -156,30 +151,30 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void FillInputTensor(Mat rgb)
|
private void FillInputTensor(Mat rgb)
|
||||||
{
|
{
|
||||||
int height = _inputHeight;
|
var height = _inputHeight;
|
||||||
int width = _inputWidth;
|
var width = _inputWidth;
|
||||||
|
|
||||||
// NCHW: [1, 3, H, W]
|
// NCHW: [1, 3, H, W]
|
||||||
int planeSize = height * width;
|
var planeSize = height * width;
|
||||||
|
|
||||||
Span<float> dst = _inputBuffer.AsSpan();
|
Span<float> dst = _inputBuffer.AsSpan();
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
for (int y = 0; y < height; y++)
|
for (var y = 0; y < height; y++)
|
||||||
{
|
{
|
||||||
byte* rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
var rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||||
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
||||||
|
|
||||||
int srcIndex = 0;
|
var srcIndex = 0;
|
||||||
|
|
||||||
for (int x = 0; x < width; x++)
|
for (var x = 0; x < width; x++)
|
||||||
{
|
{
|
||||||
byte r = rowSpan[srcIndex + 0];
|
var r = rowSpan[srcIndex + 0];
|
||||||
byte g = rowSpan[srcIndex + 1];
|
var g = rowSpan[srcIndex + 1];
|
||||||
byte b = rowSpan[srcIndex + 2];
|
var b = rowSpan[srcIndex + 2];
|
||||||
|
|
||||||
int offset = y * width + x;
|
var offset = y * width + x;
|
||||||
|
|
||||||
// channel 0: R
|
// channel 0: R
|
||||||
dst[offset] = r * _inv255;
|
dst[offset] = r * _inv255;
|
||||||
@ -206,27 +201,27 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
detections.Clear();
|
detections.Clear();
|
||||||
|
|
||||||
// YOLOv8 output: [1, 84, 8400]
|
// YOLOv8 output: [1, 84, 8400]
|
||||||
int channels = output.Dimensions[1]; // 84
|
var channels = output.Dimensions[1]; // 84
|
||||||
int count = output.Dimensions[2]; // 8400
|
var count = output.Dimensions[2]; // 8400
|
||||||
|
|
||||||
float xScale = (float)originalWidth / 640f;
|
var xScale = (float)originalWidth / 640f;
|
||||||
float yScale = (float)originalHeight / 640f;
|
var yScale = (float)originalHeight / 640f;
|
||||||
|
|
||||||
for (int i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
float x = output[0, 0, i];
|
var x = output[0, 0, i];
|
||||||
float y = output[0, 1, i];
|
var y = output[0, 1, i];
|
||||||
float w = output[0, 2, i];
|
var w = output[0, 2, i];
|
||||||
float h = output[0, 3, i];
|
var h = output[0, 3, i];
|
||||||
|
|
||||||
float classScore = output[0, 4 + classIndex, i];
|
var classScore = output[0, 4 + classIndex, i];
|
||||||
if (classScore < scoreThreshold)
|
if (classScore < scoreThreshold)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
float left = (x - w / 2f) * xScale;
|
var left = (x - w / 2f) * xScale;
|
||||||
float top = (y - h / 2f) * yScale;
|
var top = (y - h / 2f) * yScale;
|
||||||
float width = w * xScale;
|
var width = w * xScale;
|
||||||
float height = h * yScale;
|
var height = h * yScale;
|
||||||
|
|
||||||
detections.Add(new Detection
|
detections.Add(new Detection
|
||||||
(
|
(
|
||||||
@ -253,12 +248,12 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
// 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 (int i = 0; i < detections.Count; i++)
|
for (var i = 0; i < detections.Count; i++)
|
||||||
{
|
{
|
||||||
var candidate = detections[i];
|
var candidate = detections[i];
|
||||||
bool keep = true;
|
var keep = true;
|
||||||
|
|
||||||
for (int j = 0; j < nmsBuffer.Count; j++)
|
for (var j = 0; j < nmsBuffer.Count; j++)
|
||||||
{
|
{
|
||||||
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
||||||
{
|
{
|
||||||
@ -277,23 +272,23 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
|
|||||||
[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)
|
||||||
{
|
{
|
||||||
float x1 = MathF.Max(a.X, b.X);
|
var x1 = MathF.Max(a.X, b.X);
|
||||||
float y1 = MathF.Max(a.Y, b.Y);
|
var y1 = MathF.Max(a.Y, b.Y);
|
||||||
float x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
var x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
||||||
float y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
var y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
||||||
|
|
||||||
float interW = x2 - x1;
|
var interW = x2 - x1;
|
||||||
if (interW <= 0f) return 0f;
|
if (interW <= 0f) return 0f;
|
||||||
|
|
||||||
float interH = y2 - y1;
|
var interH = y2 - y1;
|
||||||
if (interH <= 0f) return 0f;
|
if (interH <= 0f) return 0f;
|
||||||
|
|
||||||
float interArea = interW * interH;
|
var interArea = interW * interH;
|
||||||
|
|
||||||
float areaA = a.Width * a.Height;
|
var areaA = a.Width * a.Height;
|
||||||
float areaB = b.Width * b.Height;
|
var areaB = b.Width * b.Height;
|
||||||
|
|
||||||
float union = areaA + areaB - interArea;
|
var union = areaA + areaB - interArea;
|
||||||
if (union <= 0f) return 0f;
|
if (union <= 0f) return 0f;
|
||||||
|
|
||||||
return interArea / union;
|
return interArea / union;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user