diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 7f656c6..f1e05e6 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -8,8 +8,6 @@ using CommunityToolkit.Mvvm.Input; namespace Splitter_UI.ViewModels; -public record Segment(double Start, double End); - public partial class JobViewModel : ObservableObject { private SingleJob Job { get; } diff --git a/Splitter-UI/ViewModels/MainViewModel.cs b/Splitter-UI/ViewModels/MainViewModel.cs index d22d857..9c53ad7 100644 --- a/Splitter-UI/ViewModels/MainViewModel.cs +++ b/Splitter-UI/ViewModels/MainViewModel.cs @@ -68,7 +68,7 @@ public partial class MainViewModel : ViewModelBase foreach (var file in files) { - var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, _cancellationTokenSource.Token); + var fileJobs = await _processor.GenerateJobs(file.GetJob(), false, file.Segments, _cancellationTokenSource.Token); jobs.AddRange(fileJobs); } diff --git a/Splitter-UI/Views/PreviewCanvas.cs b/Splitter-UI/Views/PreviewCanvas.cs index d8234ee..e8a2c0d 100644 --- a/Splitter-UI/Views/PreviewCanvas.cs +++ b/Splitter-UI/Views/PreviewCanvas.cs @@ -78,8 +78,8 @@ public sealed class PreviewCanvas : Control public PreviewCanvas() { - PointerPressed += OnPointerPressed; - PointerMoved += OnPointerMoved; + PointerPressed += OnPointerPressed; + PointerMoved += OnPointerMoved; PointerReleased += OnPointerReleased; } diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index abeaea6..440cd5c 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -36,20 +36,7 @@ VerticalAlignment="Center" HorizontalAlignment="Center" /> - diff --git a/splitter-cli/DebugOverlay.cs b/splitter-cli/DebugOverlay.cs new file mode 100644 index 0000000..4296ea5 --- /dev/null +++ b/splitter-cli/DebugOverlay.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace splitter; + +public static class DebugOverlay +{ + public static void DrawDebug( + Mat frame, + List objects, + CameraController camera, + KalmanTracker kalman) + { + if (camera.ObjectBox.HasValue) + { + var fb = camera.ObjectBox.Value; + Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2); + } + + Cv2.Circle(frame, + new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y), + 6, Scalar.LimeGreen, -1); + + Cv2.Rectangle(frame, camera.Roi, + camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3); + + DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White); + DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White); + DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White); + DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White); + } + + public static void DrawText(Mat img, string text, int x, int y, Scalar color) + { + Cv2.PutText(img, text, new Point(x, y), + HersheyFonts.HersheySimplex, 0.6, color, 2); + } +} diff --git a/splitter-cli/IJobProcessor.cs b/splitter-cli/IJobProcessor.cs index 8fc7e3c..7f20b4e 100644 --- a/splitter-cli/IJobProcessor.cs +++ b/splitter-cli/IJobProcessor.cs @@ -2,6 +2,6 @@ public interface IJobProcessor { - Task> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token); + Task> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection predefinedSegments, CancellationToken token); Task ProcessJobs(List tasks, bool singleThreaded, CancellationToken token); } \ No newline at end of file diff --git a/splitter-cli/JobProcessor.cs b/splitter-cli/JobProcessor.cs index c44ad1e..7347dac 100644 --- a/splitter-cli/JobProcessor.cs +++ b/splitter-cli/JobProcessor.cs @@ -5,7 +5,7 @@ namespace splitter; public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcessor { - public async Task> GenerateJobs(SingleJob job, bool estimateOnly, CancellationToken token) + public async Task> GenerateJobs(SingleJob job, bool estimateOnly, IReadOnlyCollection predefinedSegments, CancellationToken token) { var baseName = Path.GetFileNameWithoutExtension(job.InputFile); @@ -78,24 +78,31 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso processorFactory = i => new SimpleSplitter(i, _logger); } - var jobs = Enumerable.Range(0, segments) - .Select(i => new SingleTask - ( - Job : job, - Info: info, - OutputFileName : BuildOutputFileName(job, i), - SegmentIndex : i, - TotalSegments : segments, - SegmentStart : i * segmentLength, - SegmentLength : (i == segments - 1) - ? Math.Max(0.1, info.Duration - i * segmentLength) - : segmentLength, - ProcessorFactory : processorFactory - ) - ) - .ToList(); + var segmentsToUse = predefinedSegments; - return jobs; + if (predefinedSegments.Count == 0) + { + segmentsToUse = Enumerable.Range(0, segments).Select(i => new Segment + ( + Start: i * segmentLength, + End : (i == segments - 1) + ? Math.Max(0.1, info.Duration) + : (i + 1) * segmentLength + )).ToList(); + } + + return segmentsToUse.Select((s, i) => new SingleTask + ( + Job : job, + Info : info, + OutputFileName : BuildOutputFileName(job, i), + SegmentIndex : i, + TotalSegments : predefinedSegments.Count, + SegmentStart : s.Start, + SegmentLength : s.End - s.Start, + ProcessorFactory: processorFactory + ) + ).ToList(); } public async Task ProcessJobs(List tasks, bool singleThreaded, CancellationToken token) diff --git a/splitter-cli/SimpleSplitter.cs b/splitter-cli/SimpleSplitter.cs index 94939a1..056a5f9 100644 --- a/splitter-cli/SimpleSplitter.cs +++ b/splitter-cli/SimpleSplitter.cs @@ -3,9 +3,189 @@ using System.Globalization; namespace splitter; -public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor +public sealed class SimpleSplitter : LoggingBase, ISegmentProcessor { + // ------------------------------------------------------------ + // Internal state (opaque to caller) + // ------------------------------------------------------------ + + private sealed class State : IFrameProcessingState + { + public Process? DecodeProcess { get; set; } + public Stream? DecodeStdout { get; set; } + + public string InputFile { get; } + public double Start { get; } + public double Length { get; } + public int? Rotate { get; } + public string[] Passthrough { get; } + public VideoInfo Info { get; } + public bool PlainText { get; } + + public State(SingleTask job) + { + InputFile = job.Job.InputFile; + Start = job.SegmentStart; + Length = job.SegmentLength; + Rotate = job.Job.Rotate; + Passthrough = job.Job.Passthrough; + Info = job.Info; + PlainText = job.Job.PlainText; + } + } + + public SimpleSplitter(int segmentNo, ILogger logger) + : base(logger, segmentNo) + { + } + + // ============================================================ + // InitSegment + // ============================================================ + + public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token) + { + var state = new State(job); + + var decode = StartDecode(job, token); + state.DecodeProcess = decode; + state.DecodeStdout = decode.StandardOutput.BaseStream; + + return state; + } + + // ============================================================ + // GetNextProcessedFrame + // ============================================================ + + public Mat? GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token) + { + var state = (State)processorState; + + if (state.DecodeStdout == null) + return null; + + // SimpleSplitter does not modify frames; it only copies or rotates. + // For preview, we decode raw frames and return them as-is. + + // Determine expected frame size + var w = state.Info.Width; + var h = state.Info.Height; + var bytes = w * h * 3; + + var buffer = new byte[bytes]; + var read = state.DecodeStdout.Read(buffer, 0, bytes); + if (read != bytes) + return null; + + var mat = new Mat(h, w, MatType.CV_8UC3); + System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.Data, bytes); + + return mat; + } + + // ============================================================ + // FinishSegment + // ============================================================ + + public void FinishSegment(IFrameProcessingState processorState) + { + var state = (State)processorState; + + try + { + if (state.DecodeProcess != null && !state.DecodeProcess.HasExited) + state.DecodeProcess.Kill(entireProcessTree: true); + } + catch { } + + try + { + if (state.DecodeProcess != null && !state.DecodeProcess.HasExited) + state.DecodeProcess.WaitForExit(); + } + catch { } + } + + // ============================================================ + // ProcessSegment (now uses preview API) + // ============================================================ + public async Task ProcessSegment(SingleTask job, CancellationToken token) + { + var state = (State)InitSegment(job, token); + + var encode = StartEncode(job); + using var encodeStdin = encode.StandardInput.BaseStream; + + var name = Path.GetFileNameWithoutExtension(job.OutputFileName); + var sw = Stopwatch.StartNew(); + + while (true) + { + token.ThrowIfCancellationRequested(); + + var frame = GetNextProcessedFrame(state, token); + if (frame == null) + break; + + // Write raw frame to encoder + var bytes = frame.Width * frame.Height * 3; + var buffer = new byte[bytes]; + System.Runtime.InteropServices.Marshal.Copy(frame.Data, buffer, 0, bytes); + encodeStdin.Write(buffer, 0, bytes); + + frame.Dispose(); + } + + encodeStdin.Flush(); + encodeStdin.Close(); + + await encode.WaitForExitAsync(token); + + FinishSegment(state); + + ClearProgress(name); + + if (encode.ExitCode != 0) + LogError($"Segment {name} FFmpeg encoding failed"); + else + LogInfo($"Segment {name} processing completed"); + } + + // ============================================================ + // FFmpeg helpers + // ============================================================ + + private Process StartDecode(SingleTask job, CancellationToken token) + { + var ss = job.SegmentStart.ToString("0.###", CultureInfo.InvariantCulture); + var t = job.SegmentLength.ToString("0.###", CultureInfo.InvariantCulture); + + var rotate = GetRotationFilter(job.Job.Rotate); + var vf = rotate != null ? $"-vf format=bgr24,{rotate}" : "-vf format=bgr24"; + + var args = + $"-i \"{job.Job.InputFile}\" -ss {ss} -t {t} " + + "-an -sn " + + $"{vf} " + + "-f rawvideo -"; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var p = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg decode."); + return p; + } + + private Process StartEncode(SingleTask job) { var inputFile = job.Job.InputFile; var outputFile = job.OutputFileName; @@ -18,7 +198,6 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, if (rotation == null) { - // Copy path: keep original SAR/DAR exactly as in source args = $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + $"-i \"{inputFile}\" " + @@ -31,33 +210,27 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, var sarArg = ""; var darArg = ""; - var sar = job.Info.SampleAspectRatio; // e.g. "4:3" + var sar = job.Info.SampleAspectRatio; if (sar != null) { - // Rotation path: must re-encode and recompute DAR - var sarNum = Convert.ToInt64(job.Info.Sar.X); var sarDen = Convert.ToInt64(job.Info.Sar.Y); - // After rotation, width/height swap var w = job.Info.Width; var h = job.Info.Height; if (job.Job.Rotate == 90 || job.Job.Rotate == 270) - { (w, h) = (h, w); - } - // Compute DAR = (w * sarNum) : (h * sarDen) var darNum = w * sarNum; var darDen = h * sarDen; - // Reduce fraction long Gcd(long a, long b) { while (b != 0) (a, b) = (b, a % b); return a; } + var g = Gcd(darNum, darDen); darNum /= g; darDen /= g; @@ -78,32 +251,21 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, $"{string.Join(" ", job.Job.Passthrough)} " + $"\"{outputFile}\" -y"; } + var psi = new ProcessStartInfo { FileName = "ffmpeg", Arguments = args, + RedirectStandardInput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; - using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg."); - - var name = Path.GetFileNameWithoutExtension(outputFile); - await ShowFFMpegProgress(length, proc, name, token); - - await proc.WaitForExitAsync(token); - - ClearProgress(name); - - if (proc.ExitCode != 0) - LogError($"Segment {name} FFmpeg encoding failed"); - else - LogInfo($"Segment {name} processing completed"); + return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode."); } - - string? GetRotationFilter(int? degrees) => + private string? GetRotationFilter(int? degrees) => degrees switch { 90 => "transpose=1", @@ -111,80 +273,4 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, 270 => "transpose=2", _ => null }; - - private static long Gcd(long a, long b) - { - a = Math.Abs(a); - b = Math.Abs(b); - - while (b != 0) - { - var t = b; - b = a % b; - a = t; - } - - return a; - } - - private async Task ShowFFMpegProgress(double length, Process proc, string name, CancellationToken token) - { - var sw = Stopwatch.StartNew(); - - string? line; - while ((line = await proc.StandardError.ReadLineAsync(token)) != 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(name, progress, eta, speed); - } - } - - private static string? ExtractTimestamp(string line, int startIndex) - { - // FFmpeg formats: HH:MM:SS.xx - // We read until whitespace - var 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/splitter-cli/SingleJob.cs b/splitter-cli/SingleJob.cs index 9762e9c..90d7260 100644 --- a/splitter-cli/SingleJob.cs +++ b/splitter-cli/SingleJob.cs @@ -2,6 +2,8 @@ namespace splitter; +public record Segment(double Start, double End); + public class SingleJob { /// diff --git a/splitter-cli/TrackingSplitter.cs b/splitter-cli/TrackingSplitter.cs index ceea1a7..871ab36 100644 --- a/splitter-cli/TrackingSplitter.cs +++ b/splitter-cli/TrackingSplitter.cs @@ -4,12 +4,60 @@ using System.Runtime.InteropServices; namespace splitter; -public class TrackingSplitter : LoggingBase, ISegmentProcessor +public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor { - private readonly IObjectTracker _tracker; + private readonly IObjectTracker _tracker; + + // ------------------------------------------------------------ + // Internal state (never exposed) + // ------------------------------------------------------------ + + private sealed class FrameProcessingState : IFrameProcessingState + { + public SingleTask Job { get; } + public KalmanTracker Kalman { get; } + public CameraController Camera { get; } + + public Mat FrameMat { get; } + public Mat OutMat { get; } + public byte[] InBuffer { get; } + public byte[] OutBuffer { get; } + + public IVideoEnhancer? Enhancer { get; } + + public int InBytes { get; } + public int OutBytes { get; } + + public Process? DecodeProcess { get; set; } + public Stream? DecodeStdout { get; set; } + + public FrameProcessingState( + SingleTask job, + KalmanTracker kalman, + CameraController camera, + Mat frameMat, + Mat outMat, + byte[] inBuffer, + byte[] outBuffer, + IVideoEnhancer? enhancer, + int inBytes, + int outBytes) + { + Job = job; + Kalman = kalman; + Camera = camera; + FrameMat = frameMat; + OutMat = outMat; + InBuffer = inBuffer; + OutBuffer = outBuffer; + Enhancer = enhancer; + InBytes = inBytes; + OutBytes = outBytes; + } + } public TrackingSplitter( - int progressLine, + int progressLine, IObjectTracker tracker, SingleJob cmd, ILogger logger) @@ -18,177 +66,142 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor _tracker = tracker; } + // ============================================================ + // PUBLIC PREVIEW API + // ============================================================ + + // ------------------------------------------------------------ + // InitSegment + // ------------------------------------------------------------ + + public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token) + { + var state = (FrameProcessingState)CreateFrameState(job); + + if (state.Enhancer != null) + state.Enhancer.InitializeAsync( + state.OutMat.Width, + state.OutMat.Height, + 5, + token).Wait(token); + + var decode = StartFfmpegDecode( + job.Job.InputFile, + job.SegmentStart, + job.SegmentLength, + job.Job.Rotate, + job.Job.PlainText, + token).Result; + + state.DecodeProcess = decode; + state.DecodeStdout = decode.StandardOutput.BaseStream; + + return state; + } + + // ------------------------------------------------------------ + // GetNextProcessedFrame + // ------------------------------------------------------------ + + public Mat? GetNextProcessedFrame( + IFrameProcessingState processorState, + CancellationToken token) + { + var state = (FrameProcessingState)processorState; + + if (state.DecodeStdout == null) + return null; + + if (!TryReadNextFrame(state.DecodeStdout, state, token)) + return null; + + return ProcessFrame(state.FrameMat, state, state.Job, token); + } + + // ------------------------------------------------------------ + // FinishSegment + // ------------------------------------------------------------ + + public void FinishSegment(IFrameProcessingState processorState) + { + var state = (FrameProcessingState)processorState; + + try + { + if (state.DecodeProcess != null && !state.DecodeProcess.HasExited) + state.DecodeProcess.Kill(entireProcessTree: true); + } + catch { } + + try + { + if (state.DecodeProcess != null && !state.DecodeProcess.HasExited) + state.DecodeProcess.WaitForExit(); + } + catch { } + + if (state.Enhancer is IAsyncDisposable ad) + ad.DisposeAsync().AsTask().Wait(); + else if (state.Enhancer is IDisposable d) + d.Dispose(); + } + + // ============================================================ + // PROCESSSEGMENT (full pipeline) + // ============================================================ + public async Task ProcessSegment(SingleTask job, CancellationToken token) { - var inputFile = job.Job.InputFile; - var outputFile = job.OutputFileName; - var start = job.SegmentStart; - var length = job.SegmentLength; - var videoWidth = job.Info.Width; - var videoHeight = job.Info.Height; - var fps = job.Info.Fps; - var bitrate = job.Info.Bitrate; - var ffmpegPassthroughParameters = job.Job.Passthrough; + var name = Path.GetFileNameWithoutExtension(job.OutputFileName); + var fps = job.Info.Fps; - var name = Path.GetFileNameWithoutExtension(outputFile); - - if (videoWidth <= 0 || videoHeight <= 0 || fps <= 0) - { - LogError($"{name}: ffprobe failed to get metadata"); - return; - } - - if (job.Job.Crop == null) - { - LogError($"{name}: Crop parameters are required"); - return; - } - - // Processing size (what you crop / feed into enhancer) - var procWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width; - var procHeight = job.Job.Debug ? videoHeight : job.Job.Crop.Value.height; - - IVideoEnhancer? enhancer = null; - - const int window = 5; - - if (job.Job.Enhance) - { - enhancer = new RealBasicVsr2xDmlEnhancer(); - await enhancer.InitializeAsync(procWidth, procHeight, window, token); - } - - // Encoding size (what FFmpeg encoder expects) - var encWidth = enhancer != null ? procWidth * enhancer.ResolutionMultiplier : procWidth; - var encHeight = enhancer != null ? procHeight * enhancer.ResolutionMultiplier : procHeight; - - LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] proc={procWidth}x{procHeight} enc={encWidth}x{encHeight}"); - - var decode = await StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText, token); - using var decodeStdout = decode.StandardOutput.BaseStream; + var state = (FrameProcessingState)InitSegment(job, token); var encode = await StartFfmpegEncode( - inputFile, - outputFile, - start, - length, - encWidth, - encHeight, + job.Job.InputFile, + job.OutputFileName, + job.SegmentStart, + job.SegmentLength, + state.OutMat.Width, + state.OutMat.Height, job.Info, - ffmpegPassthroughParameters, + job.Job.Passthrough, job.Job.PlainText, token); using var encodeStdin = encode.StandardInput.BaseStream; - // Input: always full frame - var inBytes = videoWidth * videoHeight * 3; + var totalFrames = (int)Math.Round(job.SegmentLength * fps); + var frameIndex = 0; + var startTime = DateTime.UtcNow; - // Output: encoded frame size (may be 4x if enhancement enabled) - var outBytes = encWidth * encHeight * 3; - - var inBuffer = new byte[inBytes]; - var outBuffer = new byte[outBytes]; - - using var frameMat = new Mat(videoHeight, videoWidth, MatType.CV_8UC3); - - // outMat is processing size (crop), not necessarily encoding size - using var outMat = new Mat(procHeight, procWidth, MatType.CV_8UC3); - - var kalman = new KalmanTracker(); - var camera = new CameraController( - videoWidth, - videoHeight, - job.Job.Crop.Value.width, - job.Job.Crop.Value.height, - kalman, - job.Job); - - try + while (frameIndex < totalFrames) { - var startTime = DateTime.UtcNow; - var totalFrames = (int)Math.Round(length * fps); - var frameIndex = 0; - - var enhancedOutput = new Mat[window]; - //totalFrames = 10; - while (frameIndex < totalFrames) - { - token.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); - frameIndex++; + var frame = GetNextProcessedFrame(state, token); + if (frame == null) + break; - var read = await ReadExact(decodeStdout, inBuffer, 0, inBytes, token); - if (read != inBytes) - break; + frameIndex++; - Marshal.Copy(inBuffer, 0, frameMat.Data, inBytes); + EncodeFrame(frame, state, encodeStdin); - var (objects, primary) = _tracker.SelectTrackedObject(job, frameMat, kalman.LastMeasurement); + var elapsed = DateTime.UtcNow - startTime; + var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0; + var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0; - camera.Update(primary); - var roi = camera.Roi; + var remainingFrames = Math.Max(totalFrames - frameIndex, 0); + var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0; + var eta = TimeSpan.FromSeconds(etaSeconds); - if (job.Job.Debug) - { - DrawDebug(frameMat, objects, camera, kalman); - frameMat.CopyTo(outMat); // outMat: procWidth x procHeight == full frame in debug - } - else - { - using var cropped = new Mat(frameMat, roi); - cropped.CopyTo(outMat); // outMat: procWidth x procHeight == crop - } - - Mat frameToWrite = outMat; - - if (enhancer != null) - { - if (enhancer.TryProcessFrame(outMat, out var enhanced, token)) - frameToWrite = enhanced; // enhanced: encWidth x encHeight - else - continue; - } - - Marshal.Copy(frameToWrite.Data, outBuffer, 0, outBytes); - encodeStdin.Write(outBuffer, 0, outBytes); - - var elapsed = DateTime.UtcNow - startTime; - var progress = totalFrames > 0 ? (double)frameIndex / totalFrames : 0.0; - var speed = elapsed.TotalSeconds > 0 ? (frameIndex / elapsed.TotalSeconds) / fps : 0.0; - var remainingFrames = Math.Max(totalFrames - frameIndex, 0); - var etaSeconds = speed > 0 ? remainingFrames / speed : 0.0; - var eta = TimeSpan.FromSeconds(etaSeconds); - - DrawProgress(name, progress, eta, speed); - } - - if (enhancer != null) - { - int count = enhancer.Flush(enhancedOutput, token); - for (int i = 0; i < count; i++) - { - var mat = enhancedOutput[i]; // encWidth x encHeight - Marshal.Copy(mat.Data, outBuffer, 0, outBytes); - encodeStdin.Write(outBuffer, 0, outBytes); - } - } - - encodeStdin.Flush(); - encodeStdin.Close(); - - await encode.WaitForExitAsync(); - } - finally - { - if (enhancer is IAsyncDisposable asyncDisp) - await asyncDisp.DisposeAsync(); - else if (enhancer is IDisposable disp) - disp?.Dispose(); + DrawProgress(name, progress, eta, speed); } - try { if (!decode.HasExited) decode.Kill(entireProcessTree: true); } catch { } - try { if (!decode.HasExited) await decode.WaitForExitAsync(); } catch { } + encodeStdin.Flush(); + encodeStdin.Close(); + + await encode.WaitForExitAsync(); ClearProgress(name); @@ -196,12 +209,123 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor LogError($"{name}: FFmpeg encoding failed"); else LogInfo($"{name}: Segment processing completed"); + + FinishSegment(state); } + // ============================================================ + // INTERNAL HELPERS + // ============================================================ - // ---------- FFmpeg decode / encode ---------- + private object CreateFrameState(SingleTask job) + { + var w = job.Info.Width; + var h = job.Info.Height; + var cw = job.Job.Debug ? w : job.Job.Crop!.Value.width; + var ch = job.Job.Debug ? h : job.Job.Crop!.Value.height; - private async Task StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText, CancellationToken token) + var kalman = new KalmanTracker(); + var camera = new CameraController(w, h, cw, ch, kalman, job.Job); + + var frameMat = new Mat(h, w, MatType.CV_8UC3); + var outMat = new Mat(ch, cw, MatType.CV_8UC3); + + var inBytes = w * h * 3; + var outBytes = cw * ch * 3; + + var inBuffer = new byte[inBytes]; + var outBuffer = new byte[outBytes]; + + IVideoEnhancer? enhancer = job.Job.Enhance + ? new RealBasicVsr2xDmlEnhancer() + : null; + + return new FrameProcessingState( + job, + kalman, + camera, + frameMat, + outMat, + inBuffer, + outBuffer, + enhancer, + inBytes, + outBytes); + } + + private bool TryReadNextFrame( + Stream decodeStdout, + FrameProcessingState state, + CancellationToken token) + { + var read = ReadExact( + decodeStdout, + state.InBuffer, + 0, + state.InBytes, + token).Result; + + if (read != state.InBytes) + return false; + + Marshal.Copy(state.InBuffer, 0, state.FrameMat.Data, state.InBytes); + return true; + } + + private Mat ProcessFrame( + Mat inputFrame, + FrameProcessingState state, + SingleTask job, + CancellationToken token) + { + var (objects, primary) = + _tracker.SelectTrackedObject(job, inputFrame, state.Kalman.LastMeasurement); + + state.Camera.Update(primary); + var roi = state.Camera.Roi; + + if (job.Job.Debug) + { + DebugOverlay.DrawDebug(inputFrame, objects, state.Camera, state.Kalman); + inputFrame.CopyTo(state.OutMat); + } + else + { + using var cropped = new Mat(inputFrame, roi); + cropped.CopyTo(state.OutMat); + } + + if (state.Enhancer != null) + { + if (state.Enhancer.TryProcessFrame(state.OutMat, out var enhanced, token)) + return enhanced; + + return state.OutMat; + } + + return state.OutMat; + } + + private void EncodeFrame( + Mat frame, + FrameProcessingState state, + Stream encodeStdin) + { + Marshal.Copy(frame.Data, state.OutBuffer, 0, state.OutBytes); + encodeStdin.Write(state.OutBuffer, 0, state.OutBytes); + } + + // ------------------------------------------------------------ + // FFmpeg helpers + // ------------------------------------------------------------ + + private async Task StartFfmpegDecode( + string inputFile, + double start, + double length, + int? rotate, + bool plainText, + CancellationToken token) { var ss = start .ToString("0.###", CultureInfo.InvariantCulture); var t = length.ToString("0.###", CultureInfo.InvariantCulture); @@ -209,10 +333,10 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor var rotateStr = GetRorationArg(rotate); var args = - $"-i \"{inputFile}\" -ss {ss} -t {t} " + - "-an -sn " + - $"-vf format=bgr24{rotateStr} " + - "-f rawvideo -"; + $"-i \"{inputFile}\" -ss {ss} -t {t} " + + "-an -sn " + + $"-vf format=bgr24{rotateStr} " + + "-f rawvideo -"; var psi = new ProcessStartInfo { @@ -256,7 +380,6 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor case 270: rotateStr = ",transpose=2"; break; } } - return rotateStr; } @@ -265,7 +388,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor string outputFile, double start, double length, - int width, int height, + int width, + int height, VideoInfo info, string[] passthrough, bool plainText, @@ -275,19 +399,17 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor var fpsStr = info.Fps.ToString("0.###", CultureInfo.InvariantCulture); var ss = start.ToString("0.###", CultureInfo.InvariantCulture); var t = length.ToString("0.###", CultureInfo.InvariantCulture); + var sarArg = !string.IsNullOrWhiteSpace(info.SampleAspectRatio) ? $"-vf setsar={info.SampleAspectRatio} " : ""; - var darArg = ""; - + var darArg = ""; if (info.Sar is { } s) { - // compute DAR from output size and SAR - var darNum = width * s.X; + var darNum = width * s.X; var darDen = height * s.Y; - // clamp to int and reduce var dn = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darNum)); var dd = (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, darDen)); ReduceFraction(ref dn, ref dd); @@ -306,9 +428,6 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor "-c:a copy " + pass + $" \"{outputFile}\""; - // "-c:a aac -b:a 192k " + - - var psi = new ProcessStartInfo { FileName = "ffmpeg", @@ -330,10 +449,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor { string? line; while ((line = await p.StandardError.ReadLineAsync(token)) != null) - { if (plainText) LogInfo($"[ffmpeg-encode] {fileName}: {line}"); - } } catch { } }); @@ -341,8 +458,6 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor return p; } - // ---------- helpers ---------- - private static void ReduceFraction(ref int num, ref int den) { int Gcd(int a, int b) @@ -363,7 +478,13 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor den /= g; } } - private static async Task ReadExact(Stream s, byte[] buffer, int offset, int count, CancellationToken token) + + private static async Task ReadExact( + Stream s, + byte[] buffer, + int offset, + int count, + CancellationToken token) { var total = 0; while (total < count) @@ -376,35 +497,5 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor return total; } - private void DrawDebug( - Mat frame, - List objects, - CameraController camera, - KalmanTracker kalman) - { - if (camera.ObjectBox.HasValue) - { - var fb = camera.ObjectBox.Value; - Cv2.Rectangle(frame, fb, Scalar.LimeGreen, 2); - } - - Cv2.Circle(frame, - new Point((int)camera.SmoothedCenter.X, (int)camera.SmoothedCenter.Y), - 6, Scalar.LimeGreen, -1); - - Cv2.Rectangle(frame, camera.Roi, - camera.ObjectCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3); - - DrawText(frame, $"Faces: {objects.Count}", 20, 40, Scalar.White); - DrawText(frame, $"LostFrames: {camera.LostFrames}", 20, 70, Scalar.White); - DrawText(frame, $"Noise: {kalman.CurrentNoise:F3}", 20, 130, Scalar.White); - DrawText(frame, $"Camera: {camera.CameraCenter.X:F1},{camera.CameraCenter.Y:F1}", 20, 160, Scalar.White); - } - - private static void DrawText(Mat img, string text, int x, int y, Scalar color) - { - Cv2.PutText(img, text, new Point(x, y), - HersheyFonts.HersheySimplex, 0.6, color, 2); - } } diff --git a/splitter-cli/algo/ISegmentProcessor.cs b/splitter-cli/algo/ISegmentProcessor.cs index 98329be..5be9c1f 100644 --- a/splitter-cli/algo/ISegmentProcessor.cs +++ b/splitter-cli/algo/ISegmentProcessor.cs @@ -1,6 +1,14 @@ namespace splitter.algo; +public interface IFrameProcessingState +{ +} + public interface ISegmentProcessor { + IFrameProcessingState InitSegment(SingleTask job, CancellationToken token); + Mat? GetNextProcessedFrame( IFrameProcessingState processorState, CancellationToken token); + void FinishSegment(IFrameProcessingState processorState); + Task ProcessSegment( SingleTask job, CancellationToken token); } diff --git a/splitter-cli/algo/YoloV10ObjectDetector.cs b/splitter-cli/algo/YoloV10ObjectDetector.cs index 621e59f..df2e356 100644 --- a/splitter-cli/algo/YoloV10ObjectDetector.cs +++ b/splitter-cli/algo/YoloV10ObjectDetector.cs @@ -6,26 +6,25 @@ namespace splitter.algo; public sealed class YoloV10ObjectDetector : LoggingBase, IObjectDetector, IDisposable { - private readonly InferenceSession _session; - private readonly string _inputName; - private readonly string _outputName; + private readonly InferenceSession _session; + private readonly string _inputName; + private readonly string _outputName; - private const int _inputWidth = 640; - private const int _inputHeight = 640; - private const float _scoreThreshold = 0.35f; - private const float _nmsThreshold = 0.45f; - private const int _personClassIndex = 0; + private const int _inputWidth = 640; + private const int _inputHeight = 640; + private const float _nmsThreshold = 0.45f; + private const int _personClassIndex = 0; - private readonly Mat _resizeMat = new(); - private readonly Mat _rgbMat = new(); + private readonly Mat _resizeMat = new(); + private readonly Mat _rgbMat = new(); - private readonly float[] _inputBuffer; - private readonly DenseTensor _inputTensor; + private readonly float[] _inputBuffer; + private readonly DenseTensor _inputTensor; private readonly List _inputs = new(1); - private readonly List _detections = new(256); - private readonly List _nmsBuffer = new(256); + private readonly List _detections = new(256); + private readonly List _nmsBuffer = new(256); private readonly List _results = new(64); @@ -41,11 +40,11 @@ public sealed class YoloV10ObjectDetector : LoggingBase, IObjectDetector, IDispo 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; } } @@ -57,13 +56,13 @@ public sealed class YoloV10ObjectDetector : LoggingBase, IObjectDetector, IDispo var basePath = AppDomain.CurrentDomain.BaseDirectory; var modelPath = Path.Combine(basePath, "models", "yolov10m.onnx"); - _session = new InferenceSession(modelPath, options); + _session = new InferenceSession(modelPath, options); - _inputName = _session.InputMetadata.Keys.First(); - _outputName = _session.OutputMetadata.Keys.First(); + _inputName = _session.InputMetadata.Keys.First(); + _outputName = _session.OutputMetadata.Keys.First(); - _inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth]; - _inputTensor = new DenseTensor(_inputBuffer, new[] { 1, 3, _inputHeight, _inputWidth }); + _inputBuffer = new float[1 * 3 * _inputHeight * _inputWidth]; + _inputTensor = new DenseTensor(_inputBuffer, new[] { 1, 3, _inputHeight, _inputWidth }); _inputs.Add(NamedOnnxValue.CreateFromTensor(_inputName, _inputTensor)); } diff --git a/splitter-cli/splitter.cs b/splitter-cli/splitter.cs index 3d0970b..0983813 100644 --- a/splitter-cli/splitter.cs +++ b/splitter-cli/splitter.cs @@ -38,7 +38,7 @@ static partial class Program var allJobs = new List(); foreach ( var job in cmd.Jobs ) { - var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, CancellationToken.None); + var jobs = await processor.GenerateJobs(job, cmd.Master.EstimateOnly, [], CancellationToken.None); allJobs.AddRange(jobs); } diff --git a/splitter-cli/tui/SpectreConsoleLogger.cs b/splitter-cli/tui/SpectreConsoleLogger.cs index a791986..a63530d 100644 --- a/splitter-cli/tui/SpectreConsoleLogger.cs +++ b/splitter-cli/tui/SpectreConsoleLogger.cs @@ -375,7 +375,7 @@ public sealed class SpectreConsoleLogger : ILogger, IDisposable return new Measurement(width, width); } - public IEnumerable Render(RenderOptions options, int maxWidth) + public IEnumerable Render(RenderOptions options, int maxWidth) { var width = Math.Max(1, maxWidth);