mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 04:52:39 +08:00
[feat] add Sixel rendering to CLI
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user