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