Initial Commit
This commit is contained in:
5
README.md
Normal file
5
README.md
Normal 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
1
v2a-converter/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
20
v2a-converter/Cargo.toml
Normal file
20
v2a-converter/Cargo.toml
Normal 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"
|
||||
BIN
v2a-converter/Examples/bad_apple.v2a
Normal file
BIN
v2a-converter/Examples/bad_apple.v2a
Normal file
Binary file not shown.
BIN
v2a-converter/Examples/fiishh.v2a
Normal file
BIN
v2a-converter/Examples/fiishh.v2a
Normal file
Binary file not shown.
16
v2a-converter/README.md
Normal file
16
v2a-converter/README.md
Normal 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
|
||||
35
v2a-converter/src/audio.rs
Normal file
35
v2a-converter/src/audio.rs
Normal 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))
|
||||
}
|
||||
79
v2a-converter/src/block.rs
Normal file
79
v2a-converter/src/block.rs
Normal 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
104
v2a-converter/src/color.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
126
v2a-converter/src/converter.rs
Normal file
126
v2a-converter/src/converter.rs
Normal 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
143
v2a-converter/src/lib.rs
Normal 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
54
v2a-converter/src/main.rs
Normal 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
158
v2a-converter/src/video.rs
Normal 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
214
v2a-player/.gitignore
vendored
Normal 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
10
v2a-player/README.md
Normal 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
26
v2a-player/pyproject.toml
Normal 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"
|
||||
6
v2a-player/v2a_player/__init__.py
Normal file
6
v2a-player/v2a_player/__init__.py
Normal 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
|
||||
155
v2a-player/v2a_player/audio_player.py
Normal file
155
v2a-player/v2a_player/audio_player.py
Normal 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
103
v2a-player/v2a_player/cli.py
Executable 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()
|
||||
205
v2a-player/v2a_player/player.py
Normal file
205
v2a-player/v2a_player/player.py
Normal 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()
|
||||
179
v2a-player/v2a_player/reader.py
Normal file
179
v2a-player/v2a_player/reader.py
Normal 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
|
||||
165
v2a-player/v2a_player/terminal.py
Normal file
165
v2a-player/v2a_player/terminal.py
Normal 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)
|
||||
Reference in New Issue
Block a user