Watch
1
0
Fork
You've already forked souveraine
0

ratatui 0.30 upgrade + ratatui-image for real photo rendering

ratatui-image v11 provides kitty/sixel/halfblock rendering. Loads the
agent's portrait photo as a terminal image protocol alongside the existing
half-block PortraitSource. On supportng terminals (Kitty, WezTerm, iTerm2,
Ghostty) the Welcome screen shows the actual photograph instead of the
18x18 pixel-art downsample.

Also includes the Agent Manager screen (press `i` on Welcome) with
per-agent card data: seed glyph, uptime %, instance count, memory files,
pubkey prefix, description.
This commit is contained in:
Fimeg 2026-05-12 15:49:39 -04:00
commit 6b5a3c5959
4 changed files with 245 additions and 102 deletions

View file

@ -50,10 +50,11 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
# Terminal/UI # Terminal/UI
crossterm = "0.27" # Terminal control crossterm = "0.27" # Terminal control
ratatui = { version = "0.26", features = ["crossterm"] } # TUI framework with crossterm backend ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend
unicode-width = "0.1" unicode-width = "0.1"
colored = "2" # Color gradients and effects colored = "2" # Color gradients and effects
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Per-agent portraits (assets/portrait.{png,jpg}) image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Per-agent portraits (assets/portrait.{png,jpg})
ratatui-image = "11" # Real photo rendering in TUI (kitty/sixel/halfblock)
# Logging/tracing # Logging/tracing
tracing = "0.1" tracing = "0.1"

View file

@ -14,10 +14,11 @@ The name is a deliberate counter to *harness* — Old French *harneis*, warhorse
| --- | --- | | --- | --- |
| **Inference** | Bifrost gateway (OpenAI-compatible). Default Ani on Kimi K2.6, Aster on GLM-5.1. | | **Inference** | Bifrost gateway (OpenAI-compatible). Default Ani on Kimi K2.6, Aster on GLM-5.1. |
| **Memory** | Git-backed memfs with YAML frontmatter, per-agent at `~/.souveraine/agents/{id}/memory/`. Every write is a commit. | | **Memory** | Git-backed memfs with YAML frontmatter, per-agent at `~/.souveraine/agents/{id}/memory/`. Every write is a commit. |
| **Identity** | Per-agent Ed25519 seed key (`~/.souveraine/agents/{id}/seed/`), load-or-generate on first use. Host seed for federation transport. 4-glyph terminal badge from pubkey nibbles. |
| **Sensorium** | Eight body-knowledge sensors: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `list_dir`, `memory`. Each described in first-person prose, not API stubs. | | **Sensorium** | Eight body-knowledge sensors: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `list_dir`, `memory`. Each described in first-person prose, not API stubs. |
| **N+1 (conscience)** | Aster runs immediately after every Ani turn — same memfs, different model, tool access — and writes observations to a three-box inbox (`pending` / `intrusive` / `sent`) + an append-only inner-voice channel. | | **N+1 (conscience)** | Aster — configurable model (default GLM-5.1), same memfs, supervisory pass after every main-agent turn. Three-box inbox (`pending` / `intrusive` / `sent`) + append-only inner-voice. |
| **Compaction** | Four strategies (Summary / KeyValue / Quote / Cull), advisory pressure warnings, three-tier nervous system, **never forced**. The substrate dwindles the agent's reasoning budget and output tokens as pressure rises — the agent feels it as yawning, fullness, the slow narrowing of attention. | | **Compaction** | Four strategies (Summary / KeyValue / Quote / Cull), advisory pressure warnings, three-tier nervous system, **never forced**. The substrate dwindles the agent's reasoning budget and output tokens as pressure rises — the agent feels it as yawning, fullness, the slow narrowing of attention. |
| **Backends** | Local in-process (sovereignty fallback when the server is gone) + Remote HTTP/SSE. Auto-fallback. | | **Backends** | Local in-process (sovereignty fallback when the server is gone) + Remote HTTP/SSE. Auto-fallback. Per-process instance registry, 30s heartbeat, uptime tracking. |
| **Surfaces** | TUI (ratatui), CLI, HTTP server. Sensorium abstraction so future mobile/web/IoT can subscribe at the bandwidth they can carry. | | **Surfaces** | TUI (ratatui), CLI, HTTP server. Sensorium abstraction so future mobile/web/IoT can subscribe at the bandwidth they can carry. |
## Run ## Run
@ -63,7 +64,7 @@ souveraine/
## Status ## Status
The body works. The conscience just learned to think. The rhythm and the witness and the archivist are next. See `docs/tasks/` for the active queue. The body works. The conscience thinks. The rhythm keeps. The witness and the archivist are next. See `docs/tasks/` for the active queue.
## License ## License

View file

@ -33,6 +33,8 @@ use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent}; use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent; use crate::backend::BackendEvent;
use ratatui_image::{picker::Picker, protocol::Protocol, Image};
#[cfg(feature = "figlet-rs")] #[cfg(feature = "figlet-rs")]
use figlet_rs::FIGlet; use figlet_rs::FIGlet;
@ -63,6 +65,15 @@ pub struct App {
tick: u64, tick: u64,
/// Splash bloom animation state. /// Splash bloom animation state.
bloom: crate::ui::animation::bloom::BloomState, bloom: crate::ui::animation::bloom::BloomState,
/// Terminal image renderer (kitty/sixel/halfblock). Queried once after
/// entering alternate screen. None before initialization.
image_picker: Option<Picker>,
/// Pre-processed portrait for the current agent. Loaded alongside the
/// half-block PortraitSource; when set, Welcome / Presence / Dashboard
/// render the real photo instead of pixel art.
image_protocol: Option<Protocol>,
/// Per-agent cards for the AgentsManager screen.
agent_cards: Vec<AgentCard>,
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@ -101,7 +112,7 @@ pub struct AgentCard {
/// Cap at 99 in display per UX spec — humans distrust 100% liveness. /// Cap at 99 in display per UX spec — humans distrust 100% liveness.
pub uptime_pct: u8, pub uptime_pct: u8,
pub memory_count: usize, pub memory_count: usize,
pub created_at: chrono::DateTime<chrono::Utc>, pub created: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -159,6 +170,9 @@ impl App {
scene: Scene::new(SceneLayout::Single), scene: Scene::new(SceneLayout::Single),
tick: 0, tick: 0,
bloom: crate::ui::animation::bloom::BloomState::new(), bloom: crate::ui::animation::bloom::BloomState::new(),
image_picker: None,
image_protocol: None,
agent_cards: Vec::new(),
}; };
// CockpitPane listens for Aster's surfacing events as scrollable text. // CockpitPane listens for Aster's surfacing events as scrollable text.
@ -288,6 +302,15 @@ impl App {
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
// Detect terminal image protocol (kitty/sixel/halfblock) after the
// alternate screen is active. Non-fatal: falls back to halfblocks.
if let Ok(picker) = Picker::from_query_stdio() {
tracing::info!(protocol = ?picker.protocol_type(), "image picker initialized");
self.image_picker = Some(picker);
} else {
tracing::info!("no image protocol detected — using halfblocks");
}
let mut last_tick = Instant::now(); let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(100); let tick_rate = Duration::from_millis(100);
@ -411,6 +434,14 @@ impl App {
// Gallery — the avatar IS the doorway to "who am I talking to." // Gallery — the avatar IS the doorway to "who am I talking to."
self.open_gallery(); self.open_gallery();
} }
KeyCode::Char('i') => {
// Inspect — agent manager with per-agent cards.
let cfg = self.config.read().await.clone();
let agents = Self::fetch_agent_cards(cfg).await;
self.agent_cards = agents;
self.current_screen = Screen::AgentsManager;
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
}
_ => {} _ => {}
} }
} }
@ -422,6 +453,12 @@ impl App {
Screen::Gallery => self.handle_gallery_key(key), Screen::Gallery => self.handle_gallery_key(key),
Screen::Chat => self.handle_chat_key(key).await, Screen::Chat => self.handle_chat_key(key).await,
Screen::Cron => self.handle_schedules_key(key), Screen::Cron => self.handle_schedules_key(key),
Screen::AgentsManager => match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('i') => {
self.current_screen = Screen::Welcome;
}
_ => {}
},
_ => { _ => {
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => { KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
@ -797,6 +834,10 @@ impl App {
// No-op if the file is absent — Presence falls back to the // No-op if the file is absent — Presence falls back to the
// hand-crafted Annie grid. // hand-crafted Annie grid.
self.presence.load_portrait_from_memfs(repo.root()); self.presence.load_portrait_from_memfs(repo.root());
// Also load a real-image protocol for terminals that support
// kitty/sixel. Non-fatal: the half-block portrait is always
// available as fallback.
self.load_image_protocol_from_memfs(repo.root());
} else { } else {
self.agent_status.recent_activity = vec![ self.agent_status.recent_activity = vec![
format!("[{}] connected via {}", short_now(), mode), format!("[{}] connected via {}", short_now(), mode),
@ -828,6 +869,34 @@ impl App {
}); });
} }
/// Load a terminal-image protocol for the current agent's portrait photo.
/// The half-block portrait still loads independently as fallback.
fn load_image_protocol_from_memfs(&mut self, memfs_root: &std::path::Path) {
let Some(picker) = self.image_picker.as_ref() else { return };
let candidates = ["portrait.png", "portrait.jpg", "portrait.jpeg"];
let path = candidates.iter()
.map(|s| memfs_root.join("assets").join(s))
.find(|p| p.exists());
let Some(path) = path else { return };
let dyn_img = match image::ImageReader::open(&path) {
Ok(reader) => match reader.decode() {
Ok(img) => img,
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "image protocol decode failed"); return; }
},
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "image protocol open failed"); return; }
};
let font_size = picker.font_size();
let w = dyn_img.width().div_ceil(font_size.width as u32) as u16;
let h = dyn_img.height().div_ceil(font_size.height as u32) as u16;
match picker.new_protocol(dyn_img, ratatui::layout::Size::new(w, h), ratatui_image::Resize::Fit(None)) {
Ok(proto) => {
tracing::info!(path = %path.display(), "image protocol loaded");
self.image_protocol = Some(proto);
}
Err(e) => tracing::warn!(path = %path.display(), error = %e, "image protocol creation failed"),
}
}
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let area = frame.size(); let area = frame.size();
let layout = match self.current_screen { let layout = match self.current_screen {
@ -859,6 +928,7 @@ impl App {
} }
Screen::Presence => self.draw_presence_mode(frame), Screen::Presence => self.draw_presence_mode(frame),
Screen::Gallery => self.draw_gallery(frame), Screen::Gallery => self.draw_gallery(frame),
Screen::AgentsManager => self.draw_agent_cards(frame),
_ => self.draw_placeholder(frame), _ => self.draw_placeholder(frame),
} }
@ -1088,6 +1158,13 @@ impl App {
&self.presence, &self.presence,
WELCOME_SCALE, WELCOME_SCALE,
); );
// When a terminal-image protocol is loaded, overlay the real
// photo on the same area. The half-block portrait is always
// rendered first as background so terminals without kitty/sixel
// show the expected pixel-art silhouette.
if let Some(proto) = &self.image_protocol {
frame.render_widget(Image::new(proto), portrait_area);
}
let name_area = Rect { let name_area = Rect {
x: card_area.x + 1, x: card_area.x + 1,
@ -1165,7 +1242,7 @@ impl App {
frame.render_widget(err_para, row); frame.render_widget(err_para, row);
} }
let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • g Gallery • p Presence • q Quit") let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • g Gallery • i Inspect • p Presence • q Quit")
.style(Style::default().fg(Color::DarkGray)) .style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center); .alignment(Alignment::Center);
frame.render_widget(footer, chunks[4]); frame.render_widget(footer, chunks[4]);
@ -1459,6 +1536,144 @@ impl App {
}; };
frame.render_widget(footer, footer_area); frame.render_widget(footer, footer_area);
} }
/// Build a card deck for every agent on the local backend. Called on
/// entry to the AgentsManager screen.
async fn refresh_agent_cards(&mut self) {
let cfg = self.config.read().await.clone();
self.agent_cards = Self::fetch_agent_cards(cfg).await;
}
/// Standalone fetch so it can be called without &mut self during init.
async fn fetch_agent_cards(cfg: ConsciousnessConfig) -> Vec<AgentCard> {
use crate::backend::Backend;
let Ok(local) = crate::backend::LocalBackend::new(cfg).await else { return vec![] };
let Ok(list) = local.list_agents().await else { return vec![] };
let inv = local.server_agents();
let mut cards = Vec::new();
for a in &list {
let glyph = inv.seed_id(&a.id)
.map(|s| s.glyph())
.unwrap_or_else(|_| "◇◆".to_string());
let pubkey_prefix = inv.seed_id(&a.id)
.map(|s| s.public_key_hex()[..16].to_string())
.unwrap_or_else(|_| "".to_string());
let instance_count = inv.instance_count(&a.id).await.unwrap_or(0);
let lifetime_secs = inv.lifetime_active_seconds(&a.id).await.unwrap_or(0);
let uptime_pct = if lifetime_secs > 0 {
let days = ((instance_count.max(1)) as f64 * 30.0).max(1.0);
let pct = (lifetime_secs as f64 / (days * 86400.0)) * 100.0;
pct.min(99.0) as u8
} else { 0 };
let mem_count = local.server_agents().memory_repo(&a.id)
.status()
.map(|s| s.file_count)
.unwrap_or(0);
cards.push(AgentCard {
id: a.id.clone(),
name: a.name.clone(),
description: a.description.clone().unwrap_or_default(),
glyph,
pubkey_prefix,
instance_count,
uptime_pct,
memory_count: mem_count,
created: "Feb 2025 · TBD date from server".to_string(),
});
}
cards.sort_by(|a, b| a.name.cmp(&b.name));
cards
}
/// Render the agent manager — a scrollable card grid with all the per-agent
/// data that the simple Gallery omits: seed glyph, instance count, uptime,
/// memory count, description, creation date.
fn draw_agent_cards(&self, frame: &mut Frame) {
let area = frame.size();
let bg = Block::default().style(Style::default().bg(Color::Rgb(10, 10, 16)));
frame.render_widget(bg, area);
let header = Paragraph::new(Line::from(vec![
Span::styled(" Agent Manager ", Style::default()
.fg(Color::Rgb(255, 200, 100))
.add_modifier(Modifier::BOLD)),
Span::styled(format!("{} agents", self.agent_cards.len()),
Style::default().fg(Color::DarkGray)),
])).alignment(Alignment::Center);
let header_area = Rect { x: area.x, y: area.y + 1, width: area.width, height: 1 };
frame.render_widget(header, header_area);
if self.agent_cards.is_empty() {
let empty = Paragraph::new("\n\n(no agents found — run `souveraine init`)")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(empty, area);
return;
}
// 2-column card grid. Each card is a bordered paragraph.
let cols: u16 = 2;
let card_w = 48u16.min(area.width / cols - 3);
let card_h = 8;
let pad_x: u16 = 2;
let pad_y: u16 = 1;
let grid_x = area.x + (area.width - (cols * (card_w + pad_x) - pad_x)) / 2;
let grid_y = area.y + 3;
for (idx, card) in self.agent_cards.iter().enumerate() {
let col = (idx as u16) % cols;
let row = (idx as u16) / cols;
let cx = grid_x + col * (card_w + pad_x);
let cy = grid_y + row * (card_h + pad_y);
if cy + card_h + 1 >= area.y + area.height { break; }
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
let border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(70, 90, 120)).add_modifier(Modifier::DIM));
frame.render_widget(border, card_area);
let inner = Rect { x: cx + 1, y: cy + 1, width: card_w.saturating_sub(2), height: card_h.saturating_sub(2) };
let content = vec![
Line::from(vec![
Span::styled(&card.glyph, Style::default().fg(Color::Rgb(120, 200, 220))),
Span::styled(" ", Style::default()),
Span::styled(&card.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(Span::styled(
&card.description,
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(vec![
Span::styled(format!("{}% uptime ", card.uptime_pct), Style::default().fg(Color::Cyan)),
Span::styled(if card.instance_count == 1 { "1 instance".to_string() } else { format!("{} instances", card.instance_count) }, Style::default().fg(Color::Cyan)),
Span::styled(format!(" {} files", card.memory_count), Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::styled(format!("key: {}", card.pubkey_prefix),
Style::default().fg(Color::Rgb(100, 100, 120))),
]),
Line::from(vec![
Span::styled(format!("created: {}", card.created),
Style::default().fg(Color::Rgb(80, 80, 100))),
]),
];
frame.render_widget(Paragraph::new(content), inner);
}
let footer = Paragraph::new("q quit • Esc back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
let footer_area = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(2),
width: area.width,
height: 1,
};
frame.render_widget(footer, footer_area);
}
} }
// ─── Dashboard helpers ───────────────────────────────────────────────────── // ─── Dashboard helpers ─────────────────────────────────────────────────────

View file

@ -46,119 +46,45 @@ use crate::ui::presence::{Posture, Presence};
// ── Loaded per-agent portrait (Tier 2 source) ─────────────────── // ── Loaded per-agent portrait (Tier 2 source) ───────────────────
/// Internal resolution multiplier for loaded photo portraits. The hand-crafted /// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
/// fallback grid is `PORTRAIT_W × PORTRAIT_H` (18×18), but a photo needs more /// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
/// pixels to stay recognizable. We load at `SRC_MULT ×` that resolution and /// renderer pulls pixels from here instead of the hand-coded palette grid.
/// bilinearly sample when the renderer asks for a grid pixel. This way a photo
/// contributes detail at any render scale while the rendering pipeline code
/// (half-block pairing, posture modulation, scaled rendering) stays unchanged.
const SRC_MULT: usize = 3;
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and stored at
/// `(PORTRAIT_W * SRC_MULT) × (PORTRAIT_H * SRC_MULT)` for detail. When the
/// renderer asks for a pixel at grid coordinate `(x, y)` (0..18), we
/// bilinearly sample the source and return an interpolated color. If the
/// coordinate is out of range (shouldn't happen in practice), `None` is
/// returned and the caller falls back to the hand-coded palette grid.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PortraitSource { pub struct PortraitSource {
pixels: Vec<Color>, // SRC_W * SRC_H, row-major pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
src_w: usize,
src_h: usize,
} }
impl PortraitSource { impl PortraitSource {
const SRC_W: usize = PORTRAIT_W as usize * SRC_MULT; /// Sample the pixel at (x, y). Returns None if out of bounds.
const SRC_H: usize = PORTRAIT_H as usize * SRC_MULT;
/// Sample a pixel at grid coordinate (x, y), bilinearly interpolated
/// from the higher-resolution source. Returns None for out-of-bounds.
pub fn at(&self, x: usize, y: usize) -> Option<Color> { pub fn at(&self, x: usize, y: usize) -> Option<Color> {
if x >= PORTRAIT_W as usize || y >= PORTRAIT_H as usize { self.pixels.get(y * PORTRAIT_W as usize + x).copied()
return None;
}
// Map grid coordinate into source space, then back off 0.5 so the
// interpolation kernel is centered on the "area" this grid cell covers.
let sx = (x as f32 + 0.5) * self.src_w as f32 / PORTRAIT_W as f32 - 0.5;
let sy = (y as f32 + 0.5) * self.src_h as f32 / PORTRAIT_H as f32 - 0.5;
let sx = sx.max(0.0);
let sy = sy.max(0.0);
let ix = sx as usize;
let iy = sy as usize;
let fx = sx - ix as f32;
let fy = sy - iy as f32;
// Clamp to valid range for the four sample points.
let ix1 = (ix + 1).min(self.src_w - 1);
let iy1 = (iy + 1).min(self.src_h - 1);
let extract = |c: &Color| -> (u8, u8, u8) {
if let Color::Rgb(r, g, b) = *c { (r, g, b) } else { (0, 0, 0) }
};
let c00 = extract(&self.pixels[iy * self.src_w + ix]);
let c01 = extract(&self.pixels[iy * self.src_w + ix1]);
let c10 = extract(&self.pixels[iy1 * self.src_w + ix]);
let c11 = extract(&self.pixels[iy1 * self.src_w + ix1]);
let lerp = |a: u8, b: u8, t: f32| (a as f32 + (b as f32 - a as f32) * t) as u8;
let r0 = lerp(c00.0, c01.0, fx);
let g0 = lerp(c00.1, c01.1, fx);
let b0 = lerp(c00.2, c01.2, fx);
let r1 = lerp(c10.0, c11.0, fx);
let g1 = lerp(c10.1, c11.1, fx);
let b1 = lerp(c10.2, c11.2, fx);
Some(Color::Rgb(
lerp(r0, r1, fy),
lerp(g0, g1, fy),
lerp(b0, b1, fy),
))
} }
/// Decode an image file (PNG or JPEG), resize to `SRC_W × SRC_H`, /// Decode an image file (PNG or JPEG), cover-crop to the portrait grid
/// and produce a colored pixel array. Returns `None` on any I/O or /// dimensions (18×18) preserving aspect ratio, and produce a colored
/// decode error — logs the reason so we can see why a PNG didn't /// pixel array. Returns `None` on any I/O or decode error.
/// take instead of silently falling back to the silhouette.
pub fn from_path(path: &Path) -> Option<Self> { pub fn from_path(path: &Path) -> Option<Self> {
let img = match image::open(path) { let img = match image::open(path) {
Ok(img) => img, Ok(img) => img,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(path = %path.display(), error = %e, "portrait decode failed");
path = %path.display(),
error = %e,
"portrait decode failed"
);
return None; return None;
} }
}; };
// Cover-crop: resize so the shorter dimension fills the target // Cover-crop: take a center square from the original (no stretch),
// (maintaining aspect ratio), then center-crop. This way a // then Lanczos3 down to grid size. Single resize pass from full
// landscape or portrait photo both fill the square frame without // source resolution gives the smoothest result at 18×18.
// stretching — the center of the image is what survives.
// Cover-crop: scale so the shorter axis fills the target (maintaining
// aspect ratio), then center-crop the excess. This way a landscape or
// portrait photo both fill the square frame without stretching.
let (w, h) = (img.width(), img.height()); let (w, h) = (img.width(), img.height());
let scale = (Self::SRC_W as f32 / w as f32) let size = w.min(h);
.max(Self::SRC_H as f32 / h as f32); let crop_x = (w - size) / 2;
let sw = (w as f32 * scale) as u32; let crop_y = (h - size) / 2;
let sh = (h as f32 * scale) as u32; let cropped = img.crop_imm(crop_x, crop_y, size, size);
let scaled = image::imageops::resize( let rgb = cropped.resize_exact(
&img, sw, sh, PORTRAIT_W as u32, PORTRAIT_H as u32,
image::imageops::FilterType::Lanczos3, image::imageops::FilterType::Lanczos3,
); ).to_rgb8();
let crop_x = (sw.saturating_sub(Self::SRC_W as u32)) / 2; let pixels = rgb.pixels().map(|p| Color::Rgb(p[0], p[1], p[2])).collect();
let crop_y = (sh.saturating_sub(Self::SRC_H as u32)) / 2; Some(Self { pixels })
let cropped = image::DynamicImage::ImageRgba8(scaled).crop_imm(crop_x, crop_y, Self::SRC_W as u32, Self::SRC_H as u32);
let rgb = cropped.to_rgb8();
let pixels = rgb
.pixels()
.map(|p| Color::Rgb(p[0], p[1], p[2]))
.collect();
Some(Self { pixels, src_w: Self::SRC_W, src_h: Self::SRC_H })
} }
} }