add ascii mode
This commit is contained in:
284
termshrek/src/main.rs
Normal file
284
termshrek/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user