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, MaskProps, ModifierKind, Node,
13    NodeKind, Parent, ShapeKind, StarKind, StrokeCap, StrokeJoin, StyleKind, StylePaint, TextAlign,
14    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 512×512 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            dash: None,
173        }),
174    ))
175}
176
177fn key_vec2(frame: i64, value: DVec2) -> renamite_animation::Keyframe<DVec2> {
178    renamite_animation::Keyframe {
179        frame: Frame(frame),
180        value,
181        interpolation: Interpolation::CubicBezier,
182        ease_out: EasingHandle { x: 0.42, y: 0.0 },
183        ease_in: EasingHandle { x: 0.58, y: 1.0 },
184    }
185}
186
187fn key_f64(frame: i64, value: f64) -> renamite_animation::Keyframe<f64> {
188    renamite_animation::Keyframe {
189        frame: Frame(frame),
190        value,
191        interpolation: Interpolation::CubicBezier,
192        ease_out: EasingHandle { x: 0.42, y: 0.0 },
193        ease_in: EasingHandle { x: 0.58, y: 1.0 },
194    }
195}
196
197fn bouncing_ball() -> RenFile {
198    let mut doc = doc_named("Bouncing Ball");
199
200    let ball = doc.create_node(Node::new(
201        "Ball",
202        NodeKind::Shape(ShapeKind::Ellipse {
203            pos: Animated::new(DVec2::ZERO),
204            size: Animated::new(DVec2::new(96.0, 96.0)),
205        }),
206    ));
207
208    let fill = solid_fill(&mut doc, Color::rgba(0.96, 0.42, 0.18, 1.0));
209    let group = attach_group_with(&mut doc, "Bouncing Ball", vec![ball, fill]);
210
211    doc.nodes[group].transform.position = Animated {
212        base: DVec2::new(256.0, 160.0),
213        keyframes: vec![
214            key_vec2(0, DVec2::new(256.0, 160.0)),
215            key_vec2(30, DVec2::new(256.0, 380.0)),
216            key_vec2(60, DVec2::new(256.0, 160.0)),
217        ],
218    };
219
220    doc.nodes[group].transform.scale = Animated {
221        base: DVec2::splat(100.0),
222        keyframes: vec![
223            key_vec2(0, DVec2::splat(100.0)),
224            key_vec2(28, DVec2::new(120.0, 80.0)),
225            key_vec2(34, DVec2::splat(100.0)),
226        ],
227    };
228
229    RenFile::new(doc, "Bouncing Ball")
230}
231
232fn loader_trim_path() -> RenFile {
233    let mut doc = doc_named("Trim Path Loader");
234
235    let circle = doc.create_node(Node::new(
236        "Circle",
237        NodeKind::Shape(ShapeKind::Ellipse {
238            pos: Animated::new(DVec2::new(256.0, 256.0)),
239            size: Animated::new(DVec2::new(260.0, 260.0)),
240        }),
241    ));
242
243    let trim = doc.create_node(Node::new(
244        "Trim Path",
245        NodeKind::Modifier(ModifierKind::TrimPath {
246            start: Animated::new(0.0),
247            end: Animated {
248                base: 0.2,
249                keyframes: vec![key_f64(0, 0.15), key_f64(45, 0.75), key_f64(90, 0.15)],
250            },
251            offset: Animated {
252                base: 0.0,
253                keyframes: vec![key_f64(0, 0.0), key_f64(90, 1.0)],
254            },
255            mode: TrimMode::Individually,
256        }),
257    ));
258
259    let stroke = solid_stroke(&mut doc, Color::rgba(0.1, 0.4, 0.9, 1.0), 22.0);
260
261    attach_group_with(&mut doc, "Loader", vec![circle, trim, stroke]);
262
263    RenFile::new(doc, "Trim Path Loader")
264}
265
266fn masked_text() -> RenFile {
267    let mut doc = doc_named("Masked Text");
268
269    let mask = doc.create_node(Node::new(
270        "Ellipse Mask",
271        NodeKind::Mask(MaskProps {
272            inverted: false,
273            shape: ShapeKind::Ellipse {
274                pos: Animated::new(DVec2::new(256.0, 210.0)),
275                size: Animated::new(DVec2::new(360.0, 150.0)),
276            },
277        }),
278    ));
279
280    let mut text = Node::new(
281        "Text",
282        NodeKind::Text(TextNode {
283            text: "RENAMITE".into(),
284            size: Animated::new(72.0),
285            align: TextAlign::Center,
286            font: None,
287        }),
288    );
289    text.transform.position = Animated::new(DVec2::new(256.0, 280.0));
290
291    let text = doc.create_node(text);
292    let fill = solid_fill(&mut doc, Color::rgba(0.96, 0.42, 0.18, 1.0));
293
294    attach_group_with(&mut doc, "Masked Text", vec![mask, text, fill]);
295
296    RenFile::new(doc, "Masked Text")
297}
298
299/// A deterministic 2×2 RGBA PNG: red / green / blue / yellow.
300fn tiny_png() -> Vec<u8> {
301    let mut image = image::RgbaImage::new(2, 2);
302    image.put_pixel(0, 0, image::Rgba([255, 80, 80, 255]));
303    image.put_pixel(1, 0, image::Rgba([80, 255, 120, 255]));
304    image.put_pixel(0, 1, image::Rgba([80, 120, 255, 255]));
305    image.put_pixel(1, 1, image::Rgba([255, 230, 80, 255]));
306
307    let mut out = std::io::Cursor::new(Vec::new());
308    image::DynamicImage::ImageRgba8(image)
309        .write_to(&mut out, image::ImageFormat::Png)
310        .unwrap();
311    out.into_inner()
312}
313
314fn photo_card() -> RenFile {
315    let mut doc = doc_named("Photo Card");
316
317    let asset = doc
318        .assets
319        .insert(renamite_model::Asset::Image(renamite_model::ImageAsset {
320            name: "placeholder.png".into(),
321            mime: "image/png".into(),
322            bytes: tiny_png(),
323            width: 2,
324            height: 2,
325            srgb: true,
326        }));
327    doc.asset_order.push(asset);
328
329    let mut image = Node::new("Image", NodeKind::Image(asset));
330    image.transform.anchor = Animated::new(DVec2::new(1.0, 1.0));
331    image.transform.position = Animated::new(DVec2::new(256.0, 256.0));
332    image.transform.scale = Animated::new(DVec2::splat(10_000.0));
333
334    let image = doc.create_node(image);
335
336    let mask = doc.create_node(Node::new(
337        "Rounded Mask",
338        NodeKind::Mask(MaskProps {
339            inverted: false,
340            shape: ShapeKind::Rect {
341                pos: Animated::new(DVec2::new(256.0, 256.0)),
342                size: Animated::new(DVec2::new(300.0, 220.0)),
343                rounded: Animated::new(24.0),
344            },
345        }),
346    ));
347
348    attach_group_with(&mut doc, "Photo Card", vec![mask, image]);
349
350    RenFile::new(doc, "Photo Card")
351}
352
353fn repeater_burst() -> RenFile {
354    let mut doc = doc_named("Repeater Burst");
355
356    let star = doc.create_node(Node::new(
357        "Spark",
358        NodeKind::Shape(ShapeKind::Star {
359            pos: Animated::new(DVec2::new(256.0, 140.0)),
360            points: Animated::new(5.0),
361            inner_r: Animated::new(16.0),
362            outer_r: Animated::new(42.0),
363            roundness: Animated::new(0.0),
364            kind: StarKind::Star,
365        }),
366    ));
367
368    let mut step = AnimatedTransform::identity();
369    step.rotation = Animated::new(renamite_animation::Angle(36.0));
370    step.position = Animated::new(DVec2::new(0.0, 0.0));
371    step.scale = Animated::new(DVec2::splat(96.0));
372
373    let repeater = doc.create_node(Node::new(
374        "Repeater",
375        NodeKind::Modifier(ModifierKind::Repeater {
376            copies: Animated::new(10.0),
377            offset: Animated::new(0.0),
378            transform: step,
379            start_opacity: Animated::new(1.0),
380            end_opacity: Animated::new(0.15),
381        }),
382    ));
383
384    let fill = solid_fill(&mut doc, Color::rgba(1.0, 0.85, 0.1, 1.0));
385
386    attach_group_with(&mut doc, "Repeater Burst", vec![star, repeater, fill]);
387
388    RenFile::new(doc, "Repeater Burst")
389}
390
391fn gradient_poster() -> RenFile {
392    let mut doc = doc_named("Gradient Poster");
393
394    let rect = doc.create_node(Node::new(
395        "Background",
396        NodeKind::Shape(ShapeKind::Rect {
397            pos: Animated::new(DVec2::new(256.0, 256.0)),
398            size: Animated::new(DVec2::new(512.0, 512.0)),
399            rounded: Animated::new(0.0),
400        }),
401    ));
402
403    let gradient = doc.create_node(Node::new(
404        "Gradient Fill",
405        NodeKind::Style(StyleKind::Fill {
406            paint: StylePaint::linear(
407                DVec2::new(0.0, 0.0),
408                DVec2::new(512.0, 512.0),
409                GradientStops(vec![
410                    GradientStop {
411                        offset: 0.0,
412                        color: Color::rgba(0.1, 0.2, 0.9, 1.0),
413                    },
414                    GradientStop {
415                        offset: 1.0,
416                        color: Color::rgba(1.0, 0.3, 0.6, 1.0),
417                    },
418                ]),
419            ),
420            rule: FillRule::NonZero,
421        }),
422    ));
423
424    let mut text = Node::new(
425        "Title",
426        NodeKind::Text(TextNode {
427            text: "MOTION".into(),
428            size: Animated::new(92.0),
429            align: TextAlign::Center,
430            font: None,
431        }),
432    );
433    text.transform.position = Animated::new(DVec2::new(256.0, 280.0));
434    let text = doc.create_node(text);
435
436    let text_fill = solid_fill(&mut doc, Color::WHITE);
437
438    // Each style scopes to its own shape, so keep the background and the title
439    // in separate groups (a style paints every shape path in its group).
440    attach_group_with(&mut doc, "Background", vec![rect, gradient]);
441    attach_group_with(&mut doc, "Title", vec![text, text_fill]);
442
443    RenFile::new(doc, "Gradient Poster")
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn every_template_has_main_comp_and_renders_nonempty_or_blank() {
452        for t in templates() {
453            let f = build_template(t.id);
454            assert!(f.document.compositions.contains_key(f.document.main));
455
456            if t.id != TemplateId::Blank {
457                let scene = renamite_model::evaluate(&f.document, f.document.main, 0.0);
458                assert!(!scene.items.is_empty(), "{} should render items", t.name);
459            }
460        }
461    }
462
463    #[test]
464    fn templates_serialize_to_ren_and_back() {
465        for t in templates() {
466            let file = build_template(t.id);
467            let bytes = renamite_io_ren::save(&file).unwrap();
468            let back = renamite_io_ren::open(&bytes).unwrap();
469            assert_eq!(
470                back.document.compositions[back.document.main].name,
471                file.document.compositions[file.document.main].name
472            );
473        }
474    }
475
476    #[test]
477    fn templates_pack_and_unpack_binary() {
478        for t in templates() {
479            let file = build_template(t.id);
480            let packed = renamite_io_ren::save_binary(&file).unwrap();
481            let back = renamite_io_ren::open_binary(&packed).unwrap();
482            assert_eq!(back.document.nodes.len(), file.document.nodes.len());
483        }
484    }
485}