1use 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 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 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 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
123pub 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
299fn 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 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}