diff --git a/splitter.cs b/splitter.cs new file mode 100644 index 0000000..a8e73a0 --- /dev/null +++ b/splitter.cs @@ -0,0 +1,424 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +class Program +{ + static int logLines = 0; + static readonly object consoleLock = new(); + static bool progressRunning = true; + + static void Main(string[] args) + { + double? overrideTargetDuration = null; + bool estimateOnly = false; + bool forceFixed = false; + + + Console.OutputEncoding = Encoding.UTF8; + + if (args.Length == 0 || args.Contains("--help")) + { + PrintHelp(); + return; + } + + // Extract passthrough parameters after "--" + string[] passthrough = Array.Empty(); + int passthroughIndex = Array.IndexOf(args, "--"); + + if (passthroughIndex >= 0) + { + if (passthroughIndex < args.Length - 1) + passthrough = args.Skip(passthroughIndex + 1).ToArray(); + + args = args.Take(passthroughIndex).ToArray(); + } + + if (args.Length < 2) + { + LogError("Missing required parameters."); + PrintHelp(); + return; + } + + string inputFile = args[0]; + string outputFolder = args[1]; + string? mask = null; + + foreach (var arg in args.Skip(2)) + { + if (arg.StartsWith("--mask=")) + { + mask = arg.Substring("--mask=".Length); + } + else if (arg.StartsWith("--duration=")) + { + string dur = arg.Substring("--duration=".Length); + overrideTargetDuration = ParseDuration(dur); + if (overrideTargetDuration <= 0) + { + LogError($"Invalid --duration value: {dur}"); + return; + } + } + else if (arg == "--estimate") + { + estimateOnly = true; + } + else if (arg == "--force") + { + forceFixed = true; + } + } + + if (!File.Exists(inputFile)) + { + LogError("Input file not found."); + return; + } + + if (!Directory.Exists(outputFolder)) + Directory.CreateDirectory(outputFolder); + + string baseName = Path.GetFileNameWithoutExtension(inputFile); + string outputMask = mask ?? $"{baseName}_Seg%03d.mp4"; + + LogInfo("Reading duration via ffprobe..."); + + double duration = GetDuration(inputFile); + if (duration <= 0) + { + LogError("Could not read duration."); + return; + } + + double target = overrideTargetDuration ?? 58.0; + + int segments; + double segmentLength; + + if (forceFixed) + { + // Fixed chunk size, last one may be shorter + segments = (int)Math.Ceiling(duration / target); + segmentLength = target; + } + else + { + // Equalized segments + segments = (int)Math.Ceiling(duration / target); + segmentLength = duration / segments; + } + + if (estimateOnly) + { + LogInfo("=== ESTIMATE MODE ==="); + LogInfo($"Total duration: {duration:F2}s"); + LogInfo($"Target duration: {target:F2}s"); + LogInfo($"Segments: {segments}"); + LogInfo(forceFixed + ? $"Fixed segment length: {segmentLength:F2}s (last may be shorter)" + : $"Equalized segment length: {segmentLength:F2}s"); + return; + } + + LogInfo($"Duration: {duration:F2}s"); + LogInfo($"Segments: {segments}"); + LogInfo($"Equal segment length: {segmentLength:F3}s"); + + LogInfo("Starting multi-threaded ffmpeg splitting..."); + + RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough); + + LogSuccess("Done."); + progressRunning = false; + // Move cursor below progress area + lock (consoleLock) + { + Console.SetCursorPosition(0, logLines + 4); + Console.WriteLine(); + } + } + + // ----------------------------- + // Logging + Progress UI + // ----------------------------- + + static void Log(string prefix, ConsoleColor color, string msg) + { + lock (consoleLock) + { + Console.ForegroundColor = color; + Console.WriteLine($"{prefix} {msg}"); + Console.ResetColor(); + logLines++; + } + } + + static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg); + static void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg); + static void LogWarn(string msg) => Log("[WARN]", ConsoleColor.Yellow, msg); + static void LogError(string msg) => Log("[ERR ]", ConsoleColor.Red, msg); + + static void DrawProgress(double progress, TimeSpan eta, double speed) + { + lock (consoleLock) + { + int width = Math.Max(20, Console.WindowWidth - 20); + int filled = (int)(progress * width); + if (filled < 0) filled = 0; + if (filled > width) filled = width; + + int barLine = logLines + 1; + int infoLine = logLines + 2; + + // Progress bar with 24-bit color (green) + Console.SetCursorPosition(0, barLine); + Console.Write("\u001b[38;2;0;255;0m["); + Console.Write(new string('#', filled)); + Console.Write(new string('-', width - filled)); + Console.Write("]\u001b[0m"); + + // Info line: percentage, ETA, speed + Console.SetCursorPosition(0, infoLine); + string etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) + ? "ETA: --:--" + : $"ETA: {eta:mm\\:ss}"; + string speedStr = double.IsNaN(speed) || double.IsInfinity(speed) + ? "Speed: -.-x" + : $"Speed: {speed:F2}x"; + + string info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; + Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m"); + } + } + + // ----------------------------- + // ffprobe + // ----------------------------- + + static double GetDuration(string inputFile) + { + var psi = new ProcessStartInfo + { + FileName = "ffprobe", + Arguments = $"-v error -show_entries format=duration -of csv=p=0 \"{inputFile}\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffprobe."); + string? output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + if (output != null && + double.TryParse(output, NumberStyles.Any, CultureInfo.InvariantCulture, out double duration)) + return duration; + + return -1; + } + + // ----------------------------- + // Multi-threaded splitting + // ----------------------------- + + static void RunMultiThreadedSplit( + string inputFile, + string outputFolder, + string mask, + double totalDuration, + int segments, + double segmentLength, + string[] passthrough) + { + var jobs = Enumerable.Range(0, segments) + .Select(i => new + { + Index = i, + Start = i * segmentLength, + Length = (i == segments - 1) + ? Math.Max(0.1, totalDuration - i * segmentLength) + : segmentLength + }) + .ToList(); + + int completed = 0; + var sw = Stopwatch.StartNew(); + + // Progress thread + var progressThread = new Thread(() => + { + while (progressRunning) + { + double progress = segments == 0 ? 0 : (double)completed / segments; + double processedSeconds = completed * segmentLength; + double speed = sw.Elapsed.TotalSeconds > 0 + ? processedSeconds / sw.Elapsed.TotalSeconds + : 0; + + double remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001); + if (remainingSeconds < 0) remainingSeconds = 0; + var eta = TimeSpan.FromSeconds(remainingSeconds); + + DrawProgress(progress, eta, speed); + Thread.Sleep(200); + } + }) + { + IsBackground = true + }; + progressThread.Start(); + + int maxDegree = Math.Max(1, Environment.ProcessorCount / 2); + + Parallel.ForEach( + jobs, + new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, + job => + { + string outputFile = BuildOutputFileName(outputFolder, mask, job.Index); + RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough); + Interlocked.Increment(ref completed); + }); + + sw.Stop(); + progressRunning = false; + progressThread.Join(); + DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001)); + } + + static string BuildOutputFileName(string folder, string mask, int index) + { + string fileName; + + if (mask.Contains("%03d")) + { + fileName = string.Format(mask.Replace("%03d", "{0:000}"), index); + } + else if (mask.Contains("%d")) + { + fileName = string.Format(mask.Replace("%d", "{0}"), index); + } + else + { + // If no placeholder, append index + string name = Path.GetFileNameWithoutExtension(mask); + string ext = Path.GetExtension(mask); + fileName = $"{name}_{index:000}{ext}"; + } + + return Path.Combine(folder, fileName); + } + + static void RunFFmpegSegment(string inputFile, string outputFile, double start, double length, string[] passthrough) + { + string pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : ""; + + string args = + $"-ss {start.ToString(CultureInfo.InvariantCulture)} -i \"{inputFile}\" -t {length.ToString(CultureInfo.InvariantCulture)} -c copy {pass} \"{outputFile}\" -y"; + + var psi = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffmpeg."); + proc.StandardError.ReadToEnd(); // swallow output + proc.WaitForExit(); + } + + static double ParseDuration(string text) + { + text = text.Trim().ToLowerInvariant(); + + // Case 1: pure number to seconds + if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out double 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 + int mIndex = text.IndexOf('m'); + int sIndex = text.IndexOf('s'); + + if (mIndex > 0 && sIndex > mIndex) + { + string mPart = text[..mIndex]; + string sPart = text[(mIndex + 1)..sIndex]; + + if (double.TryParse(mPart, out double minutes) && + double.TryParse(sPart, out double seconds)) + { + return minutes * 60 + seconds; + } + } + + throw new FormatException($"Invalid duration format: {text}"); + } + + // ----------------------------- + // Help + // ----------------------------- + + static void PrintHelp() + { + Console.WriteLine(@" +Usage: + splitter [options] [--] + +Options: + --mask= Output filename pattern. + Default: _Seg%03d.mp4 + Supports %03d or %d for segment index. + + --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: + Segments are equalized so all have same length. + + --force Use fixed segment duration exactly as given. + Last segment may be shorter. + Default: OFF + + --estimate Print calculated segment information and exit. + No splitting is performed. + +Passthrough: + Anything after -- is passed directly to ffmpeg. + +Examples: + splitter video.mp4 out/ + splitter video.mp4 out/ --duration=90s + splitter video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4"" + splitter video.mp4 out/ --estimate + splitter video.mp4 out/ --force --duration=45 -- -an -sn + +Description: + Splits a video into equal or fixed-length segments using multi-threaded + ffmpeg execution. Supports ETA, speed, and rich progress display. +"); + } +} diff --git a/splitter.csproj b/splitter.csproj new file mode 100644 index 0000000..0613750 --- /dev/null +++ b/splitter.csproj @@ -0,0 +1,12 @@ + + + + Exe + net10.0 + enable + enable + latest + true + + + diff --git a/splitter.sln b/splitter.sln new file mode 100644 index 0000000..0f1e65b --- /dev/null +++ b/splitter.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11716.220 stable +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "splitter", "splitter.csproj", "{D628372F-EA0D-4E59-EB15-96FD85D90AC4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D628372F-EA0D-4E59-EB15-96FD85D90AC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D628372F-EA0D-4E59-EB15-96FD85D90AC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D628372F-EA0D-4E59-EB15-96FD85D90AC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D628372F-EA0D-4E59-EB15-96FD85D90AC4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A289E39A-C107-4125-9F2D-ADEE83CEE0B2} + EndGlobalSection +EndGlobal