Add project files.

This commit is contained in:
Alexander Shabarshov 2026-04-28 10:47:58 +01:00
parent 1a317a94a5
commit 916b39d6c2
3 changed files with 461 additions and 0 deletions

424
splitter.cs Normal file
View File

@ -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<string>();
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 <input.mp4> <output_folder> [options] [--] <ffmpeg passthrough>
Options:
--mask=<pattern> Output filename pattern.
Default: <OriginalName>_Seg%03d.mp4
Supports %03d or %d for segment index.
--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:
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.
");
}
}

12
splitter.csproj Normal file
View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<Optimize>true</Optimize>
</PropertyGroup>
</Project>

25
splitter.sln Normal file
View File

@ -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