using System.Globalization; namespace splitter; public class SingleJob { /// /// File path of the input video. This is required for each job and should be /// set to a valid video file path. The splitter will read this file, analyze it, /// and split it into segments based on the specified parameters. /// The output segments will be saved in the OutputFolder with names /// derived from this input file and the Mask pattern if provided. /// public string InputFile { get; set; } = null!; /// /// Output folder where the split segments will be saved. This should be set /// to a valid directory path. /// public string OutputFolder { get; set; } = null!; /// /// Crop parameters. Width and height for cropping the video. If set, the /// splitter will crop the video to the specified dimensions while tracking the subject. /// public (int width, int height)? Crop { get; set; } /// /// The fallback point to gravitate towards when tracking the subject. Coordinates are normalized (0.0 to 1.0). /// By default , the splitter gravitates towards the center of the frame (0.5, 0.5). /// Setting this allows you to bias the tracking towards a specific area of the frame, /// such as left-center (0.2, 0.5) or top-right (0.8, 0.2). This can be useful for /// videos where the subject tends to be off-center or for creative framing choices. /// public Point2f? GravitateTo { get; set; } /// /// Destination file mask. /// public string? Mask { get; set; } /// /// Instead of producing the output, just generate debug frames with tracking /// overlay to visually verify that the tracking is working correctly. /// public bool Debug { get; set; } /// /// Type of detector to use for tracking. Supported values are: face (UltraFace), /// body (YoloOnnx, default), none (no tracking, just a center point). /// public string? Detect { get; set; } /// /// Set starget segments length explicitly. By default, the splitter calculates segment /// lengths to be equal and not exceed 58 seconds. /// public double? OverrideTargetDuration { get; set; } /// /// Parameters to pass thru to ffmpeg. These are specified after "--" in the command /// line and are passed directly to the ffmpeg command line for each segment. /// public string[] Passthrough { get; set; } = []; /// /// Debugging parameter. Instead of text UI putput lines in plain text. /// This is useful when the output is being piped to a file or another program, /// or when the user prefers a simpler log format without progress bars and dynamic updates. /// public bool PlainText { get; set; } /// /// Debugging parameter. Just show estimated segments length, count, and other info /// without actually performing the splitting. /// public bool EstimateOnly { get; set; } /// /// Do not adapt segment length. When set, the splitter will use the exact /// segment duration specified by --duration for all segments except possibly /// the last one, which may be shorter. /// public bool ForceFixed { get; set; } /// /// Use single thread for operations. When set, the splitter will not run /// multiple ffmpeg processes in parallel. /// public bool SingleThreaded { get; set; } /// /// Rotation angle: 90, 180, or 270 degrees. This is useful for videos that /// have incorrect orientation metadata. /// public int? Rotate { get; set; } /// /// Autodetect if rotation is needed. Not very reliable but can work for some videos. /// Uses edge orientation statistics to determine if the video is rotated and /// applies the appropriate rotation if needed. /// public bool RotateAuto { get; set; } /// /// Override internal parameters. This allows you to set custom parameters for the /// object detector or rotation detector. /// public Dictionary Parameters { get; set; } = []; public void Override(ref T member, string name) { if (!Parameters.TryGetValue(name, out var raw)) return; try { // Convert.ChangeType handles int, float, double, etc. var converted = (T)Convert.ChangeType( raw, typeof(T), CultureInfo.InvariantCulture ); member = converted; } catch { Console.WriteLine($"Invalid value for parameter '{name}': {raw}"); } } } public sealed class CommandLine { public SingleJob Master { get; } = new SingleJob(); public SingleJob[] Jobs { get; } public bool IsValid => !string.IsNullOrEmpty(Master.OutputFolder) && Jobs.Length > 0; public CommandLine(string[] args) { Master.InputFile = ""; Master.OutputFolder = ""; Jobs = []; if (args.Length == 0 || args.Contains("--help")) { PrintHelp(); return; } // Extract passthrough parameters after "--" var passthroughIndex = Array.IndexOf(args, "--"); if (passthroughIndex >= 0) { if (passthroughIndex < args.Length - 1) Master.Passthrough = args.Skip(passthroughIndex + 1).ToArray(); args = args.Take(passthroughIndex).ToArray(); } if (args.Length < 1) { PrintHelp(); Console.WriteLine("[ERROR]: Missing required parameters."); return; } var inputFiles = new List(); foreach (var arg in args) { if (!arg.StartsWith("-")) { inputFiles.Add(arg); } else if ( arg.StartsWith("--file=")) { var fileName = arg.Substring("--file=".Length); if (File.Exists(arg)) LoadFile(fileName, inputFiles); } else if (arg.StartsWith("--mask=")) { Master.Mask = arg.Substring("--mask=".Length); } else if (arg.StartsWith("--out=")) { Master.OutputFolder = arg.Substring("--out=".Length); } else if (arg.StartsWith("--detect=")) { 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)); } else if (arg == "--crop") { Master.Crop = ParseCrop(""); } else if (arg == "--text") { Master.PlainText = true; } else if (arg == "--debug") { Master.Debug = true; } else if (arg == "--single-thread") { Master.SingleThreaded = true; } else if (arg == "--rotate-auto") { Master.RotateAuto = true; } else if (arg.StartsWith("--gravitate=")) { var val = arg.Substring("--gravitate=".Length); Master.GravitateTo = ParseGravitate(val); } else if (arg.StartsWith("--duration=")) { var dur = arg.Substring("--duration=".Length); Master.OverrideTargetDuration = ParseDuration(dur); if (Master.OverrideTargetDuration <= 0) { Console.WriteLine($"Invalid --duration value: {dur}"); return; } } else if (arg.StartsWith("-p:", StringComparison.Ordinal)) { var spec = arg.Substring("-p:".Length); if (!TryParseParameter(spec, out var key, out var value)) { Console.WriteLine($"Invalid -p parameter: {spec}"); return; } Master.Parameters[key] = value; } else if (arg == "--estimate") { Master.EstimateOnly = true; } else if (arg == "--force") { Master.ForceFixed = true; } } var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x)); Jobs = files.Select(x => new SingleJob { InputFile = x, OutputFolder = Master.OutputFolder, Crop = Master.Crop, GravitateTo = Master.GravitateTo, Mask = Master.Mask, Debug = Master.Debug, Detect = Master.Detect, OverrideTargetDuration = Master.OverrideTargetDuration, Passthrough = Master.Passthrough, PlainText = Master.PlainText, EstimateOnly = Master.EstimateOnly, ForceFixed = Master.ForceFixed, SingleThreaded = Master.SingleThreaded, Rotate = Master.Rotate, RotateAuto = Master.RotateAuto, Parameters = new Dictionary(Master.Parameters) }).ToArray(); if ( Jobs.Length == 0) { PrintHelp(); Console.WriteLine("[ERROR]:No valid input files found."); return; } if (string.IsNullOrWhiteSpace(Master.OutputFolder)) { var firstInput = Jobs[0].InputFile; Master.OutputFolder = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(firstInput))!, "Splitter"); foreach (var job in Jobs) job.OutputFolder = Master.OutputFolder; Console.WriteLine($"Using default output folder: {Master.OutputFolder}"); } } private void LoadFile(string fileName, List inputFiles) { if (!File.Exists(fileName)) { Console.WriteLine($"File not found: {fileName}"); return; } var lines = File.ReadAllLines(fileName); foreach (var line in lines) { var trimmed = line.Trim(); if (trimmed.Length > 0 && !trimmed.StartsWith("#")) inputFiles.AddRange(FileMaskExpander.Expand(trimmed)); } } private static bool TryParseParameter(string spec, out string key, out string value) { key = ""; value = ""; var idx = spec.IndexOf('='); if (idx <= 0 || idx == spec.Length - 1) return false; key = spec.Substring(0, idx).Trim(); value = spec.Substring(idx + 1).Trim(); return key.Length > 0; } private static Point2f? ParseGravitate(string value) { // Expected format: ":" var parts = value.Split(':'); if (parts.Length != 2) return null; if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null; if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null; // Normalized range check (0.0–1.0) if (x < 0f || x > 1f || y < 0f || y > 1f) return null; return new Point2f(x, y); } private static (int width, int height)? ParseCrop(string v) { // Default vertical Full HD for YouTube Shorts const int defaultW = 607; const int defaultH = 1080; // Empty or whitespace → default crop if (string.IsNullOrWhiteSpace(v)) return (defaultW, defaultH); var s = v.Trim().ToLowerInvariant(); // Expected format: "WWWxHHH" var parts = s.Split('x'); if (parts.Length != 2) return null; var okW = int.TryParse(parts[0], out var w); var okH = int.TryParse(parts[1], out var h); if (!okW || !okH || w <= 0 || h <= 0) return null; return (w, h); } static double ParseDuration(string text) { text = text.Trim().ToLowerInvariant(); // Case 1: pure number to seconds if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var sec)) return sec; // Case 2: Ns (seconds) if (text.EndsWith("s") && double.TryParse(text[..^1], out sec)) return sec; // Case 3: NmMs (minutes + seconds) // Examples: 2m30s, 1m5s, 10m0s var mIndex = text.IndexOf('m'); var sIndex = text.IndexOf('s'); if (mIndex > 0 && sIndex > mIndex) { var mPart = text[..mIndex]; var sPart = text[(mIndex + 1)..sIndex]; if (double.TryParse(mPart, out var minutes) && double.TryParse(sPart, out var seconds)) { return minutes * 60 + seconds; } } throw new FormatException($"Invalid duration format: {text}"); } public static void PrintVersion() { Console.WriteLine($"...---=== splitter version {BuildInfo.Version} (file version: {BuildInfo.FileVersion}, build {BuildInfo.AssemblyVersion}) ===---..."); } public static void PrintHelp() { PrintVersion(); Console.WriteLine(@" Usage: splitter [ ...] [options] [--] Options: --out= Output folder for segments. Default: same folder as input video + ""Splitter"". --file= Input names or file masks (e.g. ""videos/*.mp4""). If not specified, the first non-option argument is used as input. --mask= Output filename pattern. Default: [NAME]_seg[NN].[EXT] Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders. --duration= Override target segment duration. Accepted formats: Ns - N seconds NmMs - N minutes M seconds N - N seconds (plain number) Examples: --duration=90s --duration=2m30s --duration=45 Without --force: Default: maximum of 58 seconds, but segments are equalized so all have same length. --force Use fixed segment duration exactly as given. Last segment may be shorter. Default: OFF --rotate= Rotate video by specified degrees (90, 180, 270). Useful for videos with incorrect orientation metadata. --rotate-auto Auto-detect rotation and rotate accordingly. Uses edge orientation statistics to determine if video is rotated. --estimate Print calculated segment information and exit. No splitting is performed. --crop[=] Crop video to width w and height h, with face tracking. Useful to making YouTube Shorts or TikToks from horizontal video. Default: 607x1080 (vertical video cropped from Full HD original) --detect= Object detector to use for tracking. Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center) --gravitate= Gravitate towards a specific point (x, y) in the video frame when tracking. Coordinates are normalized (0.0 to 1.0). Example: --gravitate=0.2:0.5 (gravitate towards left-center) --text Display log in plain text. --single-thread Run in single-threaded mode (no parallel ffmpeg processes). Useful for debugging or if system is resource-constrained. --debug Show debug overlay during face tracking. -p:= Set a custom parameter for the object detector. Example: -p:EmaFactor=0.65 Tracking splitter defaults: DropoutToleranceFrames = 20; EmaFactor = 0.65; CameraEasing = 0.03; LostFreezeFrames = 60; Rotation detector defaults: RotationDetectorSampleCount = 5; RotationDetectorSampleLength = 0.15; RotationDetectorFrameWidth = 320; RotationDetectorFrameHeight = 180; Passthrough: Anything after -- is passed directly to ffmpeg. input.mp4 can be a file mask, e.g. ""videos/*.mp4"". Output files will be named based on the input filename and the --mask pattern if provided. Examples: splitter vertical-video.mp4 splitter vertical-video.mp4 --duration=90s splitter vertical-video.mp4 --duration=2m30s --mask=""[NAME]_[NNNN].mp4"" splitter vertical-video.mp4 --estimate splitter vertical-video.mp4 --force --duration=45 -- -an -sn splitter horizontal-video.mp4 --out=Cropped/ --crop splitter --file=file_names.txt --out=Cropped/ --crop --detect=body Description: Splits a video into equal or fixed-length segments using multi-threaded ffmpeg execution. Supports ETA, speed, and rich progress display. "); } }