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 is a high‑performance command line tool for cutting one or more video files into equal or fixed‑length segments using multi‑threaded FFmpeg execution.
|
||||
It supports batch input, flexible duration formats, rotation, smart face/body‑aware cropping, ETA and speed reporting, and both rich and plain‑text terminal output.
|
||||
Splitter is a high-performance command line tool for cutting one or more video files into equal or
|
||||
fixed‑length segments using multi‑threaded FFmpeg execution. It supports batch input, flexible
|
||||
duration formats, rotation, smart face/body‑aware cropping, ETA and speed reporting, with nice GUI
|
||||
or both rich and plain‑text terminal output.
|
||||
|
||||

|
||||
The intended primary use case is for content creators who need to split large video files into smaller
|
||||
segments for platforms like TikTok, Instagram Reels, YouTube Shorts, or similar. The smart
|
||||
cropping feature allows the tool to automatically detect and keep faces or bodies in the frame
|
||||
when splitting, ensuring that important content is not cut off.
|
||||
|
||||
Splitter uses cutting-edge body-detection CV models to analyze the video and determine optimal
|
||||
cropping regions for each segment. Smooth tracking and gravitation bias ensure that the cropping remains
|
||||
stable and focused on the subject without excessive jitter or erratic movements.
|
||||
The tool can also correct for rotation metadata to ensure proper orientation in the output segments.
|
||||
|
||||
Splitter uses FFmpeg for the actual splitting and encoding, with multi-threading to maximize performance.
|
||||
|
||||
## Features
|
||||
|
||||
- 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
|
||||
- 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)
|
||||
- [Potentially] Cross-platform (.NET 10)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Command line interface
|
||||

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

|
||||
|
||||
## Requirements
|
||||
|
||||
- FFmpeg and FFprobe available in system PATH
|
||||
- .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)
|
||||
- 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
|
||||
```
|
||||
[Command line tool](splitter-cli/README.md)
|
||||
|
||||
[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 splitter.util;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
public class SingleJob
|
||||
{
|
||||
public string InputFile { get; set; } = null!;
|
||||
public string OutputFolder { get; set; } = null!;
|
||||
public (int width, int height)? Crop { get; set; }
|
||||
public Point2f? GravitateTo { get; set; }
|
||||
public string? Mask { get; set; }
|
||||
public bool Debug { get; set; }
|
||||
public string? Detect { get; set; }
|
||||
public double? OverrideTargetDuration { get; set; }
|
||||
public string[] Passthrough { get; set; } = [];
|
||||
public bool PlainText { get; set; }
|
||||
public bool EstimateOnly { get; set; }
|
||||
public bool ForceFixed { get; set; }
|
||||
public bool SingleThreaded { get; set; }
|
||||
public int? Rotate { get; set; }
|
||||
public bool RotateAuto { get; set; }
|
||||
public Dictionary<string, string> Parameters { get; set; } = [];
|
||||
|
||||
public void Override<T>(ref T member, string name)
|
||||
{
|
||||
if (!Parameters.TryGetValue(name, out var raw))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Convert.ChangeType handles int, float, double, etc.
|
||||
var converted = (T)Convert.ChangeType(
|
||||
raw,
|
||||
typeof(T),
|
||||
CultureInfo.InvariantCulture
|
||||
);
|
||||
|
||||
member = converted;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine($"Invalid value for parameter '{name}': {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class CommandLine
|
||||
{
|
||||
// Default vertical Full HD for YouTube Shorts
|
||||
public const int DefaultW = 607;
|
||||
public const int DefaultH = 1080;
|
||||
|
||||
public SingleJob Master { get; } = new SingleJob();
|
||||
public SingleJob[] Jobs { get; }
|
||||
|
||||
@ -272,13 +234,9 @@ public sealed class CommandLine
|
||||
|
||||
private static (int width, int height)? ParseCrop(string v)
|
||||
{
|
||||
// Default vertical Full HD for YouTube Shorts
|
||||
const int defaultW = 607;
|
||||
const int defaultH = 1080;
|
||||
|
||||
// Empty or whitespace → default crop
|
||||
if (string.IsNullOrWhiteSpace(v))
|
||||
return (defaultW, defaultH);
|
||||
return (DefaultW, DefaultH);
|
||||
|
||||
var s = v.Trim().ToLowerInvariant();
|
||||
|
||||
|
||||
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 async Task ProcessSegment(SingleTask job)
|
||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||
{
|
||||
string inputFile = job.Job.InputFile;
|
||||
string outputFile = job.OutputFileName;
|
||||
double start = job.SegmentStart;
|
||||
double length = job.SegmentLength;
|
||||
int videoWidth = job.Info.Width;
|
||||
int videoHeight = job.Info.Height;
|
||||
double fps = job.Info.Fps;
|
||||
string[] ffmpegPassthroughParameters = job.Job.Passthrough;
|
||||
|
||||
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
|
||||
string inputFile = job.Job.InputFile;
|
||||
string outputFile = job.OutputFileName;
|
||||
double start = job.SegmentStart;
|
||||
double length = job.SegmentLength;
|
||||
|
||||
var rotation = GetRotationFilter(job.Job.Rotate);
|
||||
|
||||
string args;
|
||||
var rotation = GetRotationFilter(job.Job.Rotate);
|
||||
|
||||
if (rotation == null)
|
||||
{
|
||||
// Copy path: keep original SAR/DAR exactly as in source
|
||||
args =
|
||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{inputFile}\" " +
|
||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-c copy {pass} \"{outputFile}\" -y";
|
||||
$"-c copy {string.Join(" ", job.Job.Passthrough)} " +
|
||||
$"\"{outputFile}\" -y";
|
||||
}
|
||||
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 =
|
||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-i \"{inputFile}\" " +
|
||||
$"-t {length.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"-vf \"{rotation}\" " +
|
||||
sarArg + darArg +
|
||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||
"-c:a copy " +
|
||||
$"{pass} \"{outputFile}\" -y";
|
||||
$"{string.Join(" ", job.Job.Passthrough)} " +
|
||||
$"\"{outputFile}\" -y";
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args,
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg.");
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(outputFile);
|
||||
ShowFFMpegProgress(length, proc, name);
|
||||
await ShowFFMpegProgress(length, proc, name, token);
|
||||
|
||||
proc.WaitForExit();
|
||||
|
||||
ClearProgress();
|
||||
await proc.WaitForExitAsync(token);
|
||||
|
||||
ClearProgress(name);
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
LogError($"Segment {name} FFmpeg encoding failed");
|
||||
@ -65,6 +102,7 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
|
||||
LogInfo($"Segment {name} processing completed");
|
||||
}
|
||||
|
||||
|
||||
string? GetRotationFilter(int? degrees) =>
|
||||
degrees switch
|
||||
{
|
||||
@ -74,13 +112,27 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
|
||||
_ => 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();
|
||||
|
||||
string? line;
|
||||
while ((line = proc.StandardError.ReadLine()) != null)
|
||||
while ((line = await proc.StandardError.ReadLineAsync(token)) != null)
|
||||
{
|
||||
// Look for "time=00:00:03.52"
|
||||
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.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
|
||||
@ -25,7 +24,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
d.Dispose();
|
||||
}
|
||||
|
||||
public async Task ProcessSegment(SingleTask job)
|
||||
public async Task ProcessSegment(SingleTask job, CancellationToken token)
|
||||
{
|
||||
string inputFile = job.Job.InputFile;
|
||||
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}");
|
||||
|
||||
// 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;
|
||||
|
||||
// 3) Start FFmpeg encode (video from stdin + audio from original)
|
||||
var encode = StartFfmpegEncode(
|
||||
var encode = await StartFfmpegEncode(
|
||||
inputFile,
|
||||
outputFile,
|
||||
start,
|
||||
length,
|
||||
encWidth,
|
||||
encHeight,
|
||||
fps,
|
||||
job.Info,
|
||||
ffmpegPassthroughParameters,
|
||||
job.Job.PlainText);
|
||||
job.Job.PlainText,
|
||||
token);
|
||||
|
||||
using var encodeStdin = encode.StandardInput.BaseStream;
|
||||
|
||||
@ -100,9 +100,11 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
|
||||
while (frameIndex < totalFrames)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
frameIndex++;
|
||||
|
||||
var read = ReadExact(decodeStdout, inBuffer, 0, inBytes);
|
||||
var read = await ReadExact(decodeStdout, inBuffer, 0, inBytes, token);
|
||||
if (read != inBytes)
|
||||
break;
|
||||
|
||||
@ -153,7 +155,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { }
|
||||
try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { }
|
||||
|
||||
ClearProgress();
|
||||
ClearProgress(name);
|
||||
|
||||
|
||||
if (encode.ExitCode != 0)
|
||||
@ -165,21 +167,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
|
||||
// ---------- 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 t = length.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
var rotateStr = "";
|
||||
if (rotate != null)
|
||||
{
|
||||
switch (rotate.Value)
|
||||
{
|
||||
case 90: rotateStr = ",transpose=1"; break;
|
||||
case 180: rotateStr = ",transpose=PI"; break;
|
||||
case 270: rotateStr = ",transpose=2"; break;
|
||||
}
|
||||
}
|
||||
var rotateStr = GetRorationArg(rotate);
|
||||
|
||||
var args =
|
||||
$"-i \"{inputFile}\" -ss {ss} -t {t} " +
|
||||
@ -202,12 +195,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
|
||||
var fileName = Path.GetFileName(inputFile);
|
||||
|
||||
_ = Task.Run(() =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = p.StandardError.ReadLine()) != null)
|
||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||
if (plainText)
|
||||
LogInfo($"[ffmpeg-decode] {fileName}: {line}");
|
||||
}
|
||||
@ -217,21 +210,57 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
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 outputFile,
|
||||
double start,
|
||||
double length,
|
||||
int width,
|
||||
int height,
|
||||
double fps,
|
||||
int width, int height,
|
||||
VideoInfo info,
|
||||
string[] passthrough,
|
||||
bool plainText)
|
||||
bool plainText,
|
||||
CancellationToken token)
|
||||
{
|
||||
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 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 =
|
||||
"-y " +
|
||||
@ -239,6 +268,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
$"-ss {ss} -i \"{inputFile}\" " +
|
||||
"-map 0:v:0 -map 1:a:0? -shortest " +
|
||||
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||
sarArg + darArg +
|
||||
"-c:a copy " +
|
||||
pass + $" \"{outputFile}\"";
|
||||
|
||||
@ -260,12 +290,12 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
|
||||
var fileName = Path.GetFileName(outputFile);
|
||||
|
||||
_ = Task.Run(() =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = p.StandardError.ReadLine()) != null)
|
||||
while ((line = await p.StandardError.ReadLineAsync(token)) != null)
|
||||
{
|
||||
if (plainText)
|
||||
LogInfo($"[ffmpeg-encode] {fileName}: {line}");
|
||||
@ -279,12 +309,32 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
|
||||
|
||||
// ---------- 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;
|
||||
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)
|
||||
break;
|
||||
total += read;
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
namespace splitter.algo;
|
||||
|
||||
public enum TrackState
|
||||
{
|
||||
@ -1,6 +1,4 @@
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
namespace splitter.algo;
|
||||
|
||||
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
|
||||
{
|
||||
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 OpenCvSharp;
|
||||
using UltraFaceDotNet;
|
||||
|
||||
namespace splitter;
|
||||
namespace splitter.algo;
|
||||
|
||||
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
|
||||
{
|
||||
@ -1,9 +1,8 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
using OpenCvSharp;
|
||||
|
||||
namespace splitter;
|
||||
namespace splitter.algo;
|
||||
|
||||
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;
|
||||
namespace splitter.probe;
|
||||
|
||||
public sealed class FrameRotationDetector
|
||||
{
|
||||
@ -18,17 +16,17 @@ public sealed class FrameRotationDetector
|
||||
|
||||
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
|
||||
{
|
||||
_w = width;
|
||||
_h = height;
|
||||
_bins = bins;
|
||||
_w = width;
|
||||
_h = height;
|
||||
_bins = bins;
|
||||
|
||||
_gray = new Mat(height, width, MatType.CV_8UC1);
|
||||
_gx = new Mat(height, width, MatType.CV_32F);
|
||||
_gy = new Mat(height, width, MatType.CV_32F);
|
||||
_mag = new Mat(height, width, MatType.CV_32F);
|
||||
_gray = new Mat(height, width, MatType.CV_8UC1);
|
||||
_gx = new Mat(height, width, MatType.CV_32F);
|
||||
_gy = new Mat(height, width, MatType.CV_32F);
|
||||
_mag = 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)
|
||||
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
|
||||
{
|
||||
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 int RotationDetectorFrameWidth = 320;
|
||||
public static int RotationDetectorFrameHeight = 180;
|
||||
@ -16,16 +15,19 @@ public sealed class VideoRotationSampler
|
||||
private readonly byte[] _buffer;
|
||||
private readonly Mat _frameMat;
|
||||
|
||||
public VideoRotationSampler(SingleJob _master)
|
||||
public VideoRotationSampler(IDictionary<string, string>? overrides)
|
||||
{
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||
RotationDetectorSampleCount = int.Parse(s);
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorSampleLength", out s))
|
||||
RotationDetectorSampleLength = double.Parse(s);
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||
RotationDetectorFrameWidth = int.Parse(s);
|
||||
if (_master.Parameters.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||
RotationDetectorFrameHeight = int.Parse(s);
|
||||
if (overrides != null)
|
||||
{
|
||||
if (overrides.TryGetValue("RotationDetectorSampleCount", out var s))
|
||||
RotationDetectorSampleCount = int.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorSampleLength", out s))
|
||||
RotationDetectorSampleLength = double.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorFrameWidth", out s))
|
||||
RotationDetectorFrameWidth = int.Parse(s);
|
||||
if (overrides.TryGetValue("RotationDetectorFrameHeight", out s))
|
||||
RotationDetectorFrameHeight = int.Parse(s);
|
||||
}
|
||||
|
||||
int w = RotationDetectorFrameWidth;
|
||||
int h = RotationDetectorFrameHeight;
|
||||
@ -36,7 +38,8 @@ public sealed class VideoRotationSampler
|
||||
|
||||
public async Task<int> DetectRotationAsync(
|
||||
string inputFile,
|
||||
double videoLengthSeconds)
|
||||
double videoLengthSeconds,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (videoLengthSeconds <= 0)
|
||||
return 0;
|
||||
@ -52,7 +55,8 @@ public sealed class VideoRotationSampler
|
||||
t,
|
||||
RotationDetectorSampleLength,
|
||||
RotationDetectorFrameWidth,
|
||||
RotationDetectorFrameHeight);
|
||||
RotationDetectorFrameHeight,
|
||||
token);
|
||||
|
||||
if (frame != null && !frame.Empty())
|
||||
{
|
||||
@ -96,18 +100,21 @@ public sealed class VideoRotationSampler
|
||||
double start,
|
||||
double length,
|
||||
int width,
|
||||
int height)
|
||||
int height,
|
||||
CancellationToken token)
|
||||
{
|
||||
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
|
||||
|
||||
int needed = _buffer.Length;
|
||||
int read = 0;
|
||||
var needed = _buffer.Length;
|
||||
var read = 0;
|
||||
|
||||
using var stdout = p.StandardOutput.BaseStream;
|
||||
|
||||
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)
|
||||
return null;
|
||||
read += r;
|
||||
@ -1,6 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Spectre.Console;
|
||||
using splitter;
|
||||
|
||||
static partial class Program
|
||||
@ -33,24 +30,26 @@ static partial class Program
|
||||
uiTask = logger.RunAsync(cts.Token);
|
||||
}
|
||||
|
||||
var processor = new JobProcessor(_logger);
|
||||
|
||||
if (cmd.Master.EstimateOnly)
|
||||
LogInfo("=== ESTIMATE MODE ===");
|
||||
_logger.LogInfo("=== ESTIMATE MODE ===");
|
||||
|
||||
var allJobs = new List<SingleTask>();
|
||||
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);
|
||||
}
|
||||
|
||||
if ( allJobs.Count == 0)
|
||||
{
|
||||
if ( !cmd.Master.EstimateOnly)
|
||||
LogWarn("No valid jobs to process.");
|
||||
_logger.LogWarn("No valid jobs to process.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var success = await ProcessJobs(cmd, allJobs);
|
||||
var success = await processor.ProcessJobs(allJobs, cmd.Master.SingleThreaded, CancellationToken.None);
|
||||
if (uiTask != null)
|
||||
{
|
||||
if ( cts != null )
|
||||
@ -63,217 +62,4 @@ static partial class Program
|
||||
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>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<BuildNumber>0</BuildNumber>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- DEBUG CONFIGURATION -->
|
||||
@ -49,7 +50,6 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="ThisAssembly.g.cs" />
|
||||
|
||||
<Content Include="models/*.*">
|
||||
<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
|
||||
{
|
||||
void ClearProgress(int progressLevel);
|
||||
void ClearProgress(string name, int progressLine);
|
||||
void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed);
|
||||
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)
|
||||
=> _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)
|
||||
=> _logger.DrawProgress(name, _progressLine, percent, eta, fps);
|
||||
|
||||
protected void ClearProgress()
|
||||
=> _logger.ClearProgress(_progressLine);
|
||||
protected void ClearProgress(string name)
|
||||
=> _logger.ClearProgress(name,_progressLine);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace splitter;
|
||||
namespace splitter.tui;
|
||||
|
||||
|
||||
/// <summary>
|
||||
@ -51,11 +51,11 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
|
||||
|
||||
// ---- ILogger ----
|
||||
|
||||
public void ClearProgress(int progressLevel)
|
||||
public void ClearProgress(string name, int progressLine)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_progress[progressLevel] = ProgressEntry.Empty;
|
||||
_progress[progressLine] = ProgressEntry.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
namespace splitter;
|
||||
namespace splitter.tui;
|
||||
|
||||
public class TextLogger() : ILogger
|
||||
{
|
||||
@ -13,6 +13,6 @@ public class TextLogger() : ILogger
|
||||
}
|
||||
|
||||
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {}
|
||||
public void ClearProgress(int progressLevel){}
|
||||
public void ClearProgress(string name, int progressLine) {}
|
||||
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
namespace splitter;
|
||||
namespace splitter.util;
|
||||
|
||||
public static class FileMaskExpander
|
||||
{
|
||||
@ -6,4 +6,5 @@
|
||||
<File Path="README.md" />
|
||||
</Folder>
|
||||
<Project Path="splitter-cli/splitter.csproj" />
|
||||
<Project Path="Splitter-UI/Splitter-UI.csproj" Id="f80bfed3-d5f0-4292-92b2-909c21625ee3" />
|
||||
</Solution>
|
||||
|
||||