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

1
termshrek/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target

448
termshrek/Cargo.lock generated Normal file
View File

@@ -0,0 +1,448 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cc"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ffmpeg-next"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40"
dependencies = [
"bitflags",
"ffmpeg-sys-next",
"libc",
]
[[package]]
name = "ffmpeg-sys-next"
version = "8.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b"
dependencies = [
"bindgen",
"cc",
"libc",
"num_cpus",
"pkg-config",
"vcpkg",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "term_size"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "termshrek"
version = "0.1.0"
dependencies = [
"clap",
"ffmpeg-next",
"term_size",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

9
termshrek/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "termshrek"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.60", features = ["derive"] }
ffmpeg-next = "8.0.0"
term_size = "0.3.2"

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);
}
}
}