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, 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 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 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
302fn 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 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}