use std::collections::HashMap; use skia_canvas::prelude::*; use crate::{ analysis::{high_energy_for_mapping, low_energy_for_mapping, overall_energy}, color::to_linear_color, Composition, MeterFrame, Rgba, DIAG_RING_OFFSET, FOOTER_BOTTOM_RATIO, FOOTER_SIDE_RATIO, }; const RING_OFFSETS: [(f32, f32, RingResponse); 4] = [ (DIAG_RING_OFFSET, -DIAG_RING_OFFSET, RingResponse::Overall), (DIAG_RING_OFFSET, DIAG_RING_OFFSET, RingResponse::Bass), (-DIAG_RING_OFFSET, DIAG_RING_OFFSET, RingResponse::Mid), (-DIAG_RING_OFFSET, -DIAG_RING_OFFSET, RingResponse::High), ]; const INTER_FONT_DATA: &[u8] = include_bytes!("../assets/Inter-Regular.ttf"); const FOOTER_COLOR: Rgba = Rgba { red: 239.0 / 255.0, green: 247.0 / 255.0, blue: 1.0, }; const WHITE: Rgba = Rgba { red: 1.0, green: 1.0, blue: 1.0, }; #[derive(Clone, Copy)] enum RingResponse { Overall, Bass, Mid, High, } pub struct Renderer { text: TextEngine, cache: Option, } fn inter_font_manager() -> FontManager { let manager = FontManager::new(); manager .register_font_from_data("Inter", INTER_FONT_DATA) .expect("the bundled Inter font should be valid"); manager } #[derive(Debug, PartialEq)] struct CacheKey { width: u32, height: u32, label: String, has_artwork: bool, } struct RenderCache { key: CacheKey, scale: f32, center_x: f32, center_y: f32, portrait_radius: f32, base_radius: f32, max_radius: f32, padding: f32, baseline: f32, font_size: f32, ring_paint: Paint, portrait_clip: Path, outline_paint: Paint, speaker_path: Path, speaker_paint: Paint, placeholder_paint: Option, footer_style: TextStyle, digit_width: f32, label_layout: TextLayout, label_width: f32, label_ascent: f32, timestamps: HashMap, } struct CachedTimestamp { glyphs: Vec, width: f32, } struct CachedGlyph { layout: TextLayout, width: f32, ascent: f32, is_digit: bool, } impl Renderer { pub fn new() -> Self { let font_manager = inter_font_manager(); Self { text: TextEngine::new(&font_manager), cache: None, } } fn ensure_cache(&mut self, composition: &Composition) -> Result<(), Error> { let key = CacheKey { width: composition.width, height: composition.height, label: composition.label.clone(), has_artwork: composition.artwork.is_some(), }; if self.cache.as_ref().is_some_and(|cache| cache.key == key) { return Ok(()); } self.cache = Some(RenderCache::new(key, composition, &self.text)?); Ok(()) } pub fn render_frame( &mut self, surface: &mut Surface, composition: &Composition, meter: MeterFrame, time: f32, ) -> Result { self.ensure_cache(composition)?; let total_seconds = time.max(0.0).floor() as u32; self.ensure_timestamp(total_seconds); let cache = self.cache(); let timestamp = cache .timestamps .get(&total_seconds) .expect("timestamp cache was initialized"); let visual_scale = 1.25; let gain = 0.5 + composition.intensity; let growth = 0.6 + composition.response * 0.8; let offset = composition.offset * visual_scale * std::f32::consts::SQRT_2.recip() * cache.scale; surface.with_canvas(|canvas| -> Result<(), Error> { canvas.clear(to_linear_color(composition.background, 1.0)); draw_rings(canvas, cache, meter, gain, growth, offset); draw_portrait(canvas, cache, composition); draw_footer(canvas, cache, timestamp, composition.width); Ok(()) })?; surface.flush(); surface.read_pixels() } fn cache(&self) -> &RenderCache { self.cache.as_ref().expect("render cache was initialized") } fn cache_mut(&mut self) -> &mut RenderCache { self.cache.as_mut().expect("render cache was initialized") } fn ensure_timestamp(&mut self, total_seconds: u32) { if self.cache().timestamps.contains_key(&total_seconds) { return; } let (footer_style, digit_width) = { let cache = self.cache(); (cache.footer_style.clone(), cache.digit_width) }; let timestamp = build_timestamp_layout(&self.text, &footer_style, digit_width, total_seconds); self.cache_mut().timestamps.insert(total_seconds, timestamp); } } impl RenderCache { fn new(key: CacheKey, composition: &Composition, text: &TextEngine) -> Result { let scale = composition.height as f32 / 1080.0; let center_x = composition.width as f32 / 2.0; let center_y = composition.height as f32 * 0.46; let portrait_radius = 320.0 * scale; let base_radius = 170.0 * scale; let max_radius = 520.0 * scale; let padding = composition.width as f32 * FOOTER_SIDE_RATIO; let baseline = composition.height as f32 * (1.0 - FOOTER_BOTTOM_RATIO); let font_size = composition.height as f32 * 0.058; let footer_style = footer_style(font_size); let digit_width = max_digit_width(text, &footer_style); let label_layout = text.layout_text(&composition.label, &footer_style, f32::MAX); let label_width = label_layout.width(); let label_ascent = label_layout.first_line_ascent(); let mut ring_paint = Paint::fill(RgbaLinear::opaque(1.0, 1.0, 1.0)); ring_paint.set_alpha(0.14).set_blend_mode(BlendMode::Screen); let outline_paint = Paint::stroke(to_linear_color(WHITE, 0.28), (2.0 * scale).max(1.0)); let speaker_size = font_size * 0.72; let speaker_path = speaker_path(speaker_size)?; let speaker_paint = speaker_paint(speaker_size); let portrait_clip = circle_path(center_x, center_y, portrait_radius)?; let placeholder_paint = placeholder_paint( composition.artwork.is_none(), center_x, center_y, portrait_radius, )?; Ok(Self { key, scale, center_x, center_y, portrait_radius, base_radius, max_radius, padding, baseline, font_size, ring_paint, portrait_clip, outline_paint, speaker_path, speaker_paint, placeholder_paint, footer_style, digit_width, label_layout, label_width, label_ascent, timestamps: HashMap::new(), }) } } fn footer_style(font_size: f32) -> TextStyle { TextStyle { font_families: vec!["Inter".to_string(), "sans-serif".to_string()], font_size, font_weight: 400, color: to_linear_color(FOOTER_COLOR, 0.56), ..TextStyle::default() } } fn max_digit_width(text: &TextEngine, style: &TextStyle) -> f32 { (b'0'..=b'9') .map(|digit| { text.layout_text(&(digit as char).to_string(), style, f32::MAX) .width() }) .fold(0.0, f32::max) } fn placeholder_paint( enabled: bool, center_x: f32, center_y: f32, radius: f32, ) -> Result, Error> { if !enabled { return Ok(None); } let placeholder = Shader::linear_gradient( Point::new(center_x - radius, center_y - radius), Point::new(center_x + radius, center_y + radius), &[ GradientStop { position: 0.0, color: to_linear_color(WHITE, 0.23), }, GradientStop { position: 1.0, color: to_linear_color(WHITE, 0.08), }, ], GradientInterpolation::Srgb, )?; let mut paint = Paint::fill(RgbaLinear::opaque(1.0, 1.0, 1.0)); paint.set_shader(Some(placeholder)); Ok(Some(paint)) } fn draw_rings( canvas: &mut Canvas<'_>, cache: &RenderCache, meter: MeterFrame, gain: f32, growth: f32, offset: f32, ) { for &(ring_x, ring_y, response) in &RING_OFFSETS { let energy = (mapped_band_energy(meter, response) * gain).min(1.0); let radius = cache.base_radius + (cache.max_radius - cache.base_radius) * energy * growth; let x = cache.center_x + ring_x.signum() * offset; let y = cache.center_y + ring_y.signum() * offset; canvas.draw_oval( Rect::from_xywh(x - radius, y - radius, radius * 2.0, radius * 2.0), &cache.ring_paint, ); } } fn draw_portrait(canvas: &mut Canvas<'_>, cache: &RenderCache, composition: &Composition) { let portrait = portrait_rect(cache); canvas.save(); canvas.clip_path(&cache.portrait_clip); match ( composition.artwork.as_ref(), cache.placeholder_paint.as_ref(), ) { (Some(artwork), _) => { let source_size = artwork.width().min(artwork.height()) as f32; let source = Rect::from_xywh( (artwork.width() as f32 - source_size) / 2.0, (artwork.height() as f32 - source_size) / 2.0, source_size, source_size, ); canvas.draw_image_src(artwork, source, portrait, None, SamplingMode::Linear); } (None, Some(placeholder)) => canvas.draw_rect(portrait, placeholder), (None, None) => {} } canvas.restore(); canvas.draw_oval(portrait, &cache.outline_paint); } fn draw_footer( canvas: &mut Canvas<'_>, cache: &RenderCache, timestamp: &CachedTimestamp, width: u32, ) { let timestamp_width = draw_tabular_timestamp( canvas, timestamp, cache.padding, cache.baseline, cache.digit_width, ); draw_speaker( canvas, cache.padding + timestamp_width + 24.0 * cache.scale, cache.baseline - cache.font_size * 0.34, &cache.speaker_path, &cache.speaker_paint, ); canvas.draw_text_layout( &cache.label_layout, width as f32 - cache.padding - cache.label_width, cache.baseline - cache.label_ascent, ); } fn portrait_rect(cache: &RenderCache) -> Rect { Rect::from_xywh( cache.center_x - cache.portrait_radius, cache.center_y - cache.portrait_radius, cache.portrait_radius * 2.0, cache.portrait_radius * 2.0, ) } impl Default for Renderer { fn default() -> Self { Self::new() } } fn mapped_band_energy(meter: MeterFrame, response: RingResponse) -> f32 { match response { RingResponse::Overall => overall_energy(meter), RingResponse::Bass => low_energy_for_mapping(meter), RingResponse::Mid => meter.mid, RingResponse::High => high_energy_for_mapping(meter), } } fn draw_tabular_timestamp( canvas: &mut Canvas<'_>, timestamp: &CachedTimestamp, x: f32, baseline: f32, digit_width: f32, ) -> f32 { let mut cursor = x; for glyph in ×tamp.glyphs { let draw_x = if glyph.is_digit { cursor + (digit_width - glyph.width) / 2.0 } else { cursor }; canvas.draw_text_layout(&glyph.layout, draw_x, baseline - glyph.ascent); cursor += if glyph.is_digit { digit_width } else { glyph.width }; } timestamp.width } fn build_timestamp_layout( text: &TextEngine, style: &TextStyle, digit_width: f32, total_seconds: u32, ) -> CachedTimestamp { let mut width = 0.0; let glyphs = format_time_from_seconds(total_seconds) .chars() .map(|character| { let layout = text.layout_text(&character.to_string(), style, f32::MAX); let glyph = CachedGlyph { width: layout.width(), ascent: layout.first_line_ascent(), is_digit: character.is_ascii_digit(), layout, }; width += if glyph.is_digit { digit_width } else { glyph.width }; glyph }) .collect(); CachedTimestamp { glyphs, width } } fn speaker_path(size: f32) -> Result { let wave = |radius: f32| { let center_x = size * 0.43; let start_angle = -0.72_f32; let end_angle = 0.72_f32; let start_x = center_x + radius * start_angle.cos(); let start_y = radius * start_angle.sin(); let end_x = center_x + radius * end_angle.cos(); let end_y = radius * end_angle.sin(); format!("M {start_x} {start_y} A {radius} {radius} 0 0 1 {end_x} {end_y}") }; let path = format!( "M 0 {} L {} {} L {} {} L {} {} L {} {} L 0 {} Z {} {}", -size * 0.18, size * 0.2, -size * 0.18, size * 0.46, -size * 0.42, size * 0.46, size * 0.42, size * 0.2, size * 0.18, size * 0.18, wave(size * 0.3), wave(size * 0.48), ); Path::from_svg(&path, FillRule::NonZero) } fn speaker_paint(size: f32) -> Paint { let mut paint = Paint::stroke( to_linear_color( Rgba { red: 239.0 / 255.0, green: 247.0 / 255.0, blue: 1.0, }, 0.56, ), size * 0.09, ); paint.set_stroke_cap(StrokeCap::Round); paint.set_stroke_width(size * 0.09); paint } fn draw_speaker(canvas: &mut Canvas<'_>, x: f32, y: f32, path: &Path, paint: &Paint) { canvas.save(); canvas.translate(Point::new(x, y)); canvas.draw_path(path, paint); canvas.restore(); } fn circle_path(center_x: f32, center_y: f32, radius: f32) -> Result { let path = format!( "M {} {} A {} {} 0 1 0 {} {} A {} {} 0 1 0 {} {} Z", center_x - radius, center_y, radius, radius, center_x + radius, center_y, radius, radius, center_x - radius, center_y, ); Path::from_svg(&path, FillRule::NonZero) } fn format_time_from_seconds(total_seconds: u32) -> String { format!("{:02}:{:02}", total_seconds / 60, total_seconds % 60) } #[cfg(test)] mod tests { use super::*; #[test] fn registers_the_bundled_inter_font() { assert!(inter_font_manager().has_font("Inter")); } #[test] fn defaults_to_native_720p_video() { let composition = Composition::default(); assert_eq!((composition.width, composition.height), (1280, 720)); assert_eq!(crate::video::VideoOptions::default().render_scale, 1.0); } #[test] fn formats_elapsed_time() { assert_eq!(format_time_from_seconds(0), "00:00"); assert_eq!(format_time_from_seconds(65), "01:05"); } #[test] fn maps_overall_meter_from_three_bands() { let overall = mapped_band_energy( MeterFrame { bass: 0.3, mid: 0.6, high: 0.9, ..Default::default() }, RingResponse::Overall, ); assert!((overall - 0.6).abs() < f32::EPSILON * 4.0); } #[test] fn maps_low_and_high_rings_through_their_normalizers() { let meter = MeterFrame { bass: 0.9, mid: 0.2, high: 0.1, ..Default::default() }; assert!(mapped_band_energy(meter, RingResponse::Bass) < meter.bass); assert!(mapped_band_energy(meter, RingResponse::High) > meter.high); } #[test] fn renders_a_rgba_frame() -> Result<(), Error> { let composition = Composition { width: 320, height: 180, ..Composition::default() }; let backend = Backend::new(); let mut surface = backend.create_surface( composition.width, composition.height, SurfaceOptions { engine: RenderEngine::Cpu, ..SurfaceOptions::default() }, )?; let mut renderer = Renderer::new(); let frame = renderer.render_frame( &mut surface, &composition, MeterFrame { rms: 0.4, bass: 0.5, mid: 0.3, high: 0.2, }, 0.0, )?; assert_eq!(frame.width(), composition.width); assert_eq!(frame.height(), composition.height); assert_eq!( frame.pixels().len(), (composition.width * composition.height * 4) as usize ); assert_eq!(&frame.pixels()[..4], &[99, 153, 225, 255]); renderer.render_frame( &mut surface, &composition, MeterFrame { rms: 0.4, bass: 0.5, mid: 0.3, high: 0.2, }, 0.01, )?; assert_eq!(renderer.cache.as_ref().unwrap().timestamps.len(), 1); Ok(()) } }