use std::marker::PhantomData;
use fframes::{AudioMap, Color, Duration, FFramesContext, Frame, Svgr, Video};
use crate::ctx::{ComponentFrame, RenderCtx};
pub trait Canvas: std::fmt::Debug + Clone + Copy + PartialEq + Eq + Sync + Send + 'static {
const WIDTH: usize;
const HEIGHT: usize;
const FPS: usize = 30;
const LABEL: &'static str;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Landscape;
impl Canvas for Landscape {
const WIDTH: usize = 1920;
const HEIGHT: usize = 1080;
const FPS: usize = 30;
const LABEL: &'static str = "landscape 16:9 (1920x1080)";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Portrait;
impl Canvas for Portrait {
const WIDTH: usize = 1080;
const HEIGHT: usize = 1920;
const FPS: usize = 30;
const LABEL: &'static str = "portrait 9:16 (1080x1920)";
}
pub const CANVAS_WIDTH: usize = <Landscape as Canvas>::WIDTH;
pub const CANVAS_HEIGHT: usize = <Landscape as Canvas>::HEIGHT;
pub const CANVAS_FPS: usize = <Landscape as Canvas>::FPS;
#[derive(Debug, Clone, PartialEq)]
pub struct Fonts {
pub sans: String,
pub mono: String,
pub weight_medium: u16,
pub weight_bold: u16,
pub weight_black: u16,
}
impl Default for Fonts {
fn default() -> Self {
Self {
sans: "DM Sans".to_owned(),
mono: "DM Sans".to_owned(),
weight_medium: 500,
weight_bold: 700,
weight_black: 800,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ComponentMeta {
pub id: &'static str,
pub name: &'static str,
pub summary: &'static str,
pub feature: &'static str,
}
pub trait Component: std::fmt::Debug + Sync + Send {
fn meta(&self) -> ComponentMeta;
fn duration(&self) -> usize;
fn background(&self) -> Option<&'static str> {
None
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_>;
}
pub trait ComponentFactory: std::fmt::Debug + Sync + Send {
fn meta(&self) -> ComponentMeta;
fn build(&self) -> Box<dyn Component>;
}
#[derive(Debug)]
pub struct CatalogEntry {
pub meta_fn: fn() -> ComponentMeta,
pub build_fn: fn() -> Box<dyn Component>,
}
impl CatalogEntry {
pub fn meta(&self) -> ComponentMeta {
(self.meta_fn)()
}
pub fn build(&self) -> Box<dyn Component> {
(self.build_fn)()
}
}
impl ComponentFactory for CatalogEntry {
fn meta(&self) -> ComponentMeta {
(self.meta_fn)()
}
fn build(&self) -> Box<dyn Component> {
CatalogEntry::build(self)
}
}
pub fn catalog() -> &'static [&'static CatalogEntry] {
crate::catalog::REGISTRY
}
pub fn find(id: &str) -> Option<&'static CatalogEntry> {
let wanted = id.trim().to_ascii_lowercase();
crate::catalog::REGISTRY
.iter()
.copied()
.find(|entry| entry.meta().id.eq_ignore_ascii_case(&wanted))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownComponent(pub String);
impl std::fmt::Display for UnknownComponent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown component \"{}\" (this build has {} components compiled in; run with \
--list to see them)",
self.0,
catalog().len()
)
}
}
impl std::error::Error for UnknownComponent {}
pub fn selection(spec: &str) -> Result<Vec<&'static CatalogEntry>, UnknownComponent> {
let trimmed = spec.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("all") {
return Ok(catalog().to_vec());
}
let mut chosen: Vec<&'static CatalogEntry> = Vec::new();
for part in trimmed.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let entry = find(part).ok_or_else(|| UnknownComponent(part.to_owned()))?;
if !chosen.iter().any(|e| e.meta().id == entry.meta().id) {
chosen.push(entry);
}
}
let order = catalog();
chosen.sort_by_key(|entry| {
order
.iter()
.position(|e| e.meta().id == entry.meta().id)
.unwrap_or(usize::MAX)
});
Ok(chosen)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Slot {
pub start: usize,
pub end: usize,
pub index: usize,
}
impl Slot {
pub fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug)]
pub struct ComponentsVideo<C: Canvas = Landscape> {
components: Vec<Box<dyn Component>>,
slots: Vec<Slot>,
fonts: Fonts,
meters: crate::ctx::TextMeters,
canvas: PhantomData<C>,
}
pub type Horizontal = ComponentsVideo<Landscape>;
pub type Vertical = ComponentsVideo<Portrait>;
impl<C: Canvas> ComponentsVideo<C> {
pub fn from_components(components: Vec<Box<dyn Component>>) -> Self {
let mut slots = Vec::with_capacity(components.len());
let mut cursor = 0;
for (index, component) in components.iter().enumerate() {
let frames = component.duration().max(1);
slots.push(Slot {
start: cursor,
end: cursor + frames,
index,
});
cursor += frames;
}
Self {
components,
slots,
fonts: Fonts::default(),
meters: crate::embedded_meters(),
canvas: PhantomData,
}
}
pub fn from_spec(spec: &str) -> Result<Self, UnknownComponent> {
let entries = selection(spec)?;
let components = entries.iter().map(|entry| entry.build()).collect();
Ok(Self::from_components(components))
}
pub fn with_fonts(mut self, fonts: Fonts) -> Self {
self.fonts = fonts;
self
}
pub fn components(&self) -> &[Box<dyn Component>] {
&self.components
}
pub fn slots(&self) -> &[Slot] {
&self.slots
}
pub fn total_frames(&self) -> usize {
self.slots.last().map_or(0, |slot| slot.end)
}
pub fn fonts(&self) -> &Fonts {
&self.fonts
}
pub fn meters(&self) -> &crate::ctx::TextMeters {
&self.meters
}
pub fn width(&self) -> usize {
C::WIDTH
}
pub fn height(&self) -> usize {
C::HEIGHT
}
pub fn fps(&self) -> usize {
C::FPS
}
pub fn slot_at(&self, frame: usize) -> Option<Slot> {
self.slots
.iter()
.find(|slot| frame >= slot.start && frame < slot.end)
.copied()
}
pub fn timeline_report(&self) -> String {
let fps = C::FPS;
let mut out = String::new();
out.push_str(&format!(
"{} -- {}x{} @ {} fps, {} frames ({:.2}s), {} components\n",
C::LABEL,
C::WIDTH,
C::HEIGHT,
fps,
self.total_frames(),
self.total_frames() as f32 / fps as f32,
self.components.len(),
));
for slot in &self.slots {
let component = &self.components[slot.index];
out.push_str(&format!(
" {:<24} frames {:>5}..{:<5} ({:>5.2}s..{:<5.2}s) {}\n",
component.meta().id,
slot.start,
slot.end,
slot.start as f32 / fps as f32,
slot.end as f32 / fps as f32,
component.meta().summary,
));
}
out
}
fn render_global<'a>(
&'a self,
global: usize,
frame: Frame,
_ctx: &FFramesContext<'a, '_>,
) -> Svgr<'a> {
let Some(slot) = self.slot_at(global) else {
return Svgr::empty();
};
let component = &self.components[slot.index];
let info = ComponentFrame {
local: global.saturating_sub(slot.start) as f32,
global,
fps: C::FPS as f32,
duration: slot.len(),
width: C::WIDTH as f32,
height: C::HEIGHT as f32,
};
let mut frame = frame;
let mut render_ctx = RenderCtx::new(&mut frame, self.fonts.clone(), &self.meters, info);
let body = component.show(&mut render_ctx);
let background = component.background().unwrap_or("#000000");
let view_box = format!("0 0 {} {}", C::WIDTH, C::HEIGHT);
fframes::svgr!(
<svg xmlns="http://www.w3.org/2000/svg" viewBox={view_box}
width={C::WIDTH} height={C::HEIGHT}>
<rect x="0" y="0" width={C::WIDTH} height={C::HEIGHT} fill={background} />
{body}
</svg>
)
}
}
macro_rules! impl_video_for_canvas {
($canvas:ty) => {
impl Video for ComponentsVideo<$canvas> {
const FPS: usize = <$canvas as Canvas>::FPS;
const WIDTH: usize = <$canvas as Canvas>::WIDTH;
const HEIGHT: usize = <$canvas as Canvas>::HEIGHT;
const BACKGROUND_COLOR: Color = Color::BLACK;
fn duration(&self) -> Duration<'_> {
Duration::Frames(self.total_frames().max(1))
}
fn audio(&self) -> AudioMap<'_> {
AudioMap::none()
}
fn render_frame<'a>(&'a self, frame: Frame, ctx: &FFramesContext<'a, '_>) -> Svgr<'a> {
let global = frame.global_index;
self.render_global(global, frame, ctx)
}
}
};
}
impl_video_for_canvas!(Landscape);
impl_video_for_canvas!(Portrait);