use clap::{Parser, ValueEnum};
use orber::animate::{MotionDirection, MotionSpeed};
use orber::aquarelle::AquarelleParams;
use orber::cluster::{derive_background_rgba, drop_dominant, extract_clusters, Cluster};
use orber::orb::{OrbShape, RenderOptions};
use orber::output_mode::OutputMode;
use orber::style::{render_css, render_svg, StyleOptions};
use orber::variations::{select_specs, VariationKind, VariationMode, VariationSpec};
use orber::video::{render_video, VideoCodec, VideoOptions};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliDirection {
Lr,
Rl,
Tb,
Bt,
}
impl From<CliDirection> for MotionDirection {
fn from(d: CliDirection) -> Self {
match d {
CliDirection::Lr => MotionDirection::LeftToRight,
CliDirection::Rl => MotionDirection::RightToLeft,
CliDirection::Tb => MotionDirection::TopToBottom,
CliDirection::Bt => MotionDirection::BottomToTop,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliSpeed {
VerySlow,
Slow,
}
impl From<CliSpeed> for MotionSpeed {
fn from(s: CliSpeed) -> Self {
match s {
CliSpeed::VerySlow => MotionSpeed::VerySlow,
CliSpeed::Slow => MotionSpeed::Slow,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliVariationMode {
Still,
Video,
Mixed,
}
impl From<CliVariationMode> for VariationMode {
fn from(m: CliVariationMode) -> Self {
match m {
CliVariationMode::Still => VariationMode::Still,
CliVariationMode::Video => VariationMode::Video,
CliVariationMode::Mixed => VariationMode::Mixed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Shape {
Circle,
Aquarelle,
}
impl Shape {
fn to_orb_shape(self, params: AquarelleParams) -> OrbShape {
match self {
Shape::Circle => OrbShape::Circle,
Shape::Aquarelle => OrbShape::Aquarelle(params),
}
}
}
fn parse_f32_in_range(min: f32, max: f32) -> impl Fn(&str) -> Result<f32, String> + Clone {
move |s: &str| {
let v: f32 = s
.parse()
.map_err(|e: std::num::ParseFloatError| e.to_string())?;
if !v.is_finite() {
return Err(format!("must be a finite number (not NaN/inf), got {v}"));
}
if v < min || v > max {
return Err(format!("must be in {min}..={max}, got {v}"));
}
Ok(v)
}
}
fn parse_orb_size(s: &str) -> Result<f32, String> {
parse_f32_in_range(0.0, 10.0)(s)
}
fn parse_variations_n(s: &str) -> Result<usize, String> {
let v: usize = s
.parse()
.map_err(|e: std::num::ParseIntError| e.to_string())?;
if v == 0 {
return Err("must be >= 1 (use --variations N with N >= 1)".to_string());
}
Ok(v)
}
fn parse_count(s: &str) -> Result<usize, String> {
let v: usize = s
.parse()
.map_err(|e: std::num::ParseIntError| e.to_string())?;
if !(1..=1024).contains(&v) {
return Err(format!("must be in 1..=1024, got {v}"));
}
Ok(v)
}
fn parse_unit_interval(s: &str) -> Result<f32, String> {
parse_f32_in_range(0.0, 1.0)(s)
}
fn parse_saturation(s: &str) -> Result<f32, String> {
parse_f32_in_range(0.0, 4.0)(s)
}
#[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: Option<PathBuf>,
#[arg(long, value_parser = parse_variations_n)]
variations: Option<usize>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = CliVariationMode::Mixed)]
variations_mode: CliVariationMode,
#[arg(long, default_value_t = 0)]
seed: u64,
#[arg(long, default_value_t = 1.0, value_parser = parse_orb_size)]
orb_size: f32,
#[arg(long, default_value_t = 0.5, value_parser = parse_unit_interval)]
blur: f32,
#[arg(long, value_enum, default_value_t = CliDirection::Lr)]
direction: CliDirection,
#[arg(long, value_enum, default_value_t = CliSpeed::Slow)]
speed: CliSpeed,
#[arg(long, default_value_t = 20, value_parser = parse_count)]
count: usize,
#[arg(long, value_enum, default_value_t = Shape::Circle)]
shape: Shape,
#[arg(long, default_value_t = 1.0, value_parser = parse_saturation)]
saturation: f32,
#[arg(long, default_value_t = 5000, value_parser = clap::value_parser!(u64).range(1000..=600_000))]
duration_ms: u64,
#[arg(long, default_value_t = 0.5, value_parser = parse_unit_interval)]
aquarelle_bleed: f32,
#[arg(long, default_value_t = 0.5, value_parser = parse_unit_interval)]
aquarelle_bloom: f32,
#[arg(long, default_value_t = 0.5, value_parser = parse_unit_interval)]
aquarelle_offset: f32,
#[arg(long, default_value_t = 0.5, value_parser = parse_unit_interval)]
aquarelle_halo: f32,
}
impl Cli {
fn aquarelle_params(&self) -> AquarelleParams {
AquarelleParams {
bleed: self.aquarelle_bleed,
bloom: self.aquarelle_bloom,
offset: self.aquarelle_offset,
halo: self.aquarelle_halo,
}
}
fn orb_shape(&self) -> OrbShape {
self.shape.to_orb_shape(self.aquarelle_params())
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(n) = cli.variations {
return render_variations(&cli, n);
}
let output = match &cli.output {
Some(p) => p.clone(),
None => {
eprintln!("orber: either --output FILE or --variations N --output-dir DIR is required");
return ExitCode::from(2);
}
};
let mode = match OutputMode::from_path(&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, &output, codec);
}
match mode {
OutputMode::Png => render_png(&cli, &output),
OutputMode::Svg | OutputMode::Css => render_style_path(&cli, &output, mode),
_ => {
eprintln!("orber: output mode {mode:?} is not yet implemented");
ExitCode::from(1)
}
}
}
fn resolve_motion(cli: &Cli) -> (MotionDirection, MotionSpeed) {
(cli.direction.into(), cli.speed.into())
}
fn warn_if_orb_pool_empty(orb_clusters: &[Cluster]) {
if orb_clusters.is_empty() {
eprintln!(
"orber: warning: input image yielded only 1 cluster; orb pool is empty (output will be background only)"
);
}
}
fn warn_if_aquarelle_count_ignored(cli: &Cli) {
if matches!(cli.shape, Shape::Aquarelle) {
eprintln!(
"orber: warning: aquarelle shape ignores --count (rendering one orb per k-means cluster from the palette)"
);
}
}
fn render_style_path(cli: &Cli, output: &Path, 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 background = derive_background_rgba(&clusters);
let orb_clusters = drop_dominant(&clusters);
warn_if_orb_pool_empty(&orb_clusters);
let opts = StyleOptions {
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
background,
};
let content = match mode {
OutputMode::Svg => render_svg(&orb_clusters, &opts),
OutputMode::Css => render_css(&orb_clusters, &opts),
_ => unreachable!("render_style_path called with non-style mode {mode:?}"),
};
if let Err(e) = std::fs::write(output, content) {
eprintln!("orber: failed to write output {}: {e}", output.display());
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", output.display());
ExitCode::SUCCESS
}
fn render_video_path(cli: &Cli, output: &Path, 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 background = derive_background_rgba(&clusters);
let orb_clusters = drop_dominant(&clusters);
warn_if_orb_pool_empty(&orb_clusters);
warn_if_aquarelle_count_ignored(cli);
let (direction, speed) = resolve_motion(cli);
let opts = VideoOptions {
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
direction,
speed,
seed: cli.seed,
count: Some(cli.count),
background,
shape: cli.orb_shape(),
};
if let Err(e) = render_video(&orb_clusters, &opts, output, cli.duration_ms, codec) {
eprintln!("orber: video render failed: {e}");
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", output.display());
ExitCode::SUCCESS
}
fn render_png(cli: &Cli, output: &Path) -> 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 background = derive_background_rgba(&clusters);
let orb_clusters = drop_dominant(&clusters);
warn_if_orb_pool_empty(&orb_clusters);
warn_if_aquarelle_count_ignored(cli);
let (direction, speed) = resolve_motion(cli);
let frame_opts = orber::animate::AnimateOptions {
width: RenderOptions::default().width,
height: RenderOptions::default().height,
orb_size: cli.orb_size,
blur: cli.blur,
saturation: cli.saturation,
direction,
speed,
seed: cli.seed,
count: Some(cli.count),
background,
shape: cli.orb_shape(),
};
let out = orber::animate::render_frame(&orb_clusters, &frame_opts, 0.0);
if let Err(e) = out.save(output) {
eprintln!("orber: failed to write output {}: {e}", output.display());
return ExitCode::from(2);
}
eprintln!("orber: wrote {}", output.display());
ExitCode::SUCCESS
}
fn render_variations(cli: &Cli, n: usize) -> ExitCode {
let dir = match &cli.output_dir {
Some(d) => d.clone(),
None => {
eprintln!("orber: --variations requires --output-dir DIR");
return ExitCode::from(2);
}
};
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("orber: failed to create output dir {}: {e}", dir.display());
return ExitCode::from(2);
}
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 specs = select_specs(n, cli.variations_mode.into());
if specs.is_empty() {
eprintln!(
"orber: no variations matched (requested n={n}, mode={:?})",
cli.variations_mode
);
return ExitCode::from(2);
}
let total = specs.len();
if total < n {
eprintln!(
"orber: only {total} variation(s) available for mode {:?} (requested {n})",
cli.variations_mode
);
}
let orb_shape = cli.orb_shape();
let base_clusters = match extract_clusters(&img, VARIATIONS_KMEANS_K) {
Ok(c) => c,
Err(e) => {
eprintln!("orber: cluster extraction failed: {e}");
return ExitCode::from(2);
}
};
let background = derive_background_rgba(&base_clusters);
let orb_clusters = drop_dominant(&base_clusters);
warn_if_orb_pool_empty(&orb_clusters);
warn_if_aquarelle_count_ignored(cli);
for (i, spec) in specs.iter().enumerate() {
let idx = i + 1;
let filename = format!("{idx:02}_{}.{}", spec.label, spec.kind.ext());
let out_path = dir.join(&filename);
eprintln!("orber: variation {idx}/{total} ({filename})");
let result = render_one_variation(&orb_clusters, spec, &out_path, background, orb_shape);
if let Err(msg) = result {
eprintln!("orber: variation {idx} ({filename}) failed: {msg}");
return ExitCode::from(2);
}
}
ExitCode::SUCCESS
}
const VARIATIONS_KMEANS_K: usize = 5;
fn render_one_variation(
clusters: &[Cluster],
spec: &VariationSpec,
out_path: &std::path::Path,
bg_rgba: [u8; 4],
orb_shape: OrbShape,
) -> Result<(), String> {
match spec.kind {
VariationKind::Png => {
let frame_opts = orber::animate::AnimateOptions {
width: RenderOptions::default().width,
height: RenderOptions::default().height,
orb_size: spec.orb_size,
blur: spec.blur,
saturation: 1.0,
direction: spec.direction,
speed: spec.speed,
seed: spec.seed,
count: Some(spec.count),
background: bg_rgba,
shape: orb_shape,
};
let img = orber::animate::render_frame(clusters, &frame_opts, 0.0);
img.save(out_path).map_err(|e| e.to_string())
}
VariationKind::Mp4 => {
let opts = VideoOptions {
orb_size: spec.orb_size,
blur: spec.blur,
saturation: 1.0,
direction: spec.direction,
speed: spec.speed,
seed: spec.seed,
count: Some(spec.count),
background: bg_rgba,
shape: orb_shape,
};
render_video(
clusters,
&opts,
out_path,
spec.duration_ms,
VideoCodec::H264,
)
.map_err(|e| e.to_string())
}
}
}
#[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 (direction, speed) = resolve_motion(&cli);
assert_eq!(direction, a.direction, "direction default mismatch");
assert_eq!(speed, a.speed, "speed 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
);
}
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
let mut full = vec!["orber", "--input", "x", "--output", "x.png"];
full.extend(args);
Cli::try_parse_from(full)
}
#[test]
fn parse_f32_in_range_helper() {
let p = parse_f32_in_range(0.0, 1.0);
assert_eq!(p("0.0").unwrap(), 0.0);
assert_eq!(p("1.0").unwrap(), 1.0);
assert!(p("1.5").is_err(), "above max should error");
assert!(p("-0.1").is_err(), "below min should error");
assert!(p("NaN").is_err(), "NaN should error");
assert!(p("inf").is_err(), "inf should error");
assert!(p("xyz").is_err(), "non-numeric should error");
}
#[test]
fn blur_out_of_range_rejected() {
assert!(try_parse(&["--blur", "1.5"]).is_err());
assert!(try_parse(&["--blur", "-0.1"]).is_err());
assert!(try_parse(&["--blur", "NaN"]).is_err());
assert!(try_parse(&["--blur", "0.5"]).is_ok());
}
#[test]
fn orb_size_out_of_range_rejected() {
assert!(try_parse(&["--orb-size", "20.0"]).is_err());
assert!(try_parse(&["--orb-size", "-1.0"]).is_err());
assert!(try_parse(&["--orb-size", "1.5"]).is_ok());
}
#[test]
fn saturation_out_of_range_rejected() {
assert!(try_parse(&["--saturation", "5.0"]).is_err());
assert!(try_parse(&["--saturation", "-0.1"]).is_err());
assert!(try_parse(&["--saturation", "1.0"]).is_ok());
assert!(try_parse(&["--saturation", "0.0"]).is_ok());
}
#[test]
fn duration_ms_out_of_range_rejected() {
assert!(try_parse(&["--duration-ms", "999"]).is_err());
assert!(try_parse(&["--duration-ms", "600001"]).is_err());
assert!(try_parse(&["--duration-ms", "1000"]).is_ok());
assert!(try_parse(&["--duration-ms", "600000"]).is_ok());
}
#[test]
fn count_out_of_range_rejected() {
assert!(try_parse(&["--count", "0"]).is_err());
assert!(try_parse(&["--count", "1025"]).is_err());
assert!(try_parse(&["--count", "abc"]).is_err());
assert!(try_parse(&["--count", "1"]).is_ok());
assert!(try_parse(&["--count", "20"]).is_ok());
assert!(try_parse(&["--count", "1024"]).is_ok());
}
#[test]
fn count_default_is_twenty() {
let cli = Cli::parse_from(["orber", "--input", "x", "--output", "x.png"]);
assert_eq!(cli.count, 20);
}
#[test]
fn aquarelle_params_out_of_range_rejected() {
assert!(try_parse(&["--aquarelle-bleed", "1.5"]).is_err());
assert!(try_parse(&["--aquarelle-bloom", "-0.1"]).is_err());
assert!(try_parse(&["--aquarelle-offset", "0.7"]).is_ok());
assert!(try_parse(&["--aquarelle-halo", "0.0"]).is_ok());
}
}