1use anyhow::{Context, Result, anyhow, bail};
5use clap::{CommandFactory, Parser, Subcommand};
6use renamite_behavior_common::ViewTransform;
7use renamite_io_ren::RenFile;
8use renamite_player::Player;
9use renamite_render_bridge::SceneRenderer;
10use renamite_render_offscreen::OffscreenRenderer;
11use serde::Serialize;
12use serde_json::Value;
13use std::path::{Path, PathBuf};
14
15#[derive(Parser)]
16#[command(name = "renamite")]
17#[command(about = "Runtime and tooling for .ren animations")]
18#[command(version, author)]
19pub struct Cli {
20 #[command(subcommand)]
21 pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26 Bake {
28 input: PathBuf,
29 #[arg(short, long, default_value = "60")]
30 frames: usize,
31 #[arg(short, long, default_value = "0.016666667")]
32 dt: f64,
33 #[arg(short, long, default_value = "scenes.json")]
34 output: PathBuf,
35 },
36
37 Render {
40 input: PathBuf,
41 #[arg(long, conflicts_with = "frames")]
42 frame: Option<i64>,
43 #[arg(long, conflicts_with = "frame")]
44 frames: Option<usize>,
45 #[arg(long, default_value = "0.016666667")]
46 dt: f64,
47 #[arg(long, default_value = "512")]
48 width: u32,
49 #[arg(long, default_value = "512")]
50 height: u32,
51 #[arg(short, long)]
53 out: Option<PathBuf>,
54 #[arg(long)]
56 out_dir: Option<PathBuf>,
57 #[arg(long, default_value = "frame")]
58 prefix: String,
59 #[arg(long, default_value = "white")]
61 background: String,
62 },
63
64 Pack {
66 input: PathBuf,
67 #[arg(short, long)]
68 output: PathBuf,
69 },
70
71 Unpack {
73 input: PathBuf,
74 #[arg(short, long)]
75 output: PathBuf,
76 },
77
78 Info {
80 input: PathBuf,
81 #[arg(long)]
83 json: bool,
84 },
85
86 Validate {
88 input: PathBuf,
89 #[arg(long)]
90 fix: bool,
91 #[arg(long)]
93 deep: bool,
94 #[arg(long, requires = "deep")]
96 json: bool,
97 #[arg(long, requires = "deep")]
99 warnings_as_errors: bool,
100 },
101
102 Diff {
104 a: PathBuf,
105 b: PathBuf,
106 #[arg(long)]
108 fail_on_diff: bool,
109 },
110
111 New {
113 output: PathBuf,
114 #[arg(long, default_value = "ellipse")]
116 template: String,
117 },
118
119 Templates {},
121
122 Play {
124 input: PathBuf,
125 #[arg(short, long, default_value = "5.0")]
126 duration: f64,
127 },
128
129 ExportLottie {
131 input: PathBuf,
132 #[arg(short, long)]
133 output: PathBuf,
134 #[arg(long)]
136 strict: bool,
137 },
138
139 ImportLottie {
141 input: PathBuf,
142 #[arg(short, long)]
143 output: PathBuf,
144 #[arg(long)]
146 strict: bool,
147 },
148
149 ExportSvg {
151 input: PathBuf,
152 #[arg(short, long)]
153 output: PathBuf,
154 #[arg(long, default_value = "0")]
155 frame: f64,
156 #[arg(long)]
158 strict: bool,
159 },
160
161 ImportSvg {
163 input: PathBuf,
164 #[arg(short, long)]
165 output: PathBuf,
166 #[arg(long)]
168 strict: bool,
169 },
170
171 Completions { shell: clap_complete::Shell },
173}
174
175pub fn run() -> Result<()> {
176 let cli = Cli::parse();
177 dispatch(cli.command)
178}
179
180pub fn run_from<I, T>(args: I) -> Result<()>
182where
183 I: IntoIterator<Item = T>,
184 T: Into<std::ffi::OsString> + Clone,
185{
186 let cli = Cli::try_parse_from(args)?;
187 dispatch(cli.command)
188}
189
190fn dispatch(command: Commands) -> Result<()> {
191 match command {
192 Commands::Bake {
193 input,
194 frames,
195 dt,
196 output,
197 } => cmd_bake(input, frames, dt, output),
198 Commands::Render {
199 input,
200 frame,
201 frames,
202 dt,
203 width,
204 height,
205 out,
206 out_dir,
207 prefix,
208 background,
209 } => cmd_render(
210 input, frame, frames, dt, width, height, out, out_dir, prefix, background,
211 ),
212 Commands::Pack { input, output } => cmd_pack(input, output),
213 Commands::Unpack { input, output } => cmd_unpack(input, output),
214 Commands::Info { input, json } => cmd_info(input, json),
215 Commands::Validate {
216 input,
217 fix,
218 deep,
219 json,
220 warnings_as_errors,
221 } => cmd_validate(input, fix, deep, json, warnings_as_errors),
222 Commands::Diff { a, b, fail_on_diff } => cmd_diff(a, b, fail_on_diff),
223 Commands::New { output, template } => cmd_new(output, template),
224 Commands::Templates {} => cmd_templates(),
225 Commands::Play { input, duration } => cmd_play(input, duration),
226 Commands::ExportLottie {
227 input,
228 output,
229 strict,
230 } => cmd_export_lottie(input, output, strict),
231 Commands::ImportLottie {
232 input,
233 output,
234 strict,
235 } => cmd_import_lottie(input, output, strict),
236 Commands::ExportSvg {
237 input,
238 output,
239 frame,
240 strict,
241 } => cmd_export_svg(input, output, frame, strict),
242 Commands::ImportSvg {
243 input,
244 output,
245 strict,
246 } => cmd_import_svg(input, output, strict),
247 Commands::Completions { shell } => {
248 let mut cmd = Cli::command();
249 let name = cmd.get_name().to_string();
250 clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
251 Ok(())
252 }
253 }
254}
255
256fn cmd_bake(input: PathBuf, frames: usize, dt: f64, output: PathBuf) -> Result<()> {
257 let file = load_file(&input).with_context(|| format!("failed to load {}", input.display()))?;
258 let mut player = Player::new(file)
259 .with_context(|| format!("failed to open player for {}", input.display()))?;
260 let scenes = player.bake(frames, dt);
261 let json = serde_json::to_string_pretty(&scenes)?;
262 std::fs::write(&output, json)?;
263 println!("Baked {frames} frames -> {}", output.display());
264 Ok(())
265}
266
267#[allow(clippy::too_many_arguments)]
268fn cmd_render(
269 input: PathBuf,
270 frame: Option<i64>,
271 frames: Option<usize>,
272 dt: f64,
273 width: u32,
274 height: u32,
275 out: Option<PathBuf>,
276 out_dir: Option<PathBuf>,
277 prefix: String,
278 background: String,
279) -> Result<()> {
280 if frame.is_none() && frames.is_none() {
281 bail!("specify either --frame N or --frames N");
282 }
283
284 let bg = parse_background(&background)?;
285 let mut player = Player::new(load_file(&input)?)
286 .with_context(|| format!("failed to open player for {}", input.display()))?;
287 let comp_size = player.project.document.compositions[player.project.document.main].size;
288 let view = export_view(comp_size, width, height);
289 let bg_clear = bg.map(|[r, g, b, a]| {
290 [
291 r as f64 / 255.0,
292 g as f64 / 255.0,
293 b as f64 / 255.0,
294 a as f64 / 255.0,
295 ]
296 });
297
298 let mut bridge = SceneRenderer::new();
299 let mut gpu = pollster::block_on(OffscreenRenderer::new(width, height, 4))?;
300 gpu.sync_document_images(&player.project.document)?;
301
302 match (frame, frames) {
303 (Some(f), None) => {
304 player.scrub(f as f64);
305 let png = rasterize_png(&mut bridge, &mut gpu, player.scene(), &view, bg_clear)?;
306 let out = out.ok_or_else(|| anyhow!("--out is required with --frame"))?;
307 std::fs::write(&out, png)?;
308 println!("Rendered frame {f} -> {}", out.display());
309 Ok(())
310 }
311 (None, Some(n)) => {
312 let out_dir = out_dir.ok_or_else(|| anyhow!("--out-dir is required with --frames"))?;
313 std::fs::create_dir_all(&out_dir)?;
314 let scenes = player.bake(n, dt);
315 for (i, scene) in scenes.iter().enumerate() {
316 let png = rasterize_png(&mut bridge, &mut gpu, scene, &view, bg_clear)?;
317 let path = out_dir.join(format!("{prefix}_{i:05}.png"));
318 std::fs::write(&path, png)?;
319 }
320 println!("Rendered {n} frames -> {}", out_dir.display());
321 Ok(())
322 }
323 (None, None) => unreachable!("guard at top of cmd_render"),
324 (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
325 }
326}
327
328fn export_view(comp_size: (u32, u32), out_w: u32, out_h: u32) -> ViewTransform {
330 let (cw, ch) = (comp_size.0 as f64, comp_size.1 as f64);
331 if cw <= 0.0 || ch <= 0.0 || out_w == 0 || out_h == 0 {
332 return ViewTransform::identity();
333 }
334 let scale = (out_w as f64 / cw).min(out_h as f64 / ch);
335 let ox = (out_w as f64 - cw * scale) * 0.5;
336 let oy = (out_h as f64 - ch * scale) * 0.5;
337 ViewTransform {
338 scale,
339 offset: glam::DVec2::new(ox, oy),
340 }
341}
342
343fn rasterize_png(
344 bridge: &mut SceneRenderer,
345 gpu: &mut OffscreenRenderer,
346 scene: &renamite_model::Scene,
347 view: &ViewTransform,
348 bg: Option<[f64; 4]>,
349) -> Result<Vec<u8>> {
350 let prepared = bridge.prepare(scene, view);
351 let mut repose = repose_core::Scene::default();
352 bridge.append_repose_scene(&prepared, &mut repose);
353 gpu.render_png(&repose, bg)
354}
355
356fn parse_background(s: &str) -> Result<Option<[u8; 4]>> {
357 match s {
358 "transparent" | "none" => Ok(None),
359 "white" => Ok(Some([255, 255, 255, 255])),
360 "black" => Ok(Some([0, 0, 0, 255])),
361 hex => {
362 let hex = hex.trim_start_matches('#');
363 let bytes = match hex.len() {
364 6 => [
365 u8::from_str_radix(&hex[0..2], 16)?,
366 u8::from_str_radix(&hex[2..4], 16)?,
367 u8::from_str_radix(&hex[4..6], 16)?,
368 255,
369 ],
370 8 => [
371 u8::from_str_radix(&hex[0..2], 16)?,
372 u8::from_str_radix(&hex[2..4], 16)?,
373 u8::from_str_radix(&hex[4..6], 16)?,
374 u8::from_str_radix(&hex[6..8], 16)?,
375 ],
376 _ => bail!(
377 "invalid background '{s}': expected 'transparent', 'white', 'black', or hex RRGGBB[AA]"
378 ),
379 };
380 Ok(Some(bytes))
381 }
382 }
383}
384
385fn cmd_pack(input: PathBuf, output: PathBuf) -> Result<()> {
386 let text = std::fs::read_to_string(&input)?;
387 let mut file: RenFile = renamite_io_ren::open(&text)?;
388 file.normalize();
389 std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
390 println!("Packed {} -> {}", input.display(), output.display());
391 Ok(())
392}
393
394fn cmd_unpack(input: PathBuf, output: PathBuf) -> Result<()> {
395 let bytes = std::fs::read(&input)?;
396 let mut file: RenFile = renamite_io_ren::open_binary(&bytes)?;
397 file.normalize();
398 std::fs::write(&output, renamite_io_ren::save(&file)?)?;
399 println!("Unpacked {} -> {}", input.display(), output.display());
400 Ok(())
401}
402
403#[derive(Serialize)]
404struct InfoSummary {
405 path: String,
406 name: String,
407 format_version: u32,
408 compositions: usize,
409 nodes: usize,
410 clips: usize,
411 machines: usize,
412 start_machine: Option<String>,
413 main: MainCompInfo,
414}
415
416#[derive(Serialize)]
417struct MainCompInfo {
418 name: String,
419 width: u32,
420 height: u32,
421 fps: f64,
422 in_frame: i64,
423 out_frame: i64,
424}
425
426fn cmd_info(input: PathBuf, json: bool) -> Result<()> {
427 let file = load_file(&input)?;
428 let comp = &file.document.compositions[file.document.main];
429 let summary = InfoSummary {
430 path: input.display().to_string(),
431 name: file.meta.name.clone(),
432 format_version: file.format_version,
433 compositions: file.document.compositions.len(),
434 nodes: file.document.nodes.len(),
435 clips: file.clips.len(),
436 machines: file.machines.len(),
437 start_machine: file
438 .start_machine
439 .and_then(|id| file.machines.get(id))
440 .map(|m| m.name.clone()),
441 main: MainCompInfo {
442 name: comp.name.clone(),
443 width: comp.size.0,
444 height: comp.size.1,
445 fps: comp.rate.fps(),
446 in_frame: comp.range.0.0,
447 out_frame: comp.range.1.0,
448 },
449 };
450
451 if json {
452 println!("{}", serde_json::to_string_pretty(&summary)?);
453 return Ok(());
454 }
455
456 println!("File: {}", summary.path);
457 println!("Name: {}", summary.name);
458 println!("Format: v{}", summary.format_version);
459 println!("Compositions: {}", summary.compositions);
460 println!("Nodes: {}", summary.nodes);
461 println!("Clips: {}", summary.clips);
462 println!("Machines: {}", summary.machines);
463 if let Some(name) = &summary.start_machine {
464 println!("Start machine: {name}");
465 }
466 println!("\nMain composition:");
467 println!(" Name: {}", summary.main.name);
468 println!(" Size: {}x{}", summary.main.width, summary.main.height);
469 println!(" Rate: {:.2} fps", summary.main.fps);
470 println!(
471 " Range: {} - {}",
472 summary.main.in_frame, summary.main.out_frame
473 );
474 Ok(())
475}
476
477fn cmd_validate(
478 input: PathBuf,
479 fix: bool,
480 deep: bool,
481 json: bool,
482 warnings_as_errors: bool,
483) -> Result<()> {
484 let mut file = load_file(&input)?;
485 let before_json = serde_json::to_string(&file)?;
486 file.normalize();
487 file.garbage_collect();
488
489 if deep {
490 let report = renamite_validate::validate(&file);
491
492 if json {
493 println!("{}", serde_json::to_string_pretty(&report)?);
494 } else {
495 for d in &report.diagnostics {
496 println!("{:?}: {}: {}", d.severity, d.path, d.message);
497 }
498 println!(
499 "{} error(s), {} warning(s)",
500 report.error_count(),
501 report.warning_count()
502 );
503 }
504
505 if report.has_errors() || (warnings_as_errors && report.warning_count() > 0) {
506 bail!(
507 "validation failed: {} error(s), {} warning(s)",
508 report.error_count(),
509 report.warning_count()
510 );
511 }
512 return Ok(());
513 }
514
515 if fix {
516 std::fs::write(&input, renamite_io_ren::save_binary(&file)?)?;
517 println!("Normalized and saved {}", input.display());
518 } else if serde_json::to_string(&file)? == before_json {
519 println!("{} is valid", input.display());
520 } else {
521 bail!("{} needs normalization (use --fix)", input.display());
522 }
523 Ok(())
524}
525
526fn cmd_diff(a: PathBuf, b: PathBuf, fail_on_diff: bool) -> Result<()> {
527 let fa = load_file(&a)?;
528 let fb = load_file(&b)?;
529 let va = serde_json::to_value(&fa)?;
530 let vb = serde_json::to_value(&fb)?;
531
532 let mut diffs = Vec::new();
533 diff_values("", &va, &vb, &mut diffs);
534
535 if diffs.is_empty() {
536 println!("No structural differences.");
537 } else {
538 println!("{} difference(s):", diffs.len());
539 for d in &diffs {
540 println!(" {d}");
541 }
542 if fail_on_diff {
543 bail!("structural differences found: {} difference(s)", diffs.len());
544 }
545 }
546 Ok(())
547}
548
549fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
552 match (a, b) {
553 (Value::Object(ma), Value::Object(mb)) => {
554 let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
555 keys.sort();
556 keys.dedup();
557 for k in keys {
558 let sub = if path.is_empty() {
559 k.clone()
560 } else {
561 format!("{path}.{k}")
562 };
563 match (ma.get(k), mb.get(k)) {
564 (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
565 (Some(_), None) => out.push(format!("- {sub} (removed)")),
566 (None, Some(_)) => out.push(format!("+ {sub} (added)")),
567 (None, None) => unreachable!(),
568 }
569 }
570 }
571 (Value::Array(aa), Value::Array(ba)) => {
572 if aa.len() != ba.len() {
573 out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
574 } else {
575 for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
576 diff_values(&format!("{path}[{i}]"), av, bv, out);
577 }
578 }
579 }
580 _ => {
581 if a != b {
582 out.push(format!("~ {path}: {a} -> {b}"));
583 }
584 }
585 }
586}
587
588fn cmd_new(output: PathBuf, template: String) -> Result<()> {
589 let name = name_from_path(&output);
590 let mut file = match template.as_str() {
591 "ellipse" => scaffold_ellipse(name.clone()),
593 other => match renamite_examples::parse_template(other) {
594 Some(id) => renamite_examples::build_template(id),
595 None => {
596 let known: Vec<&str> = std::iter::once("ellipse")
597 .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
598 .collect();
599 bail!(
600 "unknown template '{other}' (expected one of: {})",
601 known.join(", ")
602 )
603 }
604 },
605 };
606 file.meta.name = name;
607
608 let ext = output.extension().and_then(|s| s.to_str()).unwrap_or("ren");
609 match ext {
610 "renb" => std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?,
611 _ => std::fs::write(&output, renamite_io_ren::save(&file)?)?,
612 }
613 println!("Created {}", output.display());
614 Ok(())
615}
616
617fn cmd_templates() -> Result<()> {
618 println!("{}", templates_text());
619 Ok(())
620}
621
622fn templates_text() -> String {
623 let mut out =
624 String::from("Available templates (use with `renamite new --template <slug>`):\n");
625 for t in renamite_examples::templates() {
626 out.push_str(&format!(" {:<18} {}\n", t.id.slug(), t.description));
627 }
628 out
629}
630
631fn scaffold_ellipse(name: String) -> RenFile {
632 use renamite_animation::Animated;
633 use renamite_model::{
634 Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
635 };
636
637 let mut doc = Document::empty();
638 let comp = doc.main;
639 let (w, h) = doc.compositions[comp].size;
640 let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
641
642 let shape = doc.create_node(Node::new(
643 "Ellipse",
644 NodeKind::Shape(ShapeKind::Ellipse {
645 pos: Animated::new(center),
646 size: Animated::new(glam::DVec2::new(180.0, 180.0)),
647 }),
648 ));
649 let fill = doc.create_node(Node::new(
650 "Fill",
651 NodeKind::Style(StyleKind::Fill {
652 paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
653 rule: FillRule::NonZero,
654 }),
655 ));
656 doc.attach(shape, Parent::Comp(comp), 0).unwrap();
657 doc.attach(fill, Parent::Comp(comp), 1).unwrap();
658
659 RenFile::new(doc, name)
660}
661
662fn name_from_path(p: &Path) -> String {
663 p.file_stem()
664 .and_then(|s| s.to_str())
665 .unwrap_or("Untitled")
666 .to_string()
667}
668
669fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
670 let mut player = Player::new(load_file(&input)?)?;
671 let dt = 1.0 / 60.0;
672 let ticks = (duration / dt) as usize;
673
674 println!("Playing {} for {duration:.1}s...", input.display());
675 for _ in 0..ticks {
676 for ev in player.tick(dt) {
677 println!(" {ev}");
678 }
679 }
680 println!("Done. Final head: {:.2}", player.head());
681 Ok(())
682}
683
684fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
685 let file = load_file(&input)?;
686 let report = renamite_io_lottie::export_with_report(&file.document)?;
687 if strict && !report.warnings.is_empty() {
688 for warning in &report.warnings {
689 eprintln!("warning at {}: {}", warning.path, warning.message);
690 }
691 bail!(
692 "Lottie export produced {} compatibility warning(s)",
693 report.warnings.len()
694 );
695 }
696 for warning in &report.warnings {
697 eprintln!("warning at {}: {}", warning.path, warning.message);
698 }
699 std::fs::write(&output, serde_json::to_vec_pretty(&report.value)?)?;
700 println!("Exported {} -> {}", input.display(), output.display());
701 Ok(())
702}
703
704fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
705 let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
706 let report = renamite_io_lottie::import_with_report(&value)?;
707 if strict && !report.warnings.is_empty() {
708 for warning in &report.warnings {
709 eprintln!("warning at {}: {}", warning.path, warning.message);
710 }
711 bail!(
712 "Lottie import produced {} compatibility warning(s)",
713 report.warnings.len()
714 );
715 }
716 for warning in &report.warnings {
717 eprintln!("warning at {}: {}", warning.path, warning.message);
718 }
719 let file = RenFile::new(report.value, name_from_path(&input));
720 match output.extension().and_then(|extension| extension.to_str()) {
721 Some("renb") => {
722 std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
723 }
724 _ => {
725 std::fs::write(&output, renamite_io_ren::save(&file)?)?;
726 }
727 }
728 println!("Imported {} -> {}", input.display(), output.display());
729 Ok(())
730}
731
732fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
733 let file = load_file(&input)?;
734 let report = renamite_io_svg::export_with_report(&file.document, file.document.main, frame)?;
735 if strict && !report.warnings.is_empty() {
736 for warning in &report.warnings {
737 eprintln!("warning at {}: {}", warning.path, warning.message);
738 }
739 bail!(
740 "SVG export produced {} compatibility warning(s)",
741 report.warnings.len()
742 );
743 }
744 for warning in &report.warnings {
745 eprintln!("warning at {}: {}", warning.path, warning.message);
746 }
747 std::fs::write(&output, report.value)?;
748 println!(
749 "Exported {} frame {frame} -> {}",
750 input.display(),
751 output.display()
752 );
753 Ok(())
754}
755
756fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
757 let bytes = std::fs::read(&input)?;
758 let report = renamite_io_svg::import_with_report(&bytes)?;
759 if strict && !report.warnings.is_empty() {
760 for warning in &report.warnings {
761 eprintln!("warning at {}: {}", warning.path, warning.message);
762 }
763 bail!(
764 "SVG import produced {} compatibility warning(s)",
765 report.warnings.len()
766 );
767 }
768 for warning in &report.warnings {
769 eprintln!("warning at {}: {}", warning.path, warning.message);
770 }
771 let file = RenFile::new(report.value, name_from_path(&input));
772 match output.extension().and_then(|extension| extension.to_str()) {
773 Some("renb") => {
774 std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
775 }
776 _ => {
777 std::fs::write(&output, renamite_io_ren::save(&file)?)?;
778 }
779 }
780 println!("Imported {} -> {}", input.display(), output.display());
781 Ok(())
782}
783
784fn load_file(path: &Path) -> Result<RenFile> {
785 let bytes =
786 std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
787
788 if renamite_io_ren::is_binary(&bytes) {
790 return Ok(renamite_io_ren::open_binary(&bytes)?);
791 }
792
793 let text = std::str::from_utf8(&bytes)
794 .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
795 Ok(renamite_io_ren::open(text)?)
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 use serde_json::json;
802
803 #[test]
804 fn parses_named_colors() {
805 assert_eq!(parse_background("transparent").unwrap(), None);
806 assert_eq!(
807 parse_background("white").unwrap(),
808 Some([255, 255, 255, 255])
809 );
810 assert_eq!(parse_background("black").unwrap(), Some([0, 0, 0, 255]));
811 }
812
813 #[test]
814 fn parses_hex_with_and_without_alpha() {
815 assert_eq!(parse_background("#ff0000").unwrap(), Some([255, 0, 0, 255]));
816 assert_eq!(
817 parse_background("00ff0080").unwrap(),
818 Some([0, 255, 0, 0x80])
819 );
820 }
821
822 #[test]
823 fn rejects_garbage_background() {
824 assert!(parse_background("not-a-color").is_err());
825 assert!(parse_background("#ff00").is_err());
826 }
827
828 #[test]
829 fn diff_detects_added_removed_changed() {
830 let a = json!({ "x": 1, "y": 2, "obj": { "same": 1 } });
831 let b = json!({ "x": 5, "z": 3, "obj": { "same": 1 } });
832 let mut diffs = Vec::new();
833 diff_values("", &a, &b, &mut diffs);
834 assert!(diffs.iter().any(|d| d.contains("~ x: 1 -> 5")));
835 assert!(diffs.iter().any(|d| d.contains("- y (removed)")));
836 assert!(diffs.iter().any(|d| d.contains("+ z (added)")));
837 assert!(!diffs.iter().any(|d| d.contains("obj")));
838 }
839
840 #[test]
841 fn diff_reports_array_length_change_wholesale() {
842 let a = json!({ "arr": [1, 2, 3] });
843 let b = json!({ "arr": [1, 2] });
844 let mut diffs = Vec::new();
845 diff_values("", &a, &b, &mut diffs);
846 assert_eq!(diffs, vec!["~ arr (array len 3 -> 2)"]);
847 }
848
849 #[test]
850 fn render_rejects_neither_frame_nor_frames() {
851 let err = run_from(["renamite", "render", "x.ren"]).unwrap_err();
852 assert!(err.to_string().contains("--frame"));
853 }
854
855 #[test]
856 fn render_rejects_both_frame_and_frames() {
857 let result = Cli::try_parse_from([
858 "renamite", "render", "x.ren", "--frame", "1", "--frames", "10",
859 ]);
860 assert!(result.is_err(), "clap must reject mutually exclusive flags");
861 }
862
863 #[test]
864 fn new_ellipse_roundtrips_through_pack_and_open() {
865 let dir = tempfile::tempdir().unwrap();
866 let ren_path = dir.path().join("scene.ren");
867 cmd_new(ren_path.clone(), "ellipse".into()).unwrap();
868
869 let file = load_file(&ren_path).unwrap();
870 assert_eq!(file.document.nodes.len(), 2); assert_eq!(file.meta.name, "scene");
872
873 let renb_path = dir.path().join("scene.renb");
874 cmd_pack(ren_path, renb_path.clone()).unwrap();
875 let repacked = load_file(&renb_path).unwrap();
876 assert_eq!(repacked.document.nodes.len(), 2);
877 }
878
879 #[test]
880 fn play_and_render_accept_renb() {
881 let dir = tempfile::tempdir().unwrap();
882 let ren = dir.path().join("scene.ren");
883 let renb = dir.path().join("scene.renb");
884 cmd_new(ren.clone(), "ellipse".into()).unwrap();
885 cmd_pack(ren, renb.clone()).unwrap();
886 assert!(Player::new(load_file(&renb).unwrap()).is_ok());
887 }
888
889 #[test]
890 fn new_blank_has_no_nodes() {
891 let dir = tempfile::tempdir().unwrap();
892 let path = dir.path().join("empty.ren");
893 cmd_new(path.clone(), "blank".into()).unwrap();
894 let file = load_file(&path).unwrap();
895 assert_eq!(file.document.nodes.len(), 0);
896 }
897
898 #[test]
899 fn new_rejects_unknown_template() {
900 let dir = tempfile::tempdir().unwrap();
901 let path = dir.path().join("x.ren");
902 assert!(cmd_new(path, "not-a-template".into()).is_err());
903 }
904
905 #[test]
906 fn templates_lists_every_builtin_slug() {
907 let text = templates_text();
908 for t in renamite_examples::templates() {
909 assert!(
910 text.contains(t.id.slug()),
911 "templates output must mention {}",
912 t.id.slug()
913 );
914 }
915 }
916
917 #[test]
918 fn parse_template_accepts_slugs_and_display_names() {
919 use renamite_examples::TemplateId;
920 for id in TemplateId::all() {
921 assert_eq!(renamite_examples::parse_template(id.slug()), Some(*id));
922 assert_eq!(
923 renamite_examples::parse_template(id.display_name()),
924 Some(*id)
925 );
926 assert_eq!(
927 renamite_examples::parse_template(&id.slug().to_uppercase()),
928 Some(*id),
929 "template lookup must be case-insensitive"
930 );
931 }
932 assert_eq!(renamite_examples::parse_template("nope"), None);
933 }
934
935 #[test]
936 fn new_with_each_template_roundtrips() {
937 use renamite_examples::TemplateId;
938 for id in TemplateId::all() {
939 let dir = tempfile::tempdir().unwrap();
940 let path = dir.path().join(format!("{}.ren", id.slug()));
941 cmd_new(path.clone(), id.slug().into()).unwrap();
942
943 let loaded = load_file(&path).unwrap();
944 let mut expected = renamite_examples::build_template(*id);
945 expected.meta.name = id.slug().to_string();
946 assert_eq!(
947 serde_json::to_value(&loaded).unwrap(),
948 serde_json::to_value(&expected).unwrap(),
949 "template {} must survive save->load roundtrip",
950 id.slug()
951 );
952
953 let renb = dir.path().join(format!("{}.renb", id.slug()));
954 cmd_pack(path, renb.clone()).unwrap();
955 let packed = load_file(&renb).unwrap();
956 assert_eq!(
957 serde_json::to_value(&packed).unwrap(),
958 serde_json::to_value(&expected).unwrap(),
959 "template {} must survive binary pack->unpack roundtrip",
960 id.slug()
961 );
962 }
963 }
964
965 #[test]
966 #[ignore]
967 fn render_single_frame_writes_valid_png() {
968 let dir = tempfile::tempdir().unwrap();
969 let ren = dir.path().join("scene.ren");
970 cmd_new(ren.clone(), "ellipse".into()).unwrap();
971
972 let png = dir.path().join("out.png");
973 cmd_render(
974 ren,
975 Some(0),
976 None,
977 1.0 / 60.0,
978 64,
979 64,
980 Some(png.clone()),
981 None,
982 "frame".into(),
983 "white".into(),
984 )
985 .unwrap();
986
987 let bytes = std::fs::read(&png).unwrap();
988 assert_eq!(
989 &bytes[..8],
990 &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
991 );
992 }
993
994 #[test]
995 #[ignore]
996 fn render_sequence_writes_numbered_files() {
997 let dir = tempfile::tempdir().unwrap();
998 let ren = dir.path().join("scene.ren");
999 cmd_new(ren.clone(), "ellipse".into()).unwrap();
1000
1001 let out_dir = dir.path().join("frames");
1002 cmd_render(
1003 ren,
1004 None,
1005 Some(3),
1006 1.0 / 60.0,
1007 32,
1008 32,
1009 None,
1010 Some(out_dir.clone()),
1011 "f".into(),
1012 "transparent".into(),
1013 )
1014 .unwrap();
1015
1016 for i in 0..3 {
1017 assert!(out_dir.join(format!("f_{i:05}.png")).exists());
1018 }
1019 }
1020
1021 #[test]
1022 fn diff_of_identical_files_is_empty() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let a = dir.path().join("a.ren");
1025 let b = dir.path().join("b.ren");
1026 cmd_new(a.clone(), "ellipse".into()).unwrap();
1027 std::fs::copy(&a, &b).unwrap();
1028
1029 let fa = load_file(&a).unwrap();
1030 let fb = load_file(&b).unwrap();
1031 let mut diffs = Vec::new();
1032 diff_values(
1033 "",
1034 &serde_json::to_value(&fa).unwrap(),
1035 &serde_json::to_value(&fb).unwrap(),
1036 &mut diffs,
1037 );
1038 assert!(diffs.is_empty());
1039 }
1040
1041 #[test]
1042 fn validate_reports_clean_file_without_writing() {
1043 let dir = tempfile::tempdir().unwrap();
1044 let path = dir.path().join("scene.ren");
1045 cmd_new(path.clone(), "ellipse".into()).unwrap();
1046
1047 let before = std::fs::read(&path).unwrap();
1048 cmd_validate(path.clone(), false, false, false, false).unwrap(); let after = std::fs::read(&path).unwrap();
1050 assert_eq!(before, after);
1051 }
1052
1053 #[test]
1054 fn validate_deep_reports_clean_ellipse_template() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let path = dir.path().join("scene.ren");
1057 cmd_new(path.clone(), "ellipse".into()).unwrap();
1058
1059 let file = load_file(&path).unwrap();
1060 let report = renamite_validate::validate(&file);
1061 assert_eq!(report.error_count(), 0);
1062 assert_eq!(report.warning_count(), 0);
1063 }
1064
1065 #[test]
1066 fn import_svg_then_export_roundtrips() {
1067 let dir = tempfile::tempdir().unwrap();
1068 let svg = dir.path().join("in.svg");
1069 let ren = dir.path().join("out.ren");
1070 let out_svg = dir.path().join("out.svg");
1071 std::fs::write(
1072 &svg,
1073 r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
1074 <rect x="10" y="10" width="50" height="30" fill="#ff0000"/>
1075 </svg>"##,
1076 )
1077 .unwrap();
1078
1079 cmd_import_svg(svg.clone(), ren.clone(), false).unwrap();
1080 let file = load_file(&ren).unwrap();
1081 assert_eq!(
1082 file.document.compositions[file.document.main].size,
1083 (100, 100)
1084 );
1085
1086 cmd_export_svg(ren.clone(), out_svg.clone(), 0.0, false).unwrap();
1087 let text = std::fs::read_to_string(&out_svg).unwrap();
1088 assert!(text.contains("<svg"));
1089 assert!(text.contains("fill=\"#FF0000\""));
1090 }
1091
1092 #[test]
1093 fn import_svg_strict_fails_on_filter_warning() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let svg = dir.path().join("in.svg");
1096 let ren = dir.path().join("out.ren");
1097 std::fs::write(
1098 &svg,
1099 r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
1100 <filter id="b"><feGaussianBlur stdDeviation="2"/></filter>
1101 <g filter="url(#b)"><rect x="0" y="0" width="10" height="10"/></g>
1102 </svg>"##,
1103 )
1104 .unwrap();
1105
1106 assert!(cmd_import_svg(svg.clone(), ren.clone(), true).is_err());
1107 }
1108
1109 #[test]
1110 fn export_svg_missing_composition_is_err() {
1111 let dir = tempfile::tempdir().unwrap();
1112 let ren = dir.path().join("out.ren");
1113 let svg = dir.path().join("out.svg");
1114 std::fs::write(&ren, r##"<svg xmlns="http://www.w3.org/2000/svg"/>"##).unwrap();
1115 assert!(cmd_export_svg(ren, svg, 0.0, false).is_err());
1116 }
1117}