commit 0cb370f8ec861e1233040a531c14a2f049bc6414 Author: Exil Productions Date: Fri Dec 19 20:18:56 2025 +0100 Initial Commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a9c9a5 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# V2A + +v2a long "Video to Audio" is a video format playable in a Terminal, it uses ascii Characters to Render Content to the Terminal + +This seams oodly unprofesional but im really tired and i cant write a readme anymore so thats all you get, i put more readmes in each folder that explain what they do and how to build/use them. diff --git a/v2a-converter/.gitignore b/v2a-converter/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/v2a-converter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/v2a-converter/Cargo.toml b/v2a-converter/Cargo.toml new file mode 100644 index 0000000..750c3f1 --- /dev/null +++ b/v2a-converter/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "v2a-converter" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.0", features = ["derive"] } +rayon = "1.10" +flate2 = "1.0" +indicatif = "0.17" +anyhow = "1.0" +bincode = "2.0" +byteorder = "1.5" +crossbeam-channel = "0.5" +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +lru = "0.12" +once_cell = "1.20" +tempfile = "3.10" +num_cpus = "1.16" diff --git a/v2a-converter/Examples/bad_apple.v2a b/v2a-converter/Examples/bad_apple.v2a new file mode 100644 index 0000000..fbc1685 Binary files /dev/null and b/v2a-converter/Examples/bad_apple.v2a differ diff --git a/v2a-converter/Examples/fiishh.v2a b/v2a-converter/Examples/fiishh.v2a new file mode 100644 index 0000000..c66fa01 Binary files /dev/null and b/v2a-converter/Examples/fiishh.v2a differ diff --git a/v2a-converter/README.md b/v2a-converter/README.md new file mode 100644 index 0000000..ae553dc --- /dev/null +++ b/v2a-converter/README.md @@ -0,0 +1,16 @@ +# V2A-Converter + +This is the Converter where you can convert mp4 files or other Media Formats to the v2a Format + +You will have to install ffmpeg if not already installed. + +Use the help argument to see the commands you can use. + +## How to build + +1. install rustup and with it cargo +2. run `cargo build`or `cargo run ` to run it directly + +## Examples + +I put some pre converted files in the Examples Folder which i used to test the converter and player diff --git a/v2a-converter/src/audio.rs b/v2a-converter/src/audio.rs new file mode 100644 index 0000000..38d5d42 --- /dev/null +++ b/v2a-converter/src/audio.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result}; +use std::io::Write; +use std::process::{Command, Stdio}; +use tempfile::NamedTempFile; + +pub fn extract_audio(video_path: &str) -> Result> { + let output = Command::new("ffmpeg") + .args([ + "-i", video_path, + "-vn", + "-acodec", "pcm_s16le", + "-ar", "44100", + "-ac", "2", + "-f", "wav", + "-", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("Failed to start ffmpeg for audio extraction")? + .wait_with_output() + .context("Failed to read audio output")?; + if !output.status.success() { + anyhow::bail!("ffmpeg audio extraction failed"); + } + Ok(output.stdout) +} + +pub fn extract_audio_to_temp(video_path: &str) -> Result<(NamedTempFile, u64)> { + let mut temp = NamedTempFile::new()?; + let audio_data = extract_audio(video_path)?; + temp.write_all(&audio_data)?; + let size = audio_data.len() as u64; + Ok((temp, size)) +} \ No newline at end of file diff --git a/v2a-converter/src/block.rs b/v2a-converter/src/block.rs new file mode 100644 index 0000000..ca466e3 --- /dev/null +++ b/v2a-converter/src/block.rs @@ -0,0 +1,79 @@ +use crate::color::{Ansi256Palette, Rgb}; +use crate::V2AFrame; + +pub struct BlockProcessor { + palette: Ansi256Palette, +} + +impl BlockProcessor { + pub fn new() -> Self { + Self { + palette: Ansi256Palette::new(), + } + } + + pub fn process_frame( + &self, + rgb_data: &[u8], + original_width: u32, + original_height: u32, + ) -> V2AFrame { + let block_width = (original_width / 2) as u16; + let block_height = (original_height / 2) as u16; + let mut frame = V2AFrame::new(block_width, block_height); + let stride = (original_width * 3) as usize; + for y in 0..block_height { + let base_y = (y as u32) * 2; + for x in 0..block_width { + let base_x = (x as u32) * 2; + let mut top_r = 0u32; + let mut top_g = 0u32; + let mut top_b = 0u32; + let mut bottom_r = 0u32; + let mut bottom_g = 0u32; + let mut bottom_b = 0u32; + for dy in 0..2 { + let row = base_y + dy; + let row_start = row as usize * stride; + for dx in 0..2 { + let col = base_x + dx; + let pixel_start = row_start + (col as usize) * 3; + let r = rgb_data[pixel_start] as u32; + let g = rgb_data[pixel_start + 1] as u32; + let b = rgb_data[pixel_start + 2] as u32; + if dy == 0 { + top_r += r; + top_g += g; + top_b += b; + } else { + bottom_r += r; + bottom_g += g; + bottom_b += b; + } + } + } + let top_avg = Rgb::new( + (top_r / 2) as u8, + (top_g / 2) as u8, + (top_b / 2) as u8, + ); + let bottom_avg = Rgb::new( + (bottom_r / 2) as u8, + (bottom_g / 2) as u8, + (bottom_b / 2) as u8, + ); + let top_idx = self.palette.find_closest(top_avg); + let bottom_idx = self.palette.find_closest(bottom_avg); + frame.pixel_pairs[(y as usize) * (block_width as usize) + (x as usize)] = + [top_idx, bottom_idx]; + } + } + frame + } +} + +impl Default for BlockProcessor { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/v2a-converter/src/color.rs b/v2a-converter/src/color.rs new file mode 100644 index 0000000..f4eaad9 --- /dev/null +++ b/v2a-converter/src/color.rs @@ -0,0 +1,104 @@ +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex}; +use lru::LruCache; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + fn distance_squared(&self, other: &Rgb) -> u32 { + let dr = self.r as i32 - other.r as i32; + let dg = self.g as i32 - other.g as i32; + let db = self.b as i32 - other.b as i32; + (dr * dr + dg * dg + db * db) as u32 + } +} + +pub struct Ansi256Palette { + colors: Vec, + cache: Arc>>, +} + +impl Ansi256Palette { + pub fn new() -> Self { + let mut colors = Vec::with_capacity(256); + let standard = [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ]; + for &(r, g, b) in &standard { + colors.push(Rgb::new(r, g, b)); + } + let steps = [0, 95, 135, 175, 215, 255]; + for r in 0..6 { + for g in 0..6 { + for b in 0..6 { + colors.push(Rgb::new(steps[r], steps[g], steps[b])); + } + } + } + for i in 0..24 { + let gray = 8 + i * 10; + colors.push(Rgb::new(gray, gray, gray)); + } + assert_eq!(colors.len(), 256); + Self { + colors, + cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(65536).unwrap()))), + } + } + + pub fn find_closest(&self, rgb: Rgb) -> u8 { + { + let mut cache = self.cache.lock().unwrap(); + if let Some(&index) = cache.get(&rgb) { + return index; + } + } + let mut best_index = 0; + let mut best_dist = u32::MAX; + for (i, palette_color) in self.colors.iter().enumerate() { + let dist = rgb.distance_squared(palette_color); + if dist < best_dist { + best_dist = dist; + best_index = i; + } + } + let best_index = best_index as u8; + let mut cache = self.cache.lock().unwrap(); + cache.put(rgb, best_index); + best_index + } + + pub fn get_color(&self, index: u8) -> Rgb { + self.colors[index as usize] + } +} + +impl Default for Ansi256Palette { + fn default() -> Self { + Self::new() + } +} diff --git a/v2a-converter/src/converter.rs b/v2a-converter/src/converter.rs new file mode 100644 index 0000000..c18d537 --- /dev/null +++ b/v2a-converter/src/converter.rs @@ -0,0 +1,126 @@ +use crate::audio; +use crate::block::BlockProcessor; +use crate::video::{VideoInfo, FrameExtractor}; +use crate::{V2AHeader, V2AFrame}; +use anyhow::{Context, Result}; +use crossbeam_channel::bounded; +use indicatif::{ProgressBar, ProgressStyle}; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::sync::Arc; +use std::thread; + +pub struct Converter { + num_workers: usize, +} + +impl Converter { + pub fn new(num_workers: usize) -> Self { + Self { num_workers } + } + + pub fn convert(&self, input_path: &str, output_path: &str) -> Result<()> { + let info = VideoInfo::from_path(input_path) + .context("Failed to get video info")?; + println!("Video: {}x{} @ {:.2} fps, {} frames", info.width, info.height, info.fps, info.frame_count); + let progress = ProgressBar::new(info.frame_count as u64); + progress.set_style(ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}") + .unwrap()); + progress.set_message("Extracting audio..."); + let audio_data = audio::extract_audio(input_path) + .context("Audio extraction failed")?; + let audio_size = audio_data.len() as u64; + progress.set_message("Audio extracted"); + let file = File::create(output_path) + .context("Failed to create output file")?; + let mut writer = BufWriter::new(file); + + let header = V2AHeader::new( + info.frame_count, + info.width, + info.height, + info.fps, + audio_size, + ); + header.write(&mut writer) + .context("Failed to write header")?; + + writer.write_all(&audio_data) + .context("Failed to write audio data")?; + progress.set_message("Audio written"); + + let (raw_tx, raw_rx) = bounded::<(usize, Vec)>(self.num_workers * 2); + let (processed_tx, processed_rx) = bounded::<(usize, V2AFrame)>(self.num_workers * 2); + + let writer_thread = thread::spawn(move || -> Result<()> { + let mut next_frame = 0; + let mut buffer = std::collections::BTreeMap::new(); + while let Ok((idx, frame)) = processed_rx.recv() { + buffer.insert(idx, frame); + while let Some(frame) = buffer.remove(&next_frame) { + frame.write_compressed(&mut writer) + .context("Failed to write compressed frame")?; + next_frame += 1; + } + } + for (idx, frame) in buffer.into_iter() { + if idx != next_frame { + anyhow::bail!("Missing frame {}, got {}", next_frame, idx); + } + frame.write_compressed(&mut writer)?; + next_frame += 1; + } + writer.flush()?; + Ok(()) + }); + + let block_processor = Arc::new(BlockProcessor::new()); + let width = info.width; + let height = info.height; + let worker_handles: Vec<_> = (0..self.num_workers) + .map(|_| { + let raw_rx = raw_rx.clone(); + let processed_tx = processed_tx.clone(); + let block_processor = block_processor.clone(); + let progress = progress.clone(); + thread::spawn(move || -> Result<()> { + while let Ok((idx, rgb_data)) = raw_rx.recv() { + let frame = block_processor.process_frame( + &rgb_data, + width, + height, + ); + processed_tx.send((idx, frame)) + .context("Failed to send processed frame")?; + progress.inc(1); + } + Ok(()) + }) + }) + .collect(); + + let mut extractor = FrameExtractor::new(input_path, info.width, info.height) + .context("Failed to start frame extractor")?; + let frame_size = (info.width * info.height * 3) as usize; + let mut frame_buffer = vec![0; frame_size]; + let mut frame_index = 0; + while extractor.read_frame(&mut frame_buffer) + .context("Failed to read frame")? + { + raw_tx.send((frame_index, frame_buffer.clone())) + .context("Failed to send raw frame")?; + frame_index += 1; + } + drop(raw_tx); + + for handle in worker_handles { + handle.join().unwrap()?; + } + drop(processed_tx); + + writer_thread.join().unwrap()?; + progress.finish_with_message("Conversion complete"); + Ok(()) + } +} \ No newline at end of file diff --git a/v2a-converter/src/lib.rs b/v2a-converter/src/lib.rs new file mode 100644 index 0000000..d435dc6 --- /dev/null +++ b/v2a-converter/src/lib.rs @@ -0,0 +1,143 @@ +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use std::io::{Read, Write}; + +pub mod audio; +pub mod block; +pub mod color; +pub mod converter; +pub mod video; + +pub const MAGIC: &[u8; 4] = b"V2A\0"; +pub const VERSION: u16 = 2; + +#[derive(Debug, Clone, Copy)] +pub struct V2AHeader { + pub magic: [u8; 4], + pub version: u16, + pub frame_count: u32, + pub original_width: u32, + pub original_height: u32, + pub fps: f32, + pub audio_size: u64, + pub _padding: [u8; 2], +} + +impl V2AHeader { + pub fn new( + frame_count: u32, + original_width: u32, + original_height: u32, + fps: f32, + audio_size: u64, + ) -> Self { + Self { + magic: *MAGIC, + version: VERSION, + frame_count, + original_width, + original_height, + fps, + audio_size, + _padding: [0; 2], + } + } + + pub fn write(&self, mut writer: W) -> std::io::Result<()> { + writer.write_all(&self.magic)?; + writer.write_u16::(self.version)?; + writer.write_u32::(self.frame_count)?; + writer.write_u32::(self.original_width)?; + writer.write_u32::(self.original_height)?; + writer.write_f32::(self.fps)?; + writer.write_u64::(self.audio_size)?; + writer.write_all(&self._padding)?; + Ok(()) + } + + pub fn read(mut reader: R) -> std::io::Result { + let mut magic = [0; 4]; + reader.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid magic", + )); + } + let version = reader.read_u16::()?; + if version != VERSION { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unsupported version", + )); + } + let frame_count = reader.read_u32::()?; + let original_width = reader.read_u32::()?; + let original_height = reader.read_u32::()?; + let fps = reader.read_f32::()?; + let audio_size = reader.read_u64::()?; + let mut padding = [0; 2]; + reader.read_exact(&mut padding)?; + Ok(Self { + magic, + version, + frame_count, + original_width, + original_height, + fps, + audio_size, + _padding: padding, + }) + } +} + +#[derive(Debug, Clone)] +pub struct V2AFrame { + pub width: u16, + pub height: u16, + pub pixel_pairs: Vec<[u8; 2]>, +} + +impl V2AFrame { + pub fn new(width: u16, height: u16) -> Self { + Self { + width, + height, + pixel_pairs: vec![[0, 0]; (width as usize) * (height as usize)], + } + } + + pub fn write_compressed(&self, writer: W) -> std::io::Result<()> { + let mut encoder = GzEncoder::new(writer, Compression::best()); + encoder.write_u16::(self.width)?; + encoder.write_u16::(self.height)?; + for pair in &self.pixel_pairs { + encoder.write_all(pair)?; + } + encoder.finish()?; + Ok(()) + } + + pub fn read_compressed(reader: R) -> std::io::Result { + let mut decoder = GzDecoder::new(reader); + let width = decoder.read_u16::()?; + let height = decoder.read_u16::()?; + let pixel_count = (width as usize) * (height as usize); + let mut pixel_pairs = Vec::with_capacity(pixel_count); + for _ in 0..pixel_count { + let mut pair = [0; 2]; + decoder.read_exact(&mut pair)?; + pixel_pairs.push(pair); + } + Ok(Self { + width, + height, + pixel_pairs, + }) + } +} + +pub use converter::Converter; +pub use block::BlockProcessor; +pub use color::Ansi256Palette; +pub use video::{VideoInfo, FrameExtractor}; \ No newline at end of file diff --git a/v2a-converter/src/main.rs b/v2a-converter/src/main.rs new file mode 100644 index 0000000..4c75609 --- /dev/null +++ b/v2a-converter/src/main.rs @@ -0,0 +1,54 @@ +use clap::{Parser, Subcommand}; +use v2a_converter::{Converter, V2AHeader}; +use std::fs::File; +use std::io::BufReader; + +#[derive(Parser)] +#[command(name = "v2a-converter")] +#[command(about = "Convert video to V2A format", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Convert { + input: String, + output: String, + #[arg(short, long, default_value_t = num_cpus::get())] + workers: usize, + #[arg(long)] + fps: Option, + }, + Info { + file: String, + }, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Convert { input, output, workers, fps } => { + let converter = Converter::new(workers); + converter.convert(&input, &output)?; + println!("Successfully converted {} to {}", input, output); + } + Commands::Info { file } => { + let f = File::open(&file)?; + let mut reader = BufReader::new(f); + let header = V2AHeader::read(&mut reader)?; + println!("V2A File: {}", file); + println!(" Magic: {}", String::from_utf8_lossy(&header.magic)); + println!(" Version: {}", header.version); + println!(" Frames: {}", header.frame_count); + println!(" Original resolution: {}x{}", header.original_width, header.original_height); + println!(" FPS: {:.2}", header.fps); + println!(" Audio size: {} bytes", header.audio_size); + let metadata = std::fs::metadata(&file)?; + println!(" Total file size: {} bytes", metadata.len()); + println!(" Frame data size: {} bytes", metadata.len() - 32 - header.audio_size); + } + } + Ok(()) +} \ No newline at end of file diff --git a/v2a-converter/src/video.rs b/v2a-converter/src/video.rs new file mode 100644 index 0000000..85d3e48 --- /dev/null +++ b/v2a-converter/src/video.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::process::{Command, Stdio}; +use std::io::Read; + +#[derive(Debug, Clone)] +pub struct VideoInfo { + pub width: u32, + pub height: u32, + pub frame_count: u32, + pub fps: f32, + pub duration: f32, +} + +fn parse_fraction(fraction: &str) -> Option<(u32, u32)> { + let parts: Vec<&str> = fraction.split('/').collect(); + if parts.len() == 2 { + let num = parts[0].parse().ok()?; + let den = parts[1].parse().ok()?; + Some((num, den)) + } else { + None + } +} + +impl VideoInfo { + pub fn from_path(path: &str) -> Result { + let output = Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + path, + ]) + .output() + .context("Failed to execute ffprobe")?; + if !output.status.success() { + anyhow::bail!("ffprobe failed: {}", String::from_utf8_lossy(&output.stderr)); + } + let probe: FfprobeOutput = serde_json::from_slice(&output.stdout) + .context("Failed to parse ffprobe JSON")?; + + let video_stream = probe + .streams + .into_iter() + .find(|s| s.codec_type == "video") + .context("No video stream found")?; + + let width = video_stream.width.unwrap_or(0); + let height = video_stream.height.unwrap_or(0); + let nb_frames = video_stream.nb_frames.and_then(|s| s.parse().ok()); + let avg_frame_rate = video_stream.avg_frame_rate.as_deref() + .and_then(parse_fraction) + .unwrap_or((0, 1)); + let fps = if avg_frame_rate.1 == 0 { 0.0 } else { avg_frame_rate.0 as f32 / avg_frame_rate.1 as f32 }; + let duration = video_stream.duration + .as_deref() + .and_then(|s| s.parse().ok()) + .or_else(|| probe.format.duration.as_deref().and_then(|s| s.parse().ok())) + .unwrap_or(0.0); + let frame_count = nb_frames.unwrap_or_else(|| { + (duration * fps).round() as u32 + }); + + Ok(Self { + width, + height, + frame_count, + fps, + duration, + }) + } +} + +#[derive(Debug, Deserialize)] +struct FfprobeOutput { + streams: Vec, + format: Format, +} + +#[derive(Debug, Deserialize)] +struct Stream { + codec_type: String, + width: Option, + height: Option, + #[serde(rename = "nb_frames")] + nb_frames: Option, + #[serde(rename = "avg_frame_rate")] + avg_frame_rate: Option, + duration: Option, +} + +#[derive(Debug, Deserialize)] +struct Format { + duration: Option, +} + +pub struct FrameExtractor { + width: u32, + height: u32, + child: std::process::Child, + stdout: std::process::ChildStdout, + frame_size: usize, +} + +impl FrameExtractor { + pub fn new(path: &str, width: u32, height: u32) -> Result { + let mut child = Command::new("ffmpeg") + .args([ + "-i", path, + "-vf", "format=rgb24", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("Failed to start ffmpeg")?; + + let stdout = child.stdout.take().context("Failed to capture stdout")?; + let frame_size = (width * height * 3) as usize; + + Ok(Self { + width, + height, + child, + stdout, + frame_size, + }) + } + + pub fn read_frame(&mut self, buffer: &mut [u8]) -> Result { + buffer.iter_mut().for_each(|b| *b = 0); + let mut read = 0; + while read < self.frame_size { + match self.stdout.read(&mut buffer[read..]) { + Ok(0) => return Ok(false), + Ok(n) => read += n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + } + Ok(true) + } + + pub fn width(&self) -> u32 { self.width } + pub fn height(&self) -> u32 { self.height } +} + +impl Drop for FrameExtractor { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} \ No newline at end of file diff --git a/v2a-player/.gitignore b/v2a-player/.gitignore new file mode 100644 index 0000000..a31886c --- /dev/null +++ b/v2a-player/.gitignore @@ -0,0 +1,214 @@ +# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,python +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,linux,python + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,python + +# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) + diff --git a/v2a-player/README.md b/v2a-player/README.md new file mode 100644 index 0000000..28cf427 --- /dev/null +++ b/v2a-player/README.md @@ -0,0 +1,10 @@ +# V2A Player + +This is the player which you can use to play v2a files, currently thsi implemention is not stable and can have issues, it is experimental for now on. + +## How to Build + +1. Create a Venv +2. install build via pip +3. run `python -m build --wheel` +4. install the wheel file in dist using pipx or pip depending on your OS diff --git a/v2a-player/pyproject.toml b/v2a-player/pyproject.toml new file mode 100644 index 0000000..4ac4e6b --- /dev/null +++ b/v2a-player/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "v2a-player" +version = "0.1.0" +description = "Player for V2A video format" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "numpy", + "pygame", + "rich", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "mypy", +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project.scripts] +v2a-player = "v2a_player.cli:main" \ No newline at end of file diff --git a/v2a-player/v2a_player/__init__.py b/v2a-player/v2a_player/__init__.py new file mode 100644 index 0000000..0a968e3 --- /dev/null +++ b/v2a-player/v2a_player/__init__.py @@ -0,0 +1,6 @@ +__version__ = "0.1.0" + +from .reader import V2AReader, V2AHeader, V2AFrame +from .terminal import TerminalRenderer, get_terminal_size +from .audio_player import create_audio_player +from .player import V2APlayer \ No newline at end of file diff --git a/v2a-player/v2a_player/audio_player.py b/v2a-player/v2a_player/audio_player.py new file mode 100644 index 0000000..340ede7 --- /dev/null +++ b/v2a-player/v2a_player/audio_player.py @@ -0,0 +1,155 @@ +import io +import struct +import threading +import time +from typing import Optional, Tuple + +try: + import pygame + PYGAME_AVAILABLE = True +except ImportError: + PYGAME_AVAILABLE = False + +class AudioPlayer: + + def __init__(self, wav_data: bytes): + self.wav_data = wav_data + self.player_thread: Optional[threading.Thread] = None + self.stop_event = threading.Event() + self.paused_event = threading.Event() + self.paused_event.set() + self._parse_wav_header() + + def _parse_wav_header(self): + if len(self.wav_data) < 44: + self.valid = False + return + + if self.wav_data[0:4] != b'RIFF' or self.wav_data[8:12] != b'WAVE': + self.valid = False + return + + fmt_chunk_offset = 12 + + while fmt_chunk_offset < len(self.wav_data) - 8: + chunk_id = self.wav_data[fmt_chunk_offset:fmt_chunk_offset+4] + chunk_size = struct.unpack(' bool: + return self.valid and PYGAME_AVAILABLE + + def start(self): + if not self.is_valid() or self.player_thread is not None: + return + self.stop_event.clear() + self.paused_event.set() + self.player_thread = threading.Thread(target=self._playback_thread) + self.player_thread.start() + + def stop(self): + self.stop_event.set() + if self.player_thread: + self.player_thread.join(timeout=1.0) + self.player_thread = None + + def pause(self): + self.paused_event.clear() + + def resume(self): + self.paused_event.set() + + def seek(self, position: float): + + pass + + def _playback_thread(self): + try: + pygame.mixer.init(frequency=self.sample_rate, size=-self.bits_per_sample, + channels=self.num_channels, buffer=4096) + sound = pygame.mixer.Sound(buffer=self.audio_data) + channel = sound.play() + + while not self.stop_event.is_set(): + + self.paused_event.wait() + if self.stop_event.is_set(): + break + + if not channel.get_busy(): + break + time.sleep(0.01) + + if channel and channel.get_busy(): + channel.stop() + pygame.mixer.quit() + except Exception as e: + print(f"Audio playback error: {e}") + + def get_position(self) -> float: + + return 0.0 + +class NullAudioPlayer: + def __init__(self, wav_data: bytes): + self.wav_data = wav_data + + def is_valid(self) -> bool: + return False + + def start(self): + pass + + def stop(self): + pass + + def pause(self): + pass + + def resume(self): + pass + + def seek(self, position: float): + pass + + def get_position(self) -> float: + return 0.0 + +def create_audio_player(wav_data: bytes): + if PYGAME_AVAILABLE and len(wav_data) >= 44 and wav_data[0:4] == b'RIFF': + player = AudioPlayer(wav_data) + if player.is_valid(): + return player + return NullAudioPlayer(wav_data) diff --git a/v2a-player/v2a_player/cli.py b/v2a-player/v2a_player/cli.py new file mode 100755 index 0000000..0a513ee --- /dev/null +++ b/v2a-player/v2a_player/cli.py @@ -0,0 +1,103 @@ + + +import argparse +import sys +import os + +def main(): + + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + + from v2a_player.player import V2APlayer + from v2a_player.reader import V2AReader + + parser = argparse.ArgumentParser( + description="V2A Player - Terminal-based player for V2A video format", + epilog="For more information, see README.md" + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to execute") + + + info_parser = subparsers.add_parser("info", help="Display information about V2A file") + info_parser.add_argument("file", help="V2A file to examine") + + + play_parser = subparsers.add_parser("play", help="Play V2A video file") + play_parser.add_argument("file", help="V2A file to play") + + + args = parser.parse_args() + + if args.command == "info": + info_command(args, V2AReader) + elif args.command == "play": + play_command(args, V2APlayer) + else: + parser.print_help() + sys.exit(1) + +def info_command(args, reader_class): + try: + with reader_class(args.file) as reader: + print(f"File: {args.file}") + print(f" Magic: {reader.header.magic!r}") + print(f" Version: {reader.header.version}") + print(f" Frame count: {reader.header.frame_count}") + print(f" Original resolution: {reader.header.original_width}x{reader.header.original_height}") + print(f" FPS: {reader.header.fps:.2f}") + print(f" Audio size: {reader.header.audio_size} bytes") + + + first_frame = reader.read_frame() + if first_frame: + print(f" Frame dimensions: {first_frame.width}x{first_frame.height} characters") + print(f" Pixel pairs: {len(first_frame.pixel_pairs)}") + + + if reader.audio: + print(f" Audio: Available ({len(reader.audio)} bytes)") + + if len(reader.audio) >= 44: + try: + import struct + + if reader.audio[0:4] == b'RIFF': + fmt = reader.audio[8:12] + if fmt == b'WAVE': + print(f" Audio format: WAV") + except: + pass + else: + print(f" Audio: Not present") + + except Exception as e: + print(f"Error reading {args.file}: {e}", file=sys.stderr) + sys.exit(1) + +def play_command(args, player_class): + if not os.path.exists(args.file): + print(f"Error: File not found: {args.file}", file=sys.stderr) + sys.exit(1) + + try: + player = player_class(args.file) + player.load() + + if args.speed: + player.playback_speed = args.speed + + print(f"Starting playback...") + player.play() + + except KeyboardInterrupt: + print("\nPlayback interrupted") + except Exception as e: + print(f"Error during playback: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/v2a-player/v2a_player/player.py b/v2a-player/v2a_player/player.py new file mode 100644 index 0000000..90ec87f --- /dev/null +++ b/v2a-player/v2a_player/player.py @@ -0,0 +1,205 @@ +import sys +import time +import threading +import select +import tty +import termios +from typing import Optional +from .reader import V2AReader +from .terminal import TerminalRenderer +from .audio_player import create_audio_player + +class V2APlayer: + + def __init__(self, filepath: str): + self.filepath = filepath + self.reader: Optional[V2AReader] = None + self.renderer: Optional[TerminalRenderer] = None + self.audio_player = None + self.playing = False + self.paused = False + self.current_frame = 0 + self.frame_delay = 0.0 + self.control_thread: Optional[threading.Thread] = None + self.stop_event = threading.Event() + self.paused_event = threading.Event() + self.paused_event.set() + self.original_termios = None + + def load(self): + self.reader = V2AReader(self.filepath) + + first_frame = self.reader.read_frame() + if first_frame is None: + raise ValueError("No frames in V2A file") + self.reader.reset() + + self.renderer = TerminalRenderer() + self.renderer.update_layout(first_frame.width, first_frame.height) + + if self.reader.audio: + self.audio_player = create_audio_player(self.reader.audio) + + self.frame_delay = 1.0 / self.reader.frame_rate if self.reader.frame_rate > 0 else 0.1 + + print(f"Loaded: {self.reader.header.frame_count} frames, " + f"{self.reader.frame_rate:.1f} fps, " + f"{first_frame.width}x{first_frame.height} chars") + if self.audio_player and self.audio_player.is_valid(): + print("Audio: Available") + else: + print("Audio: Not available (install pygame for audio)") + + def _setup_terminal(self): + if not sys.stdin.isatty(): + return + self.original_termios = termios.tcgetattr(sys.stdin) + tty.setraw(sys.stdin.fileno()) + + def _restore_terminal(self): + if self.original_termios: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.original_termios) + self.original_termios = None + + def _handle_input(self): + while not self.stop_event.is_set(): + + ready, _, _ = select.select([sys.stdin], [], [], 0.1) + if ready: + ch = sys.stdin.read(1) + self._process_key(ch) + + def _process_key(self, key: str): + if key == ' ': + self.toggle_pause() + elif key == 'q' or key == '\x03': + self.stop() + elif key == 'f': + + if self.reader: + first_frame = None + pos = self.reader.file.tell() + self.reader.reset() + first_frame = self.reader.read_frame() + self.reader.reset() + if first_frame: + self.renderer.update_layout(first_frame.width, first_frame.height) + print(f"\rResized to: {self.renderer.scaled_width}x{self.renderer.scaled_height}") + + def toggle_pause(self): + self.paused = not self.paused + if self.paused: + self.paused_event.clear() + if self.audio_player: + self.audio_player.pause() + print("\rPaused", end='') + else: + self.paused_event.set() + if self.audio_player: + self.audio_player.resume() + print("\rPlaying", end='') + sys.stdout.flush() + + def _playback_loop(self): + frame_count = self.reader.header.frame_count + start_time = time.time() + expected_frame = 0 + + while (not self.stop_event.is_set() and + self.current_frame < frame_count): + + self.paused_event.wait() + if self.stop_event.is_set(): + break + + elapsed = time.time() - start_time + expected_frame = int(elapsed / self.frame_delay) + + if self.current_frame > expected_frame: + time.sleep(0.001) + continue + + while self.current_frame < expected_frame and self.current_frame < frame_count - 1: + + frame = self.reader.read_frame() + if frame is None: + break + self.current_frame += 1 + + if self.renderer.check_resize(): + sys.stdout.write(self.renderer.prepare_display()) + sys.stdout.flush() + + + frame = self.reader.read_frame() + if frame is None: + break + + output = self.renderer.render_frame(frame.pixel_pairs, frame.width, frame.height) + sys.stdout.write(self.renderer.clear_video_area() + self.renderer.frame_prefix() + output) + sys.stdout.flush() + + self.current_frame += 1 + + target_time = start_time + (self.current_frame * self.frame_delay) + sleep_time = target_time - time.time() + if sleep_time > 0: + time.sleep(sleep_time) + + def play(self): + if self.playing: + return + + self.playing = True + self.stop_event.clear() + self.paused_event.set() + + try: + + self._setup_terminal() + + if self.audio_player and self.audio_player.is_valid(): + self.audio_player.start() + + sys.stdout.write(self.renderer.prepare_display()) + sys.stdout.flush() + + if sys.stdin.isatty(): + self.control_thread = threading.Thread(target=self._handle_input) + self.control_thread.start() + else: + self.control_thread = None + + self._playback_loop() + + except KeyboardInterrupt: + pass + finally: + self.stop() + + def stop(self): + if not self.playing: + return + + self.stop_event.set() + self.paused_event.set() + + if self.audio_player: + self.audio_player.stop() + + if self.control_thread: + self.control_thread.join(timeout=0.5) + + self._restore_terminal() + + if self.renderer: + sys.stdout.write(self.renderer.restore_display()) + sys.stdout.flush() + + self.playing = False + print(f"\nPlayback stopped at frame {self.current_frame}/{self.reader.header.frame_count}") + + def close(self): + self.stop() + if self.reader: + self.reader.close() diff --git a/v2a-player/v2a_player/reader.py b/v2a-player/v2a_player/reader.py new file mode 100644 index 0000000..0951a7e --- /dev/null +++ b/v2a-player/v2a_player/reader.py @@ -0,0 +1,179 @@ +import struct +import gzip +import io +import zlib +from dataclasses import dataclass +from typing import BinaryIO, Iterator, Tuple, Optional + +MAGIC = b"V2A\0" +VERSION = 2 + +@dataclass +class V2AHeader: + magic: bytes + version: int + frame_count: int + original_width: int + original_height: int + fps: float + audio_size: int + padding: bytes + + @classmethod + def read(cls, f: BinaryIO) -> "V2AHeader": + magic = f.read(4) + if magic != MAGIC: + raise ValueError(f"Invalid magic: {magic!r}") + version = struct.unpack(" None: + f.write(self.magic) + f.write(struct.pack(" "V2AFrame": + import zlib + + d = zlib.decompressobj(wbits=31) + decompressed = bytearray() + chunk_size = 4096 + + while True: + + chunk = f.read(chunk_size) + if not chunk: + raise EOFError("End of file while reading gzip stream") + + try: + + decompressed.extend(d.decompress(chunk)) + except zlib.error as e: + raise ValueError(f"zlib decompression error: {e}") + + if d.eof: + + unused_data = d.unused_data + if unused_data: + + f.seek(-len(unused_data), 1) + + if len(decompressed) < 4: + raise ValueError(f"Decompressed data too short: {len(decompressed)}") + + width = struct.unpack(" 8192 * 1024: + raise ValueError(f"Decompressed data too large ({len(decompressed)} > 8MB), likely corrupted data") + + def write_compressed(self, f: BinaryIO) -> None: + with gzip.GzipFile(fileobj=f, mode='wb') as gz: + gz.write(struct.pack(" Optional[V2AFrame]: + if self.current_frame >= self.header.frame_count: + return None + try: + frame = V2AFrame.read_compressed(self.file) + self.current_frame += 1 + return frame + except EOFError: + return None + + def frames(self) -> Iterator[V2AFrame]: + while True: + frame = self.read_frame() + if frame is None: + break + yield frame + + def reset(self): + self.file.seek(32 + self.header.audio_size) + self.current_frame = 0 + + @property + def frame_rate(self) -> float: + return self.header.fps + + @property + def original_dimensions(self) -> Tuple[int, int]: + return (self.header.original_width, self.header.original_height) + + @property + def frame_dimensions(self) -> Tuple[int, int]: + pos = self.file.tell() + self.file.seek(32 + self.header.audio_size) + try: + frame = V2AFrame.read_compressed(self.file) + self.file.seek(pos) + return (frame.width, frame.height) + except Exception: + self.file.seek(pos) + raise + + @property + def audio(self) -> bytes: + return self.audio_data diff --git a/v2a-player/v2a_player/terminal.py b/v2a-player/v2a_player/terminal.py new file mode 100644 index 0000000..57c31e5 --- /dev/null +++ b/v2a-player/v2a_player/terminal.py @@ -0,0 +1,165 @@ +import os +import shutil +import sys +import math +from typing import Tuple, Optional + +def get_terminal_size() -> Tuple[int, int]: + size = shutil.get_terminal_size() + return (size.columns, size.lines) + +def calculate_scaled_dimensions( + src_width: int, + src_height: int, + max_width: Optional[int] = None, + max_height: Optional[int] = None, +) -> Tuple[int, int]: + if max_width is None or max_height is None: + max_width, max_height = get_terminal_size() + + max_height = max_height - 2 + + if src_width <= max_width and src_height <= max_height: + return (src_width, src_height) + + width_scale = max_width / (2.0 * src_width) + height_scale = max_height / src_height + scale = min(width_scale, height_scale) + + scaled_width = int(2.0 * scale * src_width) + scaled_height = int(scale * src_height) + + scaled_width = max(1, scaled_width) + scaled_height = max(1, scaled_height) + + return (scaled_width, scaled_height) + +def calculate_centering_offset( + src_width: int, + src_height: int, + container_width: int, + container_height: int, +) -> Tuple[int, int]: + x = (container_width - src_width) // 2 + y = (container_height - src_height) // 2 + return (max(0, x), max(0, y)) + +def ansi_color_fg(index: int) -> str: + return f"\x1b[38;5;{index}m" + +def ansi_color_bg(index: int) -> str: + return f"\x1b[48;5;{index}m" + +def ansi_reset() -> str: + return "\x1b[0m" + +def ansi_clear_screen() -> str: + return "\x1b[2J\x1b[H" + +def ansi_move_cursor(row: int, col: int) -> str: + return f"\x1b[{row};{col}H" + +def ansi_hide_cursor() -> str: + return "\x1b[?25l" + +def ansi_show_cursor() -> str: + return "\x1b[?25h" + +def render_half_block(top_color: int, bottom_color: int) -> str: + if top_color == bottom_color: + + return f"{ansi_color_fg(top_color)}█{ansi_reset()}" + else: + return f"{ansi_color_fg(top_color)}{ansi_color_bg(bottom_color)}▀{ansi_reset()}" + +class TerminalRenderer: + + def __init__(self): + self.term_width, self.term_height = get_terminal_size() + self.scaled_width = 0 + self.scaled_height = 0 + self.src_width = 0 + self.src_height = 0 + self.offset_x = 0 + self.offset_y = 0 + + def update_layout(self, src_width: int, src_height: int): + self.src_width = src_width + self.src_height = src_height + self.scaled_width, self.scaled_height = calculate_scaled_dimensions( + src_width, src_height, self.term_width, self.term_height + ) + self.offset_x, self.offset_y = calculate_centering_offset( + self.scaled_width, self.scaled_height, self.term_width, self.term_height + ) + + def check_resize(self) -> bool: + new_width, new_height = get_terminal_size() + if new_width != self.term_width or new_height != self.term_height: + self.term_width, self.term_height = new_width, new_height + if self.src_width > 0 and self.src_height > 0: + self.update_layout(self.src_width, self.src_height) + return True + return False + + def render_frame(self, frame_pixel_pairs, frame_width: int, frame_height: int) -> str: + + + if (self.scaled_width, self.scaled_height) != (frame_width, frame_height): + + return self._render_scaled_frame(frame_pixel_pairs, frame_width, frame_height) + else: + return self._render_exact_frame(frame_pixel_pairs, frame_width, frame_height) + + def _render_exact_frame(self, pixel_pairs, width: int, height: int) -> str: + result = [] + for y in range(height): + + row = self.offset_y + 1 + y + col = self.offset_x + 1 + result.append(ansi_move_cursor(row, col)) + line_parts = [] + for x in range(width): + idx = y * width + x + top, bottom = pixel_pairs[idx] + line_parts.append(render_half_block(top, bottom)) + result.append("".join(line_parts)) + return "".join(result) + + def _render_scaled_frame(self, pixel_pairs, src_width: int, src_height: int) -> str: + dst_width, dst_height = self.scaled_width, self.scaled_height + result = [] + for dy in range(dst_height): + + row = self.offset_y + 1 + dy + col = self.offset_x + 1 + result.append(ansi_move_cursor(row, col)) + + sy = int(dy * src_height / dst_height) + line_parts = [] + for dx in range(dst_width): + sx = int(dx * src_width / dst_width) + idx = sy * src_width + sx + top, bottom = pixel_pairs[idx] + line_parts.append(render_half_block(top, bottom)) + result.append("".join(line_parts)) + return "".join(result) + + def prepare_display(self) -> str: + return ansi_clear_screen() + ansi_hide_cursor() + + def restore_display(self) -> str: + return ansi_show_cursor() + ansi_clear_screen() + + def frame_prefix(self) -> str: + return ansi_move_cursor(self.offset_y + 1, self.offset_x + 1) + + def clear_video_area(self) -> str: + if self.scaled_width <= 0 or self.scaled_height <= 0: + return "" + result = [] + for row in range(self.offset_y + 1, self.offset_y + self.scaled_height + 1): + result.append(ansi_move_cursor(row, self.offset_x + 1)) + result.append(ansi_reset()) + result.append(" " * self.scaled_width) + return "".join(result)