use clap::{Parser, ValueEnum};
use orber::animate::MotionPreset;
use orber::cluster::extract_clusters;
use orber::orb::{render_static, RenderOptions};
use orber::output_mode::OutputMode;
use orber::style::{render_css, render_svg, StyleOptions};
use orber::video::{render_video, VideoCodec, VideoOptions};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Motion {
Still,
Slow,
Lively,
}
impl From<Motion> for MotionPreset {
fn from(m: Motion) -> Self {
match m {
Motion::Still => MotionPreset::Still,
Motion::Slow => MotionPreset::Slow,
Motion::Lively => MotionPreset::Lively,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Shape {
Circle,
Aquarelle,
}
#[derive(Debug, Parser)]
#[command(name = "orber")]
#[command(version)]
#[command(about = "Turn photos and videos into abstract orb mood output")]
struct Cli {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
seed: Option<u64>,
#[arg(long, default_value_t = 1.0)]
orb_size: f32,
#[arg(long, default_value_t = 0.5)]
blur: f32,
#[arg(long, value_enum, default_value_t = Motion::Slow)]
motion: Motion,
#[arg(long, value_enum, default_value_t = Shape::Circle)]
shape: Shape,
#[arg(long, default_value_t = 1.0)]
saturation: f32,
#[arg(long, default_value_t = 5000)]
duration_ms: u64,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let mode = match OutputMode::from_path(&cli.output) {
Ok(m) => m,
Err(e) => {
eprintln!("orber: {e}");
return ExitCode::from(2);
}
};
if let Some(codec) = VideoCodec::from_output_mode(mode) {
return render_video_path(&cli, codec);
}
match mode {
OutputMode::Png => render_png(&cli),
OutputMode::Svg | OutputMode::Css => render_style_path(&cli, mode),
_ => {
eprintln!("orber: output mode {mode:?} is not yet implemented");
ExitCode::from(1)
}
}
}
fn render_style_path(cli: &Cli, mode: OutputMode) -> ExitCode {
let img = match image::open(&cli.input) {
Ok(img) => img.to_rgb8(),
Err(e) => {
eprintln!("orber: failed to read input {}: {e}", cli.input.display());
return ExitCode::from(2);
}
};
let clusters = match extract_clusters(&img, 6) {
Ok(c) => c,
Err(e) => {
eprintln!("orber: cluster extraction failed: {e}");
return ExitCode::from(2);
}
};
let opts = StyleOptions {
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
};
let content = match mode {
OutputMode::Svg => render_svg(&clusters, &opts),
OutputMode::Css => render_css(&clusters, &opts),
_ => unreachable!("render_style_path called with non-style mode {mode:?}"),
};
if let Err(e) = std::fs::write(&cli.output, content) {
eprintln!(
"orber: failed to write output {}: {e}",
cli.output.display()
);
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", cli.output.display());
ExitCode::SUCCESS
}
fn render_video_path(cli: &Cli, codec: VideoCodec) -> ExitCode {
let img = match image::open(&cli.input) {
Ok(img) => img.to_rgb8(),
Err(e) => {
eprintln!("orber: failed to read input {}: {e}", cli.input.display());
return ExitCode::from(2);
}
};
let clusters = match extract_clusters(&img, 6) {
Ok(c) => c,
Err(e) => {
eprintln!("orber: cluster extraction failed: {e}");
return ExitCode::from(2);
}
};
let opts = VideoOptions {
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
motion: cli.motion.into(),
seed: cli.seed.unwrap_or(0),
};
if let Err(e) = render_video(&clusters, &opts, &cli.output, cli.duration_ms, codec) {
eprintln!("orber: video render failed: {e}");
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", cli.output.display());
ExitCode::SUCCESS
}
fn render_png(cli: &Cli) -> ExitCode {
let img = match image::open(&cli.input) {
Ok(img) => img.to_rgb8(),
Err(e) => {
eprintln!("orber: failed to read input {}: {e}", cli.input.display());
return ExitCode::from(2);
}
};
let clusters = match extract_clusters(&img, 6) {
Ok(c) => c,
Err(e) => {
eprintln!("orber: cluster extraction failed: {e}");
return ExitCode::from(2);
}
};
let opts = RenderOptions {
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
..RenderOptions::default()
};
let out = render_static(&clusters, &opts);
if let Err(e) = out.save(&cli.output) {
eprintln!(
"orber: failed to write output {}: {e}",
cli.output.display()
);
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", cli.output.display());
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
use orber::animate::AnimateOptions;
use orber::video::MAX_DURATION_MS;
#[test]
fn cli_defaults_match_render_options_defaults() {
let cli = Cli::parse_from(["orber", "--input", "x", "--output", "x.png"]);
let defaults = RenderOptions::default();
assert_eq!(cli.orb_size, defaults.orb_size, "orb_size default mismatch");
assert_eq!(cli.blur, defaults.blur, "blur default mismatch");
assert_eq!(
cli.saturation, defaults.saturation,
"saturation default mismatch"
);
}
#[test]
fn cli_defaults_match_animate_options_defaults() {
let cli = Cli::parse_from(["orber", "--input", "x", "--output", "x.mp4"]);
let a = AnimateOptions::default();
let motion: MotionPreset = cli.motion.into();
assert_eq!(motion, a.motion, "motion default mismatch");
assert_eq!(cli.orb_size, a.orb_size, "orb_size default mismatch");
assert_eq!(cli.blur, a.blur, "blur default mismatch");
assert_eq!(cli.saturation, a.saturation, "saturation default mismatch");
assert!(cli.duration_ms > 0, "duration_ms default must be > 0");
assert!(
cli.duration_ms <= MAX_DURATION_MS,
"duration_ms default must be <= MAX_DURATION_MS, got {}",
cli.duration_ms
);
}
}