Compare commits

...

2 Commits

3 changed files with 83 additions and 47 deletions

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using OpenCvSharp;
namespace splitter;
@ -52,7 +53,7 @@ public sealed class CommandLine
public SingleJob Master { get; } = new SingleJob();
public SingleJob[] Jobs { get; }
public bool IsValid => !string.IsNullOrEmpty(Master.InputFile) && !string.IsNullOrEmpty(Master.OutputFolder) && Jobs.Length > 0;
public bool IsValid => !string.IsNullOrEmpty(Master.OutputFolder) && Jobs.Length > 0;
public CommandLine(string[] args)
{
@ -79,24 +80,33 @@ public sealed class CommandLine
if (args.Length < 1)
{
Console.WriteLine("Missing required parameters.");
PrintHelp();
Console.WriteLine("[ERROR]: Missing required parameters.");
return;
}
Master.InputFile = args[0];
var hasOutputFolder = args.Length > 1 && !args[1].StartsWith("-");
var inputFiles = new List<string>();
if (hasOutputFolder)
Master.OutputFolder = args[1];
else
Master.OutputFolder = Path.Combine(Path.GetDirectoryName(Master.InputFile) ?? Directory.GetCurrentDirectory(), "Splitter");
foreach (var arg in args.Skip(hasOutputFolder ? 2 : 1))
foreach (var arg in args)
{
if (arg.StartsWith("--mask="))
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();
@ -169,7 +179,8 @@ public sealed class CommandLine
}
}
var files = FileMaskExpander.Expand(Master.InputFile);
var files = inputFiles.SelectMany(x => FileMaskExpander.Expand(x));
Jobs = files.Select(x => new SingleJob
{
InputFile = x,
@ -188,6 +199,36 @@ public sealed class CommandLine
Rotate = Master.Rotate,
Parameters = new Dictionary<string, string>(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");
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)
@ -286,12 +327,18 @@ public sealed class CommandLine
{
Console.WriteLine(@"
Usage:
splitter <input.mp4> <output_folder> [options] [--] <ffmpeg passthrough>
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: <OriginalName>_Seg%03d.mp4
Supports %03d or %d for segment index.
Default: [NAME]_seg[NN].[EXT]
Supports [NAME], [N], [NN], [NNN], [NNNN], [EXT] placeholders.
--duration=<value> Override target segment duration.
Accepted formats:
@ -305,7 +352,7 @@ Options:
--duration=45
Without --force:
Segments are equalized so all have same length.
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.
@ -350,12 +397,13 @@ Passthrough:
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 out/
splitter vertical-video.mp4 out/ --duration=90s
splitter vertical-video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4""
splitter vertical-video.mp4 out/ --estimate
splitter vertical-video.mp4 out/ --force --duration=45 -- -an -sn
splitter horizontal-video.mp4 out/ --crop
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

View File

@ -6,7 +6,7 @@ public static class FileMaskExpander
{
// If no mask, return the single full path
if (!HasMask(input))
return new[] { Path.GetFullPath(input) };
return [Path.GetFullPath(input)];
string directory = Path.GetDirectoryName(input) ?? Directory.GetCurrentDirectory();
string pattern = Path.GetFileName(input);

View File

@ -78,8 +78,6 @@ static partial class Program
if (!Directory.Exists(job.OutputFolder))
Directory.CreateDirectory(job.OutputFolder);
job.Mask ??= $"{baseName}_seg%03d.mp4";
var info = ProbeVideo.Probe(job.InputFile);
if (info.Duration <= 0)
{
@ -105,7 +103,8 @@ static partial class Program
segmentLength = info.Duration / segments;
}
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($"{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 [];
@ -134,7 +133,7 @@ static partial class Program
(
Job : job,
Info: info,
OutputFileName : BuildOutputFileName(job.OutputFolder, job.Mask, i),
OutputFileName : BuildOutputFileName(job, i),
SegmentIndex : i,
TotalSegments : segments,
SegmentStart : i * segmentLength,
@ -261,31 +260,20 @@ static partial class Program
}
}
static string BuildOutputFileName(string folder, string mask, int index)
static string BuildOutputFileName(SingleJob job, int index)
{
string fileName;
if (mask.Contains("%03d"))
{
fileName = string.Format(mask.Replace("%03d", "{0:000}"), index);
}
else if (mask.Contains("%02d"))
{
fileName = string.Format(mask.Replace("%02d", "{0:00}"), index);
}
else if (mask.Contains("%d"))
{
fileName = string.Format(mask.Replace("%d", "{0}"), index);
}
else
{
// If no placeholder, append index
var name = Path.GetFileNameWithoutExtension(mask);
var ext = Path.GetExtension(mask);
fileName = $"{name}_{index:000}{ext}";
}
fileName = Path.GetFileName(job.Mask ?? "[NAME]_seg[NN].[EXT]")
.Replace("[NAME]", Path.GetFileNameWithoutExtension(job.InputFile))
.Replace("[N]" , index.ToString())
.Replace("[NN]" , index.ToString("00"))
.Replace("[NNN]" , index.ToString("000"))
.Replace("[NNNN]", index.ToString("0000"))
.Replace("[EXT]" , Path.GetExtension(job.InputFile).TrimStart('.'))
;
return Path.Combine(folder, fileName);
return Path.Combine(job.OutputFolder, fileName);
}