Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .claude/skills/rustmotion/rules/1600-brutalist-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Style "1600" — motion brutalist à blocs de couleur

Recette pour produire des vidéos dans l'esprit des studios de motion design type [1600.agency](https://www.1600.agency/) : typographie massive, blocs de couleur saturés plein cadre, kinetic typography, transitions franches. Exemple de référence : `examples/1600-style.json`.

## Langage visuel

- **Typographie** : une seule grotesque condensée ultra-bold en `UPPERCASE`, partout. Anton (Google Font) est le choix canonique. Déclarer au niveau racine :
```json
"fonts": [{ "family": "Anton", "source": "google", "weights": [400] }]
```
puis `"font-family": "Anton"` sur chaque texte. `line-height` serré (0.92–1.02) pour empiler les lignes, `letter-spacing` 1–2 sur les gros titres, 4–8 sur les petits kickers.
- **Tailles (16:9, 1920×1080)** : hero 240–300px, titres 120–200px, chiffres 190px, kicker/label 32–56px. On cherche le texte qui remplit la largeur.
- **Palette — blocs saturés alternés** : chaque scène est un aplat plein cadre qui bascule brutalement. Un seul aplat par scène, texte en noir `#0A0A0A` ou crème `#F5F0E8` selon le fond.
- Jaune `#FFE500`, cobalt `#1A1AE5`, corail `#FF4A32`, vert acide `#00E676`, noir `#0A0A0A`.
- Accent sur un mot : réutiliser une des couleurs de bloc (ex. jaune sur fond noir).
- **Fond de scène** : un `shape` `rounded_rect` (border-radius 0) plein cadre `1920×1080` en `position: absolute` posé en **premier** enfant ; le contenu passe en flow centré au-dessus.

## Animation

- **Kinetic entrances** : `slide_in_up` (lignes empilées, stagger 0.12s), `slide_in_left` (listes de services), `scale_in` avec `overshoot` (mots accent, CTA). Jamais de fondu mou.
- **Transitions entre scènes** : franches et directionnelles — `wipe_up` / `wipe_left` / `wipe_right` / `wipe_down` / `slide`, durée 0.35s. Alterner les directions pour le rythme.
- **Chiffres** : `counter` (`from`/`to`, `prefix`), il monte sur la durée de la scène. Réserver sa hauteur (`"height"` ≈ font-size + 10) et un `gap` ≥ 24 avec son label, sinon le label remonte sur le chiffre (le counter hors `card` n'a pas de correction de baseline).

## Profondeur & caméra — jouer sur l'espace

Sans ça, le style est un diaporama 2D. Trois leviers, cumulés sur chaque scène :

1. **Caméra en mouvement continu** (`scene.camera.keyframes`, propriétés `zoom` / `origin.x` / `origin.y` / `rotation`) : un push-in ou pull-out lent (zoom 1.0↔1.14) + une dérive du point focal (`origin`) donne une vie permanente. Alterner push-in / pull-out d'une scène à l'autre.
2. **Plans de profondeur** (`style.depth` sur les enfants **directs** de la scène, qui deviennent les plans de parallaxe) : structurer chaque scène en 3 plans absolus plein cadre —
- **fond profond** `depth ~0.4` : un mot/chiffre géant ton-sur-ton (`overflow: hidden` sur le plan pour le clipper au cadre) ;
- **contenu** `depth 1.0` : la typo principale ;
- **avant-plan** `depth ~1.75` : petites formes d'accent avec `float_3d` en boucle.
Au mouvement caméra, les plans se séparent → vraie profondeur.
3. **Entrées en rotation 3D** sur la ligne clé : `flip_in_x` / `flip_in_y` / `tilt_in` (+ `perspective` 900–1400 sur l'élément, `transform-origin` pour pivoter sur une charnière), qui se résolvent face caméra → lisible une fois posé.

**Piège du bleed** : une `rotation` caméra (ou un zoom < 1.0) révèle le `video.background` aux coins. Garder un zoom couvrant pendant toute la rotation (`zoom ≥ ~1.06` pour ±2°), ou fixer `video.background` à la couleur de la scène. Un `origin` qui dérive à zoom 1.0 ne bleede pas (l'origin n'a d'effet qu'avec du zoom).

## Structure d'une scène type

```json
{
"duration": 3.4,
"transition": { "type": "wipe_up", "duration": 0.35 },
"layout": { "direction": "column", "align_items": "center", "justify_content": "center", "gap": 6 },
"children": [
{ "type": "shape", "shape": "rounded_rect", "fill": "#0A0A0A", "position": "absolute", "x": 0, "y": 0, "style": { "width": 1920, "height": 1080, "border-radius": 0 } },
{ "type": "text", "content": "STUDIO DE", "style": { "font-family": "Anton", "font-size": 150, "color": "#F5F0E8", "line-height": 0.95, "animation": [{ "name": "slide_in_up", "duration": 0.55 }] } },
{ "type": "text", "content": "MOTION DESIGN", "style": { "font-family": "Anton", "font-size": 200, "color": "#FFE500", "line-height": 0.95, "animation": [{ "name": "scale_in", "delay": 0.18, "duration": 0.6, "overshoot": 0.12 }] } }
]
}
```

## Pour dupliquer sur un autre contenu

Garder le squelette (aplat + typo Anton + entrées kinetic + transitions franches), remplacer les textes/chiffres/palette. Rythme : 3–4s par scène, une idée par scène. Réutiliser une couleur d'accent cohérente d'un bout à l'autre.
97 changes: 97 additions & 0 deletions crates/rustmotion-core/src/engine/renderer/fonts.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};

use skia_safe::{FontMgr, FontStyle, Typeface};

use crate::error::{Result, RustmotionError};
Expand All @@ -8,12 +12,58 @@ use super::google_fonts::{font_cache_dir, resolve_google_font};
// Thread-local FontMgr instance, created once per thread and reused
thread_local! {
static THREAD_FONT_MGR: FontMgr = FontMgr::default();
// Per-thread cache of Typefaces built from the global custom-font bytes,
// so each render thread builds each custom face at most once.
static CUSTOM_TYPEFACES: RefCell<HashMap<String, Typeface>> = RefCell::new(HashMap::new());
}

pub fn font_mgr() -> FontMgr {
THREAD_FONT_MGR.with(|mgr| mgr.clone())
}

/// Global registry of custom/Google-font bytes, keyed by family name. Filled
/// once by [`load_custom_fonts`] on the main thread; read by every render
/// thread through [`custom_typeface`]. A family maps to the first file
/// registered for it (one weight per custom family via this path — sufficient
/// for accent display faces; multi-weight custom families are future work).
fn custom_font_registry() -> &'static Mutex<HashMap<String, Vec<u8>>> {
static REG: OnceLock<Mutex<HashMap<String, Vec<u8>>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Store a custom font's bytes under `family` (first registration wins).
pub fn register_custom_font_bytes(family: &str, data: Vec<u8>) {
let mut reg = custom_font_registry()
.lock()
.unwrap_or_else(|e| e.into_inner());
reg.entry(family.to_string()).or_insert(data);
}

/// The raw bytes registered for `family`, if any (test/introspection helper).
pub fn custom_font_bytes(family: &str) -> Option<Vec<u8>> {
custom_font_registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(family)
.cloned()
}

/// Resolve a registered custom font to a Typeface, building it from the global
/// bytes on first use per thread and caching it thereafter. `None` when no
/// custom font is registered under `family`.
fn custom_typeface(family: &str) -> Option<Typeface> {
CUSTOM_TYPEFACES.with(|cache| {
if let Some(tf) = cache.borrow().get(family) {
return Some(tf.clone());
}
let data = custom_font_bytes(family)?;
let sk_data = skia_safe::Data::new_copy(&data);
let tf = font_mgr().new_from_data(&sk_data, None)?;
cache.borrow_mut().insert(family.to_string(), tf.clone());
Some(tf)
})
}

/// Validate a `FontEntry` and resolve it to a list of TTF file paths.
///
/// - Local entry (`path` set, `source` absent): returns `[path]` as-is.
Expand Down Expand Up @@ -88,7 +138,14 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path)
family,
path.display()
);
return;
}
// Skia's default FontMgr can build a Typeface from `new_from_data`
// but never exposes it to `match_family_style` (name lookup only
// sees installed system fonts). So keep the raw bytes in a global
// registry; `typeface_with_fallback` builds and caches a Typeface
// from them per thread, ahead of the system match.
register_custom_font_bytes(family, data);
}
Err(e) => {
eprintln!(
Expand All @@ -107,6 +164,12 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path)
/// supported platform). Use this instead of `.expect("FontNotFound")` so we
/// never panic from a `paint` callback.
pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result<Typeface> {
// Custom/Google fonts declared in the scenario win over system fonts:
// they are not visible to `match_family_style`, so resolve them from the
// registry first.
if let Some(t) = custom_typeface(family) {
return Ok(t);
}
let fm = font_mgr();
if let Some(t) = fm.match_family_style(family, style) {
return Ok(t);
Expand Down Expand Up @@ -187,6 +250,40 @@ mod tests {
assert_eq!(paths[0].to_str().unwrap(), "fonts/Inter.ttf");
}

#[test]
fn custom_font_registry_stores_first_and_serves_bytes() {
register_custom_font_bytes("RmProbeRegistryFamily", vec![1, 2, 3]);
// First registration wins (a later weight must not clobber it).
register_custom_font_bytes("RmProbeRegistryFamily", vec![9, 9]);
assert_eq!(
custom_font_bytes("RmProbeRegistryFamily"),
Some(vec![1, 2, 3])
);
assert!(custom_font_bytes("RmProbeUnregistered").is_none());
}

/// The bug this fix targets: a registered custom family must resolve to the
/// custom typeface, not the Helvetica/Arial fallback. Uses the cached Anton
/// TTF when present (Google-font path); skips on a cold cache so CI without
/// network still passes — the render QA is the visual counterpart.
#[test]
fn registered_custom_font_resolves_over_system_fallback() {
let path = format!(
"{}/.cache/rustmotion/fonts/anton-400.ttf",
std::env::var("HOME").unwrap_or_default()
);
let Ok(bytes) = std::fs::read(&path) else {
return; // cold font cache → skip (render QA covers it)
};
register_custom_font_bytes("Anton", bytes);
let tf = typeface_with_fallback("Anton", FontStyle::normal()).unwrap();
assert_eq!(
tf.family_name(),
"Anton",
"must resolve the custom face, not a system fallback"
);
}

#[test]
fn neither_path_nor_source_is_error() {
let entry = neither_entry();
Expand Down
Loading
Loading