Skip to main content

renamite_examples/
lib.rs

1//! Built-in Renamite example projects and templates.
2//!
3//! Templates are constructed in code (rather than shipped as `.ren` fixtures)
4//! so they never drift out of sync with the model format. The same builders
5//! back the CLI `new --template`, the editor empty state, and the example
6//! smoke tests.
7
8use glam::DVec2;
9use renamite_animation::{Animated, AnimatedTransform, EasingHandle, Frame, Interpolation};
10use renamite_io_ren::RenFile;
11use renamite_model::{
12    Color, Document, FillRule, GradientStop, GradientStops, ImageNode, MaskProps, ModifierKind,
13    Node, NodeKind, Parent, ShapeKind, StarKind, StrokeCap, StrokeJoin, StyleKind, StylePaint,
14    TextAlign, TextNode, TrimMode,
15};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TemplateId {
19    Blank,
20    BouncingBall,
21    LoaderTrimPath,
22    MaskedText,
23    PhotoCard,
24    RepeaterBurst,
25    GradientPoster,
26}
27
28pub struct TemplateInfo {
29    pub id: TemplateId,
30    pub name: &'static str,
31    pub description: &'static str,
32}
33
34pub fn templates() -> &'static [TemplateInfo] {
35    &[
36        TemplateInfo {
37            id: TemplateId::Blank,
38            name: "Blank",
39            description: "Empty 512x512 composition.",
40        },
41        TemplateInfo {
42            id: TemplateId::BouncingBall,
43            name: "Bouncing Ball",
44            description: "Position keyframes with eased motion.",
45        },
46        TemplateInfo {
47            id: TemplateId::LoaderTrimPath,
48            name: "Trim Path Loader",
49            description: "Animated trim path on a circular stroke.",
50        },
51        TemplateInfo {
52            id: TemplateId::MaskedText,
53            name: "Masked Text",
54            description: "Text clipped by a moving vector mask.",
55        },
56        TemplateInfo {
57            id: TemplateId::PhotoCard,
58            name: "Photo Card",
59            description: "Image placeholder with rounded clipping mask.",
60        },
61        TemplateInfo {
62            id: TemplateId::RepeaterBurst,
63            name: "Repeater Burst",
64            description: "Repeater with opacity falloff.",
65        },
66        TemplateInfo {
67            id: TemplateId::GradientPoster,
68            name: "Gradient Poster",
69            description: "Gradient fill, text, and vector shapes.",
70        },
71    ]
72}
73
74pub fn build_template(id: TemplateId) -> RenFile {
75    match id {
76        TemplateId::Blank => RenFile::new(Document::empty(), "Blank"),
77        TemplateId::BouncingBall => bouncing_ball(),
78        TemplateId::LoaderTrimPath => loader_trim_path(),
79        TemplateId::MaskedText => masked_text(),
80        TemplateId::PhotoCard => photo_card(),
81        TemplateId::RepeaterBurst => repeater_burst(),
82        TemplateId::GradientPoster => gradient_poster(),
83    }
84}
85
86impl TemplateId {
87    /// All template ids in [`templates`] order.
88    pub fn all() -> &'static [TemplateId] {
89        &[
90            TemplateId::Blank,
91            TemplateId::BouncingBall,
92            TemplateId::LoaderTrimPath,
93            TemplateId::MaskedText,
94            TemplateId::PhotoCard,
95            TemplateId::RepeaterBurst,
96            TemplateId::GradientPoster,
97        ]
98    }
99
100    /// Kebab-case CLI value name, e.g. `bouncing-ball`.
101    pub fn slug(&self) -> &'static str {
102        match self {
103            TemplateId::Blank => "blank",
104            TemplateId::BouncingBall => "bouncing-ball",
105            TemplateId::LoaderTrimPath => "loader-trim-path",
106            TemplateId::MaskedText => "masked-text",
107            TemplateId::PhotoCard => "photo-card",
108            TemplateId::RepeaterBurst => "repeater-burst",
109            TemplateId::GradientPoster => "gradient-poster",
110        }
111    }
112
113    /// Human-readable display name, e.g. `Bouncing Ball`.
114    pub fn display_name(&self) -> &'static str {
115        templates()
116            .iter()
117            .find(|t| t.id == *self)
118            .map(|t| t.name)
119            .unwrap_or(self.slug())
120    }
121}
122
123/// Case- and separator-insensitive template lookup. Accepts slugs
124/// (`bouncing-ball`) and display names (`Bouncing Ball`).
125pub fn parse_template(input: &str) -> Option<TemplateId> {
126    let normalize = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "");
127    let input = normalize(input);
128    templates()
129        .iter()
130        .find(|t| normalize(t.id.slug()) == input || normalize(t.name) == input)
131        .map(|t| t.id)
132}
133
134fn doc_named(name: &str) -> Document {
135    let mut doc = Document::empty();
136    doc.compositions[doc.main].name = name.into();
137    doc
138}
139
140fn attach_group_with(
141    doc: &mut Document,
142    name: &str,
143    children: Vec<renamite_model::NodeId>,
144) -> renamite_model::NodeId {
145    let group = doc.create_node(Node::new(name, NodeKind::Group));
146    for child in children {
147        doc.attach(child, Parent::Node(group), usize::MAX).unwrap();
148    }
149    doc.attach(group, Parent::Comp(doc.main), usize::MAX)
150        .unwrap();
151    group
152}
153
154fn solid_fill(doc: &mut Document, color: Color) -> renamite_model::NodeId {
155    doc.create_node(Node::new(
156        "Fill",
157        NodeKind::Style(StyleKind::Fill {
158            paint: StylePaint::solid(color),
159            rule: FillRule::NonZero,
160        }),
161    ))
162}
163
164fn solid_stroke(doc: &mut Document, color: Color, width: f64) -> renamite_model::NodeId {
165    doc.create_node(Node::new(
166        "Stroke",
167        NodeKind::Style(StyleKind::Stroke {
168            paint: StylePaint::solid(color),
169            width: Animated::new(width),
170            cap: StrokeCap::Round,
171            join: StrokeJoin::Round,
172            miter_limit: Animated::new(4.0),
173            dash: None,
174        }),
175    ))
176}
177
178fn key_vec2(frame: i64, value: DVec2) -> renamite_animation::Keyframe<DVec2> {
179    renamite_animation::Keyframe {
180        frame: Frame(frame),
181        value,
182        interpolation: Interpolation::CubicBezier,
183        ease_out: EasingHandle { x: 0.42, y: 0.0 },
184        ease_in: EasingHandle { x: 0.58, y: 1.0 },
185    }
186}
187
188fn key_f64(frame: i64, value: f64) -> renamite_animation::Keyframe<f64> {
189    renamite_animation::Keyframe {
190        frame: Frame(frame),
191        value,
192        interpolation: Interpolation::CubicBezier,
193        ease_out: EasingHandle { x: 0.42, y: 0.0 },
194        ease_in: EasingHandle { x: 0.58, y: 1.0 },
195    }
196}
197
198fn bouncing_ball() -> RenFile {
199    let mut doc = doc_named("Bouncing Ball");
200
201    let ball = doc.create_node(Node::new(
202        "Ball",
203        NodeKind::Shape(ShapeKind::Ellipse {
204            pos: Animated::new(DVec2::ZERO),
205            size: Animated::new(DVec2::new(96.0, 96.0)),
206        }),
207    ));
208
209    let fill = solid_fill(&mut doc, Color::rgba(0.96, 0.42, 0.18, 1.0));
210    let group = attach_group_with(&mut doc, "Bouncing Ball", vec![ball, fill]);
211
212    doc.nodes[group].transform.position = Animated {
213        base: DVec2::new(256.0, 160.0),
214        keyframes: vec![
215            key_vec2(0, DVec2::new(256.0, 160.0)),
216            key_vec2(30, DVec2::new(256.0, 380.0)),
217            key_vec2(60, DVec2::new(256.0, 160.0)),
218        ],
219    };
220
221    doc.nodes[group].transform.scale = Animated {
222        base: DVec2::splat(100.0),
223        keyframes: vec![
224            key_vec2(0, DVec2::splat(100.0)),
225            key_vec2(28, DVec2::new(120.0, 80.0)),
226            key_vec2(34, DVec2::splat(100.0)),
227        ],
228    };
229
230    RenFile::new(doc, "Bouncing Ball")
231}
232
233fn loader_trim_path() -> RenFile {
234    let mut doc = doc_named("Trim Path Loader");
235
236    let circle = doc.create_node(Node::new(
237        "Circle",
238        NodeKind::Shape(ShapeKind::Ellipse {
239            pos: Animated::new(DVec2::new(256.0, 256.0)),
240            size: Animated::new(DVec2::new(260.0, 260.0)),
241        }),
242    ));
243
244    let trim = doc.create_node(Node::new(
245        "Trim Path",
246        NodeKind::Modifier(ModifierKind::TrimPath {
247            start: Animated::new(0.0),
248            end: Animated {
249                base: 0.2,
250                keyframes: vec![key_f64(0, 0.15), key_f64(45, 0.75), key_f64(90, 0.15)],
251            },
252            offset: Animated {
253                base: 0.0,
254                keyframes: vec![key_f64(0, 0.0), key_f64(90, 1.0)],
255            },
256            mode: TrimMode::Individually,
257        }),
258    ));
259
260    let stroke = solid_stroke(&mut doc, Color::rgba(0.1, 0.4, 0.9, 1.0), 22.0);
261
262    attach_group_with(&mut doc, "Loader", vec![circle, trim, stroke]);
263
264    RenFile::new(doc, "Trim Path Loader")
265}
266
267fn masked_text() -> RenFile {
268    let mut doc = doc_named("Masked Text");
269
270    let mask = doc.create_node(Node::new(
271        "Ellipse Mask",
272        NodeKind::Mask(MaskProps {
273            inverted: false,
274            shape: ShapeKind::Ellipse {
275                pos: Animated::new(DVec2::new(256.0, 210.0)),
276                size: Animated::new(DVec2::new(360.0, 150.0)),
277            },
278        }),
279    ));
280
281    let mut text = Node::new(
282        "Text",
283        NodeKind::Text(TextNode {
284            text: "RENAMITE".into(),
285            size: Animated::new(72.0),
286            align: TextAlign::Center,
287            font: None,
288                tracking: Animated::new(0.0),
289                leading: Animated::new(0.0),
290        }),
291    );
292    text.transform.position = Animated::new(DVec2::new(256.0, 280.0));
293
294    let text = doc.create_node(text);
295    let fill = solid_fill(&mut doc, Color::rgba(0.96, 0.42, 0.18, 1.0));
296
297    attach_group_with(&mut doc, "Masked Text", vec![mask, text, fill]);
298
299    RenFile::new(doc, "Masked Text")
300}
301
302/// A deterministic 2x2 RGBA PNG: red / green / blue / yellow.
303fn tiny_png() -> Vec<u8> {
304    let mut image = image::RgbaImage::new(2, 2);
305    image.put_pixel(0, 0, image::Rgba([255, 80, 80, 255]));
306    image.put_pixel(1, 0, image::Rgba([80, 255, 120, 255]));
307    image.put_pixel(0, 1, image::Rgba([80, 120, 255, 255]));
308    image.put_pixel(1, 1, image::Rgba([255, 230, 80, 255]));
309
310    let mut out = std::io::Cursor::new(Vec::new());
311    image::DynamicImage::ImageRgba8(image)
312        .write_to(&mut out, image::ImageFormat::Png)
313        .unwrap();
314    out.into_inner()
315}
316
317fn photo_card() -> RenFile {
318    let mut doc = doc_named("Photo Card");
319
320    let asset = doc
321        .assets
322        .insert(renamite_model::Asset::Image(renamite_model::ImageAsset {
323            name: "placeholder.png".into(),
324            mime: "image/png".into(),
325            bytes: tiny_png(),
326            width: 2,
327            height: 2,
328            srgb: true,
329        }));
330    doc.asset_order.push(asset);
331
332    let mut image = Node::new("Image", NodeKind::Image(ImageNode::new(asset)));
333    image.transform.anchor = Animated::new(DVec2::new(1.0, 1.0));
334    image.transform.position = Animated::new(DVec2::new(256.0, 256.0));
335    image.transform.scale = Animated::new(DVec2::splat(10_000.0));
336
337    let image = doc.create_node(image);
338
339    let mask = doc.create_node(Node::new(
340        "Rounded Mask",
341        NodeKind::Mask(MaskProps {
342            inverted: false,
343            shape: ShapeKind::Rect {
344                pos: Animated::new(DVec2::new(256.0, 256.0)),
345                size: Animated::new(DVec2::new(300.0, 220.0)),
346                rounded: Animated::new(24.0),
347            },
348        }),
349    ));
350
351    attach_group_with(&mut doc, "Photo Card", vec![mask, image]);
352
353    RenFile::new(doc, "Photo Card")
354}
355
356fn repeater_burst() -> RenFile {
357    let mut doc = doc_named("Repeater Burst");
358
359    let star = doc.create_node(Node::new(
360        "Spark",
361        NodeKind::Shape(ShapeKind::Star {
362            pos: Animated::new(DVec2::new(256.0, 140.0)),
363            points: Animated::new(5.0),
364            inner_r: Animated::new(16.0),
365            outer_r: Animated::new(42.0),
366            roundness: Animated::new(0.0),
367            kind: StarKind::Star,
368        }),
369    ));
370
371    let mut step = AnimatedTransform::identity();
372    step.rotation = Animated::new(renamite_animation::Angle(36.0));
373    step.position = Animated::new(DVec2::new(0.0, 0.0));
374    step.scale = Animated::new(DVec2::splat(96.0));
375
376    let repeater = doc.create_node(Node::new(
377        "Repeater",
378        NodeKind::Modifier(ModifierKind::Repeater {
379            copies: Animated::new(10.0),
380            offset: Animated::new(0.0),
381            transform: Box::new(step),
382            start_opacity: Animated::new(1.0),
383            end_opacity: Animated::new(0.15),
384        }),
385    ));
386
387    let fill = solid_fill(&mut doc, Color::rgba(1.0, 0.85, 0.1, 1.0));
388
389    attach_group_with(&mut doc, "Repeater Burst", vec![star, repeater, fill]);
390
391    RenFile::new(doc, "Repeater Burst")
392}
393
394fn gradient_poster() -> RenFile {
395    let mut doc = doc_named("Gradient Poster");
396
397    let rect = doc.create_node(Node::new(
398        "Background",
399        NodeKind::Shape(ShapeKind::Rect {
400            pos: Animated::new(DVec2::new(256.0, 256.0)),
401            size: Animated::new(DVec2::new(512.0, 512.0)),
402            rounded: Animated::new(0.0),
403        }),
404    ));
405
406    let gradient = doc.create_node(Node::new(
407        "Gradient Fill",
408        NodeKind::Style(StyleKind::Fill {
409            paint: StylePaint::linear(
410                DVec2::new(0.0, 0.0),
411                DVec2::new(512.0, 512.0),
412                GradientStops(vec![
413                    GradientStop {
414                        offset: 0.0,
415                        color: Color::rgba(0.1, 0.2, 0.9, 1.0),
416                    },
417                    GradientStop {
418                        offset: 1.0,
419                        color: Color::rgba(1.0, 0.3, 0.6, 1.0),
420                    },
421                ]),
422            ),
423            rule: FillRule::NonZero,
424        }),
425    ));
426
427    let mut text = Node::new(
428        "Title",
429        NodeKind::Text(TextNode {
430            text: "MOTION".into(),
431            size: Animated::new(92.0),
432            align: TextAlign::Center,
433            font: None,
434                tracking: Animated::new(0.0),
435                leading: Animated::new(0.0),
436        }),
437    );
438    text.transform.position = Animated::new(DVec2::new(256.0, 280.0));
439    let text = doc.create_node(text);
440
441    let text_fill = solid_fill(&mut doc, Color::WHITE);
442
443    // Each style scopes to its own shape, so keep the background and the title
444    // in separate groups (a style paints every shape path in its group).
445    attach_group_with(&mut doc, "Background", vec![rect, gradient]);
446    attach_group_with(&mut doc, "Title", vec![text, text_fill]);
447
448    RenFile::new(doc, "Gradient Poster")
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn every_template_has_main_comp_and_renders_nonempty_or_blank() {
457        for t in templates() {
458            let f = build_template(t.id);
459            assert!(f.document.compositions.contains_key(f.document.main));
460
461            if t.id != TemplateId::Blank {
462                let scene = renamite_model::evaluate(&f.document, f.document.main, 0.0);
463                assert!(!scene.items.is_empty(), "{} should render items", t.name);
464            }
465        }
466    }
467
468    #[test]
469    fn templates_serialize_to_ren_and_back() {
470        for t in templates() {
471            let file = build_template(t.id);
472            let bytes = renamite_io_ren::save(&file).unwrap();
473            let back = renamite_io_ren::open(&bytes).unwrap();
474            assert_eq!(
475                back.document.compositions[back.document.main].name,
476                file.document.compositions[file.document.main].name
477            );
478        }
479    }
480
481    #[test]
482    fn templates_pack_and_unpack_binary() {
483        for t in templates() {
484            let file = build_template(t.id);
485            let packed = renamite_io_ren::save_binary(&file).unwrap();
486            let back = renamite_io_ren::open_binary(&packed).unwrap();
487            assert_eq!(back.document.nodes.len(), file.document.nodes.len());
488        }
489    }
490}