[feat] add Sixel rendering to CLI

This commit is contained in:
dijunkun
2026-11-02 11:41:25 +08:00
parent b1a0dcdb92
commit 597c4971cf
10 changed files with 244 additions and 34 deletions
+6 -1
View File
@@ -67,7 +67,12 @@ struct PixelFrame {
int width = 0, height = 0;
std::vector<uint8_t> rgb;
};
PixelFrame KittyPixels(const DecodedFrame &, const Viewport &);
PixelFrame ImagePixels(const DecodedFrame &, const Viewport &);
// Sixel has no placement scaling. Limit the cell viewport, not the bitmap,
// so the displayed pixels and pointer mapping keep the same geometry.
Viewport SixelViewport(Viewport);
std::string Sixel(const DecodedFrame &, const Viewport &);
bool SixelReply(const std::string &response);
std::string KittySharedUpload(const std::string &name, int width, int height,
bool alternate_buffer, bool query = false);
std::string KittyPresent(const Viewport &, bool alternate_buffer);
+169
View File
@@ -0,0 +1,169 @@
#include "terminal_renderer.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <stdexcept>
namespace crossdesk::terminal {
Viewport SixelViewport(Viewport v) {
if (v.columns < 1 || v.columns > 240 || v.rows < 1 || v.rows > 100)
throw std::invalid_argument("invalid Sixel viewport");
const auto limit = [](int &cells, int &pixels, int fallback, int maximum) {
const int original_cells = cells;
if (pixels <= 0)
pixels = cells * fallback;
cells =
std::max(1, std::min(cells, int(int64_t(maximum) * cells / pixels)));
pixels = std::clamp(
int((int64_t(pixels) * cells + original_cells / 2) / original_cells), 2,
maximum);
};
limit(v.columns, v.pixel_width, 10, 1920);
limit(v.rows, v.pixel_height, 20, 1080);
v.pixel_mode = true;
return v;
}
bool SixelReply(const std::string &response) {
for (size_t pos = 0;
(pos = response.find("\033[?", pos)) != std::string::npos; ++pos) {
const auto end = response.find('c', pos + 3);
if (end == std::string::npos || end - pos > 1024)
continue;
const char *cur = response.data() + pos + 3;
const char *last = response.data() + end;
bool first = true, supported = false;
while (cur < last) {
unsigned value = 0;
const auto parsed = std::from_chars(cur, last, value);
if (parsed.ec != std::errc{})
break;
// The first parameter identifies the terminal, not a capability.
supported = supported || (!first && value == 4);
first = false;
cur = parsed.ptr;
if (cur == last) {
if (supported)
return true;
break;
}
if (*cur++ != ';')
break;
}
}
return false;
}
namespace {
struct Palette {
int levels, grays;
std::vector<std::array<int, 3>> colors;
Palette(int cube_levels, int gray_levels)
: levels(cube_levels), grays(gray_levels) {
for (int r = 0; r < levels; ++r)
for (int g = 0; g < levels; ++g)
for (int b = 0; b < levels; ++b)
colors.push_back({Level(r), Level(g), Level(b)});
for (int i = 0; i < grays; ++i) {
const int value = Gray(i);
colors.push_back({value, value, value});
}
}
int Level(int i) const { return (i * 255 + (levels - 1) / 2) / (levels - 1); }
int Gray(int i) const { return (i * 255 + (grays - 1) / 2) / (grays - 1); }
uint8_t Index(const uint8_t *rgb) const {
const int gray =
((int(rgb[0]) + rgb[1] + rgb[2]) * (grays - 1) + 382) / 765;
const int gray_index = levels * levels * levels + gray;
if (!levels)
return uint8_t(gray_index);
int index = 0, cube_error = 0, gray_error = 0;
for (int c = 0; c < 3; ++c) {
const int step = (rgb[c] * (levels - 1) + 127) / 255;
index = index * levels + step;
const int dc = rgb[c] - Level(step), dg = rgb[c] - Gray(gray);
cube_error += dc * dc;
gray_error += dg * dg;
}
return uint8_t(gray_error < cube_error ? gray_index : index);
}
};
void Run(std::string &out, uint8_t bits, int count) {
if (count > 3) {
out += '!';
out += std::to_string(count);
out += char('?' + bits);
} else {
out.append(size_t(count), char('?' + bits));
}
}
std::string Encode(const PixelFrame &pixels, const Viewport &v,
const Palette &palette) {
constexpr size_t budget = 1900000; // Leave room for terminal/UI commands.
const int width = pixels.width, height = pixels.height;
std::string out = "\0337\033[" + std::to_string(v.row_offset + 1) +
";1H\033P0;1;0q\"1;1;" + std::to_string(width) + ";" +
std::to_string(height);
// Define a complete palette for every frame, including black margins.
// Pb=1 preserves pixels outside the image in its final partial six-row band.
for (size_t i = 0; i < palette.colors.size(); ++i) {
out += '#' + std::to_string(i) + ";2";
for (int c : palette.colors[i])
out += ';' + std::to_string((c * 100 + 127) / 255);
}
std::vector<uint8_t> planes(palette.colors.size() * width);
std::vector<int> ends(palette.colors.size());
for (int y = 0; y < height; y += 6) {
std::fill(planes.begin(), planes.end(), 0);
std::fill(ends.begin(), ends.end(), 0);
for (int dy = 0; dy < std::min(6, height - y); ++dy)
for (int x = 0; x < width; ++x) {
const auto color =
palette.Index(&pixels.rgb[(size_t(y + dy) * width + x) * 3]);
planes[size_t(color) * width + x] |= uint8_t(1 << dy);
ends[color] = std::max(ends[color], x + 1);
}
bool first = true;
for (size_t color = 0; color < ends.size(); ++color) {
if (!ends[color])
continue;
if (!first)
out += '$'; // Return to the left edge without advancing the band.
first = false;
out += '#' + std::to_string(color);
const auto *plane = &planes[color * width];
for (int x = 0; x < ends[color];) {
int end = x + 1;
while (end < ends[color] && plane[end] == plane[x])
++end;
Run(out, plane[x], end - x);
x = end;
}
if (out.size() > budget)
return {};
}
if (y + 6 < height)
out += '-';
}
out += "\033\\\0338";
return out;
}
} // namespace
std::string Sixel(const DecodedFrame &frame, const Viewport &viewport) {
const auto v = SixelViewport(viewport);
const auto pixels = ImagePixels(frame, v);
// Sixel cannot scale a smaller bitmap to a cell placement. Reduce color
// detail on high-entropy frames instead of shrinking or misplacing them.
for (const auto &sizes :
{std::pair{6, 32}, std::pair{3, 16}, std::pair{2, 8}, std::pair{0, 4}}) {
auto out = Encode(pixels, v, Palette(sizes.first, sizes.second));
if (!out.empty())
return out;
}
// Four planes at 1920 x 1080 always fit the budget above.
throw std::runtime_error("Sixel output budget exceeded");
}
} // namespace crossdesk::terminal
+1 -1
View File
@@ -543,7 +543,7 @@ static std::string Base64(const std::vector<uint8_t> &data) {
}
return out;
}
PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) {
PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);