From e986964f0002d251e8d949534a2a2672e4823ccf Mon Sep 17 00:00:00 2001 From: unclshura Date: Sun, 5 Jul 2026 12:17:27 +0100 Subject: [PATCH] Separated pass-through and rotating splitters. Rotation fixed. --- Splitter-UI/Services/BufferPool.cs | 5 +- Splitter-UI/Views/PreviewPane.axaml | 8 +- splitter-cli/JobProcessor.cs | 8 +- splitter-cli/PassthroughSplitter.cs | 103 ++++++++++ splitter-cli/RotatingSplitter.cs | 142 ++++++++++++++ splitter-cli/SimpleSplitter.cs | 279 ---------------------------- splitter-cli/TrackingSplitter.cs | 2 +- 7 files changed, 260 insertions(+), 287 deletions(-) create mode 100644 splitter-cli/PassthroughSplitter.cs create mode 100644 splitter-cli/RotatingSplitter.cs delete mode 100644 splitter-cli/SimpleSplitter.cs diff --git a/Splitter-UI/Services/BufferPool.cs b/Splitter-UI/Services/BufferPool.cs index a2b2c57..3f75e71 100644 --- a/Splitter-UI/Services/BufferPool.cs +++ b/Splitter-UI/Services/BufferPool.cs @@ -2,7 +2,6 @@ public sealed class BufferPool : IBufferPool { - private readonly int _capacity; public sealed class Entry { @@ -18,10 +17,14 @@ public sealed class BufferPool : IBufferPool Bgr = new byte[w * h * 3]; Bgra = new byte[w * h * 4]; } + + override public string ToString() => $"Entry({Width}x{Height})"; } private readonly Dictionary<(int w, int h), LinkedListNode> _map; private readonly LinkedList _lru; + private readonly int _capacity; + private readonly Lock _lock = new(); public BufferPool() { diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index b52fef0..5afacc2 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -8,7 +8,7 @@ x:DataType="vm:PreviewPaneViewModel"> - + - + + diff --git a/splitter-cli/JobProcessor.cs b/splitter-cli/JobProcessor.cs index 0f4bd8d..a5c5e2e 100644 --- a/splitter-cli/JobProcessor.cs +++ b/splitter-cli/JobProcessor.cs @@ -57,7 +57,11 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso return []; Func processorFactory; - if (job.Crop != null) + if (job.Rotate != null && job.Rotate != 0) + { + processorFactory = i => new RotatingSplitter(i, _logger); + } + else if (job.Crop != null && job.Detect != null && job.Detect != "none") { processorFactory = i => { @@ -75,7 +79,7 @@ public class JobProcessor(ILogger logger) : LoggingBase(logger, 0), IJobProcesso } else { - processorFactory = i => new SimpleSplitter(i, _logger); + processorFactory = i => new PassthroughSplitter(i, _logger); } var segmentsToUse = predefinedSegments; diff --git a/splitter-cli/PassthroughSplitter.cs b/splitter-cli/PassthroughSplitter.cs new file mode 100644 index 0000000..d3d96f9 --- /dev/null +++ b/splitter-cli/PassthroughSplitter.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Globalization; + +namespace splitter; + +public sealed class PassthroughSplitter : LoggingBase, ISegmentProcessor +{ + private sealed class State : IFrameProcessingState + { + public Process? EncodeProcess { get; set; } + + public string InputFile { get; } + public string OutputFile { get; } + public double Start { get; } + public double Length { get; } + public string[] Passthrough { get; } + + public State(SingleTask job) + { + InputFile = job.Job.InputFile; + OutputFile = job.OutputFileName; + Start = job.SegmentStart; + Length = job.SegmentLength; + Passthrough = job.Job.Passthrough; + } + } + + public PassthroughSplitter(int segmentNo, ILogger logger) + : base(logger, segmentNo) + { + } + + public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token) + { + var state = new State(job); + state.EncodeProcess = StartEncode(job); + return state; + } + + public FrameProcessingResult GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token) + { + return new FrameProcessingResult(null, [], null); + } + + public void FinishSegment(IFrameProcessingState processorState) + { + var state = (State)processorState; + + try + { + if (state.EncodeProcess != null && !state.EncodeProcess.HasExited) + state.EncodeProcess.WaitForExit(); + } + catch { } + } + + public async Task ProcessSegment( + SingleTask job, + Action? onFrameProcessed, + CancellationToken token) + { + var state = (State)InitSegment(job, token); + + var p = state.EncodeProcess; + if (p != null) + await p.WaitForExitAsync(token); + + FinishSegment(state); + + ClearProgress(job.OutputFileName); + + if (p != null && p.ExitCode != 0) + LogError($"Segment {job.OutputFileName} FFmpeg passthrough failed"); + else + LogInfo($"Segment {job.OutputFileName} passthrough completed"); + } + + private Process StartEncode(SingleTask job) + { + var inputFile = job.Job.InputFile; + var outputFile = job.OutputFileName; + var start = job.SegmentStart; + var length = job.SegmentLength; + + var args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + + $"-i \"{inputFile}\" " + + $"-t {length.ToString(CultureInfo.InvariantCulture)} " + + $"-c copy {string.Join(" ", job.Job.Passthrough)} " + + $"\"{outputFile}\" -y"; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg passthrough."); + } +} diff --git a/splitter-cli/RotatingSplitter.cs b/splitter-cli/RotatingSplitter.cs new file mode 100644 index 0000000..04422f6 --- /dev/null +++ b/splitter-cli/RotatingSplitter.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Globalization; +using System.Xml.Linq; +using Spectre.Console; + +namespace splitter; + +public sealed class RotatingSplitter : LoggingBase, ISegmentProcessor +{ + private sealed class State : IFrameProcessingState + { + public Process? EncodeProcess { get; set; } + + public string InputFile { get; } + public string OutputFile { get; } + public double Start { get; } + public double Length { get; } + public int Rotate { get; } + public VideoInfo Info { get; } + public string[] Passthrough { get; } + + public State(SingleTask job) + { + InputFile = job.Job.InputFile; + OutputFile = job.OutputFileName; + Start = job.SegmentStart; + Length = job.SegmentLength; + Rotate = job.Job.Rotate ?? 0; + Info = job.Info; + Passthrough = job.Job.Passthrough; + } + } + + public RotatingSplitter(int segmentNo, ILogger logger) + : base(logger, segmentNo) + { + } + + public IFrameProcessingState InitSegment(SingleTask job, CancellationToken token) + { + var state = new State(job); + state.EncodeProcess = StartEncode(job, state.Info, state.Rotate); + return state; + } + + public FrameProcessingResult GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token) + { + return new FrameProcessingResult(null, [], null); + } + + public void FinishSegment(IFrameProcessingState processorState) + { + var state = (State)processorState; + + try + { + if (state.EncodeProcess != null && !state.EncodeProcess.HasExited) + state.EncodeProcess.WaitForExit(); + } + catch { } + } + + public async Task ProcessSegment( + SingleTask job, + Action? onFrameProcessed, + CancellationToken token) + { + DrawProgress(Path.GetFileName(job.OutputFileName), 0, TimeSpan.FromSeconds(10), 60); + + var state = (State)InitSegment(job, token); + + var p = state.EncodeProcess; + if (p != null) + await p.WaitForExitAsync(token); + + FinishSegment(state); + + ClearProgress(job.OutputFileName); + + if (p != null && p.ExitCode != 0) + LogError($"Segment {job.OutputFileName} FFmpeg rotation failed"); + else + LogInfo($"Segment {job.OutputFileName} rotation completed"); + } + + private Process StartEncode(SingleTask job, VideoInfo info, int rotate) + { + var inputFile = job.Job.InputFile; + var outputFile = job.OutputFileName; + var start = job.SegmentStart; + var length = job.SegmentLength; + + var rotation = GetRotationFilter(rotate); + + // Rotation only. No SAR/DAR manipulation. + var vfArg = $"-vf \"{rotation}\" "; + + var args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + + $"-i \"{inputFile}\" " + + $"-t {length.ToString(CultureInfo.InvariantCulture)} " + + vfArg + + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + + "-c:a copy " + + $"{string.Join(" ", job.Job.Passthrough)} " + + $"\"{outputFile}\" -y"; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var p = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode."); + + // Drain stderr and log + _ = Task.Run(() => + { + try + { + string? line; + while ((line = p.StandardError.ReadLine()) != null) + LogInfo(line); + } + catch { } + }); + + return p; + } + + private static string GetRotationFilter(int degrees) => + degrees switch + { + 90 => "transpose=1", + 180 => "rotate=PI", + 270 => "transpose=2", + _ => "transpose=1" + }; +} diff --git a/splitter-cli/SimpleSplitter.cs b/splitter-cli/SimpleSplitter.cs deleted file mode 100644 index 563a315..0000000 --- a/splitter-cli/SimpleSplitter.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System.Diagnostics; -using System.Globalization; - -namespace splitter; - -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 FrameProcessingResult GetNextProcessedFrame(IFrameProcessingState processorState, CancellationToken token) - { - var state = (State)processorState; - - if (state.DecodeStdout == null) - return new FrameProcessingResult(null, [], 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 new FrameProcessingResult(null, [], null); - - var mat = new Mat(h, w, MatType.CV_8UC3); - System.Runtime.InteropServices.Marshal.Copy(buffer, 0, mat.Data, bytes); - - return new FrameProcessingResult(mat, [], null); - } - - // ============================================================ - // 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, Action? onFrameProcessed, 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 res = GetNextProcessedFrame(state, token); - var frame = res.Image; - 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); - - onFrameProcessed?.Invoke(res); - - 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; - var start = job.SegmentStart; - var length = job.SegmentLength; - - var rotation = GetRotationFilter(job.Job.Rotate); - - string args; - - if (rotation == null) - { - args = - $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + - $"-i \"{inputFile}\" " + - $"-t {length.ToString(CultureInfo.InvariantCulture)} " + - $"-c copy {string.Join(" ", job.Job.Passthrough)} " + - $"\"{outputFile}\" -y"; - } - else - { - var sarArg = ""; - var darArg = ""; - - var sar = job.Info.SampleAspectRatio; - if (sar != null) - { - var sarNum = Convert.ToInt64(job.Info.Sar.X); - var sarDen = Convert.ToInt64(job.Info.Sar.Y); - - var w = job.Info.Width; - var h = job.Info.Height; - - if (job.Job.Rotate == 90 || job.Job.Rotate == 270) - (w, h) = (h, w); - - var darNum = w * sarNum; - var darDen = h * sarDen; - - 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; - - sarArg = $"-vf \"{rotation},setsar={sarNum}:{sarDen}\" "; - darArg = $"-aspect {darNum}:{darDen} "; - } - else - sarArg = $"-vf \"{rotation}\" "; - - args = - $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + - $"-i \"{inputFile}\" " + - $"-t {length.ToString(CultureInfo.InvariantCulture)} " + - sarArg + darArg + - "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + - "-c:a copy " + - $"{string.Join(" ", job.Job.Passthrough)} " + - $"\"{outputFile}\" -y"; - } - - var psi = new ProcessStartInfo - { - FileName = "ffmpeg", - Arguments = args, - RedirectStandardInput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - return Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg encode."); - } - - private string? GetRotationFilter(int? degrees) => - degrees switch - { - 90 => "transpose=1", - 180 => "rotate=PI", - 270 => "transpose=2", - _ => null - }; -} diff --git a/splitter-cli/TrackingSplitter.cs b/splitter-cli/TrackingSplitter.cs index 6f5ebfe..3f44f17 100644 --- a/splitter-cli/TrackingSplitter.cs +++ b/splitter-cli/TrackingSplitter.cs @@ -219,7 +219,7 @@ public sealed class TrackingSplitter : LoggingBase, ISegmentProcessor // INTERNAL HELPERS // ============================================================ - private object CreateFrameState(SingleTask job) + private FrameProcessingState CreateFrameState(SingleTask job) { var w = job.Info.Width; var h = job.Info.Height;