diff --git a/Logger.cs b/Logger.cs deleted file mode 100644 index 19450ce..0000000 --- a/Logger.cs +++ /dev/null @@ -1,117 +0,0 @@ -namespace splitter; - -public class Logger(CommandLine cmd) : ILogger -{ - int _logLines = Math.Max(1, Environment.ProcessorCount / 2) * 2; - readonly object _consoleLock = new(); - - public void Log(string prefix, ConsoleColor color, string msg) - { - lock (_consoleLock) - { - if (cmd.PlainText) - { - Console.WriteLine($"{prefix} {msg}"); - } - else - { - Console.SetCursorPosition(0, _logLines); - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write($"{prefix} "); - - Console.ForegroundColor = color; - Console.WriteLine(msg); - - Console.ResetColor(); - - _logLines++; - } - } - } - - private readonly Dictionary _progressTrack = new(); - - public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) - { - if (cmd.PlainText || progressLine < 0) - return; - - // Crop name to max 20 chars - name = name.Length > 20 ? name[..20] : name; - - lock (_consoleLock) - { - var width = Math.Max(20, Console.WindowWidth - 20); - - // Reserve space for name + space - var namePrefix = name + " "; - var barWidth = Math.Max(10, width - namePrefix.Length); - - var filled = (int)(progress * barWidth); - if (filled < 0) filled = 0; - if (filled > barWidth) filled = barWidth; - - // --- NEW: skip drawing if visually unchanged --- - if (_progressTrack.TryGetValue(progressLine, out var lastFilled) && - lastFilled == filled) - { - return; // no visual change → skip - } - - _progressTrack[progressLine] = filled; - // ------------------------------------------------ - - var barLine = _logLines + 1 + progressLine * 2; - var infoLine = _logLines + 2 + progressLine * 2; - - // Draw progress bar - Console.SetCursorPosition(0, barLine); - Console.Write("\u001b[38;2;0;255;0m"); // green - Console.Write(namePrefix); - Console.Write("["); - Console.Write(new string('#', filled)); - Console.Write(new string('-', barWidth - filled)); - Console.Write("]\u001b[0m"); - - // Info line - Console.SetCursorPosition(0, infoLine); - - var etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds) - ? "ETA: --:--" - : $"ETA: {eta:mm\\:ss}"; - - var speedStr = double.IsNaN(speed) || double.IsInfinity(speed) - ? "Speed: -.-x" - : $"Speed: {speed:F2}x"; - - var info = $"{progress * 100:0.0}% {etaStr} {speedStr} "; - - Console.Write("\u001b[38;2;180;180;180m" + - info.PadRight(Console.WindowWidth - 1) + - "\u001b[0m"); - } - } - - - public void ClearProgress(int progressLevel) - { - if (cmd.PlainText || progressLevel < 0) - return; - - lock (_consoleLock) - { - var barLine = _logLines + 1 + progressLevel * 2; - var infoLine = _logLines + 2 + progressLevel * 2; - - // Clear bar line - Console.SetCursorPosition(0, barLine); - Console.Write(new string(' ', Console.WindowWidth - 1)); - - // Clear info line - Console.SetCursorPosition(0, infoLine); - Console.Write(new string(' ', Console.WindowWidth - 1)); - } - } - -} diff --git a/SpectreConsoleLogger.cs b/SpectreConsoleLogger.cs new file mode 100644 index 0000000..1e36ae8 --- /dev/null +++ b/SpectreConsoleLogger.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace splitter; + + +/// +/// Spectre.Console-based live TUI logger. +/// - Title centered at top of outer box +/// - Progress section (N rows) with name, gradient bar, %, ETA, FPS +/// - Log section taking remaining space, auto-scrolling to latest messages +/// - Bottom row with a [ Cancel ] button (dummy handler: key 'c' / 'C') +/// - Resizes with console window +/// +public sealed class SpectreConsoleLogger : ILogger, IDisposable +{ + private readonly object _sync = new(); + private readonly List _logs = new(); + private readonly Dictionary _progress = new(); + + private readonly CancellationTokenSource _cts = new(); + private Task? _uiTask; + private Task? _inputTask; + + // Public configuration + public string Title { get; set; } = string.Empty; + + /// + /// Number of logical progress rows. UI reacts dynamically. + /// + public int NumberOfProcesses + { + get => _numberOfProcesses; + set + { + lock (_sync) + { + _numberOfProcesses = Math.Max(1, value); + for (int i = 0; i < _numberOfProcesses; i++) + { + if (!_progress.ContainsKey(i)) + _progress[i] = ProgressEntry.Empty; + } + } + } + } + + private int _numberOfProcesses = 1; + + private const int MaxLogEntries = 500; + + public SpectreConsoleLogger() + { + NumberOfProcesses = 1; + } + + // ---- ILogger ---- + + public void ClearProgress(int progressLevel) + { + lock (_sync) + { + _progress[progressLevel] = ProgressEntry.Empty; + } + } + + public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) + { + lock (_sync) + { + if (progressLine < 0) + return; + + if (progressLine >= NumberOfProcesses) + NumberOfProcesses = progressLine + 1; + + _progress[progressLine] = new ProgressEntry( + name ?? string.Empty, + Math.Clamp(progress, 0.0, 1.0), + eta, + speed + ); + } + } + + public void Log(string prefix, ConsoleColor color, string msg) + { + lock (_sync) + { + if (_logs.Count >= MaxLogEntries) + _logs.RemoveRange(0, _logs.Count - MaxLogEntries + 1); + + _logs.Add(new LogEntry( + DateTime.Now, + prefix ?? string.Empty, + color, + msg ?? string.Empty + )); + } + } + + // ---- UI lifecycle ---- + + /// + /// Starts the live TUI loop. This method blocks until the cancellation token is triggered. + /// + public Task RunAsync(CancellationToken cancellationToken = default) + { + if (_uiTask != null) + throw new InvalidOperationException("UI already started."); + + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellationToken); + var token = linkedCts.Token; + + _uiTask = Task.Run(() => RunUiAsync(token), token); + _inputTask = Task.Run(() => RunInputLoopAsync(token), token); + + return _uiTask; + } + + private async Task RunUiAsync(CancellationToken token) + { + await AnsiConsole.Live(BuildRoot()) + .AutoClear(false) + .StartAsync(async ctx => + { + while (!token.IsCancellationRequested) + { + ctx.UpdateTarget(BuildRoot()); + await Task.Delay(100, token); + } + }); + } + + private async Task RunInputLoopAsync(CancellationToken token) + { + // Dummy handler for Cancel button: press 'c' or 'C' + while (!token.IsCancellationRequested) + { + if (Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true); + if (key.KeyChar == 'c' || key.KeyChar == 'C') + { + ((ILogger)this).LogWarn("Cancel button pressed (dummy handler)."); + } + } + + await Task.Delay(50, token).ConfigureAwait(false); + } + } + + // ---- Rendering ---- + + private IRenderable BuildRoot() + { + List progressSnapshot; + List logSnapshot; + string titleSnapshot; + int numberOfProcessesSnapshot; + + lock (_sync) + { + titleSnapshot = Title; + numberOfProcessesSnapshot = NumberOfProcesses; + progressSnapshot = Enumerable.Range(0, numberOfProcessesSnapshot) + .Select(i => _progress.TryGetValue(i, out var p) ? p : ProgressEntry.Empty) + .ToList(); + logSnapshot = _logs.ToList(); + } + + var layout = new Layout("root") + .SplitRows( + new Layout("progress") { Size = Math.Max(3, numberOfProcessesSnapshot + 2) }, + new Layout("log"), + new Layout("buttons") { Size = 3 } + ); + + layout["progress"].Update(BuildProgressPanel(progressSnapshot)); + layout["log"].Update(BuildLogPanel(logSnapshot)); + layout["buttons"].Update(BuildButtonsPanel()); + return layout; + } + + private static IRenderable BuildProgressPanel(IReadOnlyList entries) + { + var table = new Table() + .Expand() + .Border(TableBorder.Rounded) + .AddColumn("[bold]Name[/]") + .AddColumn("[bold]Progress[/]") + .AddColumn("[bold]%[/]") + .AddColumn("[bold]ETA[/]") + .AddColumn("[bold]FPS[/]"); + + foreach (var entry in entries) + { + var bar = new DynamicGradientBar(entry.Progress); + + var percentText = $"{entry.Progress * 100:0.0}%"; + var etaText = entry.Eta == TimeSpan.Zero + ? "--:--" + : $"{(int)entry.Eta.TotalMinutes:00}:{entry.Eta.Seconds:00}"; + var fpsText = entry.Speed <= 0 ? "-" : $"{entry.Speed:0.0}"; + + table.AddRow( + new Markup(Escape(entry.Name)), + bar, + new Markup(percentText), + new Markup(etaText), + new Markup(fpsText) + ); + } + return table; + } + + private static IRenderable BuildLogPanel(IReadOnlyList logs) + { + const int maxVisible = 200; + var slice = logs.Count > maxVisible + ? logs.Skip(logs.Count - maxVisible).ToList() + : logs.ToList(); + + var rows = new List(); + + foreach (var log in slice) + { + var time = log.Timestamp.ToString("HH:mm:ss"); + var timeColor = "deepskyblue1"; // dark-ish blue + var prefixColor = "lightpink1"; // light magenta + var msgColor = MapConsoleColor(log.Color); + + var line = + $"[{timeColor}]{Escape(time)}[/] " + + $"[{prefixColor}]{Escape(log.Prefix)}[/] " + + $"[{msgColor}]{Escape(log.Message)}[/]"; + + rows.Add(new Markup(line)); + } + + IRenderable content = + rows.Count == 0 + ? new Markup("[grey]No log messages yet.[/]") + : new Rows(rows); + + var panel = new Panel(content) + { + Header = new PanelHeader("Log", Justify.Left), + Border = BoxBorder.Rounded, + Expand = true + }; + + return panel; + } + + + private static IRenderable BuildButtonsPanel() + { + // Visual [ Cancel ] button; key handling is in RunInputLoopAsync + var text = new Markup("[bold white on red] Cancel [/]"); + var grid = new Grid(); + grid.AddColumn(new GridColumn().Centered()); + grid.AddRow(text); + + var panel = new Panel(grid) + { + Border = BoxBorder.Rounded, + Header = new PanelHeader("Actions", Justify.Left), + Expand = true + }; + + return panel; + } + + // ---- Helpers ---- + + private static string Escape(string value) => + value is null ? string.Empty : Markup.Escape(value); + + private static string MapConsoleColor(ConsoleColor color) => + color switch + { + ConsoleColor.Black => "black", + ConsoleColor.DarkBlue => "navy", + ConsoleColor.DarkGreen => "green", + ConsoleColor.DarkCyan => "teal", + ConsoleColor.DarkRed => "maroon", + ConsoleColor.DarkMagenta => "purple", + ConsoleColor.DarkYellow => "olive", + ConsoleColor.Gray => "silver", + ConsoleColor.DarkGray => "grey", + ConsoleColor.Blue => "blue", + ConsoleColor.Green => "lime", + ConsoleColor.Cyan => "aqua", + ConsoleColor.Red => "red", + ConsoleColor.Magenta => "fuchsia", + ConsoleColor.Yellow => "yellow", + ConsoleColor.White => "white", + _ => "white" + }; + + /// + /// Renders a horizontal gradient bar (blue → yellow → green) for the given progress [0..1]. + /// + private static string RenderGradientBar(double progress, int width) + { + progress = Math.Clamp(progress, 0.0, 1.0); + if (width <= 0) + return string.Empty; + + int filled = (int)Math.Round(progress * width); + int empty = width - filled; + + if (filled <= 0) + return $"[grey]{new string('─', width)}[/]"; + + // Split filled part into three segments: blue / yellow / green + // low progress: mostly blue; mid: yellow; high: green + int blueCount = (int)Math.Round(filled * 0.33); + int yellowCount = (int)Math.Round(filled * 0.34); + int greenCount = filled - blueCount - yellowCount; + + var sb = new StringBuilder(); + + if (blueCount > 0) + { + sb.Append("[blue]"); + sb.Append(new string('█', blueCount)); + sb.Append("[/]"); + } + + if (yellowCount > 0) + { + sb.Append("[yellow]"); + sb.Append(new string('█', yellowCount)); + sb.Append("[/]"); + } + + if (greenCount > 0) + { + sb.Append("[green]"); + sb.Append(new string('█', greenCount)); + sb.Append("[/]"); + } + + if (empty > 0) + { + sb.Append("[grey]"); + sb.Append(new string('─', empty)); + sb.Append("[/]"); + } + + return sb.ToString(); + } + + // ---- Types & disposal ---- + + private readonly record struct ProgressEntry( + string Name, + double Progress, + TimeSpan Eta, + double Speed) + { + public static ProgressEntry Empty => new(string.Empty, 0.0, TimeSpan.Zero, 0.0); + } + + private readonly record struct LogEntry( + DateTime Timestamp, + string Prefix, + ConsoleColor Color, + string Message); + + public void Dispose() + { + _cts.Cancel(); + try + { + _uiTask?.Wait(500); + _inputTask?.Wait(500); + } + catch + { + // ignore + } + _cts.Dispose(); + } + + private sealed class DynamicGradientBar : IRenderable + { + private readonly double _progress; + + public DynamicGradientBar(double progress) + { + _progress = Math.Clamp(progress, 0, 1); + } + + public Measurement Measure(RenderOptions options, int maxWidth) + { + // Use the full width Spectre gives us + var width = Math.Max(1, maxWidth); + return new Measurement(width, width); + } + + public IEnumerable Render(RenderOptions options, int maxWidth) + { + var width = Math.Max(1, maxWidth); + + // Your gradient bar string WITH markup + var bar = RenderGradientBar(_progress, width); + + // Wrap it in a Markup renderable + var markup = new Markup(bar); + + // Correct: delegate rendering to Markup + foreach (var segment in ((IRenderable)markup).Render(options, maxWidth)) + yield return segment; + } + } + +} diff --git a/TextLogger.cs b/TextLogger.cs new file mode 100644 index 0000000..8651a9d --- /dev/null +++ b/TextLogger.cs @@ -0,0 +1,18 @@ +namespace splitter; + +public class TextLogger() : ILogger +{ + readonly object _consoleLock = new(); + + public void Log(string prefix, ConsoleColor color, string msg) + { + lock (_consoleLock) + { + Console.WriteLine($"{prefix} {msg}"); + } + } + + public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed) {} + public void ClearProgress(int progressLevel){} + +} diff --git a/TrackingSplitter.cs b/TrackingSplitter.cs index 590e35e..f3a5c75 100644 --- a/TrackingSplitter.cs +++ b/TrackingSplitter.cs @@ -56,7 +56,7 @@ public class TrackingSplitter : LoggingBase, ISegmentProcessor, IDisposable var originalCropWidth = _cropWidth; var originalCropHeight = _cropHeight; - Console.WriteLine($"[TrackingSplitter] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}"); + LogInfo($"[TrackingSplitter] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}"); var encWidth = _debugOverlay ? videoWidth : originalCropWidth; var encHeight = _debugOverlay ? videoHeight : originalCropHeight; diff --git a/splitter.cs b/splitter.cs index 258b80a..c7c8304 100644 --- a/splitter.cs +++ b/splitter.cs @@ -1,20 +1,58 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using Spectre.Console; using splitter; static class Program { private static ILogger _logger = null!; - static async Task Main(string[] args) + static async Task Main(string[] args) { + Task? uiTask = null; + var cmd = new CommandLine(args); - _logger = new Logger(cmd); - + if ( !cmd.IsValid) + return -1; + + if (cmd.PlainText) + { + _logger = new TextLogger(); + } + else + { + Console.SetBufferSize(Console.WindowWidth, Console.BufferHeight); + + var logger = new SpectreConsoleLogger + { + Title = "Splitter", + NumberOfProcesses = cmd.SingleThreaded ? 1 : Math.Max(1, Environment.ProcessorCount / 2) + }; + _logger = logger; + + using var cts = new CancellationTokenSource(); + + uiTask = logger.RunAsync(cts.Token); + } + + var success = await ProcessAll(cmd); + + if (uiTask != null) + { + await uiTask; + } + if (_logger is IDisposable disposable) + disposable.Dispose(); + + return success ? 1 : 0; + } + + private static async Task ProcessAll(CommandLine cmd) + { if (!File.Exists(cmd.InputFile)) { LogError("Input file not found."); - return; + return false; } if (!Directory.Exists(cmd.OutputFolder)) @@ -28,7 +66,7 @@ static class Program if (duration <= 0) { LogError("Could not read duration."); - return; + return false; } var target = cmd.OverrideTargetDuration ?? 58.0; @@ -58,7 +96,7 @@ static class Program LogInfo(cmd.ForceFixed ? $"Fixed segment length: {segmentLength:F2}s (last may be shorter)" : $"Equalized segment length: {segmentLength:F2}s"); - return; + return false; } LogInfo($"Duration: {duration:F2}s"); @@ -95,6 +133,7 @@ static class Program } LogInfo("Done."); + return true; } private static void LogInfo(string message) diff --git a/splitter.csproj b/splitter.csproj index e33bf69..1958ec5 100644 --- a/splitter.csproj +++ b/splitter.csproj @@ -45,6 +45,7 @@ +