Compare commits

...

2 Commits

12 changed files with 114 additions and 43 deletions

View File

@ -3,7 +3,7 @@ name: Build and Publish
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
permissions: permissions:
contents: write contents: write
@ -22,19 +22,19 @@ jobs:
dotnet-version: 10.0.x dotnet-version: 10.0.x
- name: Restore - 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 id: version
uses: battila7/get-version-action@v2 uses: battila7/get-version-action@v2
- name: Publish Release - 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 - name: Create ZIP
shell: pwsh shell: pwsh
run: | 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 }}" $version = "${{ steps.version.outputs.version-without-v }}"
$zip = "splitter-win-x64-$version.zip" $zip = "splitter-win-x64-$version.zip"
@ -51,5 +51,3 @@ jobs:
files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -26,7 +27,7 @@ public partial class ProgressViewModel : ObservableObject
_mainModel = mainModel; _mainModel = mainModel;
} }
public void ClearProgress(string name, int progressLine) public void ClearProgress(string name, int progressLine) => Dispatch(() =>
{ {
lock (_lock) lock (_lock)
{ {
@ -36,8 +37,9 @@ public partial class ProgressViewModel : ObservableObject
NumberOfProcesses -= 1; NumberOfProcesses -= 1;
Processes[progressLine] = new ProgressInfo("", progressLine, 0, TimeSpan.Zero, 0); 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) lock (_lock)
{ {
@ -53,6 +55,18 @@ public partial class ProgressViewModel : ObservableObject
NumberOfProcesses += 1; NumberOfProcesses += 1;
Processes[progressLine] = new ProgressInfo(name, progressLine, progress, eta, speed); 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" <StackPanel Orientation="Horizontal"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Spacing="8" Spacing="8"
Margin="0,10,0,0"> Margin="0,0,10,0">
<Button Content="Apply to Selected" <Button Content="Apply to Selected"
Command="{Binding ApplyOverridesCommand}"/> Command="{Binding ApplyOverridesCommand}"/>
@ -103,7 +103,7 @@ x:DataType="vm:InspectorPaneViewModel">
<!-- ScoreThreshold --> <!-- ScoreThreshold -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Score Threshold" Width="120"/> <TextBlock Text="Score threshold" Width="120"/>
<StackPanel Orientation="Vertical" Spacing="4" Width="260"> <StackPanel Orientation="Vertical" Spacing="4" Width="260">
<Slider Minimum="0" <Slider Minimum="0"
@ -119,6 +119,25 @@ x:DataType="vm:InspectorPaneViewModel">
HorizontalAlignment="Right"/> HorizontalAlignment="Right"/>
</StackPanel> </StackPanel>
</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 --> <!-- DetectAbove -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">

View File

@ -47,6 +47,10 @@
</Grid> </Grid>
<Grid ColumnDefinitions="*"
IsVisible="{Binding TransformMode}">
<views:ProgressView DataContext="{Binding Progress}"/>
</Grid>
</DockPanel> </DockPanel>
</Window> </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 ## Options
| Option | Description | | Parameter | Description |
|--------|-------------| |----------|-------------|
| **--out=<folder>** | Output folder for generated segments. Default: `<input folder>/Splitter`. | | --out=&lt;folder&gt; | Output folder for segments. Default: same folder as input video + "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`. | | --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=<pattern>** | Custom output filename pattern. Default: `[NAME]_seg[NN].[EXT]`. Supports `[NAME]`, `[N]`, `[NN]`, `[NNN]`, `[NNNN]`, `[EXT]`. Example: `--mask="[NAME]_[NNNN].mp4"`. | | --mask=&lt;pattern&gt; | Output filename pattern. Default: [NAME]_seg[NN].[EXT]. Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders. |
| **--duration=<value>** | Override target segment duration. Formats: `Ns`, `NmMs`, `N`. Examples: `--duration=90s`, `--duration=2m30s`, `--duration=45`. Without `--force`: max 58 seconds, equalized across segments. | | --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 the duration exactly as provided. Last segment may be shorter. | | --force | Use fixed segment duration exactly as given. Last segment may be shorter. Default OFF. |
| **--enhance** | Enable video enhancement. Increases output resolution x4 using RealBasicVSR_x4 model. | | --enhance | Enable video enhancement. Output resolution x4 using RealBasicVSR_x4 model. |
| **--rotate=<degrees>** | Rotate video by 90, 180, or 270 degrees. Useful for correcting orientation metadata. | | --rotate=&lt;degrees&gt; | Rotate video by 90, 180, or 270 degrees. |
| **--rotate-auto** | Use automatic rotation detection. | | --rotate-auto | Auto-detect rotation using edge orientation statistics. |
| **--estimate** | Print calculated segment information and exit. No splitting is performed. | | --estimate | Print calculated segment information and exit. No splitting performed. |
| **--crop[=<w:h>]** | Crop video to a target width and height with face/body tracking. Default: 607x1080. Ideal for Shorts, TikTok, Reels. | | --crop[=&lt;w:h&gt;] | Crop video to width w and height h with face tracking. Default: 607x1080. |
| **--detect=<name>** | Object detector for tracking. Values: `face` (UltraFace), `body` (YoloOnnx, default), `none` (center crop). | | --detect=&lt;name&gt; | Object detector: face (UltraFace), body (YoloOnnx, default), none. |
| **--gravitate=<x:y>** | Bias the crop window toward a normalized point in the frame. Example: `--gravitate=0.2:0.5`. | | --detect-above=&lt;0-1&gt; | Report detections only if upper bound starts below this threshold (0.01.0 mapped to 0..Height). |
| **--text** | Use plain-text logging instead of the rich terminal UI. | | --detect-id=&lt;hex&gt; | Hexadecimal ID of face/person to track across segments. Obtained via --debug overlay. |
| **--single-thread** | Disable parallel FFmpeg execution. Useful for debugging or low-resource systems. | | --gravitate=&lt;x:y&gt; | Gravitate tracking toward normalized point (0.01.0). Example: 0.2:0.5. |
| **--debug** | Show debug overlay during tracking. No cropping performed, but crop region shown. | | --text | Display log in plain text. |
| **-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. | | --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 ## FFmpeg Passthrough

View File

@ -49,6 +49,10 @@ public class SingleJob
/// </summary> /// </summary>
public float ScoreThreshold { get; set; } = 0.25f; public float ScoreThreshold { get; set; } = 0.25f;
/// <summary> /// <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. /// 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. /// This is a value between 0.0 and 1.0 mapped to 0..Height.
/// </summary> /// </summary>
@ -144,6 +148,7 @@ public class SingleJob
target.Debug = Debug; target.Debug = Debug;
target.Detect = Detect; target.Detect = Detect;
target.ScoreThreshold = ScoreThreshold; target.ScoreThreshold = ScoreThreshold;
target.IdentityThreshold = IdentityThreshold;
target.DetectAbove = DetectAbove; target.DetectAbove = DetectAbove;
target.DetectId = DetectId; target.DetectId = DetectId;
target.OverrideTargetDuration = OverrideTargetDuration; target.OverrideTargetDuration = OverrideTargetDuration;

View File

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