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.3" have entirely different histories.
30
.github/workflows/publish.yml
vendored
30
.github/workflows/publish.yml
vendored
@ -5,9 +5,6 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@ -22,32 +19,33 @@ jobs:
|
|||||||
dotnet-version: 10.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore Splitter-UI/Splitter-UI.csproj -r win-x64
|
run: dotnet restore -r win-x64
|
||||||
|
|
||||||
- name: Get Version
|
- name: 'Get Version'
|
||||||
id: version
|
id: version
|
||||||
uses: battila7/get-version-action@v2
|
uses: battila7/get-version-action@v2
|
||||||
|
|
||||||
|
- name: Build Release
|
||||||
|
run: dotnet build -c Release --no-restore /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
run: dotnet publish Splitter-UI/Splitter-UI.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
|
run: dotnet publish -c Release -r win-x64 --self-contained true --no-build /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
|
||||||
|
|
||||||
|
|
||||||
- name: Create ZIP
|
- name: Create ZIP
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$publish = "Splitter-UI/bin/Release/net10.0/win-x64/publish"
|
$publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
|
||||||
$version = "${{ steps.version.outputs.version-without-v }}"
|
$version = ${{ steps.version.outputs.version-without-v }}
|
||||||
$zip = "splitter-win-x64-$version.zip"
|
$zip = "splitter-win-x64-$version-${{ github.run_number }}.zip"
|
||||||
|
|
||||||
if (Test-Path $zip) { Remove-Item $zip }
|
if (Test-Path $zip) { Remove-Item $zip }
|
||||||
Compress-Archive -Path "$publish/*" -DestinationPath $zip
|
Compress-Archive -Path "$publish/*" -DestinationPath $zip
|
||||||
|
|
||||||
Write-Host "Created $zip"
|
Write-Host "Created $zip"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Upload Artifact
|
||||||
uses: softprops/action-gh-release@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
name: splitter-win-x64
|
||||||
name: "Release ${{ github.ref_name }}"
|
path: splitter-win-x64-*.zip
|
||||||
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
|
# 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
|
## 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
|
- Equal or fixed‑length segmentation
|
||||||
- Batch input via file masks or list files
|
- Batch input via file masks or list files
|
||||||
- Smart cropping with face/body tracking
|
- Smart cropping with face/body tracking
|
||||||
- Rotation correction
|
- Rotation correction
|
||||||
- ETA, speed, and progress display
|
- ETA, speed, and progress display
|
||||||
- FFmpeg passthrough for advanced control
|
- FFmpeg passthrough for advanced control
|
||||||
- [Potentially] Cross-platform (.NET 10)
|
- [Potentially] Cross‑platform (.NET 10)
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
### Command line interface
|
|
||||||

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

|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- FFmpeg and FFprobe available in system PATH
|
- FFmpeg and FFprobe available in system PATH
|
||||||
- .NET 10 Runtime or newer
|
- .NET 10 Runtime or newer
|
||||||
|
|
||||||
## 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
|
public enum TrackState
|
||||||
{
|
{
|
||||||
@ -58,7 +60,7 @@ public sealed class CameraController
|
|||||||
_kalman.Reset(_cameraCenter);
|
_kalman.Reset(_cameraCenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Point2f DefaultCenter => _cmd.GravitateTo;
|
private Point2f DefaultCenter => _cmd.GravitateTo ?? new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
||||||
|
|
||||||
public int LostFrames => _lostFrames;
|
public int LostFrames => _lostFrames;
|
||||||
public Point2f CameraCenter => _cameraCenter;
|
public Point2f CameraCenter => _cameraCenter;
|
||||||
@ -68,15 +70,15 @@ public sealed class CameraController
|
|||||||
public Point2f? ObjectCenter => _objectCenter;
|
public Point2f? ObjectCenter => _objectCenter;
|
||||||
public Rect Roi => _roi;
|
public Rect Roi => _roi;
|
||||||
|
|
||||||
public void Update(DetectedPerson? primary)
|
public void Update((Rect box, Point2f center)? primary)
|
||||||
{
|
{
|
||||||
Rect? objectBox = null;
|
Rect? objectBox = null;
|
||||||
Point2f? objectCenter = null;
|
Point2f? objectCenter = null;
|
||||||
|
|
||||||
if (primary.HasValue)
|
if (primary.HasValue)
|
||||||
{
|
{
|
||||||
objectCenter = primary.Value.Center;
|
objectCenter = primary.Value.center;
|
||||||
objectBox = primary.Value.Box;
|
objectBox = primary.Value.box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@ -95,7 +97,7 @@ public sealed class CameraController
|
|||||||
_dropoutCounter = 0;
|
_dropoutCounter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isLost = !objectCenter.HasValue;
|
bool isLost = !objectCenter.HasValue;
|
||||||
|
|
||||||
// LOST / REACQUIRE STATE MACHINE
|
// LOST / REACQUIRE STATE MACHINE
|
||||||
if (isLost)
|
if (isLost)
|
||||||
@ -147,7 +149,7 @@ public sealed class CameraController
|
|||||||
{
|
{
|
||||||
smoothedCenter = _kalman.Update(objectCenter);
|
smoothedCenter = _kalman.Update(objectCenter);
|
||||||
|
|
||||||
var driftEasing = 0.01f;
|
float driftEasing = 0.01f;
|
||||||
var fallbackCenter = new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
var fallbackCenter = new Point2f(_videoWidth / 2f, _videoHeight / 2f);
|
||||||
|
|
||||||
_cameraCenter = new Point2f(
|
_cameraCenter = new Point2f(
|
||||||
@ -1,14 +1,52 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using splitter.util;
|
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
|
public class SingleJob
|
||||||
|
{
|
||||||
|
public string InputFile { get; set; } = null!;
|
||||||
|
public string OutputFolder { get; set; } = null!;
|
||||||
|
public (int width, int height)? Crop { get; set; }
|
||||||
|
public Point2f? GravitateTo { get; set; }
|
||||||
|
public string? Mask { get; set; }
|
||||||
|
public bool Debug { get; set; }
|
||||||
|
public string? Detect { get; set; }
|
||||||
|
public double? OverrideTargetDuration { get; set; }
|
||||||
|
public string[] Passthrough { get; set; } = [];
|
||||||
|
public bool PlainText { get; set; }
|
||||||
|
public bool EstimateOnly { get; set; }
|
||||||
|
public bool ForceFixed { get; set; }
|
||||||
|
public bool SingleThreaded { get; set; }
|
||||||
|
public int? Rotate { get; set; }
|
||||||
|
public bool RotateAuto { get; set; }
|
||||||
|
public Dictionary<string, string> Parameters { get; set; } = [];
|
||||||
|
|
||||||
|
public void Override<T>(ref T member, string name)
|
||||||
|
{
|
||||||
|
if (!Parameters.TryGetValue(name, out var raw))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Convert.ChangeType handles int, float, double, etc.
|
||||||
|
var converted = (T)Convert.ChangeType(
|
||||||
|
raw,
|
||||||
|
typeof(T),
|
||||||
|
CultureInfo.InvariantCulture
|
||||||
|
);
|
||||||
|
|
||||||
|
member = converted;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class CommandLine
|
public sealed class CommandLine
|
||||||
{
|
{
|
||||||
// Default vertical Full HD for YouTube Shorts
|
|
||||||
public const int DefaultW = 607;
|
|
||||||
public const int DefaultH = 1080;
|
|
||||||
|
|
||||||
public SingleJob Master { get; } = new SingleJob();
|
public SingleJob Master { get; } = new SingleJob();
|
||||||
public SingleJob[] Jobs { get; }
|
public SingleJob[] Jobs { get; }
|
||||||
|
|
||||||
@ -74,10 +112,6 @@ public sealed class CommandLine
|
|||||||
{
|
{
|
||||||
Master.Rotate = 90;
|
Master.Rotate = 90;
|
||||||
}
|
}
|
||||||
else if (arg == "--enhance")
|
|
||||||
{
|
|
||||||
Master.Enhance = true;
|
|
||||||
}
|
|
||||||
else if (arg.StartsWith("--rotate="))
|
else if (arg.StartsWith("--rotate="))
|
||||||
{
|
{
|
||||||
var val = arg.Substring("--rotate=".Length);
|
var val = arg.Substring("--rotate=".Length);
|
||||||
@ -86,34 +120,10 @@ public sealed class CommandLine
|
|||||||
else
|
else
|
||||||
throw new FormatException($"Invalid --rotate value: {val}");
|
throw new FormatException($"Invalid --rotate value: {val}");
|
||||||
}
|
}
|
||||||
else if (arg.StartsWith("--detect-id="))
|
|
||||||
{
|
|
||||||
var val = arg.Substring("--detect-id=".Length);
|
|
||||||
if (ulong.TryParse(val, out var detectId))
|
|
||||||
Master.DetectId = detectId;
|
|
||||||
else
|
|
||||||
throw new FormatException($"Invalid --detect-id value: {val}");
|
|
||||||
}
|
|
||||||
else if (arg.StartsWith("--crop="))
|
else if (arg.StartsWith("--crop="))
|
||||||
{
|
{
|
||||||
Master.Crop = ParseCrop(arg.Substring("--crop=".Length));
|
Master.Crop = ParseCrop(arg.Substring("--crop=".Length));
|
||||||
}
|
}
|
||||||
else if (arg.StartsWith("--detect-above="))
|
|
||||||
{
|
|
||||||
var val = arg.Substring("--detect-above=".Length);
|
|
||||||
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var detectAbove) && detectAbove >= 0.0f && detectAbove <= 1.0f)
|
|
||||||
Master.DetectAbove = detectAbove;
|
|
||||||
else
|
|
||||||
Master.DetectAbove = 0.7f;
|
|
||||||
}
|
|
||||||
else if (arg.StartsWith("--score-threshold="))
|
|
||||||
{
|
|
||||||
var val = arg.Substring("--score-threshold=".Length);
|
|
||||||
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var scoreThreshold) && scoreThreshold >= 0.0f && scoreThreshold <= 1.0f)
|
|
||||||
Master.ScoreThreshold = scoreThreshold;
|
|
||||||
else
|
|
||||||
Master.ScoreThreshold = 0.25f;
|
|
||||||
}
|
|
||||||
else if (arg == "--crop")
|
else if (arg == "--crop")
|
||||||
{
|
{
|
||||||
Master.Crop = ParseCrop("");
|
Master.Crop = ParseCrop("");
|
||||||
@ -172,11 +182,24 @@ public sealed class CommandLine
|
|||||||
|
|
||||||
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
|
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
|
||||||
|
|
||||||
Jobs = files.Select(x =>
|
Jobs = files.Select(x => new SingleJob
|
||||||
{
|
{
|
||||||
var job = new SingleJob { InputFile = x };
|
InputFile = x,
|
||||||
Master.CopyTo(job);
|
OutputFolder = Master.OutputFolder,
|
||||||
return job;
|
Crop = Master.Crop,
|
||||||
|
GravitateTo = Master.GravitateTo,
|
||||||
|
Mask = Master.Mask,
|
||||||
|
Debug = Master.Debug,
|
||||||
|
Detect = Master.Detect,
|
||||||
|
OverrideTargetDuration = Master.OverrideTargetDuration,
|
||||||
|
Passthrough = Master.Passthrough,
|
||||||
|
PlainText = Master.PlainText,
|
||||||
|
EstimateOnly = Master.EstimateOnly,
|
||||||
|
ForceFixed = Master.ForceFixed,
|
||||||
|
SingleThreaded = Master.SingleThreaded,
|
||||||
|
Rotate = Master.Rotate,
|
||||||
|
RotateAuto = Master.RotateAuto,
|
||||||
|
Parameters = new Dictionary<string, string>(Master.Parameters)
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
if ( Jobs.Length == 0)
|
if ( Jobs.Length == 0)
|
||||||
@ -227,31 +250,35 @@ public sealed class CommandLine
|
|||||||
return key.Length > 0;
|
return key.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Point2f ParseGravitate(string value)
|
private static Point2f? ParseGravitate(string value)
|
||||||
{
|
{
|
||||||
// Expected format: "<x>:<y>"
|
// Expected format: "<x>:<y>"
|
||||||
var parts = value.Split(':');
|
var parts = value.Split(':');
|
||||||
if (parts.Length != 2)
|
if (parts.Length != 2)
|
||||||
return new Point2f(0.5f, 0.5f);
|
return null;
|
||||||
|
|
||||||
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
|
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
|
||||||
return new Point2f(0.5f, 0.5f);
|
return null;
|
||||||
|
|
||||||
if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||||
return new Point2f(0.5f, 0.5f);
|
return null;
|
||||||
|
|
||||||
// Normalized range check (0.0–1.0)
|
// Normalized range check (0.0–1.0)
|
||||||
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
||||||
return new Point2f(0.5f, 0.5f);
|
return null;
|
||||||
|
|
||||||
return new Point2f(x, y);
|
return new Point2f(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (int width, int height)? ParseCrop(string v)
|
private static (int width, int height)? ParseCrop(string v)
|
||||||
{
|
{
|
||||||
|
// Default vertical Full HD for YouTube Shorts
|
||||||
|
const int defaultW = 607;
|
||||||
|
const int defaultH = 1080;
|
||||||
|
|
||||||
// Empty or whitespace → default crop
|
// Empty or whitespace → default crop
|
||||||
if (string.IsNullOrWhiteSpace(v))
|
if (string.IsNullOrWhiteSpace(v))
|
||||||
return (DefaultW, DefaultH);
|
return (defaultW, defaultH);
|
||||||
|
|
||||||
var s = v.Trim().ToLowerInvariant();
|
var s = v.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
@ -343,9 +370,6 @@ Options:
|
|||||||
Last segment may be shorter.
|
Last segment may be shorter.
|
||||||
Default: OFF
|
Default: OFF
|
||||||
|
|
||||||
--enhance Enable video enhancement.
|
|
||||||
Increases output resolution x4 Using RealBasicVSR_x4 model.
|
|
||||||
|
|
||||||
--rotate=<degrees> Rotate video by specified degrees (90, 180, 270).
|
--rotate=<degrees> Rotate video by specified degrees (90, 180, 270).
|
||||||
Useful for videos with incorrect orientation metadata.
|
Useful for videos with incorrect orientation metadata.
|
||||||
|
|
||||||
@ -362,14 +386,6 @@ Options:
|
|||||||
--detect=<name> Object detector to use for tracking.
|
--detect=<name> Object detector to use for tracking.
|
||||||
Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center)
|
Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center)
|
||||||
|
|
||||||
--detect-above=<0-1> Face or human detectors should only report detections if their upper bound starts below this threshold.
|
|
||||||
This is a value between 0.0 and 1.0 mapped to 0..Height.
|
|
||||||
|
|
||||||
--detect-id=<hex> Object ID to track. This is a hexadecimal string that identifies a specific face or
|
|
||||||
person to track across segments. This is useful when you want to consistently track the same person
|
|
||||||
across all segments of a video, even if there are multiple people present.
|
|
||||||
The ID can be obtained when running with --debug or from the debug overlay.
|
|
||||||
|
|
||||||
--gravitate=<x:y> Gravitate towards a specific point (x, y) in the video frame when tracking.
|
--gravitate=<x:y> Gravitate towards a specific point (x, y) in the video frame when tracking.
|
||||||
Coordinates are normalized (0.0 to 1.0).
|
Coordinates are normalized (0.0 to 1.0).
|
||||||
Example: --gravitate=0.2:0.5 (gravitate towards left-center)
|
Example: --gravitate=0.2:0.5 (gravitate towards left-center)
|
||||||
|
|||||||
@ -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
|
public static class FileMaskExpander
|
||||||
{
|
{
|
||||||
@ -8,8 +8,8 @@ public static class FileMaskExpander
|
|||||||
if (!HasMask(input))
|
if (!HasMask(input))
|
||||||
return [Path.GetFullPath(input)];
|
return [Path.GetFullPath(input)];
|
||||||
|
|
||||||
var directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
|
string directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
|
||||||
var pattern = Path.GetFileName(input);
|
string pattern = Path.GetFileName(input);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(directory))
|
if (string.IsNullOrEmpty(directory))
|
||||||
directory = Directory.GetCurrentDirectory();
|
directory = Directory.GetCurrentDirectory();
|
||||||
@ -1,4 +1,6 @@
|
|||||||
namespace splitter.probe;
|
using OpenCvSharp;
|
||||||
|
|
||||||
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class FrameRotationDetector
|
public sealed class FrameRotationDetector
|
||||||
{
|
{
|
||||||
@ -42,28 +44,28 @@ public sealed class FrameRotationDetector
|
|||||||
Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true);
|
Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true);
|
||||||
|
|
||||||
// 4. Clear histogram
|
// 4. Clear histogram
|
||||||
for (var i = 0; i < _bins; i++)
|
for (int i = 0; i < _bins; i++)
|
||||||
_hist[i] = 0;
|
_hist[i] = 0;
|
||||||
|
|
||||||
var binSize = 180f / _bins;
|
float binSize = 180f / _bins;
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
var anglePtr = (float*)_angle.Data;
|
float* anglePtr = (float*)_angle.Data;
|
||||||
var magPtr = (float*)_mag.Data;
|
float* magPtr = (float*)_mag.Data;
|
||||||
|
|
||||||
var total = _w * _h;
|
int total = _w * _h;
|
||||||
|
|
||||||
for (var i = 0; i < total; i++)
|
for (int i = 0; i < total; i++)
|
||||||
{
|
{
|
||||||
var m = magPtr[i];
|
float m = magPtr[i];
|
||||||
if (m < 5f) continue; // ignore weak gradients
|
if (m < 5f) continue; // ignore weak gradients
|
||||||
|
|
||||||
var a = anglePtr[i];
|
float a = anglePtr[i];
|
||||||
if (a < 0) a += 360f;
|
if (a < 0) a += 360f;
|
||||||
a = a % 180f;
|
a = a % 180f;
|
||||||
|
|
||||||
var bin = (int)(a / binSize);
|
int bin = (int)(a / binSize);
|
||||||
if (bin < 0) bin = 0;
|
if (bin < 0) bin = 0;
|
||||||
if (bin >= _bins) bin = _bins - 1;
|
if (bin >= _bins) bin = _bins - 1;
|
||||||
|
|
||||||
@ -73,12 +75,12 @@ public sealed class FrameRotationDetector
|
|||||||
|
|
||||||
// 5. Energy around 0° vs 90°
|
// 5. Energy around 0° vs 90°
|
||||||
float e0 = 0, e90 = 0;
|
float e0 = 0, e90 = 0;
|
||||||
var window = 3;
|
int window = 3;
|
||||||
|
|
||||||
var bin0 = 0;
|
int bin0 = 0;
|
||||||
var bin90 = _bins / 2;
|
int bin90 = _bins / 2;
|
||||||
|
|
||||||
for (var i = -window; i <= window; i++)
|
for (int i = -window; i <= window; i++)
|
||||||
{
|
{
|
||||||
e0 += _hist[Wrap(bin0 + i)];
|
e0 += _hist[Wrap(bin0 + i)];
|
||||||
e90 += _hist[Wrap(bin90 + i)];
|
e90 += _hist[Wrap(bin90 + i)];
|
||||||
@ -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
|
public interface ILogger
|
||||||
{
|
{
|
||||||
void ClearProgress(string name, int progressLine);
|
void ClearProgress(int progressLevel);
|
||||||
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
|
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
|
||||||
void Log(string prefix, ConsoleColor color, string msg);
|
void Log(string prefix, ConsoleColor color, string msg);
|
||||||
|
|
||||||
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
|
public sealed class KalmanTracker
|
||||||
{
|
{
|
||||||
@ -35,8 +35,8 @@ public sealed class KalmanTracker
|
|||||||
_state[3] = 0;
|
_state[3] = 0;
|
||||||
|
|
||||||
// Large initial uncertainty
|
// Large initial uncertainty
|
||||||
for (var i = 0; i < 4; i++)
|
for (int i = 0; i < 4; i++)
|
||||||
for (var j = 0; j < 4; j++)
|
for (int j = 0; j < 4; j++)
|
||||||
_p[i, j] = (i == j) ? 1f : 0f;
|
_p[i, j] = (i == j) ? 1f : 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,16 +63,16 @@ public sealed class KalmanTracker
|
|||||||
var z = measurement.Value;
|
var z = measurement.Value;
|
||||||
|
|
||||||
// Innovation y = z - Hx
|
// Innovation y = z - Hx
|
||||||
var yx = z.X - _state[0];
|
float yx = z.X - _state[0];
|
||||||
var yy = z.Y - _state[1];
|
float yy = z.Y - _state[1];
|
||||||
|
|
||||||
// Innovation covariance S = P + R
|
// Innovation covariance S = P + R
|
||||||
var Sx = _p[0, 0] + _r;
|
float Sx = _p[0, 0] + _r;
|
||||||
var Sy = _p[1, 1] + _r;
|
float Sy = _p[1, 1] + _r;
|
||||||
|
|
||||||
// Kalman gain K = P / S
|
// Kalman gain K = P / S
|
||||||
var Kx0 = _p[0, 0] / Sx;
|
float Kx0 = _p[0, 0] / Sx;
|
||||||
var Kx1 = _p[1, 1] / Sy;
|
float Kx1 = _p[1, 1] / Sy;
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_state[0] += Kx0 * yx;
|
_state[0] += Kx0 * yx;
|
||||||
@ -1,11 +1,7 @@
|
|||||||
namespace splitter.tui;
|
namespace splitter;
|
||||||
|
|
||||||
public abstract class LoggingBase(ILogger logger, int _progressLine)
|
public abstract class LoggingBase(ILogger _logger, int _progressLine)
|
||||||
{
|
{
|
||||||
#pragma warning disable IDE1006 // Naming Styles
|
|
||||||
protected ILogger _logger = logger;
|
|
||||||
#pragma warning restore IDE1006 // Naming Styles
|
|
||||||
|
|
||||||
protected void Log(string level, ConsoleColor color, string message)
|
protected void Log(string level, ConsoleColor color, string message)
|
||||||
=> _logger.Log(level, color, message);
|
=> _logger.Log(level, color, message);
|
||||||
|
|
||||||
@ -21,6 +17,6 @@ public abstract class LoggingBase(ILogger logger, int _progressLine)
|
|||||||
protected void DrawProgress(string name, double percent, TimeSpan eta, double fps)
|
protected void DrawProgress(string name, double percent, TimeSpan eta, double fps)
|
||||||
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps);
|
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps);
|
||||||
|
|
||||||
protected void ClearProgress(string name)
|
protected void ClearProgress()
|
||||||
=> _logger.ClearProgress(name,_progressLine);
|
=> _logger.ClearProgress(_progressLine);
|
||||||
}
|
}
|
||||||
13
splitter-cli/Point2f.cs
Normal file
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;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
||||||
{
|
{
|
||||||
// ------------------------------------------------------------
|
public async Task ProcessSegment(SingleTask job)
|
||||||
// Internal state (opaque to caller)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private sealed class State : IFrameProcessingState
|
|
||||||
{
|
{
|
||||||
public Process? DecodeProcess { get; set; }
|
string inputFile = job.Job.InputFile;
|
||||||
public Stream? DecodeStdout { get; set; }
|
string outputFile = job.OutputFileName;
|
||||||
|
double start = job.SegmentStart;
|
||||||
|
double length = job.SegmentLength;
|
||||||
|
int videoWidth = job.Info.Width;
|
||||||
|
int videoHeight = job.Info.Height;
|
||||||
|
double fps = job.Info.Fps;
|
||||||
|
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
|
||||||
|
|
||||||
public string InputFile { get; }
|
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
|
||||||
public double Start { get; }
|
|
||||||
public double Length { get; }
|
|
||||||
public int? Rotate { get; }
|
|
||||||
public string[] Passthrough { get; }
|
|
||||||
public VideoInfo Info { get; }
|
|
||||||
public bool PlainText { get; }
|
|
||||||
|
|
||||||
public State(SingleTask job)
|
|
||||||
{
|
|
||||||
InputFile = job.Job.InputFile;
|
|
||||||
Start = job.SegmentStart;
|
|
||||||
Length = job.SegmentLength;
|
|
||||||
Rotate = job.Job.Rotate;
|
|
||||||
Passthrough = job.Job.Passthrough;
|
|
||||||
Info = job.Info;
|
|
||||||
PlainText = job.Job.PlainText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public SimpleSplitter(int segmentNo, ILogger logger)
|
|
||||||
: base(logger, segmentNo)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// InitSegment
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = new State(job);
|
|
||||||
|
|
||||||
var decode = StartDecode(job, token);
|
|
||||||
state.DecodeProcess = decode;
|
|
||||||
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// GetNextProcessedFrame
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public Mat? GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (State)processorState;
|
|
||||||
|
|
||||||
if (state.DecodeStdout == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// SimpleSplitter does not modify frames; it only copies or rotates.
|
|
||||||
// For preview, we decode raw frames and return them as-is.
|
|
||||||
|
|
||||||
// Determine expected frame size
|
|
||||||
var w = state.Info.Width;
|
|
||||||
var h = state.Info.Height;
|
|
||||||
var bytes = w * h * 3;
|
|
||||||
|
|
||||||
var buffer = new byte[bytes];
|
|
||||||
var read = state.DecodeStdout.Read(buffer, 0, bytes);
|
|
||||||
if (read != bytes)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var mat = new Mat(h, w, MatType.CV_8UC3);
|
|
||||||
System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.Data, bytes);
|
|
||||||
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FinishSegment
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public void FinishSegment(IFrameProcessingState processorState)
|
|
||||||
{
|
|
||||||
var state = (State)processorState;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ProcessSegment (now uses preview API)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (State)InitSegment(job, token);
|
|
||||||
|
|
||||||
var encode = StartEncode(job);
|
|
||||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
|
||||||
|
|
||||||
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var frame = GetNextProcessedFrame(state, token);
|
|
||||||
if (frame == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Write raw frame to encoder
|
|
||||||
var bytes = frame.Width * frame.Height * 3;
|
|
||||||
var buffer = new byte[bytes];
|
|
||||||
System.Runtime.InteropServices.Marshal.Copy(frame.Data, buffer, 0, bytes);
|
|
||||||
encodeStdin.Write(buffer, 0, bytes);
|
|
||||||
|
|
||||||
frame.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
encodeStdin.Flush();
|
|
||||||
encodeStdin.Close();
|
|
||||||
|
|
||||||
await encode.WaitForExitAsync(token);
|
|
||||||
|
|
||||||
FinishSegment(state);
|
|
||||||
|
|
||||||
ClearProgress(name);
|
|
||||||
|
|
||||||
if (encode.ExitCode != 0)
|
|
||||||
LogError($"Segment {name} FFmpeg encoding failed");
|
|
||||||
else
|
|
||||||
LogInfo($"Segment {name} processing completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FFmpeg helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private Process StartDecode(SingleTask job, CancellationToken token)
|
|
||||||
{
|
|
||||||
var ss = job.SegmentStart.ToString("0.###", CultureInfo.InvariantCulture);
|
|
||||||
var t = job.SegmentLength.ToString("0.###", CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
var rotate = GetRotationFilter(job.Job.Rotate);
|
|
||||||
var vf = rotate != null ? $"-vf format=bgr24,{rotate}" : "-vf format=bgr24";
|
|
||||||
|
|
||||||
var args =
|
|
||||||
$"-i \"{job.Job.InputFile}\" -ss {ss} -t {t} " +
|
|
||||||
"-an -sn " +
|
|
||||||
$"{vf} " +
|
|
||||||
"-f rawvideo -";
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "ffmpeg",
|
|
||||||
Arguments = args,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var p = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg decode.");
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Process StartEncode(SingleTask job)
|
|
||||||
{
|
|
||||||
var inputFile = job.Job.InputFile;
|
|
||||||
var outputFile = job.OutputFileName;
|
|
||||||
var start = job.SegmentStart;
|
|
||||||
var length = job.SegmentLength;
|
|
||||||
|
|
||||||
var rotation = GetRotationFilter(job.Job.Rotate);
|
|
||||||
|
|
||||||
string args;
|
string args;
|
||||||
|
var rotation = GetRotationFilter(job.Job.Rotate);
|
||||||
if (rotation == null)
|
if (rotation == null)
|
||||||
{
|
{
|
||||||
args =
|
args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-i \"{inputFile}\" " +
|
$"-i \"{inputFile}\" " +
|
||||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-c copy {string.Join(" ", job.Job.Passthrough)} " +
|
$"-c copy {pass} \"{outputFile}\" -y";
|
||||||
$"\"{outputFile}\" -y";
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var sarArg = "";
|
// Rotation → must re-encode
|
||||||
var darArg = "";
|
|
||||||
|
|
||||||
var sar = job.Info.SampleAspectRatio;
|
|
||||||
if (sar != null)
|
|
||||||
{
|
|
||||||
var sarNum = Convert.ToInt64(job.Info.Sar.X);
|
|
||||||
var sarDen = Convert.ToInt64(job.Info.Sar.Y);
|
|
||||||
|
|
||||||
var w = job.Info.Width;
|
|
||||||
var h = job.Info.Height;
|
|
||||||
|
|
||||||
if (job.Job.Rotate == 90 || job.Job.Rotate == 270)
|
|
||||||
(w, h) = (h, w);
|
|
||||||
|
|
||||||
var darNum = w * sarNum;
|
|
||||||
var darDen = h * sarDen;
|
|
||||||
|
|
||||||
long Gcd(long a, long b)
|
|
||||||
{
|
|
||||||
while (b != 0) (a, b) = (b, a % b);
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
var g = Gcd(darNum, darDen);
|
|
||||||
darNum /= g;
|
|
||||||
darDen /= g;
|
|
||||||
|
|
||||||
sarArg = $"-vf \"{rotation},setsar={sarNum}:{sarDen}\" ";
|
|
||||||
darArg = $"-aspect {darNum}:{darDen} ";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
sarArg = $"-vf \"{rotation}\" ";
|
|
||||||
|
|
||||||
args =
|
args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-i \"{inputFile}\" " +
|
$"-i \"{inputFile}\" " +
|
||||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
sarArg + darArg +
|
$"-vf \"{rotation}\" " +
|
||||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||||
"-c:a copy " +
|
"-c:a copy " +
|
||||||
$"{string.Join(" ", job.Job.Passthrough)} " +
|
$"{pass} \"{outputFile}\" -y";
|
||||||
$"\"{outputFile}\" -y";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
Arguments = args,
|
Arguments = args,
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
|
|
||||||
return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode.");
|
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||||
|
ShowFFMpegProgress(length, proc, name);
|
||||||
|
|
||||||
|
proc.WaitForExit();
|
||||||
|
|
||||||
|
ClearProgress();
|
||||||
|
|
||||||
|
if (proc.ExitCode != 0)
|
||||||
|
LogError($"Segment {name} FFmpeg encoding failed");
|
||||||
|
else
|
||||||
|
LogInfo($"Segment {name} processing completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetRotationFilter(int? degrees) =>
|
string? GetRotationFilter(int? degrees) =>
|
||||||
degrees switch
|
degrees switch
|
||||||
{
|
{
|
||||||
90 => "transpose=1",
|
90 => "transpose=1",
|
||||||
@ -273,4 +73,66 @@ public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor
|
|||||||
270 => "transpose=2",
|
270 => "transpose=2",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
private void ShowFFMpegProgress(double length, Process proc, string name)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = proc.StandardError.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
// Look for "time=00:00:03.52"
|
||||||
|
var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var timeStr = ExtractTimestamp(line, idx + 5);
|
||||||
|
if (timeStr == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!TryParseFfmpegTime(timeStr, out var current))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var progress = current.TotalSeconds / length;
|
||||||
|
if (progress < 0) progress = 0;
|
||||||
|
if (progress > 1) progress = 1;
|
||||||
|
|
||||||
|
var elapsed = sw.Elapsed;
|
||||||
|
var speed = current.TotalSeconds > 0
|
||||||
|
? current.TotalSeconds / elapsed.TotalSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var remaining = length - current.TotalSeconds;
|
||||||
|
var etaSeconds = speed > 0 ? remaining / speed : remaining;
|
||||||
|
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||||
|
|
||||||
|
DrawProgress(name, progress, eta, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractTimestamp(string line, int startIndex)
|
||||||
|
{
|
||||||
|
// FFmpeg formats: HH:MM:SS.xx
|
||||||
|
// We read until whitespace
|
||||||
|
int end = startIndex;
|
||||||
|
while (end < line.Length && !char.IsWhiteSpace(line[end]))
|
||||||
|
end++;
|
||||||
|
|
||||||
|
if (end <= startIndex)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return line[startIndex..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseFfmpegTime(string s, out TimeSpan ts)
|
||||||
|
{
|
||||||
|
// FFmpeg uses "00:00:03.52"
|
||||||
|
return TimeSpan.TryParseExact(
|
||||||
|
s,
|
||||||
|
@"hh\:mm\:ss\.ff",
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out ts);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
using Spectre.Console.Rendering;
|
using Spectre.Console.Rendering;
|
||||||
|
|
||||||
namespace splitter.tui;
|
namespace splitter;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -39,7 +39,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_numberOfProcesses = Math.Max(1, value);
|
_numberOfProcesses = Math.Max(1, value);
|
||||||
for (var i = 0; i < _numberOfProcesses; i++)
|
for (int i = 0; i < _numberOfProcesses; i++)
|
||||||
{
|
{
|
||||||
if (!_progress.ContainsKey(i))
|
if (!_progress.ContainsKey(i))
|
||||||
_progress[i] = ProgressEntry.Empty;
|
_progress[i] = ProgressEntry.Empty;
|
||||||
@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
|
|
||||||
// ---- ILogger ----
|
// ---- ILogger ----
|
||||||
|
|
||||||
public void ClearProgress(string name, int progressLine)
|
public void ClearProgress(int progressLevel)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_progress[progressLine] = ProgressEntry.Empty;
|
_progress[progressLevel] = ProgressEntry.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,17 +282,17 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
if (width <= 0)
|
if (width <= 0)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
var filled = (int)Math.Round(progress * width);
|
int filled = (int)Math.Round(progress * width);
|
||||||
var empty = width - filled;
|
int empty = width - filled;
|
||||||
|
|
||||||
if (filled <= 0)
|
if (filled <= 0)
|
||||||
return $"[grey]{new string('─', width)}[/]";
|
return $"[grey]{new string('─', width)}[/]";
|
||||||
|
|
||||||
// Split filled part into three segments: blue / yellow / green
|
// Split filled part into three segments: blue / yellow / green
|
||||||
// low progress: mostly blue; mid: yellow; high: green
|
// low progress: mostly blue; mid: yellow; high: green
|
||||||
var blueCount = (int)Math.Round(filled * 0.33);
|
int blueCount = (int)Math.Round(filled * 0.33);
|
||||||
var yellowCount = (int)Math.Round(filled * 0.34);
|
int yellowCount = (int)Math.Round(filled * 0.34);
|
||||||
var greenCount = filled - blueCount - yellowCount;
|
int greenCount = filled - blueCount - yellowCount;
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
@ -375,7 +375,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
return new Measurement(width, width);
|
return new Measurement(width, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<Spectre.Console.Rendering.Segment> Render(RenderOptions options, int maxWidth)
|
public IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
|
||||||
{
|
{
|
||||||
var width = Math.Max(1, maxWidth);
|
var width = Math.Max(1, maxWidth);
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter.tui;
|
namespace splitter;
|
||||||
|
|
||||||
public class TextLogger() : ILogger
|
public class TextLogger() : ILogger
|
||||||
{
|
{
|
||||||
@ -13,6 +13,6 @@ public class TextLogger() : ILogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
|
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
|
||||||
public void ClearProgress(string name, int progressLine) {}
|
public void ClearProgress(int progressLevel){}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,196 +1,138 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using OpenCvSharp;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IObjectTracker _tracker;
|
private readonly IObjectDetector _detector;
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// Internal state (never exposed)
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private sealed class FrameProcessingState : IFrameProcessingState
|
|
||||||
{
|
|
||||||
public SingleTask Job { get; }
|
|
||||||
public KalmanTracker Kalman { get; }
|
|
||||||
public CameraController Camera { get; }
|
|
||||||
|
|
||||||
public Mat FrameMat { get; }
|
|
||||||
public Mat OutMat { get; }
|
|
||||||
public byte[] InBuffer { get; }
|
|
||||||
public byte[] OutBuffer { get; }
|
|
||||||
|
|
||||||
public IVideoEnhancer? Enhancer { get; }
|
|
||||||
|
|
||||||
public int InBytes { get; }
|
|
||||||
public int OutBytes { get; }
|
|
||||||
|
|
||||||
public Process? DecodeProcess { get; set; }
|
|
||||||
public Stream? DecodeStdout { get; set; }
|
|
||||||
|
|
||||||
public FrameProcessingState(
|
|
||||||
SingleTask job,
|
|
||||||
KalmanTracker kalman,
|
|
||||||
CameraController camera,
|
|
||||||
Mat frameMat,
|
|
||||||
Mat outMat,
|
|
||||||
byte[] inBuffer,
|
|
||||||
byte[] outBuffer,
|
|
||||||
IVideoEnhancer? enhancer,
|
|
||||||
int inBytes,
|
|
||||||
int outBytes)
|
|
||||||
{
|
|
||||||
Job = job;
|
|
||||||
Kalman = kalman;
|
|
||||||
Camera = camera;
|
|
||||||
FrameMat = frameMat;
|
|
||||||
OutMat = outMat;
|
|
||||||
InBuffer = inBuffer;
|
|
||||||
OutBuffer = outBuffer;
|
|
||||||
Enhancer = enhancer;
|
|
||||||
InBytes = inBytes;
|
|
||||||
OutBytes = outBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public TrackingSplitter(
|
public TrackingSplitter(
|
||||||
int progressLine,
|
int progressLine,
|
||||||
IObjectTracker tracker,
|
IObjectDetector detector,
|
||||||
SingleJob cmd,
|
SingleJob cmd,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
: base(logger, progressLine)
|
: base(logger, progressLine)
|
||||||
{
|
{
|
||||||
_tracker = tracker;
|
_detector = detector;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
public void Dispose()
|
||||||
// PUBLIC PREVIEW API
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// InitSegment
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var state = (FrameProcessingState)CreateFrameState(job);
|
if (_detector is IDisposable d)
|
||||||
|
|
||||||
if (state.Enhancer != null)
|
|
||||||
state.Enhancer.InitializeAsync(
|
|
||||||
state.OutMat.Width,
|
|
||||||
state.OutMat.Height,
|
|
||||||
5,
|
|
||||||
token).Wait(token);
|
|
||||||
|
|
||||||
var decode = StartFfmpegDecode(
|
|
||||||
job.Job.InputFile,
|
|
||||||
job.SegmentStart,
|
|
||||||
job.SegmentLength,
|
|
||||||
job.Job.Rotate,
|
|
||||||
job.Job.PlainText,
|
|
||||||
token).Result;
|
|
||||||
|
|
||||||
state.DecodeProcess = decode;
|
|
||||||
state.DecodeStdout = decode.StandardOutput.BaseStream;
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// GetNextProcessedFrame
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public Mat? GetNextProcessedFrame(
|
|
||||||
IFrameProcessingState processorState,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var state = (FrameProcessingState)processorState;
|
|
||||||
|
|
||||||
if (state.DecodeStdout == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (!TryReadNextFrame(state.DecodeStdout, state, token))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return ProcessFrame(state.FrameMat, state, state.Job, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// FinishSegment
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
public void FinishSegment(IFrameProcessingState processorState)
|
|
||||||
{
|
|
||||||
var state = (FrameProcessingState)processorState;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (state.DecodeProcess != null && !state.DecodeProcess.HasExited)
|
|
||||||
state.DecodeProcess.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
if (state.Enhancer is IAsyncDisposable ad)
|
|
||||||
ad.DisposeAsync().AsTask().Wait();
|
|
||||||
else if (state.Enhancer is IDisposable d)
|
|
||||||
d.Dispose();
|
d.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
public async Task ProcessSegment(SingleTask job)
|
||||||
// PROCESSSEGMENT (full pipeline)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var name = Path.GetFileNameWithoutExtension(job.OutputFileName);
|
string inputFile = job.Job.InputFile;
|
||||||
var fps = job.Info.Fps;
|
string outputFile = job.OutputFileName;
|
||||||
|
double start = job.SegmentStart;
|
||||||
|
double length = job.SegmentLength;
|
||||||
|
int videoWidth = job.Info.Width;
|
||||||
|
int videoHeight = job.Info.Height;
|
||||||
|
double fps = job.Info.Fps;
|
||||||
|
double bitrate = job.Info.Bitrate;
|
||||||
|
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
|
||||||
|
|
||||||
var state = (FrameProcessingState)InitSegment(job, token);
|
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||||
|
|
||||||
var encode = await StartFfmpegEncode(
|
// 1) Probe source video
|
||||||
job.Job.InputFile,
|
if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0)
|
||||||
job.OutputFileName,
|
{
|
||||||
job.SegmentStart,
|
LogError($"{name}: ffprobe failed to get metadata");
|
||||||
job.SegmentLength,
|
return;
|
||||||
state.OutMat.Width,
|
}
|
||||||
state.OutMat.Height,
|
|
||||||
job.Info,
|
if (job.Job.Crop == null)
|
||||||
job.Job.Passthrough,
|
{
|
||||||
job.Job.PlainText,
|
LogError($"{name}: Crop parameters are required");
|
||||||
token);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var encWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width;
|
||||||
|
var encHeight = job.Job.Debug ? videoHeight : job.Job.Crop.Value.height;
|
||||||
|
|
||||||
|
LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] enc={encWidth}x{encHeight}");
|
||||||
|
|
||||||
|
// 2) Start FFmpeg decode (video only → raw BGR24 to stdout)
|
||||||
|
var decode = StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText);
|
||||||
|
using var decodeStdout = decode.StandardOutput.BaseStream;
|
||||||
|
|
||||||
|
// 3) Start FFmpeg encode (video from stdin + audio from original)
|
||||||
|
var encode = StartFfmpegEncode(
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
start,
|
||||||
|
length,
|
||||||
|
encWidth,
|
||||||
|
encHeight,
|
||||||
|
fps,
|
||||||
|
ffmpegPassthroughParameters,
|
||||||
|
job.Job.PlainText);
|
||||||
|
|
||||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
using var encodeStdin = encode.StandardInput.BaseStream;
|
||||||
|
|
||||||
var totalFrames = (int)Math.Round(job.SegmentLength * fps);
|
// Separate input/output sizes and buffers
|
||||||
var frameIndex = 0;
|
var inBytes = videoWidth * videoHeight * 3;
|
||||||
|
var outBytes = encWidth * encHeight * 3;
|
||||||
|
|
||||||
|
var inBuffer = new byte[inBytes];
|
||||||
|
var outBuffer = new byte[outBytes];
|
||||||
|
|
||||||
|
using var frameMat = new Mat(videoHeight, videoWidth, MatType.CV_8UC3);
|
||||||
|
using var outMat = new Mat(encHeight, encWidth, MatType.CV_8UC3);
|
||||||
|
|
||||||
|
var kalman = new KalmanTracker();
|
||||||
|
var camera = new CameraController(
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
job.Job.Crop.Value.width,
|
||||||
|
job.Job.Crop.Value.height,
|
||||||
|
kalman,
|
||||||
|
job.Job);
|
||||||
|
|
||||||
var startTime = DateTime.UtcNow;
|
var startTime = DateTime.UtcNow;
|
||||||
|
var totalFrames = (int)Math.Round(length * fps);
|
||||||
|
var frameIndex = 0;
|
||||||
|
|
||||||
while (frameIndex < totalFrames)
|
while (frameIndex < totalFrames)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var frame = GetNextProcessedFrame(state, token);
|
|
||||||
if (frame == null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
frameIndex++;
|
frameIndex++;
|
||||||
|
|
||||||
EncodeFrame(frame, state, encodeStdin);
|
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
|
||||||
|
if (read != inBytes)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// input frame → Mat
|
||||||
|
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
|
||||||
|
|
||||||
|
var objects = _detector.DetectAll(frameMat);
|
||||||
|
var primary = SelectTrackedObject(objects, kalman.LastMeasurement);
|
||||||
|
|
||||||
|
camera.Update(primary);
|
||||||
|
var roi = camera.Roi;
|
||||||
|
|
||||||
|
if (job.Job.Debug)
|
||||||
|
{
|
||||||
|
DrawDebug(frameMat, objects, camera, kalman);
|
||||||
|
frameMat.CopyTo(outMat);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var cropped = new Mat(frameMat, roi);
|
||||||
|
cropped.CopyTo(outMat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// output Mat → outBuffer
|
||||||
|
Marshal.Copy(outMat.Data, outBuffer, 0, outBytes);
|
||||||
|
encodeStdin.Write(outBuffer, 0, outBytes);
|
||||||
|
|
||||||
var elapsed = DateTime.UtcNow - startTime;
|
var elapsed = DateTime.UtcNow - startTime;
|
||||||
var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0;
|
var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0;
|
||||||
var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0;
|
var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0;
|
||||||
|
|
||||||
var remainingFrames = Math.Max(totalFrames - frameIndex, 0);
|
var remainingFrames = Math.Max(totalFrames - frameIndex, 0);
|
||||||
var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0;
|
var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0;
|
||||||
var eta = TimeSpan.FromSeconds(etaSeconds);
|
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||||
@ -199,138 +141,45 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
encodeStdin.Flush();
|
encodeStdin.Flush();
|
||||||
encodeStdin.Close();
|
|
||||||
|
// loop finished
|
||||||
|
|
||||||
|
encodeStdin.Flush();
|
||||||
|
encodeStdin.Close(); // must happen before waiting encode
|
||||||
|
|
||||||
await encode.WaitForExitAsync();
|
await encode.WaitForExitAsync();
|
||||||
|
|
||||||
ClearProgress(name);
|
// belt-and-braces: if decode is still alive, kill it
|
||||||
|
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
||||||
|
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
||||||
|
|
||||||
|
ClearProgress();
|
||||||
|
|
||||||
|
|
||||||
if (encode.ExitCode != 0)
|
if (encode.ExitCode != 0)
|
||||||
LogError($"{name}: FFmpeg encoding failed");
|
LogError($"{name}: FFmpeg encoding failed");
|
||||||
else
|
else
|
||||||
LogInfo($"{name}: Segment processing completed");
|
LogInfo($"{name}: Segment processing completed");
|
||||||
|
|
||||||
FinishSegment(state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// INTERNAL HELPERS
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private object CreateFrameState(SingleTask job)
|
// ---------- FFmpeg decode / encode ----------
|
||||||
{
|
|
||||||
var w = job.Info.Width;
|
|
||||||
var h = job.Info.Height;
|
|
||||||
var cw = job.Job.Debug ? w : job.Job.Crop!.Value.width;
|
|
||||||
var ch = job.Job.Debug ? h : job.Job.Crop!.Value.height;
|
|
||||||
|
|
||||||
var kalman = new KalmanTracker();
|
private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
|
||||||
var camera = new CameraController(w, h, cw, ch, kalman, job.Job);
|
|
||||||
|
|
||||||
var frameMat = new Mat(h, w, MatType.CV_8UC3);
|
|
||||||
var outMat = new Mat(ch, cw, MatType.CV_8UC3);
|
|
||||||
|
|
||||||
var inBytes = w * h * 3;
|
|
||||||
var outBytes = cw * ch * 3;
|
|
||||||
|
|
||||||
var inBuffer = new byte[inBytes];
|
|
||||||
var outBuffer = new byte[outBytes];
|
|
||||||
|
|
||||||
IVideoEnhancer? enhancer = job.Job.Enhance
|
|
||||||
? new RealBasicVsr2xDmlEnhancer()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return new FrameProcessingState(
|
|
||||||
job,
|
|
||||||
kalman,
|
|
||||||
camera,
|
|
||||||
frameMat,
|
|
||||||
outMat,
|
|
||||||
inBuffer,
|
|
||||||
outBuffer,
|
|
||||||
enhancer,
|
|
||||||
inBytes,
|
|
||||||
outBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryReadNextFrame(
|
|
||||||
Stream decodeStdout,
|
|
||||||
FrameProcessingState state,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var read = ReadExact(
|
|
||||||
decodeStdout,
|
|
||||||
state.InBuffer,
|
|
||||||
0,
|
|
||||||
state.InBytes,
|
|
||||||
token).Result;
|
|
||||||
|
|
||||||
if (read != state.InBytes)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
Marshal.Copy(state.InBuffer, 0, state.FrameMat.Data, state.InBytes);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mat ProcessFrame(
|
|
||||||
Mat inputFrame,
|
|
||||||
FrameProcessingState state,
|
|
||||||
SingleTask job,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var (objects, primary) =
|
|
||||||
_tracker.SelectTrackedObject(job, inputFrame, state.Kalman.LastMeasurement);
|
|
||||||
|
|
||||||
state.Camera.Update(primary);
|
|
||||||
var roi = state.Camera.Roi;
|
|
||||||
|
|
||||||
if (job.Job.Debug)
|
|
||||||
{
|
|
||||||
DebugOverlay.DrawDebug(inputFrame, objects, state.Camera, state.Kalman);
|
|
||||||
inputFrame.CopyTo(state.OutMat);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
using var cropped = new Mat(inputFrame, roi);
|
|
||||||
cropped.CopyTo(state.OutMat);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.Enhancer != null)
|
|
||||||
{
|
|
||||||
if (state.Enhancer.TryProcessFrame(state.OutMat, out var enhanced, token))
|
|
||||||
return enhanced;
|
|
||||||
|
|
||||||
return state.OutMat;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.OutMat;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EncodeFrame(
|
|
||||||
Mat frame,
|
|
||||||
FrameProcessingState state,
|
|
||||||
Stream encodeStdin)
|
|
||||||
{
|
|
||||||
Marshal.Copy(frame.Data, state.OutBuffer, 0, state.OutBytes);
|
|
||||||
encodeStdin.Write(state.OutBuffer, 0, state.OutBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// FFmpeg helpers
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
private async Task<Process> StartFfmpegDecode(
|
|
||||||
string inputFile,
|
|
||||||
double start,
|
|
||||||
double length,
|
|
||||||
int? rotate,
|
|
||||||
bool plainText,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var rotateStr = GetRorationArg(rotate);
|
var rotateStr = "";
|
||||||
|
if (rotate != null)
|
||||||
|
{
|
||||||
|
switch (rotate.Value)
|
||||||
|
{
|
||||||
|
case 90: rotateStr = ",transpose=1"; break;
|
||||||
|
case 180: rotateStr = ",transpose=PI"; break;
|
||||||
|
case 270: rotateStr = ",transpose=2"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var args =
|
var args =
|
||||||
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
||||||
@ -353,12 +202,12 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(inputFile);
|
var fileName = Path.GetFileName(inputFile);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
while ((line = p.StandardError.ReadLine()) != null)
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
@ -368,66 +217,34 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetRorationArg(int? rotate)
|
private Process StartFfmpegEncode(
|
||||||
{
|
|
||||||
var rotateStr = "";
|
|
||||||
if (rotate != null)
|
|
||||||
{
|
|
||||||
switch (rotate.Value)
|
|
||||||
{
|
|
||||||
case 90: rotateStr = ",transpose=1"; break;
|
|
||||||
case 180: rotateStr = ",transpose=PI"; break;
|
|
||||||
case 270: rotateStr = ",transpose=2"; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rotateStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Process> StartFfmpegEncode(
|
|
||||||
string inputFile,
|
string inputFile,
|
||||||
string outputFile,
|
string outputFile,
|
||||||
double start,
|
double start,
|
||||||
double length,
|
double length,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
VideoInfo info,
|
double fps,
|
||||||
string[] passthrough,
|
string[] passthrough,
|
||||||
bool plainText,
|
bool plainText)
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
||||||
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio)
|
|
||||||
? $"-vf setsar={info.SampleAspectRatio} "
|
|
||||||
: "";
|
|
||||||
|
|
||||||
var darArg = "";
|
|
||||||
if (info.Sar is { } s)
|
|
||||||
{
|
|
||||||
var darNum = width * s.X;
|
|
||||||
var darDen = height * s.Y;
|
|
||||||
|
|
||||||
var dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum));
|
|
||||||
var dd = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darDen));
|
|
||||||
ReduceFraction(ref dn, ref dd);
|
|
||||||
|
|
||||||
if (dn > 0 && dd > 0)
|
|
||||||
darArg = $"-aspect {dn}:{dd} ";
|
|
||||||
}
|
|
||||||
|
|
||||||
var args =
|
var args =
|
||||||
"-y " +
|
"-y " +
|
||||||
$"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " +
|
$"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " +
|
||||||
$"-ss {ss} -i \"{inputFile}\" " +
|
$"-ss {ss} -i \"{inputFile}\" " +
|
||||||
"-map 0:v:0 -map 1:a:0? -shortest " +
|
"-map 0:v:0 -map 1:a:0? -shortest " +
|
||||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||||
sarArg + darArg +
|
|
||||||
"-c:a copy " +
|
"-c:a copy " +
|
||||||
pass + $" \"{outputFile}\"";
|
pass + $" \"{outputFile}\"";
|
||||||
|
|
||||||
|
// "-c:a aac -b:a 192k " +
|
||||||
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
@ -443,53 +260,31 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(outputFile);
|
var fileName = Path.GetFileName(outputFile);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
while ((line = p.StandardError.ReadLine()) != null)
|
||||||
|
{
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch { }
|
catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ReduceFraction(ref int num, ref int den)
|
// ---------- helpers ----------
|
||||||
{
|
|
||||||
int Gcd(int a, int b)
|
|
||||||
{
|
|
||||||
while (b != 0)
|
|
||||||
{
|
|
||||||
var t = b;
|
|
||||||
b = a % b;
|
|
||||||
a = t;
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
var g = Gcd(Math.Abs(num), Math.Abs(den));
|
private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
|
||||||
if (g > 1)
|
|
||||||
{
|
|
||||||
num /= g;
|
|
||||||
den /= g;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ReadExact(
|
|
||||||
Stream s,
|
|
||||||
byte[] buffer,
|
|
||||||
int offset,
|
|
||||||
int count,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
while (total < count)
|
while (total < count)
|
||||||
{
|
{
|
||||||
var read = await s.ReadAsync(buffer, offset + total, count - total, token);
|
var read = s.Read(buffer, offset + total, count - total);
|
||||||
if (read <= 0)
|
if (read <= 0)
|
||||||
break;
|
break;
|
||||||
total += read;
|
total += read;
|
||||||
@ -497,5 +292,83 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawDebug(
|
||||||
|
Mat frame,
|
||||||
|
System.Collections.Generic.List<(Rect box, Point2f center)> objects,
|
||||||
|
CameraController camera,
|
||||||
|
KalmanTracker kalman)
|
||||||
|
{
|
||||||
|
if (camera.ObjectBox.HasValue)
|
||||||
|
{
|
||||||
|
var fb = camera.ObjectBox.Value;
|
||||||
|
Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cv2.Circle(frame,
|
||||||
|
new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y),
|
||||||
|
6, Scalar.LimeGreen, -1);
|
||||||
|
|
||||||
|
Cv2.Rectangle(frame, camera.Roi,
|
||||||
|
camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3);
|
||||||
|
|
||||||
|
DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White);
|
||||||
|
DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White);
|
||||||
|
DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White);
|
||||||
|
DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawText(Mat img, string text, int x, int y, Scalar color)
|
||||||
|
{
|
||||||
|
Cv2.PutText(img, text, new Point(x, y),
|
||||||
|
HersheyFonts.HersheySimplex, 0.6, color, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Rect box, Point2f center)? SelectTrackedObject(
|
||||||
|
List<(Rect box, Point2f center)> foundObjects,
|
||||||
|
Point2f? previousCenter)
|
||||||
|
{
|
||||||
|
if (foundObjects == null || foundObjects.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!previousCenter.HasValue)
|
||||||
|
{
|
||||||
|
var bestIndex = 0;
|
||||||
|
var bestArea = float.MinValue;
|
||||||
|
|
||||||
|
for (int i = 0; i < foundObjects.Count; i++)
|
||||||
|
{
|
||||||
|
var f = foundObjects[i];
|
||||||
|
var area = f.box.Width * f.box.Height;
|
||||||
|
if (area > bestArea)
|
||||||
|
{
|
||||||
|
bestArea = area;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundObjects[bestIndex];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var prev = previousCenter.Value;
|
||||||
|
var bestIndex = 0;
|
||||||
|
var bestDist2 = float.MaxValue;
|
||||||
|
|
||||||
|
for (int i = 0; i < foundObjects.Count; i++)
|
||||||
|
{
|
||||||
|
var f = foundObjects[i];
|
||||||
|
var dx = f.center.X - prev.X;
|
||||||
|
var dy = f.center.Y - prev.Y;
|
||||||
|
var d2 = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (d2 < bestDist2)
|
||||||
|
{
|
||||||
|
bestDist2 = d2;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundObjects[bestIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using OpenCvSharp;
|
||||||
using UltraFaceDotNet;
|
using UltraFaceDotNet;
|
||||||
|
|
||||||
namespace splitter.algo;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||||
{
|
{
|
||||||
@ -23,14 +24,14 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
|||||||
_ultraFace = UltraFace.Create(param);
|
_ultraFace = UltraFace.Create(param);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
||||||
{
|
{
|
||||||
// Convert to byte[] for UltraFace
|
// Convert to byte[] for UltraFace
|
||||||
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
|
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
|
||||||
var bgr = new byte[bytesFull];
|
var bgr = new byte[bytesFull];
|
||||||
Marshal.Copy(frameCont.Data, bgr, 0, bytesFull);
|
Marshal.Copy(frameCont.Data, bgr, 0, bytesFull);
|
||||||
|
|
||||||
var results = new List<DetectedPerson>();
|
var results = new List<(Rect box, Point2f center)>();
|
||||||
|
|
||||||
if (bgr == null || bgr.Length == 0)
|
if (bgr == null || bgr.Length == 0)
|
||||||
return results;
|
return results;
|
||||||
@ -51,10 +52,10 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
|||||||
|
|
||||||
foreach (var f in faces)
|
foreach (var f in faces)
|
||||||
{
|
{
|
||||||
var x1 = (int)f.X1;
|
int x1 = (int)f.X1;
|
||||||
var y1 = (int)f.Y1;
|
int y1 = (int)f.Y1;
|
||||||
var x2 = (int)f.X2;
|
int x2 = (int)f.X2;
|
||||||
var y2 = (int)f.Y2;
|
int y2 = (int)f.Y2;
|
||||||
|
|
||||||
var rect = new Rect(
|
var rect = new Rect(
|
||||||
x1,
|
x1,
|
||||||
@ -69,7 +70,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
|||||||
rect.X + rect.Width / 2f,
|
rect.X + rect.Width / 2f,
|
||||||
rect.Y + rect.Height / 2f);
|
rect.Y + rect.Height / 2f);
|
||||||
|
|
||||||
results.Add(new DetectedPerson{ Box = rect, Center = center });
|
results.Add((rect, center));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
using System.Diagnostics;
|
using OpenCvSharp;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace splitter.probe;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class VideoRotationSampler
|
public sealed class VideoRotationSampler
|
||||||
{
|
{
|
||||||
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
|
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
|
||||||
|
|
||||||
public static int RotationDetectorSampleCount = 10;
|
public static int RotationDetectorSampleCount = 20;
|
||||||
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
|
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
|
||||||
public static int RotationDetectorFrameWidth = 320;
|
public static int RotationDetectorFrameWidth = 320;
|
||||||
public static int RotationDetectorFrameHeight = 180;
|
public static int RotationDetectorFrameHeight = 180;
|
||||||
@ -15,22 +16,19 @@ public sealed class VideoRotationSampler
|
|||||||
private readonly byte[] _buffer;
|
private readonly byte[] _buffer;
|
||||||
private readonly Mat _frameMat;
|
private readonly Mat _frameMat;
|
||||||
|
|
||||||
public VideoRotationSampler(IDictionary<string, string>? overrides)
|
public VideoRotationSampler(SingleJob _master)
|
||||||
{
|
{
|
||||||
if (overrides != null)
|
if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||||
{
|
|
||||||
if (overrides.TryGetValue("RotationDetectorSampleCount", out var s))
|
|
||||||
RotationDetectorSampleCount = int.Parse(s);
|
RotationDetectorSampleCount = int.Parse(s);
|
||||||
if (overrides.TryGetValue("RotationDetectorSampleLength", out s))
|
if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s))
|
||||||
RotationDetectorSampleLength = double.Parse(s);
|
RotationDetectorSampleLength = double.Parse(s);
|
||||||
if (overrides.TryGetValue("RotationDetectorFrameWidth", out s))
|
if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||||
RotationDetectorFrameWidth = int.Parse(s);
|
RotationDetectorFrameWidth = int.Parse(s);
|
||||||
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
|
if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||||
RotationDetectorFrameHeight = int.Parse(s);
|
RotationDetectorFrameHeight = int.Parse(s);
|
||||||
}
|
|
||||||
|
|
||||||
var w = RotationDetectorFrameWidth;
|
int w = RotationDetectorFrameWidth;
|
||||||
var h = RotationDetectorFrameHeight;
|
int h = RotationDetectorFrameHeight;
|
||||||
|
|
||||||
_buffer = new byte[w * h * 3]; // raw BGR24 buffer
|
_buffer = new byte[w * h * 3]; // raw BGR24 buffer
|
||||||
_frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer
|
_frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer
|
||||||
@ -38,29 +36,27 @@ public sealed class VideoRotationSampler
|
|||||||
|
|
||||||
public async Task<int> DetectRotationAsync(
|
public async Task<int> DetectRotationAsync(
|
||||||
string inputFile,
|
string inputFile,
|
||||||
double videoLengthSeconds,
|
double videoLengthSeconds)
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
if (videoLengthSeconds <= 0)
|
if (videoLengthSeconds <= 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var rotations = new List<int>();
|
var rotations = new List<int>();
|
||||||
|
|
||||||
for (var i = 0; i < RotationDetectorSampleCount; i++)
|
for (int i = 0; i < RotationDetectorSampleCount; i++)
|
||||||
{
|
{
|
||||||
var t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
|
double t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
|
||||||
|
|
||||||
var frame = await DecodeSingleFrameAsync(
|
var frame = await DecodeSingleFrameAsync(
|
||||||
inputFile,
|
inputFile,
|
||||||
t,
|
t,
|
||||||
RotationDetectorSampleLength,
|
RotationDetectorSampleLength,
|
||||||
RotationDetectorFrameWidth,
|
RotationDetectorFrameWidth,
|
||||||
RotationDetectorFrameHeight,
|
RotationDetectorFrameHeight);
|
||||||
token);
|
|
||||||
|
|
||||||
if (frame != null && !frame.Empty())
|
if (frame != null && !frame.Empty())
|
||||||
{
|
{
|
||||||
var rot = _detector.GetRotation(frame);
|
int rot = _detector.GetRotation(frame);
|
||||||
rotations.Add(rot);
|
rotations.Add(rot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,8 +76,8 @@ public sealed class VideoRotationSampler
|
|||||||
counts[v]++;
|
counts[v]++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var best = 0;
|
int best = 0;
|
||||||
var bestCount = 0;
|
int bestCount = 0;
|
||||||
|
|
||||||
foreach (var kv in counts)
|
foreach (var kv in counts)
|
||||||
{
|
{
|
||||||
@ -100,21 +96,18 @@ public sealed class VideoRotationSampler
|
|||||||
double start,
|
double start,
|
||||||
double length,
|
double length,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height)
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
|
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
|
||||||
|
|
||||||
var needed = _buffer.Length;
|
int needed = _buffer.Length;
|
||||||
var read = 0;
|
int read = 0;
|
||||||
|
|
||||||
using var stdout = p.StandardOutput.BaseStream;
|
using var stdout = p.StandardOutput.BaseStream;
|
||||||
|
|
||||||
while (read < needed)
|
while (read < needed)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
int r = await stdout.ReadAsync(_buffer, read, needed - read);
|
||||||
|
|
||||||
var r = await stdout.ReadAsync(_buffer, read, needed - read, token);
|
|
||||||
if (r == 0)
|
if (r == 0)
|
||||||
return null;
|
return null;
|
||||||
read += r;
|
read += r;
|
||||||
@ -1,10 +1,11 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Microsoft.ML.OnnxRuntime;
|
using Microsoft.ML.OnnxRuntime;
|
||||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
|
using OpenCvSharp;
|
||||||
|
|
||||||
namespace splitter.algo;
|
namespace splitter;
|
||||||
|
|
||||||
public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||||
{
|
{
|
||||||
private readonly InferenceSession _session;
|
private readonly InferenceSession _session;
|
||||||
private readonly string _inputName;
|
private readonly string _inputName;
|
||||||
@ -32,7 +33,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
private readonly List<Detection> _nmsBuffer = new(256);
|
private readonly List<Detection> _nmsBuffer = new(256);
|
||||||
|
|
||||||
// Reusable result list
|
// Reusable result list
|
||||||
private readonly List<DetectedPerson> _results = new(64);
|
private readonly List<(Rect box, Point2f center)> _results = new(64);
|
||||||
|
|
||||||
private readonly float _inv255 = 1f / 255f;
|
private readonly float _inv255 = 1f / 255f;
|
||||||
|
|
||||||
@ -54,7 +55,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public YoloV8ObjectDetector(ILogger logger) : base(logger, -1)
|
public YoloOnnxObjectDetector(ILogger logger) : base(logger, -1)
|
||||||
{
|
{
|
||||||
var options = new SessionOptions();
|
var options = new SessionOptions();
|
||||||
options.AppendExecutionProvider_DML();
|
options.AppendExecutionProvider_DML();
|
||||||
@ -78,7 +79,7 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DetectedPerson> DetectAll(SingleTask job, Mat frameCont)
|
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont)
|
||||||
{
|
{
|
||||||
if (frameCont.Empty())
|
if (frameCont.Empty())
|
||||||
{
|
{
|
||||||
@ -125,24 +126,28 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
|
|
||||||
// Build reusable result list
|
// Build reusable result list
|
||||||
_results.Clear();
|
_results.Clear();
|
||||||
for (var i = 0; i < final.Count; i++)
|
for (int i = 0; i < final.Count; i++)
|
||||||
{
|
{
|
||||||
var d = final[i];
|
var d = final[i];
|
||||||
|
|
||||||
var x = (int)d.X;
|
int x = (int)d.X;
|
||||||
var y = (int)d.Y;
|
int y = (int)d.Y;
|
||||||
var w = (int)d.Width;
|
int w = (int)d.Width;
|
||||||
var h = (int)d.Height;
|
int h = (int)d.Height;
|
||||||
|
|
||||||
x = Math.Clamp(x, 0, frameCont.Width - 1);
|
x = Math.Clamp(x, 0, frameCont.Width - 1);
|
||||||
y = Math.Clamp(y, 0, frameCont.Height - 1);
|
y = Math.Clamp(y, 0, frameCont.Height - 1);
|
||||||
w = Math.Clamp(w, 1, frameCont.Width - x);
|
w = Math.Clamp(w, 1, frameCont.Width - x);
|
||||||
h = Math.Clamp(h, 1, frameCont.Height - y);
|
h = Math.Clamp(h, 1, frameCont.Height - y);
|
||||||
|
|
||||||
|
// Ignore detections starting in the lower 1/2 of the frame
|
||||||
|
if (y > frameCont.Height * 0.5f)
|
||||||
|
continue;
|
||||||
|
|
||||||
var rect = new Rect(x, y, w, h);
|
var rect = new Rect(x, y, w, h);
|
||||||
var center = new Point2f(x + w / 2f, y + h / 2f);
|
var center = new Point2f(x + w / 2f, y + h / 2f);
|
||||||
|
|
||||||
_results.Add(new DetectedPerson{ Box = rect, Center = center });
|
_results.Add((rect, center));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _results;
|
return _results;
|
||||||
@ -151,30 +156,30 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void FillInputTensor(Mat rgb)
|
private void FillInputTensor(Mat rgb)
|
||||||
{
|
{
|
||||||
var height = _inputHeight;
|
int height = _inputHeight;
|
||||||
var width = _inputWidth;
|
int width = _inputWidth;
|
||||||
|
|
||||||
// NCHW: [1, 3, H, W]
|
// NCHW: [1, 3, H, W]
|
||||||
var planeSize = height * width;
|
int planeSize = height * width;
|
||||||
|
|
||||||
Span<float> dst = _inputBuffer.AsSpan();
|
Span<float> dst = _inputBuffer.AsSpan();
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
for (var y = 0; y < height; y++)
|
for (int y = 0; y < height; y++)
|
||||||
{
|
{
|
||||||
var rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
byte* rowPtr = (byte*)rgb.Ptr(y).ToPointer();
|
||||||
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
var rowSpan = new Span<byte>(rowPtr, width * 3);
|
||||||
|
|
||||||
var srcIndex = 0;
|
int srcIndex = 0;
|
||||||
|
|
||||||
for (var x = 0; x < width; x++)
|
for (int x = 0; x < width; x++)
|
||||||
{
|
{
|
||||||
var r = rowSpan[srcIndex + 0];
|
byte r = rowSpan[srcIndex + 0];
|
||||||
var g = rowSpan[srcIndex + 1];
|
byte g = rowSpan[srcIndex + 1];
|
||||||
var b = rowSpan[srcIndex + 2];
|
byte b = rowSpan[srcIndex + 2];
|
||||||
|
|
||||||
var offset = y * width + x;
|
int offset = y * width + x;
|
||||||
|
|
||||||
// channel 0: R
|
// channel 0: R
|
||||||
dst[offset] = r * _inv255;
|
dst[offset] = r * _inv255;
|
||||||
@ -201,27 +206,27 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
detections.Clear();
|
detections.Clear();
|
||||||
|
|
||||||
// YOLOv8 output: [1, 84, 8400]
|
// YOLOv8 output: [1, 84, 8400]
|
||||||
var channels = output.Dimensions[1]; // 84
|
int channels = output.Dimensions[1]; // 84
|
||||||
var count = output.Dimensions[2]; // 8400
|
int count = output.Dimensions[2]; // 8400
|
||||||
|
|
||||||
var xScale = (float)originalWidth / 640f;
|
float xScale = (float)originalWidth / 640f;
|
||||||
var yScale = (float)originalHeight / 640f;
|
float yScale = (float)originalHeight / 640f;
|
||||||
|
|
||||||
for (var i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
var x = output[0, 0, i];
|
float x = output[0, 0, i];
|
||||||
var y = output[0, 1, i];
|
float y = output[0, 1, i];
|
||||||
var w = output[0, 2, i];
|
float w = output[0, 2, i];
|
||||||
var h = output[0, 3, i];
|
float h = output[0, 3, i];
|
||||||
|
|
||||||
var classScore = output[0, 4 + classIndex, i];
|
float classScore = output[0, 4 + classIndex, i];
|
||||||
if (classScore < scoreThreshold)
|
if (classScore < scoreThreshold)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var left = (x - w / 2f) * xScale;
|
float left = (x - w / 2f) * xScale;
|
||||||
var top = (y - h / 2f) * yScale;
|
float top = (y - h / 2f) * yScale;
|
||||||
var width = w * xScale;
|
float width = w * xScale;
|
||||||
var height = h * yScale;
|
float height = h * yScale;
|
||||||
|
|
||||||
detections.Add(new Detection
|
detections.Add(new Detection
|
||||||
(
|
(
|
||||||
@ -248,12 +253,12 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
// Sort in-place by score descending
|
// Sort in-place by score descending
|
||||||
detections.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
detections.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
||||||
|
|
||||||
for (var i = 0; i < detections.Count; i++)
|
for (int i = 0; i < detections.Count; i++)
|
||||||
{
|
{
|
||||||
var candidate = detections[i];
|
var candidate = detections[i];
|
||||||
var keep = true;
|
bool keep = true;
|
||||||
|
|
||||||
for (var j = 0; j < nmsBuffer.Count; j++)
|
for (int j = 0; j < nmsBuffer.Count; j++)
|
||||||
{
|
{
|
||||||
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
if (IoU(candidate, nmsBuffer[j]) >= nmsThreshold)
|
||||||
{
|
{
|
||||||
@ -272,23 +277,23 @@ public sealed class YoloV8ObjectDetector : LoggingBase, IObjectDetector, IDispos
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private static float IoU(in Detection a, in Detection b)
|
private static float IoU(in Detection a, in Detection b)
|
||||||
{
|
{
|
||||||
var x1 = MathF.Max(a.X, b.X);
|
float x1 = MathF.Max(a.X, b.X);
|
||||||
var y1 = MathF.Max(a.Y, b.Y);
|
float y1 = MathF.Max(a.Y, b.Y);
|
||||||
var x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
float x2 = MathF.Min(a.X + a.Width, b.X + b.Width);
|
||||||
var y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
float y2 = MathF.Min(a.Y + a.Height, b.Y + b.Height);
|
||||||
|
|
||||||
var interW = x2 - x1;
|
float interW = x2 - x1;
|
||||||
if (interW <= 0f) return 0f;
|
if (interW <= 0f) return 0f;
|
||||||
|
|
||||||
var interH = y2 - y1;
|
float interH = y2 - y1;
|
||||||
if (interH <= 0f) return 0f;
|
if (interH <= 0f) return 0f;
|
||||||
|
|
||||||
var interArea = interW * interH;
|
float interArea = interW * interH;
|
||||||
|
|
||||||
var areaA = a.Width * a.Height;
|
float areaA = a.Width * a.Height;
|
||||||
var areaB = b.Width * b.Height;
|
float areaB = b.Width * b.Height;
|
||||||
|
|
||||||
var union = areaA + areaB - interArea;
|
float union = areaA + areaB - interArea;
|
||||||
if (union <= 0f) return 0f;
|
if (union <= 0f) return 0f;
|
||||||
|
|
||||||
return interArea / union;
|
return interArea / union;
|
||||||
@ -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