mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Compare commits
No commits in common. "master" and "v0.0.8" have entirely different histories.
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@ -22,19 +22,19 @@ jobs:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore Splitter-UI/Splitter-UI.csproj -r win-x64
|
||||
run: dotnet restore -r win-x64
|
||||
|
||||
- name: Get Version
|
||||
- name: 'Get Version'
|
||||
id: version
|
||||
uses: battila7/get-version-action@v2
|
||||
|
||||
- name: Publish Release
|
||||
run: dotnet publish Splitter-UI/Splitter-UI.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
|
||||
run: dotnet publish splitter-cli/splitter.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
|
||||
|
||||
- name: Create ZIP
|
||||
shell: pwsh
|
||||
run: |
|
||||
$publish = "Splitter-UI/bin/Release/net10.0/win-x64/publish"
|
||||
$publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
|
||||
$version = "${{ steps.version.outputs.version-without-v }}"
|
||||
$zip = "splitter-win-x64-$version.zip"
|
||||
|
||||
@ -51,3 +51,5 @@ jobs:
|
||||
files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@ -1,19 +0,0 @@
|
||||
You are c# programmer. I'm senior c# programmer with 30+ years of experience.
|
||||
Do not be overconfident about your answers - they are 70% incorrect.
|
||||
Do not say "final solution". Do not start every reply with my name.
|
||||
Do not use emoji or non-ascii symbols. Do not explain "why it work".
|
||||
|
||||
I have C#. .NET 10 Avalonia 12 UI for ffmpeg/OpenCV video app. All packages are of very latest versions.
|
||||
|
||||
Use namespace splitter for splitter-cli and Splitter_UI for Splitter-UI.
|
||||
|
||||
Splitter pipeline is:
|
||||
|
||||
* FFProbe extracting all video meta to VideoInfo
|
||||
* FFMpeg used to decode video frames into OpenCVSharp.Mat
|
||||
* One of detectors used:
|
||||
- For face detection: [opencv_zoo/models/face_detection_yunet at main opencv/opencv_zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
|
||||
- For body detection: [THU-MIG/yolov10: YOLOv10: Real-Time End-to-End Object Detection [NeurIPS 2024]](https://github.com/THU-MIG/yolov10/tree/main)
|
||||
* Camera control aplied (CameraControl class)
|
||||
* Final video frames are encoded back to video file using FFMpeg
|
||||
|
||||
222
README.md
222
README.md
@ -1,50 +1,212 @@
|
||||
# Splitter
|
||||
|
||||
This application was built to help me with maintaining my YouTube channel - [UnclShura](https://www.youtube.com/@UnclShura).
|
||||
Splitter is a high‑performance command line tool for cutting one or more video files into equal or fixed‑length segments using multi‑threaded FFmpeg execution.
|
||||
It supports batch input, flexible duration formats, rotation, smart face/body‑aware cropping, ETA and speed reporting, and both rich and plain‑text terminal output.
|
||||
|
||||
Splitter 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
|
||||
|
||||
- Human face or body detection with smart cropping
|
||||
- Multi-threaded FFmpeg splitting for maximum throughput
|
||||
- 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)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Command line interface
|
||||

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

|
||||
- [Potentially] Cross‑platform (.NET 10)
|
||||
|
||||
## Requirements
|
||||
|
||||
- FFmpeg and FFprobe available in system PATH
|
||||
- .NET 10 Runtime or newer
|
||||
|
||||
## More info
|
||||
If you want to update model:
|
||||
|
||||
[Command line tool](splitter-cli/README.md)
|
||||
- For face detection: [opencv_zoo/models/face_detection_yunet at main · opencv/opencv_zoo](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
|
||||
- For body detection: [yolov8s.pt · Ultralytics/YOLOv8 at main](https://huggingface.co/Ultralytics/YOLOv8/blob/main/yolov8s.pt)
|
||||
|
||||
To convert models from PyTorch to ONNX, you can use the following command:
|
||||
|
||||
```python
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8x.pt")
|
||||
model.export(format="onnx", opset=12, half=False) # FP32 ONNX
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Reads total duration using ffprobe
|
||||
2. Parses target duration
|
||||
3. Computes number of segments
|
||||
4. If not forced, equalizes segment lengths
|
||||
5. Runs multiple FFmpeg processes in parallel
|
||||
6. Applies rotation, crop, and tracking if enabled
|
||||
7. Displays progress, ETA, and speed
|
||||
|
||||
## Face Tracking vs Body Tracking
|
||||
|
||||
Face tracking and body tracking serve different purposes, and Splitter supports both because each
|
||||
excels in different recording environments. When converting horizontal footage into vertical clips,
|
||||
the choice of detector determines how stable, reliable, and natural the automated camera motion will be.
|
||||
|
||||

|
||||
|
||||
### Face 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)
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Splitter_UI.App"
|
||||
xmlns:local="using:Splitter_UI"
|
||||
xmlns:cnv="using:Splitter_UI.Converters"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Resources>
|
||||
<cnv:ActionToIconConverter x:Key="ActionToIconConverter"/>
|
||||
<FontFamily x:Key="FontAwesome">avares://Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf#Font Awesome 7 Free Solid</FontFamily>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme/>
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@ -1,39 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Splitter_UI.Views;
|
||||
|
||||
namespace Splitter_UI;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly ServiceProvider _provider = null!;
|
||||
|
||||
public App() { }
|
||||
|
||||
public App(ServiceProvider provider)
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var vm = _provider.GetRequiredService<MainViewModel>();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = vm
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
@ -1,100 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<!-- Background -->
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="100%" stop-color="#020617"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#22C55E"/>
|
||||
<stop offset="100%" stop-color="#0EA5E9"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="videoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#F97316"/>
|
||||
<stop offset="100%" stop-color="#EC4899"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="cropGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#38BDF8"/>
|
||||
<stop offset="100%" stop-color="#6366F1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bgGrad)" />
|
||||
|
||||
<!-- Split timeline bar -->
|
||||
<g transform="translate(32,188)">
|
||||
<rect x="0" y="-10" width="192" height="20" rx="10" fill="#020617" />
|
||||
<rect x="0" y="-10" width="192" height="20" rx="10" fill="url(#accentGrad)" opacity="0.18" />
|
||||
|
||||
<!-- Segment markers -->
|
||||
<rect x="32" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||
<rect x="80" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||
<rect x="128" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||
<rect x="176" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||
</g>
|
||||
|
||||
<!-- Video tile (left) -->
|
||||
<g transform="translate(26,52)">
|
||||
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
|
||||
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.9" />
|
||||
|
||||
<!-- Play triangle -->
|
||||
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.9" />
|
||||
|
||||
<!-- Small split indicator -->
|
||||
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
|
||||
<rect x="36" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||
<rect x="60" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||
</g>
|
||||
|
||||
<!-- Video tile (right, shifted to suggest batch / multi-thread) -->
|
||||
<g transform="translate(134,40)">
|
||||
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
|
||||
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.75" />
|
||||
|
||||
<!-- Play triangle -->
|
||||
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.85" />
|
||||
|
||||
<!-- Small split indicator -->
|
||||
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
|
||||
<rect x="30" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||
<rect x="54" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||
<rect x="78" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||
</g>
|
||||
|
||||
<!-- Smart crop frame with body/face hint -->
|
||||
<g transform="translate(76,86)">
|
||||
<!-- Outer crop frame -->
|
||||
<rect x="0" y="0" width="104" height="104" rx="18" fill="#020617" opacity="0.9" />
|
||||
<rect x="0" y="0" width="104" height="104" rx="18" fill="url(#cropGrad)" opacity="0.25" />
|
||||
|
||||
<!-- Corner crop brackets -->
|
||||
<path d="M10 30 V14 A4 4 0 0 1 14 10 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||
<path d="M74 10 H90 A4 4 0 0 1 94 14 V30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||
<path d="M10 74 V90 A4 4 0 0 0 14 94 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||
<path d="M74 94 H90 A4 4 0 0 0 94 90 V74" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||
|
||||
<!-- Body / face glyph -->
|
||||
<!-- Head -->
|
||||
<circle cx="52" cy="36" r="11" fill="#F9FAFB" />
|
||||
<!-- Torso -->
|
||||
<path d="M32 72 C34 56 42 48 52 48 C62 48 70 56 72 72 Z"
|
||||
fill="#F9FAFB" />
|
||||
|
||||
<!-- Gravitation / tracking bias arc -->
|
||||
<path d="M30 82 A24 24 0 0 0 74 82"
|
||||
stroke="url(#accentGrad)" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
<!-- Subtle multi-thread / performance hint (three vertical bars) -->
|
||||
<g transform="translate(210,188)">
|
||||
<rect x="-10" y="-18" width="4" height="24" rx="2" fill="#22C55E" opacity="0.9" />
|
||||
<rect x="-2" y="-22" width="4" height="28" rx="2" fill="#4ADE80" opacity="0.9" />
|
||||
<rect x="6" y="-16" width="4" height="22" rx="2" fill="#16A34A" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@ -1,298 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Point = Avalonia.Point;
|
||||
|
||||
namespace Splitter_UI.Controls
|
||||
{
|
||||
public sealed class PreviewSlider : Control
|
||||
{
|
||||
public static readonly StyledProperty<double> MinimumProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(Minimum), 0d);
|
||||
|
||||
public static readonly StyledProperty<double> MaximumProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(Maximum), 100d);
|
||||
|
||||
public static readonly StyledProperty<double> ValueProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(
|
||||
nameof(Value), 0d,
|
||||
coerce: (o, v) =>
|
||||
{
|
||||
var slider = (PreviewSlider)o;
|
||||
if (v < slider.Minimum) return slider.Minimum;
|
||||
if (v > slider.Maximum) return slider.Maximum;
|
||||
return v;
|
||||
});
|
||||
|
||||
public static readonly StyledProperty<double> SegmentDurationProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(SegmentDuration), 1d);
|
||||
|
||||
public static readonly StyledProperty<double> TrackThicknessProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(TrackThickness), 4d);
|
||||
|
||||
public static readonly StyledProperty<double> ThumbRadiusProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(ThumbRadius), 8d);
|
||||
|
||||
public static readonly StyledProperty<IBrush> TrackBrushProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(TrackBrush), Brushes.Gray);
|
||||
|
||||
public static readonly StyledProperty<IBrush> TrackFillBrushProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(TrackFillBrush), Brushes.DodgerBlue);
|
||||
|
||||
public static readonly StyledProperty<IBrush> ThumbBrushProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(ThumbBrush), Brushes.White);
|
||||
|
||||
public static readonly StyledProperty<IBrush> ThumbBorderBrushProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(ThumbBorderBrush), Brushes.DodgerBlue);
|
||||
|
||||
public static readonly StyledProperty<double> ThumbBorderThicknessProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(ThumbBorderThickness), 1d);
|
||||
|
||||
public static readonly StyledProperty<IBrush> SegmentLineBrushProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, IBrush>(nameof(SegmentLineBrush), Brushes.LightSalmon);
|
||||
|
||||
public static readonly StyledProperty<double> SegmentLineThicknessProperty =
|
||||
AvaloniaProperty.Register<PreviewSlider, double>(nameof(SegmentLineThickness), 1d);
|
||||
|
||||
private bool _isDragging;
|
||||
|
||||
public double Minimum
|
||||
{
|
||||
get => GetValue(MinimumProperty);
|
||||
set => SetValue(MinimumProperty, value);
|
||||
}
|
||||
|
||||
public double Maximum
|
||||
{
|
||||
get => GetValue(MaximumProperty);
|
||||
set => SetValue(MaximumProperty, value);
|
||||
}
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, value);
|
||||
}
|
||||
|
||||
public double SegmentDuration
|
||||
{
|
||||
get => GetValue(SegmentDurationProperty);
|
||||
set => SetValue(SegmentDurationProperty, value);
|
||||
}
|
||||
|
||||
public double TrackThickness
|
||||
{
|
||||
get => GetValue(TrackThicknessProperty);
|
||||
set => SetValue(TrackThicknessProperty, value);
|
||||
}
|
||||
|
||||
public double ThumbRadius
|
||||
{
|
||||
get => GetValue(ThumbRadiusProperty);
|
||||
set => SetValue(ThumbRadiusProperty, value);
|
||||
}
|
||||
|
||||
public IBrush TrackBrush
|
||||
{
|
||||
get => GetValue(TrackBrushProperty);
|
||||
set => SetValue(TrackBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush TrackFillBrush
|
||||
{
|
||||
get => GetValue(TrackFillBrushProperty);
|
||||
set => SetValue(TrackFillBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush ThumbBrush
|
||||
{
|
||||
get => GetValue(ThumbBrushProperty);
|
||||
set => SetValue(ThumbBrushProperty, value);
|
||||
}
|
||||
|
||||
public IBrush ThumbBorderBrush
|
||||
{
|
||||
get => GetValue(ThumbBorderBrushProperty);
|
||||
set => SetValue(ThumbBorderBrushProperty, value);
|
||||
}
|
||||
|
||||
public double ThumbBorderThickness
|
||||
{
|
||||
get => GetValue(ThumbBorderThicknessProperty);
|
||||
set => SetValue(ThumbBorderThicknessProperty, value);
|
||||
}
|
||||
|
||||
public IBrush SegmentLineBrush
|
||||
{
|
||||
get => GetValue(SegmentLineBrushProperty);
|
||||
set => SetValue(SegmentLineBrushProperty, value);
|
||||
}
|
||||
|
||||
public double SegmentLineThickness
|
||||
{
|
||||
get => GetValue(SegmentLineThicknessProperty);
|
||||
set => SetValue(SegmentLineThicknessProperty, value);
|
||||
}
|
||||
|
||||
static PreviewSlider()
|
||||
{
|
||||
FocusableProperty.OverrideDefaultValue<PreviewSlider>(true);
|
||||
|
||||
ValueProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
||||
MinimumProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
||||
MaximumProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
||||
SegmentDurationProperty.Changed.AddClassHandler<PreviewSlider>((s, _) => s.InvalidateVisual());
|
||||
}
|
||||
|
||||
|
||||
public PreviewSlider()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
|
||||
AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
|
||||
AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
|
||||
AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
base.Render(context);
|
||||
|
||||
var bounds = Bounds;
|
||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||
return;
|
||||
|
||||
var centerY = bounds.Height / 2.0;
|
||||
var left = ThumbRadius;
|
||||
var right = bounds.Width - ThumbRadius;
|
||||
|
||||
var trackThickness = TrackThickness;
|
||||
var trackRect = new Rect(left, centerY - trackThickness / 2.0, right - left, trackThickness);
|
||||
|
||||
context.FillRectangle(TrackBrush, trackRect);
|
||||
|
||||
var range = Maximum - Minimum;
|
||||
if (SegmentDuration > 0 && range > 0 && SegmentLineBrush != null && SegmentLineThickness > 0)
|
||||
{
|
||||
var pen = new Pen(SegmentLineBrush, SegmentLineThickness);
|
||||
var totalSegments = (int)Math.Floor(range / SegmentDuration);
|
||||
|
||||
for (var i = 1; i <= totalSegments; i++)
|
||||
{
|
||||
var segmentValue = Minimum + i * SegmentDuration;
|
||||
var tSeg = (segmentValue - Minimum) / range;
|
||||
var xSeg = left + tSeg * (right - left);
|
||||
|
||||
var p1 = new Point(xSeg, centerY - trackThickness);
|
||||
var p2 = new Point(xSeg, centerY + trackThickness);
|
||||
context.DrawLine(pen, p1, p2);
|
||||
}
|
||||
}
|
||||
|
||||
var t = (range <= 0) ? 0.0 : (Value - Minimum) / range;
|
||||
t = Math.Clamp(t, 0.0, 1.0);
|
||||
|
||||
var thumbX = left + t * (right - left);
|
||||
|
||||
var fillRect = new Rect(left, centerY - trackThickness / 2.0, thumbX - left, trackThickness);
|
||||
context.FillRectangle(TrackFillBrush, fillRect);
|
||||
|
||||
var thumbRadius = ThumbRadius;
|
||||
var thumbCenter = new Point(thumbX, centerY);
|
||||
|
||||
var ellipse = new EllipseGeometry(new Rect(
|
||||
thumbCenter.X - thumbRadius,
|
||||
thumbCenter.Y - thumbRadius,
|
||||
thumbRadius * 2,
|
||||
thumbRadius * 2));
|
||||
|
||||
context.DrawGeometry(ThumbBrush, null, ellipse);
|
||||
|
||||
if (ThumbBorderThickness > 0 && ThumbBorderBrush != null)
|
||||
{
|
||||
var pen = new Pen(ThumbBorderBrush, ThumbBorderThickness);
|
||||
context.DrawGeometry(null, pen, ellipse);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||
{
|
||||
base.OnPointerWheelChanged(e);
|
||||
|
||||
var delta = e.Delta.Y;
|
||||
if (delta == 0)
|
||||
return;
|
||||
|
||||
var step = (Maximum - Minimum) / 100.0;
|
||||
if (step <= 0)
|
||||
step = 1.0;
|
||||
|
||||
if (delta > 0)
|
||||
Value = Math.Clamp(Value - step, Minimum, Maximum);
|
||||
else
|
||||
Value = Math.Clamp(Value + step, Minimum, Maximum);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
|
||||
e.Pointer.Capture(this);
|
||||
UpdateValueFromPoint(e.GetPosition(this));
|
||||
_isDragging = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
|
||||
UpdateValueFromPoint(e.GetPosition(this));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
|
||||
_isDragging = false;
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void UpdateValueFromPoint(Point point)
|
||||
{
|
||||
var bounds = Bounds;
|
||||
var left = ThumbRadius;
|
||||
var right = bounds.Width - ThumbRadius;
|
||||
|
||||
if (right <= left)
|
||||
return;
|
||||
|
||||
var x = Math.Clamp(point.X, left, right);
|
||||
var t = (x - left) / (right - left);
|
||||
|
||||
var newValue = Minimum + t * (Maximum - Minimum);
|
||||
Value = newValue;
|
||||
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,957 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Point = Avalonia.Point;
|
||||
|
||||
namespace Splitter_UI.Controls;
|
||||
|
||||
public class TimelinePreviewSlider : Control, IDisposable
|
||||
{
|
||||
// Public properties
|
||||
public static readonly StyledProperty<JobViewModel?> ViewModelProperty =
|
||||
AvaloniaProperty.Register<TimelinePreviewSlider, JobViewModel?>(nameof(ViewModel));
|
||||
|
||||
public JobViewModel? ViewModel
|
||||
{
|
||||
get => GetValue(ViewModelProperty);
|
||||
set => SetValue(ViewModelProperty, value);
|
||||
}
|
||||
|
||||
private double PixelsPerSecond
|
||||
{
|
||||
get
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null || vm.DurationSeconds <= 0 || Bounds.Width <= 0)
|
||||
return 10000; // fallback value
|
||||
|
||||
// Full control width maps to full video duration
|
||||
return Bounds.Width / vm.DurationSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
public static readonly StyledProperty<IBrush?> SegmentFillProperty =
|
||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(SegmentFill), Brushes.DimGray);
|
||||
|
||||
public IBrush? SegmentFill
|
||||
{
|
||||
get => GetValue(SegmentFillProperty);
|
||||
set => SetValue(SegmentFillProperty, value);
|
||||
}
|
||||
|
||||
public static readonly StyledProperty<IBrush?> MarkerStrokeProperty =
|
||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(MarkerStroke), Brushes.White);
|
||||
|
||||
public IBrush? MarkerStroke
|
||||
{
|
||||
get => GetValue(MarkerStrokeProperty);
|
||||
set => SetValue(MarkerStrokeProperty, value);
|
||||
}
|
||||
|
||||
// Visual constants
|
||||
private const double _timelineHeight = 80;
|
||||
private const double _markerLineHeight = 36;
|
||||
private const double _markerLineWidth = 2;
|
||||
private const double _markerTriangleSize = 8;
|
||||
private const double _segmentBarHeight = 40;
|
||||
private const int _maxPreviewCacheItems = 128;
|
||||
|
||||
// Internal state
|
||||
private readonly LruCache<string, Bitmap> _previewCache = new(_maxPreviewCacheItems);
|
||||
private readonly Dictionary<string, CancellationTokenSource> _previewLoadCts = new();
|
||||
private readonly object _cacheLock = new();
|
||||
|
||||
private IDisposable? _segmentsSubscription;
|
||||
private bool _isInternalSliderUpdate;
|
||||
private JobViewModel? _currentVm;
|
||||
|
||||
// Interaction state
|
||||
private bool _isPointerCaptured;
|
||||
private Point _lastPointerPoint;
|
||||
private DragMode _dragMode = DragMode.None;
|
||||
private int _activeSegmentIndex = -1;
|
||||
private bool _isSplitModifierActive;
|
||||
|
||||
// Throttle invalidation during drag
|
||||
private DateTime _lastInvalidate = DateTime.MinValue;
|
||||
private readonly TimeSpan _invalidateThrottle = TimeSpan.FromMilliseconds(16); // ~60Hz
|
||||
|
||||
public TimelinePreviewSlider()
|
||||
{
|
||||
Focusable = true;
|
||||
Height = _timelineHeight;
|
||||
ClipToBounds = true;
|
||||
|
||||
// Use property change override instead of GetObservable.Subscribe to avoid IObserver compile issues.
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerMoved += OnPointerMoved;
|
||||
PointerReleased += OnPointerReleased;
|
||||
PointerCaptureLost += OnPointerCaptureLost;
|
||||
KeyDown += OnKeyDown;
|
||||
KeyUp += OnKeyUp;
|
||||
}
|
||||
|
||||
// Override to detect ViewModel property changes
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == ViewModelProperty)
|
||||
{
|
||||
OnViewModelChanged((JobViewModel?)change.NewValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnViewModelChanged(JobViewModel? vm)
|
||||
{
|
||||
UnsubscribeFromViewModel();
|
||||
_previewCache.Clear();
|
||||
CancelAllPreviewLoads();
|
||||
|
||||
if (vm != null)
|
||||
{
|
||||
_segmentsSubscription = SubscribeToSegments(vm.Segments);
|
||||
vm.PropertyChanged += OnVmPropertyChanged;
|
||||
}
|
||||
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
|
||||
private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(JobViewModel.SliderLiveValue))
|
||||
{
|
||||
if (_isInternalSliderUpdate)
|
||||
return;
|
||||
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private IDisposable SubscribeToSegments(ObservableCollection<Segment> segments)
|
||||
{
|
||||
NotifyCollectionChangedEventHandler handler = (s, e) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => {
|
||||
InvalidateVisual();
|
||||
}, DispatcherPriority.Background);
|
||||
};
|
||||
segments.CollectionChanged += handler;
|
||||
return Disposable.Create(() => segments.CollectionChanged -= handler);
|
||||
}
|
||||
|
||||
private void UnsubscribeFromViewModel()
|
||||
{
|
||||
if (_currentVm != null)
|
||||
_currentVm.PropertyChanged -= OnVmPropertyChanged;
|
||||
|
||||
_currentVm = null;
|
||||
|
||||
_segmentsSubscription?.Dispose();
|
||||
_segmentsSubscription = null;
|
||||
}
|
||||
|
||||
private void CancelAllPreviewLoads()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var cts in _previewLoadCts.Values)
|
||||
{
|
||||
try { cts.Cancel(); } catch { }
|
||||
}
|
||||
_previewLoadCts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext dc)
|
||||
{
|
||||
base.Render(dc);
|
||||
|
||||
var vm = ViewModel;
|
||||
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||
|
||||
// Background
|
||||
dc.FillRectangle(Brushes.Black, bounds);
|
||||
|
||||
DrawContinuousPreviewStrip(dc);
|
||||
DrawGapOverlays(dc);
|
||||
DrawOverlongSegmentOverlays(dc);
|
||||
|
||||
if (vm == null || vm.DurationSeconds <= 0 || vm.Segments.Count == 0)
|
||||
{
|
||||
// draw empty ruler
|
||||
DrawRuler(dc, 0, vm?.DurationSeconds ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw segments and previews
|
||||
for (int i = 0; i < vm.Segments.Count; i++)
|
||||
{
|
||||
var seg = vm.Segments[i];
|
||||
var segRect = SegmentRectFor(seg);
|
||||
if (segRect.Width <= 0) continue;
|
||||
|
||||
// Segment background
|
||||
var segBrush = new Pen(SegmentFill ?? Brushes.DimGray, 1);
|
||||
var segRoundedRect = new Rect(segRect.X, segRect.Y + (Bounds.Height - _segmentBarHeight) / 2, segRect.Width, _segmentBarHeight);
|
||||
var geom = new StreamGeometry();
|
||||
using (var ctx = geom.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(segRoundedRect.X, segRoundedRect.Y), true);
|
||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y));
|
||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y + segRoundedRect.Height));
|
||||
ctx.LineTo( new Point(segRoundedRect.X, segRoundedRect.Y + segRoundedRect.Height));
|
||||
ctx.EndFigure(true);
|
||||
}
|
||||
dc.DrawGeometry(null, segBrush, geom);
|
||||
}
|
||||
|
||||
// Draw markers on top
|
||||
for (int i = 0; i < vm.Segments.Count; i++)
|
||||
{
|
||||
var seg = vm.Segments[i];
|
||||
DrawMarker(dc, seg.Start, true);
|
||||
DrawMarker(dc, seg.End, false);
|
||||
}
|
||||
|
||||
// Draw current position indicator
|
||||
DrawPositionIndicator(dc, vm.SliderLiveValue);
|
||||
|
||||
// Draw ruler
|
||||
DrawRuler(dc, 0, vm.DurationSeconds);
|
||||
}
|
||||
|
||||
private void DrawRuler(DrawingContext dc, double startSec, double endSec)
|
||||
{
|
||||
var height = Bounds.Height;
|
||||
var y = height - 18;
|
||||
var pen = new Pen(Brushes.Gray, 1);
|
||||
dc.DrawLine(pen, new Point(0, y), new Point(Bounds.Width, y));
|
||||
|
||||
if (ViewModel == null || ViewModel.DurationSeconds <= 0) return;
|
||||
|
||||
var totalSec = ViewModel.DurationSeconds;
|
||||
var approxTicks = Math.Max(2, (int)(Bounds.Width / 100));
|
||||
var tickSec = Math.Max(1.0, totalSec / approxTicks);
|
||||
|
||||
for (double t = 0; t <= totalSec; t += tickSec)
|
||||
{
|
||||
var x = SecondsToPixel(t);
|
||||
dc.DrawLine(pen, new Point(x, y), new Point(x, y - 6));
|
||||
var text = FormatTime(t);
|
||||
var textBrush = Brushes.LightGray; // or new SolidColorBrush(Color.Parse("#FFCCCCCC"));
|
||||
var ft = new FormattedText(
|
||||
text,
|
||||
CultureInfo.CurrentUICulture,
|
||||
FlowDirection.LeftToRight,
|
||||
Typeface.Default,
|
||||
12,
|
||||
textBrush);
|
||||
|
||||
dc.DrawText(ft, new Point(x + 2, y - 18));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawContinuousPreviewStrip(DrawingContext dc)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null || vm.DurationSeconds <= 0)
|
||||
return;
|
||||
|
||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
||||
double stripHeight = _segmentBarHeight;
|
||||
|
||||
double currentX = 0;
|
||||
double endX = Bounds.Width;
|
||||
|
||||
var bmp = GetPreview(0);
|
||||
var noPreviewAvailable = bmp == null;
|
||||
if (bmp == null)
|
||||
{
|
||||
StartPreviewLoad(0);
|
||||
return;
|
||||
}
|
||||
|
||||
var previewScale = (double)stripHeight / bmp.PixelSize.Height;
|
||||
var previewTileWidth = bmp.PixelSize.Width * previewScale;
|
||||
|
||||
using (dc.PushClip(new Rect(0, stripY, Bounds.Width, stripHeight)))
|
||||
{
|
||||
while (currentX < endX)
|
||||
{
|
||||
double posSec = PixelToSeconds(currentX);
|
||||
if (posSec < 0) posSec = 0;
|
||||
if (posSec > vm.DurationSeconds) posSec = vm.DurationSeconds;
|
||||
|
||||
bmp = GetPreview(posSec);
|
||||
|
||||
if (bmp == null)
|
||||
{
|
||||
StartPreviewLoad(posSec);
|
||||
|
||||
// advance by estimated width
|
||||
currentX += previewTileWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
// scale full frame to strip height
|
||||
double scale = stripHeight / bmp.PixelSize.Height;
|
||||
double tileWidth = bmp.PixelSize.Width * scale;
|
||||
|
||||
var src = new Rect(0, 0, bmp.PixelSize.Width, bmp.PixelSize.Height);
|
||||
var dst = new Rect(currentX, stripY, tileWidth, stripHeight);
|
||||
|
||||
dc.DrawImage(bmp, src, dst);
|
||||
|
||||
currentX += tileWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawGapOverlays(DrawingContext dc)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null || vm.Segments.Count == 0)
|
||||
return;
|
||||
|
||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
||||
double stripHeight = _segmentBarHeight;
|
||||
|
||||
var gapBrush = new SolidColorBrush(Color.FromArgb(190, 80, 80, 80));
|
||||
|
||||
double lastEnd = 0;
|
||||
|
||||
for (int i = 0; i < vm.Segments.Count; i++)
|
||||
{
|
||||
var seg = vm.Segments[i];
|
||||
|
||||
if (seg.Start > lastEnd)
|
||||
{
|
||||
double gapLeft = SecondsToPixel(lastEnd);
|
||||
double gapRight = SecondsToPixel(seg.Start);
|
||||
double w = gapRight - gapLeft;
|
||||
|
||||
if (w > 0)
|
||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
||||
}
|
||||
|
||||
lastEnd = seg.End;
|
||||
}
|
||||
|
||||
// tail gap
|
||||
if (lastEnd < vm.DurationSeconds)
|
||||
{
|
||||
double gapLeft = SecondsToPixel(lastEnd);
|
||||
double gapRight = SecondsToPixel(vm.DurationSeconds);
|
||||
double w = gapRight - gapLeft;
|
||||
|
||||
if (w > 0)
|
||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawOverlongSegmentOverlays(DrawingContext dc)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null || vm.Segments.Count == 0)
|
||||
return;
|
||||
|
||||
if (vm.OverrideTargetDuration <= 0)
|
||||
return;
|
||||
|
||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
||||
double stripHeight = _segmentBarHeight;
|
||||
|
||||
var overBrush = new SolidColorBrush(Color.FromArgb(128, 255, 0, 0)); // 50% red
|
||||
|
||||
foreach (var seg in vm.Segments)
|
||||
{
|
||||
double length = seg.End - seg.Start;
|
||||
if (length <= vm.OverrideTargetDuration)
|
||||
continue;
|
||||
|
||||
double left = SecondsToPixel(seg.Start);
|
||||
double right = SecondsToPixel(seg.End);
|
||||
double w = right - left;
|
||||
|
||||
if (w > 0)
|
||||
dc.FillRectangle(overBrush, new Rect(left, stripY, w, stripHeight));
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap? GetPreview(double pos)
|
||||
{
|
||||
var key = PreviewCacheKey(pos);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_previewCache.TryGet(key, out var bmp);
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartPreviewLoad(double pos)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null)
|
||||
return;
|
||||
|
||||
var key = PreviewCacheKey(pos);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_previewLoadCts.ContainsKey(key) || _previewCache.ContainsKey(key))
|
||||
return;
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_previewLoadCts[key] = cts;
|
||||
|
||||
// Run an async loader on threadpool
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// call host-provided async GetPreview
|
||||
var bmp = await vm.GetThumbnail(pos).ConfigureAwait(false);
|
||||
|
||||
if (bmp != null && !cts.IsCancellationRequested)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_previewCache.Add(key, bmp);
|
||||
_previewLoadCts.Remove(key);
|
||||
}
|
||||
|
||||
// notify UI thread to redraw
|
||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_previewLoadCts.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
||||
}
|
||||
}, cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private string PreviewCacheKey(double pos) => $"{pos:F3}";
|
||||
|
||||
private Rect SegmentRectFor(Segment seg)
|
||||
{
|
||||
var left = SecondsToPixel(seg.Start);
|
||||
var right = SecondsToPixel(seg.End);
|
||||
var y = 0;
|
||||
return new Rect(left, y, Math.Max(0, right - left), Bounds.Height);
|
||||
}
|
||||
|
||||
private double SecondsToPixel(double seconds)
|
||||
{
|
||||
return seconds * PixelsPerSecond;
|
||||
}
|
||||
|
||||
private double PixelToSeconds(double px)
|
||||
{
|
||||
return px / PixelsPerSecond;
|
||||
}
|
||||
|
||||
private void DrawMarker(DrawingContext dc, double seconds, bool isStart)
|
||||
{
|
||||
var x = SecondsToPixel(seconds);
|
||||
var top = (Bounds.Height - _segmentBarHeight) / 2 - _markerTriangleSize - 2;
|
||||
var lineTop = top + _markerTriangleSize + 2;
|
||||
var lineBottom = lineTop + _markerLineHeight;
|
||||
|
||||
double midY = top + _markerTriangleSize / 2.0;
|
||||
|
||||
var tri = new StreamGeometry();
|
||||
using (var ctx = tri.Open())
|
||||
{
|
||||
if (isStart)
|
||||
{
|
||||
// segment is to the right -> triangle points right
|
||||
var vTop = new Point(x, top);
|
||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
||||
var point = new Point(x + _markerTriangleSize, midY);
|
||||
|
||||
ctx.BeginFigure(point, true);
|
||||
ctx.LineTo(vBottom);
|
||||
ctx.LineTo(vTop);
|
||||
ctx.EndFigure(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// segment is to the left -> triangle points left
|
||||
var vTop = new Point(x, top);
|
||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
||||
var point = new Point(x - _markerTriangleSize, midY);
|
||||
|
||||
ctx.BeginFigure(point, true);
|
||||
ctx.LineTo(vTop);
|
||||
ctx.LineTo(vBottom);
|
||||
ctx.EndFigure(true);
|
||||
}
|
||||
}
|
||||
|
||||
dc.DrawGeometry(MarkerStroke ?? Brushes.White, null, tri);
|
||||
|
||||
var pen = new Pen(MarkerStroke ?? Brushes.White, _markerLineWidth);
|
||||
dc.DrawLine(pen, new Point(x, lineTop), new Point(x, lineBottom));
|
||||
}
|
||||
|
||||
private void DrawPositionIndicator(DrawingContext dc, double seconds)
|
||||
{
|
||||
var x = SecondsToPixel(seconds);
|
||||
var pen = new Pen(Brushes.Red, 1.5);
|
||||
dc.DrawLine(pen, new Point(x, 0), new Point(x, Bounds.Height));
|
||||
}
|
||||
|
||||
private string FormatTime(double seconds)
|
||||
{
|
||||
var ts = TimeSpan.FromSeconds(Math.Max(0, seconds));
|
||||
if (ts.TotalHours >= 1)
|
||||
return $"{(int)ts.TotalHours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}";
|
||||
return $"{ts.Minutes:D2}:{ts.Seconds:D2}";
|
||||
}
|
||||
|
||||
// Interaction
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
|
||||
var p = e.GetPosition(this);
|
||||
_lastPointerPoint = p;
|
||||
_isSplitModifierActive = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
var hit = HitTestAtPoint(p);
|
||||
if (hit.Type == HitType.StartMarker)
|
||||
{
|
||||
BeginDrag(DragMode.DragStartMarker, hit.SegmentIndex, e);
|
||||
}
|
||||
else if (hit.Type == HitType.EndMarker)
|
||||
{
|
||||
BeginDrag(DragMode.DragEndMarker, hit.SegmentIndex, e);
|
||||
}
|
||||
else
|
||||
{
|
||||
// any other hit just moves playhead
|
||||
SetPlayheadFromPoint(p);
|
||||
}
|
||||
|
||||
if (_isSplitModifierActive && hit.Type == HitType.SegmentBody)
|
||||
{
|
||||
var sec = PixelToSeconds(p.X);
|
||||
TrySplitSegmentAt(hit.SegmentIndex, sec);
|
||||
}
|
||||
|
||||
e.Pointer.Capture(this);
|
||||
_isPointerCaptured = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isPointerCaptured) return;
|
||||
var p = e.GetPosition(this);
|
||||
|
||||
if (_dragMode == DragMode.None)
|
||||
{
|
||||
_lastPointerPoint = p;
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = ViewModel;
|
||||
if (vm == null) return;
|
||||
|
||||
var sec = PixelToSeconds(p.X);
|
||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
||||
|
||||
switch (_dragMode)
|
||||
{
|
||||
case DragMode.DragStartMarker:
|
||||
MoveSegmentStart(_activeSegmentIndex, sec);
|
||||
break;
|
||||
case DragMode.DragEndMarker:
|
||||
MoveSegmentEnd(_activeSegmentIndex, sec);
|
||||
break;
|
||||
}
|
||||
|
||||
_isInternalSliderUpdate = true;
|
||||
vm.SliderLiveValue = sec;
|
||||
_isInternalSliderUpdate = false;
|
||||
|
||||
ThrottledInvalidate();
|
||||
_lastPointerPoint = p;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (_isPointerCaptured)
|
||||
{
|
||||
e.Pointer.Capture(null);
|
||||
_isPointerCaptured = false;
|
||||
}
|
||||
_dragMode = DragMode.None;
|
||||
_activeSegmentIndex = -1;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
_isPointerCaptured = false;
|
||||
_dragMode = DragMode.None;
|
||||
_activeSegmentIndex = -1;
|
||||
}
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
||||
{
|
||||
_isSplitModifierActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Delete)
|
||||
{
|
||||
TryDeleteCurrentSegment();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
||||
_isSplitModifierActive = false;
|
||||
}
|
||||
|
||||
private void TryDeleteCurrentSegment()
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null)
|
||||
return;
|
||||
|
||||
double pos = vm.SliderLiveValue;
|
||||
|
||||
int idx = -1;
|
||||
for (int i = 0; i < vm.Segments.Count; i++)
|
||||
{
|
||||
var s = vm.Segments[i];
|
||||
if (pos >= s.Start && pos <= s.End)
|
||||
{
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (idx == -1)
|
||||
return;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (idx >= 0 && idx < vm.Segments.Count)
|
||||
vm.Segments.RemoveAt(idx);
|
||||
|
||||
if (vm.Segments.Count == 0)
|
||||
vm.GenerateSegments();
|
||||
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
|
||||
private void BeginDrag(DragMode mode, int segmentIndex, PointerPressedEventArgs e)
|
||||
{
|
||||
_dragMode = mode;
|
||||
_activeSegmentIndex = segmentIndex;
|
||||
_lastPointerPoint = e.GetPosition(this);
|
||||
}
|
||||
|
||||
private void ThrottledInvalidate()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastInvalidate > _invalidateThrottle)
|
||||
{
|
||||
_lastInvalidate = now;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPlayheadFromPoint(Point p)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null)
|
||||
return;
|
||||
|
||||
double sec = PixelToSeconds(p.X);
|
||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
||||
|
||||
_isInternalSliderUpdate = true;
|
||||
vm.SliderLiveValue = sec;
|
||||
_isInternalSliderUpdate = false;
|
||||
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private void MoveSegmentStart(int index, double newStart)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null) return;
|
||||
if (index < 0 || index >= vm.Segments.Count) return;
|
||||
|
||||
var seg = vm.Segments[index];
|
||||
var min = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
||||
var max = seg.End - 0.001;
|
||||
var clamped = Math.Max(min, Math.Min(max, newStart));
|
||||
if (Math.Abs(clamped - seg.Start) < 1e-6) return;
|
||||
|
||||
var newSeg = seg with { Start = clamped };
|
||||
vm.Segments[index] = newSeg;
|
||||
}
|
||||
|
||||
private void MoveSegmentEnd(int index, double newEnd)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null) return;
|
||||
if (index < 0 || index >= vm.Segments.Count) return;
|
||||
|
||||
var seg = vm.Segments[index];
|
||||
var min = seg.Start + 0.001;
|
||||
var max = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
||||
var clamped = Math.Max(min, Math.Min(max, newEnd));
|
||||
if (Math.Abs(clamped - seg.End) < 1e-6) return;
|
||||
|
||||
var newSeg = seg with { End = clamped };
|
||||
vm.Segments[index] = newSeg;
|
||||
}
|
||||
|
||||
private void MoveSegmentByDelta(int index, double deltaSec)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null) return;
|
||||
if (index < 0 || index >= vm.Segments.Count) return;
|
||||
|
||||
var seg = vm.Segments[index];
|
||||
var leftLimit = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
||||
var rightLimit = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
||||
|
||||
var newStart = seg.Start + deltaSec;
|
||||
var newEnd = seg.End + deltaSec;
|
||||
|
||||
// clamp so segment stays within neighbors
|
||||
var segLength = seg.End - seg.Start;
|
||||
if (newStart < leftLimit)
|
||||
{
|
||||
newStart = leftLimit;
|
||||
newEnd = newStart + segLength;
|
||||
}
|
||||
if (newEnd > rightLimit)
|
||||
{
|
||||
newEnd = rightLimit;
|
||||
newStart = newEnd - segLength;
|
||||
}
|
||||
|
||||
// apply
|
||||
vm.Segments[index] = seg with { Start = newStart, End = newEnd };
|
||||
}
|
||||
|
||||
private void TrySplitSegmentAt(int index, double sec)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null) return;
|
||||
if (index < 0 || index >= vm.Segments.Count) return;
|
||||
|
||||
var seg = vm.Segments[index];
|
||||
if (sec <= seg.Start + 0.001 || sec >= seg.End - 0.001) return;
|
||||
|
||||
var left = seg with { End = sec };
|
||||
var right = seg with { Start = sec };
|
||||
vm.Segments[index] = left;
|
||||
vm.Segments.Insert(index + 1, right);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private HitResult HitTestAtPoint(Point p)
|
||||
{
|
||||
var vm = ViewModel;
|
||||
if (vm == null)
|
||||
return new HitResult(HitType.None, -1);
|
||||
|
||||
double topRegion = Bounds.Height / 4.0;
|
||||
|
||||
for (int i = 0; i < vm.Segments.Count; i++)
|
||||
{
|
||||
var seg = vm.Segments[i];
|
||||
double startX = SecondsToPixel(seg.Start);
|
||||
double endX = SecondsToPixel(seg.End);
|
||||
|
||||
// marker hit only in top 1/4
|
||||
if (p.Y >= 0 && p.Y <= topRegion)
|
||||
{
|
||||
// start marker triangle footprint: [startX .. startX + size]
|
||||
if (p.X >= startX && p.X <= startX + _markerTriangleSize)
|
||||
return new HitResult(HitType.StartMarker, i);
|
||||
|
||||
// end marker triangle footprint: [endX - size .. endX]
|
||||
if (p.X >= endX - _markerTriangleSize && p.X <= endX)
|
||||
return new HitResult(HitType.EndMarker, i);
|
||||
}
|
||||
|
||||
// segment body (no drag, only click/split)
|
||||
if (p.X >= startX && p.X <= endX && p.Y >= 0 && p.Y <= Bounds.Height)
|
||||
return new HitResult(HitType.SegmentBody, i);
|
||||
}
|
||||
|
||||
return new HitResult(HitType.Gap, -1);
|
||||
}
|
||||
|
||||
// IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
UnsubscribeFromViewModel();
|
||||
CancelAllPreviewLoads();
|
||||
_previewCache.Clear();
|
||||
}
|
||||
|
||||
// Helpers and small types
|
||||
|
||||
private enum DragMode
|
||||
{
|
||||
None,
|
||||
DragStartMarker,
|
||||
DragEndMarker
|
||||
}
|
||||
|
||||
private enum HitType
|
||||
{
|
||||
None,
|
||||
StartMarker,
|
||||
EndMarker,
|
||||
SegmentBody,
|
||||
Gap
|
||||
}
|
||||
|
||||
private readonly struct HitResult
|
||||
{
|
||||
public HitType Type { get; }
|
||||
public int SegmentIndex { get; }
|
||||
public HitResult(HitType type, int idx) { Type = type; SegmentIndex = idx; }
|
||||
}
|
||||
|
||||
// Simple LRU cache for Bitmaps
|
||||
private class LruCache<TKey, TValue> where TKey : notnull where TValue : class
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly Dictionary<TKey, LinkedListNode<(TKey key, TValue value)>> _map;
|
||||
private readonly LinkedList<(TKey key, TValue value)> _list;
|
||||
private readonly object _sync = new();
|
||||
|
||||
public LruCache(int capacity)
|
||||
{
|
||||
_capacity = Math.Max(1, capacity);
|
||||
_map = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>();
|
||||
_list = new LinkedList<(TKey, TValue)>();
|
||||
}
|
||||
|
||||
public bool TryGet(TKey key, out TValue? value)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var node))
|
||||
{
|
||||
value = node.Value.value;
|
||||
_list.Remove(node);
|
||||
_list.AddFirst(node);
|
||||
return true;
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(TKey key, TValue value)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var node))
|
||||
{
|
||||
_list.Remove(node);
|
||||
_map.Remove(key);
|
||||
}
|
||||
|
||||
var newNode = new LinkedListNode<(TKey, TValue)>((key, value));
|
||||
_list.AddFirst(newNode);
|
||||
_map[key] = newNode;
|
||||
|
||||
if (_map.Count > _capacity)
|
||||
{
|
||||
var last = _list.Last!;
|
||||
_map.Remove(last.Value.key);
|
||||
_list.RemoveLast();
|
||||
if (last.Value.value is IDisposable d)
|
||||
{
|
||||
try { d.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsKey(TKey key)
|
||||
{
|
||||
lock (_sync) return _map.ContainsKey(key);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var node in _list)
|
||||
{
|
||||
if (node.value is IDisposable d)
|
||||
{
|
||||
try { d.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
_map.Clear();
|
||||
_list.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disposable helper
|
||||
private static class Disposable
|
||||
{
|
||||
public static IDisposable Create(Action dispose)
|
||||
{
|
||||
return new AnonymousDisposable(dispose);
|
||||
}
|
||||
|
||||
private sealed class AnonymousDisposable : IDisposable
|
||||
{
|
||||
private Action? _dispose;
|
||||
public AnonymousDisposable(Action dispose) { _dispose = dispose; }
|
||||
public void Dispose() { var d = Interlocked.Exchange(ref _dispose, null); d?.Invoke(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Splitter_UI.Converters;
|
||||
|
||||
public sealed class ActionToIconConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
var p = System.Convert.ToInt32(value);
|
||||
|
||||
return p == 0
|
||||
? "\uf125" // FA7 crop
|
||||
: "\uf2f1" // FA7 rotate
|
||||
;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Splitter_UI.Converters;
|
||||
|
||||
public sealed class BoolInvertConverter : IValueConverter
|
||||
{
|
||||
public static readonly BoolInvertConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Splitter_UI.Converters;
|
||||
|
||||
public sealed class ConsoleColorToBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is ConsoleColor c)
|
||||
return new SolidColorBrush(ToColor(c));
|
||||
|
||||
return Brushes.White;
|
||||
}
|
||||
|
||||
private static Color ToColor(ConsoleColor c) =>
|
||||
c switch
|
||||
{
|
||||
ConsoleColor.Black => Colors.Black,
|
||||
ConsoleColor.DarkBlue => Colors.DarkBlue,
|
||||
ConsoleColor.DarkGreen => Colors.DarkGreen,
|
||||
ConsoleColor.DarkCyan => Colors.DarkCyan,
|
||||
ConsoleColor.DarkRed => Colors.DarkRed,
|
||||
ConsoleColor.DarkMagenta => Colors.DarkMagenta,
|
||||
ConsoleColor.DarkYellow => Colors.Olive,
|
||||
ConsoleColor.Gray => Colors.Gray,
|
||||
ConsoleColor.DarkGray => Colors.DarkGray,
|
||||
ConsoleColor.Blue => Colors.Blue,
|
||||
ConsoleColor.Green => Colors.Green,
|
||||
ConsoleColor.Cyan => Colors.Cyan,
|
||||
ConsoleColor.Red => Colors.Red,
|
||||
ConsoleColor.Magenta => Colors.Magenta,
|
||||
ConsoleColor.Yellow => Colors.Yellow,
|
||||
ConsoleColor.White => Colors.White,
|
||||
_ => Colors.White
|
||||
};
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Splitter_UI.Converters;
|
||||
|
||||
public sealed class RotationAngleToIconConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
90 => "\uf2f9", // FA7 (fa-rotate-left / fa-arrow-rotate-left / fa-undo)
|
||||
180 => "\uf2f1", // FA7 (fa-sync-alt)
|
||||
270 => "\uf2ea", // FA7 (fa-rotate-right / fa-arrow-rotate-right / fa-redo)
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Splitter_UI.Converters;
|
||||
|
||||
public sealed class ZeroToBoolConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> (value is int i && i == 0);
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
global using OpenCvSharp;
|
||||
global using Size = Avalonia.Size;
|
||||
global using Rect = Avalonia.Rect;
|
||||
|
||||
global using splitter;
|
||||
global using splitter.tui;
|
||||
global using splitter.algo;
|
||||
global using splitter.probe;
|
||||
|
||||
global using Splitter_UI.Models;
|
||||
global using Splitter_UI.Services;
|
||||
global using Splitter_UI.ViewModels;
|
||||
@ -1,15 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Splitter_UI.Models;
|
||||
|
||||
public partial class ParameterEntry : ObservableObject
|
||||
{
|
||||
public string Key { get; }
|
||||
[ObservableProperty] private string _value;
|
||||
|
||||
public ParameterEntry(string key, string value)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
namespace Splitter_UI.Models;
|
||||
|
||||
public class PreviewData
|
||||
{
|
||||
public Avalonia.Media.Imaging.Bitmap? Frame { get; }
|
||||
public IReadOnlyList<DetectedPerson> DetectedBoxes { get; }
|
||||
public Rect? CropRect { get; }
|
||||
public Point2f GravitateTo { get; }
|
||||
public TimeSpan Position { get; }
|
||||
public int? Rotate { get; }
|
||||
|
||||
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<DetectedPerson> boxes, Rect? crop, Point2f gravitateTo, TimeSpan position, int? rotate)
|
||||
{
|
||||
Frame = frame;
|
||||
DetectedBoxes = boxes;
|
||||
CropRect = crop;
|
||||
GravitateTo = gravitateTo;
|
||||
Position = position;
|
||||
Rotate = rotate;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Splitter_UI;
|
||||
|
||||
internal sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
private static ServiceCollection ConfigureServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var logPaveVM = new LogPaneViewModel();
|
||||
// ViewModels
|
||||
services.AddTransient<MainViewModel>();
|
||||
services.AddTransient<FileListViewModel>();
|
||||
services.AddTransient<PreviewPaneViewModel>();
|
||||
services.AddTransient<InspectorPaneViewModel>();
|
||||
services.AddSingleton<StatusBarViewModel>();
|
||||
services.AddSingleton<ProgressViewModel>();
|
||||
services.AddSingleton<LogPaneViewModel>(logPaveVM);
|
||||
services.AddSingleton<ILogService>(logPaveVM);
|
||||
|
||||
// splitter services
|
||||
services.AddSingleton<UltraFaceDetector>();
|
||||
services.AddSingleton<YoloV10ObjectDetector>();
|
||||
services.AddSingleton<DummyDetector>();
|
||||
services.AddSingleton<OSNetEmbeddingExtractor>();
|
||||
services.AddSingleton<IObjectTracker, ObjectTracker>();
|
||||
services.AddSingleton<IBufferPool, BufferPool>();
|
||||
services.AddSingleton<IMatToBitmapConverter, MatToBitmapConverter>();
|
||||
services.AddKeyedSingleton<IObjectDetector>("face", (x,_) => new SingleThreadedDetector<UltraFaceDetector>(x.GetRequiredService<UltraFaceDetector>()));
|
||||
services.AddKeyedSingleton<IObjectDetector>("body", (x,_) => new SingleThreadedDetector<YoloV10ObjectDetector>(x.GetRequiredService<YoloV10ObjectDetector>()));
|
||||
services.AddKeyedSingleton<IObjectDetector>("none", (x,_) => new SingleThreadedDetector<DummyDetector>(x.GetRequiredService<DummyDetector>()));
|
||||
services.AddSingleton<IEmbeddingExtractor>(x => new SingleThreadedEmbeddingExtractor<OSNetEmbeddingExtractor>(x.GetRequiredService<OSNetEmbeddingExtractor>()));
|
||||
services.AddSingleton<Func<string, IObjectDetector>>(x => detectorName => x.GetKeyedService<IObjectDetector>(detectorName) ?? new DummyDetector());
|
||||
services.AddSingleton<Func<string, IObjectTracker>>(x => detectorName =>
|
||||
{
|
||||
var detectorFactory = x.GetRequiredService<Func<string, IObjectDetector>>();
|
||||
var extractor = x.GetRequiredService<IEmbeddingExtractor>();
|
||||
return new ObjectTracker(detectorFactory(detectorName), extractor);
|
||||
});
|
||||
services.AddSingleton<ILogger, GlobalLogger>();
|
||||
services.AddSingleton<IJobProcessor, JobProcessor>();
|
||||
|
||||
// Domain services (your pipeline)
|
||||
services.AddTransient<IFileProbeService, FileProbeService>();
|
||||
services.AddTransient<IThumbnailService, ThumbnailService>();
|
||||
services.AddSingleton<IAutoDecisionService, AutoDecisionService>();
|
||||
|
||||
services.AddSingleton<IFileJobFactory, FileJobFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
var services = ConfigureServices();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
return AppBuilder.Configure<App>(() => new App(provider))
|
||||
.UsePlatformDetect()
|
||||
.With(new FontManagerOptions
|
||||
{
|
||||
FontFallbacks = new[]
|
||||
{
|
||||
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free") },
|
||||
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free Solid") }
|
||||
}
|
||||
})
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
# Splitter-UI
|
||||
|
||||
|
||||
A compact, modern desktop front-end for Splitter (the high-performance FFmpeg-based video splitter). Built with Avalonia 12 and
|
||||
targeting .NET 10, this project provides a native-feeling cross-platform UI to configure splitting jobs, preview smart
|
||||
crops, and drive the Splitter CLI backend.
|
||||
|
||||
## Overview
|
||||
|
||||
Splitter-UI wraps the core Splitter pipeline (the referenced splitter-cli project) and exposes common workflow tasks
|
||||
through an accessible interface: input selection, output naming, duration and crop controls, rotation options, detector settings,
|
||||
and a job monitor with progress and ETA. For the full command-line feature set and the implementation rationale, see the
|
||||
repository root README (../README.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
|
||||
## 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.
|
||||
@ -1,75 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
|
||||
{
|
||||
public void ApplyAutoDecisions(JobViewModel job, CancellationToken token)
|
||||
{
|
||||
Task.Run(() => Detect(job, token));
|
||||
}
|
||||
|
||||
private async Task Detect(JobViewModel job, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
job.GravitateTo = new(0.5f, 0.5f);
|
||||
job.OverrideTargetDuration = 58.0;
|
||||
job.Mask = "[NAME]_seg[NN].[EXT]";
|
||||
job.OutputFolder = Path.Combine(Path.GetDirectoryName(job.InputFile)!, "splitter");
|
||||
|
||||
job.Probe = await _fileProbe.ProbeAsync(job.InputFile, token);
|
||||
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
|
||||
|
||||
if (job.Probe.Width > job.Probe.Height)
|
||||
{
|
||||
job.Detect = "body";
|
||||
job.Rotate = 0;
|
||||
|
||||
CalculateCrop(job);
|
||||
}
|
||||
//else
|
||||
//{
|
||||
// var sampler = new VideoRotationSampler(null);
|
||||
// job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration, token);
|
||||
// job.Detect = job.Rotate == 0 ? null : "body";
|
||||
//}
|
||||
|
||||
_log.LogInfo(job.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError($"Error creating thumbnail for {Path.GetFileName(job.InputFile)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CalculateCrop(JobViewModel job)
|
||||
{
|
||||
var targetAR = (float)CommandLine.DefaultW / CommandLine.DefaultH;
|
||||
var pixelAspect = job.Probe!.Sar.X / job.Probe.Sar.Y;
|
||||
|
||||
var srcW = job.Probe.Width * pixelAspect;
|
||||
float srcH = job.Probe.Height;
|
||||
var srcAR = srcW / srcH;
|
||||
|
||||
var cropH = srcH;
|
||||
var cropW = cropH * targetAR;
|
||||
|
||||
if (cropW > srcW)
|
||||
{
|
||||
cropW = srcW;
|
||||
cropH = cropW / targetAR;
|
||||
}
|
||||
|
||||
var x = (srcW - cropW) * 0.5f;
|
||||
var y = (srcH - cropH) * 0.5f;
|
||||
|
||||
var invPixelAspect = 1f / pixelAspect;
|
||||
|
||||
var cropW_px = cropW * invPixelAspect;
|
||||
var cropH_px = cropH;
|
||||
|
||||
var x_px = x * invPixelAspect;
|
||||
var y_px = y;
|
||||
|
||||
job.CropText = $"{(int)MathF.Round(cropW_px)},{(int)MathF.Round(cropH_px)}";
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public static class AvaloniaBitmapExtensions
|
||||
{
|
||||
public static Mat ToMatContinuous(this Bitmap bmp)
|
||||
{
|
||||
var w = bmp.PixelSize.Width;
|
||||
var h = bmp.PixelSize.Height;
|
||||
var stride = w * 4;
|
||||
var size = h * stride;
|
||||
|
||||
var buffer = new byte[size];
|
||||
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
|
||||
try
|
||||
{
|
||||
bmp.CopyPixels(
|
||||
new PixelRect(0, 0, w, h),
|
||||
handle.AddrOfPinnedObject(),
|
||||
size,
|
||||
stride);
|
||||
|
||||
return Mat.FromPixelData(h, w, MatType.CV_8UC4, buffer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
public static Mat ToMatBgrContinuous(this Bitmap bmp)
|
||||
{
|
||||
using var bgra = bmp.ToMatContinuous();
|
||||
var bgr = new Mat();
|
||||
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
|
||||
return bgr;
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class BufferPool : IBufferPool
|
||||
{
|
||||
private readonly int _capacity;
|
||||
|
||||
public sealed class Entry
|
||||
{
|
||||
public readonly int Width;
|
||||
public readonly int Height;
|
||||
public readonly byte[] Bgr;
|
||||
public readonly byte[] Bgra;
|
||||
|
||||
public Entry(int w, int h)
|
||||
{
|
||||
Width = w;
|
||||
Height = h;
|
||||
Bgr = new byte[w * h * 3];
|
||||
Bgra = new byte[w * h * 4];
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Dictionary<(int w, int h), LinkedListNode<Entry>> _map;
|
||||
private readonly LinkedList<Entry> _lru;
|
||||
|
||||
public BufferPool()
|
||||
{
|
||||
_capacity = 8;
|
||||
_map = new Dictionary<(int w, int h), LinkedListNode<Entry>>(_capacity);
|
||||
_lru = new LinkedList<Entry>();
|
||||
}
|
||||
|
||||
public Entry Get(int w, int h)
|
||||
{
|
||||
var key = (w, h);
|
||||
|
||||
if (_map.TryGetValue(key, out var node))
|
||||
{
|
||||
_lru.Remove(node);
|
||||
_lru.AddLast(node);
|
||||
return node.Value;
|
||||
}
|
||||
|
||||
var created = new Entry(w, h);
|
||||
var newNode = new LinkedListNode<Entry>(created);
|
||||
|
||||
_lru.AddLast(newNode);
|
||||
_map[key] = newNode;
|
||||
|
||||
if (_lru.Count > _capacity)
|
||||
{
|
||||
var first = _lru.First!;
|
||||
_lru.RemoveFirst();
|
||||
_map.Remove((first.Value.Width, first.Value.Height));
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public sealed class FileJobFactory : IFileJobFactory
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public FileJobFactory(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
public JobViewModel Create(SingleJob job)
|
||||
{
|
||||
// Resolve a fresh VM + fresh services
|
||||
return ActivatorUtilities.CreateInstance<JobViewModel>(_services, job);
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class FileProbeService : IFileProbeService
|
||||
{
|
||||
public async Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token)
|
||||
{
|
||||
var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false, token), token);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
internal class GlobalLogger(ILogService _logService, StatusBarViewModel _statusBar, ProgressViewModel _progress) : ILogger
|
||||
{
|
||||
public void ClearProgress(string name, int progressLine)
|
||||
{
|
||||
if (progressLine == 0)
|
||||
_statusBar.Percent = 0;
|
||||
else
|
||||
_progress.ClearProgress(name, progressLine-1);
|
||||
}
|
||||
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
|
||||
{
|
||||
if (progressLine == 0)
|
||||
_statusBar.Percent = progress;
|
||||
else
|
||||
_progress.DrawProgress(name, progressLine - 1, progress, eta, speed);
|
||||
}
|
||||
|
||||
public void Log(string prefix, ConsoleColor color, string msg)
|
||||
{
|
||||
_logService.Log(prefix, color, msg);
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IAutoDecisionService
|
||||
{
|
||||
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IBufferPool
|
||||
{
|
||||
BufferPool.Entry Get(int w, int h);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IFileJobFactory
|
||||
{
|
||||
JobViewModel Create(SingleJob job);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IFileProbeService
|
||||
{
|
||||
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface ILogService
|
||||
{
|
||||
void Log(string prefix, ConsoleColor color, string msg);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IMatToBitmapConverter
|
||||
{
|
||||
Bitmap Convert(Mat mat, Bitmap? existing = null);
|
||||
Bitmap Convert(byte[] bgr, int width, int height, Bitmap? existing = null);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public interface IThumbnailService
|
||||
{
|
||||
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null, int? rotateDegree = null);
|
||||
}
|
||||
@ -1,136 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class MatToBitmapConverter(IBufferPool _pool) : IMatToBitmapConverter
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
|
||||
public Bitmap Convert(Mat mat, Bitmap? existing = null)
|
||||
{
|
||||
if (mat.Empty())
|
||||
throw new ArgumentException("Mat is empty.", nameof(mat));
|
||||
|
||||
var w = mat.Width;
|
||||
var h = mat.Height;
|
||||
var channels = mat.Channels();
|
||||
|
||||
if (channels != 3 && channels != 4)
|
||||
throw new NotSupportedException($"Only 3 or 4 channel Mats are supported. Got {channels}.");
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
var entry = _pool.Get(w, h);
|
||||
|
||||
var src = mat;
|
||||
if (!src.IsContinuous())
|
||||
src = src.Clone();
|
||||
|
||||
unsafe
|
||||
{
|
||||
var srcPtr = (byte*)src.DataPointer;
|
||||
var totalBytes = w * h * channels;
|
||||
|
||||
if (channels == 3)
|
||||
{
|
||||
fixed (byte* dstBgr = entry.Bgr)
|
||||
{
|
||||
Buffer.MemoryCopy(srcPtr, dstBgr, entry.Bgr.Length, totalBytes);
|
||||
}
|
||||
|
||||
ConvertBgrToBgra(entry.Bgr, entry.Bgra, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
fixed (byte* dstBgra = entry.Bgra)
|
||||
{
|
||||
Buffer.MemoryCopy(srcPtr, dstBgra, entry.Bgra.Length, totalBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existing is WriteableBitmap wb &&
|
||||
wb.PixelSize.Width == w &&
|
||||
wb.PixelSize.Height == h)
|
||||
{
|
||||
UpdateWriteableBitmap(wb, entry.Bgra, w, h);
|
||||
return wb;
|
||||
}
|
||||
|
||||
return CreateBitmap(entry.Bgra, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
public Bitmap Convert(byte[] bgr, int width, int height, Bitmap? existing = null)
|
||||
{
|
||||
var entry = _pool.Get(width, height);
|
||||
ConvertBgrToBgra(bgr, entry.Bgra, width, height);
|
||||
|
||||
if (existing is WriteableBitmap wb &&
|
||||
wb.PixelSize.Width == width &&
|
||||
wb.PixelSize.Height == height)
|
||||
{
|
||||
UpdateWriteableBitmap(wb, entry.Bgra, width, height);
|
||||
return wb;
|
||||
}
|
||||
|
||||
return CreateBitmap(entry.Bgra, width, height);
|
||||
}
|
||||
|
||||
|
||||
private static void ConvertBgrToBgra(byte[] bgr, byte[] bgra, int width, int height)
|
||||
{
|
||||
var si = 0;
|
||||
var di = 0;
|
||||
var totalPixels = width * height;
|
||||
|
||||
for (var i = 0; i < totalPixels; i++)
|
||||
{
|
||||
bgra[di + 0] = bgr[si + 0];
|
||||
bgra[di + 1] = bgr[si + 1];
|
||||
bgra[di + 2] = bgr[si + 2];
|
||||
bgra[di + 3] = 255;
|
||||
|
||||
si += 3;
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void UpdateWriteableBitmap(WriteableBitmap wb, byte[] bgra, int width, int height)
|
||||
{
|
||||
using var fb = wb.Lock();
|
||||
|
||||
var dstPtr = (byte*)fb.Address;
|
||||
var dstStride = fb.RowBytes;
|
||||
var srcStride = width * 4;
|
||||
|
||||
fixed (byte* srcPtr = bgra)
|
||||
{
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var srcRow = srcPtr + y * srcStride;
|
||||
var dstRow = dstPtr + y * dstStride;
|
||||
|
||||
Buffer.MemoryCopy(srcRow, dstRow, dstStride, srcStride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height)
|
||||
{
|
||||
var stride = width * 4;
|
||||
|
||||
fixed (byte* p = bgra)
|
||||
{
|
||||
return new WriteableBitmap(
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Premul,
|
||||
(nint)p,
|
||||
new PixelSize(width, height),
|
||||
new Vector(96, 96),
|
||||
stride);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetector
|
||||
where T : IObjectDetector
|
||||
{
|
||||
private Lock _lock = new();
|
||||
|
||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _detector.DetectAll(job, frameCont);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if ( _detector is IDisposable d )
|
||||
d.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class SingleThreadedEmbeddingExtractor<T>(IEmbeddingExtractor _extractor) : IEmbeddingExtractor
|
||||
where T : IEmbeddingExtractor
|
||||
{
|
||||
private Lock _lock = new();
|
||||
|
||||
public float[] Extract(Mat frame, OpenCvSharp.Rect box)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _extractor.Extract(frame, box);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_extractor is IDisposable d)
|
||||
d.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Splitter_UI.Services;
|
||||
|
||||
public sealed class ThumbnailService : IThumbnailService
|
||||
{
|
||||
public const int ThumbWidth = 160;
|
||||
public const int ThumbHeight = 90;
|
||||
|
||||
private readonly IMatToBitmapConverter _converter;
|
||||
private readonly IBufferPool _pool;
|
||||
|
||||
private SemaphoreSlim _lock = new(1,1);
|
||||
|
||||
public ThumbnailService(
|
||||
IMatToBitmapConverter converter,
|
||||
IBufferPool pool)
|
||||
{
|
||||
_converter = converter;
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
public async Task<Bitmap?> CreateThumbnailAsync(
|
||||
string file,
|
||||
VideoInfo probe,
|
||||
TimeSpan? skip = null,
|
||||
int? width = null,
|
||||
int? height = null,
|
||||
int? rotateDegree = null)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return await CreateThumbnailInternal(
|
||||
file,
|
||||
probe,
|
||||
skip,
|
||||
width,
|
||||
height,
|
||||
rotateDegree
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Bitmap?> CreateThumbnailInternal(
|
||||
string file,
|
||||
VideoInfo probe,
|
||||
TimeSpan? skip = null,
|
||||
int? width = null,
|
||||
int? height = null,
|
||||
int? rotateDegree = null)
|
||||
{
|
||||
width ??= ThumbWidth;
|
||||
height ??= ThumbHeight;
|
||||
skip ??= TimeSpan.Zero;
|
||||
|
||||
var entry = _pool.Get(width.Value, height.Value);
|
||||
|
||||
var ok = await DecodeFrameAsync(
|
||||
entry.Bgr,
|
||||
file,
|
||||
skip.Value,
|
||||
width.Value,
|
||||
height.Value,
|
||||
rotateDegree
|
||||
);
|
||||
|
||||
if (!ok)
|
||||
return null;
|
||||
|
||||
return _converter.Convert(entry.Bgr, width.Value, height.Value);
|
||||
}
|
||||
|
||||
private static async Task<bool> DecodeFrameAsync(
|
||||
byte[] bgrBuffer,
|
||||
string file,
|
||||
TimeSpan skip,
|
||||
int width,
|
||||
int height,
|
||||
int? rotateDegree)
|
||||
{
|
||||
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
|
||||
|
||||
var args =
|
||||
$"-ss {skip.TotalSeconds} -t 0.1 -i \"{file}\" " +
|
||||
"-an -sn " +
|
||||
$"-vf \"scale={width}:{height}:force_original_aspect_ratio=decrease," +
|
||||
$"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=bgr24{rotationStr}\" " +
|
||||
"-f rawvideo -";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
var p = new Process { StartInfo = psi };
|
||||
p.Start();
|
||||
|
||||
var needed = bgrBuffer.Length;
|
||||
var read = 0;
|
||||
|
||||
using var stdout = p.StandardOutput.BaseStream;
|
||||
|
||||
while (read < needed)
|
||||
{
|
||||
var r = await stdout.ReadAsync(bgrBuffer, read, needed - read);
|
||||
if (r == 0)
|
||||
{
|
||||
TryKill(p);
|
||||
return false;
|
||||
}
|
||||
read += r;
|
||||
}
|
||||
|
||||
TryKill(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryKill(Process p)
|
||||
{
|
||||
try { p.Kill(); } catch { }
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationIcon>Assets/splitter.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\splitter-cli\splitter.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -1,34 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
|
||||
namespace Splitter_UI;
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class FileListViewModel : ObservableObject
|
||||
{
|
||||
private readonly IFileJobFactory _factory;
|
||||
private readonly IAutoDecisionService _autoDecisionService;
|
||||
public ObservableCollection<JobViewModel> Files { get; } = [];
|
||||
public ObservableCollection<JobViewModel> SelectedFiles { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private JobViewModel? _selected;
|
||||
|
||||
public event Action<JobViewModel?>? SelectedFileChanged;
|
||||
|
||||
public FileListViewModel(IFileJobFactory factory, IAutoDecisionService autoDecisionService)
|
||||
{
|
||||
_factory = factory;
|
||||
_autoDecisionService = autoDecisionService;
|
||||
}
|
||||
|
||||
partial void OnSelectedChanged(JobViewModel? value)
|
||||
=> SelectedFileChanged?.Invoke(value);
|
||||
|
||||
[RelayCommand]
|
||||
private void AddFiles(IEnumerable<string> paths)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
// Probe + auto-detect + thumbnail
|
||||
var job = new SingleJob { InputFile = path };
|
||||
var vm = _factory.Create(job);
|
||||
Files.Add(vm);
|
||||
_autoDecisionService.ApplyAutoDecisions(vm, CancellationToken.None);
|
||||
}
|
||||
|
||||
Selected = Files.LastOrDefault();
|
||||
}
|
||||
|
||||
internal void DeleteSelected()
|
||||
{
|
||||
if (SelectedFiles.Any())
|
||||
{
|
||||
var toDelete = SelectedFiles.ToList();
|
||||
foreach (var item in toDelete)
|
||||
Files.Remove(item);
|
||||
}
|
||||
else if ( Selected != null)
|
||||
{
|
||||
var sel = Selected;
|
||||
Files.Remove(sel);
|
||||
}
|
||||
|
||||
Selected = Files.LastOrDefault();
|
||||
}
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class InspectorPaneViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private JobViewModel? _selected;
|
||||
|
||||
public ObservableCollection<JobViewModel> Files { get; set; } = [];
|
||||
|
||||
public List<string> DetectModes =>
|
||||
[
|
||||
"face", "body", "none"
|
||||
];
|
||||
|
||||
[RelayCommand]
|
||||
private void TransformAll()
|
||||
{
|
||||
_ = _main.Start();
|
||||
}
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
private void ApplyOverrides()
|
||||
{
|
||||
if (Selected is null)
|
||||
return;
|
||||
|
||||
foreach (JobViewModel job in Files.Where(x => !ReferenceEquals(x, Selected)))
|
||||
job.CopyFrom(Selected);
|
||||
}
|
||||
|
||||
public IRelayCommand RotateLeftCommand { get; }
|
||||
public IRelayCommand RotateRightCommand { get; }
|
||||
|
||||
private MainViewModel _main = null!;
|
||||
|
||||
public InspectorPaneViewModel()
|
||||
{
|
||||
RotateLeftCommand = new RelayCommand(() => AdjustRotation(-90));
|
||||
RotateRightCommand = new RelayCommand(() => AdjustRotation(+90));
|
||||
}
|
||||
|
||||
public void SetMain(MainViewModel main) => _main = main;
|
||||
|
||||
private void AdjustRotation(int delta)
|
||||
{
|
||||
if ( Selected == null)
|
||||
return;
|
||||
|
||||
var r = Selected.Rotate;
|
||||
r = (r + delta) % 360;
|
||||
if (r < 0) r += 360;
|
||||
|
||||
Selected.Rotate = r;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,412 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class JobViewModel : ObservableObject
|
||||
{
|
||||
private SingleJob Job => _f.Model;
|
||||
private ViewModelForwarder<SingleJob> _f;
|
||||
|
||||
public SingleJob GetJob() => Job;
|
||||
|
||||
[ObservableProperty] private VideoInfo? _probe;
|
||||
[ObservableProperty] private PreviewData? _preview = new(null, [], null, new(0.5f, 0.5f), TimeSpan.Zero, null);
|
||||
[ObservableProperty] private Bitmap? _thumbnail;
|
||||
[ObservableProperty] private double _sliderLiveValue;
|
||||
[ObservableProperty] private double _positionSeconds;
|
||||
|
||||
public string InputFile => Job.InputFile;
|
||||
public double DurationSeconds => Probe?.Duration ?? 0;
|
||||
public double SegmentDuration
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Probe == null || Probe.Duration <= 0)
|
||||
return 58.0;
|
||||
|
||||
var target = Job.OverrideTargetDuration ?? 58.0;
|
||||
|
||||
int segments;
|
||||
double segmentLength;
|
||||
|
||||
if (Job.ForceFixed)
|
||||
{
|
||||
// Fixed chunk size, last one may be shorter
|
||||
segments = (int)Math.Ceiling(Probe.Duration / target);
|
||||
segmentLength = target;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Equalized segments
|
||||
segments = (int)Math.Ceiling(Probe.Duration / target);
|
||||
segmentLength = Probe.Duration / segments;
|
||||
}
|
||||
|
||||
return segmentLength;
|
||||
}
|
||||
}
|
||||
|
||||
public IRelayCommand StepForwardCommand { get; }
|
||||
public IRelayCommand StepBackwardCommand { get; }
|
||||
public IRelayCommand PlayPreviewCommand { get; }
|
||||
|
||||
private readonly IThumbnailService _thumbnails;
|
||||
private readonly DispatcherTimer _debounceTimer;
|
||||
private readonly Func<string, IObjectTracker> _trackerFactory;
|
||||
private readonly ILogger _log;
|
||||
|
||||
public string FileName => Path.GetFileName(Job.InputFile);
|
||||
|
||||
public string TextDesc => Probe != null
|
||||
? $"{Probe.Width}x{Probe.Height}, {TimeSpan.FromSeconds(Probe.Duration).ToString(@"hh\:mm\:ss")}), FPS: {Probe.Fps:F2}, Bitrate: {Probe.Bitrate/1024/1024:F2} MB/s"
|
||||
: "";
|
||||
|
||||
public override string ToString() => $"{FileName}: {TextDesc}";
|
||||
|
||||
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
||||
= new();
|
||||
|
||||
public ObservableCollection<Segment> Segments { get; } = new();
|
||||
|
||||
public string CropText
|
||||
{
|
||||
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
Job.Crop = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length == 2 &&
|
||||
int.TryParse(parts[0], out var w) &&
|
||||
int.TryParse(parts[1], out var h))
|
||||
Job.Crop = (w, h);
|
||||
}
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string GravitateText
|
||||
{
|
||||
get => Job.GravitateTo is { } p ? $"{p.X:F3},{p.Y:F3}" : "";
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
Job.GravitateTo = new Point2f(0.5f, 0.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length == 2 &&
|
||||
float.TryParse(parts[0], out var x) &&
|
||||
float.TryParse(parts[1], out var y))
|
||||
Job.GravitateTo = new Point2f(x, y);
|
||||
}
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(GravitateTo));
|
||||
}
|
||||
}
|
||||
|
||||
public string PassthroughText
|
||||
{
|
||||
get => string.Join(' ', Job.Passthrough);
|
||||
set
|
||||
{
|
||||
Job.Passthrough = string.IsNullOrWhiteSpace(value)
|
||||
? Array.Empty<string>()
|
||||
: value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public Point2f GravitateTo
|
||||
{
|
||||
get => Job.GravitateTo;
|
||||
set
|
||||
{
|
||||
if (Math.Abs(Job.GravitateTo.X - value.X) < 0.001 && Math.Abs(Job.GravitateTo.Y - value.Y) < 0.001)
|
||||
return;
|
||||
|
||||
Job.GravitateTo = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(GravitateText));
|
||||
}
|
||||
}
|
||||
|
||||
public string? Detect { get => Job.Detect; set => _f.Forward(value); }
|
||||
public string? Mask { get => Job.Mask; set => _f.Forward(value); }
|
||||
public string OutputFolder { get => Job.OutputFolder; set => _f.Forward(value); }
|
||||
public bool ForceFixed { get => Job.ForceFixed; set => _f.Forward(value); }
|
||||
public bool Debug { get => Job.Debug; set => _f.Forward(value); }
|
||||
public bool Enhance { get => Job.Enhance; set => _f.Forward(value); }
|
||||
public double? OverrideTargetDuration { get => Job.OverrideTargetDuration; set => _f.Forward(value); }
|
||||
public float ScoreThreshold { get => Job.ScoreThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
||||
public float IdentityThreshold { get => Job.IdentityThreshold; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
||||
public int? Rotate { get => Job.Rotate; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
||||
public float DetectAbove { get => Job.DetectAbove; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
||||
public ulong? DetectId { get => Job.DetectId; set { _f.Forward(value); Task.Run(CreatePreview); } }
|
||||
|
||||
public JobViewModel(SingleJob job, IThumbnailService thumbnails, Func<string, IObjectTracker> trackerFactory, ILogger log)
|
||||
{
|
||||
_f = new ViewModelForwarder<SingleJob>(job, this.OnPropertyChanged);
|
||||
_thumbnails = thumbnails;
|
||||
_trackerFactory = trackerFactory;
|
||||
_log = log;
|
||||
|
||||
ParametersList.Add(new ParameterEntry("DropoutToleranceFrames" , ""));
|
||||
ParametersList.Add(new ParameterEntry("EmaFactor" , ""));
|
||||
ParametersList.Add(new ParameterEntry("CameraEasing" , ""));
|
||||
ParametersList.Add(new ParameterEntry("LostFreezeFrames" , ""));
|
||||
ParametersList.Add(new ParameterEntry("RotationDetectorSampleCount" , ""));
|
||||
ParametersList.Add(new ParameterEntry("RotationDetectorSampleLength", ""));
|
||||
ParametersList.Add(new ParameterEntry("RotationDetectorFrameWidth" , ""));
|
||||
ParametersList.Add(new ParameterEntry("RotationDetectorFrameHeight" , ""));
|
||||
|
||||
foreach (var entry in ParametersList)
|
||||
{
|
||||
entry.PropertyChanged += OnParameterChanged;
|
||||
}
|
||||
|
||||
PropertyChanged += (sender, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(Probe))
|
||||
{
|
||||
if (Segments.Count == 0)
|
||||
GenerateSegments();
|
||||
OnPropertyChanged(nameof(DurationSeconds));
|
||||
}
|
||||
};
|
||||
ParametersList.CollectionChanged += OnParametersCollectionChanged;
|
||||
|
||||
StepForwardCommand = new RelayCommand(StepForward);
|
||||
StepBackwardCommand = new RelayCommand(StepBackward);
|
||||
PlayPreviewCommand = new RelayCommand(PlayPreview);
|
||||
|
||||
_debounceTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_debounceTimer.Tick += DebounceTimerTick;
|
||||
}
|
||||
|
||||
public void GenerateSegments()
|
||||
{
|
||||
Segments.Clear();
|
||||
if (Probe == null || Probe.Duration <= 0)
|
||||
return;
|
||||
var duration = SegmentDuration;
|
||||
var segments = (int)Math.Ceiling(Probe.Duration / duration);
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
var start = i * duration;
|
||||
var end = Math.Min(start + duration, Probe.Duration);
|
||||
Segments.Add(new Segment(start, end));
|
||||
}
|
||||
}
|
||||
|
||||
public void CopyFrom(JobViewModel src)
|
||||
{
|
||||
Job.CopyFrom(src.Job);
|
||||
OnPropertyChanged(string.Empty); // Refresh all properties
|
||||
}
|
||||
|
||||
public async Task CreatePreview()
|
||||
{
|
||||
if ( Probe == null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var pos = TimeSpan.FromSeconds(PositionSeconds);
|
||||
|
||||
Bitmap? frame;
|
||||
if (Preview?.Frame == null || Preview.Position != pos)
|
||||
frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, pos, Probe.Width, Probe.Height, Job.Rotate);
|
||||
else
|
||||
frame = Preview.Frame;
|
||||
if ( frame == null )
|
||||
return;
|
||||
|
||||
Preview = new PreviewData(frame, [], null, Job.GravitateTo, pos, Job.Rotate);
|
||||
|
||||
var tracker = _trackerFactory(Job.Detect ?? "");
|
||||
var j = new SingleTask
|
||||
(
|
||||
Job : Job,
|
||||
Info : Probe,
|
||||
OutputFileName : "preview.jpg",
|
||||
SegmentIndex : 0,
|
||||
TotalSegments : 1,
|
||||
SegmentStart : PositionSeconds,
|
||||
SegmentLength : 1, // 1 second segment for detection
|
||||
ProcessorFactory: _ => throw new NotImplementedException()
|
||||
);
|
||||
|
||||
var (detections, primaryDetection) = tracker.SelectTrackedObject(j, frame.ToMatContinuous(), j.Job.GravitateTo);
|
||||
|
||||
Rect? crop = null;
|
||||
var w = Probe.Width;
|
||||
var h = Probe.Height;
|
||||
|
||||
var cropWidth = Job.Crop?.width ?? CommandLine.DefaultW;
|
||||
var cropHeight = Job.Crop?.height ?? CommandLine.DefaultH;
|
||||
|
||||
var p = primaryDetection?.Center ?? new Point2f(w * Job.GravitateTo.X, h * Job.GravitateTo.Y);
|
||||
|
||||
var cx = p.X - cropWidth / 2f;
|
||||
var cy = p.Y - cropHeight / 2f;
|
||||
|
||||
var r = new Rect(cx, cy, cropWidth, cropHeight);
|
||||
|
||||
crop = ClampCrop(r, w, h);
|
||||
|
||||
Preview = new PreviewData(frame, detections ?? [], crop, Job.GravitateTo, pos, Job.Rotate);
|
||||
OnPropertyChanged(nameof(SegmentDuration));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError($"Error creating preview for {FileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Rect ClampCrop(Rect r, float w, float h)
|
||||
{
|
||||
var x = r.X;
|
||||
var y = r.Y;
|
||||
var cw = r.Width;
|
||||
var ch = r.Height;
|
||||
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
|
||||
if (x + cw > w) x = w - cw;
|
||||
if (y + ch > h) y = h - ch;
|
||||
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
|
||||
return new Rect(x, y, cw, ch);
|
||||
}
|
||||
|
||||
|
||||
private void OnParameterChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is ParameterEntry p && e.PropertyName == nameof(ParameterEntry.Value))
|
||||
{
|
||||
Job.Parameters[p.Key] = p.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnParametersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
{
|
||||
foreach (ParameterEntry p in e.NewItems)
|
||||
{
|
||||
Job.Parameters[p.Key] = p.Value;
|
||||
p.PropertyChanged += OnParameterChanged;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.OldItems != null)
|
||||
{
|
||||
foreach (ParameterEntry p in e.OldItems)
|
||||
{
|
||||
Job.Parameters.Remove(p.Key);
|
||||
p.PropertyChanged -= OnParameterChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StepForward()
|
||||
{
|
||||
if (Segments.Count <= 1)
|
||||
return;
|
||||
|
||||
var current = GetCurrentSegment();
|
||||
if ( current < 0 || current >= Segments.Count - 1 )
|
||||
return;
|
||||
|
||||
SliderLiveValue = Segments[current + 1].Start;
|
||||
}
|
||||
|
||||
private void StepBackward()
|
||||
{
|
||||
if (Segments.Count <= 0)
|
||||
return;
|
||||
|
||||
var current = GetCurrentSegment();
|
||||
if (current <= 0)
|
||||
{
|
||||
SliderLiveValue = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (SliderLiveValue > Segments[current].Start)
|
||||
SliderLiveValue = Segments[current].Start;
|
||||
else
|
||||
SliderLiveValue = Segments[current - 1].Start;
|
||||
}
|
||||
|
||||
private void PlayPreview()
|
||||
{
|
||||
// Implementation for playing preview
|
||||
}
|
||||
|
||||
private int GetCurrentSegment()
|
||||
{
|
||||
double pos = SliderLiveValue;
|
||||
|
||||
for (int i = 0; i < Segments.Count; i++)
|
||||
{
|
||||
var s = Segments[i];
|
||||
if (pos < s.Start)
|
||||
return i - 1;
|
||||
if (pos == s.Start)
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
partial void OnSliderLiveValueChanged(double value)
|
||||
{
|
||||
// Restart debounce timer on every slider update
|
||||
_debounceTimer.Stop();
|
||||
_debounceTimer.Start();
|
||||
}
|
||||
|
||||
private void DebounceTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_debounceTimer.Stop();
|
||||
|
||||
// Commit the final value
|
||||
PositionSeconds = SliderLiveValue;
|
||||
}
|
||||
|
||||
partial void OnPositionSecondsChanged(double value)
|
||||
{
|
||||
Task.Run(CreatePreview);
|
||||
}
|
||||
|
||||
public async Task<Avalonia.Media.Imaging.Bitmap?> GetThumbnail(double positionSec)
|
||||
{
|
||||
if (Probe == null)
|
||||
return null;
|
||||
|
||||
var pos = TimeSpan.FromSeconds(positionSec);
|
||||
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, pos, ThumbnailService.ThumbWidth, ThumbnailService.ThumbHeight, Job.Rotate).ConfigureAwait(false);
|
||||
//frame.Save($"c:\\temp\\thmb-{positionSec:N4}.png");
|
||||
return frame;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public sealed record LogEntry(string Prefix, ConsoleColor Color, string Message);
|
||||
|
||||
public partial class LogPaneViewModel : ObservableObject, ILogService
|
||||
{
|
||||
public ObservableCollection<LogEntry> Logs { get; } = [];
|
||||
|
||||
public void Log(string prefix, ConsoleColor color, string msg)
|
||||
{
|
||||
Add(new LogEntry(prefix.Replace("[", "").Replace("]", ""), color, msg));
|
||||
}
|
||||
|
||||
private void Add(LogEntry entry)
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
AddInternal(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => AddInternal(entry));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddInternal(LogEntry entry)
|
||||
{
|
||||
Logs.Add(entry);
|
||||
if (Logs.Count > 5000)
|
||||
Logs.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
public FileListViewModel FileList { get; }
|
||||
public PreviewPaneViewModel Preview { get; }
|
||||
public InspectorPaneViewModel Inspector { get; }
|
||||
public StatusBarViewModel StatusBar { get; }
|
||||
public LogPaneViewModel LogPane { get; }
|
||||
public ProgressViewModel Progress { get; }
|
||||
private IJobProcessor _processor = null!;
|
||||
|
||||
[ObservableProperty] private bool _transformMode = false;
|
||||
private ILogger _logger;
|
||||
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
|
||||
public MainViewModel(
|
||||
FileListViewModel fileListVM,
|
||||
PreviewPaneViewModel ppVM,
|
||||
InspectorPaneViewModel iVM,
|
||||
LogPaneViewModel lpVM,
|
||||
StatusBarViewModel sbVM,
|
||||
ProgressViewModel pVM,
|
||||
IJobProcessor processor,
|
||||
ILogger logger
|
||||
)
|
||||
{
|
||||
FileList = fileListVM;
|
||||
Preview = ppVM;
|
||||
Inspector = iVM;
|
||||
LogPane = lpVM;
|
||||
StatusBar = sbVM;
|
||||
Progress = pVM;
|
||||
_processor = processor;
|
||||
_logger = logger;
|
||||
|
||||
// Wire selection -> preview + inspector
|
||||
FileList.SelectedFileChanged += file =>
|
||||
{
|
||||
Preview.Selected = file;
|
||||
Inspector.Selected = file;
|
||||
};
|
||||
|
||||
Progress.SetMain(this);
|
||||
Inspector.SetMain(this);
|
||||
Inspector.Files = FileList.Files;
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
}
|
||||
|
||||
public Task Start() => Task.Run(async () =>
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
StatusBar.StatusText = "Processing…";
|
||||
StatusBar.Percent = 0;
|
||||
TransformMode = true;
|
||||
|
||||
var files = FileList.Files.ToList();
|
||||
var jobs = new List<SingleTask>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, file.Segments, _cancellationTokenSource.Token);
|
||||
jobs.AddRange(fileJobs);
|
||||
}
|
||||
|
||||
await _processor.ProcessJobs(jobs, jobs.First().Job.Enhance, _cancellationTokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Handle exception
|
||||
StatusBar.StatusText = "Error occurred…";
|
||||
_logger.LogError($"Error: {ex.Message}");
|
||||
_cancellationTokenSource.Cancel();
|
||||
}
|
||||
finally
|
||||
{
|
||||
StatusBar.StatusText = "Ready…";
|
||||
StatusBar.Percent = 0;
|
||||
TransformMode = false;
|
||||
|
||||
_cancellationTokenSource?.Dispose();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class PreviewPaneViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private JobViewModel? _selected;
|
||||
|
||||
public PreviewData? Preview => Selected?.Preview;
|
||||
public Point2f? Sar => Selected?.Probe?.Sar;
|
||||
public int Rotate => Selected?.Rotate ?? 0;
|
||||
public Point2f GravitateTo
|
||||
{
|
||||
get => Selected?.GravitateTo ?? new Point2f(0.5f, 0.5f);
|
||||
set
|
||||
{
|
||||
if (Selected == null)
|
||||
return;
|
||||
Selected.GravitateTo = value;
|
||||
OnPropertyChanged(nameof(GravitateTo));
|
||||
}
|
||||
}
|
||||
|
||||
public float DetectAbove
|
||||
{
|
||||
get => Selected?.DetectAbove ?? 0.7f;
|
||||
set
|
||||
{
|
||||
if (Selected == null)
|
||||
return;
|
||||
Selected.DetectAbove = value;
|
||||
OnPropertyChanged(nameof(DetectAbove));
|
||||
}
|
||||
}
|
||||
|
||||
public ulong? TrackedId
|
||||
{
|
||||
get => Selected?.DetectId;
|
||||
set
|
||||
{
|
||||
if (Selected == null)
|
||||
return;
|
||||
Selected.DetectId = value;
|
||||
OnPropertyChanged(nameof(TrackedId));
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedChanged(JobViewModel? oldValue, JobViewModel? newValue)
|
||||
{
|
||||
if (oldValue != null)
|
||||
oldValue.PropertyChanged -= SelectedPropertyChanged;
|
||||
|
||||
if (newValue != null)
|
||||
newValue.PropertyChanged += SelectedPropertyChanged;
|
||||
|
||||
OnPropertyChanged(nameof(Preview));
|
||||
OnPropertyChanged(nameof(Sar));
|
||||
OnPropertyChanged(nameof(Rotate));
|
||||
OnPropertyChanged(nameof(TrackedId));
|
||||
OnPropertyChanged(nameof(DetectAbove));
|
||||
}
|
||||
|
||||
private void SelectedPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(JobViewModel.Preview))
|
||||
OnPropertyChanged(nameof(Preview));
|
||||
|
||||
if (e.PropertyName == nameof(JobViewModel.Probe))
|
||||
{
|
||||
OnPropertyChanged(nameof(Sar));
|
||||
OnPropertyChanged(nameof(Rotate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public record ProgressInfo(string Name, int ProgressLine, double Progress, TimeSpan Eta, double Speed);
|
||||
|
||||
public partial class ProgressViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private int _numberOfProcesses = 0;
|
||||
public ObservableCollection<ProgressInfo> Processes { get; } = [];
|
||||
|
||||
private Lock _lock = new();
|
||||
|
||||
private MainViewModel _mainModel = null!;
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_mainModel.Cancel();
|
||||
}
|
||||
|
||||
public void SetMain(MainViewModel mainModel)
|
||||
{
|
||||
_mainModel = mainModel;
|
||||
}
|
||||
|
||||
public void ClearProgress(string name, int progressLine) => Dispatch(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (progressLine < 0 || progressLine > Processes.Count)
|
||||
return;
|
||||
|
||||
NumberOfProcesses -= 1;
|
||||
Processes[progressLine] = new ProgressInfo("", progressLine, 0, TimeSpan.Zero, 0);
|
||||
}
|
||||
});
|
||||
|
||||
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) => Dispatch(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (progressLine < 0)
|
||||
return;
|
||||
|
||||
while (Processes.Count <= progressLine)
|
||||
{
|
||||
Processes.Add(new ProgressInfo("", Processes.Count, 0, TimeSpan.Zero, 0));
|
||||
}
|
||||
|
||||
if (Processes[progressLine].Name == "")
|
||||
NumberOfProcesses += 1;
|
||||
Processes[progressLine] = new ProgressInfo(name, progressLine, progress, eta, speed);
|
||||
}
|
||||
});
|
||||
|
||||
private void Dispatch(Action action)
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
action();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => action());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public partial class StatusBarViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string _statusText = "Ready";
|
||||
|
||||
[ObservableProperty]
|
||||
private double _percent;
|
||||
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Splitter_UI.ViewModels;
|
||||
|
||||
internal class ViewModelForwarder<TModel>
|
||||
{
|
||||
public readonly TModel Model;
|
||||
private readonly Action<string> _onPropertyChanged;
|
||||
|
||||
public ViewModelForwarder(TModel model, Action<string> onPropertyChanged)
|
||||
{
|
||||
Model = model;
|
||||
_onPropertyChanged = onPropertyChanged;
|
||||
}
|
||||
|
||||
public void Forward<T>(
|
||||
T newValue,
|
||||
[CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
var modelType = typeof(TModel);
|
||||
var prop = modelType.GetProperty(propertyName!, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop == null)
|
||||
return;
|
||||
|
||||
var oldValue = (T)prop.GetValue(Model)!;
|
||||
|
||||
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
|
||||
return;
|
||||
|
||||
prop.SetValue(Model, newValue);
|
||||
_onPropertyChanged(propertyName!);
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
xmlns:views="clr-namespace:Splitter_UI.Views"
|
||||
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||
x:Class="Splitter_UI.Views.FileListView"
|
||||
x:DataType="vm:FileListViewModel"
|
||||
KeyDown="OnKeyDown"
|
||||
Focusable="True">
|
||||
|
||||
<UserControl.Resources>
|
||||
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
|
||||
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="views|FileListView Border#DropZone">
|
||||
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="views|FileListView[IsDragActive=true] Border#DropZone">
|
||||
<Setter Property="BorderBrush" Value="Red"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border x:Name="DropZone"
|
||||
Background="#1E1E1E"
|
||||
Padding="10"
|
||||
DragDrop.AllowDrop="True"
|
||||
DragDrop.Drop="OnDrop"
|
||||
DragDrop.DragOver="OnDragOver"
|
||||
DragDrop.DragEnter="OnDragEnter"
|
||||
DragDrop.DragLeave="OnDragLeave">
|
||||
|
||||
|
||||
<Grid>
|
||||
|
||||
<!-- Empty message -->
|
||||
<TextBlock Text="Drag files here"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="20"
|
||||
Foreground="#666"
|
||||
IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
|
||||
|
||||
<!-- File list -->
|
||||
<ScrollViewer>
|
||||
<ListBox ItemsSource="{Binding Files}"
|
||||
SelectedItems="{Binding SelectedFiles}"
|
||||
SelectedItem="{Binding Selected}"
|
||||
SelectionMode="Multiple"
|
||||
BorderThickness="0"
|
||||
Background="Transparent">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9A9A9A"/>
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:JobViewModel">
|
||||
<views:JobListItemView/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
</ListBox>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@ -1,78 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class FileListView : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<bool> IsDragActiveProperty =
|
||||
AvaloniaProperty.Register<FileListView, bool>(nameof(IsDragActive));
|
||||
|
||||
public bool IsDragActive
|
||||
{
|
||||
get => GetValue(IsDragActiveProperty);
|
||||
set => SetValue(IsDragActiveProperty, value);
|
||||
}
|
||||
|
||||
public FileListView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete)
|
||||
{
|
||||
if (DataContext is FileListViewModel vm)
|
||||
vm.DeleteSelected();
|
||||
}
|
||||
}
|
||||
private void OnDragEnter(object? sender, DragEventArgs e)
|
||||
{
|
||||
IsDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDragLeave(object? sender, DragEventArgs e)
|
||||
{
|
||||
IsDragActive = false;
|
||||
}
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
// Avalonia 12:
|
||||
// e.Data is IDataObject, but it has NO strongly typed formats.
|
||||
if (e.DataTransfer.Contains(DataFormat.File))
|
||||
e.DragEffects = DragDropEffects.Copy;
|
||||
else
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
IsDragActive = false;
|
||||
|
||||
if (DataContext is not FileListViewModel vm)
|
||||
return;
|
||||
|
||||
if (!e.DataTransfer.Contains(DataFormat.File))
|
||||
return;
|
||||
|
||||
// Avalonia 12:
|
||||
// This is the ONLY correct way to get dropped files.
|
||||
var items = e.DataTransfer.TryGetFiles();
|
||||
if (items is null)
|
||||
return;
|
||||
|
||||
var paths = items
|
||||
.OfType<IStorageFile>()
|
||||
.Select(f => f.Path.LocalPath)
|
||||
.ToList();
|
||||
|
||||
if (paths.Count > 0)
|
||||
vm.AddFilesCommand.Execute(paths);
|
||||
}
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Splitter_UI.Views.InspectorPane"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
x:DataType="vm:InspectorPaneViewModel">
|
||||
|
||||
<Border Background="#252525" Padding="12">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="2">
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="8"
|
||||
Margin="0,0,10,0">
|
||||
|
||||
<Button Content="Apply to Selected"
|
||||
Command="{Binding ApplyOverridesCommand}"/>
|
||||
|
||||
<Button Content="Transform all"
|
||||
Background="#AA0000"
|
||||
Foreground="White"
|
||||
Command="{Binding TransformAllCommand}"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Parameters" FontSize="10" Margin="0,0,0,10" FontWeight="Bold"/>
|
||||
|
||||
<!-- InputFile -->
|
||||
<StackPanel Orientation="Vertical" Spacing="8">
|
||||
<TextBlock Text="{Binding Selected.FileName}" Width="360" Margin="0,0,0,5" FontStyle="Italic"/>
|
||||
<TextBlock Text="{Binding Selected.TextDesc}" Width="360" FontSize="10" Margin="0,0,0,10" FontWeight="Bold" Foreground="#676767"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Rotate -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="Rotate" Width="120"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
|
||||
<Button Width="24" Height="24"
|
||||
Padding="0"
|
||||
Command="{Binding RotateLeftCommand}">
|
||||
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||
Text=""
|
||||
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>
|
||||
@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class InspectorPane : UserControl
|
||||
{
|
||||
public InspectorPane()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
<UserControl
|
||||
x:Class="Splitter_UI.Views.JobListItemView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||
x:DataType="vm:JobViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
|
||||
<conv:ActionToIconConverter x:Key="ActionToIconConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Margin="0"
|
||||
Padding="0"
|
||||
CornerRadius="4"
|
||||
Background="#2A2A2A">
|
||||
|
||||
<StackPanel MinWidth="160" MaxWidth="160">
|
||||
|
||||
<Border Width="160" Height="90" ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image Source="{Binding Thumbnail}"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||
Text="{Binding Rotate, Converter={StaticResource ActionToIconConverter}}"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Right"
|
||||
Foreground="LimeGreen"/>
|
||||
|
||||
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="LimeGreen"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="{Binding FileName}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,6,0,0"
|
||||
FontSize="10"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Border>
|
||||
</UserControl>
|
||||
@ -1,12 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class JobListItemView : UserControl
|
||||
{
|
||||
public JobListItemView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Splitter_UI.Views.LogPane"
|
||||
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
x:DataType="vm:LogPaneViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<conv:ConsoleColorToBrushConverter x:Key="ConsoleColorToBrushConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Background="#111" Padding="8">
|
||||
<ScrollViewer x:Name="Scroller">
|
||||
<ItemsControl ItemsSource="{Binding Logs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LogEntry">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="[" FontFamily="Consolas" FontSize="12"/>
|
||||
<TextBlock Text="{Binding Prefix}"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
Foreground="{Binding Color, Converter={StaticResource ConsoleColorToBrushConverter}}"/>
|
||||
<TextBlock Text="] " FontFamily="Consolas" FontSize="12"/>
|
||||
<TextBlock Text="{Binding Message}"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@ -1,31 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class LogPane : UserControl
|
||||
{
|
||||
public LogPane()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// When DataContext changes, subscribe to collection changes
|
||||
this.DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is LogPaneViewModel vm)
|
||||
{
|
||||
vm.Logs.CollectionChanged += (_, _) => ScrollToEnd();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void ScrollToEnd()
|
||||
{
|
||||
// Must run after layout pass
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Scroller != null)
|
||||
Scroller.ScrollToEnd();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
<Window
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:Splitter_UI.Views"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||
x:Class="Splitter_UI.Views.MainWindow"
|
||||
x:DataType="vm:MainViewModel"
|
||||
x:Name="Root"
|
||||
Width="1800"
|
||||
Height="870"
|
||||
Title="Splitter UI"
|
||||
Icon="avares://Splitter-UI/Assets/splitter.png">
|
||||
|
||||
<Window.Resources>
|
||||
<conv:BoolInvertConverter x:Key="BoolInvertConverter"/>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<views:StatusBarView DockPanel.Dock="Bottom"
|
||||
DataContext="{Binding StatusBar}" />
|
||||
|
||||
<Grid ColumnDefinitions="220,Auto,*,430"
|
||||
IsVisible="{Binding TransformMode, Converter={StaticResource BoolInvertConverter}}">
|
||||
|
||||
<!-- File List -->
|
||||
<views:FileListView Grid.Column="0"
|
||||
DataContext="{Binding FileList}" />
|
||||
|
||||
<!-- Splitter -->
|
||||
<GridSplitter Grid.Column="1"
|
||||
Width="6"
|
||||
Background="#404040"
|
||||
ResizeDirection="Columns"
|
||||
ResizeBehavior="PreviousAndNext"
|
||||
ShowsPreview="True" />
|
||||
|
||||
<!-- Preview -->
|
||||
<views:PreviewPane Grid.Column="2"
|
||||
DataContext="{Binding Preview}" />
|
||||
|
||||
<!-- Inspector -->
|
||||
<views:InspectorPane Grid.Column="3"
|
||||
DataContext="{Binding Inspector}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*"
|
||||
IsVisible="{Binding TransformMode}">
|
||||
<views:ProgressView DataContext="{Binding Progress}"/>
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class MainWindow : Avalonia.Controls.Window
|
||||
{
|
||||
public MainViewModel Data { get; } = null!; // set by DI
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@ -1,670 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public sealed class PreviewCanvas : Control
|
||||
{
|
||||
public static readonly StyledProperty<PreviewData?> PreviewProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, PreviewData?>(nameof(Preview));
|
||||
public static readonly StyledProperty<Point2f?> SarProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, Point2f?>(nameof(Sar));
|
||||
public static readonly StyledProperty<int> RotateAngleProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, int>(nameof(RotateAngle));
|
||||
public static readonly StyledProperty<Point2f> GravitateToProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, Point2f>(nameof(GravitateTo));
|
||||
public static readonly StyledProperty<float> DetectAboveProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, float>(nameof(DetectAbove), 0.2f);
|
||||
public static readonly StyledProperty<ulong?> DetectIdProperty =
|
||||
AvaloniaProperty.Register<PreviewCanvas, ulong?>(nameof(DetectId));
|
||||
|
||||
public PreviewData? Preview
|
||||
{
|
||||
get => GetValue(PreviewProperty);
|
||||
set => SetValue(PreviewProperty, value);
|
||||
}
|
||||
|
||||
public Point2f? Sar
|
||||
{
|
||||
get => GetValue(SarProperty);
|
||||
set => SetValue(SarProperty, value);
|
||||
}
|
||||
|
||||
public int RotateAngle
|
||||
{
|
||||
get => GetValue(RotateAngleProperty);
|
||||
set => SetValue(RotateAngleProperty, value);
|
||||
}
|
||||
|
||||
// GravitateTo is normalized (0..1)
|
||||
public Point2f GravitateTo
|
||||
{
|
||||
get => GetValue(GravitateToProperty);
|
||||
set => SetValue(GravitateToProperty, value);
|
||||
}
|
||||
|
||||
public ulong? DetectId
|
||||
{
|
||||
get => GetValue(DetectIdProperty);
|
||||
set => SetValue(DetectIdProperty, value);
|
||||
}
|
||||
|
||||
// DetectAbove is normalized (0..1) from top
|
||||
public float DetectAbove
|
||||
{
|
||||
get => GetValue(DetectAboveProperty);
|
||||
set => SetValue(DetectAboveProperty, value);
|
||||
}
|
||||
|
||||
private bool _draggingGravitate;
|
||||
private Avalonia.Point _dragStartCanvas;
|
||||
private Point2f _dragStartValue;
|
||||
|
||||
private bool _draggingDetectAbove;
|
||||
private double _dragStartDetectAbove; // normalized 0..1
|
||||
|
||||
static PreviewCanvas()
|
||||
{
|
||||
PreviewProperty.Changed.AddClassHandler<PreviewCanvas>(
|
||||
(canvas, args) =>
|
||||
canvas.OnPreviewChanged(args.OldValue as PreviewData,
|
||||
args.NewValue as PreviewData));
|
||||
}
|
||||
|
||||
public PreviewCanvas()
|
||||
{
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerMoved += OnPointerMoved;
|
||||
PointerReleased += OnPointerReleased;
|
||||
}
|
||||
|
||||
private void OnPreviewChanged(PreviewData? oldValue, PreviewData? newValue)
|
||||
{
|
||||
if (oldValue is INotifyPropertyChanged oldNotify)
|
||||
oldNotify.PropertyChanged -= PreviewPropertyChanged;
|
||||
|
||||
if (newValue is INotifyPropertyChanged newNotify)
|
||||
newNotify.PropertyChanged += PreviewPropertyChanged;
|
||||
|
||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void PreviewPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(PreviewData.Frame) ||
|
||||
e.PropertyName == nameof(PreviewData.DetectedBoxes) ||
|
||||
e.PropertyName == nameof(PreviewData.CropRect))
|
||||
{
|
||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) => availableSize;
|
||||
protected override Size ArrangeOverride(Size finalSize) => finalSize;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Unified transform helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private (double X, double Y) TransformPoint(
|
||||
double x, double y,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
switch (rotate)
|
||||
{
|
||||
case 90:
|
||||
(x, y) = (rawH - y, x);
|
||||
break;
|
||||
case 180:
|
||||
x = rawW - x;
|
||||
y = rawH - y;
|
||||
break;
|
||||
case 270:
|
||||
(x, y) = (y, rawW - x);
|
||||
break;
|
||||
}
|
||||
|
||||
if (rotate == 0 || rotate == 180)
|
||||
x *= pixelAspect;
|
||||
else
|
||||
y *= pixelAspect;
|
||||
|
||||
var sx = offsetX + x * scale;
|
||||
var sy = offsetY + y * scale;
|
||||
|
||||
return (sx, sy);
|
||||
}
|
||||
|
||||
private Rect TransformRect(
|
||||
double x, double y, double w, double h,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
switch (rotate)
|
||||
{
|
||||
case 90:
|
||||
(x, y) = (rawH - (y + h), x);
|
||||
(w, h) = (h, w);
|
||||
break;
|
||||
|
||||
case 180:
|
||||
x = rawW - (x + w);
|
||||
y = rawH - (y + h);
|
||||
break;
|
||||
|
||||
case 270:
|
||||
(x, y) = (y, rawW - (x + w));
|
||||
(w, h) = (h, w);
|
||||
break;
|
||||
}
|
||||
|
||||
if (rotate == 0 || rotate == 180)
|
||||
{
|
||||
x *= pixelAspect;
|
||||
w *= pixelAspect;
|
||||
}
|
||||
else
|
||||
{
|
||||
y *= pixelAspect;
|
||||
h *= pixelAspect;
|
||||
}
|
||||
|
||||
return new Rect(
|
||||
offsetX + x * scale,
|
||||
offsetY + y * scale,
|
||||
w * scale,
|
||||
h * scale);
|
||||
}
|
||||
|
||||
private void GetAspects(
|
||||
PreviewData preview,
|
||||
out int rawW,
|
||||
out int rawH,
|
||||
out int rotate,
|
||||
out float pixelAspect,
|
||||
out double scale,
|
||||
out double offsetX,
|
||||
out double offsetY)
|
||||
{
|
||||
rawW = preview.Frame!.PixelSize.Width;
|
||||
rawH = preview.Frame.PixelSize.Height;
|
||||
rotate = RotateAngle;
|
||||
|
||||
var sar = Sar ?? new Point2f(1, 1);
|
||||
pixelAspect = sar.X / sar.Y;
|
||||
|
||||
var dispW = Bounds.Width;
|
||||
var dispH = Bounds.Height;
|
||||
|
||||
double displayW, displayH;
|
||||
if (rotate == 0 || rotate == 180)
|
||||
{
|
||||
displayW = rawW * pixelAspect;
|
||||
displayH = rawH;
|
||||
}
|
||||
else
|
||||
{
|
||||
displayW = rawW;
|
||||
displayH = rawH * pixelAspect;
|
||||
}
|
||||
|
||||
scale = Math.Min(dispW / displayW, dispH / displayH);
|
||||
offsetX = (dispW - displayW * scale) / 2;
|
||||
offsetY = (dispH - displayH * scale) / 2;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Hit test for gravitate point (normalized)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private bool HitGravitate(Avalonia.Point p, out Point2f value)
|
||||
{
|
||||
value = default;
|
||||
|
||||
var preview = Preview;
|
||||
if (preview?.Frame is null)
|
||||
return false;
|
||||
|
||||
var g = GravitateTo;
|
||||
|
||||
int rawW, rawH, rotate;
|
||||
float pixelAspect;
|
||||
double scale, offsetX, offsetY;
|
||||
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
|
||||
|
||||
double px = g.X * rawW;
|
||||
double py = g.Y * rawH;
|
||||
|
||||
var (cx, cy) = TransformPoint(
|
||||
px, py,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
const double radius = 10;
|
||||
var hit = (p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy) <= radius * radius;
|
||||
|
||||
if (hit)
|
||||
value = g;
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Hit test for DetectAbove knob (normalized)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private bool HitDetectAbove(Avalonia.Point p, out double value)
|
||||
{
|
||||
value = default;
|
||||
|
||||
var preview = Preview;
|
||||
if (preview?.Frame is null)
|
||||
return false;
|
||||
|
||||
int rawW, rawH, rotate;
|
||||
float pixelAspect;
|
||||
double scale, offsetX, offsetY;
|
||||
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
|
||||
|
||||
var da = DetectAbove;
|
||||
var py = da * rawH;
|
||||
var px = rawW / 2.0;
|
||||
|
||||
var (cx, cy) = TransformPoint(
|
||||
px, py,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
const double radius = 10;
|
||||
var hit = (p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy) <= radius * radius;
|
||||
|
||||
if (hit)
|
||||
value = da;
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Hit test for detected boxes
|
||||
// ------------------------------------------------------------
|
||||
private bool HitDetectedBox(Avalonia.Point p, out ulong? value)
|
||||
{
|
||||
value = null;
|
||||
|
||||
var preview = Preview;
|
||||
if (preview?.Frame is null)
|
||||
return false;
|
||||
|
||||
int rawW, rawH, rotate;
|
||||
float pixelAspect;
|
||||
double scale, offsetX, offsetY;
|
||||
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
|
||||
|
||||
var frame = preview.Frame;
|
||||
foreach (var box in preview.DetectedBoxes)
|
||||
{
|
||||
var rect = TransformRect(
|
||||
box.Box.X, box.Box.Y, box.Box.Width, box.Box.Height,
|
||||
frame.PixelSize.Width, frame.PixelSize.Height,
|
||||
offsetX, offsetY, scale,
|
||||
RotateAngle,
|
||||
Sar?.X / Sar?.Y ?? 1);
|
||||
|
||||
if (rect.Contains(p))
|
||||
{
|
||||
value = box.Id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Pointer events
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
var p = e.GetPosition(this);
|
||||
|
||||
if (HitGravitate(p, out var g))
|
||||
{
|
||||
_draggingGravitate = true;
|
||||
_dragStartCanvas = p;
|
||||
_dragStartValue = g; // normalized
|
||||
e.Pointer.Capture(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HitDetectAbove(p, out var da))
|
||||
{
|
||||
_draggingDetectAbove = true;
|
||||
_dragStartCanvas = p;
|
||||
_dragStartDetectAbove = da; // normalized
|
||||
e.Pointer.Capture(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HitDetectedBox(p, out var id))
|
||||
{
|
||||
DetectId = id;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
var preview = Preview;
|
||||
if (preview?.Frame is null)
|
||||
return;
|
||||
|
||||
var p = e.GetPosition(this);
|
||||
var dxCanvas = p.X - _dragStartCanvas.X;
|
||||
var dyCanvas = p.Y - _dragStartCanvas.Y;
|
||||
|
||||
int rawW, rawH, rotate;
|
||||
float pixelAspect;
|
||||
double scale, offsetX, offsetY;
|
||||
GetAspects(preview, out rawW, out rawH, out rotate, out pixelAspect, out scale, out offsetX, out offsetY);
|
||||
|
||||
var dx = dxCanvas / scale;
|
||||
var dy = dyCanvas / scale;
|
||||
|
||||
if (rotate == 0 || rotate == 180)
|
||||
dx /= pixelAspect;
|
||||
else
|
||||
dy /= pixelAspect;
|
||||
|
||||
if (_draggingGravitate)
|
||||
{
|
||||
var gx = _dragStartValue.X * rawW + dx;
|
||||
var gy = _dragStartValue.Y * rawH + dy;
|
||||
|
||||
switch (rotate)
|
||||
{
|
||||
case 90:
|
||||
(gx, gy) = (gy, rawH - gx);
|
||||
break;
|
||||
case 180:
|
||||
gx = rawW - gx;
|
||||
gy = rawH - gy;
|
||||
break;
|
||||
case 270:
|
||||
(gx, gy) = (rawW - gy, gx);
|
||||
break;
|
||||
}
|
||||
|
||||
var nx = (float)(gx / rawW);
|
||||
var ny = (float)(gy / rawH);
|
||||
|
||||
if (nx < 0) nx = 0;
|
||||
if (ny < 0) ny = 0;
|
||||
if (nx > 1) nx = 1;
|
||||
if (ny > 1) ny = 1;
|
||||
|
||||
GravitateTo = new Point2f(nx, ny);
|
||||
}
|
||||
else if (_draggingDetectAbove)
|
||||
{
|
||||
var gx = rawW / 2.0;
|
||||
var gy = _dragStartDetectAbove * rawH + dy;
|
||||
|
||||
switch (rotate)
|
||||
{
|
||||
case 90:
|
||||
(gx, gy) = (gy, rawH - gx);
|
||||
break;
|
||||
case 180:
|
||||
gx = rawW - gx;
|
||||
gy = rawH - gy;
|
||||
break;
|
||||
case 270:
|
||||
(gx, gy) = (rawW - gy, gx);
|
||||
break;
|
||||
}
|
||||
|
||||
var ny = gy / rawH;
|
||||
if (ny < 0) ny = 0;
|
||||
if (ny > 1) ny = 1;
|
||||
|
||||
DetectAbove = (float)ny;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (_draggingGravitate || _draggingDetectAbove)
|
||||
{
|
||||
_draggingGravitate = false;
|
||||
_draggingDetectAbove = false;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Overlay renderers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private void RenderCropRectangle(
|
||||
DrawingContext context,
|
||||
PreviewData preview,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
if (preview.CropRect is not { } crop)
|
||||
return;
|
||||
|
||||
var rr = TransformRect(
|
||||
crop.X, crop.Y, crop.Width, crop.Height,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
var pen = new Pen(Brushes.Yellow, 2);
|
||||
context.DrawRectangle(null, pen, rr);
|
||||
}
|
||||
|
||||
private void RenderGravitateTo(
|
||||
DrawingContext context,
|
||||
PreviewData preview,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
var g = GravitateTo;
|
||||
|
||||
var px = g.X * rawW;
|
||||
var py = g.Y * rawH;
|
||||
|
||||
var (sx, sy) = TransformPoint(
|
||||
px, py,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
const double radius = 10;
|
||||
|
||||
var circle = new EllipseGeometry(
|
||||
new Rect(sx - radius, sy - radius, radius * 2, radius * 2));
|
||||
|
||||
var pen = new Pen(Brushes.Yellow, 2);
|
||||
var brush = Brushes.Yellow;
|
||||
|
||||
context.DrawGeometry(brush, pen, circle);
|
||||
}
|
||||
|
||||
private void RenderDetectedBoxes(
|
||||
DrawingContext context,
|
||||
PreviewData preview,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
if (preview.DetectedBoxes is not { Count: > 0 })
|
||||
return;
|
||||
|
||||
var pen = new Pen(Brushes.Lime, 2);
|
||||
var selectedPen = new Pen(Brushes.Magenta, 2);
|
||||
|
||||
var detected = preview.DetectedBoxes.ToList();
|
||||
|
||||
foreach (var r in detected)
|
||||
{
|
||||
var rr = TransformRect(
|
||||
r.Box.X, r.Box.Y, r.Box.Width, r.Box.Height,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
context.DrawRectangle(null, r.Id == DetectId ? selectedPen : pen, rr);
|
||||
context.DrawText(
|
||||
new FormattedText($"ID: {r.Id}", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 12, r.Id == DetectId ? Brushes.Magenta : Brushes.Lime),
|
||||
new Avalonia.Point(rr.X + 5, rr.Y + 5));
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderDetectAbove(
|
||||
DrawingContext context,
|
||||
PreviewData preview,
|
||||
double rawW, double rawH,
|
||||
double offsetX, double offsetY,
|
||||
double scale,
|
||||
int rotate,
|
||||
double pixelAspect)
|
||||
{
|
||||
var da = DetectAbove;
|
||||
var rawY = da * rawH;
|
||||
|
||||
var (x1, y1) = TransformPoint(
|
||||
0, rawY,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
var (x2, y2) = TransformPoint(
|
||||
rawW, rawY,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
var pen = new Pen(Brushes.Lime, 2);
|
||||
context.DrawLine(pen, new Avalonia.Point(x1, y1), new Avalonia.Point(x2, y2));
|
||||
|
||||
const double radius = 10;
|
||||
var (kx, ky) = TransformPoint(
|
||||
rawW / 2.0, rawY,
|
||||
rawW, rawH,
|
||||
offsetX, offsetY,
|
||||
scale,
|
||||
rotate,
|
||||
pixelAspect);
|
||||
|
||||
var knob = new EllipseGeometry(
|
||||
new Rect(kx - radius, ky - radius, radius * 2, radius * 2));
|
||||
|
||||
var knobPen = new Pen(Brushes.Lime, 2);
|
||||
var knobBrush = Brushes.Lime;
|
||||
|
||||
context.DrawGeometry(knobBrush, knobPen, knob);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Main Render
|
||||
// ------------------------------------------------------------
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var preview = Preview;
|
||||
if (preview?.Frame is null)
|
||||
return;
|
||||
|
||||
var frame = preview.Frame;
|
||||
var rawW = frame.PixelSize.Width;
|
||||
var rawH = frame.PixelSize.Height;
|
||||
|
||||
var dispW = Bounds.Width;
|
||||
var dispH = Bounds.Height;
|
||||
|
||||
if (dispW <= 0 || dispH <= 0)
|
||||
return;
|
||||
|
||||
var rotate = RotateAngle;
|
||||
|
||||
var sar = Sar ?? new Point2f(1, 1);
|
||||
var sarX = sar.X <= 0 ? 1 : sar.X;
|
||||
var sarY = sar.Y <= 0 ? 1 : sar.Y;
|
||||
var pixelAspect = sarX / sarY;
|
||||
|
||||
double displayW, displayH;
|
||||
|
||||
if (rotate == 0 || rotate == 180)
|
||||
{
|
||||
displayW = rawW * pixelAspect;
|
||||
displayH = rawH;
|
||||
}
|
||||
else
|
||||
{
|
||||
displayW = rawW;
|
||||
displayH = rawH * pixelAspect;
|
||||
}
|
||||
|
||||
var scale = Math.Min(dispW / displayW, dispH / displayH);
|
||||
|
||||
var scaledW = displayW * scale;
|
||||
var scaledH = displayH * scale;
|
||||
|
||||
var offsetX = (dispW - scaledW) / 2;
|
||||
var offsetY = (dispH - scaledH) / 2;
|
||||
|
||||
context.DrawImage(
|
||||
frame,
|
||||
new Rect(0, 0, rawW, rawH),
|
||||
new Rect(offsetX, offsetY, scaledW, scaledH));
|
||||
|
||||
RenderDetectedBoxes(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
|
||||
RenderCropRectangle(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
|
||||
RenderGravitateTo(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
|
||||
RenderDetectAbove(context, preview, rawW, rawH, offsetX, offsetY, scale, rotate, pixelAspect);
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
xmlns:local="clr-namespace:Splitter_UI.Views"
|
||||
xmlns:controls="clr-namespace:Splitter_UI.Controls"
|
||||
x:Class="Splitter_UI.Views.PreviewPane"
|
||||
x:DataType="vm:PreviewPaneViewModel">
|
||||
|
||||
<Border Background="#202020" Padding="10">
|
||||
<Grid RowDefinitions="*,Auto,Auto">
|
||||
|
||||
<local:PreviewCanvas
|
||||
Grid.Row="0"
|
||||
Preview="{Binding Preview}"
|
||||
Sar="{Binding Sar}"
|
||||
RotateAngle="{Binding Rotate}"
|
||||
GravitateTo="{Binding GravitateTo, Mode=TwoWay}"
|
||||
DetectAbove="{Binding DetectAbove, Mode=TwoWay}"
|
||||
DetectId="{Binding Selected.DetectId, Mode=TwoWay}"
|
||||
/>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="Auto"
|
||||
Margin="0,10,0,0">
|
||||
<Button Grid.Column="1"
|
||||
HorizontalAlignment="Left"
|
||||
Width="24" Height="24"
|
||||
Padding="0"
|
||||
Margin="0,0,5,0"
|
||||
Command="{Binding Selected.PlayPreviewCommand}">
|
||||
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||
Text=""
|
||||
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>
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class PreviewPane : UserControl
|
||||
{
|
||||
public PreviewPane()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
<UserControl
|
||||
x:Class="Splitter_UI.Views.ProgressView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
x:DataType="vm:ProgressViewModel">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Background="#111">
|
||||
|
||||
<!-- Processes list -->
|
||||
<ItemsControl Grid.Row="0" ItemsSource="{Binding Processes}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ProgressInfo">
|
||||
<Grid ColumnDefinitions="2*,3*,Auto,Auto"
|
||||
Margin="0,2">
|
||||
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Name}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"/>
|
||||
|
||||
<ProgressBar Grid.Column="1"
|
||||
Height="12"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding Progress}"
|
||||
Margin="8,0"/>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Width="70"
|
||||
Text="{Binding Eta, StringFormat={}{0:hh\\:mm\\:ss}}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0"
|
||||
FontSize="12"/>
|
||||
|
||||
<TextBlock Grid.Column="3"
|
||||
Width="70"
|
||||
Text="{Binding Speed, StringFormat={}{0:0.00}}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0"
|
||||
FontSize="12"/>
|
||||
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Bottom-right Cancel button -->
|
||||
<StackPanel Grid.Row="1"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,12,0,0">
|
||||
|
||||
<Button Content="Cancel"
|
||||
Background="#700000"
|
||||
Foreground="White"
|
||||
Padding="12,6"
|
||||
Margin="0,0,10,10"
|
||||
Command="{Binding CancelCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@ -1,12 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class ProgressView : UserControl
|
||||
{
|
||||
public ProgressView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Splitter_UI.Views.StatusBarView"
|
||||
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||
x:DataType="vm:StatusBarViewModel">
|
||||
|
||||
<Border Padding="4" Background="{DynamicResource ThemeBackgroundBrush}">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding StatusText}" />
|
||||
|
||||
<ProgressBar Grid.Column="1"
|
||||
Width="200"
|
||||
Height="16"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding Percent}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Splitter_UI.Views;
|
||||
|
||||
public partial class StatusBarView : UserControl
|
||||
{
|
||||
public StatusBarView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Splitter_UI.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 572 KiB |
@ -1,4 +1,6 @@
|
||||
namespace splitter.algo;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public enum TrackState
|
||||
{
|
||||
@ -58,7 +60,7 @@ public sealed class CameraController
|
||||
_kalman.Reset(_cameraCenter);
|
||||
}
|
||||
|
||||
private Point2f DefaultCenter => _cmd.GravitateTo;
|
||||
private Point2f DefaultCenter => _cmd.GravitateTo ?? new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
||||
|
||||
public int LostFrames => _lostFrames;
|
||||
public Point2f CameraCenter => _cameraCenter;
|
||||
@ -68,15 +70,15 @@ public sealed class CameraController
|
||||
public Point2f? ObjectCenter => _objectCenter;
|
||||
public Rect Roi => _roi;
|
||||
|
||||
public void Update(DetectedPerson? primary)
|
||||
public void Update((Rect box, Point2f center)? primary)
|
||||
{
|
||||
Rect? objectBox = null;
|
||||
Point2f? objectCenter = null;
|
||||
|
||||
if (primary.HasValue)
|
||||
{
|
||||
objectCenter = primary.Value.Center;
|
||||
objectBox = primary.Value.Box;
|
||||
objectCenter = primary.Value.center;
|
||||
objectBox = primary.Value.box;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@ -95,7 +97,7 @@ public sealed class CameraController
|
||||
_dropoutCounter = 0;
|
||||
}
|
||||
|
||||
var isLost = !objectCenter.HasValue;
|
||||
bool isLost = !objectCenter.HasValue;
|
||||
|
||||
// LOST / REACQUIRE STATE MACHINE
|
||||
if (isLost)
|
||||
@ -147,7 +149,7 @@ public sealed class CameraController
|
||||
{
|
||||
smoothedCenter = _kalman.Update(objectCenter);
|
||||
|
||||
var driftEasing = 0.01f;
|
||||
float driftEasing = 0.01f;
|
||||
var fallbackCenter = new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
||||
|
||||
_cameraCenter = new Point2f(
|
||||
@ -1,14 +1,52 @@
|
||||
using System.Globalization;
|
||||
using splitter.util;
|
||||
|
||||
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
|
||||
{
|
||||
// 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[] Jobs { get; }
|
||||
|
||||
@ -74,10 +112,6 @@ public sealed class CommandLine
|
||||
{
|
||||
Master.Rotate = 90;
|
||||
}
|
||||
else if (arg == "--enhance")
|
||||
{
|
||||
Master.Enhance = true;
|
||||
}
|
||||
else if (arg.StartsWith("--rotate="))
|
||||
{
|
||||
var val = arg.Substring("--rotate=".Length);
|
||||
@ -86,34 +120,10 @@ public sealed class CommandLine
|
||||
else
|
||||
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="))
|
||||
{
|
||||
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")
|
||||
{
|
||||
Master.Crop = ParseCrop("");
|
||||
@ -172,11 +182,24 @@ public sealed class CommandLine
|
||||
|
||||
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
|
||||
|
||||
Jobs = files.Select(x =>
|
||||
Jobs = files.Select(x => new SingleJob
|
||||
{
|
||||
var job = new SingleJob { InputFile = x };
|
||||
Master.CopyTo(job);
|
||||
return job;
|
||||
InputFile = x,
|
||||
OutputFolder = Master.OutputFolder,
|
||||
Crop = Master.Crop,
|
||||
GravitateTo = Master.GravitateTo,
|
||||
Mask = Master.Mask,
|
||||
Debug = Master.Debug,
|
||||
Detect = Master.Detect,
|
||||
OverrideTargetDuration = Master.OverrideTargetDuration,
|
||||
Passthrough = Master.Passthrough,
|
||||
PlainText = Master.PlainText,
|
||||
EstimateOnly = Master.EstimateOnly,
|
||||
ForceFixed = Master.ForceFixed,
|
||||
SingleThreaded = Master.SingleThreaded,
|
||||
Rotate = Master.Rotate,
|
||||
RotateAuto = Master.RotateAuto,
|
||||
Parameters = new Dictionary<string, string>(Master.Parameters)
|
||||
}).ToArray();
|
||||
|
||||
if ( Jobs.Length == 0)
|
||||
@ -227,31 +250,35 @@ public sealed class CommandLine
|
||||
return key.Length > 0;
|
||||
}
|
||||
|
||||
private static Point2f ParseGravitate(string value)
|
||||
private static Point2f? ParseGravitate(string value)
|
||||
{
|
||||
// Expected format: "<x>:<y>"
|
||||
var parts = value.Split(':');
|
||||
if (parts.Length != 2)
|
||||
return new Point2f(0.5f, 0.5f);
|
||||
return null;
|
||||
|
||||
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
|
||||
return new Point2f(0.5f, 0.5f);
|
||||
return null;
|
||||
|
||||
if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||
return new Point2f(0.5f, 0.5f);
|
||||
return null;
|
||||
|
||||
// Normalized range check (0.0–1.0)
|
||||
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
||||
return new Point2f(0.5f, 0.5f);
|
||||
return null;
|
||||
|
||||
return new Point2f(x, y);
|
||||
}
|
||||
|
||||
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
|
||||
if (string.IsNullOrWhiteSpace(v))
|
||||
return (DefaultW, DefaultH);
|
||||
return (defaultW, defaultH);
|
||||
|
||||
var s = v.Trim().ToLowerInvariant();
|
||||
|
||||
@ -343,9 +370,6 @@ Options:
|
||||
Last segment may be shorter.
|
||||
Default: OFF
|
||||
|
||||
--enhance Enable video enhancement.
|
||||
Increases output resolution x4 Using RealBasicVSR_x4 model.
|
||||
|
||||
--rotate=<degrees> Rotate video by specified degrees (90, 180, 270).
|
||||
Useful for videos with incorrect orientation metadata.
|
||||
|
||||
@ -362,14 +386,6 @@ Options:
|
||||
--detect=<name> Object detector to use for tracking.
|
||||
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.
|
||||
Coordinates are normalized (0.0 to 1.0).
|
||||
Example: --gravitate=0.2:0.5 (gravitate towards left-center)
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
namespace splitter;
|
||||
|
||||
public static class DebugOverlay
|
||||
{
|
||||
public static void DrawDebug(
|
||||
Mat frame,
|
||||
List<DetectedPerson> objects,
|
||||
CameraController camera,
|
||||
KalmanTracker kalman)
|
||||
{
|
||||
if (camera.ObjectBox.HasValue)
|
||||
{
|
||||
var fb = camera.ObjectBox.Value;
|
||||
Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2);
|
||||
}
|
||||
|
||||
Cv2.Circle(frame,
|
||||
new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y),
|
||||
6, Scalar.LimeGreen, -1);
|
||||
|
||||
Cv2.Rectangle(frame, camera.Roi,
|
||||
camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3);
|
||||
|
||||
DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White);
|
||||
DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White);
|
||||
DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White);
|
||||
DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White);
|
||||
}
|
||||
|
||||
public static void DrawText(Mat img, string text, int x, int y, Scalar color)
|
||||
{
|
||||
Cv2.PutText(img, text, new Point(x, y),
|
||||
HersheyFonts.HersheySimplex, 0.6, color, 2);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
namespace splitter.util;
|
||||
namespace splitter;
|
||||
|
||||
public static class FileMaskExpander
|
||||
{
|
||||
@ -8,8 +8,8 @@ public static class FileMaskExpander
|
||||
if (!HasMask(input))
|
||||
return [Path.GetFullPath(input)];
|
||||
|
||||
var directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
|
||||
var pattern = Path.GetFileName(input);
|
||||
string directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
|
||||
string pattern = Path.GetFileName(input);
|
||||
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
directory = Directory.GetCurrentDirectory();
|
||||
@ -1,4 +1,6 @@
|
||||
namespace splitter.probe;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public sealed class FrameRotationDetector
|
||||
{
|
||||
@ -42,28 +44,28 @@ public sealed class FrameRotationDetector
|
||||
Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true);
|
||||
|
||||
// 4. Clear histogram
|
||||
for (var i = 0; i < _bins; i++)
|
||||
for (int i = 0; i < _bins; i++)
|
||||
_hist[i] = 0;
|
||||
|
||||
var binSize = 180f / _bins;
|
||||
float binSize = 180f / _bins;
|
||||
|
||||
unsafe
|
||||
{
|
||||
var anglePtr = (float*)_angle.Data;
|
||||
var magPtr = (float*)_mag.Data;
|
||||
float* anglePtr = (float*)_angle.Data;
|
||||
float* magPtr = (float*)_mag.Data;
|
||||
|
||||
var total = _w * _h;
|
||||
int total = _w * _h;
|
||||
|
||||
for (var i = 0; i < total; i++)
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
var m = magPtr[i];
|
||||
float m = magPtr[i];
|
||||
if (m < 5f) continue; // ignore weak gradients
|
||||
|
||||
var a = anglePtr[i];
|
||||
float a = anglePtr[i];
|
||||
if (a < 0) a += 360f;
|
||||
a = a % 180f;
|
||||
|
||||
var bin = (int)(a / binSize);
|
||||
int bin = (int)(a / binSize);
|
||||
if (bin < 0) bin = 0;
|
||||
if (bin >= _bins) bin = _bins - 1;
|
||||
|
||||
@ -73,12 +75,12 @@ public sealed class FrameRotationDetector
|
||||
|
||||
// 5. Energy around 0° vs 90°
|
||||
float e0 = 0, e90 = 0;
|
||||
var window = 3;
|
||||
int window = 3;
|
||||
|
||||
var bin0 = 0;
|
||||
var bin90 = _bins / 2;
|
||||
int bin0 = 0;
|
||||
int bin90 = _bins / 2;
|
||||
|
||||
for (var i = -window; i <= window; i++)
|
||||
for (int i = -window; i <= window; i++)
|
||||
{
|
||||
e0 += _hist[Wrap(bin0 + i)];
|
||||
e90 += _hist[Wrap(bin90 + i)];
|
||||
@ -1,5 +0,0 @@
|
||||
global using OpenCvSharp;
|
||||
global using splitter.algo;
|
||||
global using splitter.probe;
|
||||
global using splitter.tui;
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
namespace splitter;
|
||||
|
||||
public interface IJobProcessor
|
||||
{
|
||||
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token);
|
||||
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
namespace splitter.tui;
|
||||
namespace splitter;
|
||||
|
||||
public interface ILogger
|
||||
{
|
||||
void ClearProgress(string name, int progressLine);
|
||||
void ClearProgress(int progressLevel);
|
||||
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
|
||||
void Log(string prefix, ConsoleColor color, string msg);
|
||||
|
||||
8
splitter-cli/IObjectDetector.cs
Normal file
8
splitter-cli/IObjectDetector.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public interface IObjectDetector : IDisposable
|
||||
{
|
||||
List<(Rect box, Point2f center)> DetectAll(Mat frameCont);
|
||||
}
|
||||
6
splitter-cli/ISegmentProcessor.cs
Normal file
6
splitter-cli/ISegmentProcessor.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace splitter;
|
||||
|
||||
public interface ISegmentProcessor
|
||||
{
|
||||
Task ProcessSegment( SingleTask job );
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor
|
||||
{
|
||||
public async Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection<Segment> predefinedSegments, CancellationToken token)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(job.InputFile);
|
||||
|
||||
if (!File.Exists(job.InputFile))
|
||||
{
|
||||
LogError($"{baseName}: Input file not found.");
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Directory.Exists(job.OutputFolder))
|
||||
Directory.CreateDirectory(job.OutputFolder);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return [];
|
||||
|
||||
var info = await ProbeVideo.Probe(job.InputFile, job.RotateAuto, token);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return [];
|
||||
|
||||
if (info.Duration <= 0)
|
||||
{
|
||||
LogError($"{baseName}: Could not read duration.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var target = job.OverrideTargetDuration ?? 58.0;
|
||||
|
||||
int segments;
|
||||
double segmentLength;
|
||||
|
||||
if (job.ForceFixed)
|
||||
{
|
||||
// Fixed chunk size, last one may be shorter
|
||||
segments = (int)Math.Ceiling(info.Duration / target);
|
||||
segmentLength = target;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Equalized segments
|
||||
segments = (int)Math.Ceiling(info.Duration / target);
|
||||
segmentLength = info.Duration / segments;
|
||||
}
|
||||
|
||||
LogInfo($"{baseName}: Duration {info.Duration:F2}s, {info.Width}x{info.Height} @ {info.Fps:F3}fps {info.Bitrate / 1024:F0}kbps," +
|
||||
$" Target duration: {target:F2}s Segments: {segments} segment length: {segmentLength:F2}s {(job.ForceFixed ? " fixed" : "")}");
|
||||
|
||||
if (estimateOnly)
|
||||
return [];
|
||||
|
||||
Func<int, ISegmentProcessor> processorFactory;
|
||||
if (job.Crop != null)
|
||||
{
|
||||
processorFactory = i =>
|
||||
{
|
||||
IObjectDetector detector = job.Detect switch
|
||||
{
|
||||
"face" => new UltraFaceDetector(_logger),
|
||||
"body" => new YoloV10ObjectDetector(_logger),
|
||||
"none" => new DummyDetector(),
|
||||
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
|
||||
};
|
||||
var osnet = new OSNetEmbeddingExtractor();
|
||||
var tracker = new ObjectTracker(detector, osnet);
|
||||
return new TrackingSplitter(i, tracker, job, _logger);
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
processorFactory = i => new SimpleSplitter(i, _logger);
|
||||
}
|
||||
|
||||
var segmentsToUse = predefinedSegments;
|
||||
|
||||
if (predefinedSegments.Count == 0)
|
||||
{
|
||||
segmentsToUse = Enumerable.Range(0, segments).Select(i => new Segment
|
||||
(
|
||||
Start: i * segmentLength,
|
||||
End : (i == segments - 1)
|
||||
? Math.Max(0.1, info.Duration)
|
||||
: (i + 1) * segmentLength
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
return segmentsToUse.Select((s, i) => new SingleTask
|
||||
(
|
||||
Job : job,
|
||||
Info : info,
|
||||
OutputFileName : BuildOutputFileName(job, i),
|
||||
SegmentIndex : i,
|
||||
TotalSegments : predefinedSegments.Count,
|
||||
SegmentStart : s.Start,
|
||||
SegmentLength : s.End - s.Start,
|
||||
ProcessorFactory: processorFactory
|
||||
)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token)
|
||||
{
|
||||
|
||||
if (singleThreaded)
|
||||
{
|
||||
LogInfo("Starting single-threaded splitting...");
|
||||
await RunSingleThreaded(tasks, token);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogInfo("Starting multi-threaded splitting...");
|
||||
await RunMultiThreaded(tasks, token);
|
||||
}
|
||||
|
||||
LogInfo("Done.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void LogProgress(double progress, TimeSpan eta, double speed) => _logger.DrawProgress("Total", 0, progress, eta, speed);
|
||||
|
||||
// -----------------------------
|
||||
// ffprobe
|
||||
// -----------------------------
|
||||
|
||||
// -----------------------------
|
||||
// Multi-threaded splitting
|
||||
// -----------------------------
|
||||
|
||||
private async Task RunMultiThreaded(List<SingleTask> jobs, CancellationToken token)
|
||||
{
|
||||
LogProgress(0.0, TimeSpan.Zero, 0.0);
|
||||
|
||||
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
|
||||
|
||||
using var sem = new SemaphoreSlim(maxDegree);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Slot pool: 0..maxDegree-1
|
||||
var freeSlots = new ConcurrentQueue<int>(Enumerable.Range(0, maxDegree));
|
||||
|
||||
var totalSegments = jobs.Count;
|
||||
var processedSegments = 0;
|
||||
var totalDuration = jobs.Sum(j => j.SegmentLength);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
await sem.WaitAsync(token);
|
||||
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
var slot = -1;
|
||||
|
||||
try
|
||||
{
|
||||
// Acquire a slot ID
|
||||
while (!freeSlots.TryDequeue(out slot))
|
||||
{
|
||||
if ( token.IsCancellationRequested)
|
||||
return;
|
||||
await Task.Yield();
|
||||
}
|
||||
await ProcessSegment(job, slot + 1, token);
|
||||
|
||||
var processed = Interlocked.Increment(ref processedSegments);
|
||||
var elapsed = sw.Elapsed;
|
||||
var eta = TimeSpan.FromTicks(elapsed.Ticks * (totalSegments - processed) / processed);
|
||||
var speed = (processed * totalDuration) / elapsed.TotalSeconds;
|
||||
LogProgress((double)processed / totalSegments, eta, speed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Return slot to pool
|
||||
if (slot >= 0)
|
||||
freeSlots.Enqueue(slot);
|
||||
|
||||
sem.Release();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------
|
||||
// Single-threaded splitting
|
||||
// -----------------------------
|
||||
|
||||
private async Task RunSingleThreaded(List<SingleTask> jobs, CancellationToken token)
|
||||
{
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
await ProcessSegment(job, 0, token);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task ProcessSegment(SingleTask t, int slot, CancellationToken token)
|
||||
{
|
||||
var processor = t.ProcessorFactory(slot);
|
||||
try
|
||||
{
|
||||
await processor.ProcessSegment(t, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (processor is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOutputFileName(SingleJob job, int index)
|
||||
{
|
||||
string fileName;
|
||||
|
||||
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
|
||||
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
|
||||
.Replace("[N]", index.ToString())
|
||||
.Replace("[NN]", index.ToString("00"))
|
||||
.Replace("[NNN]", index.ToString("000"))
|
||||
.Replace("[NNNN]", index.ToString("0000"))
|
||||
.Replace("[EXT]", Path.GetExtension(job.InputFile).TrimStart('.'))
|
||||
;
|
||||
|
||||
return Path.Combine(job.OutputFolder, fileName);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
namespace splitter.algo;
|
||||
namespace splitter;
|
||||
|
||||
public sealed class KalmanTracker
|
||||
{
|
||||
@ -35,8 +35,8 @@ public sealed class KalmanTracker
|
||||
_state[3] = 0;
|
||||
|
||||
// Large initial uncertainty
|
||||
for (var i = 0; i < 4; i++)
|
||||
for (var j = 0; j < 4; j++)
|
||||
for (int i = 0; i < 4; i++)
|
||||
for (int j = 0; j < 4; j++)
|
||||
_p[i, j] = (i == j) ? 1f : 0f;
|
||||
}
|
||||
|
||||
@ -63,16 +63,16 @@ public sealed class KalmanTracker
|
||||
var z = measurement.Value;
|
||||
|
||||
// Innovation y = z - Hx
|
||||
var yx = z.X - _state[0];
|
||||
var yy = z.Y - _state[1];
|
||||
float yx = z.X - _state[0];
|
||||
float yy = z.Y - _state[1];
|
||||
|
||||
// Innovation covariance S = P + R
|
||||
var Sx = _p[0, 0] + _r;
|
||||
var Sy = _p[1, 1] + _r;
|
||||
float Sx = _p[0, 0] + _r;
|
||||
float Sy = _p[1, 1] + _r;
|
||||
|
||||
// Kalman gain K = P / S
|
||||
var Kx0 = _p[0, 0] / Sx;
|
||||
var Kx1 = _p[1, 1] / Sy;
|
||||
float Kx0 = _p[0, 0] / Sx;
|
||||
float Kx1 = _p[1, 1] / Sy;
|
||||
|
||||
// Update state
|
||||
_state[0] += Kx0 * yx;
|
||||
@ -1,11 +1,7 @@
|
||||
namespace splitter.tui;
|
||||
namespace splitter;
|
||||
|
||||
public abstract class LoggingBase(ILogger logger, int _progressLine)
|
||||
public abstract class LoggingBase(ILogger _logger, int _progressLine)
|
||||
{
|
||||
#pragma warning disable IDE1006 // Naming Styles
|
||||
protected ILogger _logger = logger;
|
||||
#pragma warning restore IDE1006 // Naming Styles
|
||||
|
||||
protected void Log(string level, ConsoleColor color, string message)
|
||||
=> _logger.Log(level, color, message);
|
||||
|
||||
@ -21,6 +17,6 @@ public abstract class LoggingBase(ILogger logger, int _progressLine)
|
||||
protected void DrawProgress(string name, double percent, TimeSpan eta, double fps)
|
||||
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps);
|
||||
|
||||
protected void ClearProgress(string name)
|
||||
=> _logger.ClearProgress(name,_progressLine);
|
||||
protected void ClearProgress()
|
||||
=> _logger.ClearProgress(_progressLine);
|
||||
}
|
||||
13
splitter-cli/Point2f.cs
Normal file
13
splitter-cli/Point2f.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace splitter;
|
||||
|
||||
public struct Point2f
|
||||
{
|
||||
public float X;
|
||||
public float Y;
|
||||
|
||||
public Point2f(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
108
splitter-cli/ProbeVideo.cs
Normal file
108
splitter-cli/ProbeVideo.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public record VideoInfo(
|
||||
double Duration,
|
||||
int Width,
|
||||
int Height,
|
||||
double Fps,
|
||||
double Bitrate,
|
||||
int Rotation = 0
|
||||
);
|
||||
|
||||
public static class ProbeVideo
|
||||
{
|
||||
public static async Task<VideoInfo> Probe(SingleJob job)
|
||||
{
|
||||
var info = ProbeSize(job.InputFile);
|
||||
if ( job.RotateAuto)
|
||||
{
|
||||
var rotation = await ProbeRotation(job, info.Duration);
|
||||
info = info with { Rotation = rotation };
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static async Task<int> ProbeRotation(SingleJob job, double duration)
|
||||
{
|
||||
var rotation = await new VideoRotationSampler(job).DetectRotationAsync(job.InputFile, duration);
|
||||
return rotation;
|
||||
}
|
||||
|
||||
private static VideoInfo ProbeSize(string inputFile)
|
||||
{
|
||||
var args =
|
||||
"-v error " +
|
||||
"-select_streams v:0 " +
|
||||
"-show_entries format=duration " +
|
||||
"-show_entries stream=width,height,avg_frame_rate,bit_rate " +
|
||||
"-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys
|
||||
$"\"{inputFile}\"";
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffprobe",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var p = new Process { StartInfo = psi };
|
||||
p.Start();
|
||||
|
||||
var duration = -1.0;
|
||||
var width = 0;
|
||||
var height = 0;
|
||||
var fps = 0.0;
|
||||
var bitrate = 0.0;
|
||||
|
||||
while (!p.StandardOutput.EndOfStream)
|
||||
{
|
||||
var line = p.StandardOutput.ReadLine()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
if (line.StartsWith("duration="))
|
||||
{
|
||||
var v = line.Substring("duration=".Length);
|
||||
double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration);
|
||||
}
|
||||
else if (line.StartsWith("width="))
|
||||
{
|
||||
var v = line.Substring("width=".Length);
|
||||
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
|
||||
}
|
||||
else if (line.StartsWith("bit_rate="))
|
||||
{
|
||||
var v = line.Substring("bit_rate=".Length);
|
||||
double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out bitrate);
|
||||
}
|
||||
else if (line.StartsWith("height="))
|
||||
{
|
||||
var v = line.Substring("height=".Length);
|
||||
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
|
||||
}
|
||||
else if (line.StartsWith("avg_frame_rate="))
|
||||
{
|
||||
var v = line.Substring("avg_frame_rate=".Length);
|
||||
var parts = v.Split('/');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) &&
|
||||
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) &&
|
||||
den != 0)
|
||||
{
|
||||
fps = num / den;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.WaitForExit();
|
||||
|
||||
return new(duration, width, height, fps, bitrate);
|
||||
}
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
# Splitter
|
||||
|
||||
Splitter is a high-performance command line tool for cutting one or more video files into equal or fixed-length segments using multi-threaded FFmpeg execution.
|
||||
It supports batch input, flexible duration formats, rotation, smart face/body-aware cropping, ETA and speed reporting, and both rich and plain-text terminal output.
|
||||
|
||||

|
||||
|
||||
## 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,269 +3,69 @@ using System.Globalization;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
||||
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
||||
{
|
||||
// ------------------------------------------------------------
|
||||
// Internal state (opaque to caller)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
private sealed class State : IFrameProcessingState
|
||||
public async Task ProcessSegment(SingleTask job)
|
||||
{
|
||||
public Process? DecodeProcess { get; set; }
|
||||
public Stream? DecodeStdout { get; set; }
|
||||
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;
|
||||
|
||||
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);
|
||||
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
|
||||
|
||||
string args;
|
||||
|
||||
var rotation = GetRotationFilter(job.Job.Rotate);
|
||||
if (rotation == null)
|
||||
{
|
||||
args =
|
||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{inputFile}\" " +
|
||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-c copy {string.Join(" ", job.Job.Passthrough)} " +
|
||||
$"\"{outputFile}\" -y";
|
||||
$"-c copy {pass} \"{outputFile}\" -y";
|
||||
}
|
||||
else
|
||||
{
|
||||
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}\" ";
|
||||
|
||||
// Rotation → must re-encode
|
||||
args =
|
||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{inputFile}\" " +
|
||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||
sarArg + darArg +
|
||||
$"-vf \"{rotation}\" " +
|
||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||
"-c:a copy " +
|
||||
$"{string.Join(" ", job.Job.Passthrough)} " +
|
||||
$"\"{outputFile}\" -y";
|
||||
$"{pass} \"{outputFile}\" -y";
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode.");
|
||||
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||
ShowFFMpegProgress(length, proc, name);
|
||||
|
||||
proc.WaitForExit();
|
||||
|
||||
ClearProgress();
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
LogError($"Segment {name} FFmpeg encoding failed");
|
||||
else
|
||||
LogInfo($"Segment {name} processing completed");
|
||||
}
|
||||
|
||||
private string? GetRotationFilter(int? degrees) =>
|
||||
string? GetRotationFilter(int? degrees) =>
|
||||
degrees switch
|
||||
{
|
||||
90 => "transpose=1",
|
||||
@ -273,4 +73,66 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
||||
270 => "transpose=2",
|
||||
_ => 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public record Segment(double Start, double End);
|
||||
|
||||
public class SingleJob
|
||||
{
|
||||
/// <summary>
|
||||
/// File path of the input video. This is required for each job and should be
|
||||
/// set to a valid video file path. The splitter will read this file, analyze it,
|
||||
/// and split it into segments based on the specified parameters.
|
||||
/// The output segments will be saved in the OutputFolder with names
|
||||
/// derived from this input file and the Mask pattern if provided.
|
||||
/// </summary>
|
||||
public string InputFile { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// Output folder where the split segments will be saved. This should be set
|
||||
/// to a valid directory path.
|
||||
/// </summary>
|
||||
public string OutputFolder { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// Crop parameters. Width and height for cropping the video. If set, the
|
||||
/// splitter will crop the video to the specified dimensions while tracking the subject.
|
||||
/// </summary>
|
||||
public (int width, int height)? Crop { get; set; }
|
||||
/// <summary>
|
||||
/// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0).
|
||||
/// By default , the splitter gravitates towards the center of the frame (0.5, 0.5).
|
||||
/// Setting this allows you to bias the tracking towards a specific area of the frame,
|
||||
/// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for
|
||||
/// videos where the subject tends to be off-center or for creative framing choices.
|
||||
/// </summary>
|
||||
public Point2f GravitateTo { get; set; } = new Point2f(0.5f, 0.5f);
|
||||
/// <summary>
|
||||
/// Destination file mask.
|
||||
/// </summary>
|
||||
public string? Mask { get; set; }
|
||||
/// <summary>
|
||||
/// Instead of producing the output, just generate debug frames with tracking
|
||||
/// overlay to visually verify that the tracking is working correctly.
|
||||
/// </summary>
|
||||
public bool Debug { get; set; }
|
||||
/// <summary>
|
||||
/// Type of detector to use for tracking. Supported values are: face (UltraFace),
|
||||
/// body (YoloOnnx, default), none (no tracking, just a center point).
|
||||
/// </summary>
|
||||
public string? Detect { get; set; }
|
||||
/// <summary>
|
||||
/// Detection confidence threshold. This is a value between 0.0 and 1.0 that sets the minimum confidence
|
||||
/// </summary>
|
||||
public float ScoreThreshold { get; set; } = 0.25f;
|
||||
/// <summary>
|
||||
/// Identity matching confidence threshold. This is a value between 0.0 and 1.0 that sets the minimum confidence
|
||||
/// </summary>
|
||||
public float IdentityThreshold { get; set; } = 0.25f;
|
||||
/// <summary>
|
||||
/// Face or human detectors should only report detections if their upper bound starts below this threshold.
|
||||
/// This is a value between 0.0 and 1.0 mapped to 0..Height.
|
||||
/// </summary>
|
||||
public float DetectAbove { get; set; } = 0.7f;
|
||||
/// <summary>
|
||||
/// Object ID to track. This is a hexadecimal string that identifies a specific face or
|
||||
/// person to track across segments. This is useful when you want to consistently track the same person
|
||||
/// publacross all segments of a video, even if there are multiple people present
|
||||
/// The ID can be obtained when running with --debug or from the debug overlay.
|
||||
/// </summary>
|
||||
public ulong? DetectId { get; set; }
|
||||
/// <summary>
|
||||
/// Set starget segments length explicitly. By default, the splitter calculates segment
|
||||
/// lengths to be equal and not exceed 58 seconds.
|
||||
/// </summary>
|
||||
public double? OverrideTargetDuration { get; set; }
|
||||
/// <summary>
|
||||
/// Parameters to pass thru to ffmpeg. These are specified after "--" in the command
|
||||
/// line and are passed directly to the ffmpeg command line for each segment.
|
||||
/// </summary>
|
||||
public string[] Passthrough { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Debugging parameter. Instead of text UI putput lines in plain text.
|
||||
/// This is useful when the output is being piped to a file or another program,
|
||||
/// or when the user prefers a simpler log format without progress bars and dynamic updates.
|
||||
/// </summary>
|
||||
public bool PlainText { get; set; }
|
||||
/// <summary>
|
||||
/// Debugging parameter. Just show estimated segments length, count, and other info
|
||||
/// without actually performing the splitting.
|
||||
/// </summary>
|
||||
public bool EstimateOnly { get; set; }
|
||||
/// <summary>
|
||||
/// Do not adapt segment length. When set, the splitter will use the exact
|
||||
/// segment duration specified by --duration for all segments except possibly
|
||||
/// the last one, which may be shorter.
|
||||
/// </summary>
|
||||
public bool ForceFixed { get; set; }
|
||||
/// <summary>
|
||||
/// Use single thread for operations. When set, the splitter will not run
|
||||
/// multiple ffmpeg processes in parallel.
|
||||
/// </summary>
|
||||
public bool SingleThreaded { get; set; }
|
||||
/// <summary>
|
||||
/// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that
|
||||
/// have incorrect orientation metadata.
|
||||
/// </summary>
|
||||
public int? Rotate { get; set; }
|
||||
/// <summary>
|
||||
/// Autodetect if rotation is needed. Not very reliable but can work for some videos.
|
||||
/// Uses edge orientation statistics to determine if the video is rotated and
|
||||
/// applies the appropriate rotation if needed.
|
||||
/// </summary>
|
||||
public bool RotateAuto { get; set; }
|
||||
/// <summary>
|
||||
/// Override internal parameters. This allows you to set custom parameters for the
|
||||
/// object detector or rotation detector.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Parameters { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Increase output resolution by x4 using super-resolution RealBasicVSR_x4 model.
|
||||
/// </summary>
|
||||
public bool Enhance { get; set; }
|
||||
|
||||
public void Override<T>(ref T member, string name)
|
||||
{
|
||||
if (!Parameters.TryGetValue(name, out var raw))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Convert.ChangeType handles int, float, double, etc.
|
||||
var converted = (T)Convert.ChangeType(
|
||||
raw,
|
||||
typeof(T),
|
||||
CultureInfo.InvariantCulture
|
||||
);
|
||||
|
||||
member = converted;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
public void CopyTo(SingleJob target)
|
||||
{
|
||||
target.OutputFolder = OutputFolder;
|
||||
target.Crop = Crop;
|
||||
target.GravitateTo = GravitateTo;
|
||||
target.Mask = Mask;
|
||||
target.Debug = Debug;
|
||||
target.Detect = Detect;
|
||||
target.ScoreThreshold = ScoreThreshold;
|
||||
target.IdentityThreshold = IdentityThreshold;
|
||||
target.DetectAbove = DetectAbove;
|
||||
target.DetectId = DetectId;
|
||||
target.OverrideTargetDuration = OverrideTargetDuration;
|
||||
target.Passthrough = Passthrough.ToArray();
|
||||
target.PlainText = PlainText;
|
||||
target.EstimateOnly = EstimateOnly;
|
||||
target.ForceFixed = ForceFixed;
|
||||
target.SingleThreaded = SingleThreaded;
|
||||
target.Rotate = Rotate;
|
||||
target.RotateAuto = RotateAuto;
|
||||
target.Parameters = new Dictionary<string, string>(Parameters);
|
||||
target.Enhance = Enhance;
|
||||
}
|
||||
|
||||
public void CopyFrom(SingleJob source) => source.CopyTo(this);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace splitter.tui;
|
||||
namespace splitter;
|
||||
|
||||
|
||||
/// <summary>
|
||||
@ -39,7 +39,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
||||
lock (_sync)
|
||||
{
|
||||
_numberOfProcesses = Math.Max(1, value);
|
||||
for (var i = 0; i < _numberOfProcesses; i++)
|
||||
for (int i = 0; i < _numberOfProcesses; i++)
|
||||
{
|
||||
if (!_progress.ContainsKey(i))
|
||||
_progress[i] = ProgressEntry.Empty;
|
||||
@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
||||
|
||||
// ---- ILogger ----
|
||||
|
||||
public void ClearProgress(string name, int progressLine)
|
||||
public void ClearProgress(int progressLevel)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_progress[progressLine] = ProgressEntry.Empty;
|
||||
_progress[progressLevel] = ProgressEntry.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,17 +282,17 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
||||
if (width <= 0)
|
||||
return string.Empty;
|
||||
|
||||
var filled = (int)Math.Round(progress * width);
|
||||
var empty = width - filled;
|
||||
int filled = (int)Math.Round(progress * width);
|
||||
int empty = width - filled;
|
||||
|
||||
if (filled <= 0)
|
||||
return $"[grey]{new string('─', width)}[/]";
|
||||
|
||||
// Split filled part into three segments: blue / yellow / green
|
||||
// low progress: mostly blue; mid: yellow; high: green
|
||||
var blueCount = (int)Math.Round(filled * 0.33);
|
||||
var yellowCount = (int)Math.Round(filled * 0.34);
|
||||
var greenCount = filled - blueCount - yellowCount;
|
||||
int blueCount = (int)Math.Round(filled * 0.33);
|
||||
int yellowCount = (int)Math.Round(filled * 0.34);
|
||||
int greenCount = filled - blueCount - yellowCount;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@ -375,7 +375,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
||||
return new Measurement(width, width);
|
||||
}
|
||||
|
||||
public IEnumerable<Spectre.Console.Rendering.Segment> Render(RenderOptions options, int maxWidth)
|
||||
public IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
|
||||
{
|
||||
var width = Math.Max(1, maxWidth);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
namespace splitter.tui;
|
||||
namespace splitter;
|
||||
|
||||
public class TextLogger() : ILogger
|
||||
{
|
||||
@ -13,6 +13,6 @@ public class TextLogger() : ILogger
|
||||
}
|
||||
|
||||
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
|
||||
public void ClearProgress(string name, int progressLine) {}
|
||||
public void ClearProgress(int progressLevel){}
|
||||
|
||||
}
|
||||
@ -1,196 +1,138 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
private readonly IObjectDetector _detector;
|
||||
|
||||
public TrackingSplitter(
|
||||
int progressLine,
|
||||
IObjectTracker tracker,
|
||||
IObjectDetector detector,
|
||||
SingleJob cmd,
|
||||
ILogger logger)
|
||||
: base(logger, progressLine)
|
||||
{
|
||||
_tracker = tracker;
|
||||
_detector = detector;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC PREVIEW API
|
||||
// ============================================================
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// InitSegment
|
||||
// ------------------------------------------------------------
|
||||
|
||||
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
||||
public void Dispose()
|
||||
{
|
||||
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)
|
||||
if (_detector is IDisposable d)
|
||||
d.Dispose();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PROCESSSEGMENT (full pipeline)
|
||||
// ============================================================
|
||||
|
||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||
public async Task ProcessSegment(SingleTask job)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
||||
var fps = job.Info.Fps;
|
||||
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;
|
||||
double bitrate = job.Info.Bitrate;
|
||||
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
|
||||
|
||||
var state = (FrameProcessingState)InitSegment(job, token);
|
||||
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||
|
||||
var encode = await StartFfmpegEncode(
|
||||
job.Job.InputFile,
|
||||
job.OutputFileName,
|
||||
job.SegmentStart,
|
||||
job.SegmentLength,
|
||||
state.OutMat.Width,
|
||||
state.OutMat.Height,
|
||||
job.Info,
|
||||
job.Job.Passthrough,
|
||||
job.Job.PlainText,
|
||||
token);
|
||||
// 1) Probe source video
|
||||
if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0)
|
||||
{
|
||||
LogError($"{name}: ffprobe failed to get metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Job.Crop == null)
|
||||
{
|
||||
LogError($"{name}: Crop parameters are required");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var totalFrames = (int)Math.Round(job.SegmentLength * fps);
|
||||
var frameIndex = 0;
|
||||
// Separate input/output sizes and buffers
|
||||
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;
|
||||
|
||||
while (frameIndex < totalFrames)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var frame = GetNextProcessedFrame(state, token);
|
||||
if (frame == null)
|
||||
break;
|
||||
|
||||
frameIndex++;
|
||||
|
||||
EncodeFrame(frame, state, encodeStdin);
|
||||
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
|
||||
if (read != inBytes)
|
||||
break;
|
||||
|
||||
// input frame → Mat
|
||||
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
|
||||
|
||||
var objects = _detector.DetectAll(frameMat);
|
||||
var primary = SelectTrackedObject(objects, kalman.LastMeasurement);
|
||||
|
||||
camera.Update(primary);
|
||||
var roi = camera.Roi;
|
||||
|
||||
if (job.Job.Debug)
|
||||
{
|
||||
DrawDebug(frameMat, objects, camera, kalman);
|
||||
frameMat.CopyTo(outMat);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var cropped = new Mat(frameMat, roi);
|
||||
cropped.CopyTo(outMat);
|
||||
}
|
||||
|
||||
// output Mat → outBuffer
|
||||
Marshal.Copy(outMat.Data, outBuffer, 0, outBytes);
|
||||
encodeStdin.Write(outBuffer, 0, outBytes);
|
||||
|
||||
var elapsed = DateTime.UtcNow - startTime;
|
||||
var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0;
|
||||
var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0;
|
||||
|
||||
var remainingFrames = Math.Max(totalFrames - frameIndex, 0);
|
||||
var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0;
|
||||
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||
@ -199,138 +141,45 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
}
|
||||
|
||||
encodeStdin.Flush();
|
||||
encodeStdin.Close();
|
||||
|
||||
// loop finished
|
||||
|
||||
encodeStdin.Flush();
|
||||
encodeStdin.Close(); // must happen before waiting encode
|
||||
|
||||
await encode.WaitForExitAsync();
|
||||
|
||||
ClearProgress(name);
|
||||
// belt-and-braces: if decode is still alive, kill it
|
||||
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
||||
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
||||
|
||||
ClearProgress();
|
||||
|
||||
|
||||
if (encode.ExitCode != 0)
|
||||
LogError($"{name}: FFmpeg encoding failed");
|
||||
else
|
||||
LogInfo($"{name}: Segment processing completed");
|
||||
|
||||
FinishSegment(state);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INTERNAL HELPERS
|
||||
// ============================================================
|
||||
|
||||
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;
|
||||
// ---------- FFmpeg decode / encode ----------
|
||||
|
||||
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)
|
||||
private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
|
||||
{
|
||||
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
var rotateStr = GetRorationArg(rotate);
|
||||
var rotateStr = "";
|
||||
if (rotate != null)
|
||||
{
|
||||
switch (rotate.Value)
|
||||
{
|
||||
case 90: rotateStr = ",transpose=1"; break;
|
||||
case 180: rotateStr = ",transpose=PI"; break;
|
||||
case 270: rotateStr = ",transpose=2"; break;
|
||||
}
|
||||
}
|
||||
|
||||
var args =
|
||||
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
||||
@ -353,12 +202,12 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
|
||||
var fileName = Path.GetFileName(inputFile);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||
while ((line = p.StandardError.ReadLine()) != null)
|
||||
if (plainText)
|
||||
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
||||
}
|
||||
@ -368,66 +217,34 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
return p;
|
||||
}
|
||||
|
||||
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(
|
||||
private Process StartFfmpegEncode(
|
||||
string inputFile,
|
||||
string outputFile,
|
||||
double start,
|
||||
double length,
|
||||
int width,
|
||||
int height,
|
||||
VideoInfo info,
|
||||
double fps,
|
||||
string[] passthrough,
|
||||
bool plainText,
|
||||
CancellationToken token)
|
||||
bool plainText)
|
||||
{
|
||||
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
||||
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
var 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 =
|
||||
"-y " +
|
||||
$"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " +
|
||||
$"-ss {ss} -i \"{inputFile}\" " +
|
||||
"-map 0:v:0 -map 1:a:0? -shortest " +
|
||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||
sarArg + darArg +
|
||||
"-c:a copy " +
|
||||
pass + $" \"{outputFile}\"";
|
||||
|
||||
// "-c:a aac -b:a 192k " +
|
||||
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
@ -443,53 +260,31 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
|
||||
var fileName = Path.GetFileName(outputFile);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||
while ((line = p.StandardError.ReadLine()) != null)
|
||||
{
|
||||
if (plainText)
|
||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// ---------- helpers ----------
|
||||
|
||||
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)
|
||||
private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
while (total < count)
|
||||
{
|
||||
var read = await s.ReadAsync(buffer, offset + total, count - total, token);
|
||||
var read = s.Read(buffer, offset + total, count - total);
|
||||
if (read <= 0)
|
||||
break;
|
||||
total += read;
|
||||
@ -497,5 +292,83 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
||||
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,7 +1,8 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using OpenCvSharp;
|
||||
using UltraFaceDotNet;
|
||||
|
||||
namespace splitter.algo;
|
||||
namespace splitter;
|
||||
|
||||
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||
{
|
||||
@ -23,14 +24,14 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||
_ultraFace = UltraFace.Create(param);
|
||||
}
|
||||
|
||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
||||
{
|
||||
// Convert to byte[] for UltraFace
|
||||
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
|
||||
var bgr = new byte[bytesFull];
|
||||
Marshal.Copy(frameCont.Data, bgr, 0, bytesFull);
|
||||
|
||||
var results = new List<DetectedPerson>();
|
||||
var results = new List<(Rect box, Point2f center)>();
|
||||
|
||||
if (bgr == null || bgr.Length == 0)
|
||||
return results;
|
||||
@ -51,10 +52,10 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||
|
||||
foreach (var f in faces)
|
||||
{
|
||||
var x1 = (int)f.X1;
|
||||
var y1 = (int)f.Y1;
|
||||
var x2 = (int)f.X2;
|
||||
var y2 = (int)f.Y2;
|
||||
int x1 = (int)f.X1;
|
||||
int y1 = (int)f.Y1;
|
||||
int x2 = (int)f.X2;
|
||||
int y2 = (int)f.Y2;
|
||||
|
||||
var rect = new Rect(
|
||||
x1,
|
||||
@ -69,7 +70,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||
rect.X + rect.Width / 2f,
|
||||
rect.Y + rect.Height / 2f);
|
||||
|
||||
results.Add(new DetectedPerson{ Box = rect, Center = center });
|
||||
results.Add((rect, center));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
using System.Diagnostics;
|
||||
using OpenCvSharp;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace splitter.probe;
|
||||
namespace splitter;
|
||||
|
||||
public sealed class VideoRotationSampler
|
||||
{
|
||||
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
|
||||
|
||||
public static int RotationDetectorSampleCount = 10;
|
||||
public static int RotationDetectorSampleCount = 20;
|
||||
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
|
||||
public static int RotationDetectorFrameWidth = 320;
|
||||
public static int RotationDetectorFrameHeight = 180;
|
||||
@ -15,22 +16,19 @@ public sealed class VideoRotationSampler
|
||||
private readonly byte[] _buffer;
|
||||
private readonly Mat _frameMat;
|
||||
|
||||
public VideoRotationSampler(IDictionary<string, string>? overrides)
|
||||
public VideoRotationSampler(SingleJob _master)
|
||||
{
|
||||
if (overrides != null)
|
||||
{
|
||||
if (overrides.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||
RotationDetectorSampleCount = int.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorSampleLength", out s))
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s))
|
||||
RotationDetectorSampleLength = double.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||
RotationDetectorFrameWidth = int.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||
RotationDetectorFrameHeight = int.Parse(s);
|
||||
}
|
||||
|
||||
var w = RotationDetectorFrameWidth;
|
||||
var h = RotationDetectorFrameHeight;
|
||||
int w = RotationDetectorFrameWidth;
|
||||
int h = RotationDetectorFrameHeight;
|
||||
|
||||
_buffer = new byte[w * h * 3]; // raw BGR24 buffer
|
||||
_frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer
|
||||
@ -38,29 +36,27 @@ public sealed class VideoRotationSampler
|
||||
|
||||
public async Task<int> DetectRotationAsync(
|
||||
string inputFile,
|
||||
double videoLengthSeconds,
|
||||
CancellationToken token)
|
||||
double videoLengthSeconds)
|
||||
{
|
||||
if (videoLengthSeconds <= 0)
|
||||
return 0;
|
||||
|
||||
var rotations = new List<int>();
|
||||
|
||||
for (var i = 0; i < RotationDetectorSampleCount; i++)
|
||||
for (int i = 0; i < RotationDetectorSampleCount; i++)
|
||||
{
|
||||
var t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
|
||||
double t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
|
||||
|
||||
var frame = await DecodeSingleFrameAsync(
|
||||
inputFile,
|
||||
t,
|
||||
RotationDetectorSampleLength,
|
||||
RotationDetectorFrameWidth,
|
||||
RotationDetectorFrameHeight,
|
||||
token);
|
||||
RotationDetectorFrameHeight);
|
||||
|
||||
if (frame != null && !frame.Empty())
|
||||
{
|
||||
var rot = _detector.GetRotation(frame);
|
||||
int rot = _detector.GetRotation(frame);
|
||||
rotations.Add(rot);
|
||||
}
|
||||
}
|
||||
@ -80,8 +76,8 @@ public sealed class VideoRotationSampler
|
||||
counts[v]++;
|
||||
}
|
||||
|
||||
var best = 0;
|
||||
var bestCount = 0;
|
||||
int best = 0;
|
||||
int bestCount = 0;
|
||||
|
||||
foreach (var kv in counts)
|
||||
{
|
||||
@ -100,21 +96,18 @@ public sealed class VideoRotationSampler
|
||||
double start,
|
||||
double length,
|
||||
int width,
|
||||
int height,
|
||||
CancellationToken token)
|
||||
int height)
|
||||
{
|
||||
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
|
||||
|
||||
var needed = _buffer.Length;
|
||||
var read = 0;
|
||||
int needed = _buffer.Length;
|
||||
int read = 0;
|
||||
|
||||
using var stdout = p.StandardOutput.BaseStream;
|
||||
|
||||
while (read < needed)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var r = await stdout.ReadAsync(_buffer, read, needed - read, token);
|
||||
int r = await stdout.ReadAsync(_buffer, read, needed - read);
|
||||
if (r == 0)
|
||||
return null;
|
||||
read += r;
|
||||
@ -1,10 +1,11 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter.algo;
|
||||
namespace splitter;
|
||||
|
||||
public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||
{
|
||||
private readonly InferenceSession _session;
|
||||
private readonly string _inputName;
|
||||
@ -32,7 +33,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
private readonly List<Detection> _nmsBuffer = new(256);
|
||||
|
||||
// Reusable result list
|
||||
private readonly List<DetectedPerson> _results = new(64);
|
||||
private readonly List<(Rect box, Point2f center)> _results = new(64);
|
||||
|
||||
private readonly float _inv255 = 1f / 255f;
|
||||
|
||||
@ -54,7 +55,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
}
|
||||
}
|
||||
|
||||
public YoloV8ObjectDetector(ILogger logger) : base(logger, -1)
|
||||
public YoloOnnxObjectDetector(ILogger logger) : base(logger, -1)
|
||||
{
|
||||
var options = new SessionOptions();
|
||||
options.AppendExecutionProvider_DML();
|
||||
@ -78,7 +79,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
||||
}
|
||||
|
||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
||||
{
|
||||
if (frameCont.Empty())
|
||||
{
|
||||
@ -125,24 +126,28 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
|
||||
// Build reusable result list
|
||||
_results.Clear();
|
||||
for (var i = 0; i < final.Count; i++)
|
||||
for (int 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;
|
||||
int x = (int)d.X;
|
||||
int y = (int)d.Y;
|
||||
int w = (int)d.Width;
|
||||
int 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);
|
||||
|
||||
// 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 center = new Point2f(x + w / 2f, y + h / 2f);
|
||||
|
||||
_results.Add(new DetectedPerson{ Box = rect, Center = center });
|
||||
_results.Add((rect, center));
|
||||
}
|
||||
|
||||
return _results;
|
||||
@ -151,30 +156,30 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void FillInputTensor(Mat rgb)
|
||||
{
|
||||
var height = _inputHeight;
|
||||
var width = _inputWidth;
|
||||
int height = _inputHeight;
|
||||
int width = _inputWidth;
|
||||
|
||||
// NCHW: [1, 3, H, W]
|
||||
var planeSize = height * width;
|
||||
int planeSize = height * width;
|
||||
|
||||
Span<float> dst = _inputBuffer.AsSpan();
|
||||
|
||||
unsafe
|
||||
{
|
||||
for (var y = 0; y < height; y++)
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
var rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||
byte* rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
||||
|
||||
var srcIndex = 0;
|
||||
int srcIndex = 0;
|
||||
|
||||
for (var x = 0; x < width; x++)
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
var r = rowSpan[srcIndex + 0];
|
||||
var g = rowSpan[srcIndex + 1];
|
||||
var b = rowSpan[srcIndex + 2];
|
||||
byte r = rowSpan[srcIndex + 0];
|
||||
byte g = rowSpan[srcIndex + 1];
|
||||
byte b = rowSpan[srcIndex + 2];
|
||||
|
||||
var offset = y * width + x;
|
||||
int offset = y * width + x;
|
||||
|
||||
// channel 0: R
|
||||
dst[offset] = r * _inv255;
|
||||
@ -201,27 +206,27 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
detections.Clear();
|
||||
|
||||
// YOLOv8 output: [1, 84, 8400]
|
||||
var channels = output.Dimensions[1]; // 84
|
||||
var count = output.Dimensions[2]; // 8400
|
||||
int channels = output.Dimensions[1]; // 84
|
||||
int count = output.Dimensions[2]; // 8400
|
||||
|
||||
var xScale = (float)originalWidth / 640f;
|
||||
var yScale = (float)originalHeight / 640f;
|
||||
float xScale = (float)originalWidth / 640f;
|
||||
float yScale = (float)originalHeight / 640f;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var x = output[0, 0, i];
|
||||
var y = output[0, 1, i];
|
||||
var w = output[0, 2, i];
|
||||
var h = output[0, 3, i];
|
||||
float x = output[0, 0, i];
|
||||
float y = output[0, 1, i];
|
||||
float w = output[0, 2, i];
|
||||
float h = output[0, 3, i];
|
||||
|
||||
var classScore = output[0, 4 + classIndex, i];
|
||||
float classScore = output[0, 4 + classIndex, i];
|
||||
if (classScore < scoreThreshold)
|
||||
continue;
|
||||
|
||||
var left = (x - w / 2f) * xScale;
|
||||
var top = (y - h / 2f) * yScale;
|
||||
var width = w * xScale;
|
||||
var height = h * yScale;
|
||||
float left = (x - w / 2f) * xScale;
|
||||
float top = (y - h / 2f) * yScale;
|
||||
float width = w * xScale;
|
||||
float height = h * yScale;
|
||||
|
||||
detections.Add(new Detection
|
||||
(
|
||||
@ -248,12 +253,12 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
// Sort in-place by score descending
|
||||
detections.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
||||
|
||||
for (var i = 0; i < detections.Count; i++)
|
||||
for (int i = 0; i < detections.Count; i++)
|
||||
{
|
||||
var candidate = detections[i];
|
||||
var keep = true;
|
||||
bool keep = true;
|
||||
|
||||
for (var j = 0; j < nmsBuffer.Count; j++)
|
||||
for (int j = 0; j < nmsBuffer.Count; j++)
|
||||
{
|
||||
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
||||
{
|
||||
@ -272,23 +277,23 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
||||
[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);
|
||||
float x1 = MathF.Max(a.X, b.X);
|
||||
float y1 = MathF.Max(a.Y, b.Y);
|
||||
float x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
||||
float y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
||||
|
||||
var interW = x2 - x1;
|
||||
float interW = x2 - x1;
|
||||
if (interW <= 0f) return 0f;
|
||||
|
||||
var interH = y2 - y1;
|
||||
float interH = y2 - y1;
|
||||
if (interH <= 0f) return 0f;
|
||||
|
||||
var interArea = interW * interH;
|
||||
float interArea = interW * interH;
|
||||
|
||||
var areaA = a.Width * a.Height;
|
||||
var areaB = b.Width * b.Height;
|
||||
float areaA = a.Width * a.Height;
|
||||
float areaB = b.Width * b.Height;
|
||||
|
||||
var union = areaA + areaB - interArea;
|
||||
float union = areaA + areaB - interArea;
|
||||
if (union <= 0f) return 0f;
|
||||
|
||||
return interArea / union;
|
||||
@ -1,8 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public struct DetectedPerson
|
||||
{
|
||||
public ulong Id;
|
||||
public Rect Box;
|
||||
public Point2f Center;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public sealed class DummyDetector : IObjectDetector
|
||||
{
|
||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
||||
{
|
||||
var h = job.Info.Height;
|
||||
var w = job.Info.Width;
|
||||
|
||||
var c = job.Job.GravitateTo;
|
||||
var x = (int)(c.X * w);
|
||||
var y = (int)(c.Y * h);
|
||||
|
||||
var center = new Point2f(x, y);
|
||||
var rect = new Rect(x - 1, y - 1, 2, 2);
|
||||
|
||||
return [new DetectedPerson { Box = rect, Center = center }];
|
||||
}
|
||||
|
||||
public void Dispose() {}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public interface IEmbeddingExtractor : IDisposable
|
||||
{
|
||||
float[] Extract(Mat frame, Rect box);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public interface IObjectDetector : IDisposable
|
||||
{
|
||||
List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public interface IObjectTracker
|
||||
{
|
||||
(List<DetectedPerson> objects, DetectedPerson? primary) SelectTrackedObject(SingleTask job, Mat frameMat, Point2f? lastMeasurement);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
namespace splitter.algo;
|
||||
|
||||
public interface IFrameProcessingState
|
||||
{
|
||||
}
|
||||
|
||||
public interface ISegmentProcessor
|
||||
{
|
||||
IFrameProcessingState InitSegment(SingleTask job, CancellationToken token);
|
||||
Mat? GetNextProcessedFrame( IFrameProcessingState processorState, CancellationToken token);
|
||||
void FinishSegment(IFrameProcessingState processorState);
|
||||
|
||||
Task ProcessSegment( SingleTask job, CancellationToken token);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user