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 EstimateOnly { get; set; }
public bool ForceFixed { get; set; } public bool ForceFixed { get; set; }
public bool SingleThreaded { get; set; } public bool SingleThreaded { get; set; }
public int? Rotate { get; set; }
public Dictionary<string, string> Parameters { get; set; } = []; public Dictionary<string, string> Parameters { get; set; } = [];
public void Override<T>(ref T member, string name) public void Override<T>(ref T member, string name)
@ -100,6 +101,18 @@ public sealed class CommandLine
{ {
Master.Detect = arg.Substring("--detect=".Length).ToLowerInvariant(); 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=")) else if (arg.StartsWith("--crop="))
{ {
Master.Crop = ParseCrop(arg.Substring("--crop=".Length)); Master.Crop = ParseCrop(arg.Substring("--crop=".Length));
@ -172,6 +185,7 @@ public sealed class CommandLine
EstimateOnly = Master.EstimateOnly, EstimateOnly = Master.EstimateOnly,
ForceFixed = Master.ForceFixed, ForceFixed = Master.ForceFixed,
SingleThreaded = Master.SingleThreaded, SingleThreaded = Master.SingleThreaded,
Rotate = Master.Rotate,
Parameters = new Dictionary<string, string>(Master.Parameters) Parameters = new Dictionary<string, string>(Master.Parameters)
}).ToArray(); }).ToArray();
} }
@ -297,6 +311,9 @@ Options:
Last segment may be shorter. Last segment may be shorter.
Default: OFF 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. --estimate Print calculated segment information and exit.
No splitting is performed. No splitting is performed.

View File

@ -6,5 +6,5 @@ namespace splitter;
public interface ISegmentProcessor 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 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)} " + $"-ss {start.ToString(CultureInfo.InvariantCulture)} " +
$"-i \"{inputFile}\" " + $"-i \"{inputFile}\" " +
$"-t {length.ToString(CultureInfo.InvariantCulture)} " + $"-t {length.ToString(CultureInfo.InvariantCulture)} " +
$"-c copy {pass} \"{outputFile}\" -y"; $"-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 var psi = new ProcessStartInfo
{ {
@ -43,6 +69,16 @@ public class SimpleSplitter(int segmentNo, ILogger logger) : LoggingBase(logger,
LogInfo($"Segment {name} processing completed"); 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) private void ShowFFMpegProgress(double length, Process proc, string name)
{ {
var sw = Stopwatch.StartNew(); 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 public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
{ {
private readonly IObjectDetector _detector; private readonly IObjectDetector _detector;
private readonly SingleJob _cmd;
public TrackingSplitter( public TrackingSplitter(
int progressLine, int progressLine,
@ -21,7 +20,6 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
: base(logger, progressLine) : base(logger, progressLine)
{ {
_detector = detector; _detector = detector;
_cmd = cmd;
} }
public void Dispose() public void Dispose()
@ -30,14 +28,18 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
d.Dispose(); d.Dispose();
} }
public async Task ProcessSegment( public async Task ProcessSegment(SingleTask job)
string inputFile,
string outputFile,
double start,
double length,
int videoWidth, int videoHeight, double fps,
string[] ffmpegPassthroughParameters)
{ {
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); var name = Path.GetFileNameWithoutExtension(outputFile);
// 1) Probe source video // 1) Probe source video
@ -47,19 +49,19 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
return; return;
} }
if (_cmd.Crop == null) if (job.Job.Crop == null)
{ {
LogError($"{name}: Crop parameters are required"); LogError($"{name}: Crop parameters are required");
return; return;
} }
var encWidth = _cmd.Debug ? videoWidth : _cmd.Crop.Value.width; var encWidth = job.Job.Debug ? videoWidth : job.Job.Crop.Value.width;
var encHeight = _cmd.Debug ? videoHeight : _cmd.Crop.Value.height; 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}"); 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) // 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; using var decodeStdout = decode.StandardOutput.BaseStream;
// 3) Start FFmpeg encode (video from stdin + audio from original) // 3) Start FFmpeg encode (video from stdin + audio from original)
@ -71,7 +73,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
encWidth, encWidth,
encHeight, encHeight,
fps, fps,
ffmpegPassthroughParameters); ffmpegPassthroughParameters,
job.Job.PlainText);
using var encodeStdin = encode.StandardInput.BaseStream; using var encodeStdin = encode.StandardInput.BaseStream;
@ -89,10 +92,10 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var camera = new CameraController( var camera = new CameraController(
videoWidth, videoWidth,
videoHeight, videoHeight,
_cmd.Crop.Value.width, job.Job.Crop.Value.width,
_cmd.Crop.Value.height, job.Job.Crop.Value.height,
kalman, kalman,
_cmd); job.Job);
var startTime = DateTime.UtcNow; var startTime = DateTime.UtcNow;
var totalFrames = (int)Math.Round(length * fps); var totalFrames = (int)Math.Round(length * fps);
@ -115,7 +118,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
camera.Update(primary); camera.Update(primary);
var roi = camera.Roi; var roi = camera.Roi;
if (_cmd.Debug) if (job.Job.Debug)
{ {
DrawDebug(frameMat, objects, camera, kalman); DrawDebug(frameMat, objects, camera, kalman);
frameMat.CopyTo(outMat); frameMat.CopyTo(outMat);
@ -165,15 +168,26 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
// ---------- FFmpeg decode / encode ---------- // ---------- 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 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 = var args =
$"-i \"{inputFile}\" -ss {ss} -t {t} " + $"-i \"{inputFile}\" -ss {ss} -t {t} " +
"-an -sn " + "-an -sn " +
"-vf format=bgr24 " + $"-vf format=bgr24{rotateStr} " +
"-f rawvideo -"; "-f rawvideo -";
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@ -191,20 +205,17 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
var fileName = Path.GetFileName(inputFile); var fileName = Path.GetFileName(inputFile);
if (_cmd.PlainText)
{
_ = Task.Run(() => _ = Task.Run(() =>
{ {
try try
{ {
string? line; string? line;
while ((line = p.StandardError.ReadLine()) != null) while ((line = p.StandardError.ReadLine()) != null)
if (_cmd.PlainText) if (plainText)
LogInfo($"[ffmpeg-decode] {fileName}: {line}"); LogInfo($"[ffmpeg-decode] {fileName}: {line}");
} }
catch { } catch { }
}); });
}
return p; return p;
} }
@ -217,7 +228,8 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
int width, int width,
int height, int height,
double fps, double fps,
string[] passthrough) string[] passthrough,
bool plainText)
{ {
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture); var fpsStr = fps.ToString("0.###", CultureInfo.InvariantCulture);
@ -230,9 +242,10 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
$"-ss {ss} -i \"{inputFile}\" " + $"-ss {ss} -i \"{inputFile}\" " +
"-map 0:v:0 -map 1:a:0? -shortest " + "-map 0:v:0 -map 1:a:0? -shortest " +
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " + "-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
"-c:a aac -b:a 192k " + "-c:a copy " +
pass + $" \"{outputFile}\""; pass + $" \"{outputFile}\"";
// "-c:a aac -b:a 192k " +
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@ -257,7 +270,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable
string? line; string? line;
while ((line = p.StandardError.ReadLine()) != null) while ((line = p.StandardError.ReadLine()) != null)
{ {
if (_cmd.PlainText) if (plainText)
LogInfo($"[ffmpeg-encode] {fileName}: {line}"); LogInfo($"[ffmpeg-encode] {fileName}: {line}");
} }
} }

View File

@ -5,23 +5,10 @@ using System.Text;
using Spectre.Console; using Spectre.Console;
using splitter; using splitter;
static class Program static partial class Program
{ {
private static ILogger _logger = null!; 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) static async Task<int> Main(string[] args)
{ {
Task? uiTask = null; Task? uiTask = null;
@ -48,6 +35,9 @@ static class Program
uiTask = logger.RunAsync(cts.Token); uiTask = logger.RunAsync(cts.Token);
} }
if (cmd.Master.EstimateOnly)
LogInfo("=== ESTIMATE MODE ===");
var allJobs = new List<SingleTask>(); var allJobs = new List<SingleTask>();
foreach ( var job in cmd.Jobs ) foreach ( var job in cmd.Jobs )
{ {
@ -89,10 +79,9 @@ static class Program
Directory.CreateDirectory(job.OutputFolder); Directory.CreateDirectory(job.OutputFolder);
job.Mask ??= $"{baseName}_seg%03d.mp4"; job.Mask ??= $"{baseName}_seg%03d.mp4";
LogInfo($"{baseName}: Reading duration via ffprobe...");
(double duration, int width, int height, double fps) = ProbeVideo(job.InputFile); var info = ProbeVideo.Probe(job.InputFile);
if (duration <= 0) if (info.Duration <= 0)
{ {
LogError($"{baseName}: Could not read duration."); LogError($"{baseName}: Could not read duration.");
return []; return [];
@ -106,25 +95,17 @@ static class Program
if (job.ForceFixed) if (job.ForceFixed)
{ {
// Fixed chunk size, last one may be shorter // Fixed chunk size, last one may be shorter
segments = (int)Math.Ceiling(duration / target); segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = target; segmentLength = target;
} }
else else
{ {
// Equalized segments // Equalized segments
segments = (int)Math.Ceiling(duration / target); segments = (int)Math.Ceiling(info.Duration / target);
segmentLength = duration / segments; segmentLength = info.Duration / segments;
} }
if (cmd.Master.EstimateOnly) 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" : "")}" );
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");
if (cmd.Master.EstimateOnly) if (cmd.Master.EstimateOnly)
return []; return [];
@ -152,17 +133,15 @@ static class Program
.Select(i => new SingleTask .Select(i => new SingleTask
( (
Job : job, Job : job,
Info: info,
OutputFileName : BuildOutputFileName(job.OutputFolder, job.Mask, i), OutputFileName : BuildOutputFileName(job.OutputFolder, job.Mask, i),
SegmentIndex : i, SegmentIndex : i,
TotalSegments : segments, TotalSegments : segments,
SegmentStart : i * segmentLength, SegmentStart : i * segmentLength,
SegmentLength : (i == segments - 1) SegmentLength : (i == segments - 1)
? Math.Max(0.1, duration - i * segmentLength) ? Math.Max(0.1, info.Duration - i * segmentLength)
: segmentLength, : segmentLength,
ProcessorFactory : processorFactory, ProcessorFactory : processorFactory
VideoWidth : width,
VideoHeight : height,
VideoFps : fps
) )
) )
.ToList(); .ToList();
@ -273,15 +252,7 @@ static class Program
var processor = t.ProcessorFactory(slot); var processor = t.ProcessorFactory(slot);
try try
{ {
await processor.ProcessSegment( await processor.ProcessSegment(t);
t.Job.InputFile,
t.OutputFileName,
t.SegmentStart,
t.SegmentLength,
t.VideoWidth,
t.VideoHeight,
t.VideoFps,
t.Job.Passthrough);
} }
finally finally
{ {
@ -317,73 +288,6 @@ static class Program
return Path.Combine(folder, fileName); 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);
}
} }