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:
parent
e261e9758e
commit
6b5a3c5959
4 changed files with 245 additions and 102 deletions
|
|
@ -50,10 +50,11 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
|
|||
|
||||
# Terminal/UI
|
||||
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"
|
||||
colored = "2" # Color gradients and effects
|
||||
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
|
||||
tracing = "0.1"
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
## Run
|
||||
|
|
@ -63,7 +64,7 @@ souveraine/
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
219
src/ui/app.rs
219
src/ui/app.rs
|
|
@ -33,6 +33,8 @@ use crate::ui::color_support::rgb;
|
|||
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
use crate::backend::BackendEvent;
|
||||
|
||||
use ratatui_image::{picker::Picker, protocol::Protocol, Image};
|
||||
|
||||
#[cfg(feature = "figlet-rs")]
|
||||
use figlet_rs::FIGlet;
|
||||
|
||||
|
|
@ -63,6 +65,15 @@ pub struct App {
|
|||
tick: u64,
|
||||
/// Splash bloom animation state.
|
||||
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)]
|
||||
|
|
@ -101,7 +112,7 @@ pub struct AgentCard {
|
|||
/// Cap at 99 in display per UX spec — humans distrust 100% liveness.
|
||||
pub uptime_pct: u8,
|
||||
pub memory_count: usize,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -159,6 +170,9 @@ impl App {
|
|||
scene: Scene::new(SceneLayout::Single),
|
||||
tick: 0,
|
||||
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.
|
||||
|
|
@ -288,6 +302,15 @@ impl App {
|
|||
let backend = CrosstermBackend::new(stdout);
|
||||
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 tick_rate = Duration::from_millis(100);
|
||||
|
||||
|
|
@ -411,6 +434,14 @@ impl App {
|
|||
// Gallery — the avatar IS the doorway to "who am I talking to."
|
||||
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::Chat => self.handle_chat_key(key).await,
|
||||
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 {
|
||||
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
|
||||
// hand-crafted Annie grid.
|
||||
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 {
|
||||
self.agent_status.recent_activity = vec![
|
||||
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) {
|
||||
let area = frame.size();
|
||||
let layout = match self.current_screen {
|
||||
|
|
@ -859,6 +928,7 @@ impl App {
|
|||
}
|
||||
Screen::Presence => self.draw_presence_mode(frame),
|
||||
Screen::Gallery => self.draw_gallery(frame),
|
||||
Screen::AgentsManager => self.draw_agent_cards(frame),
|
||||
_ => self.draw_placeholder(frame),
|
||||
}
|
||||
|
||||
|
|
@ -1088,6 +1158,13 @@ impl App {
|
|||
&self.presence,
|
||||
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 {
|
||||
x: card_area.x + 1,
|
||||
|
|
@ -1165,7 +1242,7 @@ impl App {
|
|||
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))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, chunks[4]);
|
||||
|
|
@ -1459,6 +1536,144 @@ impl App {
|
|||
};
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -46,119 +46,45 @@ use crate::ui::presence::{Posture, Presence};
|
|||
|
||||
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
||||
|
||||
/// Internal resolution multiplier for loaded photo portraits. The hand-crafted
|
||||
/// fallback grid is `PORTRAIT_W × PORTRAIT_H` (18×18), but a photo needs more
|
||||
/// pixels to stay recognizable. We load at `SRC_MULT ×` that resolution and
|
||||
/// 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.
|
||||
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
|
||||
/// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
|
||||
/// renderer pulls pixels from here instead of the hand-coded palette grid.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortraitSource {
|
||||
pixels: Vec<Color>, // SRC_W * SRC_H, row-major
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
|
||||
}
|
||||
|
||||
impl PortraitSource {
|
||||
const SRC_W: usize = PORTRAIT_W as usize * SRC_MULT;
|
||||
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.
|
||||
/// Sample the pixel at (x, y). Returns None if out of bounds.
|
||||
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
|
||||
if x >= PORTRAIT_W as usize || y >= PORTRAIT_H as usize {
|
||||
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),
|
||||
))
|
||||
self.pixels.get(y * PORTRAIT_W as usize + x).copied()
|
||||
}
|
||||
|
||||
/// Decode an image file (PNG or JPEG), resize to `SRC_W × SRC_H`,
|
||||
/// and produce a colored pixel array. Returns `None` on any I/O or
|
||||
/// decode error — logs the reason so we can see why a PNG didn't
|
||||
/// take instead of silently falling back to the silhouette.
|
||||
/// Decode an image file (PNG or JPEG), cover-crop to the portrait grid
|
||||
/// dimensions (18×18) preserving aspect ratio, and produce a colored
|
||||
/// pixel array. Returns `None` on any I/O or decode error.
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let img = match image::open(path) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"portrait decode failed"
|
||||
);
|
||||
tracing::warn!(path = %path.display(), error = %e, "portrait decode failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Cover-crop: resize so the shorter dimension fills the target
|
||||
// (maintaining aspect ratio), then center-crop. This way a
|
||||
// landscape or portrait photo both fill the square frame without
|
||||
// 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.
|
||||
// Cover-crop: take a center square from the original (no stretch),
|
||||
// then Lanczos3 down to grid size. Single resize pass from full
|
||||
// source resolution gives the smoothest result at 18×18.
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let scale = (Self::SRC_W as f32 / w as f32)
|
||||
.max(Self::SRC_H as f32 / h as f32);
|
||||
let sw = (w as f32 * scale) as u32;
|
||||
let sh = (h as f32 * scale) as u32;
|
||||
let scaled = image::imageops::resize(
|
||||
&img, sw, sh,
|
||||
let size = w.min(h);
|
||||
let crop_x = (w - size) / 2;
|
||||
let crop_y = (h - size) / 2;
|
||||
let cropped = img.crop_imm(crop_x, crop_y, size, size);
|
||||
let rgb = cropped.resize_exact(
|
||||
PORTRAIT_W as u32, PORTRAIT_H as u32,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
);
|
||||
let crop_x = (sw.saturating_sub(Self::SRC_W as u32)) / 2;
|
||||
let crop_y = (sh.saturating_sub(Self::SRC_H as u32)) / 2;
|
||||
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 })
|
||||
).to_rgb8();
|
||||
let pixels = rgb.pixels().map(|p| Color::Rgb(p[0], p[1], p[2])).collect();
|
||||
Some(Self { pixels })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue