mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-21 16:12:01 +00:00
419 lines
15 KiB
C#
419 lines
15 KiB
C#
using System.Globalization;
|
||
using splitter.util;
|
||
|
||
namespace splitter;
|
||
|
||
public sealed class CommandLine
|
||
{
|
||
// Default vertical Full HD for YouTube Shorts
|
||
public const int DefaultW = 607;
|
||
public const int DefaultH = 1080;
|
||
|
||
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<string>();
|
||
|
||
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 == "--enhance")
|
||
{
|
||
Master.Enhance = true;
|
||
}
|
||
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("--detect-id="))
|
||
{
|
||
var val = arg.Substring("--detect-id=".Length);
|
||
if (ulong.TryParse(val, out var detectId))
|
||
Master.DetectId = detectId;
|
||
else
|
||
throw new FormatException($"Invalid --detect-id value: {val}");
|
||
}
|
||
else if (arg.StartsWith("--crop="))
|
||
{
|
||
Master.Crop = ParseCrop(arg.Substring("--crop=".Length));
|
||
}
|
||
else if (arg.StartsWith("--detect-above="))
|
||
{
|
||
var val = arg.Substring("--detect-above=".Length);
|
||
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var detectAbove) && detectAbove >= 0.0f && detectAbove <= 1.0f)
|
||
Master.DetectAbove = detectAbove;
|
||
else
|
||
Master.DetectAbove = 0.7f;
|
||
}
|
||
else if (arg.StartsWith("--score-threshold="))
|
||
{
|
||
var val = arg.Substring("--score-threshold=".Length);
|
||
if (float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var scoreThreshold) && scoreThreshold >= 0.0f && scoreThreshold <= 1.0f)
|
||
Master.ScoreThreshold = scoreThreshold;
|
||
else
|
||
Master.ScoreThreshold = 0.25f;
|
||
}
|
||
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 =>
|
||
{
|
||
var job = new SingleJob { InputFile = x };
|
||
Master.CopyTo(job);
|
||
return job;
|
||
}).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<string> 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: "<x>:<y>"
|
||
var parts = value.Split(':');
|
||
if (parts.Length != 2)
|
||
return new Point2f(0.5f, 0.5f);
|
||
|
||
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
|
||
return new Point2f(0.5f, 0.5f);
|
||
|
||
if (!float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||
return new Point2f(0.5f, 0.5f);
|
||
|
||
// Normalized range check (0.0–1.0)
|
||
if (x < 0f || x > 1f || y < 0f || y > 1f)
|
||
return new Point2f(0.5f, 0.5f);
|
||
|
||
return new Point2f(x, y);
|
||
}
|
||
|
||
private static (int width, int height)? ParseCrop(string v)
|
||
{
|
||
// 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 [<input.mp4> ...] [options] [--] <ffmpeg passthrough>
|
||
|
||
Options:
|
||
--out=<folder> Output folder for segments.
|
||
Default: same folder as input video + ""Splitter"".
|
||
|
||
--file=<path> Input names or file masks (e.g. ""videos/*.mp4"").
|
||
If not specified, the first non-option argument is used as input.
|
||
|
||
--mask=<pattern> Output filename pattern.
|
||
Default: [NAME]_seg[NN].[EXT]
|
||
Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders.
|
||
|
||
--duration=<value> 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
|
||
|
||
--enhance Enable video enhancement.
|
||
Increases output resolution x4 Using RealBasicVSR_x4 model.
|
||
|
||
--rotate=<degrees> 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[=<w:h>] 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=<name> Object detector to use for tracking.
|
||
Values: face (UltraFace), body (YoloOnnx, default), none (no tracking, just a center)
|
||
|
||
--detect-above=<0-1> Face or human detectors should only report detections if their upper bound starts below this threshold.
|
||
This is a value between 0.0 and 1.0 mapped to 0..Height.
|
||
|
||
--detect-id=<hex> Object ID to track. This is a hexadecimal string that identifies a specific face or
|
||
person to track across segments. This is useful when you want to consistently track the same person
|
||
across all segments of a video, even if there are multiple people present.
|
||
The ID can be obtained when running with --debug or from the debug overlay.
|
||
|
||
--gravitate=<x:y> 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:<name>=<value> 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.
|
||
");
|
||
}
|
||
}
|