Initial Commit

This commit is contained in:
Exil Productions
2025-12-19 20:18:56 +01:00
commit 0cb370f8ec
22 changed files with 1804 additions and 0 deletions

5
README.md Normal file
View File

@@ -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.

1
v2a-converter/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

20
v2a-converter/Cargo.toml Normal file
View File

@@ -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"

Binary file not shown.

Binary file not shown.

16
v2a-converter/README.md Normal file
View File

@@ -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 <args>` to run it directly
## Examples
I put some pre converted files in the Examples Folder which i used to test the converter and player

View File

@@ -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<Vec<u8>> {
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))
}

View File

@@ -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()
}
}

104
v2a-converter/src/color.rs Normal file
View File

@@ -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<Rgb>,
cache: Arc<Mutex<LruCache<Rgb, u8>>>,
}
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()
}
}

View File

@@ -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<u8>)>(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(())
}
}

143
v2a-converter/src/lib.rs Normal file
View File

@@ -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<W: Write>(&self, mut writer: W) -> std::io::Result<()> {
writer.write_all(&self.magic)?;
writer.write_u16::<LittleEndian>(self.version)?;
writer.write_u32::<LittleEndian>(self.frame_count)?;
writer.write_u32::<LittleEndian>(self.original_width)?;
writer.write_u32::<LittleEndian>(self.original_height)?;
writer.write_f32::<LittleEndian>(self.fps)?;
writer.write_u64::<LittleEndian>(self.audio_size)?;
writer.write_all(&self._padding)?;
Ok(())
}
pub fn read<R: Read>(mut reader: R) -> std::io::Result<Self> {
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::<LittleEndian>()?;
if version != VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Unsupported version",
));
}
let frame_count = reader.read_u32::<LittleEndian>()?;
let original_width = reader.read_u32::<LittleEndian>()?;
let original_height = reader.read_u32::<LittleEndian>()?;
let fps = reader.read_f32::<LittleEndian>()?;
let audio_size = reader.read_u64::<LittleEndian>()?;
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<W: Write>(&self, writer: W) -> std::io::Result<()> {
let mut encoder = GzEncoder::new(writer, Compression::best());
encoder.write_u16::<LittleEndian>(self.width)?;
encoder.write_u16::<LittleEndian>(self.height)?;
for pair in &self.pixel_pairs {
encoder.write_all(pair)?;
}
encoder.finish()?;
Ok(())
}
pub fn read_compressed<R: Read>(reader: R) -> std::io::Result<Self> {
let mut decoder = GzDecoder::new(reader);
let width = decoder.read_u16::<LittleEndian>()?;
let height = decoder.read_u16::<LittleEndian>()?;
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};

54
v2a-converter/src/main.rs Normal file
View File

@@ -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<f32>,
},
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(())
}

158
v2a-converter/src/video.rs Normal file
View File

@@ -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<Self> {
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<Stream>,
format: Format,
}
#[derive(Debug, Deserialize)]
struct Stream {
codec_type: String,
width: Option<u32>,
height: Option<u32>,
#[serde(rename = "nb_frames")]
nb_frames: Option<String>,
#[serde(rename = "avg_frame_rate")]
avg_frame_rate: Option<String>,
duration: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Format {
duration: Option<String>,
}
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<Self> {
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<bool> {
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();
}
}

214
v2a-player/.gitignore vendored Normal file
View File

@@ -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)

10
v2a-player/README.md Normal file
View File

@@ -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

26
v2a-player/pyproject.toml Normal file
View File

@@ -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"

View File

@@ -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

View File

@@ -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('<I', self.wav_data[fmt_chunk_offset+4:fmt_chunk_offset+8])[0]
if chunk_id == b'fmt ':
break
fmt_chunk_offset += 8 + chunk_size
else:
self.valid = False
return
fmt_data = self.wav_data[fmt_chunk_offset+8:fmt_chunk_offset+8+chunk_size]
if len(fmt_data) < 16:
self.valid = False
return
self.audio_format = struct.unpack('<H', fmt_data[0:2])[0]
self.num_channels = struct.unpack('<H', fmt_data[2:4])[0]
self.sample_rate = struct.unpack('<I', fmt_data[4:8])[0]
self.byte_rate = struct.unpack('<I', fmt_data[8:12])[0]
self.block_align = struct.unpack('<H', fmt_data[12:14])[0]
self.bits_per_sample = struct.unpack('<H', fmt_data[14:16])[0]
data_chunk_offset = fmt_chunk_offset + 8 + chunk_size
while data_chunk_offset < len(self.wav_data) - 8:
chunk_id = self.wav_data[data_chunk_offset:data_chunk_offset+4]
chunk_size = struct.unpack('<I', self.wav_data[data_chunk_offset+4:data_chunk_offset+8])[0]
if chunk_id == b'data':
self.audio_data = self.wav_data[data_chunk_offset+8:data_chunk_offset+8+chunk_size]
self.audio_data_offset = data_chunk_offset + 8
self.audio_data_size = chunk_size
break
data_chunk_offset += 8 + chunk_size
else:
self.valid = False
return
self.valid = True
self.duration = len(self.audio_data) / self.byte_rate
def is_valid(self) -> 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)

103
v2a-player/v2a_player/cli.py Executable file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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("<H", f.read(2))[0]
if version != VERSION:
raise ValueError(f"Unsupported version: {version}")
frame_count = struct.unpack("<I", f.read(4))[0]
original_width = struct.unpack("<I", f.read(4))[0]
original_height = struct.unpack("<I", f.read(4))[0]
fps = struct.unpack("<f", f.read(4))[0]
audio_size = struct.unpack("<Q", f.read(8))[0]
padding = f.read(2)
return cls(
magic=magic,
version=version,
frame_count=frame_count,
original_width=original_width,
original_height=original_height,
fps=fps,
audio_size=audio_size,
padding=padding,
)
def write(self, f: BinaryIO) -> None:
f.write(self.magic)
f.write(struct.pack("<H", self.version))
f.write(struct.pack("<I", self.frame_count))
f.write(struct.pack("<I", self.original_width))
f.write(struct.pack("<I", self.original_height))
f.write(struct.pack("<f", self.fps))
f.write(struct.pack("<Q", self.audio_size))
f.write(self.padding)
@dataclass
class V2AFrame:
width: int
height: int
pixel_pairs: list
@classmethod
def read_compressed(cls, f: BinaryIO) -> "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("<H", decompressed[0:2])[0]
height = struct.unpack("<H", decompressed[2:4])[0]
pixel_count = width * height
expected_len = 4 + pixel_count * 2
if len(decompressed) < expected_len:
raise ValueError(f"Decompressed data too short: expected {expected_len}, got {len(decompressed)}")
data = bytes(decompressed[4:expected_len])
pixel_pairs = [list(data[i:i+2]) for i in range(0, len(data), 2)]
return cls(width, height, pixel_pairs)
if len(decompressed) > 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("<H", self.width))
gz.write(struct.pack("<H", self.height))
for pair in self.pixel_pairs:
gz.write(bytes(pair))
class V2AReader:
def __init__(self, path: str):
self.path = path
self.file = open(path, 'rb')
self.header = V2AHeader.read(self.file)
self.audio_data = self.file.read(self.header.audio_size)
if len(self.audio_data) != self.header.audio_size:
raise ValueError(f"Incomplete audio data: expected {self.header.audio_size}, got {len(self.audio_data)}")
self.current_frame = 0
def close(self):
self.file.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def read_frame(self) -> 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

View File

@@ -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)