1503 lines
43 KiB
C++
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;
|
|
}
|
|
}
|