Source image rotation added. Interfaces simplified.

This commit is contained in:
Alexander Shabarshov 2026-05-14 09:02:43 +01:00
parent 44d817f17f
commit 3fa068e48b
7 changed files with 228 additions and 154 deletions

View File

@ -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<string, string> Parameters { get; set; } = [];
public void Override<T>(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<string, string>(Master.Parameters)
}).ToArray();
}
@ -297,6 +311,9 @@ Options:
Last segment may be shorter.
Default: OFF
--rotate=<degrees> 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.

View File

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

92
ProbeVideo.cs Normal file
View File

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

View File

@ -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 args =
var pass = ffmpegPassthroughParameters.Length > 0 ? string.Join(" ", ffmpegPassthroughParameters) : "";
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();

12
SingleTask.cs Normal file
View File

@ -0,0 +1,12 @@
using splitter;
public record SingleTask(
SingleJob Job,
VideoInfo Info,
string OutputFileName,
int SegmentIndex,
int TotalSegments,
double SegmentStart,
double SegmentLength,
Func<int, ISegmentProcessor> ProcessorFactory
);

View File

@ -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(() =>
{
try
{
string? line;
while ((line = p.StandardError.ReadLine()) != null)
if (_cmd.PlainText)
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}");
}
}

View File

@ -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<int, ISegmentProcessor> ProcessorFactory
);
static async Task<int> 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<SingleTask>();
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);
}
}