notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

commit 927ba5b1373b9a7ca8022812f88638ec6e6fe901
parent 9cf11d953a2480f0279a3c485c28cffa6597145a
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 18 Dec 2023 08:52:19 -0800

move around some things, add code for pfps

Diffstat:
Msrc/main.rs | 112++++++++++++++++++-------------------------------------------------------------
Asrc/nip19.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pfp.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/render.rs | 44++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 192 insertions(+), 87 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -8,104 +8,36 @@ use hyper::service::service_fn; use hyper::{Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use log::{debug, info}; +use std::sync::Arc; use tokio::net::TcpListener; use crate::error::Error; -use nostr_sdk::nips::nip19::Nip19; use nostr_sdk::prelude::*; use nostrdb::{Config, Ndb, Transaction}; use std::time::Duration; -use nostr_sdk::Kind; +use lru::LruCache; mod error; +mod nip19; +mod pfp; +mod render; -#[derive(Debug, Clone)] -struct Notecrumbs { - ndb: Ndb, - keys: Keys, - - /// How long do we wait for remote note requests - timeout: Duration, -} - -enum Target { +pub enum Target { Profile(XOnlyPublicKey), Event(EventId), } -fn nip19_target(nip19: &Nip19) -> Option<Target> { - match nip19 { - Nip19::Event(ev) => Some(Target::Event(ev.event_id)), - Nip19::EventId(evid) => Some(Target::Event(*evid)), - Nip19::Profile(prof) => Some(Target::Profile(prof.public_key)), - Nip19::Pubkey(pk) => Some(Target::Profile(*pk)), - Nip19::Secret(_) => None, - } -} - -fn note_ui(app: &Notecrumbs, ctx: &egui::Context, content: &str) { - use egui::{FontId, RichText}; - - egui::CentralPanel::default().show(&ctx, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new("✏").font(FontId::proportional(120.0))); - ui.vertical(|ui| { - ui.label(RichText::new(content).font(FontId::proportional(40.0))); - }); - }) - }); -} - -fn render_note(app: &Notecrumbs, content: &str) -> Vec<u8> { - use egui_skia::{rasterize, RasterizeOptions}; - use skia_safe::EncodedImageFormat; - - let options = RasterizeOptions { - pixels_per_point: 1.0, - frames_before_screenshot: 1, - }; - - let mut surface = rasterize((1200, 630), |ctx| note_ui(app, ctx, content), Some(options)); - - surface - .image_snapshot() - .encode_to_data(EncodedImageFormat::PNG) - .expect("expected image") - .as_bytes() - .into() -} +type ImageCache = LruCache<XOnlyPublicKey, egui::TextureHandle>; -fn nip19_to_filters(nip19: &Nip19) -> Result<Vec<Filter>, Error> { - match nip19 { - Nip19::Event(ev) => { - let mut filters = vec![Filter::new().id(ev.event_id).limit(1)]; - if let Some(author) = ev.author { - filters.push(Filter::new().author(author).kind(Kind::Metadata).limit(1)) - } - Ok(filters) - } - Nip19::EventId(evid) => Ok(vec![Filter::new().id(*evid).limit(1)]), - Nip19::Profile(prof) => Ok(vec![Filter::new() - .author(prof.public_key) - .kind(Kind::Metadata) - .limit(1)]), - Nip19::Pubkey(pk) => Ok(vec![Filter::new() - .author(*pk) - .kind(Kind::Metadata) - .limit(1)]), - Nip19::Secret(_sec) => Err(Error::InvalidNip19), - } -} +#[derive(Debug, Clone)] +pub struct Notecrumbs { + ndb: Ndb, + keys: Keys, + img_cache: Arc<ImageCache>, -fn nip19_relays(nip19: &Nip19) -> Vec<String> { - let mut relays: Vec<String> = vec![]; - match nip19 { - Nip19::Event(ev) => relays.extend(ev.relays.clone()), - Nip19::Profile(p) => relays.extend(p.relays.clone()), - _ => (), - } - relays + /// How long do we wait for remote note requests + timeout: Duration, } async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<nostr_sdk::Event, Error> { @@ -114,14 +46,14 @@ async fn find_note(app: &Notecrumbs, nip19: &Nip19) -> Result<nostr_sdk::Event, let _ = client.add_relay("wss://relay.damus.io").await; - let other_relays = nip19_relays(nip19); + let other_relays = nip19::to_relays(nip19); for relay in other_relays { let _ = client.add_relay(relay).await; } client.connect().await; - let filters = nip19_to_filters(nip19)?; + let filters = nip19::to_filters(nip19)?; client .req_events_of(filters.clone(), Some(app.timeout)) @@ -159,7 +91,7 @@ async fn serve( } }; - let target = match nip19_target(&nip19) { + let target = match nip19::to_target(&nip19) { Some(target) => target, None => { return Ok(Response::builder() @@ -218,7 +150,7 @@ async fn serve( } }; - let data = render_note(&app, &content); + let data = render::render_note(&app, &content); Ok(Response::builder() .header(header::CONTENT_TYPE, "image/png") @@ -248,7 +180,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let ndb = Ndb::new(".", &cfg).expect("ndb failed to open"); let keys = Keys::generate(); let timeout = get_env_timeout(); - let app = Notecrumbs { ndb, keys, timeout }; + let img_cache = Arc::new(LruCache::new(std::num::NonZeroUsize::new(64).unwrap())); + let app = Notecrumbs { + ndb, + keys, + timeout, + img_cache, + }; // We start a loop to continuously accept incoming connections loop { diff --git a/src/nip19.rs b/src/nip19.rs @@ -0,0 +1,46 @@ +use crate::error::Error; +use crate::Target; +use nostr_sdk::nips::nip19::Nip19; +use nostr_sdk::prelude::*; + +pub fn to_target(nip19: &Nip19) -> Option<Target> { + match nip19 { + Nip19::Event(ev) => Some(Target::Event(ev.event_id)), + Nip19::EventId(evid) => Some(Target::Event(*evid)), + Nip19::Profile(prof) => Some(Target::Profile(prof.public_key)), + Nip19::Pubkey(pk) => Some(Target::Profile(*pk)), + Nip19::Secret(_) => None, + } +} + +pub fn to_filters(nip19: &Nip19) -> Result<Vec<Filter>, Error> { + match nip19 { + Nip19::Event(ev) => { + let mut filters = vec![Filter::new().id(ev.event_id).limit(1)]; + if let Some(author) = ev.author { + filters.push(Filter::new().author(author).kind(Kind::Metadata).limit(1)) + } + Ok(filters) + } + Nip19::EventId(evid) => Ok(vec![Filter::new().id(*evid).limit(1)]), + Nip19::Profile(prof) => Ok(vec![Filter::new() + .author(prof.public_key) + .kind(Kind::Metadata) + .limit(1)]), + Nip19::Pubkey(pk) => Ok(vec![Filter::new() + .author(*pk) + .kind(Kind::Metadata) + .limit(1)]), + Nip19::Secret(_sec) => Err(Error::InvalidNip19), + } +} + +pub fn to_relays(nip19: &Nip19) -> Vec<String> { + let mut relays: Vec<String> = vec![]; + match nip19 { + Nip19::Event(ev) => relays.extend(ev.relays.clone()), + Nip19::Profile(p) => relays.extend(p.relays.clone()), + _ => (), + } + relays +} diff --git a/src/pfp.rs b/src/pfp.rs @@ -0,0 +1,77 @@ +use egui::{Color32, ColorImage}; +use image::imageops::FilterType; + +// Thank to gossip for this one! +pub fn round_image(image: &mut ColorImage) { + #[cfg(feature = "profiling")] + puffin::profile_function!(); + + // The radius to the edge of of the avatar circle + let edge_radius = image.size[0] as f32 / 2.0; + let edge_radius_squared = edge_radius * edge_radius; + + for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { + // y coordinate + let uy = pixnum / image.size[0]; + let y = uy as f32; + let y_offset = edge_radius - y; + + // x coordinate + let ux = pixnum % image.size[0]; + let x = ux as f32; + let x_offset = edge_radius - x; + + // The radius to this pixel (may be inside or outside the circle) + let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; + + // If inside of the avatar circle + if pixel_radius_squared <= edge_radius_squared { + // squareroot to find how many pixels we are from the edge + let pixel_radius: f32 = pixel_radius_squared.sqrt(); + let distance = edge_radius - pixel_radius; + + // If we are within 1 pixel of the edge, we should fade, to + // antialias the edge of the circle. 1 pixel from the edge should + // be 100% of the original color, and right on the edge should be + // 0% of the original color. + if distance <= 1.0 { + *pixel = Color32::from_rgba_premultiplied( + (pixel.r() as f32 * distance) as u8, + (pixel.g() as f32 * distance) as u8, + (pixel.b() as f32 * distance) as u8, + (pixel.a() as f32 * distance) as u8, + ); + } + } else { + // Outside of the avatar circle + *pixel = Color32::TRANSPARENT; + } + } +} + +fn process_pfp_bitmap(size: u32, image: &mut image::DynamicImage) -> ColorImage { + #[cfg(features = "profiling")] + puffin::profile_function!(); + + // Crop square + let smaller = image.width().min(image.height()); + + if image.width() > smaller { + let excess = image.width() - smaller; + *image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); + } else if image.height() > smaller { + let excess = image.height() - smaller; + *image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); + } + let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage + let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) + let mut color_image = ColorImage::from_rgba_unmultiplied( + [ + image_buffer.width() as usize, + image_buffer.height() as usize, + ], + image_buffer.as_flat_samples().as_slice(), + ); + round_image(&mut color_image); + color_image +} diff --git a/src/render.rs b/src/render.rs @@ -0,0 +1,44 @@ +struct ProfileRenderData {} + +use crate::Notecrumbs; + +struct NoteRenderData { + content: String, + profile: ProfileRenderData, +} + +enum RenderData { + Note(NoteRenderData), +} + +fn note_ui(app: &Notecrumbs, ctx: &egui::Context, content: &str) { + use egui::{FontId, RichText}; + + egui::CentralPanel::default().show(&ctx, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("✏").font(FontId::proportional(120.0))); + ui.vertical(|ui| { + ui.label(RichText::new(content).font(FontId::proportional(40.0))); + }); + }) + }); +} + +pub fn render_note(app: &Notecrumbs, content: &str) -> Vec<u8> { + use egui_skia::{rasterize, RasterizeOptions}; + use skia_safe::EncodedImageFormat; + + let options = RasterizeOptions { + pixels_per_point: 1.0, + frames_before_screenshot: 1, + }; + + let mut surface = rasterize((1200, 630), |ctx| note_ui(app, ctx, content), Some(options)); + + surface + .image_snapshot() + .encode_to_data(EncodedImageFormat::PNG) + .expect("expected image") + .as_bytes() + .into() +}