Compare commits
21 Commits
93de483bc6
...
9496d46411
| Author | SHA1 | Date | |
|---|---|---|---|
| 9496d46411 | |||
| fd75af7f99 | |||
| 0359d61ae0 | |||
| 05d203c446 | |||
| 093c7c7803 | |||
| 23bfdc8452 | |||
| af363ebb9a | |||
| 9cdf611ec8 | |||
| 2dc7b050c8 | |||
| c6ca4fcbb6 | |||
| 61c94d4661 | |||
| 417d511bc8 | |||
| a408d43b61 | |||
| 4f83fc1dd2 | |||
| 18928a23f9 | |||
| 42408bba38 | |||
| e566bb6137 | |||
| e18d043b78 | |||
| ad418e18a9 | |||
| 3f1924a429 | |||
| 1f93eba839 |
220
README.md
@ -1,212 +1,48 @@
|
|||||||
# Splitter
|
# Splitter
|
||||||
|
|
||||||
Splitter is a high‑performance command line tool for cutting one or more video files into equal or fixed‑length segments using multi‑threaded FFmpeg execution.
|
Splitter is a high-performance command line tool for cutting one or more video files into equal or
|
||||||
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.
|
fixed‑length segments using multi‑threaded FFmpeg execution. It supports batch input, flexible
|
||||||
|
duration formats, rotation, smart face/body‑aware cropping, ETA and speed reporting, with nice GUI
|
||||||
|
or both rich and plain‑text terminal output.
|
||||||
|
|
||||||

|
The intended primary use case is for content creators who need to split large video files into smaller
|
||||||
|
segments for platforms like TikTok, Instagram Reels, YouTube Shorts, or similar. The smart
|
||||||
|
cropping feature allows the tool to automatically detect and keep faces or bodies in the frame
|
||||||
|
when splitting, ensuring that important content is not cut off.
|
||||||
|
|
||||||
|
Splitter uses cutting-edge body-detection CV models to analyze the video and determine optimal
|
||||||
|
cropping regions for each segment. Smooth tracking and gravitation bias ensure that the cropping remains
|
||||||
|
stable and focused on the subject without excessive jitter or erratic movements.
|
||||||
|
The tool can also correct for rotation metadata to ensure proper orientation in the output segments.
|
||||||
|
|
||||||
|
Splitter uses FFmpeg for the actual splitting and encoding, with multi-threading to maximize performance.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Multi‑threaded FFmpeg splitting for maximum throughput
|
- Human face or body detection with smart cropping
|
||||||
|
- Multi-threaded FFmpeg splitting for maximum throughput
|
||||||
- Equal or fixed‑length segmentation
|
- Equal or fixed‑length segmentation
|
||||||
- Batch input via file masks or list files
|
- Batch input via file masks or list files
|
||||||
- Smart cropping with face/body tracking
|
- Smart cropping with face/body tracking
|
||||||
- Rotation correction
|
- Rotation correction
|
||||||
- ETA, speed, and progress display
|
- ETA, speed, and progress display
|
||||||
- FFmpeg passthrough for advanced control
|
- FFmpeg passthrough for advanced control
|
||||||
- [Potentially] Cross‑platform (.NET 10)
|
- [Potentially] Cross-platform (.NET 10)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Command line interface
|
||||||
|

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

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

|
|
||||||
|
|
||||||
### Face Tracking Using UltraFace 320
|
|
||||||
|
|
||||||
Splitter uses the UltraFace 320 ONNX model to perform lightweight, real‑time face detection on each
|
|
||||||
frame of the input video. The detector produces bounding boxes for visible faces, and the tracking
|
|
||||||
system maintains a stable, smoothed target region across time. This is achieved by combining per‑frame
|
|
||||||
detections with temporal smoothing (EMA), dropout tolerance, and camera easing. The result is a
|
|
||||||
continuous, stable crop window that follows the performer even when the face is partially occluded,
|
|
||||||
briefly lost, or moving rapidly.
|
|
||||||
|
|
||||||
During segmentation, the crop window is recalculated for every frame, ensuring that each output
|
|
||||||
segment inherits the same smooth camera motion. This makes the vertical clips appear as if they
|
|
||||||
were recorded with a dedicated portrait‑oriented camera operator. The UltraFace 320 model is
|
|
||||||
fast enough to run alongside multi‑threaded FFmpeg splitting without becoming a bottleneck,
|
|
||||||
making it suitable for long recordings and batch processing.
|
|
||||||
|
|
||||||
### Benefits of Full‑Body Detection Using YOLOv8s for Live Gig Recordings
|
|
||||||
|
|
||||||
When recording concerts or live gigs, performers often move unpredictably, turn away from the
|
|
||||||
camera, or become partially obscured by lighting, instruments, or stage effects.
|
|
||||||
Full‑body detection using a YOLOv8s ONNX model provides a more reliable tracking anchor than
|
|
||||||
face detection alone. Because YOLOv8s can detect the entire human silhouette, the tracker
|
|
||||||
maintains stable framing even when the face is not visible, when the performer is far from
|
|
||||||
the camera, or when stage lighting makes facial features hard to detect. This produces vertical
|
|
||||||
clips that feel intentional and professionally framed, with fewer sudden jumps or lost‑tracking
|
|
||||||
moments. For creators converting horizontal gig footage into short vertical clips for YouTube
|
|
||||||
Shorts or TikTok, body‑based tracking significantly improves consistency, reduces manual editing,
|
|
||||||
and preserves the energy and motion of the performance.
|
|
||||||
|
|
||||||
### Automated Camera Control
|
|
||||||
|
|
||||||
Splitter includes an automated camera control system that simulates the behavior of a virtual
|
|
||||||
camera operator when generating vertical crops from horizontal footage. The goal is to maintain
|
|
||||||
smooth, intentional framing around the tracked subject, even when detections are noisy, intermittent,
|
|
||||||
or temporarily lost.
|
|
||||||
|
|
||||||
The controller receives object detections (face or body) and converts them into a stable crop
|
|
||||||
window using a combination of Kalman filtering, exponential smoothing, dropout tolerance,
|
|
||||||
and a three‑state tracking model. The Kalman filter provides predictive motion smoothing,
|
|
||||||
while the EMA factor blends the predicted position with the previous camera center to avoid jitter.
|
|
||||||
The camera easing value controls how quickly the virtual camera follows the subject, producing
|
|
||||||
natural‑looking motion rather than abrupt jumps.
|
|
||||||
|
|
||||||
When detections disappear, the controller enters one of two fallback modes. In LostFreeze mode,
|
|
||||||
the camera holds its last known position for a configurable number of frames, preventing sudden
|
|
||||||
jumps during brief occlusions. If the subject remains lost beyond that threshold, the controller
|
|
||||||
transitions to LostDrift mode, slowly drifting the camera back toward a neutral center position.
|
|
||||||
This prevents the crop from drifting off‑screen and ensures that the output remains usable even
|
|
||||||
when tracking fails. All positions are clamped to valid bounds, guaranteeing that the crop window
|
|
||||||
never leaves the video frame.
|
|
||||||
|
|
||||||
### Automatic rotation detection
|
|
||||||
|
|
||||||
The rotation‑estimation method is based on analyzing the distribution of gradient orientations within
|
|
||||||
a video frame. After converting the frame to grayscale, the algorithm computes horizontal and vertical
|
|
||||||
image gradients using Sobel operators and derives per‑pixel gradient magnitudes and orientations.
|
|
||||||
These orientations are folded into the range [0, 180) and accumulated into a fixed‑size,
|
|
||||||
magnitude‑weighted histogram. The histogram represents the structural edge distribution of the frame,
|
|
||||||
independent of brightness fluctuations or local lighting artifacts. By comparing the total gradient
|
|
||||||
energy concentrated near 0 degrees (vertical edges) with the energy near 90 degrees (horizontal edges),
|
|
||||||
the method determines whether the frame is more consistent with an upright or sideways orientation.
|
|
||||||
|
|
||||||
This approach is designed for environments where brightness‑based cues are unreliable, such as
|
|
||||||
live concerts with strobe lights, LED walls, haze, and crowd movement. It relies solely on geometric
|
|
||||||
edge structure, which remains stable even under extreme lighting variation. The implementation is
|
|
||||||
optimized for high‑throughput video processing: all intermediate Mats, buffers, and histograms are
|
|
||||||
preallocated, and pixel data is accessed directly through pointers to avoid per‑frame memory
|
|
||||||
allocation. The method is intentionally biased toward the upright orientation, returning a sideways
|
|
||||||
classification only when the horizontal‑edge energy significantly exceeds the vertical‑edge energy.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
splitter [<input.mp4> ...] [options] [--] <ffmpeg passthrough>
|
|
||||||
```
|
|
||||||
|
|
||||||
Inputs may be provided directly, via `--file=...`, or using file masks such as `videos/*.mp4`.
|
|
||||||
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
Below is a clean, ASCII‑only **options table** version of your content.
|
|
||||||
All option names are preserved exactly, and descriptions are consolidated for clarity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| **--out=<folder>** | Output folder for generated segments. Default: `<input folder>/Splitter`. |
|
|
||||||
| **--file=<path>** | Input file list or file mask. If omitted, the first non‑option argument is used as input. Examples: `--file=videos/*.mp4`, `--file=file_list.txt`. |
|
|
||||||
| **--mask=<pattern>** | Custom output filename pattern. Default: `[NAME]_seg[NN].[EXT]`. Supports `[NAME]`, `[N]`, `[NN]`, `[NNN]`, `[NNNN]`, `[EXT]`. Example: `--mask="[NAME]_[NNNN].mp4"`. |
|
|
||||||
| **--duration=<value>** | Override target segment duration. Formats: `Ns`, `NmMs`, `N`. Examples: `--duration=90s`, `--duration=2m30s`, `--duration=45`. Without `--force`: max 58 seconds, equalized across segments. |
|
|
||||||
| **--force** | Use the duration exactly as provided. Last segment may be shorter. |
|
|
||||||
| **--rotate=<degrees>** | Rotate video by 90, 180, or 270 degrees. Useful for correcting orientation metadata. |
|
|
||||||
| **--rotate-auto** | Use automatic rotation detection. |
|
|
||||||
| **--estimate** | Print calculated segment information and exit. No splitting is performed. |
|
|
||||||
| **--crop[=<w:h>]** | Crop video to a target width and height with face/body tracking. Default: 607x1080. Ideal for Shorts, TikTok, Reels. |
|
|
||||||
| **--detect=<name>** | Object detector for tracking. Values: `face` (UltraFace), `body` (YoloOnnx, default), `none` (center crop). |
|
|
||||||
| **--gravitate=<x:y>** | Bias the crop window toward a normalized point in the frame. Example: `--gravitate=0.2:0.5`. |
|
|
||||||
| **--text** | Use plain‑text logging instead of the rich terminal UI. |
|
|
||||||
| **--single-thread** | Disable parallel FFmpeg execution. Useful for debugging or low‑resource systems. |
|
|
||||||
| **--debug** | Show debug overlay during tracking. No cropping performed, but crop region shown. |
|
|
||||||
| **-p:<name>=<value>** | Set custom parameters for the object detector. Example: `-p:confidence=0.5`. Defaults: DropoutToleranceFrames=20, EmaFactor=0.65, CameraEasing=0.03, LostFreezeFrames=60. |
|
|
||||||
|
|
||||||
## FFmpeg Passthrough
|
|
||||||
|
|
||||||
Anything after `--` is passed directly to FFmpeg.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
splitter video.mp4 --force --duration=45 -- -an -sn
|
|
||||||
```
|
|
||||||
|
|
||||||
## Input and Output Behavior
|
|
||||||
|
|
||||||
- `input.mp4` may be a file mask (`videos/*.mp4`)
|
|
||||||
- Output filenames follow the `--mask` pattern
|
|
||||||
- Output folder defaults to `<input folder>/Splitter` unless overridden
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
Split into equal 60‑second segments:
|
|
||||||
```
|
|
||||||
splitter vertical-video.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Split into equal 90‑second segments:
|
|
||||||
```
|
|
||||||
splitter vertical-video.mp4 --duration=90s
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom naming:
|
|
||||||
```
|
|
||||||
splitter vertical-video.mp4 --duration=2m30s --mask="[NAME]_[NNNN].mp4"
|
|
||||||
```
|
|
||||||
|
|
||||||
Estimate only:
|
|
||||||
```
|
|
||||||
splitter vertical-video.mp4 --estimate
|
|
||||||
```
|
|
||||||
|
|
||||||
Fixed 45‑second segments with passthrough:
|
|
||||||
```
|
|
||||||
splitter vertical-video.mp4 --force --duration=45 -- -an -sn
|
|
||||||
```
|
|
||||||
|
|
||||||
Smart crop for Shorts:
|
|
||||||
```
|
|
||||||
splitter horizontal-video.mp4 --out=Cropped/ --crop
|
|
||||||
```
|
|
||||||
|
|
||||||
Batch processing with body tracking:
|
|
||||||
```
|
|
||||||
splitter --file=file_names.txt --out=Cropped/ --crop --detect=body
|
|
||||||
```
|
|
||||||
|
|
||||||
|
[GUI tool](Splitter-UI/README.md)
|
||||||
|
|||||||
23
Splitter-UI/App.axaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Splitter_UI.App"
|
||||||
|
xmlns:local="using:Splitter_UI"
|
||||||
|
xmlns:cnv="using:Splitter_UI.Converters"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator/>
|
||||||
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
<cnv:ActionToIconConverter x:Key="ActionToIconConverter"/>
|
||||||
|
<FontFamily x:Key="FontAwesome">avares://Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf#Font Awesome 7 Free Solid</FontFamily>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme/>
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
</Application.Styles>
|
||||||
|
|
||||||
|
</Application>
|
||||||
39
Splitter-UI/App.axaml.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Splitter_UI.Views;
|
||||||
|
|
||||||
|
namespace Splitter_UI;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider = null!;
|
||||||
|
|
||||||
|
public App() { }
|
||||||
|
|
||||||
|
public App(ServiceProvider provider)
|
||||||
|
{
|
||||||
|
_provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var vm = _provider.GetRequiredService<MainViewModel>();
|
||||||
|
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = vm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Regular-400.otf
Normal file
BIN
Splitter-UI/Assets/Fonts/Font Awesome 7 Free-Solid-900.otf
Normal file
BIN
Splitter-UI/Assets/splitter.ico
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
Splitter-UI/Assets/splitter.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
100
Splitter-UI/Assets/splitter.svg
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#111827"/>
|
||||||
|
<stop offset="100%" stop-color="#020617"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#22C55E"/>
|
||||||
|
<stop offset="100%" stop-color="#0EA5E9"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="videoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#F97316"/>
|
||||||
|
<stop offset="100%" stop-color="#EC4899"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cropGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#38BDF8"/>
|
||||||
|
<stop offset="100%" stop-color="#6366F1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bgGrad)" />
|
||||||
|
|
||||||
|
<!-- Split timeline bar -->
|
||||||
|
<g transform="translate(32,188)">
|
||||||
|
<rect x="0" y="-10" width="192" height="20" rx="10" fill="#020617" />
|
||||||
|
<rect x="0" y="-10" width="192" height="20" rx="10" fill="url(#accentGrad)" opacity="0.18" />
|
||||||
|
|
||||||
|
<!-- Segment markers -->
|
||||||
|
<rect x="32" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||||
|
<rect x="80" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||||
|
<rect x="128" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||||
|
<rect x="176" y="-14" width="3" height="28" rx="1.5" fill="#FACC15" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Video tile (left) -->
|
||||||
|
<g transform="translate(26,52)">
|
||||||
|
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
|
||||||
|
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.9" />
|
||||||
|
|
||||||
|
<!-- Play triangle -->
|
||||||
|
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.9" />
|
||||||
|
|
||||||
|
<!-- Small split indicator -->
|
||||||
|
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
|
||||||
|
<rect x="36" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||||
|
<rect x="60" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Video tile (right, shifted to suggest batch / multi-thread) -->
|
||||||
|
<g transform="translate(134,40)">
|
||||||
|
<rect x="0" y="0" width="96" height="72" rx="12" fill="#020617" />
|
||||||
|
<rect x="0" y="0" width="96" height="72" rx="12" fill="url(#videoGrad)" opacity="0.75" />
|
||||||
|
|
||||||
|
<!-- Play triangle -->
|
||||||
|
<polygon points="38,22 64,36 38,50" fill="#F9FAFB" opacity="0.85" />
|
||||||
|
|
||||||
|
<!-- Small split indicator -->
|
||||||
|
<rect x="12" y="60" width="72" height="4" rx="2" fill="#111827" opacity="0.7" />
|
||||||
|
<rect x="30" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||||
|
<rect x="54" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||||
|
<rect x="78" y="58" width="2" height="8" rx="1" fill="#FACC15" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Smart crop frame with body/face hint -->
|
||||||
|
<g transform="translate(76,86)">
|
||||||
|
<!-- Outer crop frame -->
|
||||||
|
<rect x="0" y="0" width="104" height="104" rx="18" fill="#020617" opacity="0.9" />
|
||||||
|
<rect x="0" y="0" width="104" height="104" rx="18" fill="url(#cropGrad)" opacity="0.25" />
|
||||||
|
|
||||||
|
<!-- Corner crop brackets -->
|
||||||
|
<path d="M10 30 V14 A4 4 0 0 1 14 10 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M74 10 H90 A4 4 0 0 1 94 14 V30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M10 74 V90 A4 4 0 0 0 14 94 H30" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M74 94 H90 A4 4 0 0 0 94 90 V74" stroke="#E5E7EB" stroke-width="3" stroke-linecap="round" fill="none" />
|
||||||
|
|
||||||
|
<!-- Body / face glyph -->
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="52" cy="36" r="11" fill="#F9FAFB" />
|
||||||
|
<!-- Torso -->
|
||||||
|
<path d="M32 72 C34 56 42 48 52 48 C62 48 70 56 72 72 Z"
|
||||||
|
fill="#F9FAFB" />
|
||||||
|
|
||||||
|
<!-- Gravitation / tracking bias arc -->
|
||||||
|
<path d="M30 82 A24 24 0 0 0 74 82"
|
||||||
|
stroke="url(#accentGrad)" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.9" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Subtle multi-thread / performance hint (three vertical bars) -->
|
||||||
|
<g transform="translate(210,188)">
|
||||||
|
<rect x="-10" y="-18" width="4" height="24" rx="2" fill="#22C55E" opacity="0.9" />
|
||||||
|
<rect x="-2" y="-22" width="4" height="28" rx="2" fill="#4ADE80" opacity="0.9" />
|
||||||
|
<rect x="6" y="-16" width="4" height="22" rx="2" fill="#16A34A" opacity="0.9" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
23
Splitter-UI/Converters/ActionToIconConverter.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Converters;
|
||||||
|
|
||||||
|
public sealed class ActionToIconConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var p = System.Convert.ToInt32(value);
|
||||||
|
|
||||||
|
return p == 0
|
||||||
|
? "\uf125" // FA7 crop
|
||||||
|
: "\uf2f1" // FA7 rotate
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
15
Splitter-UI/Converters/BoolInvertConverter.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolInvertConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly BoolInvertConverter Instance = new();
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is bool b ? !b : value;
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is bool b ? !b : value;
|
||||||
|
}
|
||||||
39
Splitter-UI/Converters/ConsoleColorToBrushConverter.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Converters;
|
||||||
|
|
||||||
|
public sealed class ConsoleColorToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is ConsoleColor c)
|
||||||
|
return new SolidColorBrush(ToColor(c));
|
||||||
|
|
||||||
|
return Brushes.White;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ToColor(ConsoleColor c) =>
|
||||||
|
c switch
|
||||||
|
{
|
||||||
|
ConsoleColor.Black => Colors.Black,
|
||||||
|
ConsoleColor.DarkBlue => Colors.DarkBlue,
|
||||||
|
ConsoleColor.DarkGreen => Colors.DarkGreen,
|
||||||
|
ConsoleColor.DarkCyan => Colors.DarkCyan,
|
||||||
|
ConsoleColor.DarkRed => Colors.DarkRed,
|
||||||
|
ConsoleColor.DarkMagenta => Colors.DarkMagenta,
|
||||||
|
ConsoleColor.DarkYellow => Colors.Olive,
|
||||||
|
ConsoleColor.Gray => Colors.Gray,
|
||||||
|
ConsoleColor.DarkGray => Colors.DarkGray,
|
||||||
|
ConsoleColor.Blue => Colors.Blue,
|
||||||
|
ConsoleColor.Green => Colors.Green,
|
||||||
|
ConsoleColor.Cyan => Colors.Cyan,
|
||||||
|
ConsoleColor.Red => Colors.Red,
|
||||||
|
ConsoleColor.Magenta => Colors.Magenta,
|
||||||
|
ConsoleColor.Yellow => Colors.Yellow,
|
||||||
|
ConsoleColor.White => Colors.White,
|
||||||
|
_ => Colors.White
|
||||||
|
};
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
21
Splitter-UI/Converters/RotationAngleToIconConverter.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Converters;
|
||||||
|
|
||||||
|
public sealed class RotationAngleToIconConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
90 => "\uf2f9", // FA7 (fa-rotate-left / fa-arrow-rotate-left / fa-undo)
|
||||||
|
180 => "\uf2f1", // FA7 (fa-sync-alt)
|
||||||
|
270 => "\uf2ea", // FA7 (fa-rotate-right / fa-arrow-rotate-right / fa-redo)
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
13
Splitter-UI/Converters/ZeroToBoolConverter.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Converters;
|
||||||
|
|
||||||
|
public sealed class ZeroToBoolConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> (value is int i && i == 0);
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
16
Splitter-UI/GlobalUsing.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
|
|
||||||
|
global using OpenCvSharp;
|
||||||
|
global using Size = Avalonia.Size;
|
||||||
|
global using Rect = Avalonia.Rect;
|
||||||
|
|
||||||
|
global using splitter;
|
||||||
|
global using splitter.tui;
|
||||||
|
global using splitter.algo;
|
||||||
|
global using splitter.probe;
|
||||||
|
|
||||||
|
global using Splitter_UI.Models;
|
||||||
|
global using Splitter_UI.Services;
|
||||||
|
global using Splitter_UI.ViewModels;
|
||||||
15
Splitter-UI/Models/ParameterEntry.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Models;
|
||||||
|
|
||||||
|
public partial class ParameterEntry : ObservableObject
|
||||||
|
{
|
||||||
|
public string Key { get; }
|
||||||
|
[ObservableProperty] private string _value;
|
||||||
|
|
||||||
|
public ParameterEntry(string key, string value)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Splitter-UI/Models/PreviewData.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Splitter_UI.Models;
|
||||||
|
|
||||||
|
public class PreviewData
|
||||||
|
{
|
||||||
|
public Avalonia.Media.Imaging.Bitmap? Frame { get; }
|
||||||
|
public IReadOnlyList<OpenCvSharp.Rect> DetectedBoxes { get; }
|
||||||
|
public Rect? CropRect { get; }
|
||||||
|
public Point2f GravitateTo { get; }
|
||||||
|
|
||||||
|
public PreviewData(Avalonia.Media.Imaging.Bitmap? frame, IReadOnlyList<OpenCvSharp.Rect> boxes, Rect? crop, Point2f gravitateTo)
|
||||||
|
{
|
||||||
|
Frame = frame;
|
||||||
|
DetectedBoxes = boxes;
|
||||||
|
CropRect = crop;
|
||||||
|
GravitateTo = gravitateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
81
Splitter-UI/Program.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var services = ConfigureServices();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
BuildAvaloniaApp(provider)
|
||||||
|
.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<YoloOnnxObjectDetector>();
|
||||||
|
services.AddSingleton( x => new SingleThreadedDetector<UltraFaceDetector>(x.GetRequiredService<UltraFaceDetector>()) );
|
||||||
|
services.AddSingleton( x => new SingleThreadedDetector<YoloOnnxObjectDetector>(x.GetRequiredService<YoloOnnxObjectDetector>()));
|
||||||
|
services.AddSingleton<Func<string, IObjectDetector>>( x => detectorName =>
|
||||||
|
{
|
||||||
|
return detectorName switch
|
||||||
|
{
|
||||||
|
"face" => x.GetRequiredService<SingleThreadedDetector<UltraFaceDetector>>(),
|
||||||
|
"body" => x.GetRequiredService<SingleThreadedDetector<YoloOnnxObjectDetector>>(),
|
||||||
|
_ => new DummyDetector()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
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(ServiceProvider provider)
|
||||||
|
=> AppBuilder.Configure<App>(() => new App(provider))
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
FontFallbacks = new[]
|
||||||
|
{
|
||||||
|
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free") },
|
||||||
|
new FontFallback { FontFamily = new FontFamily("Font Awesome 7 Free Solid") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
#if DEBUG
|
||||||
|
.WithDeveloperTools()
|
||||||
|
#endif
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
59
Splitter-UI/README.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Splitter-UI
|
||||||
|
|
||||||
|
|
||||||
|
A compact, modern desktop front-end for Splitter (the high-performance FFmpeg-based video splitter). Built with Avalonia 12 and
|
||||||
|
targeting .NET 10, this project provides a native-feeling cross-platform UI to configure splitting jobs, preview smart
|
||||||
|
crops, and drive the Splitter CLI backend.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Splitter-UI wraps the core Splitter pipeline (the referenced splitter-cli project) and exposes common workflow tasks
|
||||||
|
through an accessible interface: input selection, output naming, duration and crop controls, rotation options, detector settings,
|
||||||
|
and a job monitor with progress and ETA. For the full command-line feature set and the implementation rationale, see the
|
||||||
|
repository root README (../README.md).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Requirements: .NET 10 runtime and FFmpeg/FFprobe available on PATH. The UI references the splitter-cli project;
|
||||||
|
build the solution to ensure the CLI is available to the UI during development.
|
||||||
|
|
||||||
|
To build and run locally:
|
||||||
|
|
||||||
|
1. From the solution root run: dotnet build
|
||||||
|
2. Start the UI project: dotnet run --project Splitter-UI
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
The csproj is configured for a win-x64 self-contained runtime identifier. Use dotnet publish with the desired
|
||||||
|
configuration and runtime identifier to produce distributable artifacts.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings exposed in the UI map closely to the CLI options: output folder, filename mask, segment duration and
|
||||||
|
force mode, rotation, crop and detector choices, gravitation bias and detector parameters. Advanced passthrough
|
||||||
|
arguments can still be supplied to FFmpeg via the CLI passthrough field.
|
||||||
|
|
||||||
|
## Developer notes
|
||||||
|
|
||||||
|
- Project: Splitter-UI (Avalonia 12, net10.0)
|
||||||
|
- Key packages: Avalonia, Avalonia.Controls.DataGrid, Avalonia.Desktop, Avalonia.Themes.Fluent, CommunityToolkit.Mvvm
|
||||||
|
- The UI project references the splitter-cli project for tight integration during development.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If FFmpeg or FFprobe are not found, the app will be unable to probe media or run splits. Verify tools are on the
|
||||||
|
system PATH and that the runtime matches the built RID.
|
||||||
|
|
||||||
|
## Contributing and License
|
||||||
|
|
||||||
|
Contributions follow the main repository guidelines. See the root README for contributor and license information.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For issues or questions, open an issue on the project repository or contact the
|
||||||
|
maintainer listed in the main README.
|
||||||
75
Splitter-UI/Services/AutoDecisionService.cs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public sealed class AutoDecisionService(IThumbnailService _thumbnails, IFileProbeService _fileProbe, ILogger _log) : IAutoDecisionService
|
||||||
|
{
|
||||||
|
public void ApplyAutoDecisions(JobViewModel job, CancellationToken token)
|
||||||
|
{
|
||||||
|
Task.Run(() => Detect(job, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Detect(JobViewModel job, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
job.GravitateTo = new(0.5f, 0.5f);
|
||||||
|
job.OverrideTargetDuration = 58.0;
|
||||||
|
job.Mask = "[NAME]_seg[NN].[EXT]";
|
||||||
|
job.OutputFolder = Path.Combine(Path.GetDirectoryName(job.InputFile)!, "splitter");
|
||||||
|
|
||||||
|
job.Probe = await _fileProbe.ProbeAsync(job.InputFile, token);
|
||||||
|
job.Thumbnail = await _thumbnails.CreateThumbnailAsync(job.InputFile, job.Probe, rotateDegree: job.Rotate);
|
||||||
|
|
||||||
|
if (job.Probe.Width > job.Probe.Height)
|
||||||
|
{
|
||||||
|
job.Detect = "body";
|
||||||
|
job.Rotate = 0;
|
||||||
|
|
||||||
|
CalculateCrop(job);
|
||||||
|
}
|
||||||
|
//else
|
||||||
|
//{
|
||||||
|
// var sampler = new VideoRotationSampler(null);
|
||||||
|
// job.Rotate = await sampler.DetectRotationAsync(job.InputFile, job.Probe.Duration, token);
|
||||||
|
// job.Detect = job.Rotate == 0 ? null : "body";
|
||||||
|
//}
|
||||||
|
|
||||||
|
_log.LogInfo(job.ToString());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError($"Error creating thumbnail for {Path.GetFileName(job.InputFile)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CalculateCrop(JobViewModel job)
|
||||||
|
{
|
||||||
|
var targetAR = (float)CommandLine.DefaultW / CommandLine.DefaultH;
|
||||||
|
var pixelAspect = job.Probe!.Sar.X / job.Probe.Sar.Y;
|
||||||
|
|
||||||
|
float srcW = job.Probe.Width * pixelAspect;
|
||||||
|
float srcH = job.Probe.Height;
|
||||||
|
var srcAR = srcW / srcH;
|
||||||
|
|
||||||
|
float cropH = srcH;
|
||||||
|
float cropW = cropH * targetAR;
|
||||||
|
|
||||||
|
if (cropW > srcW)
|
||||||
|
{
|
||||||
|
cropW = srcW;
|
||||||
|
cropH = cropW / targetAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
float x = (srcW - cropW) * 0.5f;
|
||||||
|
float y = (srcH - cropH) * 0.5f;
|
||||||
|
|
||||||
|
float invPixelAspect = 1f / pixelAspect;
|
||||||
|
|
||||||
|
float cropW_px = cropW * invPixelAspect;
|
||||||
|
float cropH_px = cropH;
|
||||||
|
|
||||||
|
float x_px = x * invPixelAspect;
|
||||||
|
float y_px = y;
|
||||||
|
|
||||||
|
job.CropText = $"{(int)MathF.Round(cropW_px)},{(int)MathF.Round(cropH_px)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Splitter-UI/Services/AvaloniaBitmapExtensions.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public static class AvaloniaBitmapExtensions
|
||||||
|
{
|
||||||
|
public static Mat ToMatContinuous(this Bitmap bmp)
|
||||||
|
{
|
||||||
|
var w = bmp.PixelSize.Width;
|
||||||
|
var h = bmp.PixelSize.Height;
|
||||||
|
var stride = w * 4;
|
||||||
|
var size = h * stride;
|
||||||
|
|
||||||
|
var buffer = new byte[size];
|
||||||
|
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bmp.CopyPixels(
|
||||||
|
new PixelRect(0, 0, w, h),
|
||||||
|
handle.AddrOfPinnedObject(),
|
||||||
|
size,
|
||||||
|
stride);
|
||||||
|
|
||||||
|
return Mat.FromPixelData(h, w, MatType.CV_8UC4, buffer);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handle.Free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Mat ToMatBgrContinuous(this Bitmap bmp)
|
||||||
|
{
|
||||||
|
using var bgra = bmp.ToMatContinuous();
|
||||||
|
var bgr = new Mat();
|
||||||
|
Cv2.CvtColor(bgra, bgr, ColorConversionCodes.BGRA2BGR);
|
||||||
|
return bgr;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Splitter-UI/Services/DummyDetector.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
internal class DummyDetector : IObjectDetector
|
||||||
|
{
|
||||||
|
public List<(OpenCvSharp.Rect box, Point2f center)> DetectAll(Mat frameCont) => [];
|
||||||
|
public void Dispose() {}
|
||||||
|
}
|
||||||
17
Splitter-UI/Services/FileJobFactory.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
public sealed class FileJobFactory : IFileJobFactory
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
public FileJobFactory(IServiceProvider services)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobViewModel Create(SingleJob job)
|
||||||
|
{
|
||||||
|
// Resolve a fresh VM + fresh services
|
||||||
|
return ActivatorUtilities.CreateInstance<JobViewModel>(_services, job);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Splitter-UI/Services/FileProbeService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public sealed class FileProbeService : IFileProbeService
|
||||||
|
{
|
||||||
|
public async Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token)
|
||||||
|
{
|
||||||
|
var res = await Task.Run(() => ProbeVideo.Probe(inputFile, false, token), token);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Splitter-UI/Services/GlobalLogger.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
internal class GlobalLogger(ILogService _logService, StatusBarViewModel _statusBar, ProgressViewModel _progress) : ILogger
|
||||||
|
{
|
||||||
|
public void ClearProgress(string name, int progressLine)
|
||||||
|
{
|
||||||
|
if (progressLine == 0)
|
||||||
|
_statusBar.Percent = 0;
|
||||||
|
else
|
||||||
|
_progress.ClearProgress(name, progressLine-1);
|
||||||
|
}
|
||||||
|
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
|
||||||
|
{
|
||||||
|
if (progressLine == 0)
|
||||||
|
_statusBar.Percent = progress;
|
||||||
|
else
|
||||||
|
_progress.DrawProgress(name, progressLine - 1, progress, eta, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log(string prefix, ConsoleColor color, string msg)
|
||||||
|
{
|
||||||
|
_logService.Log(prefix, color, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IAutoDecisionService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IAutoDecisionService
|
||||||
|
{
|
||||||
|
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IFileJobFactory.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IFileJobFactory
|
||||||
|
{
|
||||||
|
JobViewModel Create(SingleJob job);
|
||||||
|
}
|
||||||
6
Splitter-UI/Services/IFileProbeService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IFileProbeService
|
||||||
|
{
|
||||||
|
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
|
||||||
|
}
|
||||||
7
Splitter-UI/Services/ILogService.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface ILogService
|
||||||
|
{
|
||||||
|
void Log(string prefix, ConsoleColor color, string msg);
|
||||||
|
}
|
||||||
8
Splitter-UI/Services/IThumbnailService.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public interface IThumbnailService
|
||||||
|
{
|
||||||
|
Task<Bitmap?> CreateThumbnailAsync(string file, VideoInfo probe, TimeSpan? skip = null, int? width = null, int? height = null, int? rotateDegree = null);
|
||||||
|
}
|
||||||
21
Splitter-UI/Services/SingleThreadedDetector.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public class SingleThreadedDetector<T>(IObjectDetector _detector) : IObjectDetector
|
||||||
|
where T : IObjectDetector
|
||||||
|
{
|
||||||
|
private Lock _lock = new();
|
||||||
|
|
||||||
|
public List<(OpenCvSharp.Rect box, Point2f center)> DetectAll(Mat frameCont)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _detector.DetectAll(frameCont);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if ( _detector is IDisposable d )
|
||||||
|
d.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
167
Splitter-UI/Services/ThumbnailService.cs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Services;
|
||||||
|
|
||||||
|
public sealed class ThumbnailService : IThumbnailService
|
||||||
|
{
|
||||||
|
private const int _thumbWidth = 160;
|
||||||
|
private const int _thumbHeight = 90;
|
||||||
|
|
||||||
|
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
|
||||||
|
private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
|
||||||
|
|
||||||
|
public SemaphoreSlim _lock = new(1,1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// buffer for BGR24 → 3 bytes per pixel
|
||||||
|
|
||||||
|
var canUseStaticBuffers =
|
||||||
|
width.Value == _thumbWidth &&
|
||||||
|
height.Value == _thumbHeight;
|
||||||
|
|
||||||
|
var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3];
|
||||||
|
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
||||||
|
|
||||||
|
// Decode a single frame using ffmpeg → raw BGR24 into _bgrBuffer
|
||||||
|
bool ok = await DecodeFrameAsync(bgrBuffer, file, skip.Value, width.Value, height.Value, rotateDegree);
|
||||||
|
if (!ok)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Convert BGR24 → BGRA32
|
||||||
|
ConvertBgrToBgra(bgrBuffer, bgraBuffer, width.Value, height.Value);
|
||||||
|
|
||||||
|
// Create Avalonia Bitmap
|
||||||
|
return CreateBitmap(bgraBuffer, width.Value, height.Value, rotateDegree == 90 || rotateDegree == 270);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> DecodeFrameAsync(byte [] bgrBuffer, string file, TimeSpan skip, int width, int height, int? rotateDegree)
|
||||||
|
{
|
||||||
|
var rotationStr = TrackingSplitter.GetRorationArg(rotateDegree);
|
||||||
|
|
||||||
|
// ffmpeg command: decode one frame, resize, output raw BGR24
|
||||||
|
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();
|
||||||
|
|
||||||
|
int needed = bgrBuffer.Length;
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
using var stdout = p.StandardOutput.BaseStream;
|
||||||
|
|
||||||
|
while (read < needed)
|
||||||
|
{
|
||||||
|
int 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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConvertBgrToBgra(byte[] bgr, byte[] bgra, int width, int height)
|
||||||
|
{
|
||||||
|
int si = 0;
|
||||||
|
int di = 0;
|
||||||
|
|
||||||
|
int totalPixels = width * height;
|
||||||
|
|
||||||
|
for (int i = 0; i < totalPixels; i++)
|
||||||
|
{
|
||||||
|
bgra[di + 0] = bgr[si + 0]; // B
|
||||||
|
bgra[di + 1] = bgr[si + 1]; // G
|
||||||
|
bgra[di + 2] = bgr[si + 2]; // R
|
||||||
|
bgra[di + 3] = 255; // A
|
||||||
|
|
||||||
|
si += 3;
|
||||||
|
di += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe Bitmap CreateBitmap(byte[] bgra, int width, int height, bool isRotated)
|
||||||
|
{
|
||||||
|
if (isRotated)
|
||||||
|
{
|
||||||
|
(height, width) = (width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
int stride = width * 4;
|
||||||
|
|
||||||
|
fixed (byte* p = bgra)
|
||||||
|
{
|
||||||
|
return new Bitmap(
|
||||||
|
PixelFormat.Bgra8888,
|
||||||
|
AlphaFormat.Premul,
|
||||||
|
(nint)p,
|
||||||
|
new PixelSize(width, height),
|
||||||
|
new Vector(96, 96),
|
||||||
|
stride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
37
Splitter-UI/Splitter-UI.csproj
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ApplicationIcon>Assets/splitter.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="12.0.3" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.3" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||||
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||||
|
<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.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\splitter-cli\splitter.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
34
Splitter-UI/ViewLocator.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
|
||||||
|
namespace Splitter_UI;
|
||||||
|
/// <summary>
|
||||||
|
/// Given a view model, returns the corresponding view if possible.
|
||||||
|
/// </summary>
|
||||||
|
[RequiresUnreferencedCode(
|
||||||
|
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||||
|
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||||
|
public class ViewLocator : IDataTemplate
|
||||||
|
{
|
||||||
|
public Control? Build(object? param)
|
||||||
|
{
|
||||||
|
if (param is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||||
|
var type = Type.GetType(name);
|
||||||
|
|
||||||
|
if (type != null)
|
||||||
|
{
|
||||||
|
return (Control)Activator.CreateInstance(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextBlock { Text = "Not Found: " + name };
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Match(object? data)
|
||||||
|
{
|
||||||
|
return data is ViewModelBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Splitter-UI/ViewModels/FileListViewModel.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public partial class FileListViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IFileJobFactory _factory;
|
||||||
|
private readonly IAutoDecisionService _autoDecisionService;
|
||||||
|
public ObservableCollection<JobViewModel> Files { get; } = [];
|
||||||
|
public ObservableCollection<JobViewModel> SelectedFiles { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private JobViewModel? _selected;
|
||||||
|
|
||||||
|
public event Action<JobViewModel?>? SelectedFileChanged;
|
||||||
|
|
||||||
|
public FileListViewModel(IFileJobFactory factory, IAutoDecisionService autoDecisionService)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_autoDecisionService = autoDecisionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedChanged(JobViewModel? value)
|
||||||
|
=> SelectedFileChanged?.Invoke(value);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddFiles(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
// Probe + auto-detect + thumbnail
|
||||||
|
var job = new SingleJob { InputFile = path };
|
||||||
|
var vm = _factory.Create(job);
|
||||||
|
Files.Add(vm);
|
||||||
|
_autoDecisionService.ApplyAutoDecisions(vm, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Selected = Files.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void DeleteSelected()
|
||||||
|
{
|
||||||
|
if (SelectedFiles.Any())
|
||||||
|
{
|
||||||
|
var toDelete = SelectedFiles.ToList();
|
||||||
|
foreach (var item in toDelete)
|
||||||
|
Files.Remove(item);
|
||||||
|
}
|
||||||
|
else if ( Selected != null)
|
||||||
|
{
|
||||||
|
var sel = Selected;
|
||||||
|
Files.Remove(sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Selected = Files.LastOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Splitter-UI/ViewModels/InspectorPaneViewModel.cs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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.Detect = Selected.Detect;
|
||||||
|
job.Rotate = Selected.Rotate;
|
||||||
|
job.CropText = Selected.CropText;
|
||||||
|
job.ForceFixed = Selected.ForceFixed;
|
||||||
|
job.GravitateText = Selected.GravitateText;
|
||||||
|
job.Mask = Selected.Mask;
|
||||||
|
job.OutputFolder = Selected.OutputFolder;
|
||||||
|
job.OverrideTargetDuration = Selected.OverrideTargetDuration;
|
||||||
|
job.PassthroughText = Selected.PassthroughText;
|
||||||
|
|
||||||
|
job.ParametersList.Clear();
|
||||||
|
foreach (var param in Selected.ParametersList)
|
||||||
|
job.ParametersList.Add(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
372
Splitter-UI/ViewModels/JobViewModel.cs
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
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 { get; }
|
||||||
|
|
||||||
|
public SingleJob GetJob() => Job;
|
||||||
|
|
||||||
|
[ObservableProperty] private VideoInfo? _probe;
|
||||||
|
[ObservableProperty] private PreviewData? _preview = new(null, [], null, new(0.5f, 0.5f));
|
||||||
|
[ObservableProperty] private Bitmap? _thumbnail;
|
||||||
|
[ObservableProperty] private double _sliderLiveValue;
|
||||||
|
[ObservableProperty] private double _positionSeconds;
|
||||||
|
|
||||||
|
public string InputFile => Job.InputFile;
|
||||||
|
public double DurationSeconds => Probe?.Duration ?? 0;
|
||||||
|
|
||||||
|
public IRelayCommand StepForwardCommand { get; }
|
||||||
|
public IRelayCommand StepBackwardCommand { get; }
|
||||||
|
|
||||||
|
private readonly IThumbnailService _thumbnails;
|
||||||
|
private readonly DispatcherTimer _debounceTimer;
|
||||||
|
private readonly Func<string, IObjectDetector> _detectorFactory;
|
||||||
|
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 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 = null;
|
||||||
|
}
|
||||||
|
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 string? Detect
|
||||||
|
{
|
||||||
|
get => Job.Detect;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.Detect == value)
|
||||||
|
return;
|
||||||
|
Job.Detect = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Mask
|
||||||
|
{
|
||||||
|
get => Job.Mask;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.Mask == value)
|
||||||
|
return;
|
||||||
|
Job.Mask = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string OutputFolder
|
||||||
|
{
|
||||||
|
get => Job.OutputFolder;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.OutputFolder == value)
|
||||||
|
return;
|
||||||
|
Job.OutputFolder = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ForceFixed
|
||||||
|
{
|
||||||
|
get => Job.ForceFixed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.ForceFixed == value)
|
||||||
|
return;
|
||||||
|
Job.ForceFixed = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Debug
|
||||||
|
{
|
||||||
|
get => Job.Debug;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.Debug == value)
|
||||||
|
return;
|
||||||
|
Job.Debug = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? Rotate
|
||||||
|
{
|
||||||
|
get => Job.Rotate;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Job.Rotate = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
Task.Run(CreatePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Point2f GravitateTo
|
||||||
|
{
|
||||||
|
get => Job.GravitateTo ?? new Point2f(0.5f, 0.5f);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.GravitateTo != null && Math.Abs(Job.GravitateTo.Value.X - value.X) < 0.001 && Math.Abs(Job.GravitateTo.Value.Y - value.Y) < 0.001)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Job.GravitateTo = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(GravitateText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double? OverrideTargetDuration
|
||||||
|
{
|
||||||
|
get => Job.OverrideTargetDuration;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Job.OverrideTargetDuration != null && value != null && Math.Abs(Job.OverrideTargetDuration.Value - value.Value) < 0.01)
|
||||||
|
return;
|
||||||
|
Job.OverrideTargetDuration = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public JobViewModel(SingleJob job, IThumbnailService thumbnails, Func<string, IObjectDetector> detectorFactory, ILogger log)
|
||||||
|
{
|
||||||
|
Job = job;
|
||||||
|
_thumbnails = thumbnails;
|
||||||
|
_detectorFactory = detectorFactory;
|
||||||
|
_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))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(DurationSeconds));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ParametersList.CollectionChanged += OnParametersCollectionChanged;
|
||||||
|
|
||||||
|
StepForwardCommand = new RelayCommand(StepForward);
|
||||||
|
StepBackwardCommand = new RelayCommand(StepBackward);
|
||||||
|
|
||||||
|
_debounceTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1)
|
||||||
|
};
|
||||||
|
_debounceTimer.Tick += DebounceTimerTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreatePreview()
|
||||||
|
{
|
||||||
|
if ( Probe == null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, TimeSpan.FromSeconds(PositionSeconds), Probe.Width, Probe.Height, Job.Rotate);
|
||||||
|
if ( frame == null )
|
||||||
|
return;
|
||||||
|
|
||||||
|
Preview = new PreviewData(frame, [], null, Job.GravitateTo ?? new (0.5f, 0.5f));
|
||||||
|
|
||||||
|
var detector = _detectorFactory(Job.Detect ?? "");
|
||||||
|
var detections = detector.DetectAll(frame.ToMatContinuous());
|
||||||
|
|
||||||
|
Rect? crop = null;
|
||||||
|
if (detections.Count > 0)
|
||||||
|
{
|
||||||
|
var primaryDetection = detections
|
||||||
|
.OrderByDescending(d => d.box.Height * d.box.Width)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var w = Probe.Width;
|
||||||
|
var h = Probe.Height;
|
||||||
|
|
||||||
|
var cropWidth = Job.Crop?.width ?? CommandLine.DefaultW;
|
||||||
|
var cropHeight = Job.Crop?.height ?? CommandLine.DefaultH;
|
||||||
|
|
||||||
|
var cx = primaryDetection.center.X - cropWidth / 2f;
|
||||||
|
var cy = primaryDetection.center.Y - cropHeight / 2f;
|
||||||
|
|
||||||
|
var r = new Rect(cx, cy, cropWidth, cropHeight);
|
||||||
|
|
||||||
|
crop = ClampCrop(r, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxes = detections.Select(x => x.box).ToList();
|
||||||
|
Preview = new PreviewData(frame, boxes, crop, Job.GravitateTo ?? new (0.5f, 0.5f));
|
||||||
|
}
|
||||||
|
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 (DurationSeconds <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var step = DurationSeconds * 0.1; // 10% of total duration
|
||||||
|
|
||||||
|
SliderLiveValue = Math.Min(DurationSeconds, SliderLiveValue + step);
|
||||||
|
// trigger seek in your playback pipeline here
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepBackward()
|
||||||
|
{
|
||||||
|
if (DurationSeconds <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var step = DurationSeconds * 0.1; // 10% of total duration
|
||||||
|
|
||||||
|
SliderLiveValue = Math.Max(0, SliderLiveValue - step);
|
||||||
|
// trigger seek in your playback pipeline here
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
36
Splitter-UI/ViewModels/LogPaneViewModel.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public sealed record LogEntry(string Prefix, ConsoleColor Color, string Message);
|
||||||
|
|
||||||
|
public partial class LogPaneViewModel : ObservableObject, ILogService
|
||||||
|
{
|
||||||
|
public ObservableCollection<LogEntry> Logs { get; } = [];
|
||||||
|
|
||||||
|
public void Log(string prefix, ConsoleColor color, string msg)
|
||||||
|
{
|
||||||
|
Add(new LogEntry(prefix.Replace("[", "").Replace("]", ""), color, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Add(LogEntry entry)
|
||||||
|
{
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
AddInternal(entry);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => AddInternal(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInternal(LogEntry entry)
|
||||||
|
{
|
||||||
|
Logs.Add(entry);
|
||||||
|
if (Logs.Count > 5000)
|
||||||
|
Logs.RemoveAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Splitter-UI/ViewModels/MainViewModel.cs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public FileListViewModel FileList { get; }
|
||||||
|
public PreviewPaneViewModel Preview { get; }
|
||||||
|
public InspectorPaneViewModel Inspector { get; }
|
||||||
|
public StatusBarViewModel StatusBar { get; }
|
||||||
|
public LogPaneViewModel LogPane { get; }
|
||||||
|
public ProgressViewModel Progress { get; }
|
||||||
|
private IJobProcessor _processor = null!;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _transformMode = false;
|
||||||
|
private ILogger _logger;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
|
||||||
|
public MainViewModel(
|
||||||
|
FileListViewModel fileListVM,
|
||||||
|
PreviewPaneViewModel ppVM,
|
||||||
|
InspectorPaneViewModel iVM,
|
||||||
|
LogPaneViewModel lpVM,
|
||||||
|
StatusBarViewModel sbVM,
|
||||||
|
ProgressViewModel pVM,
|
||||||
|
IJobProcessor processor,
|
||||||
|
ILogger logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
FileList = fileListVM;
|
||||||
|
Preview = ppVM;
|
||||||
|
Inspector = iVM;
|
||||||
|
LogPane = lpVM;
|
||||||
|
StatusBar = sbVM;
|
||||||
|
Progress = pVM;
|
||||||
|
_processor = processor;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Wire selection -> preview + inspector
|
||||||
|
FileList.SelectedFileChanged += file =>
|
||||||
|
{
|
||||||
|
Preview.Selected = file;
|
||||||
|
Inspector.Selected = file;
|
||||||
|
};
|
||||||
|
|
||||||
|
Progress.SetMain(this);
|
||||||
|
Inspector.SetMain(this);
|
||||||
|
Inspector.Files = FileList.Files;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start()
|
||||||
|
{
|
||||||
|
_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, _cancellationTokenSource.Token);
|
||||||
|
jobs.AddRange(fileJobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _processor.ProcessJobs(jobs, false, _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
51
Splitter-UI/ViewModels/PreviewPaneViewModel.cs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
59
Splitter-UI/ViewModels/ProgressViewModel.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Splitter_UI.Views;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
Splitter-UI/ViewModels/StatusBarViewModel.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public partial class StatusBarViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusText = "Ready";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _percent;
|
||||||
|
|
||||||
|
}
|
||||||
7
Splitter-UI/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
80
Splitter-UI/Views/FileListView.axaml
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<UserControl
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
|
xmlns:views="clr-namespace:Splitter_UI.Views"
|
||||||
|
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||||
|
x:Class="Splitter_UI.Views.FileListView"
|
||||||
|
x:DataType="vm:FileListViewModel"
|
||||||
|
KeyDown="OnKeyDown"
|
||||||
|
Focusable="True">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<conv:ZeroToBoolConverter x:Key="ZeroToBoolConverter"/>
|
||||||
|
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
<Style Selector="views|FileListView Border#DropZone">
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="views|FileListView[IsDragActive=true] Border#DropZone">
|
||||||
|
<Setter Property="BorderBrush" Value="Red"/>
|
||||||
|
<Setter Property="BorderThickness" Value="2"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Border x:Name="DropZone"
|
||||||
|
Background="#1E1E1E"
|
||||||
|
Padding="10"
|
||||||
|
DragDrop.AllowDrop="True"
|
||||||
|
DragDrop.Drop="OnDrop"
|
||||||
|
DragDrop.DragOver="OnDragOver"
|
||||||
|
DragDrop.DragEnter="OnDragEnter"
|
||||||
|
DragDrop.DragLeave="OnDragLeave">
|
||||||
|
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
|
||||||
|
<!-- Empty message -->
|
||||||
|
<TextBlock Text="Drag files here"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="20"
|
||||||
|
Foreground="#666"
|
||||||
|
IsVisible="{Binding Files.Count, Converter={StaticResource ZeroToBoolConverter}}"/>
|
||||||
|
|
||||||
|
<!-- File list -->
|
||||||
|
<ScrollViewer>
|
||||||
|
<ListBox ItemsSource="{Binding Files}"
|
||||||
|
SelectedItems="{Binding SelectedFiles}"
|
||||||
|
SelectedItem="{Binding Selected}"
|
||||||
|
SelectionMode="Multiple"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="Transparent">
|
||||||
|
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
|
||||||
|
<ListBox.Styles>
|
||||||
|
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#9A9A9A"/>
|
||||||
|
</Style>
|
||||||
|
</ListBox.Styles>
|
||||||
|
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:JobViewModel">
|
||||||
|
<views:JobListItemView/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
78
Splitter-UI/Views/FileListView.axaml.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class FileListView : UserControl
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<bool> IsDragActiveProperty =
|
||||||
|
AvaloniaProperty.Register<FileListView, bool>(nameof(IsDragActive));
|
||||||
|
|
||||||
|
public bool IsDragActive
|
||||||
|
{
|
||||||
|
get => GetValue(IsDragActiveProperty);
|
||||||
|
set => SetValue(IsDragActiveProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileListView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Delete)
|
||||||
|
{
|
||||||
|
if (DataContext is FileListViewModel vm)
|
||||||
|
vm.DeleteSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void OnDragEnter(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
IsDragActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragLeave(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
IsDragActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragOver(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
// Avalonia 12:
|
||||||
|
// e.Data is IDataObject, but it has NO strongly typed formats.
|
||||||
|
if (e.DataTransfer.Contains(DataFormat.File))
|
||||||
|
e.DragEffects = DragDropEffects.Copy;
|
||||||
|
else
|
||||||
|
e.DragEffects = DragDropEffects.None;
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDrop(object? sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
IsDragActive = false;
|
||||||
|
|
||||||
|
if (DataContext is not FileListViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!e.DataTransfer.Contains(DataFormat.File))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Avalonia 12:
|
||||||
|
// This is the ONLY correct way to get dropped files.
|
||||||
|
var items = e.DataTransfer.TryGetFiles();
|
||||||
|
if (items is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var paths = items
|
||||||
|
.OfType<IStorageFile>()
|
||||||
|
.Select(f => f.Path.LocalPath)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (paths.Count > 0)
|
||||||
|
vm.AddFilesCommand.Execute(paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
Splitter-UI/Views/InspectorPane.axaml
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<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">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- OverrideTargetDuration -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="Target Duration" Width="120"/>
|
||||||
|
<NumericUpDown Value="{Binding Selected.OverrideTargetDuration}" Width="120"/>
|
||||||
|
</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 Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Spacing="8"
|
||||||
|
Margin="0,10,0,0">
|
||||||
|
|
||||||
|
<Button Content="Apply to Selected"
|
||||||
|
Command="{Binding ApplyOverridesCommand}"/>
|
||||||
|
|
||||||
|
<Button Content="Transform all"
|
||||||
|
Background="#AA0000"
|
||||||
|
Foreground="White"
|
||||||
|
Command="{Binding TransformAllCommand}"/>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
11
Splitter-UI/Views/InspectorPane.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class InspectorPane : UserControl
|
||||||
|
{
|
||||||
|
public InspectorPane()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Splitter-UI/Views/JobListItemView.axaml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Splitter_UI.Views.JobListItemView"
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
|
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||||
|
x:DataType="vm:JobViewModel">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<conv:RotationAngleToIconConverter x:Key="RotationAngleToIconConverter"/>
|
||||||
|
<conv:ActionToIconConverter x:Key="ActionToIconConverter"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Margin="0"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="4"
|
||||||
|
Background="#2A2A2A">
|
||||||
|
|
||||||
|
<StackPanel MinWidth="160" MaxWidth="160">
|
||||||
|
|
||||||
|
<Border Width="160" Height="90" ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Image Source="{Binding Thumbnail}"
|
||||||
|
Stretch="UniformToFill"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||||
|
Text="{Binding Rotate, Converter={StaticResource ActionToIconConverter}}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Foreground="LimeGreen"/>
|
||||||
|
|
||||||
|
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||||
|
Text="{Binding Rotate, Converter={StaticResource RotationAngleToIconConverter}}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Foreground="LimeGreen"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding FileName}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,6,0,0"
|
||||||
|
FontSize="10"/>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
12
Splitter-UI/Views/JobListItemView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class JobListItemView : UserControl
|
||||||
|
{
|
||||||
|
public JobListItemView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
34
Splitter-UI/Views/LogPane.axaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<UserControl
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Splitter_UI.Views.LogPane"
|
||||||
|
xmlns:conv="clr-namespace:Splitter_UI.Converters"
|
||||||
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
|
x:DataType="vm:LogPaneViewModel">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<conv:ConsoleColorToBrushConverter x:Key="ConsoleColorToBrushConverter"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Background="#111" Padding="8">
|
||||||
|
<ScrollViewer x:Name="Scroller">
|
||||||
|
<ItemsControl ItemsSource="{Binding Logs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:LogEntry">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="[" FontFamily="Consolas" FontSize="12"/>
|
||||||
|
<TextBlock Text="{Binding Prefix}"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{Binding Color, Converter={StaticResource ConsoleColorToBrushConverter}}"/>
|
||||||
|
<TextBlock Text="] " FontFamily="Consolas" FontSize="12"/>
|
||||||
|
<TextBlock Text="{Binding Message}"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
31
Splitter-UI/Views/LogPane.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class LogPane : UserControl
|
||||||
|
{
|
||||||
|
public LogPane()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// When DataContext changes, subscribe to collection changes
|
||||||
|
this.DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is LogPaneViewModel vm)
|
||||||
|
{
|
||||||
|
vm.Logs.CollectionChanged += (_, _) => ScrollToEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScrollToEnd()
|
||||||
|
{
|
||||||
|
// Must run after layout pass
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (Scroller != null)
|
||||||
|
Scroller.ScrollToEnd();
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Splitter-UI/Views/MainWindow.axaml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<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="1400"
|
||||||
|
Height="950"
|
||||||
|
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}" />
|
||||||
|
|
||||||
|
<!-- Log Pane -->
|
||||||
|
<views:LogPane DockPanel.Dock="Bottom" Height="150"
|
||||||
|
DataContext="{Binding LogPane}" />
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<!-- Main Content -->
|
||||||
|
<Grid ColumnDefinitions="2*,3*,430" IsVisible="{Binding TransformMode, Converter={StaticResource BoolInvertConverter}}">
|
||||||
|
|
||||||
|
<!-- File List -->
|
||||||
|
<views:FileListView Grid.Column="0"
|
||||||
|
DataContext="{Binding FileList}" />
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<views:PreviewPane Grid.Column="1"
|
||||||
|
DataContext="{Binding Preview}" />
|
||||||
|
|
||||||
|
<!-- Inspector -->
|
||||||
|
<views:InspectorPane Grid.Column="2"
|
||||||
|
DataContext="{Binding Inspector}" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Progress view (replaces entire grid) -->
|
||||||
|
<views:ProgressView
|
||||||
|
DataContext="{Binding Progress}"
|
||||||
|
IsVisible="{Binding #Root.DataContext.TransformMode}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
|
|
||||||
12
Splitter-UI/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Avalonia.Controls.Window
|
||||||
|
{
|
||||||
|
public MainViewModel Data { get; } = null!; // set by DI
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
478
Splitter-UI/Views/PreviewCanvas.cs
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _dragging;
|
||||||
|
private Avalonia.Point _dragStartCanvas;
|
||||||
|
private Point2f _dragStartValue;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
var rawW = preview.Frame.PixelSize.Width;
|
||||||
|
var rawH = preview.Frame.PixelSize.Height;
|
||||||
|
|
||||||
|
// normalized → pixel
|
||||||
|
double px = g.X * rawW;
|
||||||
|
double py = g.Y * rawH;
|
||||||
|
|
||||||
|
var rotate = RotateAngle;
|
||||||
|
var sar = Sar ?? new Point2f(1, 1);
|
||||||
|
var 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = Math.Min(dispW / displayW, dispH / displayH);
|
||||||
|
var offsetX = (dispW - displayW * scale) / 2;
|
||||||
|
var offsetY = (dispH - displayH * scale) / 2;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Pointer events
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
var p = e.GetPosition(this);
|
||||||
|
|
||||||
|
if (HitGravitate(p, out var g))
|
||||||
|
{
|
||||||
|
_dragging = true;
|
||||||
|
_dragStartCanvas = p;
|
||||||
|
_dragStartValue = g; // normalized
|
||||||
|
e.Pointer.Capture(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_dragging || Preview?.Frame is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var p = e.GetPosition(this);
|
||||||
|
var dxCanvas = p.X - _dragStartCanvas.X;
|
||||||
|
var dyCanvas = p.Y - _dragStartCanvas.Y;
|
||||||
|
|
||||||
|
var preview = Preview;
|
||||||
|
var rawW = preview.Frame.PixelSize.Width;
|
||||||
|
var rawH = preview.Frame.PixelSize.Height;
|
||||||
|
|
||||||
|
var rotate = RotateAngle;
|
||||||
|
var sar = Sar ?? new Point2f(1, 1);
|
||||||
|
var 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = Math.Min(dispW / displayW, dispH / displayH);
|
||||||
|
|
||||||
|
double dx = dxCanvas / scale;
|
||||||
|
double dy = dyCanvas / scale;
|
||||||
|
|
||||||
|
if (rotate == 0 || rotate == 180)
|
||||||
|
dx /= pixelAspect;
|
||||||
|
else
|
||||||
|
dy /= pixelAspect;
|
||||||
|
|
||||||
|
// start normalized → pixel
|
||||||
|
double gx = _dragStartValue.X * rawW + dx;
|
||||||
|
double 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pixel → normalized
|
||||||
|
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);
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragging)
|
||||||
|
{
|
||||||
|
_dragging = 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;
|
||||||
|
|
||||||
|
// normalized → pixel
|
||||||
|
double px = g.X * rawW;
|
||||||
|
double 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);
|
||||||
|
|
||||||
|
foreach (var r in preview.DetectedBoxes)
|
||||||
|
{
|
||||||
|
var rr = TransformRect(
|
||||||
|
r.X, r.Y, r.Width, r.Height,
|
||||||
|
rawW, rawH,
|
||||||
|
offsetX, offsetY,
|
||||||
|
scale,
|
||||||
|
rotate,
|
||||||
|
pixelAspect);
|
||||||
|
|
||||||
|
context.DrawRectangle(null, pen, rr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Splitter-UI/Views/PreviewPane.axaml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<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"
|
||||||
|
x:Class="Splitter_UI.Views.PreviewPane"
|
||||||
|
x:DataType="vm:PreviewPaneViewModel">
|
||||||
|
|
||||||
|
<Border Background="#202020" Padding="10">
|
||||||
|
<Grid RowDefinitions="*,Auto">
|
||||||
|
|
||||||
|
<local:PreviewCanvas
|
||||||
|
Grid.Row="0"
|
||||||
|
Preview="{Binding Preview}"
|
||||||
|
Sar="{Binding Sar}"
|
||||||
|
RotateAngle="{Binding Rotate}"
|
||||||
|
GravitateTo="{Binding GravitateTo, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Slider Grid.Column="1"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="{Binding Selected.DurationSeconds}"
|
||||||
|
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
||||||
|
Margin="5,0,5,0" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Width="24" Height="24"
|
||||||
|
Padding="0"
|
||||||
|
Margin="5,0,0,0"
|
||||||
|
Command="{Binding Selected.StepForwardCommand}">
|
||||||
|
<TextBlock FontFamily="{StaticResource FontAwesome}"
|
||||||
|
Text=""
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
|
||||||
12
Splitter-UI/Views/PreviewPane.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class PreviewPane : UserControl
|
||||||
|
{
|
||||||
|
public PreviewPane()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
63
Splitter-UI/Views/ProgressView.axaml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Splitter_UI.Views.ProgressView"
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
|
x:DataType="vm:ProgressViewModel">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="*,Auto" Background="#111">
|
||||||
|
|
||||||
|
<!-- Processes list -->
|
||||||
|
<ItemsControl Grid.Row="0" ItemsSource="{Binding Processes}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ProgressInfo">
|
||||||
|
<Grid ColumnDefinitions="2*,3*,Auto,Auto"
|
||||||
|
Margin="0,2">
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12"/>
|
||||||
|
|
||||||
|
<ProgressBar Grid.Column="1"
|
||||||
|
Height="12"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
|
Value="{Binding Progress}"
|
||||||
|
Margin="8,0"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Width="70"
|
||||||
|
Text="{Binding Eta, StringFormat={}{0:hh\\:mm\\:ss}}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="12,0"
|
||||||
|
FontSize="12"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="3"
|
||||||
|
Width="70"
|
||||||
|
Text="{Binding Speed, StringFormat={}{0:0.00}}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="12,0"
|
||||||
|
FontSize="12"/>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- Bottom-right Cancel button -->
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,12,0,0">
|
||||||
|
|
||||||
|
<Button Content="Cancel"
|
||||||
|
Background="#700000"
|
||||||
|
Foreground="White"
|
||||||
|
Padding="12,6"
|
||||||
|
Margin="0,0,10,10"
|
||||||
|
Command="{Binding CancelCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
12
Splitter-UI/Views/ProgressView.axaml.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class ProgressView : UserControl
|
||||||
|
{
|
||||||
|
public ProgressView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
Splitter-UI/Views/StatusBarView.axaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<UserControl
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Splitter_UI.Views.StatusBarView"
|
||||||
|
xmlns:vm="clr-namespace:Splitter_UI.ViewModels"
|
||||||
|
x:DataType="vm:StatusBarViewModel">
|
||||||
|
|
||||||
|
<Border Padding="4" Background="{DynamicResource ThemeBackgroundBrush}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{Binding StatusText}" />
|
||||||
|
|
||||||
|
<ProgressBar Grid.Column="1"
|
||||||
|
Width="200"
|
||||||
|
Height="16"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Value="{Binding Percent}" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
11
Splitter-UI/Views/StatusBarView.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Splitter_UI.Views;
|
||||||
|
|
||||||
|
public partial class StatusBarView : UserControl
|
||||||
|
{
|
||||||
|
public StatusBarView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Splitter-UI/app.manifest
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Splitter_UI.Desktop"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
BIN
Splitter-UI/screenshot.png
Normal file
|
After Width: | Height: | Size: 367 KiB |
@ -1,52 +1,14 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using splitter.util;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter;
|
||||||
|
|
||||||
public class SingleJob
|
|
||||||
{
|
|
||||||
public string InputFile { get; set; } = null!;
|
|
||||||
public string OutputFolder { get; set; } = null!;
|
|
||||||
public (int width, int height)? Crop { get; set; }
|
|
||||||
public Point2f? GravitateTo { get; set; }
|
|
||||||
public string? Mask { get; set; }
|
|
||||||
public bool Debug { get; set; }
|
|
||||||
public string? Detect { get; set; }
|
|
||||||
public double? OverrideTargetDuration { get; set; }
|
|
||||||
public string[] Passthrough { get; set; } = [];
|
|
||||||
public bool PlainText { get; set; }
|
|
||||||
public bool EstimateOnly { get; set; }
|
|
||||||
public bool ForceFixed { get; set; }
|
|
||||||
public bool SingleThreaded { get; set; }
|
|
||||||
public int? Rotate { get; set; }
|
|
||||||
public bool RotateAuto { get; set; }
|
|
||||||
public Dictionary<string, string> Parameters { get; set; } = [];
|
|
||||||
|
|
||||||
public void Override<T>(ref T member, string name)
|
|
||||||
{
|
|
||||||
if (!Parameters.TryGetValue(name, out var raw))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Convert.ChangeType handles int, float, double, etc.
|
|
||||||
var converted = (T)Convert.ChangeType(
|
|
||||||
raw,
|
|
||||||
typeof(T),
|
|
||||||
CultureInfo.InvariantCulture
|
|
||||||
);
|
|
||||||
|
|
||||||
member = converted;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CommandLine
|
public sealed class CommandLine
|
||||||
{
|
{
|
||||||
|
// Default vertical Full HD for YouTube Shorts
|
||||||
|
public const int DefaultW = 607;
|
||||||
|
public const int DefaultH = 1080;
|
||||||
|
|
||||||
public SingleJob Master { get; } = new SingleJob();
|
public SingleJob Master { get; } = new SingleJob();
|
||||||
public SingleJob[] Jobs { get; }
|
public SingleJob[] Jobs { get; }
|
||||||
|
|
||||||
@ -272,13 +234,9 @@ public sealed class CommandLine
|
|||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
5
splitter-cli/GlobalUsing.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
global using OpenCvSharp;
|
||||||
|
global using splitter.algo;
|
||||||
|
global using splitter.probe;
|
||||||
|
global using splitter.tui;
|
||||||
|
|
||||||
7
splitter-cli/IJobProcessor.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
public interface IJobProcessor
|
||||||
|
{
|
||||||
|
Task<List<SingleTask>> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token);
|
||||||
|
Task<bool> ProcessJobs(List<SingleTask> tasks, bool singleThreaded, CancellationToken token);
|
||||||
|
}
|
||||||
@ -1,6 +0,0 @@
|
|||||||
namespace splitter;
|
|
||||||
|
|
||||||
public interface ISegmentProcessor
|
|
||||||
{
|
|
||||||
Task ProcessSegment( SingleTask job );
|
|
||||||
}
|
|
||||||
227
splitter-cli/JobProcessor.cs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
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, 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 YoloOnnxObjectDetector(_logger),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
|
||||||
|
};
|
||||||
|
return new TrackingSplitter(i, detector, job, _logger);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
processorFactory = i => new SimpleSplitter(i, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs = Enumerable.Range(0, segments)
|
||||||
|
.Select(i => new SingleTask
|
||||||
|
(
|
||||||
|
Job : job,
|
||||||
|
Info: info,
|
||||||
|
OutputFileName : BuildOutputFileName(job, i),
|
||||||
|
SegmentIndex : i,
|
||||||
|
TotalSegments : segments,
|
||||||
|
SegmentStart : i * segmentLength,
|
||||||
|
SegmentLength : (i == segments - 1)
|
||||||
|
? Math.Max(0.1, info.Duration - i * segmentLength)
|
||||||
|
: segmentLength,
|
||||||
|
ProcessorFactory : processorFactory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () =>
|
||||||
|
{
|
||||||
|
int slot = -1;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Acquire a slot ID
|
||||||
|
while (!freeSlots.TryDequeue(out slot))
|
||||||
|
{
|
||||||
|
if ( token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
await ProcessSegment(job, slot + 1, token);
|
||||||
|
|
||||||
|
var processed = Interlocked.Increment(ref processedSegments);
|
||||||
|
var elapsed = sw.Elapsed;
|
||||||
|
var eta = TimeSpan.FromTicks(elapsed.Ticks * (totalSegments - processed) / processed);
|
||||||
|
var speed = (processed * totalDuration) / elapsed.TotalSeconds;
|
||||||
|
LogProgress((double)processed / totalSegments, eta, speed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Return slot to pool
|
||||||
|
if (slot >= 0)
|
||||||
|
freeSlots.Enqueue(slot);
|
||||||
|
|
||||||
|
sem.Release();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Single-threaded splitting
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
private async Task RunSingleThreaded(List<SingleTask> jobs, CancellationToken token)
|
||||||
|
{
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
await ProcessSegment(job, 0, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSegment(SingleTask t, int slot, CancellationToken token)
|
||||||
|
{
|
||||||
|
var processor = t.ProcessorFactory(slot);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await processor.ProcessSegment(t, token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (processor is IDisposable disposable)
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildOutputFileName(SingleJob job, int index)
|
||||||
|
{
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
|
||||||
|
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
|
||||||
|
.Replace("[N]", index.ToString())
|
||||||
|
.Replace("[NN]", index.ToString("00"))
|
||||||
|
.Replace("[NNN]", index.ToString("000"))
|
||||||
|
.Replace("[NNNN]", index.ToString("0000"))
|
||||||
|
.Replace("[EXT]", Path.GetExtension(job.InputFile).TrimStart('.'))
|
||||||
|
;
|
||||||
|
|
||||||
|
return Path.Combine(job.OutputFolder, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,13 +0,0 @@
|
|||||||
namespace splitter;
|
|
||||||
|
|
||||||
public struct Point2f
|
|
||||||
{
|
|
||||||
public float X;
|
|
||||||
public float Y;
|
|
||||||
|
|
||||||
public Point2f(float x, float y)
|
|
||||||
{
|
|
||||||
X = x;
|
|
||||||
Y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public record VideoInfo(
|
|
||||||
double Duration,
|
|
||||||
int Width,
|
|
||||||
int Height,
|
|
||||||
double Fps,
|
|
||||||
double Bitrate,
|
|
||||||
int Rotation = 0
|
|
||||||
);
|
|
||||||
|
|
||||||
public static class ProbeVideo
|
|
||||||
{
|
|
||||||
public static async Task<VideoInfo> Probe(SingleJob job)
|
|
||||||
{
|
|
||||||
var info = ProbeSize(job.InputFile);
|
|
||||||
if ( job.RotateAuto)
|
|
||||||
{
|
|
||||||
var rotation = await ProbeRotation(job, info.Duration);
|
|
||||||
info = info with { Rotation = rotation };
|
|
||||||
}
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ProbeRotation(SingleJob job, double duration)
|
|
||||||
{
|
|
||||||
var rotation = await new VideoRotationSampler(job).DetectRotationAsync(job.InputFile, duration);
|
|
||||||
return rotation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static VideoInfo ProbeSize(string inputFile)
|
|
||||||
{
|
|
||||||
var args =
|
|
||||||
"-v error " +
|
|
||||||
"-select_streams v:0 " +
|
|
||||||
"-show_entries format=duration " +
|
|
||||||
"-show_entries stream=width,height,avg_frame_rate,bit_rate " +
|
|
||||||
"-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys
|
|
||||||
$"\"{inputFile}\"";
|
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "ffprobe",
|
|
||||||
Arguments = args,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using var p = new Process { StartInfo = psi };
|
|
||||||
p.Start();
|
|
||||||
|
|
||||||
var duration = -1.0;
|
|
||||||
var width = 0;
|
|
||||||
var height = 0;
|
|
||||||
var fps = 0.0;
|
|
||||||
var bitrate = 0.0;
|
|
||||||
|
|
||||||
while (!p.StandardOutput.EndOfStream)
|
|
||||||
{
|
|
||||||
var line = p.StandardOutput.ReadLine()?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (line.StartsWith("duration="))
|
|
||||||
{
|
|
||||||
var v = line.Substring("duration=".Length);
|
|
||||||
double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration);
|
|
||||||
}
|
|
||||||
else if (line.StartsWith("width="))
|
|
||||||
{
|
|
||||||
var v = line.Substring("width=".Length);
|
|
||||||
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
|
|
||||||
}
|
|
||||||
else if (line.StartsWith("bit_rate="))
|
|
||||||
{
|
|
||||||
var v = line.Substring("bit_rate=".Length);
|
|
||||||
double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out bitrate);
|
|
||||||
}
|
|
||||||
else if (line.StartsWith("height="))
|
|
||||||
{
|
|
||||||
var v = line.Substring("height=".Length);
|
|
||||||
int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
|
|
||||||
}
|
|
||||||
else if (line.StartsWith("avg_frame_rate="))
|
|
||||||
{
|
|
||||||
var v = line.Substring("avg_frame_rate=".Length);
|
|
||||||
var parts = v.Split('/');
|
|
||||||
if (parts.Length == 2 &&
|
|
||||||
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) &&
|
|
||||||
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) &&
|
|
||||||
den != 0)
|
|
||||||
{
|
|
||||||
fps = num / den;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.WaitForExit();
|
|
||||||
|
|
||||||
return new(duration, width, height, fps, bitrate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
212
splitter-cli/README.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
| 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
|
||||||
|
```
|
||||||
|
|
||||||
@ -5,59 +5,96 @@ namespace splitter;
|
|||||||
|
|
||||||
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor
|
||||||
{
|
{
|
||||||
public async Task ProcessSegment(SingleTask job)
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
{
|
||||||
string inputFile = job.Job.InputFile;
|
string inputFile = job.Job.InputFile;
|
||||||
string outputFile = job.OutputFileName;
|
string outputFile = job.OutputFileName;
|
||||||
double start = job.SegmentStart;
|
double start = job.SegmentStart;
|
||||||
double length = job.SegmentLength;
|
double length = job.SegmentLength;
|
||||||
int videoWidth = job.Info.Width;
|
|
||||||
int videoHeight = job.Info.Height;
|
var rotation = GetRotationFilter(job.Job.Rotate);
|
||||||
double fps = job.Info.Fps;
|
|
||||||
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
|
|
||||||
|
|
||||||
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
|
|
||||||
|
|
||||||
string args;
|
string args;
|
||||||
var rotation = GetRotationFilter(job.Job.Rotate);
|
|
||||||
if (rotation == null)
|
if (rotation == null)
|
||||||
{
|
{
|
||||||
|
// Copy path: keep original SAR/DAR exactly as in source
|
||||||
args =
|
args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-i \"{inputFile}\" " +
|
$"-i \"{inputFile}\" " +
|
||||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-c copy {pass} \"{outputFile}\" -y";
|
$"-c copy {string.Join(" ", job.Job.Passthrough)} " +
|
||||||
|
$"\"{outputFile}\" -y";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Rotation → must re-encode
|
var sarArg = "";
|
||||||
|
var darArg = "";
|
||||||
|
|
||||||
|
var sar = job.Info.SampleAspectRatio; // e.g. "4:3"
|
||||||
|
if (sar != null)
|
||||||
|
{
|
||||||
|
// Rotation path: must re-encode and recompute DAR
|
||||||
|
|
||||||
|
long sarNum = Convert.ToInt64(job.Info.Sar.X);
|
||||||
|
long sarDen = Convert.ToInt64(job.Info.Sar.Y);
|
||||||
|
|
||||||
|
// After rotation, width/height swap
|
||||||
|
int w = job.Info.Width;
|
||||||
|
int h = job.Info.Height;
|
||||||
|
|
||||||
|
if (job.Job.Rotate == 90 || job.Job.Rotate == 270)
|
||||||
|
{
|
||||||
|
(w, h) = (h, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute DAR = (w * sarNum) : (h * sarDen)
|
||||||
|
var darNum = w * sarNum;
|
||||||
|
var darDen = h * sarDen;
|
||||||
|
|
||||||
|
// Reduce fraction
|
||||||
|
long Gcd(long a, long b)
|
||||||
|
{
|
||||||
|
while (b != 0) (a, b) = (b, a % b);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
var g = Gcd(darNum, darDen);
|
||||||
|
darNum /= g;
|
||||||
|
darDen /= g;
|
||||||
|
|
||||||
|
sarArg = $"-vf \"{rotation},setsar={sarNum}:{sarDen}\" ";
|
||||||
|
darArg = $"-aspect {darNum}:{darDen} ";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
sarArg = $"-vf \"{rotation}\" ";
|
||||||
|
|
||||||
args =
|
args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-i \"{inputFile}\" " +
|
$"-i \"{inputFile}\" " +
|
||||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"-vf \"{rotation}\" " +
|
sarArg + darArg +
|
||||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||||
"-c:a copy " +
|
"-c:a copy " +
|
||||||
$"{pass} \"{outputFile}\" -y";
|
$"{string.Join(" ", job.Job.Passthrough)} " +
|
||||||
|
$"\"{outputFile}\" -y";
|
||||||
}
|
}
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "ffmpeg",
|
FileName = "ffmpeg",
|
||||||
Arguments = args,
|
Arguments = args,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
|
|
||||||
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
||||||
|
|
||||||
var name = Path.GetFileNameWithoutExtension(outputFile);
|
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||||
ShowFFMpegProgress(length, proc, name);
|
await ShowFFMpegProgress(length, proc, name, token);
|
||||||
|
|
||||||
proc.WaitForExit();
|
await proc.WaitForExitAsync(token);
|
||||||
|
|
||||||
ClearProgress();
|
ClearProgress(name);
|
||||||
|
|
||||||
if (proc.ExitCode != 0)
|
if (proc.ExitCode != 0)
|
||||||
LogError($"Segment {name} FFmpeg encoding failed");
|
LogError($"Segment {name} FFmpeg encoding failed");
|
||||||
@ -65,6 +102,7 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
|
|||||||
LogInfo($"Segment {name} processing completed");
|
LogInfo($"Segment {name} processing completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
string? GetRotationFilter(int? degrees) =>
|
string? GetRotationFilter(int? degrees) =>
|
||||||
degrees switch
|
degrees switch
|
||||||
{
|
{
|
||||||
@ -74,13 +112,27 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static long Gcd(long a, long b)
|
||||||
|
{
|
||||||
|
a = Math.Abs(a);
|
||||||
|
b = Math.Abs(b);
|
||||||
|
|
||||||
private void ShowFFMpegProgress(double length, Process proc, string name)
|
while (b != 0)
|
||||||
|
{
|
||||||
|
long t = b;
|
||||||
|
b = a % b;
|
||||||
|
a = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowFFMpegProgress(double length, Process proc, string name, CancellationToken token)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = proc.StandardError.ReadLine()) != null)
|
while ((line = await proc.StandardError.ReadLineAsync(token)) != null)
|
||||||
{
|
{
|
||||||
// Look for "time=00:00:03.52"
|
// Look for "time=00:00:03.52"
|
||||||
var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase);
|
var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|||||||
118
splitter-cli/SingleJob.cs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
/// <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>
|
||||||
|
/// 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; } = [];
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -25,7 +24,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
d.Dispose();
|
d.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessSegment(SingleTask job)
|
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||||
{
|
{
|
||||||
string inputFile = job.Job.InputFile;
|
string inputFile = job.Job.InputFile;
|
||||||
string outputFile = job.OutputFileName;
|
string outputFile = job.OutputFileName;
|
||||||
@ -58,20 +57,21 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] enc={encWidth}x{encHeight}");
|
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)
|
// 2) Start FFmpeg decode (video only → raw BGR24 to stdout)
|
||||||
var decode = StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText);
|
var decode = await StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText, token);
|
||||||
using var decodeStdout = decode.StandardOutput.BaseStream;
|
using var decodeStdout = decode.StandardOutput.BaseStream;
|
||||||
|
|
||||||
// 3) Start FFmpeg encode (video from stdin + audio from original)
|
// 3) Start FFmpeg encode (video from stdin + audio from original)
|
||||||
var encode = StartFfmpegEncode(
|
var encode = await StartFfmpegEncode(
|
||||||
inputFile,
|
inputFile,
|
||||||
outputFile,
|
outputFile,
|
||||||
start,
|
start,
|
||||||
length,
|
length,
|
||||||
encWidth,
|
encWidth,
|
||||||
encHeight,
|
encHeight,
|
||||||
fps,
|
job.Info,
|
||||||
ffmpegPassthroughParameters,
|
ffmpegPassthroughParameters,
|
||||||
job.Job.PlainText);
|
job.Job.PlainText,
|
||||||
|
token);
|
||||||
|
|
||||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
using var encodeStdin = encode.StandardInput.BaseStream;
|
||||||
|
|
||||||
@ -100,9 +100,11 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
while (frameIndex < totalFrames)
|
while (frameIndex < totalFrames)
|
||||||
{
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
frameIndex++;
|
frameIndex++;
|
||||||
|
|
||||||
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
|
var read = await ReadExact(decodeStdout, inBuffer, 0, inBytes, token);
|
||||||
if (read != inBytes)
|
if (read != inBytes)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -153,7 +155,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
||||||
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
||||||
|
|
||||||
ClearProgress();
|
ClearProgress(name);
|
||||||
|
|
||||||
|
|
||||||
if (encode.ExitCode != 0)
|
if (encode.ExitCode != 0)
|
||||||
@ -165,21 +167,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
// ---------- FFmpeg decode / encode ----------
|
// ---------- FFmpeg decode / encode ----------
|
||||||
|
|
||||||
private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText)
|
private async Task<Process> StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText, CancellationToken token)
|
||||||
{
|
{
|
||||||
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start .ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var rotateStr = "";
|
var rotateStr = GetRorationArg(rotate);
|
||||||
if (rotate != null)
|
|
||||||
{
|
|
||||||
switch (rotate.Value)
|
|
||||||
{
|
|
||||||
case 90: rotateStr = ",transpose=1"; break;
|
|
||||||
case 180: rotateStr = ",transpose=PI"; break;
|
|
||||||
case 270: rotateStr = ",transpose=2"; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var args =
|
var args =
|
||||||
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
||||||
@ -202,12 +195,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(inputFile);
|
var fileName = Path.GetFileName(inputFile);
|
||||||
|
|
||||||
_ = Task.Run(() =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = p.StandardError.ReadLine()) != null)
|
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
||||||
}
|
}
|
||||||
@ -217,21 +210,57 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Process StartFfmpegEncode(
|
public static string GetRorationArg(int? rotate)
|
||||||
|
{
|
||||||
|
var rotateStr = "";
|
||||||
|
if (rotate != null)
|
||||||
|
{
|
||||||
|
switch (rotate.Value)
|
||||||
|
{
|
||||||
|
case 90: rotateStr = ",transpose=1"; break;
|
||||||
|
case 180: rotateStr = ",transpose=PI"; break;
|
||||||
|
case 270: rotateStr = ",transpose=2"; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Process> StartFfmpegEncode(
|
||||||
string inputFile,
|
string inputFile,
|
||||||
string outputFile,
|
string outputFile,
|
||||||
double start,
|
double start,
|
||||||
double length,
|
double length,
|
||||||
int width,
|
int width, int height,
|
||||||
int height,
|
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 = fps.ToString("0.###", CultureInfo.InvariantCulture);
|
var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
var ss = start.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
var t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||||
|
var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio)
|
||||||
|
? $"-vf setsar={info.SampleAspectRatio} "
|
||||||
|
: "";
|
||||||
|
|
||||||
|
string darArg = "";
|
||||||
|
|
||||||
|
if (info.Sar is { } s)
|
||||||
|
{
|
||||||
|
// compute DAR from output size and SAR
|
||||||
|
var darNum = width * s.X;
|
||||||
|
var darDen = height * s.Y;
|
||||||
|
|
||||||
|
// clamp to int and reduce
|
||||||
|
int dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum));
|
||||||
|
int 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 " +
|
||||||
@ -239,6 +268,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
$"-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}\"";
|
||||||
|
|
||||||
@ -260,12 +290,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
var fileName = Path.GetFileName(outputFile);
|
var fileName = Path.GetFileName(outputFile);
|
||||||
|
|
||||||
_ = Task.Run(() =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string? line;
|
string? line;
|
||||||
while ((line = p.StandardError.ReadLine()) != null)
|
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||||
{
|
{
|
||||||
if (plainText)
|
if (plainText)
|
||||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||||
@ -279,12 +309,32 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
|||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
|
|
||||||
private static int ReadExact(Stream s, byte[] buffer, int offset, int count)
|
private static void ReduceFraction(ref int num, ref int den)
|
||||||
|
{
|
||||||
|
int Gcd(int a, int b)
|
||||||
|
{
|
||||||
|
while (b != 0)
|
||||||
|
{
|
||||||
|
var t = b;
|
||||||
|
b = a % b;
|
||||||
|
a = t;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
var g = Gcd(Math.Abs(num), Math.Abs(den));
|
||||||
|
if (g > 1)
|
||||||
|
{
|
||||||
|
num /= g;
|
||||||
|
den /= g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static async Task<int> ReadExact(Stream s, byte[] buffer, int offset, int count, CancellationToken token)
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
while (total < count)
|
while (total < count)
|
||||||
{
|
{
|
||||||
var read = s.Read(buffer, offset + total, count - total);
|
var read = await s.ReadAsync(buffer, offset + total, count - total, token);
|
||||||
if (read <= 0)
|
if (read <= 0)
|
||||||
break;
|
break;
|
||||||
total += read;
|
total += read;
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
using OpenCvSharp;
|
namespace splitter.algo;
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public enum TrackState
|
public enum TrackState
|
||||||
{
|
{
|
||||||
@ -1,6 +1,4 @@
|
|||||||
using OpenCvSharp;
|
namespace splitter.algo;
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public interface IObjectDetector : IDisposable
|
public interface IObjectDetector : IDisposable
|
||||||
{
|
{
|
||||||
6
splitter-cli/algo/ISegmentProcessor.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace splitter.algo;
|
||||||
|
|
||||||
|
public interface ISegmentProcessor
|
||||||
|
{
|
||||||
|
Task ProcessSegment( SingleTask job, CancellationToken token);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class KalmanTracker
|
public sealed class KalmanTracker
|
||||||
{
|
{
|
||||||
13
splitter-cli/algo/Point2f.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//namespace splitter.algo;
|
||||||
|
|
||||||
|
//public struct Point2f
|
||||||
|
//{
|
||||||
|
// public float X;
|
||||||
|
// public float Y;
|
||||||
|
|
||||||
|
// public Point2f(float x, float y)
|
||||||
|
// {
|
||||||
|
// X = x;
|
||||||
|
// Y = y;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using OpenCvSharp;
|
|
||||||
using UltraFaceDotNet;
|
using UltraFaceDotNet;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||||
{
|
{
|
||||||
@ -1,9 +1,8 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Microsoft.ML.OnnxRuntime;
|
using Microsoft.ML.OnnxRuntime;
|
||||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||||
using OpenCvSharp;
|
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.algo;
|
||||||
|
|
||||||
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisposable
|
||||||
{
|
{
|
||||||
37
splitter-cli/probe/FfprobeFormat.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FfprobeFormat
|
||||||
|
{
|
||||||
|
public string? Filename { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Nb_streams { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Nb_programs { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Nb_stream_groups { get; set; }
|
||||||
|
|
||||||
|
public string? Format_name { get; set; }
|
||||||
|
public string? Format_long_name { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Start_time { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Duration { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Size { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Bit_rate { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Probe_score { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string>? Tags { get; set; }
|
||||||
|
}
|
||||||
7
splitter-cli/probe/FfprobeResult.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FfprobeResult
|
||||||
|
{
|
||||||
|
public List<FfprobeStream>? Streams { get; set; }
|
||||||
|
public FfprobeFormat? Format { get; set; }
|
||||||
|
}
|
||||||
126
splitter-cli/probe/FfprobeStream.cs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FfprobeStream
|
||||||
|
{
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Index { get; set; }
|
||||||
|
|
||||||
|
public string? Codec_name { get; set; }
|
||||||
|
public string? Codec_long_name { get; set; }
|
||||||
|
public string? Profile { get; set; }
|
||||||
|
public string? Codec_type { get; set; }
|
||||||
|
public string? Codec_tag_string { get; set; }
|
||||||
|
public string? Codec_tag { get; set; }
|
||||||
|
public string? Mime_codec_string { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Width { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Height { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Coded_width { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Coded_height { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Closed_captions { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Film_grain { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Has_b_frames { get; set; }
|
||||||
|
|
||||||
|
public string? Sample_aspect_ratio { get; set; }
|
||||||
|
public string? Display_aspect_ratio { get; set; }
|
||||||
|
|
||||||
|
public string? Pix_fmt { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Level { get; set; }
|
||||||
|
|
||||||
|
public string? Color_range { get; set; }
|
||||||
|
public string? Color_space { get; set; }
|
||||||
|
public string? Color_transfer { get; set; }
|
||||||
|
public string? Color_primaries { get; set; }
|
||||||
|
public string? Chroma_location { get; set; }
|
||||||
|
public string? Field_order { get; set; }
|
||||||
|
|
||||||
|
public string? Is_avc { get; set; }
|
||||||
|
public string? Nal_length_size { get; set; }
|
||||||
|
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string? R_frame_rate { get; set; }
|
||||||
|
public string? Avg_frame_rate { get; set; }
|
||||||
|
public string? Time_base { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Start_pts { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Start_time { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Duration_ts { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Duration { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Bit_rate { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Max_bit_rate { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Bits_per_raw_sample { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Bits_per_sample { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||||
|
public long? Nb_frames { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Extradata_size { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Channels { get; set; }
|
||||||
|
|
||||||
|
public string? Channel_layout { get; set; }
|
||||||
|
public string? Sample_fmt { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Sample_rate { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Initial_padding { get; set; }
|
||||||
|
|
||||||
|
public string? Disposition_raw { get; set; }
|
||||||
|
public Dictionary<string, int>? Disposition { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string>? Tags { get; set; }
|
||||||
|
|
||||||
|
public string? Language { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleIntConverter))]
|
||||||
|
public int? Bits_per_coded_sample { get; set; }
|
||||||
|
|
||||||
|
public string? Codec_time_base { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Start_pts_time { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||||
|
public double? Duration_time { get; set; }
|
||||||
|
|
||||||
|
public string? Extradata { get; set; }
|
||||||
|
public string? Default { get; set; }
|
||||||
|
public string? Forced { get; set; }
|
||||||
|
}
|
||||||
31
splitter-cli/probe/FlexibleDoubleConverter.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FlexibleDoubleConverter : JsonConverter<double?>
|
||||||
|
{
|
||||||
|
public override double? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Number)
|
||||||
|
return reader.GetDouble();
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
var s = reader.GetString();
|
||||||
|
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, double? value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (value.HasValue)
|
||||||
|
writer.WriteNumberValue(value.Value);
|
||||||
|
else
|
||||||
|
writer.WriteNullValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
splitter-cli/probe/FlexibleIntConverter.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FlexibleIntConverter : JsonConverter<int?>
|
||||||
|
{
|
||||||
|
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Number)
|
||||||
|
return reader.GetInt32();
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
var s = reader.GetString();
|
||||||
|
if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v))
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (value.HasValue)
|
||||||
|
writer.WriteNumberValue(value.Value);
|
||||||
|
else
|
||||||
|
writer.WriteNullValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
splitter-cli/probe/FlexibleLongConverter.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public sealed class FlexibleLongConverter : JsonConverter<long?>
|
||||||
|
{
|
||||||
|
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Number)
|
||||||
|
return reader.GetInt64();
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
var s = reader.GetString();
|
||||||
|
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v))
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (value.HasValue)
|
||||||
|
writer.WriteNumberValue(value.Value);
|
||||||
|
else
|
||||||
|
writer.WriteNullValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,4 @@
|
|||||||
using OpenCvSharp;
|
namespace splitter.probe;
|
||||||
|
|
||||||
namespace splitter;
|
|
||||||
|
|
||||||
public sealed class FrameRotationDetector
|
public sealed class FrameRotationDetector
|
||||||
{
|
{
|
||||||
@ -18,17 +16,17 @@ public sealed class FrameRotationDetector
|
|||||||
|
|
||||||
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
|
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
|
||||||
{
|
{
|
||||||
_w = width;
|
_w = width;
|
||||||
_h = height;
|
_h = height;
|
||||||
_bins = bins;
|
_bins = bins;
|
||||||
|
|
||||||
_gray = new Mat(height, width, MatType.CV_8UC1);
|
_gray = new Mat(height, width, MatType.CV_8UC1);
|
||||||
_gx = new Mat(height, width, MatType.CV_32F);
|
_gx = new Mat(height, width, MatType.CV_32F);
|
||||||
_gy = new Mat(height, width, MatType.CV_32F);
|
_gy = new Mat(height, width, MatType.CV_32F);
|
||||||
_mag = new Mat(height, width, MatType.CV_32F);
|
_mag = new Mat(height, width, MatType.CV_32F);
|
||||||
_angle = new Mat(height, width, MatType.CV_32F);
|
_angle = new Mat(height, width, MatType.CV_32F);
|
||||||
|
|
||||||
_hist = new float[bins]; // allocated once
|
_hist = new float[bins]; // allocated once
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetRotation(Mat frame)
|
public int GetRotation(Mat frame)
|
||||||
93
splitter-cli/probe/ProbeVideo.cs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public static class ProbeVideo
|
||||||
|
{
|
||||||
|
static ProbeVideo()
|
||||||
|
{
|
||||||
|
_ffprobeJsonOptions.Converters.Add(new FlexibleDoubleConverter());
|
||||||
|
_ffprobeJsonOptions.Converters.Add(new FlexibleIntConverter());
|
||||||
|
_ffprobeJsonOptions.Converters.Add(new FlexibleLongConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<VideoInfo> Probe(string inputFile, bool detectRotation, CancellationToken token)
|
||||||
|
{
|
||||||
|
var info = await ProbeSize(inputFile, token);
|
||||||
|
if (detectRotation)
|
||||||
|
{
|
||||||
|
var rotation = await ProbeRotation(inputFile, info.Duration, token);
|
||||||
|
info = info with { Rotation = rotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> ProbeRotation(string inputFile, double duration, CancellationToken token)
|
||||||
|
=> await new VideoRotationSampler(null).DetectRotationAsync(inputFile, duration, token);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _ffprobeJsonOptions =
|
||||||
|
new ()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
IgnoreReadOnlyProperties = false,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<VideoInfo> ProbeSize(string inputFile, CancellationToken token)
|
||||||
|
{
|
||||||
|
var args =
|
||||||
|
"-v error " +
|
||||||
|
"-show_streams " +
|
||||||
|
"-show_format " +
|
||||||
|
"-of json " +
|
||||||
|
$"\"{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 json = await p.StandardOutput.ReadToEndAsync(token);
|
||||||
|
await p.WaitForExitAsync(token);
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<FfprobeResult>(json, _ffprobeJsonOptions);
|
||||||
|
var stream = result?.Streams?.FirstOrDefault();
|
||||||
|
var format = result?.Format;
|
||||||
|
|
||||||
|
var duration = format?.Duration ?? 0.0;
|
||||||
|
var width = stream?.Width ?? 0;
|
||||||
|
var height = stream?.Height ?? 0;
|
||||||
|
|
||||||
|
double fps = 0.0;
|
||||||
|
if (!string.IsNullOrWhiteSpace(stream?.Avg_frame_rate))
|
||||||
|
{
|
||||||
|
var parts = stream.Avg_frame_rate.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitrate = stream?.Bit_rate ?? 0.0;
|
||||||
|
|
||||||
|
return new VideoInfo(duration, width, height, fps, bitrate, stream?.Sample_aspect_ratio, stream?.Display_aspect_ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
37
splitter-cli/probe/VideoInfo.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace splitter.probe;
|
||||||
|
|
||||||
|
public record VideoInfo(
|
||||||
|
double Duration,
|
||||||
|
int Width,
|
||||||
|
int Height,
|
||||||
|
double Fps,
|
||||||
|
double Bitrate,
|
||||||
|
string? SampleAspectRatio,
|
||||||
|
string? DisplayAspectRatio,
|
||||||
|
int Rotation = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public Point2f Sar => ParseAspectRatio(SampleAspectRatio);
|
||||||
|
public Point2f Dar => ParseAspectRatio(DisplayAspectRatio);
|
||||||
|
|
||||||
|
private static Point2f ParseAspectRatio(string? sar)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sar))
|
||||||
|
return new Point2f(1.0f, 1.0f);
|
||||||
|
|
||||||
|
var parts = sar.Split(':');
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return new(1.0f, 1.0f);
|
||||||
|
|
||||||
|
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) &&
|
||||||
|
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) &&
|
||||||
|
den != 0)
|
||||||
|
{
|
||||||
|
return new(num, den);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
using OpenCvSharp;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.probe;
|
||||||
|
|
||||||
public sealed class VideoRotationSampler
|
public sealed class VideoRotationSampler
|
||||||
{
|
{
|
||||||
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
|
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
|
||||||
|
|
||||||
public static int RotationDetectorSampleCount = 20;
|
public static int RotationDetectorSampleCount = 10;
|
||||||
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;
|
||||||
@ -16,16 +15,19 @@ public sealed class VideoRotationSampler
|
|||||||
private readonly byte[] _buffer;
|
private readonly byte[] _buffer;
|
||||||
private readonly Mat _frameMat;
|
private readonly Mat _frameMat;
|
||||||
|
|
||||||
public VideoRotationSampler(SingleJob _master)
|
public VideoRotationSampler(IDictionary<string, string>? overrides)
|
||||||
{
|
{
|
||||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s))
|
if (overrides != null)
|
||||||
RotationDetectorSampleCount = int.Parse(s);
|
{
|
||||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s))
|
if (overrides.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||||
RotationDetectorSampleLength = double.Parse(s);
|
RotationDetectorSampleCount = int.Parse(s);
|
||||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s))
|
if (overrides.TryGetValue("RotationDetectorSampleLength", out s))
|
||||||
RotationDetectorFrameWidth = int.Parse(s);
|
RotationDetectorSampleLength = double.Parse(s);
|
||||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s))
|
if (overrides.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||||
RotationDetectorFrameHeight = int.Parse(s);
|
RotationDetectorFrameWidth = int.Parse(s);
|
||||||
|
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||||
|
RotationDetectorFrameHeight = int.Parse(s);
|
||||||
|
}
|
||||||
|
|
||||||
int w = RotationDetectorFrameWidth;
|
int w = RotationDetectorFrameWidth;
|
||||||
int h = RotationDetectorFrameHeight;
|
int h = RotationDetectorFrameHeight;
|
||||||
@ -36,7 +38,8 @@ 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;
|
||||||
@ -52,7 +55,8 @@ public sealed class VideoRotationSampler
|
|||||||
t,
|
t,
|
||||||
RotationDetectorSampleLength,
|
RotationDetectorSampleLength,
|
||||||
RotationDetectorFrameWidth,
|
RotationDetectorFrameWidth,
|
||||||
RotationDetectorFrameHeight);
|
RotationDetectorFrameHeight,
|
||||||
|
token);
|
||||||
|
|
||||||
if (frame != null && !frame.Empty())
|
if (frame != null && !frame.Empty())
|
||||||
{
|
{
|
||||||
@ -96,18 +100,21 @@ 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);
|
||||||
|
|
||||||
int needed = _buffer.Length;
|
var needed = _buffer.Length;
|
||||||
int read = 0;
|
var read = 0;
|
||||||
|
|
||||||
using var stdout = p.StandardOutput.BaseStream;
|
using var stdout = p.StandardOutput.BaseStream;
|
||||||
|
|
||||||
while (read < needed)
|
while (read < needed)
|
||||||
{
|
{
|
||||||
int r = await stdout.ReadAsync(_buffer, read, needed - read);
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
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,6 +1,3 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using Spectre.Console;
|
|
||||||
using splitter;
|
using splitter;
|
||||||
|
|
||||||
static partial class Program
|
static partial class Program
|
||||||
@ -33,24 +30,26 @@ static partial class Program
|
|||||||
uiTask = logger.RunAsync(cts.Token);
|
uiTask = logger.RunAsync(cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var processor = new JobProcessor(_logger);
|
||||||
|
|
||||||
if (cmd.Master.EstimateOnly)
|
if (cmd.Master.EstimateOnly)
|
||||||
LogInfo("=== ESTIMATE MODE ===");
|
_logger.LogInfo("=== ESTIMATE MODE ===");
|
||||||
|
|
||||||
var allJobs = new List<SingleTask>();
|
var allJobs = new List<SingleTask>();
|
||||||
foreach ( var job in cmd.Jobs )
|
foreach ( var job in cmd.Jobs )
|
||||||
{
|
{
|
||||||
var jobs = await GenerateJobs(cmd, job);
|
var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, CancellationToken.None);
|
||||||
allJobs.AddRange(jobs);
|
allJobs.AddRange(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( allJobs.Count == 0)
|
if ( allJobs.Count == 0)
|
||||||
{
|
{
|
||||||
if ( !cmd.Master.EstimateOnly)
|
if ( !cmd.Master.EstimateOnly)
|
||||||
LogWarn("No valid jobs to process.");
|
_logger.LogWarn("No valid jobs to process.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = await ProcessJobs(cmd, allJobs);
|
var success = await processor.ProcessJobs(allJobs, cmd.Master.SingleThreaded, CancellationToken.None);
|
||||||
if (uiTask != null)
|
if (uiTask != null)
|
||||||
{
|
{
|
||||||
if ( cts != null )
|
if ( cts != null )
|
||||||
@ -63,217 +62,4 @@ static partial class Program
|
|||||||
return success ? 1 : 0;
|
return success ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<SingleTask>> GenerateJobs(CommandLine cmd, SingleJob job)
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
var info = await ProbeVideo.Probe(job);
|
|
||||||
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 (cmd.Master.EstimateOnly)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
Func<int, ISegmentProcessor> processorFactory;
|
|
||||||
if (job.Crop != null)
|
|
||||||
{
|
|
||||||
processorFactory = i =>
|
|
||||||
{
|
|
||||||
IObjectDetector detector = job.Detect switch
|
|
||||||
{
|
|
||||||
"face" => new UltraFaceDetector(_logger),
|
|
||||||
"body" => new YoloOnnxObjectDetector(_logger),
|
|
||||||
_ => throw new InvalidOperationException($"Unknown detector: {job.Detect}")
|
|
||||||
};
|
|
||||||
return new TrackingSplitter(i, detector, job, _logger);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
processorFactory = i => new SimpleSplitter(i, _logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
var jobs = Enumerable.Range(0, segments)
|
|
||||||
.Select(i => new SingleTask
|
|
||||||
(
|
|
||||||
Job : job,
|
|
||||||
Info: info,
|
|
||||||
OutputFileName : BuildOutputFileName(job, i),
|
|
||||||
SegmentIndex : i,
|
|
||||||
TotalSegments : segments,
|
|
||||||
SegmentStart : i * segmentLength,
|
|
||||||
SegmentLength : (i == segments - 1)
|
|
||||||
? Math.Max(0.1, info.Duration - i * segmentLength)
|
|
||||||
: segmentLength,
|
|
||||||
ProcessorFactory : processorFactory
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> ProcessJobs(CommandLine cmd, List<SingleTask> tasks)
|
|
||||||
{
|
|
||||||
|
|
||||||
if (cmd.Master.SingleThreaded)
|
|
||||||
{
|
|
||||||
LogInfo("Starting single-threaded splitting...");
|
|
||||||
await RunSingleThreaded(tasks);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
LogInfo("Starting multi-threaded splitting...");
|
|
||||||
await RunMultiThreaded(tasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
LogInfo("Done.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void LogInfo(string message) => _logger.LogInfo(message);
|
|
||||||
private static void LogWarn(string message) => _logger.LogWarn(message);
|
|
||||||
private static void LogError(string message) => _logger.LogError(message);
|
|
||||||
private static void LogProgress(double progress, TimeSpan eta, double speed) => _logger.DrawProgress("Total", 0, progress, eta, speed);
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// ffprobe
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// Multi-threaded splitting
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
static async Task RunMultiThreaded(List<SingleTask> jobs)
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
|
|
||||||
tasks.Add(Task.Run(async () =>
|
|
||||||
{
|
|
||||||
int slot = -1;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Acquire a slot ID
|
|
||||||
while (!freeSlots.TryDequeue(out slot))
|
|
||||||
await Task.Yield();
|
|
||||||
|
|
||||||
await ProcessSegment(job,slot + 1);
|
|
||||||
|
|
||||||
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
|
|
||||||
// -----------------------------
|
|
||||||
|
|
||||||
static async Task RunSingleThreaded(List<SingleTask> jobs)
|
|
||||||
{
|
|
||||||
foreach (var job in jobs)
|
|
||||||
{
|
|
||||||
await ProcessSegment(job, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ProcessSegment(SingleTask t, int slot)
|
|
||||||
{
|
|
||||||
var processor = t.ProcessorFactory(slot);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await processor.ProcessSegment(t);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (processor is IDisposable disposable)
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<BuildNumber>0</BuildNumber>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- DEBUG CONFIGURATION -->
|
<!-- DEBUG CONFIGURATION -->
|
||||||
@ -49,7 +50,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Update="ThisAssembly.g.cs" />
|
|
||||||
|
|
||||||
<Content Include="models/*.*">
|
<Content Include="models/*.*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 542 KiB |
@ -1,8 +1,8 @@
|
|||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
public interface ILogger
|
public interface ILogger
|
||||||
{
|
{
|
||||||
void ClearProgress(int progressLevel);
|
void ClearProgress(string name, int progressLine);
|
||||||
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);
|
||||||
|
|
||||||
@ -1,7 +1,11 @@
|
|||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -17,6 +21,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()
|
protected void ClearProgress(string name)
|
||||||
=> _logger.ClearProgress(_progressLine);
|
=> _logger.ClearProgress(name,_progressLine);
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
using Spectre.Console.Rendering;
|
using Spectre.Console.Rendering;
|
||||||
|
|
||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
|||||||
|
|
||||||
// ---- ILogger ----
|
// ---- ILogger ----
|
||||||
|
|
||||||
public void ClearProgress(int progressLevel)
|
public void ClearProgress(string name, int progressLine)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
_progress[progressLevel] = ProgressEntry.Empty;
|
_progress[progressLine] = ProgressEntry.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.tui;
|
||||||
|
|
||||||
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(int progressLevel){}
|
public void ClearProgress(string name, int progressLine) {}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace splitter;
|
namespace splitter.util;
|
||||||
|
|
||||||
public static class FileMaskExpander
|
public static class FileMaskExpander
|
||||||
{
|
{
|
||||||
@ -6,4 +6,5 @@
|
|||||||
<File Path="README.md" />
|
<File Path="README.md" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="splitter-cli/splitter.csproj" />
|
<Project Path="splitter-cli/splitter.csproj" />
|
||||||
|
<Project Path="Splitter-UI/Splitter-UI.csproj" Id="f80bfed3-d5f0-4292-92b2-909c21625ee3" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||