use std::cell::Cell;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};
use image::imageops::FilterType;
use image::{DynamicImage, RgbaImage};
use ratatui::layout::Rect;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::Protocol;
use ratatui_image::Resize;
use super::decode;
use crate::http;
pub const EMOTE_H: u16 = 2;
const MAX_W: u16 = 8;
pub(crate) const MAX_FRAMES: usize = 120;
const MAX_LIVE_FRAMES: usize = 6000;
const DRAW_BUDGET: usize = 96;
pub(crate) const MIN_DELAY_MS: u32 = 20;
const PALETTE_COLORS: u32 = 200;
struct FrameProto {
proto: Protocol,
delay_ms: u32,
}
struct Anim {
frames: Vec<FrameProto>,
w_cells: u16,
total_ms: u64,
shown: Cell<usize>,
used: Cell<u64>,
}
enum Entry {
Loading,
Ready(Anim),
Failed,
}
struct Job {
key: String, }
struct Done {
key: String,
built: Option<Built>,
}
struct Built {
frames: Vec<FrameProto>,
w_cells: u16,
total_ms: u64,
}
pub struct EmoteStore {
proto: ProtocolType, square_w: u16,
cache: HashMap<String, Entry>,
jobs: Sender<Job>,
done: Receiver<Done>,
start: Instant,
now_ms: Cell<u64>,
budget_left: Cell<usize>,
lru: Cell<u64>,
live_frames: usize,
}
impl EmoteStore {
pub fn new() -> Option<EmoteStore> {
let picker = if std::env::var_os("TMUX").is_some() {
let proto = tmux_outer_protocol()?;
let mut p = Picker::from_fontsize(cell_pixels()?);
p.set_protocol_type(proto);
p
} else {
if !graphics_capable_env() {
return None;
}
Picker::from_query_stdio().ok()?
};
let proto = picker.protocol_type();
if proto == ProtocolType::Halfblocks {
return None;
}
let (cw, ch) = picker.font_size();
let square_w = width_cells(1.0, cw, ch);
let (jobs_tx, jobs_rx) = mpsc::channel::<Job>();
let (done_tx, done_rx) = mpsc::channel::<Done>();
thread::spawn(move || loader(picker, jobs_rx, done_tx));
Some(EmoteStore {
proto,
square_w,
cache: HashMap::new(),
jobs: jobs_tx,
done: done_rx,
start: Instant::now(),
now_ms: Cell::new(0),
budget_left: Cell::new(DRAW_BUDGET),
lru: Cell::new(0),
live_frames: 0,
})
}
pub fn square_cells(&self) -> u16 {
self.square_w
}
pub fn tier_label(&self) -> &'static str {
match self.proto {
ProtocolType::Sixel => "sixel",
ProtocolType::Kitty => "kitty",
ProtocolType::Iterm2 => "iterm2",
ProtocolType::Halfblocks => "halfblocks",
}
}
pub fn request(&mut self, key: &str) {
if self.cache.contains_key(key) {
return;
}
self.cache.insert(key.to_string(), Entry::Loading);
if self
.jobs
.send(Job {
key: key.to_string(),
})
.is_err()
{
self.cache.insert(key.to_string(), Entry::Failed);
}
}
pub fn pump(&mut self) -> bool {
let mut loaded = false;
let stamp = self.lru.get();
while let Ok(d) = self.done.try_recv() {
let entry = match d.built {
Some(b) if !b.frames.is_empty() => {
self.live_frames += b.frames.len();
Entry::Ready(Anim {
frames: b.frames,
w_cells: b.w_cells,
total_ms: b.total_ms,
shown: Cell::new(0),
used: Cell::new(stamp),
})
}
_ => Entry::Failed,
};
if let Some(Entry::Ready(old)) = self.cache.insert(d.key, entry) {
self.live_frames -= old.frames.len(); }
loaded = true;
}
if loaded {
self.evict();
}
loaded
}
fn evict(&mut self) {
if self.live_frames <= MAX_LIVE_FRAMES {
return;
}
let mut aged: Vec<(u64, String)> = self
.cache
.iter()
.filter_map(|(k, e)| match e {
Entry::Ready(a) => Some((a.used.get(), k.clone())),
_ => None,
})
.collect();
aged.sort_unstable_by_key(|(used, _)| *used);
for (_, key) in aged {
if self.live_frames <= MAX_LIVE_FRAMES {
break;
}
if let Some(Entry::Ready(a)) = self.cache.remove(&key) {
self.live_frames -= a.frames.len();
}
}
}
pub fn begin_frame(&self) {
self.now_ms.set(self.start.elapsed().as_millis() as u64);
self.budget_left.set(DRAW_BUDGET);
}
pub fn next_flip_in(&self) -> Option<Duration> {
let now = self.start.elapsed().as_millis() as u64;
let mut soonest = u64::MAX;
for e in self.cache.values() {
if let Entry::Ready(a) = e {
if a.frames.len() > 1 {
soonest = soonest.min(frame_at(&a.frames, now, a.total_ms).1);
}
}
}
(soonest != u64::MAX).then(|| Duration::from_millis(soonest.max(1)))
}
pub fn cells(&self, key: &str) -> Option<u16> {
match self.cache.get(key) {
Some(Entry::Ready(a)) => {
self.touch(a);
Some(a.w_cells)
}
_ => None,
}
}
pub fn frame(&self, key: &str) -> Option<&Protocol> {
let Some(Entry::Ready(a)) = self.cache.get(key) else {
return None;
};
self.touch(a);
let idx = if a.frames.len() == 1 {
0
} else {
let left = self.budget_left.get();
if left == 0 {
a.shown.get().min(a.frames.len() - 1)
} else {
self.budget_left.set(left - 1);
frame_at(&a.frames, self.now_ms.get(), a.total_ms).0
}
};
a.shown.set(idx);
Some(&a.frames[idx].proto)
}
fn touch(&self, a: &Anim) {
let t = self.lru.get() + 1;
self.lru.set(t);
a.used.set(t);
}
}
fn cell_pixels() -> Option<(u16, u16)> {
let ws = crossterm::terminal::window_size().ok()?;
if ws.width == 0 || ws.height == 0 || ws.columns == 0 || ws.rows == 0 {
return None;
}
Some((ws.width / ws.columns, ws.height / ws.rows))
}
fn tmux_outer_protocol() -> Option<ProtocolType> {
let out = std::process::Command::new("tmux")
.args(["display-message", "-p", "#{client_termname}"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let term = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
if term.contains("kitty") || term.contains("ghostty") || term.contains("wezterm") {
Some(ProtocolType::Kitty)
} else if term.starts_with("foot") || term.contains("mlterm") || term.contains("contour") {
Some(ProtocolType::Sixel)
} else {
None
}
}
fn graphics_capable_env() -> bool {
let e = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let term = e("TERM").unwrap_or_default();
let prog = e("TERM_PROGRAM").unwrap_or_default();
if e("KITTY_WINDOW_ID").is_some() || term.contains("kitty") || term.contains("ghostty") {
return true;
}
if term.starts_with("foot") {
return true;
}
if prog == "WezTerm" || e("WEZTERM_EXECUTABLE").is_some() {
return true;
}
if prog == "iTerm.app" || e("ITERM_SESSION_ID").is_some() || term.contains("mintty") {
return true;
}
if term.contains("mlterm") || term.contains("contour") || term.contains("st-") {
return true;
}
if let Some(v) = e("VTE_VERSION") {
if v.parse::<u32>().map(|n| n >= 7800).unwrap_or(false) {
return true;
}
}
false
}
fn frame_at(frames: &[FrameProto], now_ms: u64, total_ms: u64) -> (usize, u64) {
if frames.len() <= 1 || total_ms == 0 {
return (0, u64::MAX);
}
let mut t = now_ms % total_ms;
for (i, f) in frames.iter().enumerate() {
let d = f.delay_ms.max(MIN_DELAY_MS) as u64;
if t < d {
return (i, d - t);
}
t -= d;
}
(0, 1)
}
pub(crate) fn width_cells(aspect: f32, cw: u16, ch: u16) -> u16 {
let block_h = (EMOTE_H as f32) * (ch.max(1) as f32);
let want_px = block_h * aspect.max(0.01);
((want_px / cw.max(1) as f32).round() as i64).clamp(1, MAX_W as i64) as u16
}
pub(crate) fn fit_center(src: &RgbaImage, tw: u32, th: u32) -> RgbaImage {
let (sw, sh) = src.dimensions();
let mut canvas = RgbaImage::new(tw.max(1), th.max(1));
if sw == 0 || sh == 0 {
return canvas;
}
let ratio = (tw as f32 / sw as f32).min(th as f32 / sh as f32);
let nw = ((sw as f32 * ratio).round() as u32).clamp(1, tw.max(1));
let nh = ((sh as f32 * ratio).round() as u32).clamp(1, th.max(1));
let filter = if ratio < 1.0 {
FilterType::Lanczos3
} else {
FilterType::Triangle
};
let scaled = if (nw, nh) == (sw, sh) {
src.clone()
} else {
image::imageops::resize(src, nw, nh, filter)
};
image::imageops::replace(
&mut canvas,
&scaled,
((tw - nw) / 2) as i64,
((th - nh) / 2) as i64,
);
canvas
}
pub(crate) fn flatten_onto_black(img: &mut RgbaImage) {
for px in img.pixels_mut() {
let a = px[3] as u16;
px[0] = (px[0] as u16 * a / 255) as u8;
px[1] = (px[1] as u16 * a / 255) as u8;
px[2] = (px[2] as u16 * a / 255) as u8;
px[3] = 255;
}
}
pub(crate) fn stabilize_palette(frames: &mut [RgbaImage]) {
if frames.len() < 2 {
return;
}
let total: usize = frames.iter().map(|f| f.as_raw().len()).sum();
let mut sample: Vec<u8> = Vec::with_capacity(total);
for f in frames.iter() {
sample.extend_from_slice(f.as_raw());
}
if sample.is_empty() {
return;
}
let nq = color_quant::NeuQuant::new(1, PALETTE_COLORS as usize, &sample);
let map = nq.color_map_rgba();
for f in frames.iter_mut() {
for px in f.pixels_mut() {
let i = nq.index_of(&px.0) * 4;
if i + 2 < map.len() {
px.0 = [map[i], map[i + 1], map[i + 2], px[3]];
}
}
}
}
fn alpha_over(base: &mut RgbaImage, over: &RgbaImage) {
for (b, o) in base.pixels_mut().zip(over.pixels()) {
let oa = o[3] as u16;
let ia = 255 - oa;
for c in 0..3 {
b[c] = ((o[c] as u16 * oa + b[c] as u16 * ia) / 255) as u8;
}
b[3] = (oa + b[3] as u16 * ia / 255) as u8;
}
}
fn sample_frame(frames: &[decode::Frame], t_ms: u64) -> usize {
if frames.len() <= 1 {
return 0;
}
let total: u64 = frames
.iter()
.map(|f| f.delay_ms.max(MIN_DELAY_MS) as u64)
.sum();
if total == 0 {
return 0;
}
let mut t = t_ms % total;
for (i, f) in frames.iter().enumerate() {
let d = f.delay_ms.max(MIN_DELAY_MS) as u64;
if t < d {
return i;
}
t -= d;
}
0
}
fn source_url(url: &str, min_px: u32) -> Option<String> {
if min_px <= 34 {
return None; }
let swap = |from: &str, to: &str| {
let s = url.replacen(from, to, 1);
(s != url).then_some(s)
};
if url.contains("cdn.7tv.app") {
let t = if min_px > 96 {
"/4x"
} else if min_px > 64 {
"/3x"
} else {
"/2x"
};
return swap("/1x", t);
}
if url.contains("betterttv.net") {
return swap("/1x", if min_px > 64 { "/3x" } else { "/2x" });
}
if url.contains("frankerfacez.com") {
let t = if min_px > 64 { "4" } else { "2" };
return url.strip_suffix("/1").map(|s| format!("{s}/{t}"));
}
if url.contains("jtvnw.net") {
let t = if min_px > 64 { "3.0" } else { "2.0" };
return url.strip_suffix("/1.0").map(|s| format!("{s}/{t}"));
}
None
}
fn fetch_at(url: &str, min_px: u32) -> Option<Vec<u8>> {
if let Some(alt) = source_url(url, min_px) {
if let Some(bytes) = http::image_bytes(&alt) {
return Some(bytes);
}
}
http::image_bytes(url)
}
pub(crate) fn subsample(frames: Vec<(RgbaImage, u32)>, max: usize) -> Vec<(RgbaImage, u32)> {
if frames.len() <= max || max == 0 {
return frames;
}
let total: u64 = frames.iter().map(|f| f.1.max(MIN_DELAY_MS) as u64).sum();
let per = ((total / max as u64) as u32).max(MIN_DELAY_MS);
let last = frames.len() - 1;
let step = frames.len() as f64 / max as f64;
let keep: Vec<usize> = (0..max)
.map(|i| ((i as f64 * step).round() as usize).min(last))
.collect();
frames
.into_iter()
.enumerate()
.filter(|(i, _)| keep.binary_search(i).is_ok())
.map(|(_, f)| (f.0, per))
.collect()
}
fn loader(picker: Picker, jobs: Receiver<Job>, done: Sender<Done>) {
let flatten = picker.protocol_type() == ProtocolType::Sixel;
for job in jobs {
let built = build(&picker, &job.key, flatten);
if done
.send(Done {
key: job.key,
built,
})
.is_err()
{
return; }
}
}
pub fn composite_frames(key: &str, min_px: u32) -> Option<Vec<(RgbaImage, u32)>> {
let mut layers: Vec<decode::Decoded> = Vec::new();
for url in key.split('\n') {
let bytes = fetch_at(url, min_px)?;
layers.push(decode::decode(&bytes).ok()?);
}
let (tw, th) = layers[0].frames.first()?.img.dimensions();
let single = layers.len() == 1;
let driver = layers
.iter()
.max_by_key(|l| l.frames.len())
.expect("≥1 layer");
let mut out = Vec::with_capacity(driver.frames.len());
let mut t = 0u64;
for df in &driver.frames {
let canvas = if single {
layers[0].frames[sample_frame(&layers[0].frames, t)]
.img
.clone()
} else {
let mut c = RgbaImage::new(tw, th);
for layer in &layers {
let f = &layer.frames[sample_frame(&layer.frames, t)];
if f.img.dimensions() == (tw, th) {
alpha_over(&mut c, &f.img);
} else {
let r = image::imageops::resize(&f.img, tw, th, FilterType::Lanczos3);
alpha_over(&mut c, &r);
}
}
c
};
out.push((canvas, df.delay_ms));
t += df.delay_ms.max(MIN_DELAY_MS) as u64;
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn build(picker: &Picker, key: &str, flatten: bool) -> Option<Built> {
let (cw, ch) = picker.font_size();
let block_h = EMOTE_H as u32 * ch.max(1) as u32;
let frames = subsample(composite_frames(key, block_h)?, MAX_FRAMES);
let (sw, sh) = frames[0].0.dimensions();
if sw == 0 || sh == 0 {
return None;
}
let w_cells = width_cells(sw as f32 / sh as f32, cw, ch);
let (tw, th) = (w_cells as u32 * cw.max(1) as u32, block_h);
let mut canvases: Vec<RgbaImage> = frames.iter().map(|(f, _)| fit_center(f, tw, th)).collect();
if flatten {
for c in &mut canvases {
flatten_onto_black(c);
}
stabilize_palette(&mut canvases);
}
let size = Rect::new(0, 0, w_cells, EMOTE_H);
let mut out = Vec::with_capacity(canvases.len());
let mut total_ms = 0u64;
for (canvas, (_, delay_ms)) in canvases.into_iter().zip(frames.iter()) {
let proto = picker
.new_protocol(DynamicImage::ImageRgba8(canvas), size, Resize::Fit(None))
.ok()?;
let delay_ms = (*delay_ms).max(MIN_DELAY_MS);
total_ms += delay_ms as u64;
out.push(FrameProto { proto, delay_ms });
}
if out.is_empty() {
None
} else {
Some(Built {
frames: out,
w_cells,
total_ms,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
#[test]
fn alpha_over_opaque_replaces() {
let mut base = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255])); let over = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 255])); alpha_over(&mut base, &over);
assert_eq!(base.get_pixel(0, 0), &Rgba([0, 0, 255, 255])); }
#[test]
fn alpha_over_transparent_keeps_base() {
let mut base = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255])); let over = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 0])); alpha_over(&mut base, &over);
assert_eq!(base.get_pixel(0, 0), &Rgba([255, 0, 0, 255])); }
#[test]
fn alpha_over_half_blends() {
let mut base = RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 255])); let over = RgbaImage::from_pixel(1, 1, Rgba([255, 255, 255, 128])); alpha_over(&mut base, &over);
let p = base.get_pixel(0, 0);
assert!((120..=136).contains(&p[0]), "blended ~half: {}", p[0]);
assert_eq!(p[3], 255); }
#[test]
fn sample_frame_walks_timeline() {
let f = |ms: u32| decode::Frame {
img: RgbaImage::new(1, 1),
delay_ms: ms,
};
let frames = vec![f(100), f(100), f(100)];
assert_eq!(sample_frame(&frames, 0), 0);
assert_eq!(sample_frame(&frames, 150), 1);
assert_eq!(sample_frame(&frames, 250), 2);
assert_eq!(sample_frame(&frames, 300), 0); }
#[test]
fn square_emote_gets_square_cell_block() {
assert_eq!(width_cells(1.0, 8, 16), 4);
assert_eq!(width_cells(1.0, 10, 20), 4);
assert_eq!(width_cells(1.0, 10, 10), 2);
}
#[test]
fn wide_emote_gets_more_columns() {
assert_eq!(width_cells(2.0, 8, 16), 8); assert_eq!(width_cells(0.5, 8, 16), 2); }
#[test]
fn absurd_aspect_is_clamped_not_unbounded() {
assert_eq!(width_cells(50.0, 8, 16), MAX_W);
assert_eq!(width_cells(0.0001, 8, 16), 1);
assert!((1..=MAX_W).contains(&width_cells(1.0, 0, 0)));
assert!((1..=MAX_W).contains(&width_cells(f32::NAN, 8, 16)));
assert!((1..=MAX_W).contains(&width_cells(f32::INFINITY, 8, 16)));
}
#[test]
fn fit_center_fills_exact_block_and_centres() {
let src = RgbaImage::from_pixel(32, 32, Rgba([255, 135, 0, 255]));
let out = fit_center(&src, 64, 32);
assert_eq!(out.dimensions(), (64, 32)); assert_eq!(out.get_pixel(0, 16)[3], 0); assert_eq!(out.get_pixel(63, 16)[3], 0); assert_eq!(out.get_pixel(32, 16), &Rgba([255, 135, 0, 255])); }
#[test]
fn fit_center_preserves_aspect_when_downscaling() {
let src = RgbaImage::from_pixel(128, 32, Rgba([255, 255, 255, 255]));
let out = fit_center(&src, 32, 32);
assert_eq!(out.dimensions(), (32, 32));
assert_eq!(out.get_pixel(16, 0)[3], 0); assert_eq!(out.get_pixel(16, 31)[3], 0); assert_eq!(out.get_pixel(16, 16)[3], 255); }
#[test]
fn fit_center_upscales_small_sources_to_the_block() {
let src = RgbaImage::from_pixel(16, 16, Rgba([1, 2, 3, 255]));
let out = fit_center(&src, 48, 48);
assert_eq!(out.dimensions(), (48, 48));
assert_eq!(out.get_pixel(24, 24)[3], 255); }
fn protos(delays: &[u32]) -> Vec<FrameProto> {
delays
.iter()
.map(|&delay_ms| FrameProto {
proto: Protocol::Halfblocks(Default::default()),
delay_ms,
})
.collect()
}
#[test]
fn frame_at_reports_index_and_time_to_flip() {
let f = protos(&[100, 100, 100]);
assert_eq!(frame_at(&f, 0, 300), (0, 100));
assert_eq!(frame_at(&f, 40, 300), (0, 60));
assert_eq!(frame_at(&f, 150, 300), (1, 50));
assert_eq!(frame_at(&f, 300, 300), (0, 100)); }
#[test]
fn static_image_never_schedules_a_redraw() {
assert_eq!(frame_at(&protos(&[0]), 12_345, 0).1, u64::MAX);
}
#[test]
fn subsample_caps_frames_and_keeps_duration() {
let frames: Vec<(RgbaImage, u32)> = (0..300).map(|_| (RgbaImage::new(1, 1), 20)).collect();
let out = subsample(frames, MAX_FRAMES);
assert_eq!(out.len(), MAX_FRAMES);
let total: u64 = out.iter().map(|f| f.1 as u64).sum();
assert!(
(5500..=6500).contains(&total),
"loop length preserved: {total}"
);
}
#[test]
fn subsample_leaves_short_animations_alone() {
let frames: Vec<(RgbaImage, u32)> = (0..10).map(|_| (RgbaImage::new(1, 1), 40)).collect();
assert_eq!(subsample(frames, MAX_FRAMES).len(), 10);
}
#[test]
fn stabilize_palette_makes_unchanged_regions_byte_identical() {
let mut a = RgbaImage::new(32, 32);
for (x, y, px) in a.enumerate_pixels_mut() {
*px = Rgba([(x * 7 % 256) as u8, (y * 5 % 256) as u8, 90, 255]);
}
let mut b = a.clone();
for x in 0..8 {
for y in 0..8 {
b.put_pixel(x, y, Rgba([255, 0, 0, 255]));
}
}
let mut frames = vec![a, b];
stabilize_palette(&mut frames);
let (qa, qb) = (&frames[0], &frames[1]);
for y in 8..32 {
for x in 8..32 {
assert_eq!(qa.get_pixel(x, y), qb.get_pixel(x, y), "drift at {x},{y}");
}
}
}
#[test]
fn stabilize_palette_collapses_to_at_most_the_palette_size() {
let mut f = RgbaImage::new(64, 64);
for (x, y, px) in f.enumerate_pixels_mut() {
*px = Rgba([x as u8 * 4, y as u8 * 4, (x + y) as u8, 255]);
}
let mut frames = vec![f.clone(), f];
stabilize_palette(&mut frames);
let mut seen = std::collections::HashSet::new();
for px in frames[0].pixels() {
seen.insert([px[0], px[1], px[2]]);
}
assert!(
seen.len() <= PALETTE_COLORS as usize,
"{} colors",
seen.len()
);
}
#[test]
fn stabilize_palette_noop_on_single_frame() {
let f = RgbaImage::from_pixel(4, 4, Rgba([1, 2, 3, 255]));
let mut frames = vec![f.clone()];
stabilize_palette(&mut frames);
assert_eq!(frames[0], f);
}
#[test]
fn source_url_upgrades_only_when_the_block_needs_it() {
let u = "https://cdn.7tv.app/emote/ABC/1x.webp";
assert_eq!(source_url(u, 32), None); assert_eq!(
source_url(u, 40).as_deref(),
Some("https://cdn.7tv.app/emote/ABC/2x.webp")
);
assert_eq!(
source_url(u, 100).as_deref(),
Some("https://cdn.7tv.app/emote/ABC/4x.webp")
);
}
#[test]
fn source_url_handles_each_cdn_shape() {
assert_eq!(
source_url("https://cdn.betterttv.net/emote/ID/1x", 40).as_deref(),
Some("https://cdn.betterttv.net/emote/ID/2x")
);
assert_eq!(
source_url("https://cdn.frankerfacez.com/emote/1234/1", 40).as_deref(),
Some("https://cdn.frankerfacez.com/emote/1234/2")
);
assert_eq!(
source_url(
"https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0",
40
)
.as_deref(),
Some("https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/2.0")
);
}
#[test]
fn source_url_leaves_unknown_hosts_alone() {
assert_eq!(source_url("https://example.com/emote.png", 64), None);
}
}