Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 4 additions & 106 deletions MarkupString.Ansi/Emitters/GifWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace MarkupString.Ansi;
/// </remarks>
internal static class GifWriter
{
private const int Bins = 32768;
private const int Bins = PicturePalette.Bins;

/// <summary>
/// <paramref name="frames"/>, each <paramref name="width"/> × <paramref name="height"/> RGBA shown for its
Expand All @@ -35,11 +35,11 @@ internal static byte[] Encode(IReadOnlyList<TerminalPictureFrame> frames, int wi
for (var i = 0; i < pixels.Length; i += 4)
{
if (pixels[i + 3] < 128) transparent = true;
else histogram[Bin(pixels, i)]++;
else histogram[PicturePalette.Bin(pixels, i)]++;
}
}

var (palette, lookup) = MedianCut(histogram, transparent ? 255 : 256);
var (palette, lookup) = PicturePalette.MedianCut(histogram, transparent ? 255 : 256);
var transparentIndex = palette.Count;
var colours = palette.Count + (transparent ? 1 : 0);
var bits = 1;
Expand Down Expand Up @@ -69,7 +69,7 @@ internal static byte[] Encode(IReadOnlyList<TerminalPictureFrame> frames, int wi
{
var pixels = frame.Rgba.Span;
for (int i = 0, p = 0; p < indices.Length; i += 4, p++)
indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[Bin(pixels, i)];
indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[PicturePalette.Bin(pixels, i)];

// Graphic control: restore to background after the frame, the delay in hundredths, transparency.
var centiseconds = (int)Math.Clamp(Math.Round(frame.Shown.TotalMilliseconds / 10), 2, ushort.MaxValue);
Expand All @@ -92,114 +92,12 @@ internal static byte[] Encode(IReadOnlyList<TerminalPictureFrame> frames, int wi
return output.ToArray();
}

private static int Bin(ReadOnlySpan<byte> pixels, int offset) =>
(pixels[offset] >> 3) << 10 | (pixels[offset + 1] >> 3) << 5 | pixels[offset + 2] >> 3;

private static void WriteShort(Stream output, int value)
{
output.WriteByte((byte)value);
output.WriteByte((byte)(value >> 8));
}

/// <summary>
/// A palette of at most <paramref name="maxColours"/> for the bins in <paramref name="histogram"/>, and each
/// bin's index in it: the box of bins with the widest channel, weighted by how many pixels it holds, is split
/// at its median until there are enough boxes, and each box's colour is the average of its pixels.
/// </summary>
private static (List<(byte R, byte G, byte B)> Palette, byte[] Lookup) MedianCut(int[] histogram, int maxColours)
{
var used = new List<int>();
for (var bin = 0; bin < Bins; bin++)
if (histogram[bin] > 0) used.Add(bin);

var boxes = new List<(int Start, int Length)>();
var bins = used.ToArray();
if (bins.Length > 0) boxes.Add((0, bins.Length));

while (boxes.Count < maxColours)
{
var best = -1;
long bestScore = 0;
var bestChannel = 0;
for (var b = 0; b < boxes.Count; b++)
{
var (start, length) = boxes[b];
if (length < 2) continue;
var (channel, range) = WidestChannel(bins.AsSpan(start, length));
long pixels = 0;
for (var i = start; i < start + length; i++) pixels += histogram[bins[i]];
var score = range * pixels;
if (range > 0 && score > bestScore)
{
(best, bestScore, bestChannel) = (b, score, channel);
}
}

if (best < 0) break;

var (boxStart, boxLength) = boxes[best];
var span = bins.AsSpan(boxStart, boxLength);
var shift = 10 - bestChannel * 5;
span.Sort((x, y) => ((x >> shift) & 31).CompareTo((y >> shift) & 31));

long total = 0;
foreach (var bin in span) total += histogram[bin];
long running = 0;
var split = 1;
for (var i = 0; i < span.Length - 1; i++)
{
running += histogram[span[i]];
split = i + 1;
if (running * 2 >= total) break;
}

boxes[best] = (boxStart, split);
boxes.Add((boxStart + split, boxLength - split));
}

var palette = new List<(byte, byte, byte)>(boxes.Count);
var lookup = new byte[Bins];
foreach (var (start, length) in boxes)
{
long r = 0, g = 0, b = 0, count = 0;
for (var i = start; i < start + length; i++)
{
var bin = bins[i];
long weight = histogram[bin];
r += (((bin >> 10) & 31) * 8 + 4) * weight;
g += (((bin >> 5) & 31) * 8 + 4) * weight;
b += ((bin & 31) * 8 + 4) * weight;
count += weight;
lookup[bin] = (byte)palette.Count;
}

palette.Add(((byte)(r / count), (byte)(g / count), (byte)(b / count)));
}

return (palette, lookup);
}

/// <summary>Which channel (0 red, 1 green, 2 blue) spans the most bins in <paramref name="bins"/>, and by how much.</summary>
private static (int Channel, int Range) WidestChannel(ReadOnlySpan<int> bins)
{
var (best, bestRange) = (0, -1);
for (var channel = 0; channel < 3; channel++)
{
var shift = 10 - channel * 5;
int low = 31, high = 0;
foreach (var bin in bins)
{
var value = (bin >> shift) & 31;
low = Math.Min(low, value);
high = Math.Max(high, value);
}

if (high - low > bestRange) (best, bestRange) = (channel, high - low);
}

return (best, bestRange);
}

/// <summary>
/// <paramref name="indices"/> LZW-compressed as GIF image data, in sub-blocks of at most 255 bytes and a
/// terminator. The code table is cleared when it is full.
Expand Down
110 changes: 110 additions & 0 deletions MarkupString.Ansi/Emitters/PicturePalette.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace MarkupString.Ansi;

/// <summary>A bounded, adaptive palette shared by GIF and Sixel encoders.</summary>
internal static class PicturePalette
{
internal const int Bins = 32768;

internal static int Bin(ReadOnlySpan<byte> pixels, int offset) =>
(pixels[offset] >> 3) << 10 | (pixels[offset + 1] >> 3) << 5 | pixels[offset + 2] >> 3;

/// <summary>
/// A palette of at most <paramref name="maxColours"/> for the bins in <paramref name="histogram"/>, and each
/// bin's index in it: the box of bins with the widest channel, weighted by how many pixels it holds, is split
/// at its median until there are enough boxes, and each box's colour is the average of its pixels.
/// </summary>
internal static (List<(byte R, byte G, byte B)> Palette, byte[] Lookup) MedianCut(int[] histogram, int maxColours)
{
var used = new List<int>();
for (var bin = 0; bin < Bins; bin++)
if (histogram[bin] > 0) used.Add(bin);

var boxes = new List<(int Start, int Length)>();
var bins = used.ToArray();
if (bins.Length > 0) boxes.Add((0, bins.Length));

while (boxes.Count < maxColours)
{
var best = -1;
long bestScore = 0;
var bestChannel = 0;
for (var b = 0; b < boxes.Count; b++)
{
var (start, length) = boxes[b];
if (length < 2) continue;
var (channel, range) = WidestChannel(bins.AsSpan(start, length));
long pixels = 0;
for (var i = start; i < start + length; i++) pixels += histogram[bins[i]];
var score = range * pixels;
if (range > 0 && score > bestScore)
{
(best, bestScore, bestChannel) = (b, score, channel);
}
}

if (best < 0) break;

var (boxStart, boxLength) = boxes[best];
var span = bins.AsSpan(boxStart, boxLength);
var shift = 10 - bestChannel * 5;
span.Sort((x, y) => ((x >> shift) & 31).CompareTo((y >> shift) & 31));

long total = 0;
foreach (var bin in span) total += histogram[bin];
long running = 0;
var split = 1;
for (var i = 0; i < span.Length - 1; i++)
{
running += histogram[span[i]];
split = i + 1;
if (running * 2 >= total) break;
}

boxes[best] = (boxStart, split);
boxes.Add((boxStart + split, boxLength - split));
}

var palette = new List<(byte, byte, byte)>(boxes.Count);
var lookup = new byte[Bins];
foreach (var (start, length) in boxes)
{
long r = 0, g = 0, b = 0, count = 0;
for (var i = start; i < start + length; i++)
{
var bin = bins[i];
long weight = histogram[bin];
r += (((bin >> 10) & 31) * 8 + 4) * weight;
g += (((bin >> 5) & 31) * 8 + 4) * weight;
b += ((bin & 31) * 8 + 4) * weight;
count += weight;
lookup[bin] = (byte)palette.Count;
}

palette.Add(((byte)(r / count), (byte)(g / count), (byte)(b / count)));
}

return (palette, lookup);
}

/// <summary>Which channel (0 red, 1 green, 2 blue) spans the most bins in <paramref name="bins"/>, and by how much.</summary>
private static (int Channel, int Range) WidestChannel(ReadOnlySpan<int> bins)
{
var (best, bestRange) = (0, -1);
for (var channel = 0; channel < 3; channel++)
{
var shift = 10 - channel * 5;
int low = 31, high = 0;
foreach (var bin in bins)
{
var value = (bin >> shift) & 31;
low = Math.Min(low, value);
high = Math.Max(high, value);
}

if (high - low > bestRange) (best, bestRange) = (channel, high - low);
}

return (best, bestRange);
}

}
17 changes: 10 additions & 7 deletions MarkupString.Ansi/Emitters/SixelWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
namespace MarkupString.Ansi;

/// <summary>
/// Writes RGBA pixels as sixel graphics: the 216-colour cube for a palette (each pixel's entry is arithmetic,
/// with no search), a transparent background (<c>P2 = 1</c>) when any pixel is transparent, square pixels,
/// Writes RGBA pixels as sixel graphics: an adaptive palette of up to 255 colours, a transparent background (<c>P2 = 1</c>) when any pixel is transparent, square pixels,
/// and runs of a repeated column compressed with <c>!</c>.
/// </summary>
/// <remarks>
Expand All @@ -15,12 +14,16 @@ namespace MarkupString.Ansi;
/// </remarks>
internal static class SixelWriter
{
private const int Colours = 216;
private const int Colours = 255;

/// <summary>Encodes <paramref name="pixels"/>; <paramref name="height"/> is a whole number of six-pixel bands.</summary>
internal static string Encode(byte[] pixels, int width, int height)
{
var pixelCount = width * height;
var histogram = new int[PicturePalette.Bins];
for (var i = 0; i < pixelCount * 4; i += 4)
if (pixels[i + 3] >= 128) histogram[PicturePalette.Bin(pixels, i)]++;
var (palette, lookup) = PicturePalette.MedianCut(histogram, Colours);
var index = ArrayPool<byte>.Shared.Rent(pixelCount);
// Each colour's sixel line for the band being written: one byte of six bits per column.
var lines = ArrayPool<byte>.Shared.Rent(Colours * width);
Expand All @@ -38,7 +41,7 @@ internal static string Encode(byte[] pixels, int width, int height)
continue;
}

var entry = Level(pixels[o]) * 36 + Level(pixels[o + 1]) * 6 + Level(pixels[o + 2]);
var entry = lookup[PicturePalette.Bin(pixels, o)];
index[i] = (byte)entry;
used[entry] = true;
}
Expand All @@ -52,7 +55,7 @@ internal static string Encode(byte[] pixels, int width, int height)
{
if (!used[entry]) continue;
text.Append('#').Append(entry).Append(";2;")
.Append(entry / 36 * 20).Append(';').Append(entry / 6 % 6 * 20).Append(';').Append(entry % 6 * 20);
.Append(Percent(palette[entry].R)).Append(';').Append(Percent(palette[entry].G)).Append(';').Append(Percent(palette[entry].B));
}

Span<byte> bandColours = stackalloc byte[Colours];
Expand Down Expand Up @@ -117,8 +120,8 @@ internal static string Encode(byte[] pixels, int width, int height)
}
}

/// <summary>A channel's nearest of the cube's six levels.</summary>
private static int Level(byte value) => (value * 5 + 127) / 255;
/// <summary>Sixel colour channels are integer percentages.</summary>
private static int Percent(byte value) => (value * 100 + 127) / 255;

/// <summary>
/// <paramref name="line"/> with each run of more than three equal sixels written <c>!n</c>, and the empty
Expand Down
1 change: 1 addition & 0 deletions MarkupString.Ansi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,4 @@ MarkupString.Ansi.Decoding.PictureStore.PictureStore(MarkupString.Ansi.Decoding.
MarkupString.Ansi.Decoding.PicturesPlugin.Cache.get -> MarkupString.Ansi.Decoding.IPictureCache?
MarkupString.Ansi.Decoding.PicturesPlugin.PicturesPlugin(MarkupString.Ansi.Decoding.IPictureCache? cache) -> void
static MarkupString.Ansi.Decoding.PictureDecoding.WithPictures(this MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Ansi.Decoding.IPictureCache! cache) -> MarkupString.Decoding.DecoderRegistry!
static MarkupString.Ansi.TerminalProfile.MudForge.get -> MarkupString.Ansi.TerminalProfile!
9 changes: 9 additions & 0 deletions MarkupString.Ansi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,12 @@ so nothing depends on a type surviving the trimmer by name.
## Licence

Apache-2.0. Source, guides and issues: [SharpMUSH/MarkupString](https://github.com/SharpMUSH/MarkupString).

MudForge 2.0.5657 is recognized by `MUDFORGE` and supports still iTerm2 inline images and Sixel.
This profile records verified image support; it does not enable graphics without the host/player's
opt-in or imply support for animation or Kitty. Its web client currently does not forward terminal
query replies, so automatic cell-size discovery still requires a client fix.

Sixel uses an adaptive palette of up to 255 colours, selected from the image's colour histogram,
with a reserved transparent index. The bounded median-cut palette implementation is shared with
GIF encoding; GIF's existing palette and animation behaviour are unchanged.
9 changes: 8 additions & 1 deletion MarkupString.Ansi/TerminalProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,16 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe
Reports = ["tmux"],
};

/// <summary>MudForge 2.0.5657: iTerm2 inline images and Sixel, verified in its web client.</summary>
public static TerminalProfile MudForge { get; } = new("mudforge", "MudForge",
TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt)
{
Reports = ["mudforge"],
};

/// <summary>Every terminal this package knows.</summary>
public static IReadOnlyList<TerminalProfile> Known { get; } =
[Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Rio, Mlterm, Alacritty, Vte, Tmux];
[Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Rio, Mlterm, Alacritty, Vte, Tmux, MudForge];

/// <summary>The known terminal a player calls <paramref name="id"/>, compared without case, or null.</summary>
public static TerminalProfile? Find(string? id) =>
Expand Down
Loading
Loading