From cddcd6ff6e0f32c151348994775c54ad36de0296 Mon Sep 17 00:00:00 2001 From: unclshura Date: Sat, 9 May 2026 15:04:11 +0100 Subject: [PATCH] Initial version of face derecting crop functionality added. Should crop horizontal videos for YouTube shorts with face tracking. --- .gitignore | 7 +- FaceKalmanTracker.cs | 101 ++++++++++ FaceTracker.cs | 328 +++++++++++++++++++++++++++++++ Point2f.cs | 13 ++ Properties/launchSettings.json | 8 + README.md | 3 + Rect.cs | 17 ++ UltraFaceDetector.cs | 133 +++++++++++++ splitter.cs | 341 +++++++++++++++++++++++++++------ splitter.csproj | 42 +++- 10 files changed, 931 insertions(+), 62 deletions(-) create mode 100644 FaceKalmanTracker.cs create mode 100644 FaceTracker.cs create mode 100644 Point2f.cs create mode 100644 Properties/launchSettings.json create mode 100644 Rect.cs create mode 100644 UltraFaceDetector.cs diff --git a/.gitignore b/.gitignore index 9491a2f..db32398 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,9 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# OpenCV models +*.onnx +*.bin +*.param diff --git a/FaceKalmanTracker.cs b/FaceKalmanTracker.cs new file mode 100644 index 0000000..be428e3 --- /dev/null +++ b/FaceKalmanTracker.cs @@ -0,0 +1,101 @@ +namespace splitter; + +internal sealed class FaceKalmanTracker +{ + // State vector: [x, y, vx, vy] + private float[] _state = new float[4]; + + // Covariance matrix 4x4 + private float[,] _p = new float[4, 4]; + + // Process noise (constant) + private const float _q = 1e-3f; + + // Measurement noise (dynamic) + private float _r = 1e-1f; + + // Identity matrix + private static readonly float[,] _i = + { + {1,0,0,0}, + {0,1,0,0}, + {0,0,1,0}, + {0,0,0,1} + }; + + public Point2f? LastMeasurement { get; private set; } + + public void Reset(Point2f initial) + { + _state[0] = initial.X; + _state[1] = initial.Y; + _state[2] = 0; + _state[3] = 0; + + // Large initial uncertainty + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) + _p[i, j] = (i == j) ? 1f : 0f; + } + + public void SetMeasurementNoise(float r) + { + _r = r; + } + + public Point2f Update(Point2f? measurement) + { + // --- PREDICTION --- + // State transition: + // x' = x + vx + // y' = y + vy + _state[0] += _state[2]; + _state[1] += _state[3]; + + // Update covariance + AddProcessNoise(); + + if (measurement.HasValue) + { + // --- MEASUREMENT UPDATE --- + var z = measurement.Value; + + // Innovation y = z - Hx + float yx = z.X - _state[0]; + float yy = z.Y - _state[1]; + + // Innovation covariance S = P + R + float Sx = _p[0, 0] + _r; + float Sy = _p[1, 1] + _r; + + // Kalman gain K = P / S + float Kx0 = _p[0, 0] / Sx; + float Kx1 = _p[1, 1] / Sy; + + // Update state + _state[0] += Kx0 * yx; + _state[1] += Kx1 * yy; + + // Velocity correction (helps reduce jitter) + _state[2] += 0.1f * Kx0 * yx; + _state[3] += 0.1f * Kx1 * yy; + + // Update covariance: P = (I - K)P + _p[0, 0] *= (1 - Kx0); + _p[1, 1] *= (1 - Kx1); + } + + LastMeasurement = measurement; + + return new Point2f(_state[0], _state[1]); + } + + private void AddProcessNoise() + { + // Add small noise to diagonal of covariance + _p[0, 0] += _q; + _p[1, 1] += _q; + _p[2, 2] += _q; + _p[3, 3] += _q; + } +} diff --git a/FaceTracker.cs b/FaceTracker.cs new file mode 100644 index 0000000..6adf2d5 --- /dev/null +++ b/FaceTracker.cs @@ -0,0 +1,328 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using OpenCvSharp; +using Cv = OpenCvSharp.Cv2; +using Mat = OpenCvSharp.Mat; +using CvPoint = OpenCvSharp.Point; +using CvRect = OpenCvSharp.Rect; + +namespace splitter; + +public class FaceTracker +{ + public Action DrawProgress { get; init; } = (_, _, _) => { }; + + private static Rect ToCvRect(splitter.Rect r) + => new Rect(r.X, r.Y, r.Width, r.Height); + + public async Task TrackFaceAndExtract( + string srcFileName, + string destFileName, + TimeSpan skip, + TimeSpan duration, + int cropWidth, + int cropHeight, + string[] passthrough, + bool debugOverlay) + { + // ------------------------------ + // 1. OpenCV VideoCapture (stable) + // ------------------------------ + using var capture = new VideoCapture(srcFileName); + if (!capture.IsOpened()) + throw new Exception("Cannot open video"); + + capture.Set(VideoCaptureProperties.PosMsec, skip.TotalMilliseconds); + + var videoWidth = (int)capture.Get(VideoCaptureProperties.FrameWidth); + var videoHeight = (int)capture.Get(VideoCaptureProperties.FrameHeight); + var fps = capture.Get(VideoCaptureProperties.Fps); + var totalFrames = (int)(duration.TotalSeconds * fps); + + Console.WriteLine($"[FaceTracker] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}"); + + // ------------------------------ + // 2. UltraFaceDetector (new model) + // ------------------------------ + using var detector = new UltraFaceDetector( + binPath: "slim_320.bin", + paramPath: "slim_320.param"); + + // ------------------------------ + // 3. FFmpeg one-pass encoder + // ------------------------------ + var ffmpeg = StartFfmpegNvenc( + srcFileName, + destFileName, + cropWidth, + cropHeight, + fps, + skip, + passthrough); + + using var stdin = ffmpeg.StandardInput.BaseStream; + + // ------------------------------ + // 4. Tracking state + // ------------------------------ + var frame = new Mat(); + var kalman = new FaceKalmanTracker(); + kalman.Reset(new Point2f(videoWidth / 2f, videoHeight / 2f)); + + var lostFrames = 0; + var wasLost = false; + var reacquireBoostFrames = 20; + var reacquireCounter = 0; + + var cameraCenter = new Point2f(videoWidth / 2f, videoHeight / 2f); + var startTime = DateTime.UtcNow; + + // ------------------------------ + // 5. Main loop + // ------------------------------ + for (var i = 0; i < totalFrames; i++) + { + if (!capture.Read(frame) || frame.Empty()) + break; + + // Ensure continuous memory for detector + Mat frameCont = frame.IsContinuous() ? frame : frame.Clone(); + + // Convert to byte[] for UltraFace + var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize(); + var bufferFull = new byte[bytesFull]; + Marshal.Copy(frameCont.Data, bufferFull, 0, bytesFull); + + Rect? faceBox = null; + Point2f? faceCenter = null; + + var faces = detector.DetectAll(bufferFull, videoWidth, videoHeight); // list of (box, center) + + var primary = SelectTrackedFace(faces, kalman.LastMeasurement); + + if (primary.HasValue) + { + faceCenter = primary.Value.center; + faceBox = primary.Value.box; + } + + + var isLost = !faceCenter.HasValue; + + // LOST FACE → drift toward center + if (isLost) + { + lostFrames++; + + var fallbackCenter = new Point2f(videoWidth / 2f, videoHeight / 2f); + var predicted = kalman.Update(null); + + var t = Math.Min(1f, lostFrames / 60f); + var ease = 0.02f * t; + + faceCenter = new Point2f( + predicted.X * (1 - ease) + fallbackCenter.X * ease, + predicted.Y * (1 - ease) + fallbackCenter.Y * ease); + } + else + { + if (wasLost) + reacquireCounter = reacquireBoostFrames; + + lostFrames = 0; + } + + // SMOOTH REACQUISITION + if (reacquireCounter > 0) + { + var alpha = reacquireCounter / (float)reacquireBoostFrames; + var noise = 5e-2f + (1e-1f - 5e-2f) * (1 - alpha); + kalman.SetMeasurementNoise(noise); + reacquireCounter--; + } + else + { + kalman.SetMeasurementNoise(1e-1f); + } + + wasLost = isLost; + + var smoothedCenter = kalman.Update(faceCenter); + + var halfW = cropWidth / 2f; + var halfH = cropHeight / 2f; + + smoothedCenter.X = Math.Clamp(smoothedCenter.X, halfW, videoWidth - halfW); + smoothedCenter.Y = Math.Clamp(smoothedCenter.Y, halfH, videoHeight - halfH); + + // CAMERA EASING + var easing = 0.003f; + cameraCenter = new Point2f( + cameraCenter.X + (smoothedCenter.X - cameraCenter.X) * easing, + cameraCenter.Y + (smoothedCenter.Y - cameraCenter.Y) * easing); + + cameraCenter.X = Math.Clamp(cameraCenter.X, halfW, videoWidth - halfW); + cameraCenter.Y = Math.Clamp(cameraCenter.Y, halfH, videoHeight - halfH); + + var x = (int)Math.Round(cameraCenter.X - halfW); + var y = (int)Math.Round(cameraCenter.Y - halfH); + + x = Math.Clamp(x, 0, videoWidth - cropWidth); + y = Math.Clamp(y, 0, videoHeight - cropHeight); + + var roi = new CvRect(x, y, cropWidth, cropHeight); + + if (debugOverlay) + { + if (faceBox.HasValue) + { + var fb = faceBox.Value; + Cv.Rectangle(frameCont, + new OpenCvSharp.Rect(fb.X, fb.Y, fb.Width, fb.Height), + Scalar.LimeGreen, 2); + } + + Cv.Circle(frameCont, + new CvPoint((int)smoothedCenter.X, (int)smoothedCenter.Y), + 6, Scalar.LimeGreen, -1); + + Cv.Rectangle(frameCont, roi, + faceCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3); + } + + // Crop ROI + using var cropped = new Mat(frameCont, roi); + + // Always clone to ensure contiguous memory + using var bgr = cropped.Clone(); + + // Write to FFmpeg + var bytes = bgr.Rows * bgr.Cols * bgr.ElemSize(); + var buffer = new byte[bytes]; + Marshal.Copy(bgr.Data, buffer, 0, bytes); + stdin.Write(buffer, 0, bytes); + + // Dispose frameCont only if it was a clone + if (!ReferenceEquals(frameCont, frame)) + frameCont.Dispose(); + + // Progress + var elapsed = DateTime.UtcNow - startTime; + var progress = (double)i / totalFrames; + var speed = i > 0 ? i / elapsed.TotalSeconds : 0.0; + var remainingFrames = totalFrames - i; + var etaSeconds = speed > 0 ? remainingFrames / speed : 0; + var eta = TimeSpan.FromSeconds(etaSeconds); + + DrawProgress(progress, eta, speed); + } + + stdin.Flush(); + stdin.Close(); + + await ffmpeg.WaitForExitAsync(); + if (ffmpeg.ExitCode != 0) + throw new Exception("FFmpeg NVENC encoding failed"); + } + + private (Rect box, Point2f center)? SelectTrackedFace( + List<(Rect box, Point2f center)> faces, + Point2f? previousCenter) + { + if (faces == null || faces.Count == 0) + return null; + + if (!previousCenter.HasValue) + { + // no previous face → pick largest + return faces + .OrderByDescending(f => f.box.Width * f.box.Height) + .First(); + } + + // pick the face closest to previous center + return faces + .OrderBy(f => + { + var dx = f.center.X - previousCenter.Value.X; + var dy = f.center.Y - previousCenter.Value.Y; + return dx * dx + dy * dy; + }) + .First(); + } + + private Process StartFfmpegNvenc( + string srcFileName, + string destFileName, + int width, + int height, + double fps, + TimeSpan skip, + string[] passthrough) + { + var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; + var skipSeconds = skip.TotalSeconds.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + var fpsStr = fps.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + + // One-pass pipeline: + // - rawvideo from stdin + // - audio from source MP4 (seeked) + // - NVENC video encode + // - AAC audio copy/encode + // + // This is the same structure your original OpenCV pipeline used. + // + // IMPORTANT: + // Because OpenCV reliably reads the full segment, + // FFmpeg will NOT close stdin early anymore. + // + var args = + "-y " + + // VIDEO INPUT (raw BGR24 from stdin) + $"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " + + + // AUDIO INPUT (seeked) + $"-ss {skipSeconds} -i \"{srcFileName}\" " + + + // MAP streams + "-map 0:v:0 -map 1:a:0? -shortest " + + + // VIDEO ENCODE + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + + + // AUDIO ENCODE/COPY + "-c:a aac -b:a 192k " + + + // Extra passthrough flags + pass + $" \"{destFileName}\""; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardInput = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var process = new Process { StartInfo = psi }; + process.Start(); + + // async stderr reader + _ = Task.Run(() => + { + try + { + string? line; + while ((line = process.StandardError.ReadLine()) != null) + Console.WriteLine($"[ffmpeg] {line}"); + } + catch { } + }); + + return process; + } + +} diff --git a/Point2f.cs b/Point2f.cs new file mode 100644 index 0000000..a997ea0 --- /dev/null +++ b/Point2f.cs @@ -0,0 +1,13 @@ +namespace splitter; + +public struct Point2f +{ + public float X; + public float Y; + + public Point2f(float x, float y) + { + X = x; + Y = y; + } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..f593426 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "splitter": { + "commandName": "Project", + "commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\20260426_212004.mp4\" \"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\Shorts\" --crop --debug --text" + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index bf166d0..cc0c9c2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ This tool is intended for creators, archivists, and automation workflows that ne - FFmpeg and FFprobe installed and available in the system PATH - .NET 10 SDK or newer +>Note: You must download YuNet ONNX model: +https://github.com/opencv/opencv_zoo/tree/master/models/face_detection_yunet (github.com in Bing) + --- ## Usage diff --git a/Rect.cs b/Rect.cs new file mode 100644 index 0000000..59af3c9 --- /dev/null +++ b/Rect.cs @@ -0,0 +1,17 @@ +namespace splitter; + +public struct Rect +{ + public int X; + public int Y; + public int Width; + public int Height; + + public Rect(int x, int y, int w, int h) + { + X = x; + Y = y; + Width = w; + Height = h; + } +} diff --git a/UltraFaceDetector.cs b/UltraFaceDetector.cs new file mode 100644 index 0000000..8766582 --- /dev/null +++ b/UltraFaceDetector.cs @@ -0,0 +1,133 @@ +using NcnnDotNet; +using UltraFaceDotNet; + +namespace splitter; + +public sealed class UltraFaceDetector : IDisposable +{ + private readonly UltraFace _ultraFace; + + public UltraFaceDetector(string binPath, string paramPath) + { + var param = new UltraFaceParameter + { + BinFilePath = binPath, + ParamFilePath = paramPath, + InputWidth = 320, + InputLength = 240, + NumThread = 1, + ScoreThreshold = 0.7f + }; + + _ultraFace = UltraFace.Create(param); + } + + public (Rect box, Point2f center)? Detect(byte[] bgr, int width, int height) + { + if (bgr == null || bgr.Length == 0) + return null; + + // bgr is contiguous BGR24: width * height * 3 + unsafe + { + fixed (byte* p = bgr) + { + using var mat = Mat.FromPixels( + (IntPtr)p, + PixelType.Bgr, // BGR24 input + width, + height); + + var faces = _ultraFace.Detect(mat); + if (faces == null) + return null; + + FaceInfo best = default; + bool hasBest = false; + + foreach (var f in faces) + { + if (!hasBest || f.Score > best.Score) + { + best = f; + hasBest = true; + } + } + + if (!hasBest) + return null; + + int x1 = (int)best.X1; + int y1 = (int)best.Y1; + int x2 = (int)best.X2; + int y2 = (int)best.Y2; + + var rect = new Rect( + x1, + y1, + x2 - x1, + y2 - y1); + + if (rect.Width <= 0 || rect.Height <= 0) + return null; + + var center = new Point2f( + rect.X + rect.Width / 2f, + rect.Y + rect.Height / 2f); + + return (rect, center); + } + } + } + + public List<(Rect box, Point2f center)> DetectAll(byte[] bgr, int width, int height) + { + var results = new List<(Rect box, Point2f center)>(); + + if (bgr == null || bgr.Length == 0) + return results; + + unsafe + { + fixed (byte* p = bgr) + { + using var mat = Mat.FromPixels( + (IntPtr)p, + PixelType.Bgr, // BGR24 input + width, + height); + + var faces = _ultraFace.Detect(mat); + if (faces == null) + return results; + + foreach (var f in faces) + { + int x1 = (int)f.X1; + int y1 = (int)f.Y1; + int x2 = (int)f.X2; + int y2 = (int)f.Y2; + + var rect = new Rect( + x1, + y1, + x2 - x1, + y2 - y1); + + if (rect.Width <= 0 || rect.Height <= 0) + continue; + + var center = new Point2f( + rect.X + rect.Width / 2f, + rect.Y + rect.Height / 2f); + + results.Add((rect, center)); + } + } + } + + return results; + } + + public void Dispose() => _ultraFace?.Dispose(); +} diff --git a/splitter.cs b/splitter.cs index a8e73a0..627a736 100644 --- a/splitter.cs +++ b/splitter.cs @@ -1,23 +1,20 @@ -using System; using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Linq; using System.Text; -using System.Threading; -using System.Threading.Tasks; +using splitter; class Program { static int logLines = 0; + static bool plainText = false; static readonly object consoleLock = new(); static bool progressRunning = true; static void Main(string[] args) { double? overrideTargetDuration = null; - bool estimateOnly = false; - bool forceFixed = false; + var estimateOnly = false; + var forceFixed = false; Console.OutputEncoding = Encoding.UTF8; @@ -29,8 +26,8 @@ class Program } // Extract passthrough parameters after "--" - string[] passthrough = Array.Empty(); - int passthroughIndex = Array.IndexOf(args, "--"); + var passthrough = Array.Empty(); + var passthroughIndex = Array.IndexOf(args, "--"); if (passthroughIndex >= 0) { @@ -47,9 +44,11 @@ class Program return; } - string inputFile = args[0]; - string outputFolder = args[1]; - string? mask = null; + var inputFile = args[0]; + var outputFolder = args[1]; + (int width, int height)? crop = null; + string? mask = null; + var debug = false; foreach (var arg in args.Skip(2)) { @@ -57,9 +56,25 @@ class Program { mask = arg.Substring("--mask=".Length); } + else if (arg.StartsWith("--crop=")) + { + crop = ParseCrop(arg.Substring("--crop=".Length)); + } + else if (arg == "--crop") + { + crop = ParseCrop(""); + } + else if (arg == "--text") + { + plainText = true; + } + else if (arg == "--debug") + { + debug = true; + } else if (arg.StartsWith("--duration=")) { - string dur = arg.Substring("--duration=".Length); + var dur = arg.Substring("--duration=".Length); overrideTargetDuration = ParseDuration(dur); if (overrideTargetDuration <= 0) { @@ -86,19 +101,19 @@ class Program if (!Directory.Exists(outputFolder)) Directory.CreateDirectory(outputFolder); - string baseName = Path.GetFileNameWithoutExtension(inputFile); - string outputMask = mask ?? $"{baseName}_Seg%03d.mp4"; + var baseName = Path.GetFileNameWithoutExtension(inputFile); + var outputMask = mask ?? $"{baseName}_Seg%03d.mp4"; LogInfo("Reading duration via ffprobe..."); - double duration = GetDuration(inputFile); + var duration = GetDuration(inputFile); if (duration <= 0) { LogError("Could not read duration."); return; } - double target = overrideTargetDuration ?? 58.0; + var target = overrideTargetDuration ?? 58.0; int segments; double segmentLength; @@ -132,9 +147,16 @@ class Program LogInfo($"Segments: {segments}"); LogInfo($"Equal segment length: {segmentLength:F3}s"); - LogInfo("Starting multi-threaded ffmpeg splitting..."); - - RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); + if (crop != null) + { + LogInfo("Starting multi-threaded face tracking crop and splitting..."); + RunMultiThreadedCrop(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough, crop.Value.width, crop.Value.height, debug); + } + else + { + LogInfo("Starting multi-threaded ffmpeg splitting..."); + RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); + } LogSuccess("Done."); progressRunning = false; @@ -146,6 +168,32 @@ class Program } } + 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); + + var s = v.Trim().ToLowerInvariant(); + + // Expected format: "WWWxHHH" + var parts = s.Split('x'); + if (parts.Length != 2) + return null; + + var okW = int.TryParse(parts[0], out var w); + var okH = int.TryParse(parts[1], out var h); + + if (!okW || !okH || w <= 0 || h <= 0) + return null; + + return (w, h); + } + // ----------------------------- // Logging + Progress UI // ----------------------------- @@ -154,29 +202,39 @@ class Program { lock (consoleLock) { - Console.ForegroundColor = color; - Console.WriteLine($"{prefix} {msg}"); - Console.ResetColor(); - logLines++; + if (plainText) + { + Console.WriteLine($"{prefix} {msg}"); + } + else + { + Console.ForegroundColor = color; + Console.WriteLine($"{prefix} {msg}"); + Console.ResetColor(); + logLines++; + } } } - static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); + static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); static void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg); - static void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg); - static void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg); + static void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg); + static void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg); static void DrawProgress(double progress, TimeSpan eta, double speed) { + if ( plainText ) + return; + lock (consoleLock) { - int width = Math.Max(20, Console.WindowWidth - 20); - int filled = (int)(progress * width); + var width = Math.Max(20, Console.WindowWidth - 20); + var filled = (int)(progress * width); if (filled < 0) filled = 0; if (filled > width) filled = width; - int barLine = logLines + 1; - int infoLine = logLines + 2; + var barLine = logLines + 1; + var infoLine = logLines + 2; // Progress bar with 24-bit color (green) Console.SetCursorPosition(0, barLine); @@ -187,14 +245,14 @@ class Program // Info line: percentage, ETA, speed Console.SetCursorPosition(0, infoLine); - string etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) + var etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) ? "ETA: --:--" : $"ETA: {eta:mm\\:ss}"; - string speedStr = double.IsNaN(speed) || double.IsInfinity(speed) + var speedStr = double.IsNaN(speed) || double.IsInfinity(speed) ? "Speed: -.-x" : $"Speed: {speed:F2}x"; - string info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; + var info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m"); } } @@ -215,11 +273,11 @@ class Program }; using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffprobe."); - string? output = proc.StandardOutput.ReadToEnd(); + var output = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); if (output != null && - double.TryParse(output, NumberStyles.Any, CultureInfo.InvariantCulture, out double duration)) + double.TryParse(output, NumberStyles.Any, CultureInfo.InvariantCulture, out var duration)) return duration; return -1; @@ -249,7 +307,7 @@ class Program }) .ToList(); - int completed = 0; + var completed = 0; var sw = Stopwatch.StartNew(); // Progress thread @@ -257,13 +315,13 @@ class Program { while (progressRunning) { - double progress = segments == 0 ? 0 : (double)completed / segments; - double processedSeconds = completed * segmentLength; - double speed = sw.Elapsed.TotalSeconds > 0 + var progress = segments == 0 ? 0 : (double)completed / segments; + var processedSeconds = completed * segmentLength; + var speed = sw.Elapsed.TotalSeconds > 0 ? processedSeconds / sw.Elapsed.TotalSeconds : 0; - double remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001); + var remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001); if (remainingSeconds < 0) remainingSeconds = 0; var eta = TimeSpan.FromSeconds(remainingSeconds); @@ -276,14 +334,14 @@ class Program }; progressThread.Start(); - int maxDegree = Math.Max(1, Environment.ProcessorCount / 2); + var maxDegree = Math.Max(1, Environment.ProcessorCount / 2); Parallel.ForEach( jobs, new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, job => { - string outputFile = BuildOutputFileName(outputFolder, mask, job.Index); + var outputFile = BuildOutputFileName(outputFolder, mask, job.Index); RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough); Interlocked.Increment(ref completed); }); @@ -294,6 +352,162 @@ class Program DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001)); } + static void RunSingleThreadedSplit( + string inputFile, + string outputFolder, + string mask, + double totalDuration, + int segments, + double segmentLength, + string[] passthrough) + { + var jobs = Enumerable.Range(0, segments) + .Select(i => new + { + Index = i, + Start = i * segmentLength, + Length = (i == segments - 1) + ? Math.Max(0.1, totalDuration - i * segmentLength) + : segmentLength + }) + .ToList(); + + var completed = 0; + var sw = Stopwatch.StartNew(); + + // Progress thread + var progressThread = new Thread(() => + { + while (progressRunning) + { + var progress = segments == 0 ? 0 : (double)completed / segments; + var processedSeconds = completed * segmentLength; + var speed = sw.Elapsed.TotalSeconds > 0 + ? processedSeconds / sw.Elapsed.TotalSeconds + : 0; + + var remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001); + if (remainingSeconds < 0) remainingSeconds = 0; + var eta = TimeSpan.FromSeconds(remainingSeconds); + + DrawProgress(progress, eta, speed); + Thread.Sleep(200); + } + }) + { + IsBackground = true + }; + progressThread.Start(); + + // --- SINGLE THREADED LOOP --- + foreach (var job in jobs) + { + var outputFile = BuildOutputFileName(outputFolder, mask, job.Index); + RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough); + completed++; + } + + sw.Stop(); + progressRunning = false; + progressThread.Join(); + DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001)); + } + + // ----------------------------- + // Multi-threaded cropping + // ----------------------------- + private static void RunMultiThreadedCrop( + string inputFile, + string outputFolder, + string outputMask, + double duration, + int segments, + double segmentLength, + string[] passthrough, + int width, + int height, + bool showDebugOverlay) + { + var tracker = new FaceTracker + { + DrawProgress = DrawProgress + }; + + var jobs = Enumerable.Range(0, segments) + .Select(i => new + { + Index = i, + Start = i * segmentLength, + Length = (i == segments - 1) + ? Math.Max(0.1, duration - i * segmentLength) + : segmentLength + }) + .ToList(); + + var completed = 0; + var sw = Stopwatch.StartNew(); + progressRunning = true; + + // --- PROGRESS THREAD --- + var progressThread = new Thread(() => + { + while (progressRunning) + { + var progress = segments == 0 ? 0 : (double)completed / segments; + var processedSeconds = completed * segmentLength; + + var speed = sw.Elapsed.TotalSeconds > 0 + ? processedSeconds / sw.Elapsed.TotalSeconds + : 0; + + var remainingSeconds = (duration - processedSeconds) / Math.Max(speed, 0.0001); + if (remainingSeconds < 0) remainingSeconds = 0; + + var eta = TimeSpan.FromSeconds(remainingSeconds); + + DrawProgress(progress, eta, speed); + Thread.Sleep(200); + } + }) + { + IsBackground = true + }; + progressThread.Start(); + + // --- PARALLEL EXECUTION --- + var maxDegree = Math.Max(1, Environment.ProcessorCount / 2); + + Parallel.ForEach( + jobs, + new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, + async job => + { + var outputFile = BuildOutputFileName(outputFolder, outputMask, job.Index); + + // Run the face-tracking cropper + await tracker.TrackFaceAndExtract( + inputFile, + outputFile, + TimeSpan.FromSeconds(job.Start), + TimeSpan.FromSeconds(job.Length), + width, + height, + passthrough, + showDebugOverlay); + + Interlocked.Increment(ref completed); + }); + + // --- CLEANUP --- + sw.Stop(); + progressRunning = false; + progressThread.Join(); + + var finalSpeed = duration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001); + DrawProgress(1.0, TimeSpan.Zero, finalSpeed); + } + + static string BuildOutputFileName(string folder, string mask, int index) { string fileName; @@ -309,8 +523,8 @@ class Program else { // If no placeholder, append index - string name = Path.GetFileNameWithoutExtension(mask); - string ext = Path.GetExtension(mask); + var name = Path.GetFileNameWithoutExtension(mask); + var ext = Path.GetExtension(mask); fileName = $"{name}_{index:000}{ext}"; } @@ -319,9 +533,9 @@ class Program static void RunFFmpegSegment(string inputFile, string outputFile, double start, double length, string[] passthrough) { - string pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; + var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; - string args = + var args = $"-ss {start.ToString(CultureInfo.InvariantCulture)} -i \"{inputFile}\" -t {length.ToString(CultureInfo.InvariantCulture)} -c copy {pass} \"{outputFile}\" -y"; var psi = new ProcessStartInfo @@ -343,7 +557,7 @@ class Program text = text.Trim().ToLowerInvariant(); // Case 1: pure number to seconds - if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out double sec)) + if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var sec)) return sec; // Case 2: Ns (seconds) @@ -352,16 +566,16 @@ class Program // Case 3: NmMs (minutes + seconds) // Examples: 2m30s, 1m5s, 10m0s - int mIndex = text.IndexOf('m'); - int sIndex = text.IndexOf('s'); + var mIndex = text.IndexOf('m'); + var sIndex = text.IndexOf('s'); if (mIndex > 0 && sIndex > mIndex) { - string mPart = text[..mIndex]; - string sPart = text[(mIndex + 1)..sIndex]; + var mPart = text[..mIndex]; + var sPart = text[(mIndex + 1)..sIndex]; - if (double.TryParse(mPart, out double minutes) && - double.TryParse(sPart, out double seconds)) + if (double.TryParse(mPart, out var minutes) && + double.TryParse(sPart, out var seconds)) { return minutes * 60 + seconds; } @@ -406,15 +620,24 @@ Options: --estimate Print calculated segment information and exit. No splitting is performed. + --crop[=] Crop video to width w and height h, with face tracking. + Useful to making YouTube Shorts or TikToks from horizontal video. + Default: 607x1080 (vertical video cropped from Full HD original) + + --text Display log in plain text. + + --debug Show debug overlay during face tracking. + Passthrough: Anything after -- is passed directly to ffmpeg. Examples: - splitter video.mp4 out/ - splitter video.mp4 out/ --duration=90s - splitter video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4"" - splitter video.mp4 out/ --estimate - splitter video.mp4 out/ --force --duration=45 -- -an -sn + splitter vertical-video.mp4 out/ + splitter vertical-video.mp4 out/ --duration=90s + splitter vertical-video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4"" + splitter vertical-video.mp4 out/ --estimate + splitter vertical-video.mp4 out/ --force --duration=45 -- -an -sn + splitter horizontal-video.mp4 out/ --crop Description: Splits a video into equal or fixed-length segments using multi-threaded diff --git a/splitter.csproj b/splitter.csproj index 0613750..195fc73 100644 --- a/splitter.csproj +++ b/splitter.csproj @@ -1,4 +1,4 @@ - + Exe @@ -6,7 +6,45 @@ enable enable latest - true + true + + + false + false + false + false + false + false + + + + + true + true + true + false + + true + true + win-x64 + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + +