Compare commits

...

2 Commits

12 changed files with 114 additions and 43 deletions

View File

@ -3,7 +3,7 @@ name: Build and Publish
on:
push:
tags:
- 'v*'
- 'v*'
permissions:
contents: write
@ -22,19 +22,19 @@ jobs:
dotnet-version: 10.0.x
- name: Restore
run: dotnet restore -r win-x64
run: dotnet restore Splitter-UI/Splitter-UI.csproj -r win-x64
- name: 'Get Version'
- name: Get Version
id: version
uses: battila7/get-version-action@v2
- name: Publish Release
run: dotnet publish splitter-cli/splitter.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
run: dotnet publish Splitter-UI/Splitter-UI.csproj -c Release -r win-x64 /p:Version=${{ steps.version.outputs.version-without-v }} /p:BuildNumber=${{ github.run_number }} /p:SourceRevisionId=${{ github.sha }}
- name: Create ZIP
shell: pwsh
run: |
$publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
$publish = "Splitter-UI/bin/Release/net10.0/win-x64/publish"
$version = "${{ steps.version.outputs.version-without-v }}"
$zip = "splitter-win-x64-$version.zip"
@ -51,5 +51,3 @@ jobs:
files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,5 +1,7 @@
# Splitter
This application was built to help me with maintaining my YouTube channel - [UnclShura](https://www.youtube.com/@UnclShura).
Splitter is a high-performance command line tool for cutting one or more video files into equal or
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

View File

@ -122,6 +122,19 @@ public partial class JobViewModel : ObservableObject
}
}
public float IdentityThreshold
{
get => Job.IdentityThreshold;
set
{
if (Math.Abs(Job.IdentityThreshold - value) < 0.001)
return;
Job.IdentityThreshold = value;
OnPropertyChanged();
Task.Run(CreatePreview);
}
}
public string? Mask
{
get => Job.Mask;

View File

@ -54,14 +54,14 @@ public partial class MainViewModel : ViewModelBase
_cancellationTokenSource?.Cancel();
}
public async Task Start()
public Task Start() => Task.Run(async () =>
{
_cancellationTokenSource = new CancellationTokenSource();
try
{
StatusBar.StatusText = "Processing…";
StatusBar.Percent = 0;
TransformMode = true;
StatusBar.Percent = 0;
TransformMode = true;
var files = FileList.Files.ToList();
var jobs = new List<SingleTask>();
@ -84,11 +84,11 @@ public partial class MainViewModel : ViewModelBase
finally
{
StatusBar.StatusText = "Ready…";
StatusBar.Percent = 0;
TransformMode = false;
StatusBar.Percent = 0;
TransformMode = false;
_cancellationTokenSource?.Dispose();
}
}
});
}

View File

@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -26,7 +27,7 @@ public partial class ProgressViewModel : ObservableObject
_mainModel = mainModel;
}
public void ClearProgress(string name, int progressLine)
public void ClearProgress(string name, int progressLine) => Dispatch(() =>
{
lock (_lock)
{
@ -36,8 +37,9 @@ public partial class ProgressViewModel : ObservableObject
NumberOfProcesses -= 1;
Processes[progressLine] = new ProgressInfo("", progressLine, 0, TimeSpan.Zero, 0);
}
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
});
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) => Dispatch(() =>
{
lock (_lock)
{
@ -53,6 +55,18 @@ public partial class ProgressViewModel : ObservableObject
NumberOfProcesses += 1;
Processes[progressLine] = new ProgressInfo(name, progressLine, progress, eta, speed);
}
});
private void Dispatch(Action action)
{
if (Dispatcher.UIThread.CheckAccess())
{
action();
}
else
{
Dispatcher.UIThread.Post(() => action());
}
}
}

View File

@ -12,7 +12,7 @@ x:DataType="vm:InspectorPaneViewModel">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,10,0,0">
Margin="0,0,10,0">
<Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"/>
@ -103,7 +103,7 @@ x:DataType="vm:InspectorPaneViewModel">
<!-- ScoreThreshold -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Score Threshold" Width="120"/>
<TextBlock Text="Score threshold" Width="120"/>
<StackPanel Orientation="Vertical" Spacing="4" Width="260">
<Slider Minimum="0"
@ -119,6 +119,25 @@ x:DataType="vm:InspectorPaneViewModel">
HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
<!-- ScoreThreshold -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Identity matching threshold" Width="120"/>
<StackPanel Orientation="Vertical" Spacing="4" Width="260">
<Slider Minimum="0"
Maximum="1"
SmallChange="0.01"
LargeChange="0.1"
TickFrequency="0.05"
IsSnapToTickEnabled="False"
Value="{Binding Selected.IdentityThreshold, Mode=TwoWay}"/>
<TextBlock Text="{Binding Selected.IdentityThreshold, StringFormat='0.00'}"
FontSize="10"
HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
<!-- DetectAbove -->
<StackPanel Orientation="Horizontal" Spacing="8">

View File

@ -47,6 +47,10 @@
</Grid>
<Grid ColumnDefinitions="*"
IsVisible="{Binding TransformMode}">
<views:ProgressView DataContext="{Binding Progress}"/>
</Grid>
</DockPanel>
</Window>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 572 KiB

View File

@ -140,24 +140,41 @@ All option names are preserved exactly, and descriptions are consolidated for cl
## 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. |
| **--enhance** | Enable video enhancement. Increases output resolution x4 using RealBasicVSR_x4 model. |
| **--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. |
| Parameter | Description |
|----------|-------------|
| --out=&lt;folder&gt; | Output folder for segments. Default: same folder as input video + "Splitter". |
| --file=&lt;path&gt; | Input names or file masks (e.g. "videos/*.mp4"). If not specified, the first non-option argument is used as input. |
| --mask=&lt;pattern&gt; | Output filename pattern. Default: [NAME]_seg[NN].[EXT]. Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders. |
| --duration=&lt;value&gt; | Override target segment duration. Formats: Ns, NmMs, N. Examples: 90s, 2m30s, 45. Default (without --force): max 58s, equalized segment lengths. |
| --force | Use fixed segment duration exactly as given. Last segment may be shorter. Default OFF. |
| --enhance | Enable video enhancement. Output resolution x4 using RealBasicVSR_x4 model. |
| --rotate=&lt;degrees&gt; | Rotate video by 90, 180, or 270 degrees. |
| --rotate-auto | Auto-detect rotation using edge orientation statistics. |
| --estimate | Print calculated segment information and exit. No splitting performed. |
| --crop[=&lt;w:h&gt;] | Crop video to width w and height h with face tracking. Default: 607x1080. |
| --detect=&lt;name&gt; | Object detector: face (UltraFace), body (YoloOnnx, default), none. |
| --detect-above=&lt;0-1&gt; | Report detections only if upper bound starts below this threshold (0.01.0 mapped to 0..Height). |
| --detect-id=&lt;hex&gt; | Hexadecimal ID of face/person to track across segments. Obtained via --debug overlay. |
| --gravitate=&lt;x:y&gt; | Gravitate tracking toward normalized point (0.01.0). Example: 0.2:0.5. |
| --text | Display log in plain text. |
| --single-thread | Run in single-threaded mode. Useful for debugging or constrained systems. |
| --debug | Show debug overlay during face tracking. |
| -p:&lt;name&gt;=&lt;value&gt; | Set custom detector parameter. Example: -p:EmaFactor=0.65. |
Tracking splitter defaults:
DropoutToleranceFrames = 20;
EmaFactor = 0.65;
CameraEasing = 0.03;
LostFreezeFrames = 60;
Rotation detector defaults:
RotationDetectorSampleCount = 5;
RotationDetectorSampleLength = 0.15;
RotationDetectorFrameWidth = 320;
RotationDetectorFrameHeight = 180;
## FFmpeg Passthrough

View File

@ -49,6 +49,10 @@ public class SingleJob
/// </summary>
public float ScoreThreshold { get; set; } = 0.25f;
/// <summary>
/// Identity matching confidence threshold. This is a value between 0.0 and 1.0 that sets the minimum confidence
/// </summary>
public float IdentityThreshold { get; set; } = 0.25f;
/// <summary>
/// Face or human detectors should only report detections if their upper bound starts below this threshold.
/// This is a value between 0.0 and 1.0 mapped to 0..Height.
/// </summary>
@ -144,6 +148,7 @@ public class SingleJob
target.Debug = Debug;
target.Detect = Detect;
target.ScoreThreshold = ScoreThreshold;
target.IdentityThreshold = IdentityThreshold;
target.DetectAbove = DetectAbove;
target.DetectId = DetectId;
target.OverrideTargetDuration = OverrideTargetDuration;

View File

@ -9,17 +9,16 @@ public sealed class IdentityCache
private sealed class Identity
{
public ulong Id;
public float[] Embedding; // EMA
public float[] Embedding = null!; // EMA
public int Samples;
}
private readonly List<Identity> _ids = new();
private ulong _nextId = 1;
private const float Threshold = 0.35f; // good for OSNet
private const float EmaAlpha = 0.2f;
private const float _emaAlpha = 0.2f;
public ulong ResolveId(float[] embedding)
public ulong ResolveId(float[] embedding, float threshold)
{
if (_ids.Count == 0)
return CreateNew(embedding);
@ -37,7 +36,7 @@ public sealed class IdentityCache
}
}
if (bestDist <= Threshold)
if (bestDist <= threshold)
{
UpdateEma(_ids[bestIndex].Embedding, embedding);
_ids[bestIndex].Samples++;
@ -73,6 +72,6 @@ public sealed class IdentityCache
private static void UpdateEma(float[] ema, float[] v)
{
for (int i = 0; i < ema.Length; i++)
ema[i] = ema[i] * (1 - EmaAlpha) + v[i] * EmaAlpha;
ema[i] = ema[i] * (1 - _emaAlpha) + v[i] * _emaAlpha;
}
}

View File

@ -26,7 +26,7 @@ public class ObjectTracker(IObjectDetector _detector, IEmbeddingExtractor _embed
rect.Height = Math.Clamp(rect.Height, 1, frameMat.Height - rect.Y);
var embedding = _embeddingExtractor.Extract(frameMat, rect).ToArray(); // make a copy of the embedding array
p.Id = _identityCache.ResolveId(embedding);
p.Id = _identityCache.ResolveId(embedding, job.Job.IdentityThreshold);
objects[i] = p;
}