Skip to main content

renamite_cli/
lib.rs

1//! renamite CLI - implemented as a library so every command is directly
2//! testable (no subprocess spawning). `main.rs` only calls [`run`].
3
4use 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 animation frames to JSON scenes (golden tests / export)
27    Bake {
28        input: PathBuf,
29        #[arg(short, long, default_value = "60")]
30        frames: usize,
31        /// Seconds per baked frame. Defaults to 1 / composition rate.
32        #[arg(short, long)]
33        dt: Option<f64>,
34        #[arg(short, long, default_value = "scenes.json")]
35        output: PathBuf,
36    },
37
38    /// Rasterize to PNG via the Repose WGPU renderer: a single frame
39    /// (--frame) or a sequence (--frames)
40    Render {
41        input: PathBuf,
42        #[arg(long, conflicts_with = "frames")]
43        frame: Option<i64>,
44        #[arg(long, conflicts_with = "frame")]
45        frames: Option<usize>,
46        /// Seconds per baked frame. Defaults to 1 / composition rate.
47        #[arg(long)]
48        dt: Option<f64>,
49        #[arg(long, default_value = "512")]
50        width: u32,
51        #[arg(long, default_value = "512")]
52        height: u32,
53        /// Single-frame output path
54        #[arg(short, long)]
55        out: Option<PathBuf>,
56        /// Sequence output directory
57        #[arg(long)]
58        out_dir: Option<PathBuf>,
59        #[arg(long, default_value = "frame")]
60        prefix: String,
61        /// "transparent", "white", "black", or hex RRGGBB[AA]
62        #[arg(long, default_value = "white")]
63        background: String,
64    },
65
66    /// Pack .ren -> binary .renb
67    Pack {
68        input: PathBuf,
69        #[arg(short, long)]
70        output: PathBuf,
71    },
72
73    /// Unpack .renb -> pretty .ren
74    Unpack {
75        input: PathBuf,
76        #[arg(short, long)]
77        output: PathBuf,
78    },
79
80    /// Show project info
81    Info {
82        input: PathBuf,
83        /// Emit a machine-readable JSON summary
84        #[arg(long)]
85        json: bool,
86    },
87
88    /// Validate + normalize (optionally fix in place, or deep-validate)
89    Validate {
90        input: PathBuf,
91        #[arg(long)]
92        fix: bool,
93        /// Run deep structural validation and print diagnostics
94        #[arg(long)]
95        deep: bool,
96        /// Emit the deep validation report as JSON
97        #[arg(long, requires = "deep")]
98        json: bool,
99        /// Exit 1 on warnings as well as errors in deep mode
100        #[arg(long, requires = "deep")]
101        warnings_as_errors: bool,
102    },
103
104    /// Structural diff between two .ren/.renb files
105    Diff {
106        a: PathBuf,
107        b: PathBuf,
108        /// Exit with status 1 if any differences are found
109        #[arg(long)]
110        fail_on_diff: bool,
111    },
112
113    /// Scaffold a new .ren project
114    New {
115        output: PathBuf,
116        /// Template slug, e.g. "blank", "bouncing-ball" (run `renamite templates`)
117        #[arg(long, default_value = "ellipse")]
118        template: String,
119    },
120
121    /// List built-in project templates
122    Templates {},
123
124    /// Headless playback (prints machine events)
125    Play {
126        input: PathBuf,
127        #[arg(short, long, default_value = "5.0")]
128        duration: f64,
129    },
130
131    /// Export a .ren/.renb project to Lottie JSON.
132    ExportLottie {
133        input: PathBuf,
134        #[arg(short, long)]
135        output: PathBuf,
136        /// Fail if the exporter emitted compatibility warnings.
137        #[arg(long)]
138        strict: bool,
139    },
140
141    /// Convert Lottie JSON to a Renamite project.
142    ImportLottie {
143        input: PathBuf,
144        #[arg(short, long)]
145        output: PathBuf,
146        /// Fail if unsupported objects were skipped.
147        #[arg(long)]
148        strict: bool,
149    },
150
151    /// Export a .ren/.renb project to a static SVG frame snapshot.
152    ExportSvg {
153        input: PathBuf,
154        #[arg(short, long)]
155        output: PathBuf,
156        #[arg(long, default_value = "0")]
157        frame: f64,
158        /// Fail if the exporter emitted compatibility warnings.
159        #[arg(long)]
160        strict: bool,
161    },
162
163    /// Convert an SVG file to a Renamite project.
164    ImportSvg {
165        input: PathBuf,
166        #[arg(short, long)]
167        output: PathBuf,
168        /// Fail if unsupported objects were skipped.
169        #[arg(long)]
170        strict: bool,
171    },
172
173    /// Generate shell completions
174    Completions { shell: clap_complete::Shell },
175}
176
177pub fn run() -> Result<()> {
178    let cli = Cli::parse();
179    dispatch(cli.command)
180}
181
182/// Exposed for tests: parse from an explicit argv, bypassing `std::env::args`.
183pub fn run_from<I, T>(args: I) -> Result<()>
184where
185    I: IntoIterator<Item = T>,
186    T: Into<std::ffi::OsString> + Clone,
187{
188    let cli = Cli::try_parse_from(args)?;
189    dispatch(cli.command)
190}
191
192fn dispatch(command: Commands) -> Result<()> {
193    match command {
194        Commands::Bake {
195            input,
196            frames,
197            dt,
198            output,
199        } => cmd_bake(input, frames, dt, output),
200        Commands::Render {
201            input,
202            frame,
203            frames,
204            dt,
205            width,
206            height,
207            out,
208            out_dir,
209            prefix,
210            background,
211        } => cmd_render(
212            input, frame, frames, dt, width, height, out, out_dir, prefix, background,
213        ),
214        Commands::Pack { input, output } => cmd_pack(input, output),
215        Commands::Unpack { input, output } => cmd_unpack(input, output),
216        Commands::Info { input, json } => cmd_info(input, json),
217        Commands::Validate {
218            input,
219            fix,
220            deep,
221            json,
222            warnings_as_errors,
223        } => cmd_validate(input, fix, deep, json, warnings_as_errors),
224        Commands::Diff { a, b, fail_on_diff } => cmd_diff(a, b, fail_on_diff),
225        Commands::New { output, template } => cmd_new(output, template),
226        Commands::Templates {} => cmd_templates(),
227        Commands::Play { input, duration } => cmd_play(input, duration),
228        Commands::ExportLottie {
229            input,
230            output,
231            strict,
232        } => cmd_export_lottie(input, output, strict),
233        Commands::ImportLottie {
234            input,
235            output,
236            strict,
237        } => cmd_import_lottie(input, output, strict),
238        Commands::ExportSvg {
239            input,
240            output,
241            frame,
242            strict,
243        } => cmd_export_svg(input, output, frame, strict),
244        Commands::ImportSvg {
245            input,
246            output,
247            strict,
248        } => cmd_import_svg(input, output, strict),
249        Commands::Completions { shell } => {
250            let mut cmd = Cli::command();
251            let name = cmd.get_name().to_string();
252            clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
253            Ok(())
254        }
255    }
256}
257
258fn cmd_bake(input: PathBuf, frames: usize, dt: Option<f64>, output: PathBuf) -> Result<()> {
259    let file = load_file(&input).with_context(|| format!("failed to load {}", input.display()))?;
260    let mut player = Player::new(file)
261        .with_context(|| format!("failed to open player for {}", input.display()))?;
262    let dt = dt.unwrap_or_else(|| default_dt_for(&player));
263    let scenes = player.bake(frames, dt);
264    let json = serde_json::to_string_pretty(&scenes)?;
265    atomic_write(&output, json.as_bytes())?;
266    println!("Baked {frames} frames -> {}", output.display());
267    Ok(())
268}
269
270#[allow(clippy::too_many_arguments)]
271fn cmd_render(
272    input: PathBuf,
273    frame: Option<i64>,
274    frames: Option<usize>,
275    dt: Option<f64>,
276    width: u32,
277    height: u32,
278    out: Option<PathBuf>,
279    out_dir: Option<PathBuf>,
280    prefix: String,
281    background: String,
282) -> Result<()> {
283    if frame.is_none() && frames.is_none() {
284        bail!("specify either --frame N or --frames N");
285    }
286
287    let bg = parse_background(&background)?;
288    let mut player = Player::new(load_file(&input)?)
289        .with_context(|| format!("failed to open player for {}", input.display()))?;
290    let comp_size = player.project.document.compositions[player.project.document.main].size;
291    let view = export_view(comp_size, width, height);
292    let bg_clear = bg.map(|[r, g, b, a]| {
293        [
294            r as f64 / 255.0,
295            g as f64 / 255.0,
296            b as f64 / 255.0,
297            a as f64 / 255.0,
298        ]
299    });
300
301    let mut bridge = SceneRenderer::new();
302    let mut gpu = pollster::block_on(OffscreenRenderer::new(width, height, 4))?;
303    gpu.sync_document_images(&player.project.document)?;
304
305    match (frame, frames) {
306        (Some(f), None) => {
307            player.scrub(f as f64);
308            let png = rasterize_png(&mut bridge, &mut gpu, player.scene(), &view, bg_clear)?;
309            let out = out.ok_or_else(|| anyhow!("--out is required with --frame"))?;
310            atomic_write(&out, &png)?;
311            println!("Rendered frame {f} -> {}", out.display());
312            Ok(())
313        }
314        (None, Some(n)) => {
315            let out_dir = out_dir.ok_or_else(|| anyhow!("--out-dir is required with --frames"))?;
316            std::fs::create_dir_all(&out_dir)?;
317            let dt = dt.unwrap_or_else(|| default_dt_for(&player));
318            let scenes = player.bake(n, dt);
319            for (i, scene) in scenes.iter().enumerate() {
320                let png = rasterize_png(&mut bridge, &mut gpu, scene, &view, bg_clear)?;
321                let path = out_dir.join(format!("{prefix}_{i:05}.png"));
322                atomic_write(&path, &png)?;
323            }
324            println!("Rendered {n} frames -> {}", out_dir.display());
325            Ok(())
326        }
327        (None, None) => unreachable!("guard at top of cmd_render"),
328        (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
329    }
330}
331
332/// World -> pixel "contain" fit for the main composition.
333fn export_view(comp_size: (u32, u32), out_w: u32, out_h: u32) -> ViewTransform {
334    let (cw, ch) = (comp_size.0 as f64, comp_size.1 as f64);
335    if cw <= 0.0 || ch <= 0.0 || out_w == 0 || out_h == 0 {
336        return ViewTransform::identity();
337    }
338    let scale = (out_w as f64 / cw).min(out_h as f64 / ch);
339    let ox = (out_w as f64 - cw * scale) * 0.5;
340    let oy = (out_h as f64 - ch * scale) * 0.5;
341    ViewTransform {
342        scale,
343        offset: glam::DVec2::new(ox, oy),
344    }
345}
346
347fn rasterize_png(
348    bridge: &mut SceneRenderer,
349    gpu: &mut OffscreenRenderer,
350    scene: &renamite_model::Scene,
351    view: &ViewTransform,
352    bg: Option<[f64; 4]>,
353) -> Result<Vec<u8>> {
354    let prepared = bridge.prepare(scene, view);
355    let mut repose = repose_core::Scene::default();
356    bridge.append_repose_scene(&prepared, &mut repose);
357    gpu.render_png(&repose, bg)
358}
359
360fn parse_background(s: &str) -> Result<Option<[u8; 4]>> {
361    match s {
362        "transparent" | "none" => Ok(None),
363        "white" => Ok(Some([255, 255, 255, 255])),
364        "black" => Ok(Some([0, 0, 0, 255])),
365        hex => {
366            let hex = hex.trim_start_matches('#');
367            let bytes = match hex.len() {
368                6 => [
369                    u8::from_str_radix(&hex[0..2], 16)?,
370                    u8::from_str_radix(&hex[2..4], 16)?,
371                    u8::from_str_radix(&hex[4..6], 16)?,
372                    255,
373                ],
374                8 => [
375                    u8::from_str_radix(&hex[0..2], 16)?,
376                    u8::from_str_radix(&hex[2..4], 16)?,
377                    u8::from_str_radix(&hex[4..6], 16)?,
378                    u8::from_str_radix(&hex[6..8], 16)?,
379                ],
380                _ => bail!(
381                    "invalid background '{s}': expected 'transparent', 'white', 'black', or hex RRGGBB[AA]"
382                ),
383            };
384            Ok(Some(bytes))
385        }
386    }
387}
388
389fn cmd_pack(input: PathBuf, output: PathBuf) -> Result<()> {
390    let text = std::fs::read_to_string(&input)?;
391    let mut file: RenFile = renamite_io_ren::open(&text)?;
392    file.normalize();
393    atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
394    println!("Packed {} -> {}", input.display(), output.display());
395    Ok(())
396}
397
398fn cmd_unpack(input: PathBuf, output: PathBuf) -> Result<()> {
399    let bytes = std::fs::read(&input)?;
400    let mut file: RenFile = renamite_io_ren::open_binary(&bytes)?;
401    file.normalize();
402    atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
403    println!("Unpacked {} -> {}", input.display(), output.display());
404    Ok(())
405}
406
407#[derive(Serialize)]
408struct InfoSummary {
409    path: String,
410    name: String,
411    format_version: u32,
412    compositions: usize,
413    nodes: usize,
414    clips: usize,
415    machines: usize,
416    start_machine: Option<String>,
417    main: MainCompInfo,
418}
419
420#[derive(Serialize)]
421struct MainCompInfo {
422    name: String,
423    width: u32,
424    height: u32,
425    fps: f64,
426    in_frame: i64,
427    out_frame: i64,
428}
429
430fn cmd_info(input: PathBuf, json: bool) -> Result<()> {
431    let file = load_file(&input)?;
432    let comp = &file.document.compositions[file.document.main];
433    let summary = InfoSummary {
434        path: input.display().to_string(),
435        name: file.meta.name.clone(),
436        format_version: file.format_version,
437        compositions: file.document.compositions.len(),
438        nodes: file.document.nodes.len(),
439        clips: file.clips.len(),
440        machines: file.machines.len(),
441        start_machine: file
442            .start_machine
443            .and_then(|id| file.machines.get(id))
444            .map(|m| m.name.clone()),
445        main: MainCompInfo {
446            name: comp.name.clone(),
447            width: comp.size.0,
448            height: comp.size.1,
449            fps: comp.rate.fps(),
450            in_frame: comp.range.0.0,
451            out_frame: comp.range.1.0,
452        },
453    };
454
455    if json {
456        println!("{}", serde_json::to_string_pretty(&summary)?);
457        return Ok(());
458    }
459
460    println!("File:           {}", summary.path);
461    println!("Name:           {}", summary.name);
462    println!("Format:         v{}", summary.format_version);
463    println!("Compositions:   {}", summary.compositions);
464    println!("Nodes:          {}", summary.nodes);
465    println!("Clips:          {}", summary.clips);
466    println!("Machines:       {}", summary.machines);
467    if let Some(name) = &summary.start_machine {
468        println!("Start machine:  {name}");
469    }
470    println!("\nMain composition:");
471    println!("  Name:  {}", summary.main.name);
472    println!("  Size:  {}x{}", summary.main.width, summary.main.height);
473    println!("  Rate:  {:.2} fps", summary.main.fps);
474    println!(
475        "  Range: {} - {}",
476        summary.main.in_frame, summary.main.out_frame
477    );
478    Ok(())
479}
480
481fn cmd_validate(
482    input: PathBuf,
483    fix: bool,
484    deep: bool,
485    json: bool,
486    warnings_as_errors: bool,
487) -> Result<()> {
488    let mut file = load_file(&input)?;
489    let before_json = serde_json::to_string(&file)?;
490    file.normalize();
491    file.garbage_collect();
492
493    if deep {
494        let report = renamite_validate::validate(&file);
495
496        if json {
497            println!("{}", serde_json::to_string_pretty(&report)?);
498        } else {
499            for d in &report.diagnostics {
500                println!("{:?}: {}: {}", d.severity, d.path, d.message);
501            }
502            println!(
503                "{} error(s), {} warning(s)",
504                report.error_count(),
505                report.warning_count()
506            );
507        }
508
509        if report.has_errors() || (warnings_as_errors && report.warning_count() > 0) {
510            bail!(
511                "validation failed: {} error(s), {} warning(s)",
512                report.error_count(),
513                report.warning_count()
514            );
515        }
516        return Ok(());
517    }
518
519    if fix {
520        let bytes = save_for_output(&file, &input)?;
521        atomic_write(&input, &bytes)?;
522        println!("Normalized and saved {}", input.display());
523    } else if serde_json::to_string(&file)? == before_json {
524        println!("{} is valid", input.display());
525    } else {
526        bail!("{} needs normalization (use --fix)", input.display());
527    }
528    Ok(())
529}
530
531fn cmd_diff(a: PathBuf, b: PathBuf, fail_on_diff: bool) -> Result<()> {
532    let fa = load_file(&a)?;
533    let fb = load_file(&b)?;
534    let va = serde_json::to_value(&fa)?;
535    let vb = serde_json::to_value(&fb)?;
536
537    let mut diffs = Vec::new();
538    diff_values("", &va, &vb, &mut diffs);
539
540    if diffs.is_empty() {
541        println!("No structural differences.");
542    } else {
543        println!("{} difference(s):", diffs.len());
544        for d in &diffs {
545            println!("  {d}");
546        }
547        if fail_on_diff {
548            bail!(
549                "structural differences found: {} difference(s)",
550                diffs.len()
551            );
552        }
553    }
554    Ok(())
555}
556
557/// Minimal recursive structural diff. Object keys are compared by name;
558/// arrays of differing length are reported wholesale (no element alignment).
559fn diff_values(path: &str, a: &Value, b: &Value, out: &mut Vec<String>) {
560    match (a, b) {
561        (Value::Object(ma), Value::Object(mb)) => {
562            let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
563            keys.sort();
564            keys.dedup();
565            for k in keys {
566                let sub = if path.is_empty() {
567                    k.clone()
568                } else {
569                    format!("{path}.{k}")
570                };
571                match (ma.get(k), mb.get(k)) {
572                    (Some(av), Some(bv)) => diff_values(&sub, av, bv, out),
573                    (Some(_), None) => out.push(format!("- {sub} (removed)")),
574                    (None, Some(_)) => out.push(format!("+ {sub} (added)")),
575                    (None, None) => unreachable!(),
576                }
577            }
578        }
579        (Value::Array(aa), Value::Array(ba)) => {
580            if aa.len() != ba.len() {
581                out.push(format!("~ {path} (array len {} -> {})", aa.len(), ba.len()));
582            } else {
583                for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
584                    diff_values(&format!("{path}[{i}]"), av, bv, out);
585                }
586            }
587        }
588        _ => {
589            if a != b {
590                out.push(format!("~ {path}: {a} -> {b}"));
591            }
592        }
593    }
594}
595
596fn cmd_new(output: PathBuf, template: String) -> Result<()> {
597    let name = name_from_path(&output);
598    let mut file = match template.as_str() {
599        // Legacy alias predating the renamite-examples template set.
600        "ellipse" => scaffold_ellipse(name.clone()),
601        other => match renamite_examples::parse_template(other) {
602            Some(id) => renamite_examples::build_template(id),
603            None => {
604                let known: Vec<&str> = std::iter::once("ellipse")
605                    .chain(renamite_examples::templates().iter().map(|t| t.id.slug()))
606                    .collect();
607                bail!(
608                    "unknown template '{other}' (expected one of: {})",
609                    known.join(", ")
610                )
611            }
612        },
613    };
614    file.meta.name = name;
615
616    let bytes = save_for_output(&file, &output)?;
617    atomic_write(&output, &bytes)?;
618    println!("Created {}", output.display());
619    Ok(())
620}
621
622fn cmd_templates() -> Result<()> {
623    println!("{}", templates_text());
624    Ok(())
625}
626
627fn templates_text() -> String {
628    let mut out =
629        String::from("Available templates (use with `renamite new --template <slug>`):\n");
630    for t in renamite_examples::templates() {
631        out.push_str(&format!("  {:<18} {}\n", t.id.slug(), t.description));
632    }
633    out
634}
635
636fn scaffold_ellipse(name: String) -> RenFile {
637    use renamite_animation::Animated;
638    use renamite_model::{
639        Color, Document, FillRule, Node, NodeKind, Parent, ShapeKind, StyleKind, StylePaint,
640    };
641
642    let mut doc = Document::empty();
643    let comp = doc.main;
644    let (w, h) = doc.compositions[comp].size;
645    let center = glam::DVec2::new(w as f64 / 2.0, h as f64 / 2.0);
646
647    let shape = doc.create_node(Node::new(
648        "Ellipse",
649        NodeKind::Shape(ShapeKind::Ellipse {
650            pos: Animated::new(center),
651            size: Animated::new(glam::DVec2::new(180.0, 180.0)),
652        }),
653    ));
654    let fill = doc.create_node(Node::new(
655        "Fill",
656        NodeKind::Style(StyleKind::Fill {
657            paint: StylePaint::solid(Color::rgba(0.96, 0.42, 0.18, 1.0)),
658            rule: FillRule::NonZero,
659        }),
660    ));
661    doc.attach(shape, Parent::Comp(comp), 0).unwrap();
662    doc.attach(fill, Parent::Comp(comp), 1).unwrap();
663
664    RenFile::new(doc, name)
665}
666
667fn name_from_path(p: &Path) -> String {
668    p.file_stem()
669        .and_then(|s| s.to_str())
670        .unwrap_or("Untitled")
671        .to_string()
672}
673
674/// Seconds per frame derived from the composition rate.
675/// Falls back to 1/60 when the rate is zero, non-finite, or unavailable.
676fn default_dt_for(player: &Player) -> f64 {
677    let fps = player.rate().fps();
678    if fps > 0.0 && fps.is_finite() {
679        1.0 / fps
680    } else {
681        1.0 / 60.0
682    }
683}
684
685fn cmd_play(input: PathBuf, duration: f64) -> Result<()> {
686    let mut player = Player::new(load_file(&input)?)?;
687    let dt = default_dt_for(&player);
688    let ticks = (duration / dt) as usize;
689
690    println!("Playing {} for {duration:.1}s...", input.display());
691    for _ in 0..ticks {
692        for ev in player.tick(dt) {
693            println!("  {ev}");
694        }
695    }
696    println!("Done. Final head: {:.2}", player.head());
697    Ok(())
698}
699
700fn cmd_export_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
701    let file = load_file(&input)?;
702    let report = renamite_io_lottie::export_project_with_report(
703        &file.document,
704        file.clip_order.len(),
705        file.machine_order.len(),
706        file.start_machine.is_some(),
707    )?;
708    if strict && !report.warnings.is_empty() {
709        for warning in &report.warnings {
710            eprintln!("warning at {}: {}", warning.path, warning.message);
711        }
712        bail!(
713            "Lottie export produced {} compatibility warning(s)",
714            report.warnings.len()
715        );
716    }
717    for warning in &report.warnings {
718        eprintln!("warning at {}: {}", warning.path, warning.message);
719    }
720    atomic_write(&output, &serde_json::to_vec_pretty(&report.value)?)?;
721    println!("Exported {} -> {}", input.display(), output.display());
722    Ok(())
723}
724
725fn cmd_import_lottie(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
726    let value: Value = serde_json::from_slice(&std::fs::read(&input)?)?;
727    let report = renamite_io_lottie::import_with_report(&value)?;
728    if strict && !report.warnings.is_empty() {
729        for warning in &report.warnings {
730            eprintln!("warning at {}: {}", warning.path, warning.message);
731        }
732        bail!(
733            "Lottie import produced {} compatibility warning(s)",
734            report.warnings.len()
735        );
736    }
737    for warning in &report.warnings {
738        eprintln!("warning at {}: {}", warning.path, warning.message);
739    }
740    let file = RenFile::new(report.value, name_from_path(&input));
741    match output.extension().and_then(|extension| extension.to_str()) {
742        Some("renb") => {
743            atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
744        }
745        _ => {
746            atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
747        }
748    }
749    println!("Imported {} -> {}", input.display(), output.display());
750    Ok(())
751}
752
753fn cmd_export_svg(input: PathBuf, output: PathBuf, frame: f64, strict: bool) -> Result<()> {
754    let file = load_file(&input)?;
755    let report = renamite_io_svg::export_project_with_report(
756        &file.document,
757        file.document.main,
758        frame,
759        file.clip_order.len(),
760        file.machine_order.len(),
761        file.start_machine.is_some(),
762    )?;
763    if strict && !report.warnings.is_empty() {
764        for warning in &report.warnings {
765            eprintln!("warning at {}: {}", warning.path, warning.message);
766        }
767        bail!(
768            "SVG export produced {} compatibility warning(s)",
769            report.warnings.len()
770        );
771    }
772    for warning in &report.warnings {
773        eprintln!("warning at {}: {}", warning.path, warning.message);
774    }
775    atomic_write(&output, report.value.as_bytes())?;
776    println!(
777        "Exported {} frame {frame} -> {}",
778        input.display(),
779        output.display()
780    );
781    Ok(())
782}
783
784fn cmd_import_svg(input: PathBuf, output: PathBuf, strict: bool) -> Result<()> {
785    let bytes = std::fs::read(&input)?;
786    let report = renamite_io_svg::import_with_report(&bytes)?;
787    if strict && !report.warnings.is_empty() {
788        for warning in &report.warnings {
789            eprintln!("warning at {}: {}", warning.path, warning.message);
790        }
791        bail!(
792            "SVG import produced {} compatibility warning(s)",
793            report.warnings.len()
794        );
795    }
796    for warning in &report.warnings {
797        eprintln!("warning at {}: {}", warning.path, warning.message);
798    }
799    let file = RenFile::new(report.value, name_from_path(&input));
800    match output.extension().and_then(|extension| extension.to_str()) {
801        Some("renb") => {
802            atomic_write(&output, &renamite_io_ren::save_binary(&file)?)?;
803        }
804        _ => {
805            atomic_write(&output, renamite_io_ren::save(&file)?.as_bytes())?;
806        }
807    }
808    println!("Imported {} -> {}", input.display(), output.display());
809    Ok(())
810}
811
812fn load_file(path: &Path) -> Result<RenFile> {
813    let bytes =
814        std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
815
816    // Magic wins over extension so extension-less / mislabeled .renb still open.
817    if renamite_io_ren::is_binary(&bytes) {
818        return Ok(renamite_io_ren::open_binary(&bytes)?);
819    }
820
821    let text = std::str::from_utf8(&bytes)
822        .with_context(|| format!("{} is neither valid UTF-8 .ren nor .renb", path.display()))?;
823    Ok(renamite_io_ren::open(text)?)
824}
825
826/// Atomic file write: temp + rename so a crash/power loss can't leave a
827/// truncated corrupt project (previously direct truncate+write).
828fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
829    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
830    let tmp = match parent {
831        Some(dir) => {
832            let name = format!(
833                ".{}.tmp-{}",
834                path.file_name()
835                    .and_then(|s| s.to_str())
836                    .unwrap_or("renamite"),
837                std::process::id()
838            );
839            dir.join(name)
840        }
841        None => std::env::temp_dir().join(format!(
842            ".{}.tmp-{}",
843            path.file_name()
844                .and_then(|s| s.to_str())
845                .unwrap_or("renamite"),
846            std::process::id()
847        )),
848    };
849    std::fs::write(&tmp, bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
850    std::fs::rename(&tmp, path).with_context(|| {
851        let _ = std::fs::remove_file(&tmp);
852        format!(
853            "failed to replace {} (temp {})",
854            path.display(),
855            tmp.display()
856        )
857    })?;
858    Ok(())
859}
860
861/// Serialize a project matching the output extension (.renb = binary).
862fn save_for_output(file: &RenFile, output: &Path) -> Result<Vec<u8>> {
863    match output.extension().and_then(|s| s.to_str()) {
864        Some("renb") => Ok(renamite_io_ren::save_binary(file)?),
865        _ => Ok(renamite_io_ren::save(file)?.into_bytes()),
866    }
867}