add ascii mode

This commit is contained in:
2026-03-09 20:24:31 +01:00
parent bf3f061b19
commit c0562b5043
4 changed files with 742 additions and 0 deletions

284
termshrek/src/main.rs Normal file
View File

@@ -0,0 +1,284 @@
// TODO CLI tool, read resolution of term
use std::{
io::{self, Write},
path::Path,
thread,
time::{Duration, Instant},
};
use clap::{Parser, ValueEnum};
use ffmpeg::software::scaling::{context::Context as ScalingContext, flag::Flags};
use ffmpeg::util::format::pixel::Pixel;
use ffmpeg_next as ffmpeg;
// const ASCII_GRADIENT: &[char] = &[' ', '░', '▒', '▓', '█'];
const ASCII_GRADIENT: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ArtStyle {
Pixel,
Ascii,
}
#[derive(Parser)]
struct Args {
#[arg(short, long, default_value_t = true)]
keep_ar: bool,
#[arg(short, long, default_value_t = false)]
loop_video: bool,
#[arg(short, long, value_enum, default_value_t =ArtStyle::Pixel)]
art_style: ArtStyle,
}
struct VideoReader {
input: ffmpeg::format::context::Input,
fps: f32,
aspect_ratio: f32,
dims: (u32, u32),
decoder: ffmpeg::decoder::Video,
scaler: ScalingContext,
stream_index: usize,
frame: ffmpeg::util::frame::Video,
rgb_frame: ffmpeg::util::frame::Video,
loop_video: bool,
}
impl VideoReader {
fn new(path: &Path, loop_video: bool, term_width: usize, term_height: usize) -> Self {
ffmpeg::init().unwrap();
let input = ffmpeg::format::input(path).unwrap();
let stream = input
.streams()
.best(ffmpeg::media::Type::Video)
.expect("video stream not found");
let framerate = stream.avg_frame_rate();
let framerate_f32 = framerate.numerator() as f32 / framerate.denominator() as f32;
let stream_index = stream.index();
let codec_context =
ffmpeg::codec::context::Context::from_parameters(stream.parameters()).unwrap();
let decoder = codec_context.decoder().video().unwrap();
// Find aspect ratio
let width = decoder.width();
let height = decoder.height();
let sar = decoder.aspect_ratio();
let sar_f32 = if sar.denominator() != 0 {
sar.numerator() as f32 / sar.denominator() as f32
} else {
1.0
};
let dar = (width as f32 / height as f32) * sar_f32;
// Find target resolution
let target_width;
let target_height;
let prep_term_height = (term_height - 2) * 2;
if dar > term_width as f32 / (prep_term_height) as f32 {
target_height = (term_width as f32 / dar).floor() as u32;
target_width = term_width as u32;
} else {
target_height = (prep_term_height) as u32;
target_width = ((prep_term_height) as f32 * dar).floor() as u32;
}
let scaler = ScalingContext::get(
decoder.format(),
width,
height,
Pixel::RGB24,
target_width,
target_height.div_euclid(2) * 2,
Flags::BILINEAR,
)
.unwrap();
Self {
input,
fps: framerate_f32,
aspect_ratio: dar,
dims: (target_width, target_height.div_euclid(2) * 2),
decoder,
scaler,
stream_index,
frame: ffmpeg::util::frame::Video::empty(),
rgb_frame: ffmpeg::util::frame::Video::empty(),
loop_video,
}
}
fn extract_frame_data(&mut self) -> Vec<[u8; 2]> {
self.scaler.run(&self.frame, &mut self.rgb_frame).unwrap();
let width = self.rgb_frame.width() as usize;
let height = self.rgb_frame.height() as usize;
let stride = self.rgb_frame.stride(0);
let mut rgb: Vec<[u8; 2]> = Vec::with_capacity(width * height * 3);
let mut y = 0;
while y < height {
let upper = y * stride;
let lower = (y + 1) * stride;
let row_upper = &self.rgb_frame.data(0)[upper..upper + width * 3];
let row_lower = &self.rgb_frame.data(0)[lower..lower + width * 3];
let dualrow: Vec<[u8; 2]> = row_upper
.iter()
.zip(row_lower.iter())
.map(|(&x, &y)| [x, y])
.collect();
rgb.extend(dualrow);
y += 2
}
return rgb;
}
fn next_frame(&mut self) -> Option<Vec<[u8; 2]>> {
let mut packets_iter = self.input.packets();
loop {
if let Some((stream, packet)) = packets_iter.next() {
if stream.index() != self.stream_index {
continue;
}
self.decoder.send_packet(&packet).unwrap();
if let Ok(_) = self.decoder.receive_frame(&mut self.frame) {
return Some(self.extract_frame_data());
}
} else {
self.decoder.flush();
self.input.seek(0, ..).unwrap();
packets_iter = self.input.packets();
}
}
}
}
fn render_pixel_art(frame: &Vec<[u8; 2]>, reader: &VideoReader, buf: &mut Vec<u8>) {
for row in 0..reader.dims.1 / 2 {
for col in 0..reader.dims.0 {
let idx = 3 * (row * reader.dims.0 + col);
let reds = frame.get(idx as usize).unwrap_or_else(|| {
panic!("Index: {idx} is out of bounds at row {row} and col {col}. ")
});
let greens = frame.get((idx + 1) as usize).unwrap_or_else(|| {
panic!("Index: {idx} is out of bounds at row {row} and col {col}. ")
});
let blues = frame.get((idx + 2) as usize).unwrap_or_else(|| {
panic!("Index: {idx} is out of bounds at row {row} and col {col}. ")
});
buf.extend_from_slice(
format!(
"\x1b[48;2;{br};{bg};{bb}m\x1b[38;2;{tr};{tg};{tb}m▀",
br = reds[1],
bg = greens[1],
bb = blues[1],
tr = reds[0],
tg = greens[0],
tb = blues[0],
)
.as_bytes(),
);
}
buf.extend_from_slice(b"\x1b[0m\n");
}
}
fn luminance(r: u8, g: u8, b: u8) -> f32 {
let res = 0.2126 * f32::try_from(r).unwrap_or_else(|e| unreachable!("{e}"))
+ 0.7152 * f32::try_from(g).unwrap_or_else(|e| unreachable!("{e}"))
+ 0.0722 * f32::try_from(b).unwrap_or_else(|e| unreachable!("{e}"));
res
}
fn render_ascii_art(frame: &Vec<[u8; 2]>, reader: &VideoReader, buf: &mut Vec<u8>) {
for row in 0..reader.dims.1 / 2 {
for col in 0..reader.dims.0 {
let idx = 3 * (row * reader.dims.0 + col);
let reds = frame.get(idx as usize).unwrap_or_else(|| {
panic!("Index: {idx} is out of bounds at row {row} and col {col}. ")
});
let greens = frame.get((idx + 1) as usize).unwrap();
let blues = frame.get((idx + 2) as usize).unwrap();
// color
// buf.extend_from_slice(
// format!(
// "\x1b[38;2;{tr};{tg};{tb}m",
// tr = reds[0],
// tg = greens[0],
// tb = blues[0],
// )
// .as_bytes(),
// );
let mut extend = [0u8; 4];
buf.extend_from_slice(
ASCII_GRADIENT[(luminance(reds[0], greens[0], blues[0])) as usize
* (ASCII_GRADIENT.len() - 1)
/ 255]
.encode_utf8(&mut extend)
.as_bytes(),
);
}
buf.extend_from_slice(b"\x1b[0m\n");
}
}
fn main() {
let args = Args::parse();
let term_dims =
term_size::dimensions().unwrap_or_else(|| panic!("Could not find terminal dimensions"));
let mut reader = VideoReader::new(
Path::new("/home/jan/Videos/rotating_panda.mkv"),
args.loop_video,
term_dims.0,
term_dims.1,
);
let frame_duration_micros: Duration =
Duration::from_micros((1_000_000.0 / reader.fps).floor() as u64);
let mut out = io::stdout().lock();
let mut buf: Vec<u8> = Vec::new();
let start_time = Instant::now();
// Clear screen and hide cursor
let _ = out.write_all(b"\x1b[2J\x1b[?25l");
while let Some(frame) = reader.next_frame() {
let time = start_time.elapsed();
buf.extend_from_slice(b"\x1b[J\x1b[H"); // move to start
match args.art_style {
ArtStyle::Pixel => render_pixel_art(&frame, &reader, &mut buf),
ArtStyle::Ascii => render_ascii_art(&frame, &reader, &mut buf),
}
let _ = out.write_all(&buf);
let _ = out.flush();
buf.clear();
let sleep_time = frame_duration_micros - (start_time.elapsed() - time);
if sleep_time.as_micros() > 0 {
thread::sleep(sleep_time);
}
}
}