Skip to content

Commit 18a129f

Browse files
committed
refactor + minor fixes
1 parent bdaff55 commit 18a129f

File tree

16 files changed

+572
-279
lines changed

16 files changed

+572
-279
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
*.mp4
22
*.wav
33
*.exe
4+
45
data/
56
videos/
67

@@ -11,6 +12,8 @@ Cargo.lock
1112
debug/
1213
target/
1314

15+
.idea/
16+
1417
# These are backup files generated by rustfmt
1518
**/*.rs.bk
1619

Cargo.toml

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,22 @@ version = "0.1.0"
44
edition = "2024"
55

66
[dependencies]
7-
clap = { version = "4.5", features = ["derive"] } # Argument parsing
8-
crossterm = "0.29" # Terminal manipulation (size, cursor, raw mode, colors, events)
9-
image = "0.25" # Image loading, processing, resizing
10-
rodio = { version = "0.20", features = ["wav"] } # Audio playback (using wav format from ffmpeg)
11-
zstd = "0.13" # Zstandard compression/decompression
12-
sha2 = "0.10" # SHA-256 hashing
13-
rayon = "1.10" # Data parallelism (for frame processing)
14-
indicatif = "0.17" # Progress bars
15-
sysinfo = "0.34" # System information (CPU/Memory)
16-
chrono = "0.4" # Time/Duration formatting
17-
thiserror = "2.0" # Error handling helper
18-
log = "0.4" # Logging facade
7+
clap = { version = "4.5", features = ["derive"] }
8+
crossterm = "0.29"
9+
image = "0.25"
10+
rodio = { version = "0.20", features = ["wav"] }
11+
zstd = "0.13"
12+
sha2 = "0.10"
13+
rayon = "1.10"
14+
indicatif = "0.17"
15+
sysinfo = "0.35"
16+
thiserror = "2.0"
17+
log = "0.4"
1918
ctrlc = "3.4"
20-
env_logger = "0.11" # Logging implementation
21-
lazy_static = "1.5" # For static initialization (like ASCII_CHARS)
22-
tempfile = "3.19" # For temporary directories/files if needed during frame extraction
23-
cpal = "0.15" # Audio output and device handling
24-
hound = "3.5" # Wav file reading
25-
ringbuf = "0.4"
26-
crossbeam-channel = "0.5"
27-
ftail = "0.2"
28-
log4rs = "1.3"
19+
lazy_static = "1.5"
20+
tempfile = "3.20"
21+
hound = "3.5"
2922
serde = { version = "1.0", features = ["derive"] }
3023
serde_cbor = "0.11.2"
24+
log4rs = "1.3.0"
3125

32-
# [profile.release]
33-
# lto = true # Link-Time Optimization
34-
# codegen-units = 1 # Optimize for speed over compile time
35-
# panic = 'abort' # Abort on panic for smaller binary size (optional)
36-
# strip = true # Strip symbols

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# ascii-rs
2+
3+
A small Rust command line tool that plays videos as _coloured_ ASCII art directly in your terminal with minimal performance overhead.
4+
5+
## Steps to run
6+
7+
- If not already, install Rust [via rustup](https://rustup.rs) and FFmpeg (ffmpeg must be on your system PATH).
8+
- Put a video file somewhere on your machine (e.g., videos/sample.mp4).
9+
- From the project root, run in your terminal:
10+
- Debug: `cargo run -- <path-to-video>`
11+
- Release: `cargo run --release -- <path-to-video>`
12+
- Use Ctrl+C to stop playback at any time.
13+
14+
## Screenshots
15+
16+
![sync_test.png](img/sync_test.png)
17+
18+
![steve.png](img/steve.png)
19+
20+
## Notes
21+
22+
- Tested on Windows Terminal (Powershell), not sure about other terminals.
23+
- Larger terminals look better; a minimum of ~100x80 is recommended.
24+
- A cache file is created to speed up subsequent runs of the same video.
25+
- Add --regenerate to force rebuilding the ASCII cache for that video.

build.bat

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@echo off
2+
3+
cargo build --release
4+
5+
copy /v /y .\target\release\*.exe .

img/steve.png

998 KB
Loading

img/sync_test.png

169 KB
Loading

src/ascii.rs

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
use crate::config::{ASCII_CHARS, CHAR_ASPECT_RATIO};
2-
use crate::error::AppError;
3-
use image::imageops::FilterType;
4-
use image::{DynamicImage, GenericImageView, ImageBuffer, Rgb};
1+
use crate::{
2+
config::{ASCII_CHARS, CHAR_ASPECT_RATIO},
3+
error::AppError,
4+
};
5+
use image::{DynamicImage, GenericImageView, ImageBuffer, Rgb, RgbImage, imageops::FilterType};
56
use indicatif::{ProgressBar, ProgressStyle};
7+
use log::{debug, error, info};
68
use rayon::prelude::*;
79
use serde::{Deserialize, Serialize};
8-
use std::path::{Path, PathBuf};
9-
use std::sync::Mutex;
10+
use std::{
11+
path::{Path, PathBuf},
12+
sync::Mutex,
13+
};
1014

1115
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1216
pub struct RleRun {
@@ -21,7 +25,9 @@ pub struct RleFrame {
2125
pub runs: Vec<RleRun>,
2226
}
2327

24-
fn resize_and_center(img: &DynamicImage, cols: u16, lines: u16) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
28+
pub fn resize_and_center(img: &DynamicImage, cols: u16, lines: u16) -> RgbImage {
29+
debug!("Resizing image to fit terminal: {}x{}", cols, lines);
30+
2531
let term_w = cols as u32;
2632
let term_h = lines.saturating_sub(1) as u32;
2733
if term_w == 0 || term_h == 0 {
@@ -53,7 +59,7 @@ fn resize_and_center(img: &DynamicImage, cols: u16, lines: u16) -> ImageBuffer<R
5359
canvas
5460
}
5561

56-
fn convert_image_to_ascii(img: &ImageBuffer<Rgb<u8>, Vec<u8>>) -> RleFrame {
62+
pub fn convert_image_to_ascii(img: &RgbImage) -> RleFrame {
5763
let (w, h) = img.dimensions();
5864
if w == 0 || h == 0 {
5965
return RleFrame {
@@ -105,43 +111,52 @@ fn convert_image_to_ascii(img: &ImageBuffer<Rgb<u8>, Vec<u8>>) -> RleFrame {
105111
}
106112
}
107113

108-
fn process_single_frame(path: &Path, size: (u16, u16)) -> Result<RleFrame, AppError> {
109-
let img = image::open(path)?;
110-
let buf = resize_and_center(&img, size.0, size.1);
111-
Ok(convert_image_to_ascii(&buf))
114+
pub fn process_single_frame(path: &Path, size: (u16, u16)) -> Result<RleFrame, AppError> {
115+
debug!("Processing frame: {}", path.display());
116+
117+
let img = image::open(path).map_err(|e| {
118+
error!("Failed to open image at {}: {}", path.display(), e);
119+
AppError::Image {
120+
source: e,
121+
context: Some(path.display().to_string()),
122+
}
123+
})?;
124+
125+
let img = resize_and_center(&img, size.0, size.1);
126+
Ok(convert_image_to_ascii(&img))
112127
}
113128

114129
pub fn process_frames_parallel(
115130
paths: &[PathBuf],
116131
size: (u16, u16),
117132
) -> Result<Vec<RleFrame>, AppError> {
118-
if paths.is_empty() {
119-
return Ok(Vec::new());
120-
}
121-
let pb = ProgressBar::new(paths.len() as u64);
122-
pb.set_style(
123-
ProgressStyle::default_bar()
124-
.template("Generating ASCII frames: [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
125-
.unwrap()
126-
.progress_chars("=> "),
133+
info!("Processing {} frames", paths.len());
134+
135+
let pb = Mutex::new(
136+
ProgressBar::new(paths.len() as u64)
137+
.with_style(
138+
ProgressStyle::default_bar()
139+
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
140+
.unwrap()
141+
.progress_chars("##-")
142+
)
143+
.with_message("Processing frames"),
127144
);
128-
let m = Mutex::new(pb);
129-
let mut frames = Vec::with_capacity(paths.len());
130-
let results: Vec<_> = paths
145+
146+
let results: Result<Vec<_>, _> = paths
131147
.par_iter()
132-
.map(|p| {
133-
let f = process_single_frame(p, size);
134-
if let Ok(ref pb) = m.lock() {
135-
pb.inc(1);
148+
.map(|path| {
149+
let result = process_single_frame(path, size);
150+
if let Ok(pb_lock) = pb.lock() {
151+
pb_lock.inc(1);
136152
}
137-
f
153+
result
138154
})
139155
.collect();
140-
for res in results {
141-
frames.push(res?);
142-
}
143-
if let Ok(pb) = m.lock() {
144-
pb.finish_and_clear();
156+
157+
if let Ok(pb_lock) = pb.lock() {
158+
pb_lock.finish_with_message("Frame processing complete");
145159
}
146-
Ok(frames)
160+
161+
results
147162
}

src/cli.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ use std::path::PathBuf;
44
#[derive(Parser, Debug)]
55
#[command(author, version, about = "Plays videos in the terminal using ASCII characters", long_about = None)]
66
pub struct CliArgs {
7-
#[arg(short, long)]
8-
pub video: Option<PathBuf>,
7+
#[arg(required = true)]
8+
pub video: PathBuf,
99

10-
#[arg(short, long, action = clap::ArgAction::SetTrue)]
10+
#[arg(action = clap::ArgAction::SetTrue)]
1111
pub regenerate: bool,
1212
}
1313

src/error.rs

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,35 @@ use thiserror::Error;
77

88
#[derive(Error, Debug)]
99
pub enum AppError {
10-
#[error("I/O error: {0}")]
11-
Io(#[from] std::io::Error),
12-
13-
#[error("Image processing error: {0}")]
14-
Image(#[from] ImageError),
15-
16-
#[error("Audio playback error: {0}")]
17-
AudioPlayback(#[from] PlayError),
18-
19-
#[error("Audio decoding error: {0}")]
20-
AudioDecode(#[from] DecoderError),
21-
22-
#[error("Terminal error: {0}")]
23-
Terminal(std::io::Error),
10+
#[error("I/O error: {source}")]
11+
Io {
12+
source: std::io::Error,
13+
context: Option<String>,
14+
},
15+
16+
#[error("Image processing error: {source}")]
17+
Image {
18+
source: ImageError,
19+
context: Option<String>,
20+
},
21+
22+
#[error("Audio playback error: {source}")]
23+
AudioPlayback {
24+
source: PlayError,
25+
context: Option<String>,
26+
},
27+
28+
#[error("Audio decoding error: {source}")]
29+
AudioDecode {
30+
source: DecoderError,
31+
context: Option<String>,
32+
},
33+
34+
#[error("Terminal error: {source}")]
35+
Terminal {
36+
source: std::io::Error,
37+
context: Option<String>,
38+
},
2439

2540
#[error("FFmpeg command failed: {0}")]
2641
FFmpeg(String),
@@ -49,20 +64,36 @@ pub enum AppError {
4964
#[error("Unsupported ACSV version: {0}")]
5065
UnsupportedAcsvVersion(u8),
5166

52-
#[error("Failed to parse integer: {0}")]
53-
ParseInt(#[from] ParseIntError),
54-
55-
#[error("Failed to parse float: {0}")]
56-
ParseFloat(#[from] ParseFloatError),
57-
58-
#[error("Failed to decode UTF-8: {0}")]
59-
Utf8(#[from] FromUtf8Error),
60-
61-
#[error("Compression error: {0}")]
62-
Compression(std::io::Error),
63-
64-
#[error("Decompression error: {0}")]
65-
Decompression(std::io::Error),
67+
#[error("Failed to parse integer: {source}")]
68+
ParseInt {
69+
source: ParseIntError,
70+
context: Option<String>,
71+
},
72+
73+
#[error("Failed to parse float: {source}")]
74+
#[allow(dead_code)]
75+
ParseFloat {
76+
source: ParseFloatError,
77+
context: Option<String>,
78+
},
79+
80+
#[error("Failed to decode UTF-8: {source}")]
81+
Utf8 {
82+
source: FromUtf8Error,
83+
context: Option<String>,
84+
},
85+
86+
#[error("Compression error: {source}")]
87+
Compression {
88+
source: std::io::Error,
89+
context: Option<String>,
90+
},
91+
92+
#[error("Decompression error: {source}")]
93+
Decompression {
94+
source: std::io::Error,
95+
context: Option<String>,
96+
},
6697

6798
#[error("Frame processing failed")]
6899
FrameProcessing,
@@ -82,7 +113,3 @@ pub enum AppError {
82113
#[error("Could not get system information: {0}")]
83114
SystemInfo(String),
84115
}
85-
86-
pub fn map_terminal_error(e: std::io::Error) -> AppError {
87-
AppError::Terminal(e)
88-
}

0 commit comments

Comments
 (0)