diff --git a/CommandLine.cs b/CommandLine.cs index 50a7720..6bf3758 100644 --- a/CommandLine.cs +++ b/CommandLine.cs @@ -18,6 +18,7 @@ public sealed class CommandLine public bool PlainText { get; private init; } public bool EstimateOnly { get; private init; } public bool ForceFixed { get; private init; } + public bool SingleThreaded { get; private init; } public bool IsValid => !string.IsNullOrEmpty(InputFile) && !string.IsNullOrEmpty(OutputFolder); @@ -79,6 +80,10 @@ public sealed class CommandLine { Debug = true; } + else if (arg == "--single-thread") + { + SingleThreaded = true; + } else if (arg.StartsWith("--duration=")) { var dur = arg.Substring("--duration=".Length); @@ -198,6 +203,9 @@ Options: --text Display log in plain text. + --single-thread Run in single-threaded mode (no parallel ffmpeg processes). + Useful for debugging or if system is resource-constrained. + --debug Show debug overlay during face tracking. Passthrough: diff --git a/ISegmentProcessor.cs b/ISegmentProcessor.cs new file mode 100644 index 0000000..4f2979e --- /dev/null +++ b/ISegmentProcessor.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace splitter; + +public interface ISegmentProcessor +{ + Task ProcessSegment( string inputFile, string outputFile, double start, double length, string[] ffmpegPassthroughParameters); +} diff --git a/Logger.cs b/Logger.cs new file mode 100644 index 0000000..e6673a5 --- /dev/null +++ b/Logger.cs @@ -0,0 +1,68 @@ +namespace splitter; + +public static class Logger +{ + static int _logLines = 0; + static readonly object _consoleLock = new(); + + public static bool PlainText { get; set; } + + public static void Log(string prefix, ConsoleColor color, string msg) + { + lock (_consoleLock) + { + if (PlainText) + { + Console.WriteLine($"{prefix} {msg}"); + } + else + { + Console.ForegroundColor = color; + Console.WriteLine($"{prefix} {msg}"); + Console.ResetColor(); + _logLines++; + } + } + } + + public static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); + public static void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg); + public static void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg); + public static void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg); + + public static void DrawProgress(int progressLevel, double progress, TimeSpan eta, double speed) + { + if (PlainText || progressLevel < 0) + return; + + lock (_consoleLock) + { + var width = Math.Max(20, Console.WindowWidth - 20); + var filled = (int)(progress * width); + if (filled < 0) filled = 0; + if (filled > width) filled = width; + + var barLine = _logLines + 1 + progressLevel*2; + var infoLine = _logLines + 2 + progressLevel*2; + + // Progress bar with 24-bit color (green) + Console.SetCursorPosition(0, barLine); + Console.Write("\u001b[38;2;0;255;0m["); + Console.Write(new string('#', filled)); + Console.Write(new string('-', width - filled)); + Console.Write("]\u001b[0m"); + + // Info line: percentage, ETA, speed + Console.SetCursorPosition(0, infoLine); + var etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) + ? "ETA: --:--" + : $"ETA: {eta:mm\\:ss}"; + var speedStr = double.IsNaN(speed) || double.IsInfinity(speed) + ? "Speed: -.-x" + : $"Speed: {speed:F2}x"; + + var info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; + Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m"); + } + } +} diff --git a/LoggingBase.cs b/LoggingBase.cs index 04641e8..1edb6b9 100644 --- a/LoggingBase.cs +++ b/LoggingBase.cs @@ -1,20 +1,20 @@ using System; -using System.Collections.Generic; -using System.Text; - namespace splitter; -public class LoggingBase( - Action log, - Action drawProgress - ) +public abstract class LoggingBase(int progressLine) { - protected Action Log = log; - protected Action DrawProgress = drawProgress; + protected void Log(string level, ConsoleColor color, string message) + => Logger.Log(level, color, message); - protected void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); - protected void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg); - protected void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg); - protected void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg); + protected void LogInfo(string message) + => Logger.LogInfo(message); + protected void LogWarn(string message) + => Logger.LogWarn(message); + + protected void LogError(string message) + => Logger.LogError(message); + + protected void DrawProgress(double percent, TimeSpan eta, double fps) + => Logger.DrawProgress(progressLine, percent, eta, fps); } diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index 5ffc2d4..0be7d17 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -10,7 +10,7 @@ }, "Debug": { "commandName": "Project", - "commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\20260426_212004.mp4\" \"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\Shorts\" --crop --detect=body --debug --text" + "commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\20260426_212004.mp4\" \"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\Shorts\" --crop --detect=body --debug --single-thread --text" } } } \ No newline at end of file diff --git a/SimpleSplitter.cs b/SimpleSplitter.cs new file mode 100644 index 0000000..a589259 --- /dev/null +++ b/SimpleSplitter.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace splitter; + +public class SimpleSplitter(int segmentNo) : LoggingBase(segmentNo), ISegmentProcessor +{ + public async Task ProcessSegment(string inputFile, string outputFile, double start, double length, string[] passthrough) + { + RunFFmpegSegment(inputFile, outputFile, start, length, passthrough); + } + + private void RunFFmpegSegment( + string inputFile, + string outputFile, + double start, + double length, + string[] passthrough) + { + var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; + + var args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + + $"-i \"{inputFile}\" " + + $"-t {length.ToString(CultureInfo.InvariantCulture)} " + + $"-c copy {pass} \"{outputFile}\" -y"; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg."); + + ShowFFMpegProgress(length, proc); + + proc.WaitForExit(); + } + + private void ShowFFMpegProgress(double length, Process proc) + { + var sw = Stopwatch.StartNew(); + + string? line; + while ((line = proc.StandardError.ReadLine()) != null) + { + // Look for "time=00:00:03.52" + var idx = line.IndexOf("time=", StringComparison.OrdinalIgnoreCase); + if (idx < 0) + continue; + + var timeStr = ExtractTimestamp(line, idx + 5); + if (timeStr == null) + continue; + + if (!TryParseFfmpegTime(timeStr, out var current)) + continue; + + var progress = current.TotalSeconds / length; + if (progress < 0) progress = 0; + if (progress > 1) progress = 1; + + var elapsed = sw.Elapsed; + var speed = current.TotalSeconds > 0 + ? current.TotalSeconds / elapsed.TotalSeconds + : 0; + + var remaining = length - current.TotalSeconds; + var etaSeconds = speed > 0 ? remaining / speed : remaining; + var eta = TimeSpan.FromSeconds(etaSeconds); + + DrawProgress(progress, eta, speed); + } + } + + private static string? ExtractTimestamp(string line, int startIndex) + { + // FFmpeg formats: HH:MM:SS.xx + // We read until whitespace + int end = startIndex; + while (end < line.Length && !char.IsWhiteSpace(line[end])) + end++; + + if (end <= startIndex) + return null; + + return line[startIndex..end]; + } + + private static bool TryParseFfmpegTime(string s, out TimeSpan ts) + { + // FFmpeg uses "00:00:03.52" + return TimeSpan.TryParseExact( + s, + @"hh\:mm\:ss\.ff", + CultureInfo.InvariantCulture, + out ts); + } + +} diff --git a/TrackingSplitter.cs b/TrackingSplitter.cs index 81d6814..3030c24 100644 --- a/TrackingSplitter.cs +++ b/TrackingSplitter.cs @@ -7,49 +7,65 @@ using OpenCvSharp; namespace splitter; -public class TrackingSplitter( - Action log, - Action drawProgress - ) : LoggingBase(log, drawProgress) +public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable { - public async Task TrackAndExtract( - string srcFileName, - string destFileName, - IObjectDetector detector, - TimeSpan skip, - TimeSpan duration, - int cropWidth, - int cropHeight, - string[] passthrough, - bool debugOverlay) + private readonly int _segmentNo; + private readonly int _cropWidth; + private readonly int _cropHeight; + private readonly bool _debugOverlay; + private readonly bool _plainText; + + private readonly IObjectDetector _detector; + + public TrackingSplitter(int segmentNo, int cropWidth, int cropHeight, bool debugOverlay, bool plainText, IObjectDetector detector) + : base(segmentNo) { - using var capture = new VideoCapture(srcFileName); + _segmentNo = segmentNo; + _cropWidth = cropWidth; + _cropHeight = cropHeight; + _debugOverlay = debugOverlay; + _plainText = plainText; + _detector = detector; + } + + public void Dispose() + { + if (_detector is IDisposable d) + d.Dispose(); + } + + public async Task ProcessSegment(string inputFile, string outputFile, double start, double length, string[] ffmpegPassthroughParameters) + { + using var capture = new VideoCapture(inputFile); if (!capture.IsOpened()) throw new Exception("Cannot open video"); - capture.Set(VideoCaptureProperties.PosMsec, skip.TotalMilliseconds); + var skip = TimeSpan.FromSeconds(start); + var duration = TimeSpan.FromSeconds(length); + + capture.Set(VideoCaptureProperties.PosMsec, start); 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); + var totalFrames = (int)(length * fps); - var originalCropWidth = cropWidth; - var originalCropHeight = cropHeight; + var originalCropWidth = _cropWidth; + var originalCropHeight = _cropHeight; Console.WriteLine($"[TrackingSplitter] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}"); - var encWidth = debugOverlay ? videoWidth : originalCropWidth; - var encHeight = debugOverlay ? videoHeight : originalCropHeight; + var encWidth = _debugOverlay ? videoWidth : originalCropWidth; + var encHeight = _debugOverlay ? videoHeight : originalCropHeight; var ffmpeg = StartFfmpegNvenc( - srcFileName, - destFileName, - encWidth, - encHeight, - fps, - skip, - passthrough); + inputFile, + outputFile, + encWidth, + encHeight, + fps, + skip, + ffmpegPassthroughParameters); using var stdin = ffmpeg.StandardInput.BaseStream; @@ -63,12 +79,12 @@ public class TrackingSplitter( // initial reset is now done inside CameraController var camera = new CameraController( - videoWidth, - videoHeight, - originalCropWidth, - originalCropHeight, - kalman - ); + videoWidth, + videoHeight, + originalCropWidth, + originalCropHeight, + kalman + ); var startTime = DateTime.UtcNow; @@ -80,7 +96,7 @@ public class TrackingSplitter( Rect? objectBox = null; Point2f? objectCenter = null; - var objects = detector.DetectAll(frame, videoWidth, videoHeight); + var objects = _detector.DetectAll(frame, videoWidth, videoHeight); var primary = SelectTrackedObject(objects, kalman.LastMeasurement); camera.Update(primary); @@ -94,7 +110,7 @@ public class TrackingSplitter( var lostFrames = camera.LostFrames; var roi = camera.Roi; - if (debugOverlay) + if (_debugOverlay) { if (objectBox.HasValue) { @@ -117,7 +133,7 @@ public class TrackingSplitter( DrawText(frame, $"Camera: {cameraCenter.X:F1},{cameraCenter.Y:F1}", 20, 160, Scalar.White); } - if (debugOverlay) + if (_debugOverlay) { frame.CopyTo(outputBgr); Marshal.Copy(outputBgr.Data, videoBuffer, 0, frameBytes); @@ -134,7 +150,7 @@ public class TrackingSplitter( var elapsed = DateTime.UtcNow - startTime; var progress = (double)i / totalFrames; - var speed = i > 0 ? i / elapsed.TotalSeconds : 0.0; + var speed = i > 0 ? (i / elapsed.TotalSeconds)/fps : 0.0; var remainingFrames = totalFrames - i; var etaSeconds = speed > 0 ? remainingFrames / speed : 0; var eta = TimeSpan.FromSeconds(etaSeconds); @@ -147,7 +163,9 @@ public class TrackingSplitter( await ffmpeg.WaitForExitAsync(); if (ffmpeg.ExitCode != 0) - throw new Exception("FFmpeg NVENC encoding failed"); + LogError($"Segment {_segmentNo} FFmpeg encoding failed"); + else + LogInfo($"Segment {_segmentNo} processing completed"); } private (Rect box, Point2f center)? SelectTrackedObject( @@ -243,7 +261,10 @@ public class TrackingSplitter( { string? line; while ((line = process.StandardError.ReadLine()) != null) - Console.WriteLine($"[ffmpeg] {line}"); + { + if (_plainText) + Console.WriteLine($"[ffmpeg] {line}"); + } } catch { } }); @@ -256,4 +277,5 @@ public class TrackingSplitter( Cv2.PutText(img, text, new Point(x, y), HersheyFonts.HersheySimplex, 0.6, color, 2); } + } diff --git a/UltraFaceDetector.cs b/UltraFaceDetector.cs index 3f4420e..e3a1a8f 100644 --- a/UltraFaceDetector.cs +++ b/UltraFaceDetector.cs @@ -9,10 +9,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector { private readonly UltraFace _ultraFace; - public UltraFaceDetector( - Action log, - Action drawProgress - ) : base(log, drawProgress) + public UltraFaceDetector() : base(-1) { var basePath = AppDomain.CurrentDomain.BaseDirectory; var param = new UltraFaceParameter diff --git a/YoloOnnxObjectDetector.cs b/YoloOnnxObjectDetector.cs index 577e4fa..a8553d4 100644 --- a/YoloOnnxObjectDetector.cs +++ b/YoloOnnxObjectDetector.cs @@ -51,18 +51,15 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp public Detection(float x, float y, float w, float h, float score) { - X = x; - Y = y; - Width = w; + X = x; + Y = y; + Width = w; Height = h; - Score = score; + Score = score; } } - public YoloOnnxObjectDetector( - Action log, - Action drawProgress - ) : base(log, drawProgress) + public YoloOnnxObjectDetector() : base(-1) { var options = new SessionOptions(); options.AppendExecutionProvider_DML(); @@ -75,8 +72,8 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp _inputName = _session.InputMetadata.Keys.First(); _outputName = _session.OutputMetadata.Keys.First(); - foreach (var kv in _session.OutputMetadata) - LogInfo($"[YoloOnnx] {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}"); + //foreach (var kv in _session.OutputMetadata) + // LogInfo($"[YoloOnnx] {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}"); // Preallocate tensor buffer (fixed size for lifetime) _inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth]; diff --git a/splitter.cs b/splitter.cs index 50e21a3..8760bfa 100644 --- a/splitter.cs +++ b/splitter.cs @@ -5,12 +5,7 @@ using splitter; static class Program { - static int _logLines = 0; - static bool _plainText = false; - static readonly object _consoleLock = new(); - static bool _progressRunning = true; - - static void Main(string[] args) + static async Task Main(string[] args) { var cmd = new CommandLine(args); @@ -24,7 +19,7 @@ static class Program var debug = cmd.Debug; string? detect = cmd.Detect; double? overrideTargetDuration = cmd.OverrideTargetDuration; - _plainText = cmd.PlainText; + Logger.PlainText = cmd.PlainText; if (!File.Exists(inputFile)) { @@ -81,90 +76,46 @@ static class Program LogInfo($"Segments: {segments}"); LogInfo($"Equal segment length: {segmentLength:F3}s"); + Func processorFactory; 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, detect); + processorFactory = i => + { + IObjectDetector detector = detect switch + { + "face" => new UltraFaceDetector(), + "body" => new YoloOnnxObjectDetector(), + _ => throw new InvalidOperationException($"Unknown detector: {detect}") + }; + return new TrackingSplitter(i, crop.Value.width, crop.Value.height, debug, cmd.PlainText, detector); + }; } else { - LogInfo("Starting multi-threaded ffmpeg splitting..."); - RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); + processorFactory = i => new SimpleSplitter(i); + } + if (cmd.SingleThreaded) + { + LogInfo("Starting single-threaded splitting..."); + await RunSingleThreaded(processorFactory, inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); + } + else + { + LogInfo("Starting multi-threaded splitting..."); + await RunMultiThreaded(processorFactory, inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); } - LogSuccess("Done."); - _progressRunning = false; - // Move cursor below progress area - lock (_consoleLock) - { - Console.SetCursorPosition(0, _logLines + 4); - Console.WriteLine(); - } + LogInfo("Done."); } + private static void LogInfo(string message) + => Logger.LogInfo(message); - // ----------------------------- - // Logging + Progress UI - // ----------------------------- + private static void LogWarn(string message) + => Logger.LogWarn(message); - static void Log(string prefix, ConsoleColor color, string msg) - { - lock (_consoleLock) - { - 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 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 DrawProgress(double progress, TimeSpan eta, double speed) - { - if ( _plainText ) - return; - - lock (_consoleLock) - { - var width = Math.Max(20, Console.WindowWidth - 20); - var filled = (int)(progress * width); - if (filled < 0) filled = 0; - if (filled > width) filled = width; - - var barLine = _logLines + 1; - var infoLine = _logLines + 2; - - // Progress bar with 24-bit color (green) - Console.SetCursorPosition(0, barLine); - Console.Write("\u001b[38;2;0;255;0m["); - Console.Write(new string('#', filled)); - Console.Write(new string('-', width - filled)); - Console.Write("]\u001b[0m"); - - // Info line: percentage, ETA, speed - Console.SetCursorPosition(0, infoLine); - var etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) - ? "ETA: --:--" - : $"ETA: {eta:mm\\:ss}"; - var speedStr = double.IsNaN(speed) || double.IsInfinity(speed) - ? "Speed: -.-x" - : $"Speed: {speed:F2}x"; - - var info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; - Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m"); - } - } + private static void LogError(string message) + => Logger.LogError(message); // ----------------------------- // ffprobe @@ -196,7 +147,8 @@ static class Program // Multi-threaded splitting // ----------------------------- - static void RunMultiThreadedSplit( + static async Task RunMultiThreaded( + Func processorFactory, string inputFile, string outputFolder, string mask, @@ -216,52 +168,32 @@ static class Program }) .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(); - var maxDegree = Math.Max(1, Environment.ProcessorCount / 2); + using var sem = new SemaphoreSlim(maxDegree); + var tasks = new List(); - Parallel.ForEach( - jobs, - new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, - job => + foreach (var job in jobs) + { + await sem.WaitAsync(); + + tasks.Add(Task.Run(async () => { - var outputFile = BuildOutputFileName(outputFolder, mask, job.Index); - RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough); - Interlocked.Increment(ref completed); - }); + try + { + await ProcessSegment(processorFactory, inputFile, outputFolder, mask, passthrough, job.Index, job.Start, job.Length); + } + finally + { + sem.Release(); + } + })); + } - sw.Stop(); - _progressRunning = false; - progressThread.Join(); - DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001)); + await Task.WhenAll(tasks); } - static void RunSingleThreadedSplit( + static async Task RunSingleThreaded( + Func processorFactory, string inputFile, string outputFolder, string mask, @@ -271,157 +203,38 @@ static class Program 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) + .Select(i => new { - var progress = segments == 0 ? 0 : (double)completed / segments; - var processedSeconds = completed * segmentLength; - var speed = sw.Elapsed.TotalSeconds > 0 - ? processedSeconds / sw.Elapsed.TotalSeconds - : 0; + Index = i, + Start = i * segmentLength, + Length = (i == segments - 1) + ? Math.Max(0.1, totalDuration - i * segmentLength) + : segmentLength + }) + .ToList(); - 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++; + await ProcessSegment(processorFactory, inputFile, outputFolder, mask, passthrough, job.Index, job.Start, job.Length); } - 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, - string? detect) + private static async Task ProcessSegment(Func processorFactory, string inputFile, string outputFolder, string mask, string[] passthrough, int index, double start, double length) { - var tracker = new TrackingSplitter(Log, DrawProgress); - - var jobs = Enumerable.Range(0, segments) - .Select(i => new + var outputFile = BuildOutputFileName(outputFolder, mask, index); + var processor = processorFactory(index); + try { - 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(() => + await processor.ProcessSegment(inputFile, outputFile, start, length, passthrough); + } + finally { - 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); - using IDisposable detector = detect switch - { - "face" => new UltraFaceDetector(Log, DrawProgress), - "body" => new YoloOnnxObjectDetector(Log, DrawProgress), - _ => throw new InvalidOperationException($"Unknown detector: {detect}") - }; - - // Run the face-tracking cropper - await tracker.TrackAndExtract( - inputFile, - outputFile, - (IObjectDetector)detector, - 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); + if (processor is IDisposable disposable) + disposable.Dispose(); + } } - static string BuildOutputFileName(string folder, string mask, int index) { string fileName; @@ -445,24 +258,4 @@ static class Program return Path.Combine(folder, fileName); } - static void RunFFmpegSegment(string inputFile, string outputFile, double start, double length, string[] passthrough) - { - var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; - - var args = - $"-ss {start.ToString(CultureInfo.InvariantCulture)} -i \"{inputFile}\" -t {length.ToString(CultureInfo.InvariantCulture)} -c copy {pass} \"{outputFile}\" -y"; - - var psi = new ProcessStartInfo - { - FileName = "ffmpeg", - Arguments = args, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg."); - proc.StandardError.ReadToEnd(); // swallow output - proc.WaitForExit(); - } }