diff --git a/build.ps1 b/build.ps1 index 6f5a299..9cc77d2 100644 --- a/build.ps1 +++ b/build.ps1 @@ -23,8 +23,8 @@ if (-not (Get-Module -ListAvailable -Name InvokeBuild)) { } Import-Module InvokeBuild -ErrorAction Stop -if ($task) { - $buildparams.Task = $task +if ($Task) { + $buildparams.Task = $Task } if (-not $env:CI) { diff --git a/docs/en-US/ConvertTo-Sixel.md b/docs/en-US/ConvertTo-Sixel.md index 2e13b29..bfc4c0e 100644 --- a/docs/en-US/ConvertTo-Sixel.md +++ b/docs/en-US/ConvertTo-Sixel.md @@ -23,7 +23,7 @@ ConvertTo-Sixel [-Path] [-MaxColors ] [-Width ] [-Height [-MaxColors ] [-Width ] [-Height ] [-Force] [] +ConvertTo-Sixel -Url [-MaxColors ] [-Width ] [-Height ] [-Force] [-Timeout ] [] ``` ### Stream @@ -214,6 +214,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Timeout + +Timeout for webrequest + +```yaml +Type: TimeSpan +Parameter Sets: Url +Aliases: + +Required: False +Position: Named +Default value: 15 +Accept pipeline input: False +Accept wildcard characters: False +``` + ## INPUTS ### System.String diff --git a/docs/en-US/ConvertTo-SixelGif.md b/docs/en-US/ConvertTo-SixelGif.md index 60f4454..3b84ad1 100644 --- a/docs/en-US/ConvertTo-SixelGif.md +++ b/docs/en-US/ConvertTo-SixelGif.md @@ -24,7 +24,7 @@ ConvertTo-SixelGif [-Path] [-MaxColors ] [-Width ] [-Force] [ ### Url ```powershell -ConvertTo-SixelGif -Url [-MaxColors ] [-Width ] [-Force] [-LoopCount ] [] +ConvertTo-SixelGif -Url [-MaxColors ] [-Width ] [-Force] [-LoopCount ] [-Timeout ] [] ``` ### Stream @@ -174,6 +174,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Timeout + +Timeout for webrequest + +```yaml +Type: TimeSpan +Parameter Sets: Url +Aliases: + +Required: False +Position: Named +Default value: 15 +Accept pipeline input: False +Accept wildcard characters: False +``` + ## INPUTS ### System.String diff --git a/module/Sixel.psd1 b/module/Sixel.psd1 index d98384f..a3f9ec8 100644 --- a/module/Sixel.psd1 +++ b/module/Sixel.psd1 @@ -46,7 +46,7 @@ ProjectUri = 'https://github.com/trackd/Sixel' # Prerelease = 'prerelease01' ReleaseNotes = @' - 0.7.0 - update libraries, bugfixes. + 0.7.0 - new Braille protocol support, improved terminal detection, and library updates. 0.6.1 - bugfix resizing image sometimes cuts off the left outer edge. 0.6.0 - update libraries, tweak sizing algorithm. 0.5.0 - Refactor, cleanup, bugfixes with terminal detection and stream. diff --git a/src/Sixel/Cmdlet/ConvertToSixel.cs b/src/Sixel/Cmdlet/ConvertToSixel.cs index e074bd7..7dcd36a 100644 --- a/src/Sixel/Cmdlet/ConvertToSixel.cs +++ b/src/Sixel/Cmdlet/ConvertToSixel.cs @@ -10,7 +10,7 @@ namespace Sixel.Cmdlet; [Alias("cts")] [OutputType(typeof(string))] public sealed class ConvertSixelCmdlet : PSCmdlet { - private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly HttpClient _httpClient = new(); [Parameter( HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", Mandatory = true, @@ -66,7 +66,6 @@ public sealed class ConvertSixelCmdlet : PSCmdlet { [ValidateTerminalWidth()] public int Width { get; set; } - [Parameter( HelpMessage = "Height of the image in character cells, the width will be scaled to maintain aspect ratio." )] @@ -83,6 +82,12 @@ public sealed class ConvertSixelCmdlet : PSCmdlet { )] public ImageProtocol Protocol { get; set; } = ImageProtocol.Auto; + [Parameter( + HelpMessage = "Timeout for web request", + ParameterSetName = "Url" + )] + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(15); + protected override void ProcessRecord() { Stream? imageStream = null; try { @@ -106,6 +111,7 @@ protected override void ProcessRecord() { } break; case "Url": { + _httpClient.Timeout = Timeout; HttpResponseMessage response = _httpClient.GetAsync(Url).GetAwaiter().GetResult(); _ = response.EnsureSuccessStatusCode(); imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); @@ -119,6 +125,7 @@ protected override void ProcessRecord() { break; } default: + // just to stop analyzer from complaining.. break; } if (imageStream is null) { diff --git a/src/Sixel/Cmdlet/ConvertToSixelGif.cs b/src/Sixel/Cmdlet/ConvertToSixelGif.cs index 5e08ac1..62f0444 100644 --- a/src/Sixel/Cmdlet/ConvertToSixelGif.cs +++ b/src/Sixel/Cmdlet/ConvertToSixelGif.cs @@ -1,4 +1,4 @@ -using System.Management.Automation; +using System.Management.Automation; using System.Net.Http; using Sixel.Protocols; using Sixel.Terminal; @@ -11,7 +11,8 @@ namespace Sixel.Cmdlet; [Alias("gif")] [OutputType(typeof(SixelGif))] public sealed class ConvertSixelGifCmdlet : PSCmdlet { - private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly HttpClient _httpClient = new(); + [Parameter( HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", Mandatory = true, @@ -75,7 +76,14 @@ public sealed class ConvertSixelGifCmdlet : PSCmdlet { [Parameter( HelpMessage = "The number of times to loop the gif. Use 0 for infinite loop." )] + [ValidateRange(0, 256)] public int LoopCount { get; set; } = 3; + + [Parameter( + HelpMessage = "Timeout for web request", + ParameterSetName = "Url" + )] + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(15); protected override void ProcessRecord() { Stream? imageStream = null; try { @@ -100,8 +108,9 @@ protected override void ProcessRecord() { } break; case "Url": { + _httpClient.Timeout = Timeout; HttpResponseMessage response = _httpClient.GetAsync(Url).GetAwaiter().GetResult(); - response.EnsureSuccessStatusCode(); + _ = response.EnsureSuccessStatusCode(); imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); break; } @@ -113,6 +122,7 @@ protected override void ProcessRecord() { break; } default: + // just to stop analyzer from complaining.. break; } if (imageStream is null) { diff --git a/src/Sixel/Helpers/ResizerDev.cs b/src/Sixel/Helpers/ResizerDev.cs index 9d7d092..d735d5f 100644 --- a/src/Sixel/Helpers/ResizerDev.cs +++ b/src/Sixel/Helpers/ResizerDev.cs @@ -6,10 +6,21 @@ namespace Sixel.Terminal; -/// -/// testing math.. +/// Experimental image resizing helpers used for validating and tuning terminal rendering math. /// -public static class ResizerDev { +/// +/// +/// provides alternative resizing algorithms and size calculations +/// intended primarily for development, testing, and comparison against the main resizing +/// helpers in this library (for example, non-Dev resizer/size helper classes). +/// +/// +/// This API is considered experimental: its behavior and surface area may change +/// between releases without notice. Consumers should prefer the primary, documented +/// resizing helpers for production use, and treat this type as an advanced or diagnostic +/// utility. +/// +internal static class ResizerDev { public static Image ResizeForSixel( Image image, ImageSize imageSize, diff --git a/src/Sixel/Helpers/SizeHelperDev.cs b/src/Sixel/Helpers/SizeHelperDev.cs index 306873b..2f253c8 100644 --- a/src/Sixel/Helpers/SizeHelperDev.cs +++ b/src/Sixel/Helpers/SizeHelperDev.cs @@ -5,10 +5,14 @@ namespace Sixel.Terminal; -/// -/// testing math.. +/// Provides experimental image sizing helpers for terminal rendering. /// -public static class SizeHelperDev { +/// +/// This class contains development and testing implementations of image sizing logic +/// used when rendering images in terminal character cells. It is intended for +/// experimentation and validation of sizing math and may change between releases. +/// +internal static class SizeHelperDev { public static ImageSize GetSixelTargetSize(Image image, int maxCellWidth, int maxCellHeight) => GetRequestedOrDefaultCellSize(image, maxCellWidth, maxCellHeight); diff --git a/src/Sixel/Protocols/Blocks.cs b/src/Sixel/Protocols/Blocks.cs index ec651c7..4066d87 100644 --- a/src/Sixel/Protocols/Blocks.cs +++ b/src/Sixel/Protocols/Blocks.cs @@ -108,5 +108,16 @@ private static void AppendBlock(this StringBuilder Builder, byte tr, byte tg, by Append(Constants.Reset); } - private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; + // private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; + private static bool IsTransparent(Rgba32 pixel) { + if (pixel.A <= 8) { + return true; + } + + float luminance = ((0.299f * pixel.R) + (0.587f * pixel.G) + (0.114f * pixel.B)) / 255f; + return (pixel.A < 32 && luminance < 0.15f) || + (pixel.A < 64 && pixel.R < 12 && pixel.G < 12 && pixel.B < 12) || + (pixel.A < 128 && luminance < 0.05f) || + (pixel.A < 240 && luminance < 0.01f); + } } diff --git a/src/Sixel/Protocols/Braille.cs b/src/Sixel/Protocols/Braille.cs index ce10ab4..a3d6a9a 100644 --- a/src/Sixel/Protocols/Braille.cs +++ b/src/Sixel/Protocols/Braille.cs @@ -77,15 +77,15 @@ private static void AppendCodepoint(this StringBuilder Builder, int codepoint, b Append((char)codepoint). Append(Constants.Reset); } - private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; - private static bool IsTransparentAdv(Rgba32 pixel) { - if (pixel.A == 0) { + + // private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; + private static bool IsTransparent(Rgba32 pixel) { + if (pixel.A <= 8) { return true; } float luminance = ((0.299f * pixel.R) + (0.587f * pixel.G) + (0.114f * pixel.B)) / 255f; - return pixel.A < 8 || - (pixel.A < 32 && luminance < 0.15f) || + return (pixel.A < 32 && luminance < 0.15f) || (pixel.A < 64 && pixel.R < 12 && pixel.G < 12 && pixel.B < 12) || (pixel.A < 128 && luminance < 0.05f) || (pixel.A < 240 && luminance < 0.01f); diff --git a/src/Sixel/Sixel.csproj b/src/Sixel/Sixel.csproj index 7261352..22c4e54 100644 --- a/src/Sixel/Sixel.csproj +++ b/src/Sixel/Sixel.csproj @@ -13,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Sixel/Terminal/Compatibility.cs b/src/Sixel/Terminal/Compatibility.cs index d9cc09b..2763152 100644 --- a/src/Sixel/Terminal/Compatibility.cs +++ b/src/Sixel/Terminal/Compatibility.cs @@ -13,6 +13,8 @@ namespace Sixel.Terminal; /// Provides methods and cached properties for detecting terminal compatibility, supported protocols, and cell/window sizes. /// public static partial class Compatibility { + private static readonly object s_controlSequenceLock = new(); + /// /// Memory-caches the result of the terminal supporting sixel graphics. /// @@ -49,54 +51,139 @@ public static string GetControlSequenceResponse(string controlSequence) { const int timeoutMs = 500; const int maxRetries = 2; - for (int retry = 0; retry < maxRetries; retry++) { - try { - var response = new StringBuilder(); - bool capturing = false; + lock (s_controlSequenceLock) + { + // Drain any stale bytes that may have leaked from prior VT interactions. + DrainPendingInput(); - // Send the control sequence - Console.Write($"{Constants.ESC}{controlSequence}"); - var stopwatch = Stopwatch.StartNew(); + for (int retry = 0; retry < maxRetries; retry++) + { + try + { + var response = new StringBuilder(); + bool capturing = false; + + // Send the control sequence + Console.Write($"{Constants.ESC}{controlSequence}"); + Console.Out.Flush(); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) + { + if (!TryReadAvailableKey(out char key)) + { + Thread.Sleep(1); + continue; + } - while (stopwatch.ElapsedMilliseconds < timeoutMs) { - if (!Console.KeyAvailable) { - Thread.Sleep(1); - continue; - } + if (!capturing) + { + if (key != '\x1b') + { + continue; + } + capturing = true; + } - ConsoleKeyInfo keyInfo = Console.ReadKey(true); - char key = keyInfo.KeyChar; + response.Append(key); - if (!capturing) { - if (key != '\x1b') { - continue; + // Check if we have a complete response + if (IsCompleteResponse(response)) + { + DrainPendingInput(); + return response.ToString(); } - capturing = true; } - response.Append(key); - - // Check if we have a complete response - if (IsCompleteResponse(response)) { + // If we got a partial response, return it + if (response.Length > 0) + { + DrainPendingInput(); return response.ToString(); } } - - // If we got a partial response, return it - if (response.Length > 0) { - return response.ToString(); - } - } - catch (Exception) { - if (retry == maxRetries - 1) { - return string.Empty; + catch (Exception) + { + if (retry == maxRetries - 1) + { + DrainPendingInput(); + return string.Empty; + } } } + + DrainPendingInput(); } return string.Empty; } + /// + /// Attempts to read a key if one is available. + /// + /// The key read from stdin. + /// True when a key was read, otherwise false. + private static bool TryReadAvailableKey(out char key) + { + key = default; + + try + { + if (!Console.KeyAvailable) + { + return false; + } + + key = Console.ReadKey(true).KeyChar; + return true; + } + catch + { + return false; + } + } + + /// + /// Drains any pending stdin bytes to prevent VT probe responses from leaking into user input. + /// + private static void DrainPendingInput() + { + if (Console.IsOutputRedirected || Console.IsInputRedirected) + { + return; + } + + try + { + const int quietPeriodMs = 20; + const int maxDrainMs = 250; + + var stopwatch = Stopwatch.StartNew(); + long lastReadAt = stopwatch.ElapsedMilliseconds; + + while (stopwatch.ElapsedMilliseconds < maxDrainMs) + { + if (!Console.KeyAvailable) + { + if (stopwatch.ElapsedMilliseconds - lastReadAt >= quietPeriodMs) + { + break; + } + + Thread.Sleep(1); + continue; + } + + _ = Console.ReadKey(true); + lastReadAt = stopwatch.ElapsedMilliseconds; + } + } + catch + { + // Best effort only. + } + } + /// /// Check for complete terminal responses diff --git a/src/Sixel/Terminal/ConvertTo.cs b/src/Sixel/Terminal/ConvertTo.cs index f599897..b64197f 100644 --- a/src/Sixel/Terminal/ConvertTo.cs +++ b/src/Sixel/Terminal/ConvertTo.cs @@ -23,18 +23,18 @@ public static class ConvertTo { /// A tuple containing the image size and the converted image data. /// public static (ImageSize Size, string Data) ConsoleImage( - ImageProtocol imageProtocol, - Stream imageStream, - int maxColors, - int width = 0, - int height = 0, - bool Force = false + ImageProtocol imageProtocol, + Stream imageStream, + int maxColors, + int width = 0, + int height = 0, + bool Force = false ) { /// this is a guess at the protocol based on the environment variables and VT responses. /// the parameter `imageProtocol` is the chosen protocol, we need to see if that is supported. ImageProtocol[] autoProtocol = Compatibility.GetTerminalInfo().Protocol; - // Improved: If Auto, select the best supported protocol by priority (Kitty > Sixel > Inline > Blocks) + // Improved: If Auto, select the best supported protocol by priority (Sixel > Kitty > Inline > Blocks) ImageProtocol protocol = imageProtocol; if (imageProtocol == ImageProtocol.Auto) { protocol = autoProtocol.Contains(ImageProtocol.Sixel) @@ -93,6 +93,8 @@ public static (ImageSize Size, string Data) ConsoleImage( case ImageProtocol.Braille: return Braille.ImageToBraille(image, constrainedSize.Width, constrainedSize.Height); case ImageProtocol.Auto: + // Defensive assertion: the Auto protocol should have been resolved to a concrete protocol above. + // Reaching this case indicates a logic error in the protocol resolution code. throw new InvalidOperationException("Auto protocol should have been resolved"); default: throw new InvalidOperationException($"Unsupported image protocol: {protocol}"); diff --git a/src/Sixel/Terminal/VTWriter.cs b/src/Sixel/Terminal/VTWriter.cs index 7f641e1..03499be 100644 --- a/src/Sixel/Terminal/VTWriter.cs +++ b/src/Sixel/Terminal/VTWriter.cs @@ -14,24 +14,22 @@ internal sealed class VTWriter : IDisposable { public VTWriter() { bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - bool isRedirected = Console.IsOutputRedirected || Console.IsOutputRedirected; + bool isRedirected = Console.IsOutputRedirected || Console.IsInputRedirected; + // in my tests Console.Write has been much faster on Mac/Linux and does not need special handling here. if (isWindows && !isRedirected) { // Open the Windows stream to CONOUT$, for better performance.. // Console.Write is too slow for gifs on Windows. - if (isWindows && !isRedirected) { #if NET472 - _windowsStream = new FileStream(NativeMethods.OpenConOut(), FileAccess.Write); - _writer = new StreamWriter(_windowsStream); - _customwriter = true; + // net472 can't access CONOUT$ directly, use pinvoke to solve that. + _windowsStream = new FileStream(NativeMethods.OpenConOut(), FileAccess.Write); + _writer = new StreamWriter(_windowsStream); + _customwriter = true; #else - // Open the Windows stream to CONOUT$, for better performance.. - // Console.Write is too slow for gifs. - _windowsStream = File.OpenWrite("CONOUT$"); - _writer = new StreamWriter(_windowsStream); - _customwriter = true; + _windowsStream = File.OpenWrite("CONOUT$"); + _writer = new StreamWriter(_windowsStream); + _customwriter = true; #endif - } } }