Compare commits

...

21 Commits

Author SHA1 Message Date
9496d46411 Link updated 2026-05-30 13:28:43 +01:00
fd75af7f99 App icon added 2026-05-27 09:55:01 +01:00
0359d61ae0 README updated 2026-05-27 09:02:01 +01:00
05d203c446 Single threaded thumbnail service (no need to parallelize). Updated READMEs. 2026-05-27 08:58:27 +01:00
093c7c7803 Sar/Dar support for all segment processors 2026-05-26 08:57:08 +01:00
23bfdc8452 Cancellation support added 2026-05-25 15:03:39 +01:00
af363ebb9a Video processing implemented. 2026-05-25 12:34:36 +01:00
9cdf611ec8 Logging aded. Autoscroll for logging. 2026-05-25 10:44:05 +01:00
2dc7b050c8 Automatic crop size calculation. GravitateTo is in sync. Defaults appied at start. 2026-05-25 09:11:18 +01:00
c6ca4fcbb6 Crop rectangle and gravitate to (meveable) point added to preview. 2026-05-24 13:35:10 +01:00
61c94d4661 SingleJob made private. Correct SAR for rotated images. Structure refactoing on splitter side. 2026-05-24 11:34:38 +01:00
417d511bc8 Major structure refactoring 2026-05-24 11:09:00 +01:00
a408d43b61 SAR/DAR support added 2026-05-24 09:16:39 +01:00
4f83fc1dd2 Full ffprobe output parsing. No SaR/DaR support yet. 2026-05-23 12:47:34 +01:00
18928a23f9 Detection preview added. 2026-05-23 11:35:48 +01:00
42408bba38 UI fixed: rotation support, ff/bb buttons on preview, inspector layout. 2026-05-23 10:05:58 +01:00
e566bb6137 Detection preview UI. 2026-05-22 17:24:29 +01:00
e18d043b78 UI for SingleJob added. 2026-05-22 08:58:09 +01:00
ad418e18a9 Multiselection added to FileListView 2026-05-21 23:25:11 +01:00
3f1924a429 Added FontAwesome. Added suggested op decal. 2026-05-21 09:37:26 +01:00
1f93eba839 Avalonia UI work started 2026-05-20 08:34:54 +01:00
100 changed files with 4144 additions and 699 deletions

220
README.md
View File

@ -1,212 +1,48 @@
# Splitter
Splitter is a highperformance command line tool for cutting one or more video files into equal or fixedlength segments using multithreaded FFmpeg execution.
It supports batch input, flexible duration formats, rotation, smart face/bodyaware cropping, ETA and speed reporting, and both rich and plaintext terminal output.
Splitter is a high-performance command line tool for cutting one or more video files into equal or
fixedlength segments using multithreaded FFmpeg execution. It supports batch input, flexible
duration formats, rotation, smart face/bodyaware cropping, ETA and speed reporting, with nice GUI
or both rich and plaintext terminal output.
![Splitter](splitter.png)
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
- Multithreaded FFmpeg splitting for maximum throughput
- Human face or body detection with smart cropping
- Multi-threaded FFmpeg splitting for maximum throughput
- Equal or fixedlength 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] Crossplatform (.NET 10)
- [Potentially] Cross-platform (.NET 10)
## Screenshots
### Command line interface
![Splitter](splitter-cli/splitter.png)
### Graphical user interface
![Splitter UI](splitter-ui/screenshot.png)
## 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 vs Body Tracking](tracking.png)
### Face Tracking Using UltraFace 320
Splitter uses the UltraFace 320 ONNX model to perform lightweight, realtime 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 perframe
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 portraitoriented camera operator. The UltraFace 320 model is
fast enough to run alongside multithreaded FFmpeg splitting without becoming a bottleneck,
making it suitable for long recordings and batch processing.
### Benefits of FullBody 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.
Fullbody 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 losttracking
moments. For creators converting horizontal gig footage into short vertical clips for YouTube
Shorts or TikTok, bodybased 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 threestate 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
naturallooking 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 offscreen 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 rotationestimation 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 perpixel gradient magnitudes and orientations.
These orientations are folded into the range [0, 180) and accumulated into a fixedsize,
magnitudeweighted 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 brightnessbased 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 highthroughput video processing: all intermediate Mats, buffers, and histograms are
preallocated, and pixel data is accessed directly through pointers to avoid perframe memory
allocation. The method is intentionally biased toward the upright orientation, returning a sideways
classification only when the horizontaledge energy significantly exceeds the verticaledge 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, ASCIIonly **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 nonoption 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 plaintext logging instead of the rich terminal UI. |
| **--single-thread** | Disable parallel FFmpeg execution. Useful for debugging or lowresource 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 60second segments:
```
splitter vertical-video.mp4
```
Split into equal 90second 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 45second 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
View 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
View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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

View 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();
}

View 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;
}

View 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();
}

View 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();
}

View 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();
}

View 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;

View 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;
}
}

View 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
View 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
View 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
![Main window with job list and settings](screenshot.png)
## 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.

View 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)}";
}
}

View 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;
}
}

View 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() {}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View File

@ -0,0 +1,6 @@
namespace Splitter_UI.Services;
public interface IAutoDecisionService
{
void ApplyAutoDecisions(JobViewModel job, CancellationToken token);
}

View File

@ -0,0 +1,6 @@
namespace Splitter_UI.Services;
public interface IFileJobFactory
{
JobViewModel Create(SingleJob job);
}

View File

@ -0,0 +1,6 @@
namespace Splitter_UI.Services;
public interface IFileProbeService
{
Task<VideoInfo> ProbeAsync(string inputFile, CancellationToken token);
}

View File

@ -0,0 +1,7 @@

namespace Splitter_UI.Services;
public interface ILogService
{
void Log(string prefix, ConsoleColor color, string msg);
}

View 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);
}

View 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();
}
}

View 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);
}
}
}

View 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>

View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}
}

View 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));
}
}
}

View 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);
}
}
}

View 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;
}

View File

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Splitter_UI.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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>

View 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);
}
}

View 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="&#xf2ea;"
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="&#xf2f9;"
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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class InspectorPane : UserControl
{
public InspectorPane()
{
InitializeComponent();
}
}

View 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>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class JobListItemView : UserControl
{
public JobListItemView()
{
InitializeComponent();
}
}

View 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>

View 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);
}
}

View 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>

View 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();
}
}

View 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);
}
}

View 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="&#xf048;"
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="&#xf051;"
FontSize="12"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class PreviewPane : UserControl
{
public PreviewPane()
{
InitializeComponent();
}
}

View 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>

View File

@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace Splitter_UI.Views;
public partial class ProgressView : UserControl
{
public ProgressView()
{
InitializeComponent();
}
}

View 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>

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

View File

@ -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();

View File

@ -0,0 +1,5 @@
global using OpenCvSharp;
global using splitter.algo;
global using splitter.probe;
global using splitter.tui;

View 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);
}

View File

@ -1,6 +0,0 @@
namespace splitter;
public interface ISegmentProcessor
{
Task ProcessSegment( SingleTask job );
}

View 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);
}
}

View File

@ -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;
}
}

View File

@ -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
View 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.
![Splitter](splitter.png)
## 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 vs Body Tracking](tracking.png)
### 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
```

View File

@ -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
View 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}");
}
}
}

View File

@ -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;

View File

@ -1,6 +1,4 @@
using OpenCvSharp;
namespace splitter;
namespace splitter.algo;
public enum TrackState
{

View File

@ -1,6 +1,4 @@
using OpenCvSharp;
namespace splitter;
namespace splitter.algo;
public interface IObjectDetector : IDisposable
{

View File

@ -0,0 +1,6 @@
namespace splitter.algo;
public interface ISegmentProcessor
{
Task ProcessSegment( SingleTask job, CancellationToken token);
}

View File

@ -1,4 +1,4 @@
namespace splitter;
namespace splitter.algo;
public sealed class KalmanTracker
{

View 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;
// }
//}

View File

@ -1,8 +1,7 @@
using System.Runtime.InteropServices;
using OpenCvSharp;
using UltraFaceDotNet;
namespace splitter;
namespace splitter.algo;
public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
{

View File

@ -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
{

View 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; }
}

View File

@ -0,0 +1,7 @@
namespace splitter.probe;
public sealed class FfprobeResult
{
public List<FfprobeStream>? Streams { get; set; }
public FfprobeFormat? Format { get; set; }
}

View 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; }
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@ -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)

View 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);
}
}

View 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);
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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>

View File

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View File

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 542 KiB

View File

@ -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);

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

@ -1,4 +1,4 @@
namespace splitter;
namespace splitter.util;
public static class FileMaskExpander
{

View File

@ -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>