use clap::{Args as ClapArgs, Parser, Subcommand};
use std::io::Write;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use topoglyph::atlas::atlas::{AtlasOptions, GlyphAtlas, GlyphIndex};
use topoglyph::core::clipping;
use topoglyph::core::geometry::GridOptions;
use topoglyph::core::matching::{self, MatchOptions, MatchWeights};
use topoglyph::input::adapter::{self, SmoothingOptions};
use topoglyph::output::animation::TglyphAnimation;
use topoglyph::output::encoder::{
AnsiEncoder, DebugSvgEncoder, HtmlEncoder, JsonDebugEncoder, PlainTextEncoder, TextEncoder,
};
#[cfg(feature = "video")]
use topoglyph::video::FrameRenderOptions;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Render(RenderArgs),
Atlas(AtlasArgs),
#[cfg(feature = "video")]
Video(VideoArgs),
Play(PlayArgs),
}
#[derive(ClapArgs, Debug, Clone)]
struct AtlasArgs {
#[command(subcommand)]
action: AtlasAction,
}
#[derive(Subcommand, Debug, Clone)]
enum AtlasAction {
Inspect {
#[arg(short = 'C', long, default_value = "lines")]
charset: String,
#[arg(long, default_value = "")]
custom_chars: String,
#[arg(long)]
font: Option<String>,
},
}
#[derive(ClapArgs, Debug, Clone)]
struct RenderArgs {
input: String,
#[arg(short = 'W', long, default_value_t = 160)]
width: usize,
#[arg(short = 'H', long, default_value_t = 80)]
height: usize,
#[arg(short = 'C', long, default_value = "lines")]
charset: String,
#[arg(long, default_value = "")]
custom_chars: String,
#[arg(long)]
font: Option<String>,
#[arg(long, default_value_t = false)]
no_color: bool,
#[arg(long, default_value_t = false)]
invert: bool,
#[arg(long, default_value = "text")]
output_format: String,
#[arg(long, default_value_t = 0.5)]
tolerance: f64,
#[arg(short = 'c', long, default_value_t = 1)]
chaikin_iters: usize,
#[arg(long, default_value = "line-art")]
preset: String,
#[arg(long, default_value_t = 8)]
top_k: usize,
#[arg(long, default_value_t = 3)]
relaxation_rounds: usize,
#[arg(long, default_value = "set")]
glyph_mode: String,
}
#[cfg(feature = "video")]
#[derive(ClapArgs, Debug, Clone)]
struct VideoArgs {
input: String,
#[arg(short = 'o', long)]
output: String,
#[arg(short = 'W', long, default_value_t = 120)]
width: usize,
#[arg(short = 'H', long, default_value_t = 60)]
height: usize,
#[arg(short = 'C', long, default_value = "lines")]
charset: String,
#[arg(long, default_value = "")]
custom_chars: String,
#[arg(long)]
font: Option<String>,
#[arg(long, default_value_t = 0.5)]
tolerance: f64,
#[arg(short = 'c', long, default_value_t = 1)]
chaikin_iters: usize,
#[arg(long, default_value = "line-art")]
preset: String,
#[arg(long, default_value_t = 8)]
top_k: usize,
#[arg(long, default_value_t = 3)]
relaxation_rounds: usize,
#[arg(long, default_value_t = 24.0)]
fps: f32,
#[arg(long, default_value_t = false)]
color: bool,
}
#[derive(ClapArgs, Debug, Clone)]
struct PlayArgs {
input: String,
#[arg(long, default_value_t = false)]
r#loop: bool,
#[arg(long, default_value_t = false)]
no_color: bool,
}
fn resolve_frequency_bias(glyph_mode: &str) -> Result<f32, String> {
match glyph_mode {
"set" => Ok(0.0),
"weighted" => Ok(1.0),
other => Err(format!(
"Invalid --glyph-mode '{other}': expected 'set' or 'weighted'"
)),
}
}
fn resolve_preset(name: &str) -> Result<MatchWeights, String> {
match name {
"line-art" => Ok(MatchWeights::line_art_preset()),
"han-emoji" => Ok(MatchWeights::han_emoji_preset()),
other => Err(format!(
"Invalid preset '{other}': expected 'line-art' or 'han-emoji'"
)),
}
}
fn build_atlas(
charset: &str,
custom_chars: &str,
font: &Option<String>,
) -> Result<GlyphAtlas, String> {
if charset == "lines" {
GlyphAtlas::from_text("", &AtlasOptions::default())
.map_err(|e| format!("Failed to build built-in glyph atlas: {e}"))
} else if font.is_none() && charset != "custom" {
let glyphs = match charset {
"ascii" => topoglyph::atlas::precomputed::build_ascii_glyphs(),
"blocks" => topoglyph::atlas::precomputed::build_blocks_glyphs(),
"braille" => topoglyph::atlas::precomputed::build_braille_glyphs(),
_ => return Err(format!("Invalid charset specified: '{charset}'")),
};
let index = GlyphIndex::build(&glyphs);
Ok(GlyphAtlas {
font_id: format!("precomputed_{charset}"),
glyphs,
index,
})
} else {
let chars = if charset == "custom" {
custom_chars.to_string()
} else {
GlyphAtlas::get_charset_string(charset)
.ok_or_else(|| format!("Invalid charset specified: '{charset}'"))?
.to_string()
};
let font_path = font
.clone()
.ok_or_else(|| "A --font must be provided for custom text rasterization".to_string())?;
let font_bytes = std::fs::read(&font_path)
.map_err(|e| format!("Failed to read font file '{font_path}': {e}"))?;
GlyphAtlas::from_custom_font(&chars, &font_bytes, &AtlasOptions::default())
.map_err(|e| format!("Failed to rasterize font atlas: {e}"))
}
}
fn run_render(args: RenderArgs) -> Result<Vec<u8>, String> {
let bytes =
std::fs::read(&args.input).map_err(|e| format!("Failed to read '{}': {e}", args.input))?;
let smoothing = SmoothingOptions {
tolerance: args.tolerance,
chaikin_iters: args.chaikin_iters,
};
let mut scene = adapter::raster_to_smoothed_scene(&bytes, true, &smoothing)
.map_err(|e| format!("Failed to decode image: {e}"))?;
if args.invert {
scene = adapter::invert_scene_colors(&scene);
}
let grid_opts = GridOptions {
columns: args.width,
rows: Some(args.height),
..Default::default()
};
let (out_cols, out_rows, cell_descriptors) = clipping::process_scene(&scene, &grid_opts);
let atlas = build_atlas(&args.charset, &args.custom_chars, &args.font)?;
let mut weights = resolve_preset(&args.preset)?;
weights.frequency_bias = resolve_frequency_bias(&args.glyph_mode)?;
let match_options = MatchOptions {
top_k: args.top_k,
relaxation_rounds: args.relaxation_rounds,
};
let canvas = matching::match_scene_indexed(
out_cols,
out_rows,
&cell_descriptors,
&atlas.glyphs,
Some(&atlas.index),
&weights,
&match_options,
);
match args.output_format.as_str() {
"text" => {
if args.no_color {
PlainTextEncoder::new()
.encode(&canvas)
.map_err(|e| format!("Failed to encode output: {e}"))
} else {
AnsiEncoder::new()
.encode(&canvas)
.map_err(|e| format!("Failed to encode output: {e}"))
}
}
"html" => HtmlEncoder::new()
.encode(&canvas)
.map_err(|e| format!("Failed to encode output: {e}")),
"debug-svg" => DebugSvgEncoder::default()
.encode(&canvas)
.map_err(|e| format!("Failed to encode output: {e}")),
"json" => JsonDebugEncoder::new()
.encode(&canvas)
.map_err(|e| format!("Failed to encode output: {e}")),
other => Err(format!(
"Invalid --output-format '{other}': expected 'text', 'html', 'debug-svg', or 'json'"
)),
}
}
fn run_atlas(args: AtlasArgs) -> Result<Vec<u8>, String> {
match args.action {
AtlasAction::Inspect {
charset,
custom_chars,
font,
} => {
let atlas = build_atlas(&charset, &custom_chars, &font)?;
let summary = serde_json::json!({
"font_id": atlas.font_id,
"glyph_count": atlas.glyphs.len(),
"index": {
"by_ports_bucket_count": atlas.index.by_ports.len(),
"by_density_bucket_sizes": atlas.index.by_density.iter().map(Vec::len).collect::<Vec<_>>(),
"by_cell_width_bucket_count": atlas.index.by_cell_width.len(),
},
"glyphs": atlas.glyphs.iter().map(|g| serde_json::json!({
"token": g.token,
"cell_width": g.cell_width,
"ports": format!("{:?}", g.ports),
"density": g.density,
"curvature": g.curvature,
"centroid": g.centroid,
"orientation": g.orientation,
"stroke_count": g.stroke_count,
"frequency": g.frequency,
})).collect::<Vec<_>>(),
});
serde_json::to_vec_pretty(&summary)
.map_err(|e| format!("Failed to serialize atlas summary: {e}"))
}
}
}
#[cfg(feature = "video")]
fn run_video(args: VideoArgs) -> Result<String, String> {
let atlas = build_atlas(&args.charset, &args.custom_chars, &args.font)?;
let mut weights = resolve_preset(&args.preset)?;
weights.frequency_bias = 0.0;
let options = FrameRenderOptions {
columns: args.width,
rows: args.height,
smoothing: SmoothingOptions {
tolerance: args.tolerance,
chaikin_iters: args.chaikin_iters,
},
weights,
match_options: MatchOptions {
top_k: args.top_k,
relaxation_rounds: args.relaxation_rounds,
},
sample_color: args.color,
};
let animation = topoglyph::video::convert_video_file(
std::path::Path::new(&args.input),
&atlas,
&options,
args.fps,
args.color,
)
.map_err(|e| format!("Failed to convert video: {e}"))?;
let text = animation.to_text();
std::fs::write(&args.output, &text)
.map_err(|e| format!("Failed to write '{}': {e}", args.output))?;
Ok(format!(
"Wrote {} frames ({} bytes) to {}",
animation.frames.len(),
text.len(),
args.output
))
}
fn run_play(args: PlayArgs) -> Result<(), String> {
let text = std::fs::read_to_string(&args.input)
.map_err(|e| format!("Failed to read '{}': {e}", args.input))?;
let animation =
TglyphAnimation::decode(&text).map_err(|e| format!("Failed to parse animation: {e}"))?;
if animation.frames.is_empty() {
return Ok(());
}
let frame_duration = if animation.fps > 0.0 {
Duration::from_secs_f32(1.0 / animation.fps)
} else {
Duration::from_secs_f32(1.0 / 24.0)
};
let mut stdout = std::io::stdout();
let _ = write!(stdout, "\x1b[2J\x1b[H");
loop {
for canvas in &animation.frames {
let frame_start = Instant::now();
let encoded = if args.no_color {
PlainTextEncoder::new().encode(canvas)
} else {
AnsiEncoder::new().encode(canvas)
}
.map_err(|e| format!("Failed to encode frame: {e}"))?;
let _ = write!(stdout, "\x1b[H");
let _ = stdout.write_all(&encoded);
let _ = stdout.flush();
let elapsed = frame_start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
if !args.r#loop {
break;
}
}
Ok(())
}
const KNOWN_FIRST_ARGS: &[&str] = &[
"render",
"atlas",
"video",
"play",
"help",
"-h",
"--help",
"-V",
"--version",
];
fn with_inferred_subcommand(mut argv: Vec<String>) -> Vec<String> {
let first_real_arg = argv.get(1).map(String::as_str);
if let Some(arg) = first_real_arg {
if !KNOWN_FIRST_ARGS.contains(&arg) {
argv.insert(1, "render".to_string());
}
}
argv
}
fn main() -> ExitCode {
let argv = with_inferred_subcommand(std::env::args().collect());
let cli = Cli::parse_from(argv);
match cli.command {
Command::Play(args) => match run_play(args) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
},
#[cfg(feature = "video")]
Command::Video(args) => match run_video(args) {
Ok(message) => {
println!("{message}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
},
other => {
let result = match other {
Command::Render(args) => run_render(args),
Command::Atlas(args) => run_atlas(args),
#[cfg(feature = "video")]
Command::Video(_) => unreachable!("handled above"),
Command::Play(_) => unreachable!("handled above"),
};
match result {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => {
println!("{}", text);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: encoder produced invalid UTF-8: {e}");
ExitCode::FAILURE
}
},
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
}
}