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!(
544 "structural differences found: {} difference(s)",
545 diffs.len()
546 );
547 }
548 }
549 Ok(())
550}
551
552fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
555 match (a, b) {
556 (Value::Object(ma), Value::Object(mb)) => {
557 let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
558 keys.sort();
559 keys.dedup();
560 for k in keys {
561 let sub = if path.is_empty() {
562 k.clone()
563 } else {
564 format!("{path}.{k}")
565 };
566 match (ma.get(k), mb.get(k)) {
567 (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
568 (Some(_), None) => out.push(format!("- {sub} (removed)")),
569 (None, Some(_)) => out.push(format!("+ {sub} (added)")),
570 (None, None) => unreachable!(),
571 }
572 }
573 }
574 (Value::Array(aa), Value::Array(ba)) => {
575 if aa.len() != ba.len() {
576 out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
577 } else {
578 for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
579 diff_values(&format!("{path}[{i}]"), av, bv, out);
580 }
581 }
582 }
583 _ => {
584 if a != b {
585 out.push(format!("~ {path}: {a} -> {b}"));
586 }
587 }
588 }
589}
590
591fn cmd_new(output: PathBuf, template: String) -> Result<()> {
592 let name = name_from_path(&output);
593 let mut file = match template.as_str() {
594 "ellipse" => scaffold_ellipse(name.clone()),
596 other => match renamite_examples::parse_template(other) {
597 Some(id) => renamite_examples::build_template(id),
598 None => {
599 let known: Vec<&str> = std::iter::once("ellipse")
600 .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
601 .collect();
602 bail!(
603 "unknown template '{other}' (expected one of: {})",
604 known.join(", ")
605 )
606 }
607 },
608 };
609 file.meta.name = name;
610
611 let ext = output.extension().and_then(|s| s.to_str()).unwrap_or("ren");
612 match ext {
613 "renb" => std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?,
614 _ => std::fs::write(&output, renamite_io_ren::save(&file)?)?,
615 }
616 println!("Created {}", output.display());
617 Ok(())
618}
619
620fn cmd_templates() -> Result<()> {
621 println!("{}", templates_text());
622 Ok(())
623}
624
625fn templates_text() -> String {
626 let mut out =
627 String::from("Available templates (use with `renamite new --template <slug>`):\n");
628 for t in renamite_examples::templates() {
629 out.push_str(&format!(" {:<18} {}\n", t.id.slug(), t.description));
630 }
631 out
632}
633
634fn scaffold_ellipse(name: String) -> RenFile {
635 use renamite_animation::Animated;
636 use renamite_model::{
637 Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
638 };
639
640 let mut doc = Document::empty();
641 let comp = doc.main;
642 let (w, h) = doc.compositions[comp].size;
643 let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
644
645 let shape = doc.create_node(Node::new(
646 "Ellipse",
647 NodeKind::Shape(ShapeKind::Ellipse {
648 pos: Animated::new(center),
649 size: Animated::new(glam::DVec2::new(180.0, 180.0)),
650 }),
651 ));
652 let fill = doc.create_node(Node::new(
653 "Fill",
654 NodeKind::Style(StyleKind::Fill {
655 paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
656 rule: FillRule::NonZero,
657 }),
658 ));
659 doc.attach(shape, Parent::Comp(comp), 0).unwrap();
660 doc.attach(fill, Parent::Comp(comp), 1).unwrap();
661
662 RenFile::new(doc, name)
663}
664
665fn name_from_path(p: &Path) -> String {
666 p.file_stem()
667 .and_then(|s| s.to_str())
668 .unwrap_or("Untitled")
669 .to_string()
670}
671
672fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
673 let mut player = Player::new(load_file(&input)?)?;
674 let dt = 1.0 / 60.0;
675 let ticks = (duration / dt) as usize;
676
677 println!("Playing {} for {duration:.1}s...", input.display());
678 for _ in 0..ticks {
679 for ev in player.tick(dt) {
680 println!(" {ev}");
681 }
682 }
683 println!("Done. Final head: {:.2}", player.head());
684 Ok(())
685}
686
687fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
688 let file = load_file(&input)?;
689 let report = renamite_io_lottie::export_with_report(&file.document)?;
690 if strict && !report.warnings.is_empty() {
691 for warning in &report.warnings {
692 eprintln!("warning at {}: {}", warning.path, warning.message);
693 }
694 bail!(
695 "Lottie export produced {} compatibility warning(s)",
696 report.warnings.len()
697 );
698 }
699 for warning in &report.warnings {
700 eprintln!("warning at {}: {}", warning.path, warning.message);
701 }
702 std::fs::write(&output, serde_json::to_vec_pretty(&report.value)?)?;
703 println!("Exported {} -> {}", input.display(), output.display());
704 Ok(())
705}
706
707fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
708 let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
709 let report = renamite_io_lottie::import_with_report(&value)?;
710 if strict && !report.warnings.is_empty() {
711 for warning in &report.warnings {
712 eprintln!("warning at {}: {}", warning.path, warning.message);
713 }
714 bail!(
715 "Lottie import produced {} compatibility warning(s)",
716 report.warnings.len()
717 );
718 }
719 for warning in &report.warnings {
720 eprintln!("warning at {}: {}", warning.path, warning.message);
721 }
722 let file = RenFile::new(report.value, name_from_path(&input));
723 match output.extension().and_then(|extension| extension.to_str()) {
724 Some("renb") => {
725 std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
726 }
727 _ => {
728 std::fs::write(&output, renamite_io_ren::save(&file)?)?;
729 }
730 }
731 println!("Imported {} -> {}", input.display(), output.display());
732 Ok(())
733}
734
735fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
736 let file = load_file(&input)?;
737 let report = renamite_io_svg::export_with_report(&file.document, file.document.main, frame)?;
738 if strict && !report.warnings.is_empty() {
739 for warning in &report.warnings {
740 eprintln!("warning at {}: {}", warning.path, warning.message);
741 }
742 bail!(
743 "SVG export produced {} compatibility warning(s)",
744 report.warnings.len()
745 );
746 }
747 for warning in &report.warnings {
748 eprintln!("warning at {}: {}", warning.path, warning.message);
749 }
750 std::fs::write(&output, report.value)?;
751 println!(
752 "Exported {} frame {frame} -> {}",
753 input.display(),
754 output.display()
755 );
756 Ok(())
757}
758
759fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
760 let bytes = std::fs::read(&input)?;
761 let report = renamite_io_svg::import_with_report(&bytes)?;
762 if strict && !report.warnings.is_empty() {
763 for warning in &report.warnings {
764 eprintln!("warning at {}: {}", warning.path, warning.message);
765 }
766 bail!(
767 "SVG import produced {} compatibility warning(s)",
768 report.warnings.len()
769 );
770 }
771 for warning in &report.warnings {
772 eprintln!("warning at {}: {}", warning.path, warning.message);
773 }
774 let file = RenFile::new(report.value, name_from_path(&input));
775 match output.extension().and_then(|extension| extension.to_str()) {
776 Some("renb") => {
777 std::fs::write(&output, renamite_io_ren::save_binary(&file)?)?;
778 }
779 _ => {
780 std::fs::write(&output, renamite_io_ren::save(&file)?)?;
781 }
782 }
783 println!("Imported {} -> {}", input.display(), output.display());
784 Ok(())
785}
786
787fn load_file(path: &Path) -> Result<RenFile> {
788 let bytes =
789 std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
790
791 if renamite_io_ren::is_binary(&bytes) {
793 return Ok(renamite_io_ren::open_binary(&bytes)?);
794 }
795
796 let text = std::str::from_utf8(&bytes)
797 .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
798 Ok(renamite_io_ren::open(text)?)
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use serde_json::json;
805
806 #[test]
807 fn parses_named_colors() {
808 assert_eq!(parse_background("transparent").unwrap(), None);
809 assert_eq!(
810 parse_background("white").unwrap(),
811 Some([255, 255, 255, 255])
812 );
813 assert_eq!(parse_background("black").unwrap(), Some([0, 0, 0, 255]));
814 }
815
816 #[test]
817 fn parses_hex_with_and_without_alpha() {
818 assert_eq!(parse_background("#ff0000").unwrap(), Some([255, 0, 0, 255]));
819 assert_eq!(
820 parse_background("00ff0080").unwrap(),
821 Some([0, 255, 0, 0x80])
822 );
823 }
824
825 #[test]
826 fn rejects_garbage_background() {
827 assert!(parse_background("not-a-color").is_err());
828 assert!(parse_background("#ff00").is_err());
829 }
830
831 #[test]
832 fn diff_detects_added_removed_changed() {
833 let a = json!({ "x": 1, "y": 2, "obj": { "same": 1 } });
834 let b = json!({ "x": 5, "z": 3, "obj": { "same": 1 } });
835 let mut diffs = Vec::new();
836 diff_values("", &a, &b, &mut diffs);
837 assert!(diffs.iter().any(|d| d.contains("~ x: 1 -> 5")));
838 assert!(diffs.iter().any(|d| d.contains("- y (removed)")));
839 assert!(diffs.iter().any(|d| d.contains("+ z (added)")));
840 assert!(!diffs.iter().any(|d| d.contains("obj")));
841 }
842
843 #[test]
844 fn diff_reports_array_length_change_wholesale() {
845 let a = json!({ "arr": [1, 2, 3] });
846 let b = json!({ "arr": [1, 2] });
847 let mut diffs = Vec::new();
848 diff_values("", &a, &b, &mut diffs);
849 assert_eq!(diffs, vec!["~ arr (array len 3 -> 2)"]);
850 }
851
852 #[test]
853 fn render_rejects_neither_frame_nor_frames() {
854 let err = run_from(["renamite", "render", "x.ren"]).unwrap_err();
855 assert!(err.to_string().contains("--frame"));
856 }
857
858 #[test]
859 fn render_rejects_both_frame_and_frames() {
860 let result = Cli::try_parse_from([
861 "renamite", "render", "x.ren", "--frame", "1", "--frames", "10",
862 ]);
863 assert!(result.is_err(), "clap must reject mutually exclusive flags");
864 }
865
866 #[test]
867 fn new_ellipse_roundtrips_through_pack_and_open() {
868 let dir = tempfile::tempdir().unwrap();
869 let ren_path = dir.path().join("scene.ren");
870 cmd_new(ren_path.clone(), "ellipse".into()).unwrap();
871
872 let file = load_file(&ren_path).unwrap();
873 assert_eq!(file.document.nodes.len(), 2); assert_eq!(file.meta.name, "scene");
875
876 let renb_path = dir.path().join("scene.renb");
877 cmd_pack(ren_path, renb_path.clone()).unwrap();
878 let repacked = load_file(&renb_path).unwrap();
879 assert_eq!(repacked.document.nodes.len(), 2);
880 }
881
882 #[test]
883 fn play_and_render_accept_renb() {
884 let dir = tempfile::tempdir().unwrap();
885 let ren = dir.path().join("scene.ren");
886 let renb = dir.path().join("scene.renb");
887 cmd_new(ren.clone(), "ellipse".into()).unwrap();
888 cmd_pack(ren, renb.clone()).unwrap();
889 assert!(Player::new(load_file(&renb).unwrap()).is_ok());
890 }
891
892 #[test]
893 fn new_blank_has_no_nodes() {
894 let dir = tempfile::tempdir().unwrap();
895 let path = dir.path().join("empty.ren");
896 cmd_new(path.clone(), "blank".into()).unwrap();
897 let file = load_file(&path).unwrap();
898 assert_eq!(file.document.nodes.len(), 0);
899 }
900
901 #[test]
902 fn new_rejects_unknown_template() {
903 let dir = tempfile::tempdir().unwrap();
904 let path = dir.path().join("x.ren");
905 assert!(cmd_new(path, "not-a-template".into()).is_err());
906 }
907
908 #[test]
909 fn templates_lists_every_builtin_slug() {
910 let text = templates_text();
911 for t in renamite_examples::templates() {
912 assert!(
913 text.contains(t.id.slug()),
914 "templates output must mention {}",
915 t.id.slug()
916 );
917 }
918 }
919
920 #[test]
921 fn parse_template_accepts_slugs_and_display_names() {
922 use renamite_examples::TemplateId;
923 for id in TemplateId::all() {
924 assert_eq!(renamite_examples::parse_template(id.slug()), Some(*id));
925 assert_eq!(
926 renamite_examples::parse_template(id.display_name()),
927 Some(*id)
928 );
929 assert_eq!(
930 renamite_examples::parse_template(&id.slug().to_uppercase()),
931 Some(*id),
932 "template lookup must be case-insensitive"
933 );
934 }
935 assert_eq!(renamite_examples::parse_template("nope"), None);
936 }
937
938 #[test]
939 fn new_with_each_template_roundtrips() {
940 use renamite_examples::TemplateId;
941 for id in TemplateId::all() {
942 let dir = tempfile::tempdir().unwrap();
943 let path = dir.path().join(format!("{}.ren", id.slug()));
944 cmd_new(path.clone(), id.slug().into()).unwrap();
945
946 let loaded = load_file(&path).unwrap();
947 let mut expected = renamite_examples::build_template(*id);
948 expected.meta.name = id.slug().to_string();
949 assert_eq!(
950 serde_json::to_value(&loaded).unwrap(),
951 serde_json::to_value(&expected).unwrap(),
952 "template {} must survive save->load roundtrip",
953 id.slug()
954 );
955
956 let renb = dir.path().join(format!("{}.renb", id.slug()));
957 cmd_pack(path, renb.clone()).unwrap();
958 let packed = load_file(&renb).unwrap();
959 assert_eq!(
960 serde_json::to_value(&packed).unwrap(),
961 serde_json::to_value(&expected).unwrap(),
962 "template {} must survive binary pack->unpack roundtrip",
963 id.slug()
964 );
965 }
966 }
967
968 #[test]
969 #[ignore]
970 fn render_single_frame_writes_valid_png() {
971 let dir = tempfile::tempdir().unwrap();
972 let ren = dir.path().join("scene.ren");
973 cmd_new(ren.clone(), "ellipse".into()).unwrap();
974
975 let png = dir.path().join("out.png");
976 cmd_render(
977 ren,
978 Some(0),
979 None,
980 1.0 / 60.0,
981 64,
982 64,
983 Some(png.clone()),
984 None,
985 "frame".into(),
986 "white".into(),
987 )
988 .unwrap();
989
990 let bytes = std::fs::read(&png).unwrap();
991 assert_eq!(
992 &bytes[..8],
993 &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
994 );
995 }
996
997 #[test]
998 #[ignore]
999 fn render_sequence_writes_numbered_files() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let ren = dir.path().join("scene.ren");
1002 cmd_new(ren.clone(), "ellipse".into()).unwrap();
1003
1004 let out_dir = dir.path().join("frames");
1005 cmd_render(
1006 ren,
1007 None,
1008 Some(3),
1009 1.0 / 60.0,
1010 32,
1011 32,
1012 None,
1013 Some(out_dir.clone()),
1014 "f".into(),
1015 "transparent".into(),
1016 )
1017 .unwrap();
1018
1019 for i in 0..3 {
1020 assert!(out_dir.join(format!("f_{i:05}.png")).exists());
1021 }
1022 }
1023
1024 #[test]
1025 fn diff_of_identical_files_is_empty() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let a = dir.path().join("a.ren");
1028 let b = dir.path().join("b.ren");
1029 cmd_new(a.clone(), "ellipse".into()).unwrap();
1030 std::fs::copy(&a, &b).unwrap();
1031
1032 let fa = load_file(&a).unwrap();
1033 let fb = load_file(&b).unwrap();
1034 let mut diffs = Vec::new();
1035 diff_values(
1036 "",
1037 &serde_json::to_value(&fa).unwrap(),
1038 &serde_json::to_value(&fb).unwrap(),
1039 &mut diffs,
1040 );
1041 assert!(diffs.is_empty());
1042 }
1043
1044 #[test]
1045 fn validate_reports_clean_file_without_writing() {
1046 let dir = tempfile::tempdir().unwrap();
1047 let path = dir.path().join("scene.ren");
1048 cmd_new(path.clone(), "ellipse".into()).unwrap();
1049
1050 let before = std::fs::read(&path).unwrap();
1051 cmd_validate(path.clone(), false, false, false, false).unwrap(); let after = std::fs::read(&path).unwrap();
1053 assert_eq!(before, after);
1054 }
1055
1056 #[test]
1057 fn validate_deep_reports_clean_ellipse_template() {
1058 let dir = tempfile::tempdir().unwrap();
1059 let path = dir.path().join("scene.ren");
1060 cmd_new(path.clone(), "ellipse".into()).unwrap();
1061
1062 let file = load_file(&path).unwrap();
1063 let report = renamite_validate::validate(&file);
1064 assert_eq!(report.error_count(), 0);
1065 assert_eq!(report.warning_count(), 0);
1066 }
1067
1068 #[test]
1069 fn import_svg_then_export_roundtrips() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let svg = dir.path().join("in.svg");
1072 let ren = dir.path().join("out.ren");
1073 let out_svg = dir.path().join("out.svg");
1074 std::fs::write(
1075 &svg,
1076 r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
1077 <rect x="10" y="10" width="50" height="30" fill="#ff0000"/>
1078 </svg>"##,
1079 )
1080 .unwrap();
1081
1082 cmd_import_svg(svg.clone(), ren.clone(), false).unwrap();
1083 let file = load_file(&ren).unwrap();
1084 assert_eq!(
1085 file.document.compositions[file.document.main].size,
1086 (100, 100)
1087 );
1088
1089 cmd_export_svg(ren.clone(), out_svg.clone(), 0.0, false).unwrap();
1090 let text = std::fs::read_to_string(&out_svg).unwrap();
1091 assert!(text.contains("<svg"));
1092 assert!(text.contains("fill=\"#FF0000\""));
1093 }
1094
1095 #[test]
1096 fn import_svg_strict_fails_on_filter_warning() {
1097 let dir = tempfile::tempdir().unwrap();
1098 let svg = dir.path().join("in.svg");
1099 let ren = dir.path().join("out.ren");
1100 std::fs::write(
1101 &svg,
1102 r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
1103 <filter id="b"><feGaussianBlur stdDeviation="2"/></filter>
1104 <g filter="url(#b)"><rect x="0" y="0" width="10" height="10"/></g>
1105 </svg>"##,
1106 )
1107 .unwrap();
1108
1109 assert!(cmd_import_svg(svg.clone(), ren.clone(), true).is_err());
1110 }
1111
1112 #[test]
1113 fn export_svg_missing_composition_is_err() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let ren = dir.path().join("out.ren");
1116 let svg = dir.path().join("out.svg");
1117 std::fs::write(&ren, r##"<svg xmlns="http://www.w3.org/2000/svg"/>"##).unwrap();
1118 assert!(cmd_export_svg(ren, svg, 0.0, false).is_err());
1119 }
1120}