use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{Spring, clamp01, lerp};
use crate::ui::text;
pub const SCENE_W: f32 = 1120.0;
pub const SCENE_H: f32 = 360.0;
pub const MAX_SHARE: f32 = 0.9;
pub const BACKGROUND: &str = "#0b0b0d";
pub const CARD_TOP: f32 = 30.0;
pub const CARD_W: f32 = 1120.0;
pub const CARD_H: f32 = 300.0;
pub const CARD_RADIUS: f32 = 26.0;
pub const CARD_FILL: &str = "#15161a";
pub const CARD_BORDER: f32 = 1.0;
pub const CARD_BORDER_OPACITY: f32 = 0.09;
pub const PAD_X: f32 = 40.0;
pub const PAD_Y: f32 = 24.0;
pub const COLUMN_GAP: f32 = 18.0;
pub const SHADOW_DY: f32 = 18.0;
pub const SHADOW_LAYERS: usize = 10;
pub const SHADOW_STEP: f32 = 4.0;
pub const SHADOW_OPACITY: f32 = 0.26;
pub const AVATAR_D: f32 = 88.0;
pub const AVATAR_FILL: &str = "#2a2b31";
pub const AVATAR_BORDER_OPACITY: f32 = 0.12;
pub const HANDLE_SIZE: f32 = 26.0;
pub const HANDLE_WEIGHT: u16 = 700;
pub const HANDLE_LINE: f32 = 1.1;
pub const HANDLE_INK: &str = "#ffffff";
pub const COMMENT_SIZE: f32 = 32.0;
pub const COMMENT_WEIGHT: u16 = 500;
pub const COMMENT_LINE: f32 = 1.3;
pub const COMMENT_TRACKING: f32 = -0.35;
pub const COMMENT_INK: &str = "#f4f4f5";
pub const COMMENT_MARGIN_TOP: f32 = 8.0;
pub const ACTIONS_MARGIN_TOP: f32 = 14.0;
pub const ACTIONS_INK: &str = "#a9abb3";
pub const ACTIONS_SIZE: f32 = 22.0;
pub const ACTIONS_WEIGHT: u16 = 600;
pub const ACTIONS_GAP: f32 = 22.0;
pub const LIKE_GAP: f32 = 8.0;
pub const ICON_SIZE: f32 = 28.0;
pub const ICON_VIEW: f32 = 24.0;
pub const ICON_STROKE: f32 = 1.8;
pub const SLIDE_DROP: f32 = 760.0;
pub const SLIDE_FRAMES: f32 = 48.0;
pub const SLIDE_SPRING: Spring = Spring {
damping: 14.0,
mass: 0.8,
stiffness: 110.0,
};
pub const TILT_FRAMES: [f32; 4] = [0.0, 90.0, 180.0, 270.0];
pub const TILT_DEGREES: [f32; 4] = [-10.0, 5.0, -5.0, 5.0];
pub const THUMB_PATH: &str = "M7 10v11H4V10Zm2 11V10l4-7c1.4.2 2.2 1.5 1.8 2.8L14 9h4.5c1.3 0 2.3 1.2 2 2.5l-1.7 7A3.3 3.3 0 0 1 15.6 21Z";
#[must_use]
pub fn tilt_degrees(frame: f32) -> f32 {
if frame <= TILT_FRAMES[0] {
return TILT_DEGREES[0];
}
for i in 1..TILT_FRAMES.len() {
if frame <= TILT_FRAMES[i] {
let t = (frame - TILT_FRAMES[i - 1]) / (TILT_FRAMES[i] - TILT_FRAMES[i - 1]);
let eased = (1.0 - (std::f32::consts::PI * t).cos()) / 2.0;
return lerp(TILT_DEGREES[i - 1], TILT_DEGREES[i], eased);
}
}
TILT_DEGREES[TILT_DEGREES.len() - 1]
}
#[must_use]
pub fn tilt_scale_x(degrees: f32) -> f32 {
degrees.to_radians().cos()
}
#[must_use]
pub fn stretched_spring(t: f32) -> f32 {
if t <= 0.0 {
return 0.0;
}
if t >= 1.0 {
return 1.0;
}
let mut natural = 1.0;
for frame in 0..1000 {
if (SLIDE_SPRING.value(frame as f32) - 1.0).abs() >= 0.0005 {
natural = frame as f32 + 1.0;
}
}
let leftover = 1.0 - SLIDE_SPRING.value(natural);
let blend = t * t * (3.0 - 2.0 * t);
SLIDE_SPRING.value(t * natural) + leftover * blend
}
#[must_use]
pub fn slide_drop(scale: f32, canvas_height: f32) -> f32 {
SLIDE_DROP.max(canvas_height / (2.0 * scale) + (SCENE_H / 2.0 - CARD_TOP))
}
#[must_use]
pub fn slide_offset(frame: f32, duration: usize, drop: f32) -> f32 {
let last = duration as f32 - 1.0;
let out_start = (last - SLIDE_FRAMES).max(SLIDE_FRAMES);
if frame <= 0.0 {
drop
} else if frame < SLIDE_FRAMES {
let t = frame / SLIDE_FRAMES;
lerp(drop, 0.0, stretched_spring(t))
} else if frame <= out_start {
0.0
} else if frame < last {
let t = clamp01((frame - out_start) / (last - out_start));
lerp(0.0, drop, 1.0 - stretched_spring(1.0 - t))
} else {
drop
}
}
#[must_use]
pub fn scene_scale(canvas_width: f32, canvas_height: f32) -> f32 {
(MAX_SHARE * canvas_width / SCENE_W).min(MAX_SHARE * canvas_height / SCENE_H)
}
#[must_use]
pub fn column_width() -> f32 {
CARD_W - 2.0 * CARD_BORDER - 2.0 * PAD_X - AVATAR_D - COLUMN_GAP
}
#[must_use]
pub fn wrap_words(text: &str, max_width: f32, measure: impl Fn(&str) -> f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if line.is_empty() || measure(&candidate) <= max_width {
line = candidate;
} else {
lines.push(std::mem::take(&mut line));
line = word.to_owned();
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
#[derive(Debug, Clone, PartialEq)]
pub struct YouTubeCommentHighlightConfig {
pub handle: String,
pub comment: String,
pub likes: String,
pub avatar: String,
pub font_family: Option<String>,
pub duration: usize,
}
impl Default for YouTubeCommentHighlightConfig {
fn default() -> Self {
Self {
handle: "@Bilaal0x".to_owned(),
comment: "Added this to my video in a few lines of Rust. Easiest component \
library I've used - pick a component, set a few options, render."
.to_owned(),
likes: "2.4K".to_owned(),
avatar: "avatar1.png".to_owned(),
font_family: None,
duration: 300,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct YouTubeCommentHighlight {
pub config: YouTubeCommentHighlightConfig,
}
impl YouTubeCommentHighlight {
#[must_use]
pub fn new(config: YouTubeCommentHighlightConfig) -> Self {
Self { config }
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "youtube-comment-highlight",
name: "YouTube Comment Highlight",
summary: "A YouTube comment card swings in with a 3D tilt, holds, then drops away",
feature: "youtube-comment-highlight",
}
}
}
fn card_shadow() -> Svgr<'static> {
let per_layer = SHADOW_OPACITY / SHADOW_LAYERS as f32;
let mut layers: Vec<Svgr<'static>> = Vec::new();
for step in 0..SHADOW_LAYERS {
let grow = step as f32 * SHADOW_STEP;
layers.push(fframes::svgr!(
<rect x={-grow} y={CARD_TOP + SHADOW_DY - grow} width={CARD_W + 2.0 * grow}
height={CARD_H + 2.0 * grow} rx={CARD_RADIUS + grow} fill="#000000"
opacity={per_layer} />
));
}
Svgr::from(layers)
}
fn thumb(x: f32, y: f32, turn: f32) -> Svgr<'static> {
let scale = ICON_SIZE / ICON_VIEW;
let transform = format!(
"translate({x} {y}) rotate({turn} {c} {c}) scale({scale})",
c = ICON_SIZE / 2.0
);
fframes::svgr!(
<path transform={transform} d={THUMB_PATH} fill="none" stroke={ACTIONS_INK}
stroke-width={ICON_STROKE} stroke-linejoin="round" />
)
}
fn avatar(config: &YouTubeCommentHighlightConfig, cx: f32, cy: f32) -> Svgr<'static> {
let r = AVATAR_D / 2.0;
let picture = if config.avatar.is_empty() {
fframes::svgr!(<g></g>)
} else {
fframes::svgr!(
<g>
<defs>
<clipPath id="youtube-comment-highlight-avatar">
<circle cx={cx} cy={cy} r={r} />
</clipPath>
</defs>
<image href={config.avatar.clone()} x={cx - r} y={cy - r}
width={AVATAR_D} height={AVATAR_D}
preserveAspectRatio="xMidYMid slice"
clip-path="url(#youtube-comment-highlight-avatar)" />
</g>
)
};
fframes::svgr!(
<g>
<circle cx={cx} cy={cy} r={r} fill={AVATAR_FILL} />
{picture}
<circle cx={cx} cy={cy} r={r - 0.5} fill="none" stroke="#ffffff"
stroke-opacity={AVATAR_BORDER_OPACITY} stroke-width={1.0} />
</g>
)
}
impl Component for YouTubeCommentHighlight {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some(BACKGROUND)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = config
.font_family
.clone()
.unwrap_or_else(|| crate::INTER_FAMILY.to_owned());
let frame = ctx.frame();
let scale = scene_scale(ctx.width(), ctx.height());
let fit = format!(
"translate({} {}) scale({scale})",
(ctx.width() - SCENE_W * scale) / 2.0,
(ctx.height() - SCENE_H * scale) / 2.0
);
let drop = slide_drop(scale, ctx.height());
let slide = format!(
"translate(0 {})",
slide_offset(frame, self.config.duration, drop)
);
let (cx, cy) = (CARD_W / 2.0, CARD_TOP + CARD_H / 2.0);
let tilt = format!(
"translate({cx} {cy}) scale({} 1) translate({} {})",
tilt_scale_x(tilt_degrees(frame)),
-cx,
-cy
);
let lines = wrap_words(&config.comment, column_width(), |s| {
text::tracked_width_at(
ctx,
&family,
COMMENT_WEIGHT,
COMMENT_SIZE,
s,
COMMENT_TRACKING,
)
});
let handle_h = HANDLE_SIZE * HANDLE_LINE;
let line_h = COMMENT_SIZE * COMMENT_LINE;
let actions_h = ICON_SIZE;
let content_h = handle_h
+ COMMENT_MARGIN_TOP
+ lines.len() as f32 * line_h
+ ACTIONS_MARGIN_TOP
+ actions_h;
let inner_top = CARD_TOP + CARD_BORDER + PAD_Y;
let inner_h = CARD_H - 2.0 * CARD_BORDER - 2.0 * PAD_Y;
let top = inner_top + (inner_h - content_h).max(0.0) / 2.0;
let avatar_x = CARD_BORDER + PAD_X;
let text_x = avatar_x + AVATAR_D + COLUMN_GAP;
let handle_base =
top + ctx.baseline_offset_at(&family, HANDLE_WEIGHT, HANDLE_SIZE, handle_h);
let handle = text::text_node_left(
&config.handle,
text_x,
handle_base,
family.clone(),
HANDLE_SIZE,
HANDLE_WEIGHT,
0.0,
HANDLE_INK.to_owned(),
1.0,
);
let comment_top = top + handle_h + COMMENT_MARGIN_TOP;
let comment_offset = ctx.baseline_offset_at(&family, COMMENT_WEIGHT, COMMENT_SIZE, line_h);
let comment: Vec<Svgr<'static>> = lines
.iter()
.enumerate()
.map(|(i, line)| {
text::text_node_left(
line,
text_x,
comment_top + i as f32 * line_h + comment_offset,
family.clone(),
COMMENT_SIZE,
COMMENT_WEIGHT,
COMMENT_TRACKING,
COMMENT_INK.to_owned(),
1.0,
)
})
.collect();
let actions_top = comment_top + lines.len() as f32 * line_h + ACTIONS_MARGIN_TOP;
let actions_cy = actions_top + actions_h / 2.0;
let count_x = text_x + ICON_SIZE + LIKE_GAP;
let count_w = text::tracked_width_at(
ctx,
&family,
ACTIONS_WEIGHT,
ACTIONS_SIZE,
&config.likes,
0.0,
);
let count_base = actions_cy - ACTIONS_SIZE / 2.0
+ ctx.baseline_offset_at(&family, ACTIONS_WEIGHT, ACTIONS_SIZE, ACTIONS_SIZE);
let count = text::text_node_left(
&config.likes,
count_x,
count_base,
family.clone(),
ACTIONS_SIZE,
ACTIONS_WEIGHT,
0.0,
ACTIONS_INK.to_owned(),
1.0,
);
let dislike_x = count_x + count_w + ACTIONS_GAP;
fframes::svgr!(
<g transform={fit}>
<g transform={slide}>
<g transform={tilt}>
{card_shadow()}
<rect x={CARD_BORDER / 2.0} y={CARD_TOP + CARD_BORDER / 2.0}
width={CARD_W - CARD_BORDER} height={CARD_H - CARD_BORDER}
rx={CARD_RADIUS - CARD_BORDER / 2.0} fill={CARD_FILL}
stroke="#ffffff" stroke-opacity={CARD_BORDER_OPACITY}
stroke-width={CARD_BORDER} />
{avatar(config, avatar_x + AVATAR_D / 2.0, top + AVATAR_D / 2.0)}
{handle}
{Svgr::from(comment)}
{thumb(text_x, actions_cy - ICON_SIZE / 2.0, 0.0)}
{count}
{thumb(dislike_x, actions_cy - ICON_SIZE / 2.0, 180.0)}
</g>
</g>
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meter::TextMeter;
fn comment_meter() -> TextMeter {
let media = crate::ComponentsMedia::new().expect("embedded media");
TextMeter::new("Inter Medium", media.inter_medium_ttf.to_vec()).expect("Inter parses")
}
fn measure(meter: &TextMeter, s: &str) -> f32 {
meter.text_width(s, COMMENT_SIZE) + (s.chars().count() as f32 - 1.0) * COMMENT_TRACKING
}
#[test]
fn the_defaults_are_the_documented_config() {
let config = YouTubeCommentHighlightConfig::default();
assert_eq!(config.handle, "@Bilaal0x");
assert!(config.comment.contains("Easiest component library"));
assert_eq!(config.likes, "2.4K");
assert_eq!(config.avatar, "avatar1.png");
assert_eq!(config.font_family, None);
assert_eq!(config.duration, 300);
}
#[test]
fn the_meta_names_the_component_and_its_feature() {
let component = YouTubeCommentHighlight::default();
assert_eq!(component.meta().id, "youtube-comment-highlight");
assert_eq!(
YouTubeCommentHighlight::meta_static().feature,
"youtube-comment-highlight"
);
assert_eq!(component.duration(), 300);
assert_eq!(component.background(), Some(BACKGROUND));
}
#[test]
fn the_card_slides_in_holds_and_slides_out() {
let n = 300;
assert_eq!(slide_offset(0.0, n, 760.0), 760.0);
assert!(slide_offset(8.0, n, 760.0) > 0.0 && slide_offset(8.0, n, 760.0) < 760.0);
assert!(slide_offset(48.0, n, 760.0).abs() < 1.0);
assert_eq!(slide_offset(150.0, n, 760.0), 0.0);
assert_eq!(slide_offset(251.0, n, 760.0), 0.0);
assert!(slide_offset(295.0, n, 760.0) > 0.0);
assert_eq!(slide_offset(299.0, n, 760.0), 760.0);
assert_eq!(slide_offset(400.0, n, 760.0), 760.0);
}
#[test]
fn the_slide_in_overshoots_like_the_underdamped_spring() {
let peak = (0..48)
.map(|f| slide_offset(f as f32, 300, 760.0))
.fold(f32::MAX, f32::min);
assert!(peak < 0.0, "the spring should overshoot rest, got {peak}");
}
#[test]
fn the_tilt_hits_the_keyframes_and_clamps() {
assert!((tilt_degrees(0.0) + 10.0).abs() < 1e-4);
assert!((tilt_degrees(90.0) - 5.0).abs() < 1e-4);
assert!((tilt_degrees(180.0) + 5.0).abs() < 1e-4);
assert!((tilt_degrees(270.0) - 5.0).abs() < 1e-4);
assert!((tilt_degrees(-20.0) + 10.0).abs() < 1e-4);
assert!((tilt_degrees(500.0) - 5.0).abs() < 1e-4);
assert!((tilt_degrees(45.0) + 2.5).abs() < 1e-4);
}
#[test]
fn a_tilt_squeezes_the_card_a_little() {
assert!((tilt_scale_x(0.0) - 1.0).abs() < 1e-6);
assert!(tilt_scale_x(10.0) < 1.0 && tilt_scale_x(10.0) > 0.98);
assert_eq!(tilt_scale_x(10.0), tilt_scale_x(-10.0));
}
#[test]
fn the_thumb_path_is_the_source_glyph() {
assert!(THUMB_PATH.starts_with("M7 10v11H4V10Z"));
assert!(THUMB_PATH.ends_with("15.6 21Z"));
}
#[test]
fn wrapping_breaks_on_words_and_keeps_every_word() {
let lines = wrap_words("aa bb cc dd", 5.0, |s| s.len() as f32);
assert_eq!(lines, ["aa bb", "cc dd"]);
let long = wrap_words("aaaaaaaa bb", 3.0, |s| s.len() as f32);
assert_eq!(long, ["aaaaaaaa", "bb"]);
assert!(wrap_words("", 10.0, |s| s.len() as f32).is_empty());
}
#[test]
fn the_default_comment_fits_the_card() {
let meter = comment_meter();
let config = YouTubeCommentHighlightConfig::default();
let lines = wrap_words(&config.comment, column_width(), |s| measure(&meter, s));
assert!(lines.len() <= 3, "wrapped to {} lines", lines.len());
for line in &lines {
assert!(measure(&meter, line) <= column_width());
}
let content = HANDLE_SIZE * HANDLE_LINE
+ COMMENT_MARGIN_TOP
+ lines.len() as f32 * COMMENT_SIZE * COMMENT_LINE
+ ACTIONS_MARGIN_TOP
+ ICON_SIZE;
assert!(content <= CARD_H - 2.0 * CARD_BORDER - 2.0 * PAD_Y);
}
#[test]
fn the_scene_fits_landscape_and_portrait_canvases() {
let landscape = scene_scale(1920.0, 1080.0);
assert!(SCENE_W * landscape <= 1920.0 && SCENE_H * landscape <= 1080.0);
let portrait = scene_scale(1080.0, 1920.0);
assert!((portrait - 0.9 * 1080.0 / 1120.0).abs() < 1e-6);
assert!(SCENE_W * portrait <= 1080.0);
}
#[test]
fn the_slide_clears_the_canvas_bottom() {
for (w, h) in [(1920.0, 1080.0), (1080.0, 1920.0)] {
let scale = scene_scale(w, h);
let drop = slide_drop(scale, h);
assert!(drop >= SLIDE_DROP);
let card_top = (h - SCENE_H * scale) / 2.0 + CARD_TOP * scale;
assert!(card_top + drop * scale >= h - 1e-3, "{w}x{h}");
}
assert_eq!(slide_drop(scene_scale(1920.0, 1080.0), 1080.0), SLIDE_DROP);
}
}