Compare commits

..

No commits in common. "93de483bc6c411e6fb446aea87fb00716716cab5" and "1fd938416e7417d19eaeb3a04bab0a818a0370c0" have entirely different histories.

20 changed files with 217 additions and 461 deletions

View File

@ -2,11 +2,9 @@ name: Build and Publish
on: on:
push: push:
tags: branches: [ "main" ]
- 'v*' pull_request:
branches: [ "main" ]
permissions:
contents: write
jobs: jobs:
build: build:
@ -22,34 +20,22 @@ jobs:
dotnet-version: 10.0.x dotnet-version: 10.0.x
- name: Restore - name: Restore
run: dotnet restore -r win-x64 run: dotnet restore
- name: 'Get Version' - name: Build Release
id: version env:
uses: battila7/get-version-action@v2 GITHUB_RUN_NUMBER: ${{ github.run_number }}
GITHUB_SHA: ${{ github.sha }}
run: dotnet build -c Release --no-restore
- 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 }}
- name: Create ZIP
shell: pwsh
run: |
$publish = "splitter-cli/bin/Release/net10.0/win-x64/publish"
$version = "${{ steps.version.outputs.version-without-v }}"
$zip = "splitter-win-x64-$version.zip"
if (Test-Path $zip) { Remove-Item $zip }
Compress-Archive -Path "$publish/*" -DestinationPath $zip
Write-Host "Created $zip"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: "Release ${{ github.ref_name }}"
files: splitter-win-x64-${{ steps.version.outputs.version-without-v }}.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_RUN_NUMBER: ${{ github.run_number }}
GITHUB_SHA: ${{ github.sha }}
run: dotnet publish -c Release -r win-x64 --self-contained true --no-build
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: splitter-win-x64
path: bin/Release/net10.0/win-x64/publish/

124
README.md
View File

@ -1,25 +1,25 @@
# Splitter # Splitter
Splitter is a highperformance command line tool for cutting one or more video files into equal or fixedlength segments using multithreaded FFmpeg execution. 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. It supports batch input, flexible duration formats, rotation, smart face/bodyaware cropping, ETA and speed reporting, and both rich and plaintext terminal output.
![Splitter](splitter.png) ![Splitter](splitter.png)
## Features ## Features
- Multithreaded FFmpeg splitting for maximum throughput - Multithreaded FFmpeg splitting for maximum throughput
- Equal or fixedlength segmentation - Equal or fixedlength segmentation
- Batch input via file masks or list files - Batch input via file masks or list files
- Smart cropping with face/body tracking - Smart cropping with face/body tracking
- Rotation correction - Rotation correction
- ETA, speed, and progress display - ETA, speed, and progress display
- FFmpeg passthrough for advanced control - FFmpeg passthrough for advanced control
- [Potentially] Crossplatform (.NET 10) - [Potentially] Crossplatform (.NET 10)
## Requirements ## Requirements
- FFmpeg and FFprobe available in system PATH - FFmpeg and FFprobe available in system PATH
- .NET 10 Runtime or newer - .NET 10 Runtime or newer
If you want to update model: If you want to update model:
@ -37,90 +37,74 @@ model.export(format="onnx", opset=12, half=False) # FP32 ONNX
## How It Works ## How It Works
1. Reads total duration using ffprobe 1. Reads total duration using ffprobe
2. Parses target duration 2. Parses target duration
3. Computes number of segments 3. Computes number of segments
4. If not forced, equalizes segment lengths 4. If not forced, equalizes segment lengths
5. Runs multiple FFmpeg processes in parallel 5. Runs multiple FFmpeg processes in parallel
6. Applies rotation, crop, and tracking if enabled 6. Applies rotation, crop, and tracking if enabled
7. Displays progress, ETA, and speed 7. Displays progress, ETA, and speed
---
## Face Tracking vs Body Tracking ## Face Tracking vs Body Tracking
Face tracking and body tracking serve different purposes, and Splitter supports both because each 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, 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. the choice of detector determines how stable, reliable, and natural the automated camera motion will be.
![Face vs Body Tracking](tracking.png) ![Face vs Body Tracking](tracking.png)
### Face Tracking Using UltraFace 320 ### Face Tracking Using UltraFace 320
Splitter uses the UltraFace 320 ONNX model to perform lightweight, realtime face detection on each 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 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 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 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, continuous, stable crop window that follows the performer even when the face is partially occluded,
briefly lost, or moving rapidly. briefly lost, or moving rapidly.
During segmentation, the crop window is recalculated for every frame, ensuring that each output 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 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 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, fast enough to run alongside multithreaded FFmpeg splitting without becoming a bottleneck,
making it suitable for long recordings and batch processing. making it suitable for long recordings and batch processing.
### Benefits of FullBody Detection Using YOLOv8s for Live Gig Recordings ### Benefits of FullBody Detection Using YOLOv8s for Live Gig Recordings
When recording concerts or live gigs, performers often move unpredictably, turn away from the 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. 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 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 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 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 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 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 moments. For creators converting horizontal gig footage into short vertical clips for YouTube
Shorts or TikTok, bodybased tracking significantly improves consistency, reduces manual editing, Shorts or TikTok, bodybased tracking significantly improves consistency, reduces manual editing,
and preserves the energy and motion of the performance. and preserves the energy and motion of the performance.
### Automated Camera Control ### Automated Camera Control
Splitter includes an automated camera control system that simulates the behavior of a virtual 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 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, smooth, intentional framing around the tracked subject, even when detections are noisy, intermittent,
or temporarily lost. or temporarily lost.
The controller receives object detections (face or body) and converts them into a stable crop 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, window using a combination of Kalman filtering, exponential smoothing, dropout tolerance,
and a threestate tracking model. The Kalman filter provides predictive motion smoothing, 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. 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 The camera easing value controls how quickly the virtual camera follows the subject, producing
naturallooking motion rather than abrupt jumps. naturallooking motion rather than abrupt jumps.
When detections disappear, the controller enters one of two fallback modes. In LostFreeze mode, 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 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 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. 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 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 when tracking fails. All positions are clamped to valid bounds, guaranteeing that the crop window
never leaves the video frame. 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 ## Usage
@ -130,10 +114,11 @@ splitter [<input.mp4> ...] [options] [--] <ffmpeg passthrough>
Inputs may be provided directly, via `--file=...`, or using file masks such as `videos/*.mp4`. Inputs may be provided directly, via `--file=...`, or using file masks such as `videos/*.mp4`.
---
## Options ## Options
Below is a clean, ASCIIonly **options table** version of your content. Below is a clean, ASCIIonly **options table** version of your content.
All option names are preserved exactly, and descriptions are consolidated for clarity. All option names are preserved exactly, and descriptions are consolidated for clarity.
--- ---
@ -148,7 +133,6 @@ All option names are preserved exactly, and descriptions are consolidated for cl
| **--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=<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. | | **--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=<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. | | **--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. | | **--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). | | **--detect=<name>** | Object detector for tracking. Values: `face` (UltraFace), `body` (YoloOnnx, default), `none` (center crop). |
@ -158,6 +142,8 @@ All option names are preserved exactly, and descriptions are consolidated for cl
| **--debug** | Show debug overlay during tracking. No cropping performed, but crop region shown. | | **--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. | | **-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 ## FFmpeg Passthrough
Anything after `--` is passed directly to FFmpeg. Anything after `--` is passed directly to FFmpeg.
@ -167,12 +153,16 @@ Example:
splitter video.mp4 --force --duration=45 -- -an -sn splitter video.mp4 --force --duration=45 -- -an -sn
``` ```
---
## Input and Output Behavior ## Input and Output Behavior
- `input.mp4` may be a file mask (`videos/*.mp4`) - `input.mp4` may be a file mask (`videos/*.mp4`)
- Output filenames follow the `--mask` pattern - Output filenames follow the `--mask` pattern
- Output folder defaults to `<input folder>/Splitter` unless overridden - Output folder defaults to `<input folder>/Splitter` unless overridden
---
## Examples ## Examples
Split into equal 60second segments: Split into equal 60second segments:

View File

@ -1,12 +1,8 @@
using System.Reflection; namespace splitter;
namespace splitter;
public static class BuildInfo public static class BuildInfo
{ {
private static readonly Assembly Assembly = typeof(BuildInfo).Assembly; public static string Version { get; } = ThisAssembly.Version;
public static string BuildNumber { get; } = ThisAssembly.BuildNumber;
public static string Version => Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown"; public static string Commit { get; } = ThisAssembly.Commit;
public static string FileVersion => Assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "unknown";
public static string AssemblyVersion => Assembly.GetName().Version?.ToString() ?? "unknown";
} }

View File

@ -0,0 +1,9 @@
// Auto-generated. Do not edit.
namespace splitter;
internal static class ThisAssembly
{
public const string Version = "@VERSION@";
public const string BuildNumber = "@BUILDNUMBER@";
public const string Commit = "@COMMIT@";
}

View File

@ -1,4 +1,5 @@
using OpenCvSharp; using System;
using OpenCvSharp;
namespace splitter; namespace splitter;

View File

@ -1,4 +1,8 @@
using System.Globalization; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using OpenCvSharp;
namespace splitter; namespace splitter;
@ -18,7 +22,6 @@ public class SingleJob
public bool ForceFixed { get; set; } public bool ForceFixed { get; set; }
public bool SingleThreaded { get; set; } public bool SingleThreaded { get; set; }
public int? Rotate { get; set; } public int? Rotate { get; set; }
public bool RotateAuto { get; set; }
public Dictionary<string, string> Parameters { get; set; } = []; public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name) public void Override<T>(ref T member, string name)
@ -140,10 +143,6 @@ public sealed class CommandLine
{ {
Master.SingleThreaded = true; Master.SingleThreaded = true;
} }
else if (arg == "--rotate-auto")
{
Master.RotateAuto = true;
}
else if (arg.StartsWith("--gravitate=")) else if (arg.StartsWith("--gravitate="))
{ {
var val = arg.Substring("--gravitate=".Length); var val = arg.Substring("--gravitate=".Length);
@ -198,7 +197,6 @@ public sealed class CommandLine
ForceFixed = Master.ForceFixed, ForceFixed = Master.ForceFixed,
SingleThreaded = Master.SingleThreaded, SingleThreaded = Master.SingleThreaded,
Rotate = Master.Rotate, Rotate = Master.Rotate,
RotateAuto = Master.RotateAuto,
Parameters = new Dictionary<string, string>(Master.Parameters) Parameters = new Dictionary<string, string>(Master.Parameters)
}).ToArray(); }).ToArray();
@ -330,7 +328,7 @@ public sealed class CommandLine
public static void PrintVersion() public static void PrintVersion()
{ {
Console.WriteLine($"...---=== splitter version {BuildInfo.Version} (file version: {BuildInfo.FileVersion}, build {BuildInfo.AssemblyVersion}) ===---..."); Console.WriteLine($"...---=== splitter version {BuildInfo.Version} (commit {BuildInfo.Commit}, build {BuildInfo.BuildNumber}) ===---...");
} }
public static void PrintHelp() public static void PrintHelp()
@ -373,9 +371,6 @@ Options:
--rotate=<degrees> Rotate video by specified degrees (90, 180, 270). --rotate=<degrees> Rotate video by specified degrees (90, 180, 270).
Useful for videos with incorrect orientation metadata. Useful for videos with incorrect orientation metadata.
--rotate-auto Auto-detect rotation and rotate accordingly.
Uses edge orientation statistics to determine if video is rotated.
--estimate Print calculated segment information and exit. --estimate Print calculated segment information and exit.
No splitting is performed. No splitting is performed.
@ -398,7 +393,7 @@ Options:
--debug Show debug overlay during face tracking. --debug Show debug overlay during face tracking.
-p:<name>=<value> Set a custom parameter for the object detector. -p:<name>=<value> Set a custom parameter for the object detector.
Example: -p:EmaFactor=0.65 Example: -p:confidence=0.5
Tracking splitter defaults: Tracking splitter defaults:
DropoutToleranceFrames = 20; DropoutToleranceFrames = 20;
@ -406,12 +401,6 @@ Options:
CameraEasing = 0.03; CameraEasing = 0.03;
LostFreezeFrames = 60; LostFreezeFrames = 60;
Rotation detector defaults:
RotationDetectorSampleCount = 5;
RotationDetectorSampleLength = 0.15;
RotationDetectorFrameWidth = 320;
RotationDetectorFrameHeight = 180;
Passthrough: Passthrough:
Anything after -- is passed directly to ffmpeg. Anything after -- is passed directly to ffmpeg.

View File

@ -1,102 +0,0 @@
using OpenCvSharp;
namespace splitter;
public sealed class FrameRotationDetector
{
private readonly Mat _gray;
private readonly Mat _gx;
private readonly Mat _gy;
private readonly Mat _mag;
private readonly Mat _angle;
private readonly float[] _hist;
private readonly int _w;
private readonly int _h;
private readonly int _bins;
public FrameRotationDetector(int width = 320, int height = 180, int bins = 36)
{
_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);
_angle = new Mat(height, width, MatType.CV_32F);
_hist = new float[bins]; // allocated once
}
public int GetRotation(Mat frame)
{
// 1. Grayscale
Cv2.CvtColor(frame, _gray, ColorConversionCodes.BGR2GRAY);
// 2. Sobel
Cv2.Sobel(_gray, _gx, MatType.CV_32F, 1, 0, 3);
Cv2.Sobel(_gray, _gy, MatType.CV_32F, 0, 1, 3);
// 3. Magnitude + angle
Cv2.CartToPolar(_gx, _gy, _mag, _angle, angleInDegrees: true);
// 4. Clear histogram
for (int i = 0; i < _bins; i++)
_hist[i] = 0;
float binSize = 180f / _bins;
unsafe
{
float* anglePtr = (float*)_angle.Data;
float* magPtr = (float*)_mag.Data;
int total = _w * _h;
for (int i = 0; i < total; i++)
{
float m = magPtr[i];
if (m < 5f) continue; // ignore weak gradients
float a = anglePtr[i];
if (a < 0) a += 360f;
a = a % 180f;
int bin = (int)(a / binSize);
if (bin < 0) bin = 0;
if (bin >= _bins) bin = _bins - 1;
_hist[bin] += m;
}
}
// 5. Energy around 0° vs 90°
float e0 = 0, e90 = 0;
int window = 3;
int bin0 = 0;
int bin90 = _bins / 2;
for (int i = -window; i <= window; i++)
{
e0 += _hist[Wrap(bin0 + i)];
e90 += _hist[Wrap(bin90 + i)];
}
// 6. Decide upright vs sideways
if (e90 > e0 * 1.6f)
return 90; // sideways
return 0; // upright (concert default)
}
private int Wrap(int b)
{
if (b < 0) return b + _bins;
if (b >= _bins) return b - _bins;
return b;
}
}

View File

@ -4,5 +4,5 @@ namespace splitter;
public interface IObjectDetector : IDisposable public interface IObjectDetector : IDisposable
{ {
List<(Rect box, Point2f center)> DetectAll(Mat frameCont); List<(Rect box, Point2f center)> DetectAll(Mat frameCont, int width, int height);
} }

View File

@ -1,4 +1,8 @@
namespace splitter; using System;
using System.Collections.Generic;
using System.Text;
namespace splitter;
public interface ISegmentProcessor public interface ISegmentProcessor
{ {

View File

@ -1,4 +1,5 @@
namespace splitter; using System;
namespace splitter;
public abstract class LoggingBase(ILogger _logger, int _progressLine) public abstract class LoggingBase(ILogger _logger, int _progressLine)
{ {

View File

@ -1,5 +1,8 @@
using System.Diagnostics; using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text;
namespace splitter; namespace splitter;
@ -8,31 +11,12 @@ public record VideoInfo(
int Width, int Width,
int Height, int Height,
double Fps, double Fps,
double Bitrate, double Bitrate
int Rotation = 0
); );
public static class ProbeVideo public static class ProbeVideo
{ {
public static async Task<VideoInfo> Probe(SingleJob job) public static VideoInfo Probe(string inputFile)
{
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 = var args =
"-v error " + "-v error " +

View File

@ -10,7 +10,7 @@
}, },
"Debug": { "Debug": {
"commandName": "Project", "commandName": "Project",
"commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Laura Cox\\Video\\MAH00041.MP4\" --rotate-auto" "commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\20260426_212004.mp4\" --crop --detect=body --debug --single-thread --text"
} }
} }
} }

View File

@ -1,5 +1,9 @@
using System.Diagnostics; using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text;
using FFmpeg.AutoGen;
namespace splitter; namespace splitter;

View File

@ -1,4 +1,9 @@
using System.Text; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Spectre.Console; using Spectre.Console;
using Spectre.Console.Rendering; using Spectre.Console.Rendering;
@ -173,7 +178,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
var layout = new Layout("root") var layout = new Layout("root")
.SplitRows( .SplitRows(
new Layout("progress") { Size = Math.Max(3, numberOfProcessesSnapshot + 4) }, new Layout("progress") { Size = Math.Max(3, numberOfProcessesSnapshot + 2) },
new Layout("log") new Layout("log")
//new Layout("buttons") { Size = 3 } //new Layout("buttons") { Size = 3 }
); );
@ -228,8 +233,8 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
foreach (var log in slice) foreach (var log in slice)
{ {
var time = log.Timestamp.ToString("HH:mm:ss"); var time = log.Timestamp.ToString("HH:mm:ss");
var timeColor = "deepskyblue1"; var timeColor = "deepskyblue1"; // dark-ish blue
var prefixColor = "lightpink1"; var prefixColor = "lightpink1"; // light magenta
var msgColor = MapConsoleColor(log.Color); var msgColor = MapConsoleColor(log.Color);
var line = var line =
@ -240,10 +245,38 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
rows.Add(new Markup(line)); rows.Add(new Markup(line));
} }
if (rows.Count == 0) IRenderable content =
return new Markup("[grey]No log messages yet.[/]"); rows.Count == 0
? new Markup("[grey]No log messages yet.[/]")
: new Rows(rows);
return new Rows(rows); var panel = new Panel(content)
{
Header = new PanelHeader("Log", Justify.Left),
Border = BoxBorder.Rounded,
Expand = true
};
return panel;
}
private static IRenderable BuildButtonsPanel()
{
// Visual [ Cancel ] button; key handling is in RunInputLoopAsync
var text = new Markup("[bold white on red] Cancel [/]");
var grid = new Grid();
grid.AddColumn(new GridColumn().Centered());
grid.AddRow(text);
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("Actions", Justify.Left),
Expand = true
};
return panel;
} }
// ---- Helpers ---- // ---- Helpers ----
@ -254,23 +287,23 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable
private static string MapConsoleColor(ConsoleColor color) => private static string MapConsoleColor(ConsoleColor color) =>
color switch color switch
{ {
ConsoleColor.Black => "black", ConsoleColor.Black => "black",
ConsoleColor.DarkBlue => "navy", ConsoleColor.DarkBlue => "navy",
ConsoleColor.DarkGreen => "green", ConsoleColor.DarkGreen => "green",
ConsoleColor.DarkCyan => "teal", ConsoleColor.DarkCyan => "teal",
ConsoleColor.DarkRed => "maroon", ConsoleColor.DarkRed => "maroon",
ConsoleColor.DarkMagenta => "purple", ConsoleColor.DarkMagenta => "purple",
ConsoleColor.DarkYellow => "olive", ConsoleColor.DarkYellow => "olive",
ConsoleColor.Gray => "silver", ConsoleColor.Gray => "silver",
ConsoleColor.DarkGray => "grey", ConsoleColor.DarkGray => "grey",
ConsoleColor.Blue => "blue", ConsoleColor.Blue => "blue",
ConsoleColor.Green => "lime", ConsoleColor.Green => "lime",
ConsoleColor.Cyan => "aqua", ConsoleColor.Cyan => "aqua",
ConsoleColor.Red => "red", ConsoleColor.Red => "red",
ConsoleColor.Magenta => "fuchsia", ConsoleColor.Magenta => "fuchsia",
ConsoleColor.Yellow => "yellow", ConsoleColor.Yellow => "yellow",
ConsoleColor.White => "white", ConsoleColor.White => "white",
_ => "white" _ => "white"
}; };
/// <summary> /// <summary>

View File

@ -1,6 +1,9 @@
using System.Diagnostics; using System;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks;
using OpenCvSharp; using OpenCvSharp;
namespace splitter; namespace splitter;
@ -109,7 +112,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
// input frame → Mat // input frame → Mat
Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes); Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes);
var objects = _detector.DetectAll(frameMat); var objects = _detector.DetectAll(frameMat, videoWidth, videoHeight);
var primary = SelectTrackedObject(objects, kalman.LastMeasurement); var primary = SelectTrackedObject(objects, kalman.LastMeasurement);
camera.Update(primary); camera.Update(primary);

View File

@ -1,4 +1,5 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using NcnnDotNet.Layers;
using OpenCvSharp; using OpenCvSharp;
using UltraFaceDotNet; using UltraFaceDotNet;
@ -24,7 +25,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
_ultraFace = UltraFace.Create(param); _ultraFace = UltraFace.Create(param);
} }
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont) public List<(Rect box, Point2f center)> DetectAll(Mat frameCont, int width, int height)
{ {
// Convert to byte[] for UltraFace // Convert to byte[] for UltraFace
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize(); var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
@ -43,8 +44,8 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
using var mat = NcnnDotNet.Mat.FromPixels( using var mat = NcnnDotNet.Mat.FromPixels(
(IntPtr)p, (IntPtr)p,
NcnnDotNet.PixelType.Bgr, // BGR24 input NcnnDotNet.PixelType.Bgr, // BGR24 input
frameCont.Width, width,
frameCont.Height); height);
var faces = _ultraFace.Detect(mat); var faces = _ultraFace.Detect(mat);
if (faces == null) if (faces == null)

View File

@ -1,169 +0,0 @@
using OpenCvSharp;
using System.Diagnostics;
namespace splitter;
public sealed class VideoRotationSampler
{
private readonly FrameRotationDetector _detector = new FrameRotationDetector();
public static int RotationDetectorSampleCount = 20;
public static double RotationDetectorSampleLength = 0.15; // seconds to decode per probe
public static int RotationDetectorFrameWidth = 320;
public static int RotationDetectorFrameHeight = 180;
// --- Zero-allocation buffers ---
private readonly byte[] _buffer;
private readonly Mat _frameMat;
public VideoRotationSampler(SingleJob _master)
{
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);
int w = RotationDetectorFrameWidth;
int h = RotationDetectorFrameHeight;
_buffer = new byte[w * h * 3]; // raw BGR24 buffer
_frameMat = new Mat(h, w, MatType.CV_8UC3); // wraps buffer
}
public async Task<int> DetectRotationAsync(
string inputFile,
double videoLengthSeconds)
{
if (videoLengthSeconds <= 0)
return 0;
var rotations = new List<int>();
for (int i = 0; i < RotationDetectorSampleCount; i++)
{
double t = videoLengthSeconds * (i + 1) / (RotationDetectorSampleCount + 1);
var frame = await DecodeSingleFrameAsync(
inputFile,
t,
RotationDetectorSampleLength,
RotationDetectorFrameWidth,
RotationDetectorFrameHeight);
if (frame != null && !frame.Empty())
{
int rot = _detector.GetRotation(frame);
rotations.Add(rot);
}
}
if (rotations.Count == 0)
return 0;
return Majority(rotations);
}
private static int Majority(List<int> values)
{
var counts = new Dictionary<int, int>();
foreach (var v in values)
{
if (!counts.ContainsKey(v)) counts[v] = 0;
counts[v]++;
}
int best = 0;
int bestCount = 0;
foreach (var kv in counts)
{
if (kv.Value > bestCount)
{
best = kv.Key;
bestCount = kv.Value;
}
}
return best;
}
private async Task<Mat?> DecodeSingleFrameAsync(
string inputFile,
double start,
double length,
int width,
int height)
{
var p = StartFfmpegDecode(inputFile, start, length, rotate: null, plainText: false);
int needed = _buffer.Length;
int read = 0;
using var stdout = p.StandardOutput.BaseStream;
while (read < needed)
{
int r = await stdout.ReadAsync(_buffer, read, needed - read);
if (r == 0)
return null;
read += r;
}
try { p.Kill(); } catch { }
// Copy buffer → Mat (no new Mat)
System.Runtime.InteropServices.Marshal.Copy(_buffer, 0, _frameMat.Data, _buffer.Length);
return _frameMat;
}
private Process StartFfmpegDecode(
string inputFile,
double start,
double length,
int? rotate,
bool plainText)
{
var ss = start.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
var t = length.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
// FFmpeg does the resize + format conversion
var args =
$"-ss {ss} -t {t} -i \"{inputFile}\" " +
"-an -sn " +
$"-vf scale={RotationDetectorFrameWidth}:{RotationDetectorFrameHeight},format=bgr24 " +
"-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();
// Optional stderr logging
_ = Task.Run(() =>
{
try
{
string? line;
while ((line = p.StandardError.ReadLine()) != null)
if (plainText)
Console.WriteLine($"[ffmpeg-decode] {line}");
}
catch { }
});
return p;
}
}

View File

@ -1,4 +1,8 @@
using System.Runtime.CompilerServices; using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors; using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp; using OpenCvSharp;
@ -79,7 +83,7 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
_inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor)); _inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor));
} }
public List<(Rect box, Point2f center)> DetectAll(Mat frameCont) public List<(Rect box, Point2f center)> DetectAll(Mat frameCont, int width, int height)
{ {
if (frameCont.Empty()) if (frameCont.Empty())
{ {

View File

@ -1,5 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text;
using Spectre.Console; using Spectre.Console;
using splitter; using splitter;
@ -76,7 +78,7 @@ static partial class Program
if (!Directory.Exists(job.OutputFolder)) if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder); Directory.CreateDirectory(job.OutputFolder);
var info = await ProbeVideo.Probe(job); var info = ProbeVideo.Probe(job.InputFile);
if (info.Duration <= 0) if (info.Duration <= 0)
{ {
LogError($"{baseName}: Could not read duration."); LogError($"{baseName}: Could not read duration.");

View File

@ -27,18 +27,40 @@
<EnableAVX2>true</EnableAVX2> <EnableAVX2>true</EnableAVX2>
<DebugType>none</DebugType> <DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishReadyToRun>true</PublishReadyToRun> <PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<Version>1.0.0</Version> <Version>1.0.0</Version>
<InformationalVersion>$(Version).$(BuildNumber)+$(SourceRevisionId)</InformationalVersion> <SourceRevisionId>$(GITHUB_SHA)</SourceRevisionId>
<AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version).$(BuildNumber)</FileVersion>
</PropertyGroup> </PropertyGroup>
<Target Name="RemoveUnwantedFiles" AfterTargets="Publish" Condition="'$(Configuration)' == 'Release'"> <PropertyGroup>
<BuildNumber>$(GITHUB_RUN_NUMBER)</BuildNumber>
</PropertyGroup>
<Target Name="GenerateBuildInfo" BeforeTargets="BeforeCompile">
<ReadLinesFromFile File="BuildInfo.template">
<Output TaskParameter="Lines" ItemName="BuildInfoLines" />
</ReadLinesFromFile>
<ItemGroup>
<ProcessedBuildInfoLines Include="@(BuildInfoLines->Replace('@VERSION@', '$(Version)')->Replace('@BUILDNUMBER@', '$(BuildNumber)')->Replace('@COMMIT@', '$(SourceRevisionId)'))" />
</ItemGroup>
<WriteLinesToFile
File="BuildInfo.g.cs"
Overwrite="true"
Lines="@(ProcessedBuildInfoLines)" />
</Target>
<Target Name="RemoveUnwantedFiles" AfterTargets="Publish" Condition="'$(Configuration)' == 'Release'">
<ItemGroup> <ItemGroup>
<FilesToDelete Include="$(PublishDir)**\*.pdb" /> <FilesToDelete Include="$(PublishDir)**\*.pdb" />
<FilesToDelete Include="$(PublishDir)**\*.lib" /> <FilesToDelete Include="$(PublishDir)**\*.lib" />
@ -49,8 +71,6 @@
<ItemGroup> <ItemGroup>
<Compile Update="ThisAssembly.g.cs" />
<Content Include="models/*.*"> <Content Include="models/*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>