use anyhow::{anyhow, Context, Result};
use cascii::loop_detect::run_find_loop;
use cascii::preprocessing::{
detect_preprocess_input_kind, preprocess_directory, preprocess_image_to_file,
preprocess_image_to_temp, preprocess_video_to_file, resolve_preprocess_filter,
resolve_preprocess_output_path, PreprocessInputKind, PREPROCESS_PRESETS,
};
use cascii::{
crop_frames, run_trim, AppConfig, AsciiConverter, ConversionOptions, OutputMode, Progress,
ProgressPhase, ToVideoOptions, VideoOptions,
};
use clap::{Parser, Subcommand};
use dialoguer::{Confirm, FuzzySelect, Input};
use indicatif::{ProgressBar, ProgressStyle};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use walkdir::WalkDir;
fn load_config() -> Result<AppConfig> {
let mut tried: Vec<PathBuf> = Vec::new();
if let Some(mut d) = dirs::data_dir() {
d.push("cascii");
d.push("cascii.json");
tried.push(d);
}
tried.push(PathBuf::from("cascii.json"));
for p in &tried {
if p.exists() {
let text =
fs::read_to_string(p).with_context(|| format!("reading config {}", p.display()))?;
let cfg: AppConfig = serde_json::from_str(&text).context("parsing config json")?;
if !cfg.ascii_chars.is_ascii() {
return Err(anyhow!(
"Config file {} contains non-ASCII characters in ascii_chars field. \
This will cause corrupted output. Please use only ASCII characters.",
p.display()
));
}
return Ok(cfg);
}
}
Ok(AppConfig::default())
}
#[derive(Subcommand, Debug)]
enum Command {
Uninstall,
}
#[derive(Parser, Debug)]
#[command(version, about = "Interactive video/image to ASCII frame generator.")]
struct Args {
#[command(subcommand)]
cmd: Option<Command>,
input: Option<PathBuf>,
out: Option<PathBuf>,
#[arg(long)]
columns: Option<u32>,
#[arg(long)]
fps: Option<u32>,
#[arg(long)]
font_ratio: Option<f32>,
#[arg(long, default_value_t = false, conflicts_with_all = &["small", "large"])]
default: bool,
#[arg(long, short, default_value_t = false, conflicts_with_all = &["default", "large"])]
small: bool,
#[arg(long, short, default_value_t = false, conflicts_with_all = &["default", "small"])]
large: bool,
#[arg(long)]
luminance: Option<u8>,
#[arg(long, default_value_t = false)]
log_details: bool,
#[arg(long, default_value_t = false)]
keep_images: bool,
#[arg(long, default_value_t = false, conflicts_with = "color_only")]
colors: bool,
#[arg(long, default_value_t = false, conflicts_with = "colors")]
color_only: bool,
#[arg(long, default_value_t = false)]
to_video: bool,
#[arg(long, default_value_t = 14.0)]
video_font_size: f32,
#[arg(long, default_value_t = 18)]
crf: u8,
#[arg(long, default_value_t = false)]
audio: bool,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
#[arg(long, alias = "preprocessing", conflicts_with = "preprocess_preset")]
preprocess: Option<String>,
#[arg(long, alias = "preprocessing-preset", conflicts_with = "preprocess")]
preprocess_preset: Option<String>,
#[arg(long)]
preprocess_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
list_preprocess_presets: bool,
#[arg(long, default_value_t = false)]
find_loop: bool,
#[arg(long)]
trim: Option<usize>,
#[arg(long)]
trim_left: Option<usize>,
#[arg(long)]
trim_right: Option<usize>,
#[arg(long)]
trim_top: Option<usize>,
#[arg(long)]
trim_bottom: Option<usize>,
#[arg(long)]
trim_output: Option<PathBuf>,
}
fn print_preprocess_presets() {
println!("Available preprocessing presets:");
for preset in PREPROCESS_PRESETS {
println!(" {:<16} {}", preset.name, preset.description);
println!(" {}", preset.filter);
}
}
fn main() -> Result<()> {
let mut args = Args::parse();
let is_interactive = !(args.default || args.small || args.large);
if let Some(Command::Uninstall) = &args.cmd {
run_uninstall(is_interactive)?;
println!("cascii uninstalled.");
return Ok(());
}
if args.list_preprocess_presets {
print_preprocess_presets();
return Ok(());
}
let preprocess_filter = resolve_preprocess_filter(
args.preprocess.as_deref(),
args.preprocess_preset.as_deref(),
)?;
let any_trim = args.trim.unwrap_or(0) > 0
|| args.trim_left.unwrap_or(0) > 0
|| args.trim_right.unwrap_or(0) > 0
|| args.trim_top.unwrap_or(0) > 0
|| args.trim_bottom.unwrap_or(0) > 0;
if any_trim {
let input_path = match &args.input {
Some(p) => p.clone(),
None => return Err(anyhow!("Input path must be provided when using --trim")),
};
let base = args.trim.unwrap_or(0);
let trim_left = args.trim_left.unwrap_or(base);
let trim_right = args.trim_right.unwrap_or(base);
let trim_top = args.trim_top.unwrap_or(base);
let trim_bottom = args.trim_bottom.unwrap_or(base);
if let Some(output_dir) = &args.trim_output {
if !input_path.is_dir() {
return Err(anyhow!(
"--trim-output requires the input to be a directory"
));
}
let result = crop_frames(
&input_path,
trim_top,
trim_bottom,
trim_left,
trim_right,
output_dir,
)?;
println!(
"Trim completed: left={}, right={}, top={}, bottom={} → {} frames written to {} ({}×{})",
trim_left, trim_right, trim_top, trim_bottom,
result.frame_count,
output_dir.display(),
result.new_width,
result.new_height,
);
} else {
run_trim(&input_path, trim_left, trim_right, trim_top, trim_bottom)?;
println!(
"Trim completed: left={}, right={}, top={}, bottom={}",
trim_left, trim_right, trim_top, trim_bottom
);
}
return Ok(());
}
if args.find_loop {
let input_path = match &args.input {
Some(p) => p.clone(),
None => {
return Err(anyhow!(
"Input directory must be provided when using --find-loop"
))
}
};
if !input_path.is_dir() {
return Err(anyhow!(
"--find-loop expects a directory containing frame_*.txt files"
));
}
run_find_loop(&input_path)?;
return Ok(());
}
if args.input.is_none() {
if !is_interactive {
return Err(anyhow!("Input file must be provided when using a preset."));
}
let files = find_media_files()?;
if files.is_empty() {
return Err(anyhow!("No media files found in current directory."));
}
let selection = FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt("Choose an input file")
.default(0)
.items(&files)
.interact()?;
args.input = Some(PathBuf::from(&files[selection]));
}
let input_path = args.input.as_ref().unwrap();
if args.preprocess_output.is_some() && preprocess_filter.is_none() {
return Err(anyhow!(
"--preprocess-output requires --preprocess or --preprocess-preset"
));
}
let is_image_input = input_path.is_file()
&& matches!(
input_path.extension().and_then(|s| s.to_str()),
Some("png" | "jpg" | "jpeg")
);
if let Some(ref filter) = preprocess_filter {
if let Some(output_target) = args.preprocess_output.as_ref() {
let converter = AsciiConverter::with_config(load_config()?)?;
match detect_preprocess_input_kind(input_path)? {
PreprocessInputKind::Directory => {
let count = preprocess_directory(
input_path,
filter,
output_target,
converter.ffmpeg_config(),
)?;
println!(
"Preprocessing completed: {} images written to {}",
count,
output_target.display(),
);
}
PreprocessInputKind::Image => {
let output_file = resolve_preprocess_output_path(
input_path,
output_target,
PreprocessInputKind::Image,
)?;
preprocess_image_to_file(
input_path,
filter,
&output_file,
converter.ffmpeg_config(),
)?;
println!("Preprocessed image saved to {}", output_file.display());
}
PreprocessInputKind::Video => {
let output_file = resolve_preprocess_output_path(
input_path,
output_target,
PreprocessInputKind::Video,
)?;
preprocess_video_to_file(
input_path,
filter,
&output_file,
args.start.as_deref(),
args.end.as_deref(),
converter.ffmpeg_config(),
)?;
println!("Preprocessed video saved to {}", output_file.display());
}
}
return Ok(());
}
if input_path.is_dir() {
return Err(anyhow!(
"Preprocessing a directory requires --preprocess-output to specify where preprocessed images are written"
));
}
}
let video_output_path = if args.to_video {
if let Some(ref out) = args.out {
let mut p = out.clone();
if p.extension().map(|e| e != "mp4").unwrap_or(true) {
p.set_extension("mp4");
}
p
} else {
let stem = input_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
PathBuf::from(format!("{}_ascii.mp4", stem))
}
} else {
PathBuf::new() };
let mut output_path = args.out.clone().unwrap_or_else(|| PathBuf::from("."));
if input_path.is_file() && !args.to_video {
let file_stem = input_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cascii_output");
output_path.push(file_stem);
}
let cfg = load_config()?;
let converter = AsciiConverter::with_config(cfg.clone())?;
let active_preset_name = if args.small {
"small"
} else if args.large {
"large"
} else if args.default {
cfg.default_preset.as_str()
} else {
cfg.default_preset.as_str()
};
let active = cfg
.presets
.get(active_preset_name)
.ok_or_else(|| anyhow!(format!("Missing preset '{}' in config", active_preset_name)))?;
let default_cols = active.columns;
let default_fps = active.fps;
let default_ratio = active.font_ratio;
if is_interactive {
if args.columns.is_none() {
args.columns = Some(
Input::new()
.with_prompt("Columns (width)")
.default(default_cols)
.interact()?,
);
}
if args.font_ratio.is_none() {
args.font_ratio = Some(
Input::new()
.with_prompt("Font Ratio")
.default(default_ratio)
.interact()?,
);
}
if args.luminance.is_none() {
args.luminance = Some(
Input::new()
.with_prompt("Luminance threshold")
.default(20u8)
.interact()?,
);
}
if !is_image_input {
if args.fps.is_none() {
args.fps = Some(
Input::new()
.with_prompt("Frames per second (FPS)")
.default(default_fps)
.interact()?,
);
}
if args.start.is_none() {
args.start = Some(
Input::new()
.with_prompt("Start time (e.g., 00:00:05)")
.default(cfg.default_start.clone())
.interact()?,
);
}
if args.end.is_none() {
args.end = Some(
Input::new()
.with_prompt("End time (e.g., 00:00:10) (optional)")
.default(cfg.default_end.clone())
.interact()?,
);
}
}
}
let columns = args.columns.unwrap_or(default_cols);
let fps = args.fps.unwrap_or(default_fps);
let font_ratio = args.font_ratio.unwrap_or(default_ratio);
let luminance = args.luminance.unwrap_or(active.luminance);
if !args.to_video {
fs::create_dir_all(&output_path).context("creating output dir")?;
let has_frames = WalkDir::new(&output_path)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(Result::ok)
.any(|e| {
e.file_name()
.to_str()
.is_some_and(|s| s.starts_with("frame_"))
});
if has_frames {
if is_interactive
&& !Confirm::new()
.with_prompt(format!(
"Output directory {} already contains frames. Overwrite?",
output_path.display()
))
.default(false)
.interact()?
{
println!("Operation cancelled.");
return Ok(());
}
for entry in fs::read_dir(&output_path)? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
if name.starts_with("frame_")
&& (name.ends_with(".png")
|| name.ends_with(".txt")
|| name.ends_with(".cframe")
|| name.ends_with(".colors"))
{
fs::remove_file(path)?;
}
}
}
}
}
let output_mode = if args.color_only {
OutputMode::ColorOnly
} else if args.colors {
OutputMode::TextAndColor
} else {
OutputMode::TextOnly
};
let conv_opts = ConversionOptions {
columns: Some(columns),
font_ratio,
luminance,
ascii_chars: cfg.ascii_chars.clone(),
output_mode: output_mode.clone(),
};
if input_path.is_file() {
if is_image_input {
println!("Converting image to ASCII...");
let preprocessed_image = if let Some(filter) = preprocess_filter.as_deref() {
println!("Applying preprocessing filter before ASCII conversion...");
Some(preprocess_image_to_temp(
input_path,
filter,
converter.ffmpeg_config(),
)?)
} else {
None
};
let image_input = preprocessed_image
.as_ref()
.map_or(input_path.as_path(), |f| f.path());
converter.convert_image(
image_input,
&output_path.join(format!(
"{}.txt",
input_path.file_stem().unwrap().to_str().unwrap()
)),
&conv_opts,
)?;
} else if args.to_video {
let video_opts = VideoOptions {
fps,
start: args.start.clone(),
end: args.end.clone(),
columns,
extract_audio: args.audio,
preprocess_filter: preprocess_filter.clone(),
};
let to_video_opts = ToVideoOptions {
output_path: video_output_path.clone(),
font_size: args.video_font_size,
crf: args.crf,
mux_audio: args.audio,
use_colors: None,
};
let progress_bar: Arc<Mutex<Option<ProgressBar>>> = Arc::new(Mutex::new(None));
let spinner: Arc<Mutex<Option<ProgressBar>>> = Arc::new(Mutex::new(None));
let pb_clone = Arc::clone(&progress_bar);
let spinner_clone = Arc::clone(&spinner);
converter.convert_video_to_video(
input_path,
&video_opts,
&conv_opts,
&to_video_opts,
move |progress: Progress| {
match progress.phase {
ProgressPhase::ExtractingFrames => {
let mut sp_guard = spinner_clone.lock().unwrap();
if sp_guard.is_none() {
let sp = ProgressBar::new_spinner();
sp.set_style(ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap());
sp.set_message("Extracting frames from video...");
sp.enable_steady_tick(std::time::Duration::from_millis(100));
*sp_guard = Some(sp);
}
}
ProgressPhase::ExtractingAudio => {
let mut sp_guard = spinner_clone.lock().unwrap();
if let Some(sp) = sp_guard.take() {
sp.finish_with_message("Frames extracted");
}
let sp = ProgressBar::new_spinner();
sp.set_style(ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap());
sp.set_message("Extracting audio...");
sp.enable_steady_tick(std::time::Duration::from_millis(100));
*sp_guard = Some(sp);
}
ProgressPhase::RenderingVideo => {
let mut sp_guard = spinner_clone.lock().unwrap();
if let Some(sp) = sp_guard.take() {
sp.finish_with_message("Extraction complete");
}
drop(sp_guard);
let mut pb_guard = pb_clone.lock().unwrap();
if pb_guard.is_none() && progress.total > 0 {
let pb = ProgressBar::new(progress.total as u64);
pb.set_style(ProgressStyle::default_bar().template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)").unwrap().progress_chars("#>-"));
pb.set_message("Rendering video");
*pb_guard = Some(pb);
}
if let Some(ref pb) = *pb_guard {
pb.set_position(progress.completed as u64);
}
}
ProgressPhase::ConvertingFrames | ProgressPhase::Complete => {}
}
},
)?;
let pb_opt = progress_bar.lock().unwrap().take();
if let Some(pb) = pb_opt {
pb.finish_with_message("Done");
}
println!("\nASCII video saved to {}", video_output_path.display());
return Ok(());
} else {
let video_opts = VideoOptions {
fps,
start: args.start.clone(),
end: args.end.clone(),
columns,
extract_audio: args.audio,
preprocess_filter: preprocess_filter.clone(),
};
let progress_bar: Arc<Mutex<Option<ProgressBar>>> = Arc::new(Mutex::new(None));
let spinner: Arc<Mutex<Option<ProgressBar>>> = Arc::new(Mutex::new(None));
let pb_clone = Arc::clone(&progress_bar);
let spinner_clone = Arc::clone(&spinner);
converter.convert_video_with_detailed_progress(
input_path,
&output_path,
&video_opts,
&conv_opts,
args.keep_images,
move |progress: Progress| {
match progress.phase {
ProgressPhase::ExtractingFrames => {
let mut sp_guard = spinner_clone.lock().unwrap();
if sp_guard.is_none() {
let sp = ProgressBar::new_spinner();
sp.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap()
);
sp.set_message("Extracting frames from video...");
sp.enable_steady_tick(std::time::Duration::from_millis(100));
*sp_guard = Some(sp);
}
}
ProgressPhase::ExtractingAudio => {
let mut sp_guard = spinner_clone.lock().unwrap();
if let Some(sp) = sp_guard.take() {
sp.finish_with_message("Frames extracted");
}
let sp = ProgressBar::new_spinner();
sp.set_style(ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap());
sp.set_message("Extracting audio...");
sp.enable_steady_tick(std::time::Duration::from_millis(100));
*sp_guard = Some(sp);
}
ProgressPhase::ConvertingFrames => {
let mut sp_guard = spinner_clone.lock().unwrap();
if let Some(sp) = sp_guard.take() {
sp.finish_with_message("Extraction complete");
}
drop(sp_guard);
let mut pb_guard = pb_clone.lock().unwrap();
if pb_guard.is_none() && progress.total > 0 {
let pb = ProgressBar::new(progress.total as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)")
.unwrap()
.progress_chars("#>-"),
);
pb.set_message("Converting frames");
*pb_guard = Some(pb);
}
if let Some(ref pb) = *pb_guard {
pb.set_position(progress.completed as u64);
}
}
ProgressPhase::RenderingVideo | ProgressPhase::Complete => {
}
}
},
)?;
let pb_opt = progress_bar.lock().unwrap().take();
if let Some(pb) = pb_opt {
pb.finish_with_message("Done");
}
}
} else if input_path.is_dir() {
if args.to_video {
let to_video_opts = ToVideoOptions {
output_path: video_output_path.clone(),
font_size: args.video_font_size,
crf: args.crf,
mux_audio: args.audio,
use_colors: None,
};
let progress_bar: Arc<Mutex<Option<ProgressBar>>> = Arc::new(Mutex::new(None));
let pb_clone = Arc::clone(&progress_bar);
converter.render_frames_to_video(
input_path,
fps,
&to_video_opts,
move |progress: Progress| {
if progress.phase == ProgressPhase::RenderingVideo {
let mut pb_guard = pb_clone.lock().unwrap();
if pb_guard.is_none() && progress.total > 0 {
let pb = ProgressBar::new(progress.total as u64);
pb.set_style(ProgressStyle::default_bar().template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)").unwrap().progress_chars("#>-"));
pb.set_message("Rendering video");
*pb_guard = Some(pb);
}
if let Some(ref pb) = *pb_guard {
pb.set_position(progress.completed as u64);
}
}
},
)?;
let pb_opt = progress_bar.lock().unwrap().take();
if let Some(pb) = pb_opt {
pb.finish_with_message("Done");
}
println!("\nASCII video saved to {}", video_output_path.display());
return Ok(());
} else {
println!("Converting directory of images...");
converter.convert_directory(input_path, &output_path, &conv_opts, args.keep_images)?;
let frame_ext = if output_mode == OutputMode::ColorOnly {
"cframe"
} else {
"txt"
};
let frame_count = WalkDir::new(&output_path)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == frame_ext))
.count();
let mode_str = match output_mode {
OutputMode::TextOnly => "text-only",
OutputMode::ColorOnly => "color-only",
OutputMode::TextAndColor => "text+color",
};
let result = cascii::ConversionResult {
frame_count,
columns,
font_ratio,
luminance,
fps: None,
output_mode: mode_str.to_string(),
audio_extracted: false,
output_dir: output_path.clone(),
background_color: "black".to_string(),
color: "white".to_string(),
};
result
.write_details_file()
.context("writing details file")?;
let details = result.to_details_string();
if args.log_details {
println!("\n--- Generation Details ---");
println!("{}", details);
}
}
} else {
return Err(anyhow!("Input path does not exist"));
}
println!("\nASCII generation complete in {}", output_path.display());
Ok(())
}
fn find_media_files() -> Result<Vec<String>> {
Ok(WalkDir::new(".")
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path().extension().is_some_and(|ext| {
matches!(
ext.to_str(),
Some("mp4" | "mkv" | "mov" | "avi" | "webm" | "png" | "jpg")
)
})
})
.map(|e| e.path().to_str().unwrap_or("").to_string())
.collect())
}
fn run_uninstall(is_interactive: bool) -> Result<()> {
let bin_paths = vec!["/usr/local/bin/cascii", "/usr/local/bin/casci"]; let app_support = dirs::data_dir()
.unwrap_or_else(|| {
PathBuf::from(format!(
"{}/Library/Application Support",
std::env::var("HOME").unwrap_or_default()
))
})
.join("cascii");
if is_interactive {
let confirmed = Confirm::new()
.with_prompt("This will remove cascii and its app support directory. Continue?")
.default(false)
.interact()?;
if !confirmed {
println!("Uninstall cancelled.");
return Ok(());
}
}
for p in bin_paths {
let path = Path::new(p);
if path.exists() {
if let Err(e) = fs::remove_file(path) {
eprintln!("Warning: failed to remove {}: {}", p, e);
}
}
}
if app_support.exists() {
if let Err(e) = fs::remove_dir_all(&app_support) {
eprintln!(
"Warning: failed to remove app support directory {}: {}",
app_support.display(),
e
);
}
}
Ok(())
}