Logging subsystem is in its own class now. Separate progress indicators for segments.

This commit is contained in:
Alexander Shabarshov 2026-05-11 22:14:52 +01:00
parent 1d337cf5bf
commit 2b412694fb
10 changed files with 349 additions and 347 deletions

View File

@ -18,6 +18,7 @@ public sealed class CommandLine
public bool PlainText { get; private init; } public bool PlainText { get; private init; }
public bool EstimateOnly { get; private init; } public bool EstimateOnly { get; private init; }
public bool ForceFixed { get; private init; } public bool ForceFixed { get; private init; }
public bool SingleThreaded { get; private init; }
public bool IsValid => !string.IsNullOrEmpty(InputFile) && !string.IsNullOrEmpty(OutputFolder); public bool IsValid => !string.IsNullOrEmpty(InputFile) && !string.IsNullOrEmpty(OutputFolder);
@ -79,6 +80,10 @@ public sealed class CommandLine
{ {
Debug = true; Debug = true;
} }
else if (arg == "--single-thread")
{
SingleThreaded = true;
}
else if (arg.StartsWith("--duration=")) else if (arg.StartsWith("--duration="))
{ {
var dur = arg.Substring("--duration=".Length); var dur = arg.Substring("--duration=".Length);
@ -198,6 +203,9 @@ Options:
--text Display log in plain text. --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. --debug Show debug overlay during face tracking.
Passthrough: Passthrough:

10
ISegmentProcessor.cs Normal file
View File

@ -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);
}

68
Logger.cs Normal file
View File

@ -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");
}
}
}

View File

@ -1,20 +1,20 @@
using System; using System;
using System.Collections.Generic;
using System.Text;
namespace splitter; namespace splitter;
public class LoggingBase( public abstract class LoggingBase(int progressLine)
Action<string/*level*/, ConsoleColor /*color*/, string /*message*/> log,
Action<double /*percent*/, TimeSpan /*duration*/, double /*fps*/> drawProgress
)
{ {
protected Action<string/*level*/, ConsoleColor /*color*/, string /*message*/> Log = log; protected void Log(string level, ConsoleColor color, string message)
protected Action<double /*percent*/, TimeSpan /*duration*/, double /*fps*/> DrawProgress = drawProgress; => Logger.Log(level, color, message);
protected void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); protected void LogInfo(string message)
protected void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg); => Logger.LogInfo(message);
protected void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg);
protected void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg);
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);
} }

View File

@ -10,7 +10,7 @@
}, },
"Debug": { "Debug": {
"commandName": "Project", "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"
} }
} }
} }

107
SimpleSplitter.cs Normal file
View File

@ -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);
}
}

View File

@ -7,49 +7,65 @@ using OpenCvSharp;
namespace splitter; namespace splitter;
public class TrackingSplitter( public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
Action<string/*level*/, ConsoleColor /*color*/, string /*message*/> log,
Action<double /*percent*/, TimeSpan /*duration*/, double /*fps*/> drawProgress
) : LoggingBase(log, drawProgress)
{ {
public async Task TrackAndExtract( private readonly int _segmentNo;
string srcFileName, private readonly int _cropWidth;
string destFileName, private readonly int _cropHeight;
IObjectDetector detector, private readonly bool _debugOverlay;
TimeSpan skip, private readonly bool _plainText;
TimeSpan duration,
int cropWidth, private readonly IObjectDetector _detector;
int cropHeight,
string[] passthrough, public TrackingSplitter(int segmentNo, int cropWidth, int cropHeight, bool debugOverlay, bool plainText, IObjectDetector detector)
bool debugOverlay) : 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()) if (!capture.IsOpened())
throw new Exception("Cannot open video"); 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 videoWidth = (int)capture.Get(VideoCaptureProperties.FrameWidth);
var videoHeight = (int)capture.Get(VideoCaptureProperties.FrameHeight); var videoHeight = (int)capture.Get(VideoCaptureProperties.FrameHeight);
var fps = capture.Get(VideoCaptureProperties.Fps); var fps = capture.Get(VideoCaptureProperties.Fps);
var totalFrames = (int)(duration.TotalSeconds * fps); var totalFrames = (int)(length * fps);
var originalCropWidth = cropWidth; var originalCropWidth = _cropWidth;
var originalCropHeight = cropHeight; var originalCropHeight = _cropHeight;
Console.WriteLine($"[TrackingSplitter] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}"); Console.WriteLine($"[TrackingSplitter] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}");
var encWidth = debugOverlay ? videoWidth : originalCropWidth; var encWidth = _debugOverlay ? videoWidth : originalCropWidth;
var encHeight = debugOverlay ? videoHeight : originalCropHeight; var encHeight = _debugOverlay ? videoHeight : originalCropHeight;
var ffmpeg = StartFfmpegNvenc( var ffmpeg = StartFfmpegNvenc(
srcFileName, inputFile,
destFileName, outputFile,
encWidth, encWidth,
encHeight, encHeight,
fps, fps,
skip, skip,
passthrough); ffmpegPassthroughParameters);
using var stdin = ffmpeg.StandardInput.BaseStream; using var stdin = ffmpeg.StandardInput.BaseStream;
@ -63,12 +79,12 @@ public class TrackingSplitter(
// initial reset is now done inside CameraController // initial reset is now done inside CameraController
var camera = new CameraController( var camera = new CameraController(
videoWidth, videoWidth,
videoHeight, videoHeight,
originalCropWidth, originalCropWidth,
originalCropHeight, originalCropHeight,
kalman kalman
); );
var startTime = DateTime.UtcNow; var startTime = DateTime.UtcNow;
@ -80,7 +96,7 @@ public class TrackingSplitter(
Rect? objectBox = null; Rect? objectBox = null;
Point2f? objectCenter = null; Point2f? objectCenter = null;
var objects = detector.DetectAll(frame, videoWidth, videoHeight); var objects = _detector.DetectAll(frame, videoWidth, videoHeight);
var primary = SelectTrackedObject(objects, kalman.LastMeasurement); var primary = SelectTrackedObject(objects, kalman.LastMeasurement);
camera.Update(primary); camera.Update(primary);
@ -94,7 +110,7 @@ public class TrackingSplitter(
var lostFrames = camera.LostFrames; var lostFrames = camera.LostFrames;
var roi = camera.Roi; var roi = camera.Roi;
if (debugOverlay) if (_debugOverlay)
{ {
if (objectBox.HasValue) if (objectBox.HasValue)
{ {
@ -117,7 +133,7 @@ public class TrackingSplitter(
DrawText(frame, $"Camera: {cameraCenter.X:F1},{cameraCenter.Y:F1}", 20, 160, Scalar.White); DrawText(frame, $"Camera: {cameraCenter.X:F1},{cameraCenter.Y:F1}", 20, 160, Scalar.White);
} }
if (debugOverlay) if (_debugOverlay)
{ {
frame.CopyTo(outputBgr); frame.CopyTo(outputBgr);
Marshal.Copy(outputBgr.Data, videoBuffer, 0, frameBytes); Marshal.Copy(outputBgr.Data, videoBuffer, 0, frameBytes);
@ -134,7 +150,7 @@ public class TrackingSplitter(
var elapsed = DateTime.UtcNow - startTime; var elapsed = DateTime.UtcNow - startTime;
var progress = (double)i / totalFrames; 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 remainingFrames = totalFrames - i;
var etaSeconds = speed > 0 ? remainingFrames / speed : 0; var etaSeconds = speed > 0 ? remainingFrames / speed : 0;
var eta = TimeSpan.FromSeconds(etaSeconds); var eta = TimeSpan.FromSeconds(etaSeconds);
@ -147,7 +163,9 @@ public class TrackingSplitter(
await ffmpeg.WaitForExitAsync(); await ffmpeg.WaitForExitAsync();
if (ffmpeg.ExitCode != 0) 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( private (Rect box, Point2f center)? SelectTrackedObject(
@ -243,7 +261,10 @@ public class TrackingSplitter(
{ {
string? line; string? line;
while ((line = process.StandardError.ReadLine()) != null) while ((line = process.StandardError.ReadLine()) != null)
Console.WriteLine($"[ffmpeg] {line}"); {
if (_plainText)
Console.WriteLine($"[ffmpeg] {line}");
}
} }
catch { } catch { }
}); });
@ -256,4 +277,5 @@ public class TrackingSplitter(
Cv2.PutText(img, text, new Point(x, y), Cv2.PutText(img, text, new Point(x, y),
HersheyFonts.HersheySimplex, 0.6, color, 2); HersheyFonts.HersheySimplex, 0.6, color, 2);
} }
} }

View File

@ -9,10 +9,7 @@ public sealed class UltraFaceDetector: LoggingBase, IDisposable, IObjectDetector
{ {
private readonly UltraFace _ultraFace; private readonly UltraFace _ultraFace;
public UltraFaceDetector( public UltraFaceDetector() : base(-1)
Action<string/*level*/, ConsoleColor /*color*/, string /*message*/> log,
Action<double /*percent*/, TimeSpan /*duration*/, double /*fps*/> drawProgress
) : base(log, drawProgress)
{ {
var basePath = AppDomain.CurrentDomain.BaseDirectory; var basePath = AppDomain.CurrentDomain.BaseDirectory;
var param = new UltraFaceParameter var param = new UltraFaceParameter

View File

@ -51,18 +51,15 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
public Detection(float x, float y, float w, float h, float score) public Detection(float x, float y, float w, float h, float score)
{ {
X = x; X = x;
Y = y; Y = y;
Width = w; Width = w;
Height = h; Height = h;
Score = score; Score = score;
} }
} }
public YoloOnnxObjectDetector( public YoloOnnxObjectDetector() : base(-1)
Action<string, ConsoleColor, string> log,
Action<double, TimeSpan, double> drawProgress
) : base(log, drawProgress)
{ {
var options = new SessionOptions(); var options = new SessionOptions();
options.AppendExecutionProvider_DML(); options.AppendExecutionProvider_DML();
@ -75,8 +72,8 @@ public sealed class YoloOnnxObjectDetector : LoggingBase, IObjectDetector, IDisp
_inputName = _session.InputMetadata.Keys.First(); _inputName = _session.InputMetadata.Keys.First();
_outputName = _session.OutputMetadata.Keys.First(); _outputName = _session.OutputMetadata.Keys.First();
foreach (var kv in _session.OutputMetadata) //foreach (var kv in _session.OutputMetadata)
LogInfo($"[YoloOnnx] {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}"); // LogInfo($"[YoloOnnx] {kv.Key}: {string.Join(",", kv.Value.Dimensions)} {kv.Value.ElementType}");
// Preallocate tensor buffer (fixed size for lifetime) // Preallocate tensor buffer (fixed size for lifetime)
_inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth]; _inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth];

View File

@ -5,12 +5,7 @@ using splitter;
static class Program static class Program
{ {
static int _logLines = 0; static async Task Main(string[] args)
static bool _plainText = false;
static readonly object _consoleLock = new();
static bool _progressRunning = true;
static void Main(string[] args)
{ {
var cmd = new CommandLine(args); var cmd = new CommandLine(args);
@ -24,7 +19,7 @@ static class Program
var debug = cmd.Debug; var debug = cmd.Debug;
string? detect = cmd.Detect; string? detect = cmd.Detect;
double? overrideTargetDuration = cmd.OverrideTargetDuration; double? overrideTargetDuration = cmd.OverrideTargetDuration;
_plainText = cmd.PlainText; Logger.PlainText = cmd.PlainText;
if (!File.Exists(inputFile)) if (!File.Exists(inputFile))
{ {
@ -81,90 +76,46 @@ static class Program
LogInfo($"Segments: {segments}"); LogInfo($"Segments: {segments}");
LogInfo($"Equal segment length: {segmentLength:F3}s"); LogInfo($"Equal segment length: {segmentLength:F3}s");
Func<int, ISegmentProcessor> processorFactory;
if (crop != null) if (crop != null)
{ {
LogInfo("Starting multi-threaded face tracking crop and splitting..."); processorFactory = i =>
RunMultiThreadedCrop(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough, crop.Value.width, crop.Value.height, debug, detect); {
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 else
{ {
LogInfo("Starting multi-threaded ffmpeg splitting..."); processorFactory = i => new SimpleSplitter(i);
RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); }
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."); LogInfo("Done.");
_progressRunning = false;
// Move cursor below progress area
lock (_consoleLock)
{
Console.SetCursorPosition(0, _logLines + 4);
Console.WriteLine();
}
} }
private static void LogInfo(string message)
=> Logger.LogInfo(message);
// ----------------------------- private static void LogWarn(string message)
// Logging + Progress UI => Logger.LogWarn(message);
// -----------------------------
static void Log(string prefix, ConsoleColor color, string msg) private static void LogError(string message)
{ => Logger.LogError(message);
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");
}
}
// ----------------------------- // -----------------------------
// ffprobe // ffprobe
@ -196,7 +147,8 @@ static class Program
// Multi-threaded splitting // Multi-threaded splitting
// ----------------------------- // -----------------------------
static void RunMultiThreadedSplit( static async Task RunMultiThreaded(
Func<int, ISegmentProcessor> processorFactory,
string inputFile, string inputFile,
string outputFolder, string outputFolder,
string mask, string mask,
@ -216,52 +168,32 @@ static class Program
}) })
.ToList(); .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); var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
using var sem = new SemaphoreSlim(maxDegree);
var tasks = new List<Task>();
Parallel.ForEach( foreach (var job in jobs)
jobs, {
new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, await sem.WaitAsync();
job =>
tasks.Add(Task.Run(async () =>
{ {
var outputFile = BuildOutputFileName(outputFolder, mask, job.Index); try
RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough); {
Interlocked.Increment(ref completed); await ProcessSegment(processorFactory, inputFile, outputFolder, mask, passthrough, job.Index, job.Start, job.Length);
}); }
finally
{
sem.Release();
}
}));
}
sw.Stop(); await Task.WhenAll(tasks);
_progressRunning = false;
progressThread.Join();
DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001));
} }
static void RunSingleThreadedSplit( static async Task RunSingleThreaded(
Func<int, ISegmentProcessor> processorFactory,
string inputFile, string inputFile,
string outputFolder, string outputFolder,
string mask, string mask,
@ -271,157 +203,38 @@ static class Program
string[] passthrough) string[] passthrough)
{ {
var jobs = Enumerable.Range(0, segments) var jobs = Enumerable.Range(0, segments)
.Select(i => new .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; Index = i,
var processedSeconds = completed * segmentLength; Start = i * segmentLength,
var speed = sw.Elapsed.TotalSeconds > 0 Length = (i == segments - 1)
? processedSeconds / sw.Elapsed.TotalSeconds ? Math.Max(0.1, totalDuration - i * segmentLength)
: 0; : 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) foreach (var job in jobs)
{ {
var outputFile = BuildOutputFileName(outputFolder, mask, job.Index); await ProcessSegment(processorFactory, inputFile, outputFolder, mask, passthrough, job.Index, job.Start, job.Length);
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));
} }
// ----------------------------- private static async Task ProcessSegment(Func<int, ISegmentProcessor> processorFactory, string inputFile, string outputFolder, string mask, string[] passthrough, int index, double start, double length)
// 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)
{ {
var tracker = new TrackingSplitter(Log, DrawProgress); var outputFile = BuildOutputFileName(outputFolder, mask, index);
var processor = processorFactory(index);
var jobs = Enumerable.Range(0, segments) try
.Select(i => new
{ {
Index = i, await processor.ProcessSegment(inputFile, outputFile, start, length, passthrough);
Start = i * segmentLength, }
Length = (i == segments - 1) finally
? 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) if (processor is IDisposable disposable)
{ disposable.Dispose();
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);
} }
static string BuildOutputFileName(string folder, string mask, int index) static string BuildOutputFileName(string folder, string mask, int index)
{ {
string fileName; string fileName;
@ -445,24 +258,4 @@ static class Program
return Path.Combine(folder, fileName); 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();
}
} }