From 3fa068e48beea13257b27e6bfbc9b714c21f1a93 Mon Sep 17 00:00:00 2001 From: unclshura Date: Thu, 14 May 2026 09:02:43 +0100 Subject: [PATCH] Source image rotation added. Interfaces simplified. --- CommandLine.cs | 17 ++++++ ISegmentProcessor.cs | 2 +- ProbeVideo.cs | 92 ++++++++++++++++++++++++++++++++ SimpleSplitter.cs | 50 ++++++++++++++--- SingleTask.cs | 12 +++++ TrackingSplitter.cs | 85 ++++++++++++++++------------- splitter.cs | 124 +++++-------------------------------------- 7 files changed, 228 insertions(+), 154 deletions(-) create mode 100644 ProbeVideo.cs create mode 100644 SingleTask.cs diff --git a/CommandLine.cs b/CommandLine.cs index 9063d9a..90a01a6 100644 --- a/CommandLine.cs +++ b/CommandLine.cs @@ -20,6 +20,7 @@ public class SingleJob public bool EstimateOnly { get; set; } public bool ForceFixed { get; set; } public bool SingleThreaded { get; set; } + public int? Rotate { get; set; } public Dictionary Parameters { get; set; } = []; public void Override(ref T member, string name) @@ -100,6 +101,18 @@ public sealed class CommandLine { Master.Detect = arg.Substring("--detect=".Length).ToLowerInvariant(); } + else if (arg =="--rotate") + { + Master.Rotate = 90; + } + else if (arg.StartsWith("--rotate=")) + { + var val = arg.Substring("--rotate=".Length); + if (int.TryParse(val, out var degrees) && (degrees == 90 || degrees == 180 || degrees == 270)) + Master.Rotate = degrees; + else + throw new FormatException($"Invalid --rotate value: {val}"); + } else if (arg.StartsWith("--crop=")) { Master.Crop = ParseCrop(arg.Substring("--crop=".Length)); @@ -172,6 +185,7 @@ public sealed class CommandLine EstimateOnly = Master.EstimateOnly, ForceFixed = Master.ForceFixed, SingleThreaded = Master.SingleThreaded, + Rotate = Master.Rotate, Parameters = new Dictionary(Master.Parameters) }).ToArray(); } @@ -297,6 +311,9 @@ Options: Last segment may be shorter. Default: OFF + --rotate= Rotate video by specified degrees (90, 180, 270). + Useful for videos with incorrect orientation metadata. + --estimate Print calculated segment information and exit. No splitting is performed. diff --git a/ISegmentProcessor.cs b/ISegmentProcessor.cs index 01219b8..9e96a07 100644 --- a/ISegmentProcessor.cs +++ b/ISegmentProcessor.cs @@ -6,5 +6,5 @@ namespace splitter; public interface ISegmentProcessor { - Task ProcessSegment( string inputFile, string outputFile, double start, double length, int videoWidth, int videoHeight, double fps, string[] ffmpegPassthroughParameters); + Task ProcessSegment( SingleTask job ); } diff --git a/ProbeVideo.cs b/ProbeVideo.cs new file mode 100644 index 0000000..3652ed3 --- /dev/null +++ b/ProbeVideo.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace splitter; + +public record VideoInfo( + double Duration, + int Width, + int Height, + double Fps, + double Bitrate +); + +public static class ProbeVideo +{ + public static VideoInfo Probe(string inputFile) + { + var args = + "-v error " + + "-select_streams v:0 " + + "-show_entries format=duration " + + "-show_entries stream=width,height,avg_frame_rate,bit_rate " + + "-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys + $"\"{inputFile}\""; + + var psi = new ProcessStartInfo + { + FileName = "ffprobe", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var p = new Process { StartInfo = psi }; + p.Start(); + + var duration = -1.0; + var width = 0; + var height = 0; + var fps = 0.0; + var bitrate = 0.0; + + while (!p.StandardOutput.EndOfStream) + { + var line = p.StandardOutput.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(line)) + continue; + + if (line.StartsWith("duration=")) + { + var v = line.Substring("duration=".Length); + double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration); + } + else if (line.StartsWith("width=")) + { + var v = line.Substring("width=".Length); + int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width); + } + else if (line.StartsWith("bit_rate=")) + { + var v = line.Substring("bit_rate=".Length); + double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out bitrate); + } + else if (line.StartsWith("height=")) + { + var v = line.Substring("height=".Length); + int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height); + } + else if (line.StartsWith("avg_frame_rate=")) + { + var v = line.Substring("avg_frame_rate=".Length); + var parts = v.Split('/'); + if (parts.Length == 2 && + double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) && + double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) && + den != 0) + { + fps = num / den; + } + } + } + + p.WaitForExit(); + + return new(duration, width, height, fps, bitrate); + } +} diff --git a/SimpleSplitter.cs b/SimpleSplitter.cs index ee071d2..d113b12 100644 --- a/SimpleSplitter.cs +++ b/SimpleSplitter.cs @@ -9,15 +9,41 @@ namespace splitter; public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, segmentNo), ISegmentProcessor { - public async Task ProcessSegment(string inputFile, string outputFile, double start, double length, int videoWidth, int videoHeight, double fps, string[] passthrough) + public async Task ProcessSegment(SingleTask job) { - var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; + string inputFile = job.Job.InputFile; + string outputFile = job.OutputFileName; + double start = job.SegmentStart; + double length = job.SegmentLength; + int videoWidth = job.Info.Width; + int videoHeight = job.Info.Height; + double fps = job.Info.Fps; + string[] ffmpegPassthroughParameters = job.Job.Passthrough; + + var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : ""; - var args = - $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + - $"-i \"{inputFile}\" " + - $"-t {length.ToString(CultureInfo.InvariantCulture)} " + - $"-c copy {pass} \"{outputFile}\" -y"; + string args; + var rotation = GetRotationFilter(job.Job.Rotate); + if (rotation == null) + { + args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + + $"-i \"{inputFile}\" " + + $"-t {length.ToString(CultureInfo.InvariantCulture)} " + + $"-c copy {pass} \"{outputFile}\" -y"; + } + else + { + // Rotation → must re-encode + args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " + + $"-i \"{inputFile}\" " + + $"-t {length.ToString(CultureInfo.InvariantCulture)} " + + $"-vf \"{rotation}\" " + + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + + "-c:a copy " + + $"{pass} \"{outputFile}\" -y"; + } var psi = new ProcessStartInfo { @@ -43,6 +69,16 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger, LogInfo($"Segment {name} processing completed"); } + string? GetRotationFilter(int? degrees) => + degrees switch + { + 90 => "transpose=1", + 180 => "rotate=PI", + 270 => "transpose=2", + _ => null + }; + + private void ShowFFMpegProgress(double length, Process proc, string name) { var sw = Stopwatch.StartNew(); diff --git a/SingleTask.cs b/SingleTask.cs new file mode 100644 index 0000000..770780f --- /dev/null +++ b/SingleTask.cs @@ -0,0 +1,12 @@ +using splitter; + +public record SingleTask( + SingleJob Job, + VideoInfo Info, + string OutputFileName, + int SegmentIndex, + int TotalSegments, + double SegmentStart, + double SegmentLength, + Func ProcessorFactory + ); diff --git a/TrackingSplitter.cs b/TrackingSplitter.cs index caec81f..7ad2cd8 100644 --- a/TrackingSplitter.cs +++ b/TrackingSplitter.cs @@ -11,7 +11,6 @@ namespace splitter; public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable { private readonly IObjectDetector _detector; - private readonly SingleJob _cmd; public TrackingSplitter( int progressLine, @@ -21,7 +20,6 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable : base(logger, progressLine) { _detector = detector; - _cmd = cmd; } public void Dispose() @@ -30,14 +28,18 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable d.Dispose(); } - public async Task ProcessSegment( - string inputFile, - string outputFile, - double start, - double length, - int videoWidth, int videoHeight, double fps, - string[] ffmpegPassthroughParameters) + public async Task ProcessSegment(SingleTask job) { + string inputFile = job.Job.InputFile; + string outputFile = job.OutputFileName; + double start = job.SegmentStart; + double length = job.SegmentLength; + int videoWidth = job.Info.Width; + int videoHeight = job.Info.Height; + double fps = job.Info.Fps; + double bitrate = job.Info.Bitrate; + string[] ffmpegPassthroughParameters = job.Job.Passthrough; + var name = Path.GetFileNameWithoutExtension(outputFile); // 1) Probe source video @@ -47,19 +49,19 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable return; } - if (_cmd.Crop == null) + if (job.Job.Crop == null) { LogError($"{name}: Crop parameters are required"); return; } - var encWidth = _cmd.Debug ? videoWidth : _cmd.Crop.Value.width; - var encHeight = _cmd.Debug ? videoHeight : _cmd.Crop.Value.height; + var encWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width; + var encHeight = job.Job.Debug ? videoHeight : job.Job.Crop.Value.height; LogInfo($"{name}: src={videoWidth}x{videoHeight} @ {fps:F3}fps, seg=[{start:F3},{length:F3}] enc={encWidth}x{encHeight}"); // 2) Start FFmpeg decode (video only → raw BGR24 to stdout) - var decode = StartFfmpegDecode(inputFile, start, length); + var decode = StartFfmpegDecode(inputFile, start, length, job.Job.Rotate, job.Job.PlainText); using var decodeStdout = decode.StandardOutput.BaseStream; // 3) Start FFmpeg encode (video from stdin + audio from original) @@ -71,7 +73,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable encWidth, encHeight, fps, - ffmpegPassthroughParameters); + ffmpegPassthroughParameters, + job.Job.PlainText); using var encodeStdin = encode.StandardInput.BaseStream; @@ -89,10 +92,10 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable var camera = new CameraController( videoWidth, videoHeight, - _cmd.Crop.Value.width, - _cmd.Crop.Value.height, + job.Job.Crop.Value.width, + job.Job.Crop.Value.height, kalman, - _cmd); + job.Job); var startTime = DateTime.UtcNow; var totalFrames = (int)Math.Round(length * fps); @@ -115,7 +118,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable camera.Update(primary); var roi = camera.Roi; - if (_cmd.Debug) + if (job.Job.Debug) { DrawDebug(frameMat, objects, camera, kalman); frameMat.CopyTo(outMat); @@ -165,15 +168,26 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable // ---------- FFmpeg decode / encode ---------- - private Process StartFfmpegDecode(string inputFile, double start, double length) + private Process StartFfmpegDecode(string inputFile, double start, double length, int? rotate, bool plainText) { - var ss = start.ToString("0.###", CultureInfo.InvariantCulture); + var ss = start .ToString("0.###", CultureInfo.InvariantCulture); var t = length.ToString("0.###", CultureInfo.InvariantCulture); + var rotateStr = ""; + if (rotate != null) + { + switch (rotate.Value) + { + case 90: rotateStr = ",transpose=1"; break; + case 180: rotateStr = ",transpose=PI"; break; + case 270: rotateStr = ",transpose=2"; break; + } + } + var args = $"-i \"{inputFile}\" -ss {ss} -t {t} " + "-an -sn " + - "-vf format=bgr24 " + + $"-vf format=bgr24{rotateStr} " + "-f rawvideo -"; var psi = new ProcessStartInfo @@ -191,20 +205,17 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable var fileName = Path.GetFileName(inputFile); - if (_cmd.PlainText) + _ = Task.Run(() => { - _ = Task.Run(() => + try { - try - { - string? line; - while ((line = p.StandardError.ReadLine()) != null) - if (_cmd.PlainText) - LogInfo($"[ffmpeg-decode] {fileName}: {line}"); - } - catch { } - }); - } + string? line; + while ((line = p.StandardError.ReadLine()) != null) + if (plainText) + LogInfo($"[ffmpeg-decode] {fileName}: {line}"); + } + catch { } + }); return p; } @@ -217,7 +228,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable int width, int height, double fps, - string[] passthrough) + string[] passthrough, + bool plainText) { var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture); @@ -230,9 +242,10 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable $"-ss {ss} -i \"{inputFile}\" " + "-map 0:v:0 -map 1:a:0? -shortest " + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + - "-c:a aac -b:a 192k " + + "-c:a copy " + pass + $" \"{outputFile}\""; + // "-c:a aac -b:a 192k " + var psi = new ProcessStartInfo @@ -257,7 +270,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable string? line; while ((line = p.StandardError.ReadLine()) != null) { - if (_cmd.PlainText) + if (plainText) LogInfo($"[ffmpeg-encode] {fileName}: {line}"); } } diff --git a/splitter.cs b/splitter.cs index 176a83f..bbccb12 100644 --- a/splitter.cs +++ b/splitter.cs @@ -5,23 +5,10 @@ using System.Text; using Spectre.Console; using splitter; -static class Program +static partial class Program { private static ILogger _logger = null!; - private record SingleTask( - SingleJob Job, - string OutputFileName, - int SegmentIndex, - int TotalSegments, - double SegmentStart, - double SegmentLength, - int VideoWidth, - int VideoHeight, - double VideoFps, - Func ProcessorFactory - ); - static async Task Main(string[] args) { Task? uiTask = null; @@ -48,6 +35,9 @@ static class Program uiTask = logger.RunAsync(cts.Token); } + if (cmd.Master.EstimateOnly) + LogInfo("=== ESTIMATE MODE ==="); + var allJobs = new List(); foreach ( var job in cmd.Jobs ) { @@ -89,10 +79,9 @@ static class Program Directory.CreateDirectory(job.OutputFolder); job.Mask ??= $"{baseName}_seg%03d.mp4"; - LogInfo($"{baseName}: Reading duration via ffprobe..."); - (double duration, int width, int height, double fps) = ProbeVideo(job.InputFile); - if (duration <= 0) + var info = ProbeVideo.Probe(job.InputFile); + if (info.Duration <= 0) { LogError($"{baseName}: Could not read duration."); return []; @@ -106,25 +95,17 @@ static class Program if (job.ForceFixed) { // Fixed chunk size, last one may be shorter - segments = (int)Math.Ceiling(duration / target); + segments = (int)Math.Ceiling(info.Duration / target); segmentLength = target; } else { // Equalized segments - segments = (int)Math.Ceiling(duration / target); - segmentLength = duration / segments; + segments = (int)Math.Ceiling(info.Duration / target); + segmentLength = info.Duration / segments; } - if (cmd.Master.EstimateOnly) - LogInfo("=== ESTIMATE MODE ==="); - - LogInfo($"{baseName}: Duration {duration:F2}s, {width}x{height} @ {fps:F3}fps"); - LogInfo($"{baseName}: Target duration: {target:F2}s"); - LogInfo($"{baseName}: Segments: {segments}"); - LogInfo(job.ForceFixed - ? $"{baseName}: Fixed segment length: {segmentLength:F2}s (last may be shorter)" - : $"{baseName}: Equalized segment length: {segmentLength:F2}s"); + LogInfo($"{baseName}: Duration {info.Duration:F2}s, {info.Width}x{info.Height} @ {info.Fps:F3}fps {info.Bitrate/1024:F0}kbps, Target duration: {target:F2}s Segments: {segments} segment length: {segmentLength:F2}s {(job.ForceFixed ? " fixed" : "")}" ); if (cmd.Master.EstimateOnly) return []; @@ -152,17 +133,15 @@ static class Program .Select(i => new SingleTask ( Job : job, + Info: info, OutputFileName : BuildOutputFileName(job.OutputFolder, job.Mask, i), SegmentIndex : i, TotalSegments : segments, SegmentStart : i * segmentLength, SegmentLength : (i == segments - 1) - ? Math.Max(0.1, duration - i * segmentLength) + ? Math.Max(0.1, info.Duration - i * segmentLength) : segmentLength, - ProcessorFactory : processorFactory, - VideoWidth : width, - VideoHeight : height, - VideoFps : fps + ProcessorFactory : processorFactory ) ) .ToList(); @@ -273,15 +252,7 @@ static class Program var processor = t.ProcessorFactory(slot); try { - await processor.ProcessSegment( - t.Job.InputFile, - t.OutputFileName, - t.SegmentStart, - t.SegmentLength, - t.VideoWidth, - t.VideoHeight, - t.VideoFps, - t.Job.Passthrough); + await processor.ProcessSegment(t); } finally { @@ -317,73 +288,6 @@ static class Program return Path.Combine(folder, fileName); } - public static (double duration, int width, int height, double fps) ProbeVideo(string inputFile) - { - var args = - "-v error " + - "-select_streams v:0 " + - "-show_entries format=duration " + - "-show_entries stream=width,height,avg_frame_rate " + - "-of default=noprint_wrappers=1:nokey=0 " + // <-- IMPORTANT: include keys - $"\"{inputFile}\""; - - var psi = new ProcessStartInfo - { - FileName = "ffprobe", - Arguments = args, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var p = new Process { StartInfo = psi }; - p.Start(); - - var duration = -1.0; - var width = 0; - var height = 0; - var fps = 0.0; - - while (!p.StandardOutput.EndOfStream) - { - var line = p.StandardOutput.ReadLine()?.Trim(); - if (string.IsNullOrWhiteSpace(line)) - continue; - - if (line.StartsWith("duration=")) - { - var v = line.Substring("duration=".Length); - double.TryParse(v, NumberStyles.Any, CultureInfo.InvariantCulture, out duration); - } - else if (line.StartsWith("width=")) - { - var v = line.Substring("width=".Length); - int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out width); - } - else if (line.StartsWith("height=")) - { - var v = line.Substring("height=".Length); - int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out height); - } - else if (line.StartsWith("avg_frame_rate=")) - { - var v = line.Substring("avg_frame_rate=".Length); - var parts = v.Split('/'); - if (parts.Length == 2 && - double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var num) && - double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var den) && - den != 0) - { - fps = num / den; - } - } - } - - p.WaitForExit(); - - return (duration, width, height, fps); - } }