diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d8b4808..da36337 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -147,7 +147,15 @@ "Bash(git log:*)", "Bash(git rm:*)", "Bash(git checkout:*)", - "Bash(ffplay:*)" + "Bash(ffplay:*)", + "WebFetch(domain:humanfactors.arc.nasa.gov)", + "WebFetch(domain:developer.nvidia.com)", + "WebFetch(domain:bartwronski.com)", + "WebFetch(domain:surma.dev)", + "Bash(\"E:\\\\source\\\\vectorascii\\\\ConsoleImage\\\\test-publish\\\\consoleimage.exe\" tools --verify)", + "Bash(git stash:*)", + "WebFetch(domain:news.ycombinator.com)", + "Bash(git status:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/ConsoleImage.Benchmarks/BrailleRendererBenchmarks.cs b/ConsoleImage.Benchmarks/BrailleRendererBenchmarks.cs index 588ef3e..5628257 100644 --- a/ConsoleImage.Benchmarks/BrailleRendererBenchmarks.cs +++ b/ConsoleImage.Benchmarks/BrailleRendererBenchmarks.cs @@ -12,7 +12,7 @@ namespace ConsoleImage.Benchmarks; /// Run with: dotnet run -c Release -- --filter *Braille* /// [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net90)] +[SimpleJob] public class BrailleRendererBenchmarks { private Image _largeImage = null!; @@ -130,7 +130,7 @@ public string RenderSmall_NoColor() /// Benchmarks for brightness calculation optimizations. /// [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net90)] +[SimpleJob] public class BrightnessCalculationBenchmarks { private float[] _largeBuffer = null!; @@ -226,7 +226,7 @@ private static (float min, float max) GetMinMaxUnrolled(float[] buffer) /// Benchmarks for ANSI escape sequence generation. /// [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net90)] +[SimpleJob] public class AnsiEscapeBenchmarks { private static readonly string[] GreyscaleEscapes = InitGreyscale(); diff --git a/ConsoleImage.Benchmarks/ShapeMatchingBenchmarks.cs b/ConsoleImage.Benchmarks/ShapeMatchingBenchmarks.cs new file mode 100644 index 0000000..cafa625 --- /dev/null +++ b/ConsoleImage.Benchmarks/ShapeMatchingBenchmarks.cs @@ -0,0 +1,200 @@ +using BenchmarkDotNet.Attributes; +using ConsoleImage.Core; + +namespace ConsoleImage.Benchmarks; + +/// +/// Benchmarks for BrailleCharacterMap shape vector matching. +/// Run with: dotnet run -c Release -- --filter *ShapeMatching* +/// +[MemoryDiagnoser] +[SimpleJob] +public class BrailleShapeMatchingBenchmarks +{ + private BrailleCharacterMap _brailleMap = null!; + private float[][] _randomVectors = null!; + private float[][] _sparseVectors = null!; + private float[][] _denseVectors = null!; + + [GlobalSetup] + public void Setup() + { + _brailleMap = new BrailleCharacterMap(); + + var random = new Random(42); + + // Random 8D vectors (covers full range) + _randomVectors = new float[1000][]; + for (var i = 0; i < _randomVectors.Length; i++) + { + _randomVectors[i] = new float[8]; + for (var j = 0; j < 8; j++) + _randomVectors[i][j] = (float)random.NextDouble(); + } + + // Sparse vectors (mostly 0, few dots on - like thin lines) + _sparseVectors = new float[1000][]; + for (var i = 0; i < _sparseVectors.Length; i++) + { + _sparseVectors[i] = new float[8]; + for (var j = 0; j < 8; j++) + _sparseVectors[i][j] = random.NextDouble() < 0.25 ? (float)random.NextDouble() : 0f; + } + + // Dense vectors (mostly 1, few dots off - like filled areas) + _denseVectors = new float[1000][]; + for (var i = 0; i < _denseVectors.Length; i++) + { + _denseVectors[i] = new float[8]; + for (var j = 0; j < 8; j++) + _denseVectors[i][j] = random.NextDouble() < 0.25 ? (float)random.NextDouble() : 1f; + } + } + + [Benchmark(Baseline = true)] + public char FindBestMatch_Random_Cached() + { + char result = ' '; + for (var i = 0; i < _randomVectors.Length; i++) + result = _brailleMap.FindBestMatch(_randomVectors[i]); + return result; + } + + [Benchmark] + public char FindBestMatch_Random_BruteForce() + { + char result = ' '; + for (var i = 0; i < _randomVectors.Length; i++) + result = _brailleMap.FindBestMatchBruteForce(_randomVectors[i]); + return result; + } + + [Benchmark] + public char FindBestMatch_Sparse() + { + char result = ' '; + for (var i = 0; i < _sparseVectors.Length; i++) + result = _brailleMap.FindBestMatch(_sparseVectors[i]); + return result; + } + + [Benchmark] + public char FindBestMatch_Dense() + { + char result = ' '; + for (var i = 0; i < _denseVectors.Length; i++) + result = _brailleMap.FindBestMatch(_denseVectors[i]); + return result; + } +} + +/// +/// Benchmarks for ASCII CharacterMap with different character set sizes. +/// Measures the impact of the expanded full-printable character set. +/// Run with: dotnet run -c Release -- --filter *CharacterSet* +/// +[MemoryDiagnoser] +[SimpleJob] +public class CharacterSetBenchmarks +{ + private CharacterMap _classicMap = null!; // 70 chars (old default) + private CharacterMap _fullMap = null!; // 95 chars (new default) + private CharacterMap _extendedMap = null!; // 93 chars + private ShapeVector[] _testVectors = null!; + + [GlobalSetup] + public void Setup() + { + _classicMap = new CharacterMap(CharacterMap.ClassicCharacterSet); + _fullMap = new CharacterMap(CharacterMap.DefaultCharacterSet); + _extendedMap = new CharacterMap(CharacterMap.ExtendedCharacterSet); + + var random = new Random(42); + _testVectors = new ShapeVector[1000]; + for (var i = 0; i < _testVectors.Length; i++) + _testVectors[i] = new ShapeVector( + (float)random.NextDouble(), + (float)random.NextDouble(), + (float)random.NextDouble(), + (float)random.NextDouble(), + (float)random.NextDouble(), + (float)random.NextDouble() + ); + } + + [Benchmark(Baseline = true)] + public char Classic_70Chars() + { + char result = ' '; + for (var i = 0; i < _testVectors.Length; i++) + result = _classicMap.FindBestMatch(_testVectors[i]); + return result; + } + + [Benchmark] + public char Full_95Chars() + { + char result = ' '; + for (var i = 0; i < _testVectors.Length; i++) + result = _fullMap.FindBestMatch(_testVectors[i]); + return result; + } + + [Benchmark] + public char Extended_93Chars() + { + char result = ' '; + for (var i = 0; i < _testVectors.Length; i++) + result = _extendedMap.FindBestMatch(_testVectors[i]); + return result; + } + + [Benchmark] + public char Classic_BruteForce() + { + char result = ' '; + for (var i = 0; i < _testVectors.Length; i++) + result = _classicMap.FindBestMatchBruteForce(_testVectors[i]); + return result; + } + + [Benchmark] + public char Full_BruteForce() + { + char result = ' '; + for (var i = 0; i < _testVectors.Length; i++) + result = _fullMap.FindBestMatchBruteForce(_testVectors[i]); + return result; + } +} + +/// +/// Benchmarks for disk cache performance (CharacterMap startup). +/// Run with: dotnet run -c Release -- --filter *DiskCache* +/// +[MemoryDiagnoser] +[SimpleJob] +[IterationCount(5)] +[WarmupCount(2)] +public class DiskCacheBenchmarks +{ + [Benchmark(Baseline = true)] + public CharacterMap CreateMap_Classic_CacheHit() + { + // Second creation should hit disk cache + return new CharacterMap(CharacterMap.ClassicCharacterSet); + } + + [Benchmark] + public CharacterMap CreateMap_Full_CacheHit() + { + return new CharacterMap(CharacterMap.DefaultCharacterSet); + } + + [Benchmark] + public BrailleCharacterMap CreateBrailleMap() + { + // Mathematical generation - no disk cache needed + return new BrailleCharacterMap(); + } +} diff --git a/ConsoleImage.Core/AnsiCodes.cs b/ConsoleImage.Core/AnsiCodes.cs index ef9b736..cd19a1e 100644 --- a/ConsoleImage.Core/AnsiCodes.cs +++ b/ConsoleImage.Core/AnsiCodes.cs @@ -151,6 +151,176 @@ public static void AppendForeground(StringBuilder sb, Rgb24 color) sb.Append('m'); } + // ── 256-color and 16-color support ── + + // Standard 16-color ANSI palette (approximate RGB values) + private static readonly (byte R, byte G, byte B)[] Ansi16Colors = + { + (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), + (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), + (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255) + }; + + /// + /// Convert RGB to nearest 256-color palette index. + /// Uses 6x6x6 color cube (indices 16-231) or grey ramp (232-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int RgbTo256(byte r, byte g, byte b) + { + // Check if greyscale (use grey ramp for better precision) + if (r == g && g == b) + { + if (r < 8) return 16; // black + if (r > 248) return 231; // white + return 232 + (int)Math.Round((r - 8) / 247.0 * 23); + } + + // Map to 6x6x6 color cube + var ri = (int)Math.Round(r / 255.0 * 5); + var gi = (int)Math.Round(g / 255.0 * 5); + var bi = (int)Math.Round(b / 255.0 * 5); + return 16 + 36 * ri + 6 * gi + bi; + } + + /// + /// Convert RGB to nearest 16-color ANSI index. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int RgbTo16(byte r, byte g, byte b) + { + var bestIdx = 0; + var bestDist = int.MaxValue; + for (var i = 0; i < 16; i++) + { + var dr = r - Ansi16Colors[i].R; + var dg = g - Ansi16Colors[i].G; + var db = b - Ansi16Colors[i].B; + var dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + + return bestIdx; + } + + /// + /// Convert a brightness value (0-255) to a 256-color grey ramp index (232-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int BrightnessToGrey256(byte brightness) + { + if (brightness < 8) return 232; + if (brightness > 248) return 255; + return 232 + (int)Math.Round((brightness - 8) / 247.0 * 23); + } + + /// + /// Append foreground color using 256-color palette. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendForeground256(StringBuilder sb, byte r, byte g, byte b) + { + sb.Append("\x1b[38;5;"); + sb.Append(RgbTo256(r, g, b)); + sb.Append('m'); + } + + /// + /// Append foreground color using 16-color ANSI codes. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendForeground16(StringBuilder sb, byte r, byte g, byte b) + { + var idx = RgbTo16(r, g, b); + sb.Append("\x1b["); + sb.Append(idx < 8 ? 30 + idx : 82 + idx); // 30-37 or 90-97 + sb.Append('m'); + } + + /// + /// Append foreground color using the specified color depth. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendForegroundAdaptive(StringBuilder sb, byte r, byte g, byte b, ColorDepth depth) + { + switch (depth) + { + case ColorDepth.Palette256: + AppendForeground256(sb, r, g, b); + break; + case ColorDepth.Palette16: + AppendForeground16(sb, r, g, b); + break; + default: + sb.Append("\x1b[38;2;"); + sb.Append(r); + sb.Append(';'); + sb.Append(g); + sb.Append(';'); + sb.Append(b); + sb.Append('m'); + break; + } + } + + /// + /// Append foreground and background colors using the specified color depth. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendForegroundAndBackgroundAdaptive( + StringBuilder sb, Rgba32 foreground, Rgba32 background, ColorDepth depth) + { + switch (depth) + { + case ColorDepth.Palette256: + sb.Append("\x1b[38;5;"); + sb.Append(RgbTo256(foreground.R, foreground.G, foreground.B)); + sb.Append(";48;5;"); + sb.Append(RgbTo256(background.R, background.G, background.B)); + sb.Append('m'); + break; + case ColorDepth.Palette16: + var fgIdx = RgbTo16(foreground.R, foreground.G, foreground.B); + var bgIdx = RgbTo16(background.R, background.G, background.B); + sb.Append("\x1b["); + sb.Append(fgIdx < 8 ? 30 + fgIdx : 82 + fgIdx); + sb.Append(';'); + sb.Append(bgIdx < 8 ? 40 + bgIdx : 92 + bgIdx); + sb.Append('m'); + break; + default: + AppendForegroundAndBackground(sb, foreground, background); + break; + } + } + + /// + /// Append reset and foreground color using the specified color depth. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendResetAndForegroundAdaptive( + StringBuilder sb, Rgba32 color, ColorDepth depth) + { + sb.Append(Reset); + AppendForegroundAdaptive(sb, color.R, color.G, color.B, depth); + } + + /// + /// Append foreground greyscale using 256-color grey ramp (232-255). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AppendForegroundGrey256(StringBuilder sb, byte brightness) + { + sb.Append("\x1b[38;5;"); + sb.Append(BrightnessToGrey256(brightness)); + sb.Append('m'); + } + /// /// Check if two Rgba32 colors are equal (for color change detection). /// diff --git a/ConsoleImage.Core/AsciiRenderer.cs b/ConsoleImage.Core/AsciiRenderer.cs index 87047b6..c244870 100644 --- a/ConsoleImage.Core/AsciiRenderer.cs +++ b/ConsoleImage.Core/AsciiRenderer.cs @@ -23,12 +23,12 @@ public class AsciiRenderer : IDisposable // [3] [4] [5] <- Bottom row private static readonly (float X, float Y)[] InternalSamplingPositions = [ - (0.17f, 0.30f), // Top-left (lowered) + (0.17f, 0.25f), // Top-left (0.50f, 0.25f), // Top-center - (0.83f, 0.20f), // Top-right (raised) - (0.17f, 0.80f), // Bottom-left (lowered) + (0.83f, 0.25f), // Top-right + (0.17f, 0.75f), // Bottom-left (0.50f, 0.75f), // Bottom-center - (0.83f, 0.70f) // Bottom-right (raised) + (0.83f, 0.75f) // Bottom-right ]; // Pre-computed sin/cos lookup tables for circle sampling (major performance optimization) @@ -52,13 +52,13 @@ private static readonly (float X, float Y)[] ExternalSamplingPositions = (0.17f, -0.10f), // Above top-left (0.50f, -0.10f), // Above top-center (0.83f, -0.10f), // Above top-right - (-0.15f, 0.30f), // Left of top-left - (1.15f, 0.20f), // Right of top-right - (-0.15f, 0.80f), // Left of bottom-left - (1.15f, 0.70f), // Right of bottom-right - (0.17f, 1.10f), // Below bottom-left - (0.50f, 1.10f), // Below bottom-center - (0.83f, 1.10f) // Below bottom-right + (-0.15f, 0.25f), // Left of top-left + (1.15f, 0.25f), // Right of top-right + (-0.15f, 0.75f), // Left of bottom-left + (1.15f, 0.75f), // Right of bottom-right + (0.17f, 1.10f), // Below bottom-left + (0.50f, 1.10f), // Below bottom-center + (0.83f, 1.10f) // Below bottom-right ]; private readonly CharacterMap _characterMap; @@ -71,6 +71,7 @@ private static readonly (float X, float Y)[] ExternalSamplingPositions = public AsciiRenderer(RenderOptions? options = null) { + ConsoleHelper.EnableAnsiSupport(); _options = options ?? RenderOptions.Default; _characterMap = new CharacterMap( _options.CharacterSet, diff --git a/ConsoleImage.Core/BrailleCharacterMap.cs b/ConsoleImage.Core/BrailleCharacterMap.cs new file mode 100644 index 0000000..4783c16 --- /dev/null +++ b/ConsoleImage.Core/BrailleCharacterMap.cs @@ -0,0 +1,184 @@ +// Braille character shape vector matching +// Mathematical generation of 8D vectors for all 256 braille patterns +// Uses SIMD brute force matching (256 vectors = faster than any graph traversal) + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; + +namespace ConsoleImage.Core; + +/// +/// Generates 8D shape vectors for all 256 braille patterns mathematically. +/// Each braille code (0x00-0xFF) has 8 dots in a 2x4 grid. +/// Vector component = 1.0 if dot ON, 0.0 if dot OFF. +/// No font rendering needed - braille patterns are defined by Unicode standard. +/// +public class BrailleCharacterMap +{ + /// + /// Braille dot bit positions in standard Unicode encoding: + /// Pos: 1 4 Bits: 0x01 0x08 + /// 2 5 0x02 0x10 + /// 3 6 0x04 0x20 + /// 7 8 0x40 0x80 + /// Index order matches 2x4 grid: [row0col0, row0col1, row1col0, row1col1, ...] + /// + private static readonly int[] DotBits = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]; + + private const char BrailleBase = '\u2800'; + private const int BrailleCount = 256; + + private readonly ConcurrentDictionary _cache = new(); + private readonly char[] _characters; // 256 braille chars + private readonly float[] _vectorData; // 256 * 8 floats (SIMD-aligned) + + // Cache statistics + private long _cacheHits; + private long _cacheMisses; + + public BrailleCharacterMap() + { + _characters = new char[BrailleCount]; + _vectorData = new float[BrailleCount * 8]; + + // Generate all 256 braille patterns mathematically + for (var code = 0; code < BrailleCount; code++) + { + _characters[code] = (char)(BrailleBase + code); + + var offset = code * 8; + for (var dot = 0; dot < 8; dot++) + { + // Check if this dot is ON in the braille code + _vectorData[offset + dot] = (code & DotBits[dot]) != 0 ? 1.0f : 0.0f; + } + } + } + + /// + /// Find the best matching braille character for an 8D target vector. + /// Uses quantized caching for repeated lookups. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public char FindBestMatch(ReadOnlySpan target8) + { + // Quantize to 4 bits per component: 8 components × 4 bits = 32 bits (fits in int) + var cacheKey = GetQuantizedKey(target8); + + if (_cache.TryGetValue(cacheKey, out var cached)) + { + Interlocked.Increment(ref _cacheHits); + return cached; + } + + Interlocked.Increment(ref _cacheMisses); + + var result = FindBestMatchBruteForce(target8); + return _cache.GetOrAdd(cacheKey, result); + } + + /// + /// Find best match using SIMD brute force. + /// With 256 vectors, this is faster than any tree structure. + /// + public char FindBestMatchBruteForce(ReadOnlySpan target8) + { + var bestDist = float.MaxValue; + var bestIdx = 0; + + if (Vector256.IsHardwareAccelerated) + { + // Load target vector once (all 8 floats fit in Vector256) + var targetVec = Vector256.Create( + target8[0], target8[1], target8[2], target8[3], + target8[4], target8[5], target8[6], target8[7]); + + for (var i = 0; i < BrailleCount; i++) + { + var offset = i * 8; + var charVec = Vector256.Create( + _vectorData[offset], _vectorData[offset + 1], + _vectorData[offset + 2], _vectorData[offset + 3], + _vectorData[offset + 4], _vectorData[offset + 5], + _vectorData[offset + 6], _vectorData[offset + 7]); + + var diff = targetVec - charVec; + var squared = diff * diff; + var dist = Vector256.Sum(squared); + + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + } + else + { + // Scalar fallback + for (var i = 0; i < BrailleCount; i++) + { + var offset = i * 8; + var dist = 0f; + for (var d = 0; d < 8; d++) + { + var diff = target8[d] - _vectorData[offset + d]; + dist += diff * diff; + } + + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + } + + return _characters[bestIdx]; + } + + /// + /// Quantize 8 float components to 4 bits each = 32-bit cache key. + /// Values are expected in [0, 1] range. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetQuantizedKey(ReadOnlySpan target8) + { + const int bitsPerComponent = 4; + const int levels = 1 << bitsPerComponent; // 16 + const int mask = levels - 1; // 0xF + const float scale = levels - 1; // 15.0 + + var key = 0; + for (var i = 0; i < 8; i++) + { + var q = Math.Clamp((int)(target8[i] * scale), 0, mask); + key |= q << (i * bitsPerComponent); + } + + return key; + } + + /// + /// Clear the lookup cache. + /// + public void ClearCache() + { + _cache.Clear(); + Interlocked.Exchange(ref _cacheHits, 0); + Interlocked.Exchange(ref _cacheMisses, 0); + } + + /// + /// Get cache statistics for performance analysis. + /// + public (long Hits, long Misses, int CacheSize, double HitRate) GetCacheStats() + { + var hits = Interlocked.Read(ref _cacheHits); + var misses = Interlocked.Read(ref _cacheMisses); + var total = hits + misses; + var hitRate = total > 0 ? (double)hits / total : 0.0; + return (hits, misses, _cache.Count, hitRate); + } +} diff --git a/ConsoleImage.Core/BrailleInterlacePlayer.cs b/ConsoleImage.Core/BrailleInterlacePlayer.cs new file mode 100644 index 0000000..6aa4ac2 --- /dev/null +++ b/ConsoleImage.Core/BrailleInterlacePlayer.cs @@ -0,0 +1,351 @@ +// EXPERIMENTAL: BrailleInterlacePlayer - Temporal super-resolution via rapid threshold cycling. +// Generates N braille frames with different Atkinson dithering thresholds and cycles +// them rapidly. The human visual system integrates the subframes, perceiving more +// tonal depth than any single frame can display. Modeled after LCD FRC and DLP +// temporal dithering techniques. +// +// Known issues: +// - Black horizontal bars appear between frames (screen clearing/cursor positioning bug) +// - Frame height mismatch between subframes causes visual artifacts + +using System.Diagnostics; +using System.Text; + +namespace ConsoleImage.Core; + +/// +/// EXPERIMENTAL: Plays braille interlace frames in a continuous rapid cycle for temporal super-resolution. +/// Each subframe uses a different brightness threshold; the viewer's eye averages them, +/// producing the illusion of more grey levels than braille dots can represent. +/// Known issues: black bars between frames due to screen clearing bug; frame height mismatches. +/// +public class BrailleInterlacePlayer : IDisposable +{ + private const string SyncStart = "\x1b[?2026h"; + private const string SyncEnd = "\x1b[?2026l"; + private const string AltScreenEnter = "\x1b[?1049h"; + private const string AltScreenExit = "\x1b[?1049l"; + private const string CursorHome = "\x1b[H"; + private const string CursorHide = "\x1b[?25l"; + private const string CursorShow = "\x1b[?25h"; + + private static readonly string[] CursorMoveCache = BuildCursorMoveCache(300); + + private readonly List _frames; + private readonly bool _useAltScreen; + private CancellationTokenSource? _cts; + private bool _disposed; + + public BrailleInterlacePlayer(List frames, bool useAltScreen = true) + { + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _useAltScreen = useAltScreen; + } + + /// + /// Whether the player is currently running. + /// + public bool IsPlaying { get; private set; } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + _cts?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Play interlace frames in a continuous loop until cancelled. + /// Supports Space to pause/resume and Q/Escape to quit. + /// + public async Task PlayAsync(CancellationToken externalCt = default) + { + if (_frames.Count == 0) return; + + ConsoleHelper.EnableAnsiSupport(); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); + var ct = _cts.Token; + + // Pre-build frame buffers with diff rendering + var frameBuffers = BuildFrameBuffers(); + + // Get delay from first frame (all frames share the same delay) + var subframeDelayMs = _frames[0].DelayMs; + if (subframeDelayMs <= 0) subframeDelayMs = 12; // ~80Hz default + + if (_useAltScreen) Console.Write(AltScreenEnter); + Console.Write(CursorHide); + Console.Write("\x1b[2J"); + Console.Out.Flush(); + + IsPlaying = true; + var paused = false; + var firstCycle = true; + + try + { + while (!ct.IsCancellationRequested) + { + // Check for keyboard input + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return; + + case ConsoleKey.Spacebar: + paused = !paused; + break; + } + } + + if (paused) + { + await Task.Delay(50, ct); + continue; + } + + double timeDebtMs = 0; + + for (var i = 0; i < _frames.Count; i++) + { + if (ct.IsCancellationRequested) break; + + // Check keyboard between subframes too + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return; + case ConsoleKey.Spacebar: + paused = !paused; + if (paused) break; + continue; + } + + if (paused) break; + } + + var renderStart = Stopwatch.GetTimestamp(); + + // On subsequent cycles, use wrap-around buffer for frame 0 + // to diff against last frame instead of full redraw + var buffer = (i == 0 && !firstCycle && _wrapBuffer != null) + ? _wrapBuffer + : frameBuffers[i]; + Console.Write(buffer); + Console.Out.Flush(); + + // Adaptive timing + var (remainingDelay, newDebt) = FrameTiming.CalculateAdaptiveDelay( + subframeDelayMs, renderStart, timeDebtMs); + timeDebtMs = newDebt; + if (remainingDelay > 0) + await FrameTiming.ResponsiveDelayAsync(remainingDelay, ct); + } + + firstCycle = false; + } + } + catch (OperationCanceledException) + { + // Normal cancellation + } + finally + { + IsPlaying = false; + Console.Write(CursorShow); + if (_useAltScreen) + Console.Write(AltScreenExit); + Console.Write("\x1b[0m"); + Console.Out.Flush(); + } + } + + /// + /// Stop playback. + /// + public void Stop() + { + _cts?.Cancel(); + } + + /// + /// Pre-build all frame buffers with diff-based rendering for minimal flicker. + /// + private string[] BuildFrameBuffers() + { + var frameBuffers = new string[_frames.Count]; + var sb = new StringBuilder(8192); + Span currStarts = stackalloc int[301]; + Span prevStarts = stackalloc int[301]; + + // Determine max height across all frames + var maxHeight = 0; + for (var i = 0; i < _frames.Count; i++) + { + var lineCount = 1; + foreach (var c in _frames[i].Content) + if (c == '\n') + lineCount++; + if (lineCount > maxHeight) + maxHeight = lineCount; + } + + for (var i = 0; i < _frames.Count; i++) + { + sb.Clear(); + sb.Append(SyncStart); + + if (i == 0) + { + // First frame: full redraw + sb.Append(CursorHome); + AppendWithLineClearing(sb, _frames[i].Content, maxHeight); + } + else + { + // Diff against previous frame + var curr = _frames[i].Content; + var prev = _frames[i - 1].Content; + var currLineCount = LineUtils.BuildLineStarts(curr, currStarts); + var prevLineCount = LineUtils.BuildLineStarts(prev, prevStarts); + var lineCount = Math.Max(currLineCount, prevLineCount); + var abandonThreshold = (int)(lineCount * 0.6) + 1; + + var diffStart = sb.Length; + var changes = 0; + + for (var line = 0; line < lineCount; line++) + { + var currLine = LineUtils.GetLineFromStarts(curr, currStarts, currLineCount, line); + var prevLine = LineUtils.GetLineFromStarts(prev, prevStarts, prevLineCount, line); + + if (!currLine.SequenceEqual(prevLine)) + { + changes++; + if (changes >= abandonThreshold) + { + sb.Length = diffStart; + sb.Append(CursorHome); + AppendWithLineClearing(sb, curr, maxHeight); + break; + } + + sb.Append(line < CursorMoveCache.Length + ? CursorMoveCache[line] + : $"\x1b[{line + 1};1H"); + sb.Append("\x1b[2K"); + sb.Append(currLine); + sb.Append("\x1b[0m"); + } + } + } + + // Also build diff from last frame back to first (for seamless loop) + sb.Append(SyncEnd); + frameBuffers[i] = sb.ToString(); + } + + // Build a wrap-around buffer: diff from last frame to first for seamless looping + sb.Clear(); + sb.Append(SyncStart); + { + var curr = _frames[0].Content; + var prev = _frames[^1].Content; + var currLineCount = LineUtils.BuildLineStarts(curr, currStarts); + var prevLineCount = LineUtils.BuildLineStarts(prev, prevStarts); + var lineCount = Math.Max(currLineCount, prevLineCount); + var abandonThreshold = (int)(lineCount * 0.6) + 1; + + var diffStart = sb.Length; + var changes = 0; + + for (var line = 0; line < lineCount; line++) + { + var currLine = LineUtils.GetLineFromStarts(curr, currStarts, currLineCount, line); + var prevLine = LineUtils.GetLineFromStarts(prev, prevStarts, prevLineCount, line); + + if (!currLine.SequenceEqual(prevLine)) + { + changes++; + if (changes >= abandonThreshold) + { + sb.Length = diffStart; + sb.Append(CursorHome); + AppendWithLineClearing(sb, curr, maxHeight); + break; + } + + sb.Append(line < CursorMoveCache.Length + ? CursorMoveCache[line] + : $"\x1b[{line + 1};1H"); + sb.Append("\x1b[2K"); + sb.Append(currLine); + sb.Append("\x1b[0m"); + } + } + } + sb.Append(SyncEnd); + + // Replace index 0 buffer with wrap-around for all loops after the first + // Store it so we can swap after first cycle + _wrapBuffer = sb.ToString(); + + return frameBuffers; + } + + private string? _wrapBuffer; + + /// Pre-computed wrap-around buffer used after the first loop cycle + /// to diff frame 0 against the last frame instead of doing a full redraw. + + private static void AppendWithLineClearing(StringBuilder sb, string content, int maxHeight) + { + var lineIdx = 0; + var lineStart = 0; + + for (var i = 0; i <= content.Length; i++) + { + if (i == content.Length || content[i] == '\n') + { + sb.Append("\x1b[2K"); + var end = i; + if (end > lineStart && content[end - 1] == '\r') end--; + if (end > lineStart) + sb.Append(content.AsSpan(lineStart, end - lineStart)); + sb.Append("\x1b[0m"); + lineIdx++; + + if (i < content.Length && lineIdx < maxHeight) + sb.Append('\n'); + + lineStart = i + 1; + } + } + + while (lineIdx < maxHeight) + { + sb.Append('\n'); + sb.Append("\x1b[2K"); + lineIdx++; + } + } + + private static string[] BuildCursorMoveCache(int maxLines) + { + var cache = new string[maxLines]; + for (var i = 0; i < maxLines; i++) + cache[i] = $"\x1b[{i + 1};1H"; + return cache; + } +} diff --git a/ConsoleImage.Core/BrailleRenderer.cs b/ConsoleImage.Core/BrailleRenderer.cs index 13fa08b..5587b73 100644 --- a/ConsoleImage.Core/BrailleRenderer.cs +++ b/ConsoleImage.Core/BrailleRenderer.cs @@ -101,6 +101,11 @@ public class BrailleRenderer : IDisposable 0xFF // 8 dots: ⣿ (full block) }; + // Pre-computed sin/cos for concentric ring sampling around braille dot centers + private static readonly (float Cos, float Sin)[] InnerRingAngles = PrecomputeAngles(4, 0); + private static readonly (float Cos, float Sin)[] OuterRingAngles = PrecomputeAngles(8, MathF.PI / 8); + + private readonly BrailleCharacterMap _brailleMap = new(); private readonly RenderOptions _options; // Reusable buffers to reduce GC pressure during video playback @@ -112,9 +117,91 @@ public class BrailleRenderer : IDisposable public BrailleRenderer(RenderOptions? options = null) { + ConsoleHelper.EnableAnsiSupport(); _options = options ?? new RenderOptions(); } + private static (float Cos, float Sin)[] PrecomputeAngles(int count, float offset) + { + var angles = new (float Cos, float Sin)[count]; + for (var i = 0; i < count; i++) + { + var angle = i * MathF.PI * 2 / count + offset; + angles[i] = (MathF.Cos(angle), MathF.Sin(angle)); + } + return angles; + } + + /// + /// Sample 8 braille dot positions from brightness data using concentric ring sampling. + /// Each dot center is sampled with rings for accuracy, converting brightness to coverage. + /// Dot centers in normalized cell coords: col 0 at x=0.25, col 1 at x=0.75 + /// Rows at y = 0.125, 0.375, 0.625, 0.875 (evenly spaced in 4 rows). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SampleBrailleCell(float[] brightness, int pixelWidth, int pixelHeight, + int px, int py, Span target8) + { + // Dot center positions in pixel coordinates within the 2x4 cell + // Row 0: y = py + 0.5, Row 1: y = py + 1.5, Row 2: y = py + 2.5, Row 3: y = py + 3.5 + // Col 0: x = px + 0.5, Col 1: x = px + 1.5 + // Sampling radius relative to half-pixel spacing + const float radius = 0.35f; + + for (var row = 0; row < 4; row++) + { + var centerY = py + row + 0.5f; + for (var col = 0; col < 2; col++) + { + var centerX = px + col + 0.5f; + var dotIdx = row * 2 + col; + + // Sample with concentric rings for accuracy + float total = 0; + var count = 0; + + // Center point + var ix = (int)centerX; + var iy = (int)centerY; + if ((uint)ix < (uint)pixelWidth && (uint)iy < (uint)pixelHeight) + { + total += 1f - brightness[iy * pixelWidth + ix]; + count++; + } + + // Inner ring (4 points at 0.5 * radius) + var innerR = radius * 0.5f; + for (var i = 0; i < 4; i++) + { + var (cos, sin) = InnerRingAngles[i]; + ix = (int)(centerX + cos * innerR); + iy = (int)(centerY + sin * innerR); + if ((uint)ix < (uint)pixelWidth && (uint)iy < (uint)pixelHeight) + { + total += 1f - brightness[iy * pixelWidth + ix]; + count++; + } + } + + // Outer ring (8 points at radius) + for (var i = 0; i < 8; i++) + { + var (cos, sin) = OuterRingAngles[i]; + ix = (int)(centerX + cos * radius); + iy = (int)(centerY + sin * radius); + if ((uint)ix < (uint)pixelWidth && (uint)iy < (uint)pixelHeight) + { + total += 1f - brightness[iy * pixelWidth + ix]; + count++; + } + } + + // Coverage: 0 = white/empty, 1 = black/filled + target8[dotIdx] = count > 0 ? total / count : 0f; + } + } + } + public void Dispose() { if (_disposed) return; @@ -172,21 +259,30 @@ public string RenderFile(string filePath) using var resized = image.Clone(ctx => ctx.Resize(pixelWidth, pixelHeight)); var (brightness, colors) = PrecomputePixelData(resized); - var (minBrightness, maxBrightness) = GetBrightnessRangeFromBuffer(brightness); var cells = new CellData[charHeight, charWidth]; + var invertMode = _options.Invert; // Parallel render to cell array Parallel.For(0, charHeight, cy => { - Span cellBrightness = stackalloc float[8]; - Span cellIndices = stackalloc int[8]; + Span target8 = stackalloc float[8]; for (var cx = 0; cx < charWidth; cx++) { var px = cx * 2; var py = cy * 4; + // Shape vector matching for braille character selection + SampleBrailleCell(brightness, pixelWidth, pixelHeight, px, py, target8); + + if (invertMode) + for (var i = 0; i < 8; i++) + target8[i] = 1f - target8[i]; + + var brailleChar = _brailleMap.FindBestMatch(target8); + + // Collect average color from all pixels in cell int totalR = 0, totalG = 0, totalB = 0; var colorCount = 0; @@ -201,25 +297,14 @@ public string RenderFile(string filePath) var imgX = px + dx; if (imgX >= pixelWidth) continue; - var idx = rowOffset + imgX; - var c = colors[idx]; + var c = colors[rowOffset + imgX]; totalR += c.R; totalG += c.G; totalB += c.B; - - cellBrightness[colorCount] = brightness[idx]; - cellIndices[colorCount] = dy * 2 + dx; colorCount++; } } - // Get braille character (always full block in color mode) - var brailleCode = _options.UseColor - ? CalculateHybridBrailleCode(cellBrightness, cellIndices, colorCount, - minBrightness, maxBrightness, _options.Invert) - : 0xFF; - var brailleChar = (char)(BrailleBase + brailleCode); - // Calculate average color byte r = 0, g = 0, b = 0; if (colorCount > 0) @@ -228,10 +313,8 @@ public string RenderFile(string filePath) g = (byte)(totalG / colorCount); b = (byte)(totalB / colorCount); - // Apply gamma correction and boost saturation/brightness for braille (r, g, b) = BoostBrailleColor(r, g, b, _options.Gamma); - // Apply color quantization for reduced palette / temporal stability var paletteSize = _options.ColorCount; if (paletteSize.HasValue && paletteSize.Value > 0) { @@ -312,11 +395,11 @@ public string RenderFile(string filePath) sb.Append('H'); // Output color if needed - if (_options.UseColor) + if (_options.UseColor || _options.UseGreyscaleAnsi) { if (!hasColor || current.R != lastR || current.G != lastG || current.B != lastB) { - AppendColorCode(sb, current.R, current.G, current.B); + AppendColorCode(sb, current.R, current.G, current.B, _options.ColorDepth, _options.UseGreyscaleAnsi); lastR = current.R; lastG = current.G; lastB = current.B; @@ -376,10 +459,10 @@ private string RenderCellsToString(CellData[,] cells) { var cell = cells[y, x]; - if (_options.UseColor) + if (_options.UseColor || _options.UseGreyscaleAnsi) { // Append color code - AppendColorCode(sb, cell.R, cell.G, cell.B); + AppendColorCode(sb, cell.R, cell.G, cell.B, _options.ColorDepth, _options.UseGreyscaleAnsi); // Run-length encode: collect consecutive cells with same color var runStart = x; @@ -410,8 +493,23 @@ private string RenderCellsToString(CellData[,] cells) /// Append ANSI color code efficiently, using pre-computed greyscale when possible. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void AppendColorCode(StringBuilder sb, byte r, byte g, byte b) + private static void AppendColorCode(StringBuilder sb, byte r, byte g, byte b, + ColorDepth depth = ColorDepth.TrueColor, bool greyscale = false) { + // Greyscale ANSI: convert to brightness and use 256-color grey ramp + if (greyscale) + { + var brightness = (byte)(0.299f * r + 0.587f * g + 0.114f * b); + AnsiCodes.AppendForegroundGrey256(sb, brightness); + return; + } + + if (depth != ColorDepth.TrueColor) + { + AnsiCodes.AppendForegroundAdaptive(sb, r, g, b, depth); + return; + } + // Use pre-computed escape for greyscale colors if (r == g && g == b) { @@ -437,8 +535,6 @@ public string RenderImage(Image image) { // Calculate output dimensions (each char = 2x4 braille dots) var (pixelWidth, pixelHeight) = CalculateBrailleDimensions(image.Width, image.Height); - var charWidth = pixelWidth / 2; - var charHeight = pixelHeight / 4; // Resize image var resized = image.Clone(ctx => ctx.Resize(pixelWidth, pixelHeight)); @@ -447,37 +543,96 @@ public string RenderImage(Image image) var (brightness, colors) = PrecomputePixelData(resized); // Calculate threshold using autocontrast - var (minBrightness, maxBrightness) = GetBrightnessRangeFromBuffer(brightness); var invertMode = _options.Invert; - // For COLOR mode: show most dots, only hide truly dark pixels - // The color carries the detail, so we want dense output + // For COLOR/GREYSCALE mode: show most dots, only hide truly dark pixels // For MONOCHROME mode: use Otsu's method for optimal separation float threshold; - if (_options.UseColor) - // Color mode: use generous threshold to show most content - // In invert mode (dark terminal): only hide very dark pixels (< 15%) - // In normal mode (light terminal): only hide very bright pixels (> 85%) + if (_options.UseColor || _options.UseGreyscaleAnsi) threshold = invertMode ? 0.15f : 0.85f; else - // Monochrome: use Otsu's method for best separation threshold = CalculateOtsuThreshold(brightness); // Apply Atkinson dithering for smooth gradients brightness = ApplyAtkinsonDithering(brightness, pixelWidth, pixelHeight, threshold); // After dithering, values are 0 or 1, so use 0.5 threshold - threshold = 0.5f; + var result = RenderBrailleContent(brightness, colors, pixelWidth, pixelHeight, 0.5f); + + resized.Dispose(); + return result; + } + + /// + /// EXPERIMENTAL: Generate multiple braille frames with different brightness thresholds + /// for temporal super-resolution (perceptual interlacing). + /// Each frame uses a slightly different dithering threshold; when played + /// rapidly, the human visual system integrates the frames and perceives + /// more tonal detail than any single frame can display. + /// Known issues: playback produces black bars due to clearing bugs in BrailleInterlacePlayer. + /// + public List RenderInterlaceFrames(Image image) + { + var frameCount = Math.Clamp(_options.InterlaceFrameCount, 2, 8); + var spread = Math.Clamp(_options.InterlaceSpread, 0.01f, 0.2f); + // Delay per subframe: distribute one visible frame period across N subframes + var delayMs = Math.Max(1, (int)(1000f / (_options.InterlaceFps * frameCount))); + + // Shared computation: resize and pixel data (expensive, done once) + var (pixelWidth, pixelHeight) = CalculateBrailleDimensions(image.Width, image.Height); + using var resized = image.Clone(ctx => ctx.Resize(pixelWidth, pixelHeight)); + var (baseBrightness, colors) = PrecomputePixelData(resized); + + // Calculate base threshold + var invertMode = _options.Invert; + float baseThreshold; + if (_options.UseColor || _options.UseGreyscaleAnsi) + baseThreshold = invertMode ? 0.15f : 0.85f; + else + baseThreshold = CalculateOtsuThreshold(baseBrightness); + + var frames = new List(frameCount); + + for (var f = 0; f < frameCount; f++) + { + // Spread thresholds evenly around the base. + // For 4 frames: biases are [-0.5, -0.167, +0.167, +0.5] * spread + var bias = spread * ((float)f / Math.Max(1, frameCount - 1) - 0.5f); + var threshold = Math.Clamp(baseThreshold + bias, 0.01f, 0.99f); + + // Apply Atkinson dithering with biased threshold (creates a new array) + var dithered = ApplyAtkinsonDithering(baseBrightness, pixelWidth, pixelHeight, threshold); + + // Render to braille string using post-dithering threshold of 0.5 + var content = RenderBrailleContent(dithered, colors, pixelWidth, pixelHeight, 0.5f); + frames.Add(new BrailleFrame(content, delayMs)); + } + + return frames; + } + + /// + /// Render pre-computed brightness and color data to a braille ANSI string. + /// Uses shape vector matching against all 256 braille patterns for optimal character selection. + /// This is the core rendering step shared by RenderImage and RenderInterlaceFrames. + /// + private string RenderBrailleContent(float[] brightness, Rgba32[] colors, + int pixelWidth, int pixelHeight, float threshold) + { + var charWidth = pixelWidth / 2; + var charHeight = pixelHeight / 4; + var invertMode = _options.Invert; + + // useAnsiOutput covers both full-color and greyscale ANSI modes + var useAnsiOutput = _options.UseColor || _options.UseGreyscaleAnsi; // Pre-size StringBuilder: each char cell needs ~20 bytes for ANSI codes + 1 char - // Plus newlines and resets - var estimatedSize = charWidth * charHeight * (_options.UseColor ? 25 : 1) + charHeight * 10; + var estimatedSize = charWidth * charHeight * (useAnsiOutput ? 25 : 1) + charHeight * 10; var sb = new StringBuilder(estimatedSize); - // Key insight: separate color and brightness concerns - // - COLOR: average ALL 8 pixels in cell (prevents solarization) - // - DOTS: show brightness detail via threshold + var colorDepth = _options.ColorDepth; + var greyscaleAnsi = _options.UseGreyscaleAnsi; - if (_options.UseColor && _options.UseParallelProcessing && charHeight > 4) + if (useAnsiOutput && _options.UseParallelProcessing && charHeight > 4) { // Parallel processing: compute each row independently, then combine var rowStrings = new string[charHeight]; @@ -486,21 +641,27 @@ public string RenderImage(Image image) { var rowSb = new StringBuilder(charWidth * 25); Rgba32? lastColor = null; - - // Pre-allocate cell buffers outside inner loop to avoid stack overflow - Span cellBrightness = stackalloc float[8]; - Span cellIndices = stackalloc int[8]; + Span target8 = stackalloc float[8]; for (var cx = 0; cx < charWidth; cx++) { var px = cx * 2; var py = cy * 4; - // Collect colors and brightness for the cell + // Sample 8 braille dot positions using concentric rings + SampleBrailleCell(brightness, pixelWidth, pixelHeight, px, py, target8); + + // Invert coverage if needed (swap dot on/off semantics) + if (invertMode) + for (var i = 0; i < 8; i++) + target8[i] = 1f - target8[i]; + + // Find best matching braille character via shape vector matching + var brailleChar = _brailleMap.FindBestMatch(target8); + + // Collect average color from all pixels in cell int totalR = 0, totalG = 0, totalB = 0; var colorCount = 0; - var cellMinBright = 1f; - var cellMaxBright = 0f; for (var dy = 0; dy < 4; dy++) { @@ -513,48 +674,24 @@ public string RenderImage(Image image) var imgX = px + dx; if (imgX >= pixelWidth) continue; - var idx = rowOffset + imgX; - var c = colors[idx]; + var c = colors[rowOffset + imgX]; totalR += c.R; totalG += c.G; totalB += c.B; - - var b = brightness[idx]; - cellBrightness[colorCount] = b; - cellIndices[colorCount] = dy * 2 + dx; - if (b < cellMinBright) cellMinBright = b; - if (b > cellMaxBright) cellMaxBright = b; colorCount++; } } - // Determine braille pattern using dithered threshold - var brailleCode = 0; - for (var i = 0; i < colorCount; i++) - { - var isDot = invertMode - ? cellBrightness[i] > threshold - : cellBrightness[i] < threshold; - - if (isDot) - brailleCode |= DotBits[cellIndices[i]]; - } - - var brailleChar = (char)(BrailleBase + brailleCode); - if (colorCount > 0) { var r = (byte)(totalR / colorCount); var g = (byte)(totalG / colorCount); var b = (byte)(totalB / colorCount); - // Apply gamma correction and boost saturation/brightness for braille - // Braille dots are sparse, so colors appear less vibrant (r, g, b) = BoostBrailleColor(r, g, b, _options.Gamma); // Skip absolute black characters (invisible on dark terminal) - // This reduces file size and improves rendering - if (r <= 2 && g <= 2 && b <= 2 && brailleCode == 0) + if (r <= 2 && g <= 2 && b <= 2 && brailleChar == BrailleBase) { rowSb.Append(' '); continue; @@ -564,13 +701,12 @@ public string RenderImage(Image image) if (lastColor == null || !AnsiCodes.ColorsEqual(lastColor.Value, avgColor)) { - rowSb.Append("\x1b[38;2;"); - rowSb.Append(avgColor.R); - rowSb.Append(';'); - rowSb.Append(avgColor.G); - rowSb.Append(';'); - rowSb.Append(avgColor.B); - rowSb.Append('m'); + if (greyscaleAnsi) + AnsiCodes.AppendForegroundGrey256(rowSb, + BrightnessHelper.ToGrayscale(avgColor)); + else + AnsiCodes.AppendForegroundAdaptive(rowSb, avgColor.R, avgColor.G, avgColor.B, + colorDepth); lastColor = avgColor; } } @@ -598,10 +734,7 @@ public string RenderImage(Image image) { // Sequential processing (non-color or small images) Rgba32? lastColor = null; - - // Pre-allocate cell buffers outside loops to avoid stack overflow - Span cellBrightness = stackalloc float[8]; - Span cellIndices = stackalloc int[8]; + Span target8 = stackalloc float[8]; for (var cy = 0; cy < charHeight; cy++) { @@ -610,82 +743,68 @@ public string RenderImage(Image image) var px = cx * 2; var py = cy * 4; - // Collect colors and brightness for the cell - int totalR = 0, totalG = 0, totalB = 0; - var colorCount = 0; - var cellMinBright = 1f; - var cellMaxBright = 0f; - - for (var dy = 0; dy < 4; dy++) - { - var imgY = py + dy; - if (imgY >= pixelHeight) continue; - var rowOffset = imgY * pixelWidth; - - for (var dx = 0; dx < 2; dx++) - { - var imgX = px + dx; - if (imgX >= pixelWidth) continue; + // Sample 8 braille dot positions using concentric rings + SampleBrailleCell(brightness, pixelWidth, pixelHeight, px, py, target8); - var idx = rowOffset + imgX; - var c = colors[idx]; - totalR += c.R; - totalG += c.G; - totalB += c.B; + // Invert coverage if needed + if (invertMode) + for (var i = 0; i < 8; i++) + target8[i] = 1f - target8[i]; - var b = brightness[idx]; - cellBrightness[colorCount] = b; - cellIndices[colorCount] = dy * 2 + dx; - if (b < cellMinBright) cellMinBright = b; - if (b > cellMaxBright) cellMaxBright = b; - colorCount++; - } - } + // Find best matching braille character via shape vector matching + var brailleChar = _brailleMap.FindBestMatch(target8); - // Determine braille pattern using dithered threshold - var brailleCode = 0; - for (var i = 0; i < colorCount; i++) + if (useAnsiOutput) { - var isDot = invertMode - ? cellBrightness[i] > threshold - : cellBrightness[i] < threshold; - - if (isDot) - brailleCode |= DotBits[cellIndices[i]]; - } - - var brailleChar = (char)(BrailleBase + brailleCode); - - if (_options.UseColor && colorCount > 0) - { - var r = (byte)(totalR / colorCount); - var g = (byte)(totalG / colorCount); - var b = (byte)(totalB / colorCount); - - // Apply gamma correction and boost saturation/brightness for braille - // Braille dots are sparse, so colors appear less vibrant - (r, g, b) = BoostBrailleColor(r, g, b, _options.Gamma); + // Collect average color from all pixels in cell + int totalR = 0, totalG = 0, totalB = 0; + var colorCount = 0; - // Skip absolute black characters (invisible on dark terminal) - // This reduces file size and improves rendering - if (r <= 2 && g <= 2 && b <= 2 && brailleCode == 0) + for (var dy = 0; dy < 4; dy++) { - sb.Append(' '); - continue; + var imgY = py + dy; + if (imgY >= pixelHeight) continue; + var rowOffset = imgY * pixelWidth; + + for (var dx = 0; dx < 2; dx++) + { + var imgX = px + dx; + if (imgX >= pixelWidth) continue; + + var c = colors[rowOffset + imgX]; + totalR += c.R; + totalG += c.G; + totalB += c.B; + colorCount++; + } } - var avgColor = new Rgba32(r, g, b, 255); - - if (lastColor == null || !AnsiCodes.ColorsEqual(lastColor.Value, avgColor)) + if (colorCount > 0) { - sb.Append("\x1b[38;2;"); - sb.Append(avgColor.R); - sb.Append(';'); - sb.Append(avgColor.G); - sb.Append(';'); - sb.Append(avgColor.B); - sb.Append('m'); - lastColor = avgColor; + var r = (byte)(totalR / colorCount); + var g = (byte)(totalG / colorCount); + var b = (byte)(totalB / colorCount); + + (r, g, b) = BoostBrailleColor(r, g, b, _options.Gamma); + + if (r <= 2 && g <= 2 && b <= 2 && brailleChar == BrailleBase) + { + sb.Append(' '); + continue; + } + + var avgColor = new Rgba32(r, g, b, 255); + + if (lastColor == null || !AnsiCodes.ColorsEqual(lastColor.Value, avgColor)) + { + if (greyscaleAnsi) + AnsiCodes.AppendForegroundGrey256(sb, + BrightnessHelper.ToGrayscale(avgColor)); + else + AnsiCodes.AppendForegroundAdaptive(sb, avgColor.R, avgColor.G, avgColor.B, + colorDepth); + lastColor = avgColor; + } } sb.Append(brailleChar); @@ -698,18 +817,17 @@ public string RenderImage(Image image) if (cy < charHeight - 1) { - if (_options.UseColor) + if (useAnsiOutput) sb.Append("\x1b[0m"); sb.AppendLine(); lastColor = null; } } - if (_options.UseColor) + if (useAnsiOutput) sb.Append("\x1b[0m"); } - resized.Dispose(); return sb.ToString(); } @@ -1251,90 +1369,6 @@ private List RenderGifFramesInternal(Image image) return (Math.Max(2, width), Math.Max(4, height)); } - /// - /// Calculate braille code for colored output. - /// Strategy: Keep 6-7 dots always (minimal black), but WHICH dots are removed - /// indicates edge direction. This gives detail without holes. - /// - private static int CalculateHybridBrailleCode( - ReadOnlySpan cellBrightness, - ReadOnlySpan cellIndices, - int colorCount, - float minBrightness, - float maxBrightness, - bool invertMode) - { - if (colorCount == 0) return 0xFF; - - // Calculate cell statistics - var minCell = 1f; - var maxCell = 0f; - var totalBright = 0f; - for (var i = 0; i < colorCount; i++) - { - var b = cellBrightness[i]; - totalBright += b; - if (b < minCell) minCell = b; - if (b > maxCell) maxCell = b; - } - - var cellRange = maxCell - minCell; - - // Very uniform - full block - if (cellRange < 0.15f) return 0xFF; - - // Find the 1-2 darkest pixels (in invert mode) or brightest (normal mode) - // These become the "missing" dots, showing edge detail - - // Find indices sorted by brightness - Span<(float bright, int idx)> pixels = stackalloc (float, int)[colorCount]; - for (var i = 0; i < colorCount; i++) pixels[i] = (cellBrightness[i], cellIndices[i]); - - // Sort by brightness (ascending) - for (var i = 0; i < colorCount - 1; i++) - for (var j = i + 1; j < colorCount; j++) - if (pixels[j].bright < pixels[i].bright) - { - var tmp = pixels[i]; - pixels[i] = pixels[j]; - pixels[j] = tmp; - } - - // Start with full block - var brailleCode = 0xFF; - - // In invert mode (dark terminal): remove darkest 1-2 dots (they'd be invisible anyway) - // In normal mode: remove brightest 1-2 dots - // This shows edge detail while keeping most dots visible - - var dotsToRemove = cellRange > 0.3f ? 2 : 1; - - if (invertMode) - // Remove darkest pixels (they blend with terminal) - for (var i = 0; i < dotsToRemove && i < colorCount; i++) - brailleCode &= ~DotBits[pixels[i].idx]; - else - // Remove brightest pixels (they blend with terminal) - for (var i = 0; i < dotsToRemove && i < colorCount; i++) - brailleCode &= ~DotBits[pixels[colorCount - 1 - i].idx]; - - return brailleCode; - } - - /// - /// Count the number of set bits in an integer. - /// - private static int BitCount(int n) - { - var count = 0; - while (n != 0) - { - count += n & 1; - n >>= 1; - } - - return count; - } } /// diff --git a/ConsoleImage.Core/CharacterMap.cs b/ConsoleImage.Core/CharacterMap.cs index 6e04222..02c09da 100644 --- a/ConsoleImage.Core/CharacterMap.cs +++ b/ConsoleImage.Core/CharacterMap.cs @@ -5,6 +5,10 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Runtime.Intrinsics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using SixLabors.Fonts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; @@ -29,19 +33,26 @@ public class CharacterMap // [3] [4] [5] <- Bottom row private static readonly (float X, float Y)[] SamplingPositions = [ - (0.17f, 0.30f), // Top-left (lowered) + (0.17f, 0.25f), // Top-left (0.50f, 0.25f), // Top-center - (0.83f, 0.20f), // Top-right (raised) - (0.17f, 0.80f), // Bottom-left (lowered) + (0.83f, 0.25f), // Top-right + (0.17f, 0.75f), // Bottom-left (0.50f, 0.75f), // Bottom-center - (0.83f, 0.70f) // Bottom-right (raised) + (0.83f, 0.75f) // Bottom-right ]; /// - /// Default ASCII character set suitable for art rendering - /// Ordered from lightest to darkest + /// Default ASCII character set: all printable ASCII (32-126). + /// Shape vectors are font-rendered and disk-cached, so the full set + /// gives the matching algorithm the best possible character for each cell. /// - public static readonly string DefaultCharacterSet = + public static readonly string DefaultCharacterSet = BuildFullPrintableAscii(); + + /// + /// Classic character set from Alex Harri's article (70 chars, lightest to darkest). + /// Use this if you want the traditional curated look. + /// + public static readonly string ClassicCharacterSet = " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"; /// @@ -62,6 +73,19 @@ private static readonly (float X, float Y)[] SamplingPositions = public static readonly string ExtendedCharacterSet = " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"; + /// + /// Build the full printable ASCII character set (32-126, 95 characters). + /// Every monospace-renderable character is included so the shape matching + /// algorithm has maximum freedom to find the best visual match. + /// + private static string BuildFullPrintableAscii() + { + var sb = new System.Text.StringBuilder(95); + for (var c = 32; c <= 126; c++) + sb.Append((char)c); + return sb.ToString(); + } + private readonly ConcurrentDictionary _cache = new(); private readonly int _cacheBits; @@ -131,6 +155,12 @@ public ShapeVector GetVector(char c) private void GenerateVectors(string characterSet, string? fontFamily, int cellSize) { + // Try loading from disk cache first + var cacheKey = ComputeCacheKey(characterSet, fontFamily, cellSize); + if (TryLoadFromDiskCache(cacheKey, characterSet)) + return; + + // Cache miss - generate by rendering font var font = GetFont(fontFamily, cellSize); foreach (var c in characterSet.Distinct()) @@ -141,6 +171,88 @@ private void GenerateVectors(string characterSet, string? fontFamily, int cellSi // Normalize vectors NormalizeVectors(); + + // Save to disk cache for next time + SaveToDiskCache(cacheKey); + } + + private static string CacheDirectory + { + get + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(appData)) + return Path.Combine(appData, "consoleimage", "shapevectors"); + + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + return Path.Combine(home, ".local", "share", "consoleimage", "shapevectors"); + + return Path.Combine(Path.GetTempPath(), "consoleimage", "shapevectors"); + } + } + + private static string ComputeCacheKey(string characterSet, string? fontFamily, int cellSize) + { + var input = $"{characterSet}|{fontFamily ?? "system"}|{cellSize}|v2"; + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hashBytes)[..16].ToLowerInvariant(); + } + + private bool TryLoadFromDiskCache(string cacheKey, string characterSet) + { + try + { + var cachePath = Path.Combine(CacheDirectory, $"{cacheKey}.json"); + if (!File.Exists(cachePath)) + return false; + + var json = File.ReadAllText(cachePath); + var cached = JsonSerializer.Deserialize(json, ShapeVectorCacheContext.Default.CachedShapeVectors); + if (cached?.Vectors == null || cached.Version != "2") + return false; + + // Restore vectors for characters in the set + foreach (var c in characterSet.Distinct()) + { + var key = c.ToString(); + if (cached.Vectors.TryGetValue(key, out var floats) && floats.Length == 6) + _vectors[c] = new ShapeVector(floats[0], floats[1], floats[2], + floats[3], floats[4], floats[5]); + } + + return _vectors.Count > 0; + } + catch + { + return false; + } + } + + private void SaveToDiskCache(string cacheKey) + { + try + { + var dir = CacheDirectory; + Directory.CreateDirectory(dir); + var cachePath = Path.Combine(dir, $"{cacheKey}.json"); + + var cached = new CachedShapeVectors + { + Version = "2", + Vectors = new Dictionary() + }; + + foreach (var (c, v) in _vectors) + cached.Vectors[c.ToString()] = [v[0], v[1], v[2], v[3], v[4], v[5]]; + + var json = JsonSerializer.Serialize(cached, ShapeVectorCacheContext.Default.CachedShapeVectors); + File.WriteAllText(cachePath, json); + } + catch + { + // Non-critical - caching is best-effort + } } private static Font GetFont(string? fontFamily, int cellSize) @@ -439,4 +551,19 @@ public char FindBestMatchBruteForce(in ShapeVector target) return _characters[bestIdx]; } +} + +/// +/// Disk cache format for shape vectors. AOT-compatible via source generation. +/// +public class CachedShapeVectors +{ + public string Version { get; set; } = "2"; + public Dictionary Vectors { get; set; } = new(); +} + +[JsonSerializable(typeof(CachedShapeVectors))] +[JsonSourceGenerationOptions(WriteIndented = false)] +public partial class ShapeVectorCacheContext : JsonSerializerContext +{ } \ No newline at end of file diff --git a/ConsoleImage.Core/ColorBlockRenderer.cs b/ConsoleImage.Core/ColorBlockRenderer.cs index a663f27..c6afe96 100644 --- a/ConsoleImage.Core/ColorBlockRenderer.cs +++ b/ConsoleImage.Core/ColorBlockRenderer.cs @@ -31,6 +31,7 @@ public class ColorBlockRenderer : IDisposable public ColorBlockRenderer(RenderOptions? options = null) { + ConsoleHelper.EnableAnsiSupport(); _options = options ?? RenderOptions.Default; } @@ -180,7 +181,7 @@ private string RenderPixels(Image image) lower = ColorHelper.QuantizeColor(lower, quantStep); } - AppendColoredBlock(rowSb, upper, lower, darkThreshold, lightThreshold); + AppendColoredBlock(rowSb, upper, lower, darkThreshold, lightThreshold, _options.ColorDepth); } rowSb.Append("\x1b[0m"); // Reset at end of line @@ -225,7 +226,7 @@ private string RenderPixels(Image image) lower = ColorHelper.QuantizeColor(lower, quantStep); } - AppendColoredBlock(sb, upper, lower, darkThreshold, lightThreshold); + AppendColoredBlock(sb, upper, lower, darkThreshold, lightThreshold, _options.ColorDepth); } sb.Append("\x1b[0m"); // Reset at end of line @@ -237,7 +238,7 @@ private string RenderPixels(Image image) } private static void AppendColoredBlock(StringBuilder sb, Rgba32 upper, Rgba32 lower, float? darkThreshold, - float? lightThreshold) + float? lightThreshold, ColorDepth depth = ColorDepth.TrueColor) { // Calculate brightness var upperBrightness = BrightnessHelper.GetBrightness(upper); @@ -260,7 +261,7 @@ private static void AppendColoredBlock(StringBuilder sb, Rgba32 upper, Rgba32 lo if (upperSkip) { // Only lower visible - use lower half block with foreground color only - AnsiCodes.AppendResetAndForeground(sb, lower); + AnsiCodes.AppendResetAndForegroundAdaptive(sb, lower, depth); sb.Append(LowerHalfBlock); return; } @@ -268,13 +269,15 @@ private static void AppendColoredBlock(StringBuilder sb, Rgba32 upper, Rgba32 lo if (lowerSkip) { // Only upper visible - use upper half block with foreground color only - AnsiCodes.AppendResetAndForeground(sb, upper); + AnsiCodes.AppendResetAndForegroundAdaptive(sb, upper, depth); sb.Append(UpperHalfBlock); return; } // Both visible - use upper half block with upper as foreground, lower as background - AnsiCodes.AppendForegroundAndBackground(sb, upper, lower); + // Reset first to prevent background color bleed from previous character + sb.Append("\x1b[0m"); + AnsiCodes.AppendForegroundAndBackgroundAdaptive(sb, upper, lower, depth); sb.Append(UpperHalfBlock); } diff --git a/ConsoleImage.Core/EdgeDirection.cs b/ConsoleImage.Core/EdgeDirection.cs index ad22b91..af2494e 100644 --- a/ConsoleImage.Core/EdgeDirection.cs +++ b/ConsoleImage.Core/EdgeDirection.cs @@ -108,8 +108,12 @@ public static (float[,] magnitudes, float[,] angles) ComputeEdges( magnitudes[cy, cx] = MathF.Sqrt(avgGx * avgGx + avgGy * avgGy); // Angle: direction of edge (perpendicular to gradient) - // Add PI/2 to get edge direction instead of gradient direction - angles[cy, cx] = MathF.Atan2(avgGy, avgGx); + // Add PI/2 to rotate from gradient direction to edge direction + var angle = MathF.Atan2(avgGy, avgGx) + MathF.PI / 2; + // Normalize to [-PI, PI] + if (angle > MathF.PI) angle -= 2 * MathF.PI; + else if (angle < -MathF.PI) angle += 2 * MathF.PI; + angles[cy, cx] = angle; } } diff --git a/ConsoleImage.Core/RenderOptions.cs b/ConsoleImage.Core/RenderOptions.cs index 8335e82..e41b77a 100644 --- a/ConsoleImage.Core/RenderOptions.cs +++ b/ConsoleImage.Core/RenderOptions.cs @@ -5,6 +5,21 @@ namespace ConsoleImage.Core; +/// +/// Terminal color depth for ANSI output. +/// +public enum ColorDepth +{ + /// 24-bit true color (default). Requires modern terminal. + TrueColor, + + /// 256-color xterm palette (6x6x6 cube + 24 grey levels). + Palette256, + + /// Standard 16-color ANSI (most compatible). + Palette16 +} + /// /// Configuration options for ASCII rendering. /// Can be bound from appsettings.json via IConfiguration. @@ -85,6 +100,20 @@ public class RenderOptions /// public bool UseColor { get; set; } = true; + /// + /// When UseColor is false, output greyscale ANSI codes (256-color grey ramp 232-255) + /// instead of stripping all color. This preserves brightness information as grey shading. + /// When false with UseColor=false, output is pure monochrome (no ANSI color codes at all). + /// Default: false (for backward compatibility; --no-color enables this). + /// + public bool UseGreyscaleAnsi { get; set; } + + /// + /// Terminal color depth for ANSI output. + /// TrueColor (24-bit, default), Palette256 (xterm-256), or Palette16 (standard ANSI). + /// + public ColorDepth ColorDepth { get; set; } = ColorDepth.TrueColor; + /// /// For animated GIFs: frame delay multiplier (1.0 = original speed) /// @@ -212,6 +241,41 @@ public class RenderOptions /// public int? ColorCount { get; set; } + /// + /// EXPERIMENTAL: Enable perceptual braille interlacing (temporal super-resolution). + /// Generates multiple braille frames with slightly different brightness thresholds + /// and plays them rapidly. The human visual system integrates the frames, + /// perceiving more detail than any single frame can show. + /// Known issues: black horizontal bars appear between frames due to a screen + /// clearing/positioning bug in BrailleInterlacePlayer. + /// Default: false + /// + public bool InterlaceEnabled { get; set; } + + /// + /// EXPERIMENTAL: Number of interlace subframes per visible frame (2-8). + /// More frames = more perceived brightness levels (N frames → N+1 levels per dot). + /// 4 is the industry standard for LCD FRC (Frame Rate Control). + /// Default: 4 + /// + public int InterlaceFrameCount { get; set; } = 4; + + /// + /// EXPERIMENTAL: Threshold spread range for interlace frames (0.01-0.2). + /// Controls how much the brightness threshold varies between subframes. + /// Higher values show more tonal range but may cause visible flicker. + /// Default: 0.06 (6% of brightness range) + /// + public float InterlaceSpread { get; set; } = 0.06f; + + /// + /// EXPERIMENTAL: Target visible FPS for interlace playback. + /// Actual subframe rate = InterlaceFps * InterlaceFrameCount. + /// Minimum 60 Hz subframe rate recommended for flicker-free display. + /// Default: 20 fps (80 Hz subframes with 4 frames) + /// + public float InterlaceFps { get; set; } = 20f; + /// /// Gets the effective character set, considering presets /// @@ -282,6 +346,7 @@ private static string GetPresetCharacterSet(string? preset) "simple" => CharacterMap.SimpleCharacterSet, "block" => CharacterMap.BlockCharacterSet, "extended" => CharacterMap.ExtendedCharacterSet, + "classic" => CharacterMap.ClassicCharacterSet, _ => CharacterMap.DefaultCharacterSet }; } @@ -437,6 +502,8 @@ public RenderOptions Clone() Gamma = Gamma, Invert = Invert, UseColor = UseColor, + UseGreyscaleAnsi = UseGreyscaleAnsi, + ColorDepth = ColorDepth, AnimationSpeedMultiplier = AnimationSpeedMultiplier, LoopCount = LoopCount, EnableEdgeDetection = EnableEdgeDetection, @@ -455,7 +522,11 @@ public RenderOptions Clone() ColorStabilityThreshold = ColorStabilityThreshold, CharacterStabilityBias = CharacterStabilityBias, BrailleFullBlocks = BrailleFullBlocks, - ColorCount = ColorCount + ColorCount = ColorCount, + InterlaceEnabled = InterlaceEnabled, + InterlaceFrameCount = InterlaceFrameCount, + InterlaceSpread = InterlaceSpread, + InterlaceFps = InterlaceFps }; } } \ No newline at end of file diff --git a/ConsoleImage.Video.Core/FFmpegService.cs b/ConsoleImage.Video.Core/FFmpegService.cs index 4fe375c..1d12da7 100644 --- a/ConsoleImage.Video.Core/FFmpegService.cs +++ b/ConsoleImage.Video.Core/FFmpegService.cs @@ -31,6 +31,8 @@ public sealed class FFmpegService : IDisposable "apng", // Animated PNG "mpeg4", // MPEG-4 part 2 / DivX - hwdownload nv12 format issues "av1", // AV1 - hwdownload nv12 format issues + "hevc", // H.265/HEVC - hwdownload nv12 format issues on many GPUs + "h265", // H.265 alternate name "mjpeg", // Motion JPEG - CUDA decoder fails on many systems "mjpegb", // Motion JPEG B "jpeg2000", // JPEG 2000 @@ -415,6 +417,7 @@ public async Task> DetectSceneChangesAsync( /// Stream frames from video using pipe - no temp files needed. /// Most efficient method for sequential playback. /// Outputs RGBA32 for compatibility with renderers. + /// Automatically retries without hardware acceleration if hwaccel fails. /// /// Optional codec hint to detect problematic codecs for hwaccel. public async IAsyncEnumerable> StreamFramesAsync( @@ -434,8 +437,88 @@ public async IAsyncEnumerable> StreamFramesAsync( if (!SecurityHelper.IsValidFilePath(videoPath) && !SecurityHelper.IsValidUrl(videoPath)) throw new ArgumentException("Invalid video path", nameof(videoPath)); - var hwAccel = GetHwAccelArgs(codec); - var hwDownload = GetHwDownloadFilter(codec); + var useHwAccel = _useHardwareAcceleration && !string.IsNullOrEmpty(HardwareAccelerationType); + + // Try with hwaccel first, fall back to software if it fails + if (useHwAccel) + { + var hwAccelFailed = false; + string? hwAccelError = null; + + await foreach (var result in StreamFramesCoreAsync( + videoPath, outputWidth, outputHeight, + startTime, endTime, frameStep, targetFps, + codec, useHwAccel: true, ct)) + { + if (result.IsHwAccelFailure) + { + hwAccelFailed = true; + hwAccelError = result.ErrorMessage; + break; + } + + yield return result.Image!; + } + + if (!hwAccelFailed) + yield break; + + // hwaccel failed - retry without it + Console.Error.WriteLine( + $"[FFmpeg] Hardware acceleration failed ({hwAccelError ?? "unknown error"}), retrying with software decoding..."); + } + + // Software decoding path (also the retry path after hwaccel failure) + await foreach (var result in StreamFramesCoreAsync( + videoPath, outputWidth, outputHeight, + startTime, endTime, frameStep, targetFps, + codec, useHwAccel: false, ct)) + { + if (result.IsHwAccelFailure) + { + // Software path should never signal hwaccel failure, but if FFmpeg errors + // on this path too, throw the original error + var errorMsg = result.ErrorMessage ?? "FFmpeg failed without producing any frames"; + throw new InvalidOperationException(errorMsg); + } + + yield return result.Image!; + } + } + + /// + /// Result from frame streaming that can signal hwaccel failure for retry. + /// + private readonly struct StreamFrameResult + { + public Image? Image { get; init; } + public bool IsHwAccelFailure { get; init; } + public string? ErrorMessage { get; init; } + + public static StreamFrameResult FromImage(Image image) => new() { Image = image }; + + public static StreamFrameResult HwAccelFailure(string error) => + new() { IsHwAccelFailure = true, ErrorMessage = error }; + } + + /// + /// Core frame streaming implementation. Returns StreamFrameResult to allow + /// the caller to distinguish between successful frames and hwaccel failures. + /// + private async IAsyncEnumerable StreamFramesCoreAsync( + string videoPath, + int outputWidth, + int outputHeight, + double? startTime, + double? endTime, + int frameStep, + double? targetFps, + string? codec, + bool useHwAccel, + [EnumeratorCancellation] CancellationToken ct) + { + var hwAccel = useHwAccel ? GetHwAccelArgs(codec) : ""; + var hwDownload = useHwAccel ? GetHwDownloadFilter(codec) : ""; // Build filter chain var filters = new List(); @@ -523,9 +606,17 @@ public async IAsyncEnumerable> StreamFramesAsync( if (process.ExitCode != 0 || !string.IsNullOrWhiteSpace(stderr)) { var errorMsg = !string.IsNullOrWhiteSpace(stderr) - ? $"FFmpeg error: {stderr.Trim()}" + ? stderr.Trim() : $"FFmpeg exited with code {process.ExitCode} without producing any frames"; - throw new InvalidOperationException(errorMsg); + + // Signal hwaccel failure so caller can retry without it + if (useHwAccel) + { + yield return StreamFrameResult.HwAccelFailure(errorMsg); + yield break; + } + + throw new InvalidOperationException($"FFmpeg error: {errorMsg}"); } } @@ -548,7 +639,7 @@ public async IAsyncEnumerable> StreamFramesAsync( } framesProduced++; - yield return image; + yield return StreamFrameResult.FromImage(image); } try @@ -626,6 +717,7 @@ private static bool IsLikelyCorruptedFrame(byte[] buffer, int width, int height) /// /// Extract a single frame at a specific timestamp. /// Returns the frame as an Image for direct processing. + /// Automatically retries without hardware acceleration if hwaccel fails. /// public async Task?> ExtractFrameAsync( string videoPath, @@ -640,9 +732,6 @@ private static bool IsLikelyCorruptedFrame(byte[] buffer, int width, int height) if (!SecurityHelper.IsValidFilePath(videoPath) && !SecurityHelper.IsValidUrl(videoPath)) throw new ArgumentException("Invalid video path", nameof(videoPath)); - var hwAccel = GetHwAccelArgs(); - var hwDownload = GetHwDownloadFilter(); - // Get video dimensions if not specified var outputWidth = width ?? 0; var outputHeight = height ?? 0; @@ -667,6 +756,33 @@ private static bool IsLikelyCorruptedFrame(byte[] buffer, int width, int height) } } + // Try with hwaccel first, then fall back to software + var result = await ExtractFrameCoreAsync(videoPath, timestamp, outputWidth, outputHeight, useHwAccel: true, ct); + if (result != null) + return result; + + // If hwaccel was active, retry without it + if (_useHardwareAcceleration && !string.IsNullOrEmpty(HardwareAccelerationType)) + { + Console.Error.WriteLine( + "[FFmpeg] Hardware acceleration failed for frame extraction, retrying with software decoding..."); + return await ExtractFrameCoreAsync(videoPath, timestamp, outputWidth, outputHeight, useHwAccel: false, ct); + } + + return null; + } + + private async Task?> ExtractFrameCoreAsync( + string videoPath, + double timestamp, + int outputWidth, + int outputHeight, + bool useHwAccel, + CancellationToken ct) + { + var hwAccel = useHwAccel ? GetHwAccelArgs() : ""; + var hwDownload = useHwAccel ? GetHwDownloadFilter() : ""; + var filters = new List(); if (!string.IsNullOrEmpty(hwDownload)) filters.Add(hwDownload.TrimEnd(',')); diff --git a/ConsoleImage.Video.Core/VideoAnimationPlayer.cs b/ConsoleImage.Video.Core/VideoAnimationPlayer.cs index 8a43453..e61c104 100644 --- a/ConsoleImage.Video.Core/VideoAnimationPlayer.cs +++ b/ConsoleImage.Video.Core/VideoAnimationPlayer.cs @@ -135,8 +135,17 @@ public async Task PlayAsync(CancellationToken cancellationToken = default) Console.Write("\x1b[0m"); // Reset all attributes first if (_options.UseAltScreen) Console.Write("\x1b[?1049h"); // Enter alt screen Console.Write("\x1b[?25l"); // Hide cursor - Console.Write("\x1b[2J"); // Clear screen (ED2 - entire screen) - Console.Write("\x1b[H"); // Home cursor + if (_options.ContentStartRow <= 1) + { + Console.Write("\x1b[2J"); // Clear entire screen + Console.Write("\x1b[H"); // Home cursor + } + else + { + // Clear from content start row down (preserve header rows above) + Console.Write($"\x1b[{_options.ContentStartRow};1H"); // Move to content start + Console.Write("\x1b[J"); // Clear from cursor to end of screen + } Console.Out.Flush(); // Initialize subtitle renderer if subtitles are available @@ -169,14 +178,14 @@ public async Task PlayAsync(CancellationToken cancellationToken = default) _lastSubtitleContent = null; // Clear cached subtitle to force redraw _options.Subtitles?.ResetCache(); // Reset subtitle lookup cache for new position _options.LiveSubtitleProvider?.Track.ResetCache(); // Reset live subtitle cache too - Console.Write("\x1b[2J\x1b[H"); // Clear screen for new position + ClearContentArea(); // Clear screen for new position Console.Out.Flush(); } // Check for resize if (CheckForResize()) { - Console.Write("\x1b[2J\x1b[H"); + ClearContentArea(); Console.Out.Flush(); _previousBrailleCells = null; // Reset delta state for new dimensions // Re-create subtitle renderer with new dimensions @@ -379,7 +388,7 @@ private async Task PlayStreamAsync(int frameDelayMs, double effectiveFps, double // Show "Transcribing..." indicator while waiting subtitleContent = _subtitleRenderer.RenderText("\u23F3 Transcribing..."); var waitingBuffer = BuildFrameBuffer(frameContent, previousFrame, frameIndex == 1, - statusContent, subtitleContent, _lastSubtitleContent); + statusContent, subtitleContent, _lastSubtitleContent, _options.ContentStartRow); Console.Write(waitingBuffer); Console.Out.Flush(); @@ -409,7 +418,7 @@ private async Task PlayStreamAsync(int frameDelayMs, double effectiveFps, double // Build optimized frame buffer with status line and subtitles included var buffer = BuildFrameBuffer(frameContent, previousFrame, frameIndex == 1, statusContent, - subtitleContent, _lastSubtitleContent); + subtitleContent, _lastSubtitleContent, _options.ContentStartRow); previousFrame = frameContent; _lastSubtitleContent = subtitleContent; @@ -597,18 +606,23 @@ private static string BuildFrameBuffer( bool isFirstFrame, string? statusLine = null, string? subtitleContent = null, - string? previousSubtitle = null) + string? previousSubtitle = null, + int contentStartRow = 1) { var sb = new StringBuilder(); sb.Append("\x1b[?2026h"); // Begin synchronized output + // Row offset: contentStartRow is 1-based terminal row where content begins + // GetCursorMove expects 0-based line index → 1-based row, so offset by (contentStartRow - 1) + var rowOffset = contentStartRow - 1; + // Count frame lines for status/subtitle positioning (without allocating) var frameLines = CountLines(content); if (isFirstFrame || previousContent == null) { - // Full redraw for first frame - sb.Append("\x1b[H"); // Home cursor + // Full redraw for first frame - position at content start row + sb.Append(GetCursorMove(rowOffset)); // Move to content start row sb.Append(content); } else @@ -630,7 +644,7 @@ private static string BuildFrameBuffer( // If more than 60% changed, do full redraw if (changedLines > maxLines * 0.6) { - sb.Append("\x1b[H"); + sb.Append(GetCursorMove(rowOffset)); // Move to content start row sb.Append(content); } else @@ -643,7 +657,7 @@ private static string BuildFrameBuffer( if (!currLine.SequenceEqual(prevLine)) { - sb.Append(GetCursorMove(i)); // Move to line (cached) + sb.Append(GetCursorMove(rowOffset + i)); // Move to line with offset sb.Append(currLine); // Pad to clear previous content @@ -672,7 +686,7 @@ private static string BuildFrameBuffer( var subtitleLineCount = CountLines(subtitleContent); for (var i = 0; i < maxSubtitleLines; i++) { - sb.Append(GetCursorMove(frameLines + i)); // Move to subtitle row (cached) + sb.Append(GetCursorMove(rowOffset + frameLines + i)); // Move to subtitle row with offset if (i < subtitleLineCount) // Write subtitle line (already padded to width by SubtitleRenderer) sb.Append(GetLineSpan(subtitleContent, i)); @@ -689,7 +703,7 @@ private static string BuildFrameBuffer( // Clear previous subtitle lines by overwriting with cached blank line for (var i = 0; i < maxSubtitleLines; i++) { - sb.Append(GetCursorMove(frameLines + i)); // Cached + sb.Append(GetCursorMove(rowOffset + frameLines + i)); // With offset sb.Append(BlankLine120); } } @@ -697,8 +711,8 @@ private static string BuildFrameBuffer( // Render status line at fixed position below subtitles (overwrite, don't clear) if (!string.IsNullOrEmpty(statusLine)) { - var statusRow = frameLines + maxSubtitleLines; - sb.Append(GetCursorMove(statusRow)); // Move to status line row (cached) + var statusRow = rowOffset + frameLines + maxSubtitleLines; + sb.Append(GetCursorMove(statusRow)); // Move to status line row with offset sb.Append(statusLine); sb.Append("\x1b[0m"); // Reset colors } @@ -858,6 +872,22 @@ private void UpdateRenderDimensions() } } + /// + /// Clear the content area, preserving any header rows above ContentStartRow. + /// + private void ClearContentArea() + { + if (_options.ContentStartRow <= 1) + { + Console.Write("\x1b[2J\x1b[H"); // Clear entire screen + home + } + else + { + Console.Write($"\x1b[{_options.ContentStartRow};1H"); // Move to content start + Console.Write("\x1b[J"); // Clear from cursor to end of screen + } + } + /// /// Check for console resize. /// @@ -952,18 +982,19 @@ private void HandleKeyPress(ConsoleKeyInfo key) case ConsoleKey.Spacebar: _isPaused = !_isPaused; OnPausedChanged?.Invoke(_isPaused); - // Show pause indicator + // Show pause indicator at content start row + var indicatorRow = _options.ContentStartRow; if (_isPaused) { Console.Write("\x1b[s"); // Save cursor - Console.Write("\x1b[1;1H"); // Move to top-left + Console.Write($"\x1b[{indicatorRow};1H"); // Move to content top-left Console.Write("\x1b[43;30m PAUSED \x1b[0m"); // Yellow background Console.Write("\x1b[u"); // Restore cursor } else { Console.Write("\x1b[s"); // Save cursor - Console.Write("\x1b[1;1H"); // Move to top-left + Console.Write($"\x1b[{indicatorRow};1H"); // Move to content top-left Console.Write(" "); // Clear pause indicator Console.Write("\x1b[u"); // Restore cursor } @@ -1040,7 +1071,7 @@ private void HandleKeyPress(ConsoleKeyInfo key) private void ShowIndicator(string text) { Console.Write("\x1b[s"); // Save cursor - Console.Write("\x1b[1;1H"); // Move to top-left + Console.Write($"\x1b[{_options.ContentStartRow};1H"); // Move to content top-left Console.Write($"\x1b[46;30m {text} \x1b[0m"); // Cyan background Console.Write("\x1b[u"); // Restore cursor Console.Out.Flush(); diff --git a/ConsoleImage.Video.Core/VideoRenderOptions.cs b/ConsoleImage.Video.Core/VideoRenderOptions.cs index 92b9601..38ffd18 100644 --- a/ConsoleImage.Video.Core/VideoRenderOptions.cs +++ b/ConsoleImage.Video.Core/VideoRenderOptions.cs @@ -143,6 +143,14 @@ public class VideoRenderOptions /// public ILiveSubtitleProvider? LiveSubtitleProvider { get; set; } + /// + /// Row offset for content rendering (1-based). + /// When set, video frames start at this row instead of row 1. + /// Used by slideshow to reserve header rows above the video. + /// Default: 1 (no offset, content starts at top). + /// + public int ContentStartRow { get; set; } = 1; + /// /// Create default options suitable for most videos. /// @@ -250,7 +258,8 @@ public VideoRenderOptions Clone() ShowStatus = ShowStatus, SourceFileName = SourceFileName, Subtitles = Subtitles, // Shared reference, not deep copy - LiveSubtitleProvider = LiveSubtitleProvider // Shared reference + LiveSubtitleProvider = LiveSubtitleProvider, // Shared reference + ContentStartRow = ContentStartRow }; } } diff --git a/ConsoleImage.sln b/ConsoleImage.sln index 8e9dfbe..e60a026 100644 --- a/ConsoleImage.sln +++ b/ConsoleImage.sln @@ -10,6 +10,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{94 .gitignore = .gitignore .github\workflows\publish-nuget.yml = .github\workflows\publish-nuget.yml docs\JSON-FORMAT.md = docs\JSON-FORMAT.md + .github\workflows\publish-all-nuget.yml = .github\workflows\publish-all-nuget.yml + .github\workflows\publish-nuget-spectre.yml = .github\workflows\publish-nuget-spectre.yml + .github\workflows\publish-nuget-transcription.yml = .github\workflows\publish-nuget-transcription.yml + .github\workflows\publish-nuget-video.yml = .github\workflows\publish-nuget-video.yml + .github\workflows\release-spectre-demo.yml = .github\workflows\release-spectre-demo.yml EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleImage.Spectre", "ConsoleImage.Spectre\ConsoleImage.Spectre.csproj", "{5A378B4D-0809-4F5B-B0C5-D6EE4D1310A7}" diff --git a/ConsoleImage/.claude/settings.local.json b/ConsoleImage/.claude/settings.local.json index 9cd7126..9270e73 100644 --- a/ConsoleImage/.claude/settings.local.json +++ b/ConsoleImage/.claude/settings.local.json @@ -13,5 +13,9 @@ "Bash(\"E:\\\\source\\\\vectorascii\\\\ConsoleImage\\\\ConsoleImage.Video\\\\bin\\\\Release\\\\net10.0\\\\win-x64\\\\publish\\\\consolevideo.exe\" \"X:\\\\Movies\\\\Superman.2025.1080p.WEB.h264-ETHEL.mkv\" --info --status)", "Bash(\"E:\\\\source\\\\vectorascii\\\\ConsoleImage\\\\ConsoleImage.Video\\\\bin\\\\Release\\\\net10.0\\\\win-x64\\\\publish\\\\consolevideo.exe\" \"X:\\\\Movies\\\\Superman.2025.1080p.WEB.h264-ETHEL.mkv\" -w 60 -t 2 --status)" ] - } + }, + "enabledMcpjsonServers": [ + "consoleimage" + ], + "enableAllProjectMcpServers": true } diff --git a/ConsoleImage/CliOptions.cs b/ConsoleImage/CliOptions.cs index c8ea340..64fa242 100644 --- a/ConsoleImage/CliOptions.cs +++ b/ConsoleImage/CliOptions.cs @@ -329,6 +329,25 @@ public CliOptions() HideSlideInfo = new Option("--hide-info") { Description = "Hide [1/N] filename header in slideshow" }; HideSlideInfo.Aliases.Add("--no-header"); + // Color depth for terminal output + ColorDepthOpt = new Option("--color-depth") + { Description = "Terminal color depth: true (24-bit, default), 256 (xterm-256), 16 (standard ANSI)" }; + ColorDepthOpt.Aliases.Add("--depth"); + + // Interlace (temporal super-resolution for braille) — EXPERIMENTAL + Interlace = new Option("--interlace") + { Description = "[EXPERIMENTAL] Enable temporal interlacing for braille (known issues: black bars, clearing artifacts)" }; + Interlace.Aliases.Add("--interleave"); + + InterlaceFrames = new Option("--interlace-frames") + { Description = "[EXPERIMENTAL] Number of interlace subframes (2-8, default 4)" }; + + InterlaceSpread = new Option("--interlace-spread") + { Description = "[EXPERIMENTAL] Threshold spread across subframes (0.01-0.2, default 0.06)" }; + + InterlaceFps = new Option("--interlace-fps") + { Description = "[EXPERIMENTAL] Visible frame rate for interlace cycling (default 20)" }; + // Easter egg EasterEgg = new Option("--ee") { Description = "Play animation demo" }; @@ -494,6 +513,15 @@ public CliOptions() public Option GifLoop { get; } public Option HideSlideInfo { get; } + // Color depth + public Option ColorDepthOpt { get; } + + // Interlace (temporal super-resolution for braille) + public Option Interlace { get; } + public Option InterlaceFrames { get; } + public Option InterlaceSpread { get; } + public Option InterlaceFps { get; } + // Easter egg public Option EasterEgg { get; } @@ -653,6 +681,15 @@ public void AddToCommand(RootCommand command) command.Options.Add(VideoPreview); command.Options.Add(GifLoop); command.Options.Add(HideSlideInfo); + // Color depth + command.Options.Add(ColorDepthOpt); + + // Interlace + command.Options.Add(Interlace); + command.Options.Add(InterlaceFrames); + command.Options.Add(InterlaceSpread); + command.Options.Add(InterlaceFps); + command.Options.Add(EasterEgg); command.Options.Add(Debug); command.Options.Add(Hash); diff --git a/ConsoleImage/Handlers/ImageHandler.cs b/ConsoleImage/Handlers/ImageHandler.cs index 0d4480f..d41aa20 100644 --- a/ConsoleImage/Handlers/ImageHandler.cs +++ b/ConsoleImage/Handlers/ImageHandler.cs @@ -29,6 +29,9 @@ public static async Task HandleAsync( bool outputAsJson, string? jsonOutputPath, bool showStatus, string? markdownPath, string? markdownFormatStr, + bool interlace, int? interlaceFrames, float? interlaceSpread, float? interlaceFps, + Core.ColorDepth colorDepth, + bool useGreyscaleAnsi, CancellationToken ct) { ConsoleHelper.EnableAnsiSupport(); @@ -54,7 +57,9 @@ public static async Task HandleAsync( UseParallelProcessing = true, LoopCount = loop, AnimationSpeedMultiplier = speed, - ColorCount = colorCount + ColorCount = colorCount, + ColorDepth = colorDepth, + UseGreyscaleAnsi = useGreyscaleAnsi }; // Check if it's an animated GIF @@ -79,7 +84,8 @@ public static async Task HandleAsync( return await HandleStaticDisplay(input, options, useMatrix, matrixColor, matrixFullColor, matrixDensity, matrixSpeed, matrixAlphabet, useBraille, useBlocks, outputAsJson, jsonOutputPath, - markdownPath, markdownFormatStr, maxWidth, maxHeight, ct); + markdownPath, markdownFormatStr, maxWidth, maxHeight, + interlace, interlaceFrames, interlaceSpread, interlaceFps, ct); } private static async Task HandleGifOutput( @@ -412,7 +418,9 @@ private static async Task HandleStaticDisplay( bool useBraille, bool useBlocks, bool outputAsJson, string? jsonOutputPath, string? markdownPath, string? markdownFormatStr, - int maxWidth, int maxHeight, CancellationToken ct) + int maxWidth, int maxHeight, + bool interlace, int? interlaceFrames, float? interlaceSpread, float? interlaceFps, + CancellationToken ct) { // Parse markdown format var mdFormat = markdownFormatStr?.ToLowerInvariant() switch @@ -460,7 +468,32 @@ private static async Task HandleStaticDisplay( } else if (useBraille) { + // Apply interlace settings to RenderOptions + if (interlace) + { + options.InterlaceEnabled = true; + if (interlaceFrames.HasValue) + options.InterlaceFrameCount = interlaceFrames.Value; + if (interlaceSpread.HasValue) + options.InterlaceSpread = interlaceSpread.Value; + if (interlaceFps.HasValue) + options.InterlaceFps = interlaceFps.Value; + } + using var renderer = new BrailleRenderer(options); + + // Interlace mode: generate subframes and play them rapidly + // EXPERIMENTAL: known issues with black bars / clearing artifacts + if (interlace && string.IsNullOrEmpty(markdownPath)) + { + Console.Error.WriteLine("WARNING: Interlace mode is experimental. Known issues: black bars between frames."); + using var image = Image.Load(input.FullName); + var frames = renderer.RenderInterlaceFrames(image); + using var player = new BrailleInterlacePlayer(frames); + await player.PlayAsync(ct); + return 0; + } + var frame = renderer.RenderFileToFrame(input.FullName); ansiContent = frame.Content; Console.WriteLine(frame.Content); diff --git a/ConsoleImage/Handlers/SlideshowHandler.cs b/ConsoleImage/Handlers/SlideshowHandler.cs index 402d82c..03ab34f 100644 --- a/ConsoleImage/Handlers/SlideshowHandler.cs +++ b/ConsoleImage/Handlers/SlideshowHandler.cs @@ -42,6 +42,7 @@ public record SlideshowOptions public bool UseBraille { get; init; } = true; public bool UseMatrix { get; init; } public bool UseColor { get; init; } = true; + public bool UseGreyscaleAnsi { get; init; } public float Contrast { get; init; } = 2.5f; public float Gamma { get; init; } = 0.65f; public float? CharAspect { get; init; } @@ -382,10 +383,16 @@ private static async Task RunSlideshowAsync( Console.Write("\x1b[?2026h"); // Begin sync Console.Write("\x1b[H"); // Home position Console.Write("\x1b[2J"); // Clear screen - Console.Write("\x1b[?2026l"); // End sync - // Content starts at line 1 (no header - status is at the bottom) - var contentStartLine = 1; + // Content starts at line 2 (line 1 reserved for filename header) + var contentStartLine = 2; + + // Render filename header at row 1 + var headerWidth = Math.Max(renderOptions.MaxWidth, 30); + var headerText = FormatFilenameHeader(fileName, currentIndex + 1, fileCount, headerWidth); + Console.Write("\x1b[1;1H"); // Position at row 1 + Console.Write(headerText); + Console.Write("\x1b[?2026l"); // End sync // Check cache first for instant display CachedSlide? cached = null; @@ -947,6 +954,7 @@ private static async Task DisplayVideoAsync(string file, SlideshowOptions o SpeedMultiplier = options.Speed, UseAltScreen = false, Subtitles = subtitles, + ContentStartRow = 2, // Reserve row 1 for filename header RenderMode = options.UseBraille ? VideoRenderMode.Braille : options.UseBlocks ? VideoRenderMode.ColorBlocks : VideoRenderMode.Ascii @@ -1040,6 +1048,7 @@ private static RenderOptions CreateRenderOptions(SlideshowOptions options) MaxWidth = options.MaxWidth, MaxHeight = options.MaxHeight, UseColor = options.UseColor, + UseGreyscaleAnsi = options.UseGreyscaleAnsi, CharacterAspectRatio = options.CharAspect ?? 0.5f, ContrastPower = options.Contrast, Gamma = options.Gamma, @@ -1047,6 +1056,47 @@ private static RenderOptions CreateRenderOptions(SlideshowOptions options) }; } + /// + /// Format the filename header displayed at the top of the slideshow. + /// Format: "[1/10] filename.ext" — dim, constrained to maxWidth. + /// + private static string FormatFilenameHeader(string fileName, int current, int total, int maxWidth) + { + var indexStr = $"[{current}/{total}] "; + var availableForName = maxWidth - indexStr.Length; + + string namePart; + if (availableForName < 4) + { + namePart = ""; + } + else if (fileName.Length <= availableForName) + { + namePart = fileName; + } + else + { + var ext = Path.GetExtension(fileName); + var nameOnly = Path.GetFileNameWithoutExtension(fileName); + var maxNameLen = availableForName - ext.Length - 1; // 1 for "…" + if (maxNameLen > 3) + namePart = string.Concat(nameOnly.AsSpan(0, maxNameLen), "\u2026", ext); + else + namePart = string.Concat(fileName.AsSpan(0, Math.Max(1, availableForName - 1)), "\u2026"); + } + + var sb = new StringBuilder(maxWidth + 16); + sb.Append("\x1b[2m"); // Dim + sb.Append(indexStr); + sb.Append(namePart); + // Pad to full width to overwrite any previous content + var visibleLen = indexStr.Length + namePart.Length; + if (visibleLen < maxWidth) + sb.Append(' ', maxWidth - visibleLen); + sb.Append("\x1b[0m"); // Reset + return sb.ToString(); + } + /// /// Format the slideshow status line with filename, index, and progress bar. /// Width is constrained to maxWidth (matching the rendered content width). diff --git a/ConsoleImage/Handlers/VideoHandler.cs b/ConsoleImage/Handlers/VideoHandler.cs index 29226b6..6e6079a 100644 --- a/ConsoleImage/Handlers/VideoHandler.cs +++ b/ConsoleImage/Handlers/VideoHandler.cs @@ -1075,7 +1075,7 @@ private static async Task HandlePlayback( { "simple" => CharacterMap.SimpleCharacterSet, "block" => CharacterMap.BlockCharacterSet, - "classic" => CharacterMap.DefaultCharacterSet, + "classic" => CharacterMap.ClassicCharacterSet, _ => CharacterMap.ExtendedCharacterSet }; @@ -1117,6 +1117,7 @@ private static async Task HandlePlayback( ContrastPower = opts.Contrast, Gamma = opts.Gamma, UseColor = !opts.NoColor, + UseGreyscaleAnsi = opts.UseGreyscaleAnsi, ColorCount = opts.ColorCount, Invert = !opts.NoInvert, UseParallelProcessing = !opts.NoParallel, @@ -1189,6 +1190,7 @@ private static RenderOptions CreateRenderOptions( ContrastPower = opts.Contrast, Gamma = opts.Gamma, UseColor = !opts.NoColor, + UseGreyscaleAnsi = opts.UseGreyscaleAnsi, ColorCount = opts.ColorCount, Invert = !opts.NoInvert, UseParallelProcessing = !opts.NoParallel, @@ -1475,6 +1477,7 @@ public class VideoHandlerOptions // Color/rendering public bool NoColor { get; init; } + public bool UseGreyscaleAnsi { get; init; } public int? ColorCount { get; init; } public float Contrast { get; init; } = 2.5f; public float Gamma { get; init; } = 0.65f; diff --git a/ConsoleImage/Program.cs b/ConsoleImage/Program.cs index 48b8ba8..f8d7fc9 100644 --- a/ConsoleImage/Program.cs +++ b/ConsoleImage/Program.cs @@ -154,11 +154,34 @@ } } + // --no-color without --monochrome means greyscale ANSI output (grey ramp 232-255) + // --monochrome means pure B&W (no ANSI color codes at all) + var isMonochromeMode = useMonochromeOpt || + (!string.IsNullOrEmpty(modeOpt) && + (modeOpt.Equals("mono", StringComparison.OrdinalIgnoreCase) || + modeOpt.Equals("monochrome", StringComparison.OrdinalIgnoreCase))); + var useGreyscaleAnsi = noColor && !isMonochromeMode; + var matrixColor = parseResult.GetValue(cliOptions.MatrixColor) ?? template?.MatrixColor; var matrixFullColor = parseResult.GetValue(cliOptions.MatrixFullColor) || (template?.MatrixFullColor ?? false); var matrixDensity = parseResult.GetValue(cliOptions.MatrixDensity) ?? template?.MatrixDensity; var matrixSpeed = parseResult.GetValue(cliOptions.MatrixSpeed) ?? template?.MatrixSpeed; var matrixAlphabet = parseResult.GetValue(cliOptions.MatrixAlphabet) ?? template?.MatrixAlphabet; + // Color depth for terminal output + var colorDepthStr = parseResult.GetValue(cliOptions.ColorDepthOpt); + var colorDepth = colorDepthStr?.ToLowerInvariant() switch + { + "16" => ConsoleImage.Core.ColorDepth.Palette16, + "256" => ConsoleImage.Core.ColorDepth.Palette256, + _ => ConsoleImage.Core.ColorDepth.TrueColor + }; + + // Interlace (temporal super-resolution for braille) + var interlace = parseResult.GetValue(cliOptions.Interlace); + var interlaceFrames = parseResult.GetValue(cliOptions.InterlaceFrames); + var interlaceSpread = parseResult.GetValue(cliOptions.InterlaceSpread); + var interlaceFps = parseResult.GetValue(cliOptions.InterlaceFps); + var colorCount = parseResult.GetValue(cliOptions.Colors) ?? template?.Colors; var contrast = template?.Contrast ?? parseResult.GetValue(cliOptions.Contrast); var gamma = parseResult.GetValue(cliOptions.Gamma) ?? template?.Gamma; @@ -384,7 +407,8 @@ MaxWidth = maxWidth, MaxHeight = maxHeight, CharacterAspectRatio = charAspect ?? 0.5f, - UseColor = !noColor + UseColor = !noColor, + UseGreyscaleAnsi = useGreyscaleAnsi }; // Pure Matrix with GIF output @@ -433,6 +457,7 @@ UseBraille = useBraille, UseMatrix = useMatrix, UseColor = !noColor, + UseGreyscaleAnsi = useGreyscaleAnsi, Contrast = contrast, Gamma = effectiveGamma, CharAspect = charAspect, @@ -804,6 +829,9 @@ await YtdlpProvider.DownloadVideoAsync( outputAsJson, jsonOutputPath, showStatus, markdownPath, markdownFormat, + interlace, interlaceFrames, interlaceSpread, interlaceFps, + colorDepth, + useGreyscaleAnsi, cancellationToken); } @@ -1021,6 +1049,7 @@ await YtdlpProvider.DownloadVideoAsync( // Color/rendering NoColor = noColor, + UseGreyscaleAnsi = useGreyscaleAnsi, ColorCount = colorCount, Contrast = contrast, Gamma = effectiveGamma, diff --git a/README.md b/README.md index bb64aef..c36b92d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mostlylucid.consoleimage -**Version 4.1** - High-quality ASCII art renderer for .NET 10 with live AI transcription. +**Version 4.5** - High-quality ASCII art renderer for .NET 10 with SIMD-accelerated braille vectorization, live AI transcription, and temporal super-resolution. [![NuGet](https://img.shields.io/nuget/v/mostlylucid.consoleimage.svg)](https://www.nuget.org/packages/mostlylucid.consoleimage/) [![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](https://unlicense.org) @@ -18,6 +18,7 @@ - [Documentation](#documentation) - [Architecture](#architecture) - [Building from Source](#building-from-source) +- [Braille Shape Vector Matching](#braille-shape-vector-matching-v45) ## Quick Start @@ -129,19 +130,77 @@ For the complete CLI guide covering all modes, subtitles, YouTube, slideshow, ex ## Features - **Shape-matching algorithm**: Characters selected by visual shape similarity, not just brightness -- **3x2 staggered sampling grid**: 6 sampling circles per [Alex Harri's article](https://alexharri.com/blog/ascii-rendering) -- **K-D tree optimization**: Fast nearest-neighbor search in 6D vector space +- **SIMD braille vectorization**: 8D shape vectors for all 256 braille patterns with `Vector256` hardware acceleration +- **Expanded ASCII character sets**: Full 95 printable ASCII characters with disk-cached shape vectors +- **Braille interlace mode** (experimental): Temporal super-resolution via rapid frame cycling (FRC-inspired) - **Multiple render modes**: ASCII, ColorBlocks, Braille, Matrix, iTerm2, Kitty, Sixel - **Animated GIF/video support**: Flicker-free DECSET 2026 synchronized output - **Dynamic resize**: Animations re-render when you resize the console window - **Live AI subtitles**: Real-time Whisper transcription during video playback - **YouTube integration**: Play YouTube videos with auto-downloaded yt-dlp +- **Hardware acceleration**: CUDA/D3D11VA/VAAPI with automatic software fallback - **Slideshow mode**: Browse directories with keyboard navigation - **Markdown/SVG export**: Embeddable output for documentation and READMEs - **Document format**: Save/load rendered art as `.cidz` compressed documents - **AOT compiled**: Native binaries, no .NET runtime required - **Cross-platform**: Windows, Linux, macOS (x64 and ARM64) +
+What's New in v4.5 + +### Braille Shape Vector Matching + +The braille renderer now uses **SIMD-accelerated shape vector matching** instead of simple brightness thresholding. Each of the 256 Unicode braille patterns is represented as an 8D vector (one component per dot in the 2x4 grid), and the renderer finds the best-matching pattern using hardware-accelerated distance calculations. + +**How it works:** +1. For each character cell, 8 dot positions are sampled using concentric ring sampling (13 samples per dot, 104 total per cell) +2. Brightness values are converted to coverage vectors (0.0-1.0 per dot) +3. The coverage vector is matched against all 256 braille patterns via SIMD brute force +4. A quantized cache (8 components x 4 bits = 32-bit key) prevents redundant calculations + +With only 256 vectors, SIMD brute force outperforms tree-based search. Benchmarks show ~6 us per 1000 lookups with zero allocations. + +### Braille Interlace Mode (Experimental) + +> **Status: Experimental** — Known issues: black horizontal bars appear between frames due to a screen clearing/cursor positioning bug. The underlying frame generation works, but the playback player has visual artifacts. + +Inspired by LCD FRC (Frame Rate Control) and DLP temporal dithering, interlace mode generates multiple braille subframes with slightly different dithering thresholds. When cycled rapidly, the human visual system integrates the frames, perceiving more tonal detail than any single braille frame can display. + +```bash +# Interlace mode for still images (experimental) +consoleimage photo.jpg --interlace + +# Configure interlace parameters +consoleimage photo.jpg --interlace --interlace-frames 6 --interlace-fps 30 +``` + +### Expanded ASCII Character Sets + +ASCII shape matching now uses **all 95 printable ASCII characters** by default (up from 70). More characters means more shape options for the matching algorithm, improving output quality with no performance cost. + +- **Full (default)**: 95 printable ASCII chars (space through ~) +- **Classic**: Original 70-char curated set from Alex Harri's article +- **Extended**: 93-char set with additional gradations + +Shape vectors are **cached to disk** (`%LOCALAPPDATA%\consoleimage\shapevectors\`) to avoid re-rendering fonts on every startup. + +### Hardware Acceleration with Auto-Fallback + +FFmpeg hardware acceleration (CUDA, D3D11VA, VAAPI) now automatically falls back to software decoding when GPU decoding fails. HEVC/H.265 and other problematic codecs are proactively blocklisted to avoid known compatibility issues. + +### Greyscale ANSI Mode + +256-color greyscale output using ANSI palette indices 232-255 for terminals without full RGB support. + +### Other Improvements + +- **ConsoleHelper auto-init**: Renderers auto-enable Windows ANSI support (no manual setup for NuGet consumers) +- **Edge direction fix**: Corrected gradient-to-edge rotation (PI/2 offset) for sharper directional characters +- **Symmetric sampling grid**: Fixed asymmetric stagger in ASCII sampling that caused `\` where `/` should appear + +See [CHANGELOG.md](CHANGELOG.md) for full history. +
+
What's New in v3.0 @@ -303,9 +362,12 @@ For presets, full options, Spectre.Console integration, GIF control, and configu ``` ConsoleImage.Core # Core library (NuGet: mostlylucid.consoleimage) -├── AsciiRenderer # Shape-matching ASCII renderer +├── AsciiRenderer # Shape-matching ASCII renderer (95-char default set) ├── ColorBlockRenderer # Unicode half-block renderer -├── BrailleRenderer # 2x4 dot braille renderer +├── BrailleRenderer # SIMD shape-vector braille renderer (2x4 dots per cell) +├── BrailleCharacterMap # 8D vector generation + SIMD matching for 256 patterns +├── BrailleInterlacePlayer # [EXPERIMENTAL] Temporal super-resolution playback (FRC-inspired) +├── CharacterMap # Font shape analysis with disk-cached vectors ├── MatrixRenderer # Digital rain effect ├── MarkdownRenderer # SVG/HTML/Markdown export ├── Protocol renderers # iTerm2, Kitty, Sixel support @@ -314,14 +376,15 @@ ConsoleImage.Core # Core library (NuGet: mostlylucid.consoleimage) ├── DocumentPlayer # Document playback ├── GifWriter # Animated GIF output ├── Subtitles/ # SRT/VTT parsing, rendering, embedded extraction -└── ConsoleHelper # Windows ANSI support +└── ConsoleHelper # Windows ANSI support (auto-initialized by renderers) ConsoleImage # Unified CLI (images, GIFs, videos, documents) -ConsoleImage.Video.Core # FFmpeg video decoding (optional, for video files) +ConsoleImage.Video.Core # FFmpeg video decoding with hwaccel auto-fallback ConsoleImage.Transcription # Whisper AI transcription (optional, for --subs whisper) ConsoleImage.Mcp # MCP server for AI assistants (21 tools) ConsoleImage.Player # Standalone document playback (NuGet) ConsoleImage.Spectre # Spectre.Console integration +ConsoleImage.Benchmarks # BenchmarkDotNet performance suite ``` ## Building from Source @@ -343,6 +406,53 @@ dotnet publish ConsoleImage/ConsoleImage.csproj \ -p:PublishAot=true ``` +## Braille Shape Vector Matching (v4.5) + +The v4.5 braille renderer replaces simple brightness thresholding with **SIMD-accelerated shape vector matching** across all 256 Unicode braille patterns. This produces dramatically cleaner edges, better detail preservation, and more accurate representation of the source image. + +### How It Works + +Each Unicode braille character encodes 8 dots in a 2x4 grid. The renderer treats each character as an **8-dimensional vector** where each component represents dot on/off state: + +``` +Braille dot layout: Vector mapping: + ● ○ dot1 dot4 [1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0] + ● ● dot2 dot5 ↑ ↑ + ○ ○ dot3 dot6 dot1=ON dot4=OFF + ● ○ dot7 dot8 + = U+28D5 (⣕) +``` + +**Sampling**: For each character cell, the renderer samples 8 dot positions using concentric ring sampling (center + 4 inner + 8 outer = 13 samples per dot, 104 total per cell). This captures smooth gradients more accurately than single-point sampling. + +**Matching**: The 8-float coverage vector is matched against all 256 pre-computed braille vectors using `Vector256` SIMD instructions. With only 256 candidates, brute force with hardware acceleration outperforms any tree or graph-based search. + +**Caching**: A quantized cache maps each input to a 32-bit key (8 components x 4 bits), preventing redundant distance calculations for similar inputs. This also provides natural temporal stability for animations. + +### Performance + +Benchmarked on AMD Ryzen 9 9950X (.NET 10.0, AVX-512): + +| Operation | Time (per 1000 lookups) | Allocations | +|-----------|------------------------|-------------| +| Cached random vectors | 6.3 us | 0 B | +| Brute force (no cache) | 6.2 us | 0 B | +| Sparse vectors (edges) | 6.2 us | 0 B | + +The cache and brute force paths are essentially identical at 256 vectors -- SIMD is so fast that dictionary overhead cancels any caching benefit. Both paths are allocation-free. + +### ASCII Character Set Expansion + +The ASCII renderer also benefits from expanded shape matching. v4.5 uses all 95 printable ASCII characters by default, up from the original 70-character curated set. Shape vectors are cached to disk to avoid re-rendering fonts on startup. + +| Character Set | Chars | Cached Lookup (per 1000) | Brute Force (per 1000) | +|---------------|-------|--------------------------|------------------------| +| Classic (v4.1) | 70 | 6.1 us | 94.1 us | +| Full (v4.5 default) | 95 | 6.0 us | 133.5 us | +| Extended | 93 | 6.0 us | N/A | + +Cached lookups are character-count-independent (quantized cache hit). Brute force scales linearly but is only used on cache miss. + ## Credits - Algorithm: [Alex Harri's ASCII Rendering article](https://alexharri.com/blog/ascii-rendering) diff --git a/docs/BRAILLE-RENDERING.md b/docs/BRAILLE-RENDERING.md index 1bbccb6..22b8073 100644 --- a/docs/BRAILLE-RENDERING.md +++ b/docs/BRAILLE-RENDERING.md @@ -79,14 +79,17 @@ flowchart LR B --> C[Convert to Grayscale] C --> D[Otsu Threshold] D --> E[Atkinson Dither] - E --> F[Build Braille Codes] - F --> G[Apply Colors] - G --> H[Terminal Output] + E --> F[Ring Sampling
8D Vectors] + F --> G[SIMD Shape
Matching] + G --> H[Apply Colors] + H --> I[Terminal Output] style A stroke:#4a9eff style D stroke:#ff6600 style E stroke:#ff6600 - style G stroke:#00aa00 + style F stroke:#9933ff + style G stroke:#9933ff + style H stroke:#00aa00 ``` ### Step 1: Intelligent Resizing @@ -264,6 +267,163 @@ private static void ApplyAtkinsonDithering(Span buffer, int width, int he } ``` +## Vector Glyph Shape Matching + +After dithering produces a brightness field, we don't simply threshold each dot independently. Instead, we use **8-dimensional shape vector matching** to find the braille character whose dot pattern best represents each 2×4 pixel region. This is the same core idea behind the ASCII renderer's 6D shape matching (from Alex Harri's algorithm), adapted for braille's 8-dot grid. + +### Why Not Just Threshold Each Dot? + +A naive approach checks each of the 8 pixel positions against a threshold and builds the braille code bit by bit. This works, but it's sensitive to noise at dot boundaries—a single pixel hovering near the threshold can flicker on and off between frames, and spatial aliasing produces jagged edges. + +Shape vector matching treats the entire 2×4 cell as a unit, finding the braille pattern whose overall shape is the closest geometric match. This produces smoother, more stable output because the decision considers all 8 positions together rather than independently. + +### Concentric Ring Sampling + +Each dot position is sampled using **concentric rings** rather than a single pixel lookup. This averages brightness over a small circular area around each dot center, making the result robust to sub-pixel positioning: + +``` + ┌─ Outer ring (8 points, radius r) + │ ┌─ Inner ring (4 points, radius r/2) + │ │ ┌─ Center point + │ │ │ + · · ● · · + · · · · + · · ● · · + · · · · + · · ● · · + + Total: 1 center + 4 inner + 8 outer = 13 samples per dot +``` + +For each of the 8 dot positions in the 2×4 cell: + +1. **Center point**: Direct pixel lookup at dot center +2. **Inner ring**: 4 points at radius × 0.5, evenly spaced +3. **Outer ring**: 8 points at full radius, offset by π/8 from inner ring + +The average of all samples gives a **coverage value** between 0.0 (white/empty) and 1.0 (black/filled)—a continuous value rather than a binary on/off. + +```csharp +// Dot center positions within a 2×4 pixel cell: +// Col 0: x = px + 0.5 Col 1: x = px + 1.5 +// Row 0: y = py + 0.5 Row 1: y = py + 1.5 +// Row 2: y = py + 2.5 Row 3: y = py + 3.5 +// +// Sampling radius: 0.35 pixels (relative to half-pixel spacing) +``` + +### 8D Shape Vectors + +Each braille pattern (0x00–0xFF) is represented as an **8-dimensional vector** where each component corresponds to one dot position: + +``` +Pattern ⣿ (0xFF, all dots ON): [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] +Pattern ⠀ (0x00, all dots OFF): [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] +Pattern ⡇ (0x47, left column): [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0] +Pattern ⣤ (0xE4, bottom half): [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0] +Pattern ⠛ (0x1B, top half): [1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0] +``` + +The vector index maps to the 2×4 grid in row-major order: + +``` +Index: [0] [1] Braille: 1 4 + [2] [3] 2 5 + [4] [5] 3 6 + [6] [7] 7 8 +``` + +These 256 vectors are generated **mathematically** from the Unicode braille standard—no font rendering is needed (unlike ASCII mode, which must render each glyph to measure its shape). Each component is simply 1.0 if the corresponding bit is set in the braille code, 0.0 otherwise: + +```csharp +// Dot bit positions: index → Unicode braille bit +private static readonly int[] DotBits = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]; + +// Generate all 256 pattern vectors +for (int code = 0; code < 256; code++) + for (int dot = 0; dot < 8; dot++) + vectorData[code * 8 + dot] = (code & DotBits[dot]) != 0 ? 1.0f : 0.0f; +``` + +### SIMD Brute-Force Matching + +To find the best braille character for a sampled cell, we compute the **squared Euclidean distance** between the 8D sample vector and all 256 pattern vectors, selecting the minimum. With only 256 candidates × 8 dimensions, brute force is faster than any tree structure—and it vectorizes perfectly. + +On CPUs with AVX support, all 8 floats fit in a single `Vector256`, making the inner loop a single SIMD subtraction, multiplication, and horizontal sum: + +```csharp +// Load 8-float sample vector once +var targetVec = Vector256.Create( + target[0], target[1], target[2], target[3], + target[4], target[5], target[6], target[7]); + +// Compare against all 256 braille patterns +for (int i = 0; i < 256; i++) +{ + var charVec = Vector256.Create(/* 8 floats for pattern i */); + var diff = targetVec - charVec; + var squared = diff * diff; + var dist = Vector256.Sum(squared); // Single instruction on AVX + + if (dist < bestDist) { bestDist = dist; bestIdx = i; } +} +``` + +### Quantized Caching + +Many cells produce similar coverage vectors (e.g., solid regions of an image). To avoid redundant matching, vectors are **quantized to 4 bits per component** and used as cache keys: + +``` +8 components × 4 bits = 32 bits → fits in a single int +``` + +Each component value (0.0–1.0) maps to one of 16 levels (0–15). This gives a maximum of 2³² ≈ 4 billion possible keys, but in practice images produce far fewer unique patterns. The cache uses a `ConcurrentDictionary` for thread-safe access during parallel rendering. + +### Worked Example + +Consider a cell from a diagonal edge where the top-left is dark and bottom-right is bright: + +``` +Pixel brightness: Sampled coverage (13-point rings): +┌──────┬──────┐ ┌──────┬──────┐ +│ 0.05 │ 0.25 │ │ 0.92 │ 0.71 │ +├──────┼──────┤ ├──────┼──────┤ +│ 0.10 │ 0.45 │ → │ 0.88 │ 0.52 │ +├──────┼──────┤ ├──────┼──────┤ +│ 0.40 │ 0.80 │ │ 0.57 │ 0.18 │ +├──────┼──────┤ ├──────┼──────┤ +│ 0.75 │ 0.95 │ │ 0.23 │ 0.04 │ +└──────┴──────┘ └──────┴──────┘ + +Sample vector: [0.92, 0.71, 0.88, 0.52, 0.57, 0.18, 0.23, 0.04] + +Closest patterns (by squared distance): + ⠛ (0x1B) = [1,1,1,1,0,0,0,0] dist = 0.41 ← top half + ⡇ (0x47) = [1,0,1,0,1,0,1,0] dist = 0.93 + ⠫ (0x2B) = [1,1,0,1,1,0,0,0] dist = 0.72 + ⠻ (0x3B) = [1,1,0,1,1,1,0,0] dist = 0.36 ← best match ✓ + +Result: ⠻ (top-left 5 dots lit, bottom-right 3 dots off) +``` + +The shape matcher picks `⠻` because its dot pattern best approximates the continuous coverage gradient—better than simple thresholding which might produce `⠛` (hard top/bottom split) or `⣿`/`⠀` (all-or-nothing). + +### Comparison: ASCII 6D vs Braille 8D + +| Aspect | ASCII (`CharacterMap`) | Braille (`BrailleCharacterMap`) | +|--------|----------------------|-------------------------------| +| **Vector dimensions** | 6D (3×2 staggered grid) | 8D (2×4 dot grid) | +| **Character count** | 95 (printable ASCII) | 256 (all braille patterns) | +| **Vector generation** | Font rendering (needs `.ttf`) | Mathematical (Unicode-defined) | +| **Sampling method** | 37 points per circle, 6 circles | 13 points per dot, 8 dots | +| **Matching strategy** | KD-tree + SIMD brute force | SIMD brute force (256 is small) | +| **Cache key** | 5 bits × 6 = 30 bits | 4 bits × 8 = 32 bits | +| **Resolution** | 1 pixel per cell | 8 pixels per cell | + +The ASCII renderer uses a **KD-tree** for fast lookup among 95 characters in 6D space. The braille renderer skips the tree entirely—with only 256 patterns and AVX processing all 8 dimensions in one instruction, linear scan is faster than tree traversal overhead. + +Both approaches share the same insight from Alex Harri's algorithm: represent visual patterns as vectors in a metric space, then find the nearest neighbor. The difference is that ASCII must render each font glyph to discover its shape, while braille patterns are defined by the Unicode standard and can be generated purely from the bit encoding. + ## Color Handling: The Hybrid Approach Plain braille (white dots on black) works, but color adds tremendous visual impact. However, terminal colors present a challenge: we can only set **one foreground color** per character, but our braille character represents **up to 8 different source pixels**. @@ -517,11 +677,14 @@ The braille rendering technique combines several clever algorithms: 1. **Unicode braille encoding** packs 8 dots into each character 2. **Otsu's method** automatically finds the optimal threshold 3. **Atkinson dithering** creates smooth gradients with sharp edges -4. **Hybrid color sampling** matches displayed colors to lit dots -5. **Color boosting** compensates for sparse dot patterns -6. **Delta rendering** optimizes video/animation playback - -Together, these techniques produce terminal graphics that rival dedicated image viewers—all using nothing but text characters. +4. **Concentric ring sampling** converts pixel regions to continuous 8D coverage vectors +5. **SIMD shape vector matching** finds the closest braille pattern across all 256 candidates +6. **Quantized caching** avoids redundant matching for similar pixel regions +7. **Hybrid color sampling** matches displayed colors to lit dots +8. **Color boosting** compensates for sparse dot patterns +9. **Delta rendering** optimizes video/animation playback + +The vector matching step (4–6) is what elevates braille output beyond simple thresholding. By treating each cell as an 8-dimensional shape problem, the renderer makes holistic decisions about dot patterns rather than independent per-pixel binary choices. This produces smoother edges, better detail preservation, and more temporally stable output for video. ## References diff --git a/samples/boingball_ascii.gif b/samples/boingball_ascii.gif index df15ff1..c6f629a 100644 Binary files a/samples/boingball_ascii.gif and b/samples/boingball_ascii.gif differ diff --git a/samples/boingball_blocks.gif b/samples/boingball_blocks.gif index aff4dc5..6f5f48a 100644 Binary files a/samples/boingball_blocks.gif and b/samples/boingball_blocks.gif differ diff --git a/samples/boingball_braille.gif b/samples/boingball_braille.gif index fcec077..7f68328 100644 Binary files a/samples/boingball_braille.gif and b/samples/boingball_braille.gif differ diff --git a/samples/boingball_mono.gif b/samples/boingball_mono.gif index 4fe5725..f4fa100 100644 Binary files a/samples/boingball_mono.gif and b/samples/boingball_mono.gif differ diff --git a/samples/earth_blocks.gif b/samples/earth_blocks.gif index c4e46aa..6c0242f 100644 Binary files a/samples/earth_blocks.gif and b/samples/earth_blocks.gif differ diff --git a/samples/earth_braille.gif b/samples/earth_braille.gif index 8d25534..9d91428 100644 Binary files a/samples/earth_braille.gif and b/samples/earth_braille.gif differ diff --git a/samples/fifthelement_braille.gif b/samples/fifthelement_braille.gif index 9746157..fdae750 100644 Binary files a/samples/fifthelement_braille.gif and b/samples/fifthelement_braille.gif differ diff --git a/samples/hacktheplanet_ascii.gif b/samples/hacktheplanet_ascii.gif index 28a69f5..be3504b 100644 Binary files a/samples/hacktheplanet_ascii.gif and b/samples/hacktheplanet_ascii.gif differ diff --git a/samples/hacktheplanet_braille.gif b/samples/hacktheplanet_braille.gif index f492fd4..b8ff10d 100644 Binary files a/samples/hacktheplanet_braille.gif and b/samples/hacktheplanet_braille.gif differ diff --git a/samples/hacktheplanet_matrix.gif b/samples/hacktheplanet_matrix.gif index e153d33..69c53be 100644 Binary files a/samples/hacktheplanet_matrix.gif and b/samples/hacktheplanet_matrix.gif differ diff --git a/samples/jurassicpark_braille.gif b/samples/jurassicpark_braille.gif index 757eb8a..c33b19a 100644 Binary files a/samples/jurassicpark_braille.gif and b/samples/jurassicpark_braille.gif differ diff --git a/samples/moviebill_ascii.gif b/samples/moviebill_ascii.gif index 11a33dc..1272cf1 100644 Binary files a/samples/moviebill_ascii.gif and b/samples/moviebill_ascii.gif differ diff --git a/samples/moviebill_blocks.gif b/samples/moviebill_blocks.gif index f613fa6..8a3def9 100644 Binary files a/samples/moviebill_blocks.gif and b/samples/moviebill_blocks.gif differ diff --git a/samples/moviebill_braille.gif b/samples/moviebill_braille.gif index 74fe0bd..584f7eb 100644 Binary files a/samples/moviebill_braille.gif and b/samples/moviebill_braille.gif differ diff --git a/samples/moviebill_matrix.gif b/samples/moviebill_matrix.gif index 5689053..ca36f7b 100644 Binary files a/samples/moviebill_matrix.gif and b/samples/moviebill_matrix.gif differ diff --git a/samples/moviebill_matrix_red.gif b/samples/moviebill_matrix_red.gif index 517ba0d..e43d184 100644 Binary files a/samples/moviebill_matrix_red.gif and b/samples/moviebill_matrix_red.gif differ diff --git a/samples/moviebill_mono.gif b/samples/moviebill_mono.gif index a693b86..2ab472e 100644 Binary files a/samples/moviebill_mono.gif and b/samples/moviebill_mono.gif differ diff --git a/samples/startrek_braille.gif b/samples/startrek_braille.gif index 1683d5d..955bba5 100644 Binary files a/samples/startrek_braille.gif and b/samples/startrek_braille.gif differ diff --git a/samples/status_ascii.gif b/samples/status_ascii.gif index 9c19fea..1fe4a2f 100644 Binary files a/samples/status_ascii.gif and b/samples/status_ascii.gif differ diff --git a/samples/status_braille.gif b/samples/status_braille.gif index d19a539..4af7ca0 100644 Binary files a/samples/status_braille.gif and b/samples/status_braille.gif differ diff --git a/samples/video_ascii_status.gif b/samples/video_ascii_status.gif index a07475a..49a1180 100644 Binary files a/samples/video_ascii_status.gif and b/samples/video_ascii_status.gif differ diff --git a/samples/video_blocks.gif b/samples/video_blocks.gif index 0da960b..1a5ef05 100644 Binary files a/samples/video_blocks.gif and b/samples/video_blocks.gif differ diff --git a/samples/video_mono_status.gif b/samples/video_mono_status.gif index ef427ff..9b529eb 100644 Binary files a/samples/video_mono_status.gif and b/samples/video_mono_status.gif differ diff --git a/samples/wiggum_ascii.gif b/samples/wiggum_ascii.gif index 8dfcc35..91aeb35 100644 Binary files a/samples/wiggum_ascii.gif and b/samples/wiggum_ascii.gif differ diff --git a/samples/wiggum_blocks.gif b/samples/wiggum_blocks.gif index 50f7333..e86a6de 100644 Binary files a/samples/wiggum_blocks.gif and b/samples/wiggum_blocks.gif differ diff --git a/samples/wiggum_braille.gif b/samples/wiggum_braille.gif index 61ace95..7d520d2 100644 Binary files a/samples/wiggum_braille.gif and b/samples/wiggum_braille.gif differ diff --git a/samples/wiggum_mono.gif b/samples/wiggum_mono.gif index e0942b1..0373a70 100644 Binary files a/samples/wiggum_mono.gif and b/samples/wiggum_mono.gif differ