Files
asciicast2webp/asciicast2webp.cpp
p 53955171c7
Alpine 3.24 Success
Alpine 3.24 aarch64 Success
OpenBSD 7.8 Success
Fix OpenBSD build
LLVM 19.1 doesn't have from_chars for double.
2026-08-04 08:39:07 +02:00

1503 lines
43 KiB
C++

// asciicast2webp: render asciicast captures as animated WebP
//
// Copyright (c) 2026, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
#include <cairo/cairo.h>
#include <curses.h>
#include <getopt.h>
#include <jv.h>
#include <term.h>
#include <unistd.h>
#undef bell
#undef cursor_visible
#include <vterm.h>
#include <webp/encode.h>
#include <webp/mux.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
#include <clocale>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <map>
#include <memory>
#include <numbers>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using namespace std;
namespace {
// --drop-short would achieve a similar thing,
// though this mechanism merely introduces input latency.
constexpr double kInputMergeGap = 0.05;
constexpr double kRenderDpi = 96.;
struct BadgePosition {
double top{1.};
double left{-1.};
};
struct Options {
int preset{6};
int near_lossless{100};
int duration_min{11};
bool drop_short{};
int border{};
string font_name{"monospace"};
double point_size{10.};
double key_timeout{0.5};
BadgePosition badge_position;
};
[[noreturn]] void fail(const string &message) { throw runtime_error(message); }
// --- Colors ------------------------------------------------------------------
constexpr uint8_t color_channel(double value) {
return uint8_t(value * 255. + 0.5);
}
struct Color {
double red, green, blue;
};
constexpr Color operator""_rgb(unsigned long long value) {
return {
double((value >> 16) & 0xff) / 255.,
double((value >> 8) & 0xff) / 255.,
double(value & 0xff) / 255.,
};
}
constexpr Color kForeground = 0x000000_rgb;
constexpr Color kBackground = 0xffffff_rgb;
constexpr array kAnsiPalette{
0x000000_rgb,
0xaa0000_rgb,
0x00aa00_rgb,
0xaa5500_rgb,
0x0000aa_rgb,
0xaa00aa_rgb,
0x00aaaa_rgb,
0xaaaaaa_rgb,
0x555555_rgb,
0xff5555_rgb,
0x55ff55_rgb,
0xffff55_rgb,
0x5555ff_rgb,
0xff55ff_rgb,
0x55ffff_rgb,
0xffffff_rgb,
};
constexpr Color xterm_palette_color(size_t index) {
if (index < kAnsiPalette.size())
return kAnsiPalette[index];
if (index < 232) {
const size_t cube = index - 16;
const auto component = [](size_t value) constexpr {
return value == 0 ? 0. : double(55 + 40 * value) / 255.;
};
return {component(cube / 36), component((cube / 6) % 6),
component(cube % 6)};
}
const double gray = double(8 + 10 * (index - 232)) / 255.;
return {gray, gray, gray};
}
static_assert(xterm_palette_color(255).red == 238. / 255.);
constexpr array<Color, 256> make_xterm_palette() {
array<Color, 256> palette{};
for (size_t i = 0; i < palette.size(); ++i)
palette[i] = xterm_palette_color(i);
return palette;
}
constexpr auto kXtermPalette = make_xterm_palette();
struct Theme {
Color foreground{kForeground};
Color background{kBackground};
array<Color, 256> palette{kXtermPalette};
};
// --- asciicast ---------------------------------------------------------------
struct Header {
int version{};
int width{};
int height{};
optional<double> duration;
double idle_time_limit{numeric_limits<double>::infinity()};
string term;
Theme theme;
};
struct Event {
double time{};
char code{};
string data;
};
struct Cast {
Header header;
vector<Event> events;
double end{};
int max_cols{};
int max_rows{};
};
struct Input {
double time{};
string label;
};
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class JV {
jv value_;
public:
explicit JV(jv value) : value_(value) {}
~JV() { jv_free(value_); }
JV(JV &&other) noexcept : value_(exchange(other.value_, jv_invalid())) {}
JV(const JV &) = delete;
JV &operator=(const JV &) = delete;
JV &operator=(JV &&) = delete;
[[nodiscard]] jv_kind kind() const { return jv_get_kind(value_); }
[[nodiscard]] bool is(jv_kind kind) const { return this->kind() == kind; }
[[nodiscard]] double number() const { return jv_number_value(value_); }
[[nodiscard]] string string_value() const {
const int length = jv_string_length_bytes(jv_copy(value_));
return string(jv_string_value(value_), size_t(length));
}
[[nodiscard]] JV get(const char *key) const {
return JV(jv_object_get(jv_copy(value_), jv_string(key)));
}
[[nodiscard]] bool absent() const { return is(JV_KIND_INVALID); }
[[nodiscard]] int size() const { return jv_array_length(jv_copy(value_)); }
[[nodiscard]] JV at(int index) const {
return JV(jv_array_get(jv_copy(value_), index));
}
};
JV parse_json(const string &text, const string &where) {
jv value = jv_parse_sized(text.data(), int(text.size()));
if (jv_is_valid(value))
return JV(value);
JV message(jv_invalid_get_msg(value));
string detail =
message.is(JV_KIND_STRING) ? message.string_value() : "invalid JSON";
fail(where + ": " + detail);
}
double required_number(const JV &object, const char *key, const string &where) {
JV value = object.get(key);
if (!value.is(JV_KIND_NUMBER))
fail(where + ": " + key + " must be a number");
return value.number();
}
int required_integer(const JV &object, const char *key, const string &where) {
const double value = required_number(object, key, where);
if (!isfinite(value) || trunc(value) != value ||
value < numeric_limits<int>::min() ||
value > numeric_limits<int>::max())
fail(where + ": " + key + " must be an integer");
return int(value);
}
string required_string(const JV &object, const char *key, const string &where) {
JV value = object.get(key);
if (!value.is(JV_KIND_STRING))
fail(where + ": " + key + " must be a string");
return value.string_value();
}
Color parse_color(const string &value, const string &where) {
if (value.size() != 7 || value.front() != '#')
fail(where + " must be a color in #rrggbb format");
array<unsigned int, 3> channels{};
for (size_t i = 0; i < channels.size(); ++i) {
const char *first = value.data() + 1 + 2 * i;
const auto [last, error] =
from_chars(first, first + 2, channels[i], 16);
if (error != errc{} || last != first + 2)
fail(where + " must be a color in #rrggbb format");
}
return {channels[0] / 255., channels[1] / 255., channels[2] / 255.};
}
void parse_theme(const JV &term, Theme &theme) {
JV raw = term.get("theme");
if (raw.absent())
return;
if (!raw.is(JV_KIND_OBJECT))
fail("header.term.theme must be an object");
theme.foreground =
parse_color(required_string(raw, "fg", "header.term.theme"),
"header.term.theme.fg");
theme.background =
parse_color(required_string(raw, "bg", "header.term.theme"),
"header.term.theme.bg");
const string palette = required_string(raw, "palette", "header.term.theme");
vector<string> colors;
size_t begin = 0;
while (true) {
const size_t end = palette.find(':', begin);
colors.push_back(palette.substr(begin, end - begin));
if (end == string::npos)
break;
begin = end + 1;
}
if (colors.size() != 8 && colors.size() != 16)
fail("header.term.theme.palette must contain 8 or 16 colors");
for (size_t i = 0; i < colors.size(); ++i) {
theme.palette[i] = parse_color(
colors[i], "header.term.theme.palette color " + to_string(i + 1));
}
}
pair<int, int> parse_resize(const string &value) {
const auto split = value.find('x');
if (split == string::npos)
fail("invalid resize " + value);
try {
const int cols = stoi(value.substr(0, split));
const int rows = stoi(value.substr(split + 1));
if (cols <= 0 || rows <= 0)
fail("invalid resize " + value);
return {cols, rows};
} catch (const exception &) {
fail("invalid resize " + value);
}
}
Event parse_event(const string &line, int line_number) {
JV raw = parse_json(line, "line " + to_string(line_number));
if (!raw.is(JV_KIND_ARRAY) || raw.size() != 3)
fail("line " + to_string(line_number) +
": event must have three fields");
JV time_value = raw.at(0);
JV code_value = raw.at(1);
JV data_value = raw.at(2);
if (!time_value.is(JV_KIND_NUMBER) || !code_value.is(JV_KIND_STRING) ||
!data_value.is(JV_KIND_STRING))
fail("line " + to_string(line_number) + ": invalid event fields");
Event event;
event.time = time_value.number();
const string code = code_value.string_value();
event.data = data_value.string_value();
event.code = code.size() == 1 ? code.front() : '\0';
return event;
}
Header parse_header(const string &line) {
Header result;
JV header = parse_json(line, "header");
if (!header.is(JV_KIND_OBJECT))
fail("header must be an object");
result.version = required_integer(header, "version", "header");
if (result.version != 2 && result.version != 3)
fail("unsupported asciicast version " + to_string(result.version));
if (result.version == 2) {
result.width = required_integer(header, "width", "header");
result.height = required_integer(header, "height", "header");
} else {
JV term = header.get("term");
if (!term.is(JV_KIND_OBJECT))
fail("header: term must be an object");
result.width = required_integer(term, "cols", "header.term");
result.height = required_integer(term, "rows", "header.term");
JV type = term.get("type");
if (type.is(JV_KIND_STRING))
result.term = type.string_value();
else if (!type.absent())
fail("header.term: type must be a string");
parse_theme(term, result.theme);
JV raw_limit = header.get("idle_time_limit");
if (raw_limit.is(JV_KIND_NUMBER)) {
result.idle_time_limit = raw_limit.number();
if (!isfinite(result.idle_time_limit) ||
result.idle_time_limit < 0.)
fail("header: invalid idle_time_limit");
} else if (!raw_limit.absent()) {
fail("header: idle_time_limit must be a number");
}
}
if (result.width <= 0 || result.height <= 0)
fail("invalid terminal dimensions");
JV duration = header.get("duration");
if (result.version == 2 && duration.is(JV_KIND_NUMBER)) {
result.duration = duration.number();
if (*result.duration < 0.)
fail("negative cast duration");
}
JV env = header.get("env");
if (result.version == 2 && env.is(JV_KIND_OBJECT)) {
JV term = env.get("TERM");
if (term.is(JV_KIND_STRING))
result.term = term.string_value();
}
if (result.term.empty())
fail("cast header has no TERM identification");
return result;
}
Cast read_cast(const filesystem::path &path) {
ifstream stream(path);
if (!stream)
fail("cannot open " + path.string());
string line;
if (!getline(stream, line) || line.empty())
fail("empty cast");
Cast cast;
cast.header = parse_header(line);
cast.max_cols = cast.header.width;
cast.max_rows = cast.header.height;
double previous = 0.;
int line_number = 1;
while (getline(stream, line)) {
++line_number;
if (line.empty() || (cast.header.version == 3 && line.front() == '#'))
continue;
Event event = parse_event(line, line_number);
if (!isfinite(event.time) || event.time < 0. ||
(cast.header.version == 2 && !cast.events.empty() &&
event.time < previous)) {
fail("line " + to_string(line_number) + ": invalid event time");
}
if (cast.header.version == 3)
event.time =
previous + min(event.time, cast.header.idle_time_limit);
previous = event.time;
if (event.code == 'r') {
const auto [cols, rows] = parse_resize(event.data);
cast.max_cols = max(cast.max_cols, cols);
cast.max_rows = max(cast.max_rows, rows);
}
cast.events.push_back(move(event));
}
if (!stream.eof())
fail("error reading cast");
cast.end = cast.header.duration.value_or(previous);
if (!cast.events.empty() && cast.events.back().time > cast.end)
fail("event exceeds header duration");
return cast;
}
// --- Input decoding ----------------------------------------------------------
class KeyDecoder {
map<string, string> sequences_;
void add(const string &capability, const string &label);
public:
explicit KeyDecoder(const string &term);
string decode(const string &data) const;
};
void KeyDecoder::add(const string &capability, const string &label) {
char *value = tigetstr(const_cast<char *>(capability.c_str()));
if (value != nullptr && value != reinterpret_cast<char *>(-1) &&
*value != '\0')
sequences_.try_emplace(value, label);
}
KeyDecoder::KeyDecoder(const string &term) {
int error = 0;
if (setupterm(const_cast<char *>(term.c_str()), STDOUT_FILENO, &error) !=
OK ||
error != 1)
fail("cannot load terminfo entry " + term);
// Hardcode DEC 1004 focus events.
sequences_.try_emplace("\x1B[O", "FocusOut");
sequences_.try_emplace("\x1B[I", "FocusIn");
static const vector<pair<string, string>> canonical{
{"kbs", "Backspace"},
{"kcbt", "S-Tab"},
{"kcub1", "Left"},
{"kcud1", "Down"},
{"kcuf1", "Right"},
{"kcuu1", "Up"},
{"kdch1", "Delete"},
{"kend", "End"},
{"kent", "Enter"},
{"khome", "Home"},
{"kich1", "Insert"},
{"knp", "PageDown"},
{"kpp", "PageUp"},
{"kbeg", "Begin"},
{"kind", "S-Down"},
{"kri", "S-Up"},
};
for (const auto &[capability, label] : canonical)
add(capability, label);
const vector<pair<string, string>> modified{
{"kUP", "Up"},
{"kDN", "Down"},
{"kLFT", "Left"},
{"kRIT", "Right"},
{"kHOM", "Home"},
{"kEND", "End"},
{"kIC", "Insert"},
{"kDC", "Delete"},
{"kNXT", "PageDown"},
{"kPRV", "PageUp"},
};
const array<pair<const char *, const char *>, 6> modifiers{{
{"3", "M-"},
{"4", "M-S-"},
{"5", "C-"},
{"6", "C-S-"},
{"7", "M-C-"},
{"8", "M-C-S-"},
}};
for (const auto &[capability, label] : modified) {
add(capability, "S-" + label);
for (const auto &[suffix, prefix] : modifiers)
add(capability + suffix, string(prefix) + label);
}
// This insanity is explained in terminfo.src.
static const array<string, 6> prefixes{
{"", "S-", "C-", "C-S-", "M-", "M-S-"}};
auto label = [&](int number) -> string {
if (!term.starts_with("rxvt")) {
const int group = (number - 1) / 12;
const int key = (number - 1) % 12 + 1;
return prefixes[size_t(group)] + "F" + to_string(key);
}
if (number <= 12)
return "F" + to_string(number);
if (number <= 22)
return "S-F" + to_string(number - 10);
if (number <= 34)
return "C-F" + to_string(number - 22);
if (number <= 44)
return "C-S-F" + to_string(number - 32);
return "F" + to_string(number);
};
for (int number = 1; number <= 63; ++number)
add("kf" + to_string(number), label(number));
}
bool valid_utf8_single_codepoint(string_view value) {
if (value.empty())
return false;
const auto first = (unsigned char) value.front();
size_t length = 0;
if (first < 0x80)
length = 1;
else if ((first & 0xe0) == 0xc0)
length = 2;
else if ((first & 0xf0) == 0xe0)
length = 3;
else if ((first & 0xf8) == 0xf0)
length = 4;
else
return false;
if (value.size() != length)
return false;
for (size_t i = 1; i < length; ++i) {
if (((unsigned char) (value[i]) & 0xc0) != 0x80)
return false;
}
return true;
}
string KeyDecoder::decode(const string &data) const {
if (const auto found = sequences_.find(data); found != sequences_.end())
return found->second;
if (data.empty())
fail("empty input event");
if (data.size() == 1) {
const auto byte = (unsigned char) data.front();
if (byte == 0)
return "C-Space";
if (byte == '\t')
return "Tab";
if (byte == '\r' || byte == '\n')
return "Enter";
if (byte == 0x1b)
return "Esc";
if (byte == 0x7f)
return "Backspace";
if (byte <= 31)
return string("C-") + char('a' + byte - 1);
if (byte == 32)
return "Space";
}
if ((unsigned char) data.front() == 0x1b && data.size() > 1) {
const string remainder = data.substr(1);
if (!sequences_.contains(remainder) &&
!valid_utf8_single_codepoint(remainder))
fail("input sequence is not described by terminfo");
return "M-" + decode(remainder);
}
auto iscntrl = [](char value) {
const auto byte = (unsigned char) value;
return byte < 0x20 || byte == 0x7f;
};
if (none_of(data.begin(), data.end(), iscntrl))
return data;
fail("input sequence is not described by terminfo");
}
// --- Terminal ----------------------------------------------------------------
struct CursorState {
bool visible{true};
};
int cursor_moved(VTermPos, VTermPos, int visible, void *user) {
((CursorState *) user)->visible = visible != 0;
return 1;
}
const VTermScreenCallbacks kScreenCallbacks = [] {
VTermScreenCallbacks callbacks{};
callbacks.movecursor = cursor_moved;
return callbacks;
}();
class Terminal {
int cols_;
int rows_;
const Theme &theme_;
unique_ptr<VTerm, decltype(&vterm_free)> vt_;
VTermState *state_{};
VTermScreen *screen_{};
CursorState cursor_;
public:
Terminal(int cols, int rows, const Theme &theme);
void feed(const string &data);
void resize(int cols, int rows);
[[nodiscard]] Color color(VTermColor value, bool foreground) const;
[[nodiscard]] VTermScreen *screen() const { return screen_; }
[[nodiscard]] VTermState *state() const { return state_; }
[[nodiscard]] int cols() const { return cols_; }
[[nodiscard]] int rows() const { return rows_; }
[[nodiscard]] bool cursor_visible() const { return cursor_.visible; }
};
VTermColor vterm_color(Color color) {
VTermColor result{};
vterm_color_rgb(&result, color_channel(color.red),
color_channel(color.green), color_channel(color.blue));
return result;
}
Terminal::Terminal(int cols, int rows, const Theme &theme)
: cols_(cols), rows_(rows), theme_(theme),
vt_(vterm_new(rows, cols), vterm_free) {
if (!vt_)
fail("vterm_new failed");
vterm_set_utf8(vt_.get(), 1);
state_ = vterm_obtain_state(vt_.get());
screen_ = vterm_obtain_screen(vt_.get());
vterm_screen_set_callbacks(screen_, &kScreenCallbacks, &cursor_);
vterm_screen_enable_altscreen(screen_, 1);
vterm_screen_set_damage_merge(screen_, VTERM_DAMAGE_SCREEN);
vterm_screen_reset(screen_, 1);
const VTermColor fg = vterm_color(theme_.foreground);
const VTermColor bg = vterm_color(theme_.background);
vterm_screen_set_default_colors(screen_, &fg, &bg);
for (size_t i = 0; i < 256; ++i) {
const VTermColor color = vterm_color(theme_.palette[i]);
vterm_state_set_palette_color(state_, int(i), &color);
}
vterm_state_set_bold_highbright(state_, 0);
}
void Terminal::feed(const string &data) {
const size_t written =
vterm_input_write(vt_.get(), data.data(), data.size());
if (written != data.size())
fail("libvterm did not consume terminal output");
vterm_screen_flush_damage(screen_);
}
void Terminal::resize(int cols, int rows) {
cols_ = cols;
rows_ = rows;
vterm_set_size(vt_.get(), rows, cols);
vterm_screen_flush_damage(screen_);
}
Color Terminal::color(VTermColor value, bool foreground) const {
if ((foreground && VTERM_COLOR_IS_DEFAULT_FG(&value)) ||
(!foreground && VTERM_COLOR_IS_DEFAULT_BG(&value)))
return foreground ? theme_.foreground : theme_.background;
if (VTERM_COLOR_IS_INDEXED(&value))
return theme_.palette[value.indexed.idx];
return {
value.rgb.red / 255.,
value.rgb.green / 255.,
value.rgb.blue / 255.,
};
}
string utf8_for_cell(const VTermScreenCell &cell) {
string result;
constexpr unsigned char prefixes[]{0, 0, 0xc0, 0xe0, 0xf0};
for (uint32_t cp : cell.chars) {
if (cp == 0)
break;
size_t length = 1 + (cp > 0x7f) + (cp > 0x7ff) + (cp > 0xffff);
char bytes[4], *output = bytes + length;
while (--output != bytes) {
*output = 0x80 | (cp & 0x3f);
cp >>= 6;
}
bytes[0] = prefixes[length] | (cp & 0x7f);
result.append(bytes, length);
}
return result;
}
bool cell_is_blank(const VTermScreenCell &cell) {
if (cell.chars[0] != 0 && cell.chars[0] != uint32_t(' '))
return false;
if (cell.attrs.bold || cell.attrs.underline || cell.attrs.italic ||
cell.attrs.blink || cell.attrs.reverse || cell.attrs.conceal ||
cell.attrs.strike)
return false;
return VTERM_COLOR_IS_DEFAULT_FG(&cell.fg) &&
VTERM_COLOR_IS_DEFAULT_BG(&cell.bg);
}
bool screen_is_blank(const Terminal &terminal) {
for (int row = 0; row < terminal.rows(); ++row) {
for (int col = 0; col < terminal.cols(); ++col) {
VTermScreenCell cell{};
if (vterm_screen_get_cell(terminal.screen(), {row, col}, &cell) &&
!cell_is_blank(cell))
return false;
}
}
return true;
}
// --- Cairo -------------------------------------------------------------------
using SurfacePtr =
unique_ptr<cairo_surface_t, decltype(&cairo_surface_destroy)>;
using CairoPtr = unique_ptr<cairo_t, decltype(&cairo_destroy)>;
void set_source(cairo_t *cr, const Color &color) {
cairo_set_source_rgb(cr, color.red, color.green, color.blue);
}
struct FontMetrics {
string family;
double em_size;
int cell_width;
int cell_height;
double baseline;
};
FontMetrics measure_font(const string &family, double point_size) {
SurfacePtr surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 1, 1),
cairo_surface_destroy);
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS)
fail("cannot create Cairo font measurement surface");
CairoPtr cr(cairo_create(surface.get()), cairo_destroy);
cairo_select_font_face(cr.get(), family.c_str(), CAIRO_FONT_SLANT_NORMAL,
CAIRO_FONT_WEIGHT_NORMAL);
const double em_size = point_size * kRenderDpi / 72.;
cairo_set_font_size(cr.get(), em_size);
cairo_font_extents_t extents{};
cairo_font_extents(cr.get(), &extents);
cairo_text_extents_t em_glyph{};
cairo_text_extents(cr.get(), "M", &em_glyph);
if (cairo_status(cr.get()) != CAIRO_STATUS_SUCCESS ||
!isfinite(extents.height) || !isfinite(em_glyph.x_advance))
fail("cannot measure font " + family);
const int cell_width = max(1, int(lround(em_glyph.x_advance)));
const int cell_height = max(1, int(lround(extents.height)));
return {
family,
em_size,
cell_width,
cell_height,
(cell_height - extents.ascent - extents.descent) / 2. + extents.ascent,
};
}
// --- Rendering ---------------------------------------------------------------
int canvas_dimension(int cells, int cell_size, int border) {
const int64_t result = int64_t(cells) * cell_size + 2 * int64_t(border);
if (result > WEBP_MAX_DIMENSION)
fail("rendering options make the WebP canvas too large");
return int(result);
}
class Renderer {
FontMetrics font_;
const Theme &theme_;
BadgePosition badge_position_;
int border_;
int width_;
int height_;
void render_cell(cairo_t *cr, const Terminal &terminal,
const VTermPos &cursor, const VTermScreenCell &cell, int row, int col,
int cell_columns) const;
void render_key(cairo_t *cr, const string &key) const;
public:
Renderer(const Cast &cast, const Options &options);
[[nodiscard]] SurfacePtr render(
const Terminal &terminal, const string &key) const;
[[nodiscard]] int width() const { return width_; }
[[nodiscard]] int height() const { return height_; }
};
Renderer::Renderer(const Cast &cast, const Options &options)
: font_(measure_font(options.font_name, options.point_size)),
theme_(cast.header.theme), badge_position_(options.badge_position),
border_(options.border),
width_(canvas_dimension(cast.max_cols, font_.cell_width, border_)),
height_(canvas_dimension(cast.max_rows, font_.cell_height, border_)) {}
// These look better pixel-perfect and properly adjusted to cell boundaries.
bool add_block_element(cairo_t *cr, const VTermScreenCell &cell, double x,
double y, double width, double height) {
if (cell.chars[1])
return false;
uint32_t cp = cell.chars[0];
const auto edge = [](double origin, double length, int eighth) {
return origin + round(length * eighth / 8.);
};
const auto rectangle = [&](int left, int top, int right, int bottom) {
const double x1 = edge(x, width, left);
const double y1 = edge(y, height, top);
cairo_rectangle(cr, x1, y1, edge(x, width, right) - x1,
edge(y, height, bottom) - y1);
};
const auto bit = [&](int column, int row) {
const double bit_width = width / 8.;
const double bit_height = height / 16.;
cairo_rectangle(cr, x + column * bit_width, y + row * bit_height,
bit_width, bit_height);
};
if (cp == 0x2580) {
// U+2580 UPPER HALF BLOCK
rectangle(0, 0, 8, 4);
} else if (cp >= 0x2581 && cp <= 0x2587) {
// U+2581 LOWER ONE EIGHTH BLOCK ... U+2587 LOWER SEVEN EIGHTHS BLOCK
rectangle(0, 7 - int(cp - 0x2581), 8, 8);
} else if (cp >= 0x2588 && cp <= 0x258f) {
// U+2588 FULL BLOCK ... U+258F LEFT ONE EIGHTH BLOCK
rectangle(0, 0, 8 - int(cp - 0x2588), 8);
} else if (cp == 0x2590) {
// U+2590 RIGHT HALF BLOCK
rectangle(4, 0, 8, 8);
} else if (cp >= 0x2591 && cp <= 0x2593) {
constexpr array shade_patterns{
array<uint8_t, 2>{0x88, 0x22}, // U+2591 LIGHT SHADE
array<uint8_t, 2>{0x55, 0xaa}, // U+2592 MEDIUM SHADE
array<uint8_t, 2>{0x77, 0xdd}, // U+2593 DARK SHADE
};
const auto &pattern = shade_patterns[cp - 0x2591];
for (int row = 0; row < 16; ++row) {
for (int column = 0; column < 8; ++column) {
if (pattern[size_t(row) % pattern.size()] &
(1U << (7 - column)))
bit(column, row);
}
}
} else if (cp == 0x2594) {
// U+2594 UPPER ONE EIGHTH BLOCK
rectangle(0, 0, 8, 1);
} else if (cp == 0x2595) {
// U+2595 RIGHT ONE EIGHTH BLOCK
rectangle(7, 0, 8, 8);
} else if (cp >= 0x2596 && cp <= 0x259f) {
constexpr array<uint8_t, 10> masks{
0b0100, // U+2596 QUADRANT LO. LEFT
0b1000, // U+2597 QUADRANT LO. RIGHT
0b0001, // U+2598 QUADRANT UP. LEFT
0b1101, // U+2599 QUADRANT UP. LEFT AND LO. LEFT AND LO. RIGHT
0b1001, // U+259A QUADRANT UP. LEFT AND LO. RIGHT
0b0111, // U+259B QUADRANT UP. LEFT AND UP. RIGHT AND LO. LEFT
0b1011, // U+259C QUADRANT UP. LEFT AND UP. RIGHT AND LO. RIGHT
0b0010, // U+259D QUADRANT UP. RIGHT
0b0110, // U+259E QUADRANT UP. RIGHT AND LO. LEFT
0b1110, // U+259F QUADRANT UP. RIGHT AND LO. LEFT AND LO. RIGHT
};
const uint8_t mask = masks[cp - 0x2596];
if (mask & 0b0001)
rectangle(0, 0, 4, 4);
if (mask & 0b0010)
rectangle(4, 0, 8, 4);
if (mask & 0b0100)
rectangle(0, 4, 4, 8);
if (mask & 0b1000)
rectangle(4, 4, 8, 8);
} else {
return false;
}
return true;
}
void Renderer::render_cell(cairo_t *cr, const Terminal &terminal,
const VTermPos &cursor, const VTermScreenCell &cell, int row, int col,
int cell_columns) const {
const FontMetrics &font = font_;
Color fg = terminal.color(cell.fg, true);
Color bg = terminal.color(cell.bg, false);
if (cell.attrs.reverse)
swap(fg, bg);
if (terminal.cursor_visible() && cursor.row == row && cursor.col >= col &&
cursor.col < col + cell_columns)
swap(fg, bg);
const double x = border_ + col * font.cell_width;
const double y = border_ + row * font.cell_height;
const double cell_width = cell_columns * font.cell_width;
set_source(cr, bg);
cairo_rectangle(cr, x, y, cell_width, font.cell_height);
cairo_fill(cr);
if (cell.attrs.conceal)
return;
const cairo_font_slant_t slant =
cell.attrs.italic ? CAIRO_FONT_SLANT_ITALIC : CAIRO_FONT_SLANT_NORMAL;
const cairo_font_weight_t weight =
cell.attrs.bold ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL;
cairo_select_font_face(cr, font.family.c_str(), slant, weight);
cairo_set_font_size(cr, font.em_size);
set_source(cr, fg);
if (add_block_element(cr, cell, x, y, cell_width, font.cell_height)) {
cairo_fill(cr);
} else if (const string text = utf8_for_cell(cell); !text.empty()) {
cairo_move_to(cr, x, y + font.baseline);
cairo_show_text(cr, text.c_str());
}
if (cell.attrs.underline) {
cairo_rectangle(cr, x, y + font.cell_height - 1, cell_width, 1);
cairo_fill(cr);
}
if (cell.attrs.strike) {
cairo_rectangle(cr, x, y + font.cell_height / 2., cell_width, 1);
cairo_fill(cr);
}
}
double badge_coordinate(double offset, double canvas_size, double badge_size,
int border, double em_size) {
if (signbit(offset))
return canvas_size - border - badge_size + offset * em_size;
return border + offset * em_size;
}
void add_rounded_rectangle(cairo_t *cr, double x, double y, double width,
double height, double radius) {
radius = min(radius, min(width, height) / 2.);
cairo_new_sub_path(cr);
cairo_move_to(cr, x + radius, y);
cairo_line_to(cr, x + width - radius, y);
cairo_arc(
cr, x + width - radius, y + radius, radius, -numbers::pi / 2., 0.);
cairo_line_to(cr, x + width, y + height - radius);
cairo_arc(cr, x + width - radius, y + height - radius, radius, 0.,
numbers::pi / 2.);
cairo_line_to(cr, x + radius, y + height);
cairo_arc(cr, x + radius, y + height - radius, radius, numbers::pi / 2.,
numbers::pi);
cairo_line_to(cr, x, y + radius);
cairo_arc(
cr, x + radius, y + radius, radius, numbers::pi, 3. * numbers::pi / 2.);
cairo_close_path(cr);
}
void Renderer::render_key(cairo_t *cr, const string &key) const {
if (key.empty())
return;
const FontMetrics &font = font_;
cairo_select_font_face(cr, font.family.c_str(), CAIRO_FONT_SLANT_NORMAL,
CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, font.em_size);
cairo_text_extents_t text{};
cairo_font_extents_t badge_font{};
cairo_text_extents(cr, key.c_str(), &text);
cairo_font_extents(cr, &badge_font);
const double badge_width =
ceil(max(2. * font.em_size, text.x_advance + 1.5 * font.em_size));
const double badge_height = ceil(badge_font.height + 1. * font.em_size);
const double x = badge_coordinate(
badge_position_.left, width_, badge_width, border_, font.em_size);
const double y = badge_coordinate(
badge_position_.top, height_, badge_height, border_, font.em_size);
add_rounded_rectangle(cr, x + 0.5, y + 0.5, badge_width - 1.,
badge_height - 1., font.em_size * 0.75);
set_source(cr, theme_.background);
cairo_fill_preserve(cr);
set_source(cr, theme_.foreground);
cairo_set_line_width(cr, 1.);
cairo_stroke(cr);
const double text_x = x + (badge_width - text.x_advance) / 2.;
const double text_y = y +
(badge_height - badge_font.ascent - badge_font.descent) / 2. +
badge_font.ascent;
cairo_move_to(cr, text_x, text_y);
cairo_show_text(cr, key.c_str());
}
SurfacePtr Renderer::render(const Terminal &terminal, const string &key) const {
SurfacePtr surface(
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width_, height_),
cairo_surface_destroy);
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS)
fail("cannot create Cairo surface");
CairoPtr cr(cairo_create(surface.get()), cairo_destroy);
set_source(cr.get(), theme_.background);
cairo_paint(cr.get());
VTermPos cursor{};
vterm_state_get_cursorpos(terminal.state(), &cursor);
cairo_select_font_face(cr.get(), font_.family.c_str(),
CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr.get(), font_.em_size);
for (int row = 0; row < terminal.rows(); ++row) {
int continuation_cells = 0;
for (int col = 0; col < terminal.cols(); ++col) {
VTermScreenCell cell{};
if (!vterm_screen_get_cell(terminal.screen(), {row, col}, &cell))
continue;
if (continuation_cells > 0) {
--continuation_cells;
continue;
}
const int cell_columns = max(1, int(cell.width));
continuation_cells = cell_columns - 1;
render_cell(
cr.get(), terminal, cursor, cell, row, col, cell_columns);
}
}
render_key(cr.get(), key);
if (cairo_status(cr.get()) != CAIRO_STATUS_SUCCESS) {
fail(string("Cairo rendering failed: ") +
cairo_status_to_string(cairo_status(cr.get())));
}
cairo_surface_flush(surface.get());
return surface;
}
// --- Animation ---------------------------------------------------------------
class WebPAnimation {
int width_;
int height_;
unique_ptr<WebPAnimEncoder, decltype(&WebPAnimEncoderDelete)> encoder_;
WebPConfig config_{};
int duration_min_;
bool drop_short_;
int leading_duration_ms_{};
int frames_{};
int duration_ms_{};
public:
WebPAnimation(
int width, int height, const Options &options, const Theme &theme);
void add(cairo_surface_t *surface, int duration_ms);
void write(const filesystem::path &path);
[[nodiscard]] int frames() const { return frames_; }
[[nodiscard]] int duration_ms() const { return duration_ms_; }
};
constexpr uint32_t webp_background_color(Color color) {
return uint32_t(color_channel(color.blue)) << 24 |
uint32_t(color_channel(color.green)) << 16 |
uint32_t(color_channel(color.red)) << 8 | 0xffU;
}
static_assert(webp_background_color({0.75, 0.5, 0.25}) == 0x4080bfffU);
WebPAnimation::WebPAnimation(
int width, int height, const Options &encoding_options, const Theme &theme)
: width_(width), height_(height), encoder_(nullptr, WebPAnimEncoderDelete),
duration_min_(encoding_options.duration_min),
drop_short_(encoding_options.drop_short) {
WebPAnimEncoderOptions animation_options{};
if (!WebPAnimEncoderOptionsInit(&animation_options))
fail("cannot initialize WebP animation options");
animation_options.anim_params.bgcolor =
webp_background_color(theme.background);
animation_options.anim_params.loop_count = 0;
animation_options.minimize_size = 1;
animation_options.allow_mixed = 0;
encoder_.reset(WebPAnimEncoderNew(width, height, &animation_options));
if (!encoder_)
fail("WebPAnimEncoderNew failed");
if (!WebPConfigInit(&config_) ||
!WebPConfigLosslessPreset(&config_, encoding_options.preset))
fail("cannot initialize lossless WebP preset " +
to_string(encoding_options.preset));
config_.image_hint = WEBP_HINT_GRAPH;
config_.near_lossless = encoding_options.near_lossless;
if (!WebPValidateConfig(&config_))
fail("invalid WebP encoding configuration");
}
void WebPAnimation::add(cairo_surface_t *surface, int duration_ms) {
if (duration_ms <= 0)
return;
if (duration_ms < duration_min_) {
if (drop_short_) {
if (frames_ == 0)
leading_duration_ms_ += duration_ms;
else
duration_ms_ += duration_ms;
return;
}
duration_ms = duration_min_;
}
cairo_surface_flush(surface);
WebPPicture picture{};
if (!WebPPictureInit(&picture))
fail("cannot initialize WebP picture");
picture.use_argb = 1;
picture.width = width_;
picture.height = height_;
// Both APIs use native-endian 0xAARRGGBB words. The surface is opaque,
// so Cairo's premultiplication does not change the color channels.
picture.argb =
reinterpret_cast<uint32_t *>(cairo_image_surface_get_data(surface));
picture.argb_stride =
cairo_image_surface_get_stride(surface) / int(sizeof(uint32_t));
const int added =
WebPAnimEncoderAdd(encoder_.get(), &picture, duration_ms_, &config_);
if (!added)
fail(string("cannot append WebP frame: ") +
WebPAnimEncoderGetError(encoder_.get()));
++frames_;
duration_ms_ += duration_ms;
if (frames_ == 1) {
duration_ms_ += leading_duration_ms_;
leading_duration_ms_ = 0;
}
}
void WebPAnimation::write(const filesystem::path &path) {
if (frames_ == 0)
fail("cast has no visible positive-duration frames");
if (!WebPAnimEncoderAdd(encoder_.get(), nullptr, duration_ms_, nullptr))
fail(string("cannot finalize WebP timing: ") +
WebPAnimEncoderGetError(encoder_.get()));
WebPData output{};
if (!WebPAnimEncoderAssemble(encoder_.get(), &output))
fail(string("cannot assemble WebP animation: ") +
WebPAnimEncoderGetError(encoder_.get()));
ofstream stream(path, ios::binary);
if (!stream) {
WebPDataClear(&output);
fail("cannot create " + path.string());
}
stream.write(
reinterpret_cast<const char *>(output.bytes), streamsize(output.size));
WebPDataClear(&output);
if (!stream)
fail("cannot write " + path.string());
}
bool input_coalesces_with_output(const vector<Event> &events, size_t index) {
const Event &input = events[index];
for (size_t i = index + 1; i < events.size(); ++i) {
if (events[i].time - input.time > kInputMergeGap)
return false;
if (events[i].code == 'o')
return true;
}
return false;
}
vector<double> build_ticks(
const Cast &cast, const vector<Input> &inputs, double key_timeout) {
set<double> ticks;
for (size_t i = 0; i < cast.events.size(); ++i) {
const Event &event = cast.events[i];
if ((event.code == 'o' || event.code == 'r') && event.time < cast.end) {
ticks.insert(event.time);
} else if (event.code == 'i' && event.time < cast.end &&
!input_coalesces_with_output(cast.events, i)) {
ticks.insert(event.time);
}
}
for (size_t i = 0; i < inputs.size(); ++i) {
const double expiry = inputs[i].time + key_timeout;
if (expiry >= cast.end)
continue;
if (i + 1 < inputs.size() && inputs[i + 1].time <= expiry)
continue;
ticks.insert(expiry);
}
return {ticks.begin(), ticks.end()};
}
vector<Input> decode_inputs(const Cast &cast) {
KeyDecoder decoder(cast.header.term);
vector<Input> inputs;
for (const Event &event : cast.events) {
if (event.code != 'i')
continue;
try {
inputs.push_back({event.time, decoder.decode(event.data)});
} catch (const exception &error) {
fail("input event at " + to_string(event.time) +
"s: " + error.what());
}
}
return inputs;
}
void advance_terminal(
Terminal &terminal, const Cast &cast, size_t &event_index, double tick) {
while (event_index < cast.events.size() &&
cast.events[event_index].time <= tick) {
const Event &event = cast.events[event_index++];
if (event.code == 'o') {
terminal.feed(event.data);
} else if (event.code == 'r') {
const auto [cols, rows] = parse_resize(event.data);
terminal.resize(cols, rows);
}
}
}
string active_key(const vector<Input> &inputs, size_t &index, double tick,
double key_timeout) {
while (index + 1 < inputs.size() && inputs[index + 1].time <= tick)
++index;
if (inputs.empty() || inputs[index].time > tick ||
tick >= inputs[index].time + key_timeout)
return {};
return inputs[index].label;
}
class FrameEncoder {
Renderer renderer_;
WebPAnimation animation_;
bool visible_frame_{};
public:
FrameEncoder(const Cast &cast, const Options &options);
void add_frame(
const Terminal &terminal, const string &key, double tick, double next);
void write(const filesystem::path &path) { animation_.write(path); }
[[nodiscard]] int frames() const { return animation_.frames(); }
[[nodiscard]] int duration_ms() const { return animation_.duration_ms(); }
};
FrameEncoder::FrameEncoder(const Cast &cast, const Options &options)
: renderer_(cast, options),
animation_(
renderer_.width(), renderer_.height(), options, cast.header.theme) {}
void FrameEncoder::add_frame(
const Terminal &terminal, const string &key, double tick, double next) {
if (next <= tick)
return;
if (!visible_frame_ && key.empty() && screen_is_blank(terminal))
return;
visible_frame_ = true;
const int duration_ms =
int(lround(next * 1000.)) - int(lround(tick * 1000.));
SurfacePtr surface = renderer_.render(terminal, key);
animation_.add(surface.get(), duration_ms);
}
void replay(
const Cast &cast, const filesystem::path &output, const Options &options) {
const vector<Input> inputs = decode_inputs(cast);
const vector<double> ticks = build_ticks(cast, inputs, options.key_timeout);
if (cast.end <= 0. || ticks.empty())
fail("cast has no positive-duration frames");
Terminal terminal(cast.header.width, cast.header.height, cast.header.theme);
FrameEncoder frames(cast, options);
size_t event_index = 0;
size_t input_index = 0;
for (size_t frame_index = 0; frame_index < ticks.size(); ++frame_index) {
const double tick = ticks[frame_index];
const double next =
frame_index + 1 < ticks.size() ? ticks[frame_index + 1] : cast.end;
advance_terminal(terminal, cast, event_index, tick);
const string key =
active_key(inputs, input_index, tick, options.key_timeout);
frames.add_frame(terminal, key, tick, next);
}
frames.write(output);
cout << output << ": " << frames.frames() << " frames, "
<< frames.duration_ms() / 1000. << " s\n";
}
// --- Main --------------------------------------------------------------------
void usage(const char *program) {
cerr << "Usage: " << program << " [OPTION...] INPUT.cast OUTPUT.webp\n"
<< " -p, --preset LEVEL lossless WebP preset 0..9 (default: 6)\n"
<< " -n, --near-lossless Q near-lossless 0..100 (default: 100)\n"
<< " -d, --duration-min MS minimum frame duration (default: 11)\n"
<< " -D, --drop-short discard frames below duration minimum\n"
<< " -b, --border PIXELS outer canvas border (default: 0)\n"
<< " -f, --font NAME font family (default: monospace)\n"
<< " -s, --size POINTS font size in points (default: 10)\n"
<< " -k, --key-timeout S key display timeout (default: 0.5)\n"
<< " -t, --top EM badge offset from top (default: 1)\n"
<< " -l, --left EM badge offset from left (default: -1)\n"
<< " -h, --help show this help\n";
}
int parse_integer_option(
const char *name, const char *value, int minimum, int maximum) {
char *end = nullptr;
errno = 0;
const long result = strtol(value, &end, 10);
if (errno == ERANGE || end == value || *end || result < minimum ||
result > maximum) {
fail(string(name) + " must be between " + to_string(minimum) + " and " +
to_string(maximum));
}
return int(result);
}
double parse_real_option(const char *name, const char *value) {
char *end = nullptr;
errno = 0;
const double result = strtod(value, &end);
if (errno == ERANGE || end == value || *end || !isfinite(result))
fail(string(name) + " must be a finite number");
return result;
}
double parse_positive_option(const char *name, const char *value) {
const double result = parse_real_option(name, value);
if (result <= 0.)
fail(string(name) + " must be positive");
return result;
}
double parse_nonnegative_option(const char *name, const char *value) {
const double result = parse_real_option(name, value);
if (result < 0.)
fail(string(name) + " must not be negative");
return result;
}
int apply_option(int selected, Options &options, const char *program) {
switch (selected) {
case 'p':
options.preset = parse_integer_option("preset", optarg, 0, 9);
break;
case 'n':
options.near_lossless =
parse_integer_option("near lossless", optarg, 0, 100);
break;
case 'd':
options.duration_min = parse_integer_option(
"minimum duration", optarg, 0, numeric_limits<int>::max());
break;
case 'D':
options.drop_short = true;
break;
case 'b':
options.border = parse_integer_option(
"border", optarg, 0, numeric_limits<int>::max());
break;
case 'f':
options.font_name = optarg;
if (options.font_name.empty())
fail("font name must not be empty");
break;
case 's':
options.point_size = parse_positive_option("size", optarg);
break;
case 'k':
options.key_timeout = parse_nonnegative_option("key timeout", optarg);
break;
case 't':
options.badge_position.top = parse_real_option("top", optarg);
break;
case 'l':
options.badge_position.left = parse_real_option("left", optarg);
break;
case 'h':
usage(program);
return 0;
default:
usage(program);
return 2;
}
return -1;
}
int run(int argc, char **argv) {
Options options;
const option long_options[] = {
{"preset", required_argument, nullptr, 'p'},
{"near-lossless", required_argument, nullptr, 'n'},
{"duration-min", required_argument, nullptr, 'd'},
{"drop-short", no_argument, nullptr, 'D'},
{"border", required_argument, nullptr, 'b'},
{"font", required_argument, nullptr, 'f'},
{"size", required_argument, nullptr, 's'},
{"key-timeout", required_argument, nullptr, 'k'},
{"top", required_argument, nullptr, 't'},
{"left", required_argument, nullptr, 'l'},
{"help", no_argument, nullptr, 'h'},
{nullptr, 0, nullptr, 0},
};
while (true) {
const int selected = getopt_long(
argc, argv, "p:n:d:Db:f:s:k:t:l:h", long_options, nullptr);
if (selected == -1)
break;
const int status = apply_option(selected, options, argv[0]);
if (status >= 0)
return status;
}
if (optind + 2 != argc) {
usage(argv[0]);
return 2;
}
const filesystem::path input = argv[optind];
const filesystem::path output = argv[optind + 1];
replay(read_cast(input), output, options);
return 0;
}
} // namespace
int main(int argc, char **argv) {
setlocale(LC_ALL, "");
try {
return run(argc, argv);
} catch (const exception &error) {
cerr << argv[0] << ": " << error.what() << '\n';
return 1;
}
}