Something went wrong. Try again.
twitter voice plus plus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607use 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<RenderCache>,}
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<Paint>, footer_style: TextStyle, digit_width: f32, label_layout: TextLayout, label_width: f32, label_ascent: f32, timestamps: HashMap<u32, CachedTimestamp>,}
struct CachedTimestamp { glyphs: Vec<CachedGlyph>, 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<ExportedPixels, Error> { 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<Self, Error> { 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<Option<Paint>, 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<Path, Error> { 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<Path, Error> { 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(()) }}