Initial Commit

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

View File

@@ -0,0 +1,6 @@
__version__ = "0.1.0"
from .reader import V2AReader, V2AHeader, V2AFrame
from .terminal import TerminalRenderer, get_terminal_size
from .audio_player import create_audio_player
from .player import V2APlayer

View File

@@ -0,0 +1,155 @@
import io
import struct
import threading
import time
from typing import Optional, Tuple
try:
import pygame
PYGAME_AVAILABLE = True
except ImportError:
PYGAME_AVAILABLE = False
class AudioPlayer:
def __init__(self, wav_data: bytes):
self.wav_data = wav_data
self.player_thread: Optional[threading.Thread] = None
self.stop_event = threading.Event()
self.paused_event = threading.Event()
self.paused_event.set()
self._parse_wav_header()
def _parse_wav_header(self):
if len(self.wav_data) < 44:
self.valid = False
return
if self.wav_data[0:4] != b'RIFF' or self.wav_data[8:12] != b'WAVE':
self.valid = False
return
fmt_chunk_offset = 12
while fmt_chunk_offset < len(self.wav_data) - 8:
chunk_id = self.wav_data[fmt_chunk_offset:fmt_chunk_offset+4]
chunk_size = struct.unpack('<I', self.wav_data[fmt_chunk_offset+4:fmt_chunk_offset+8])[0]
if chunk_id == b'fmt ':
break
fmt_chunk_offset += 8 + chunk_size
else:
self.valid = False
return
fmt_data = self.wav_data[fmt_chunk_offset+8:fmt_chunk_offset+8+chunk_size]
if len(fmt_data) < 16:
self.valid = False
return
self.audio_format = struct.unpack('<H', fmt_data[0:2])[0]
self.num_channels = struct.unpack('<H', fmt_data[2:4])[0]
self.sample_rate = struct.unpack('<I', fmt_data[4:8])[0]
self.byte_rate = struct.unpack('<I', fmt_data[8:12])[0]
self.block_align = struct.unpack('<H', fmt_data[12:14])[0]
self.bits_per_sample = struct.unpack('<H', fmt_data[14:16])[0]
data_chunk_offset = fmt_chunk_offset + 8 + chunk_size
while data_chunk_offset < len(self.wav_data) - 8:
chunk_id = self.wav_data[data_chunk_offset:data_chunk_offset+4]
chunk_size = struct.unpack('<I', self.wav_data[data_chunk_offset+4:data_chunk_offset+8])[0]
if chunk_id == b'data':
self.audio_data = self.wav_data[data_chunk_offset+8:data_chunk_offset+8+chunk_size]
self.audio_data_offset = data_chunk_offset + 8
self.audio_data_size = chunk_size
break
data_chunk_offset += 8 + chunk_size
else:
self.valid = False
return
self.valid = True
self.duration = len(self.audio_data) / self.byte_rate
def is_valid(self) -> bool:
return self.valid and PYGAME_AVAILABLE
def start(self):
if not self.is_valid() or self.player_thread is not None:
return
self.stop_event.clear()
self.paused_event.set()
self.player_thread = threading.Thread(target=self._playback_thread)
self.player_thread.start()
def stop(self):
self.stop_event.set()
if self.player_thread:
self.player_thread.join(timeout=1.0)
self.player_thread = None
def pause(self):
self.paused_event.clear()
def resume(self):
self.paused_event.set()
def seek(self, position: float):
pass
def _playback_thread(self):
try:
pygame.mixer.init(frequency=self.sample_rate, size=-self.bits_per_sample,
channels=self.num_channels, buffer=4096)
sound = pygame.mixer.Sound(buffer=self.audio_data)
channel = sound.play()
while not self.stop_event.is_set():
self.paused_event.wait()
if self.stop_event.is_set():
break
if not channel.get_busy():
break
time.sleep(0.01)
if channel and channel.get_busy():
channel.stop()
pygame.mixer.quit()
except Exception as e:
print(f"Audio playback error: {e}")
def get_position(self) -> float:
return 0.0
class NullAudioPlayer:
def __init__(self, wav_data: bytes):
self.wav_data = wav_data
def is_valid(self) -> bool:
return False
def start(self):
pass
def stop(self):
pass
def pause(self):
pass
def resume(self):
pass
def seek(self, position: float):
pass
def get_position(self) -> float:
return 0.0
def create_audio_player(wav_data: bytes):
if PYGAME_AVAILABLE and len(wav_data) >= 44 and wav_data[0:4] == b'RIFF':
player = AudioPlayer(wav_data)
if player.is_valid():
return player
return NullAudioPlayer(wav_data)

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

@@ -0,0 +1,103 @@
import argparse
import sys
import os
def main():
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from v2a_player.player import V2APlayer
from v2a_player.reader import V2AReader
parser = argparse.ArgumentParser(
description="V2A Player - Terminal-based player for V2A video format",
epilog="For more information, see README.md"
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
info_parser = subparsers.add_parser("info", help="Display information about V2A file")
info_parser.add_argument("file", help="V2A file to examine")
play_parser = subparsers.add_parser("play", help="Play V2A video file")
play_parser.add_argument("file", help="V2A file to play")
args = parser.parse_args()
if args.command == "info":
info_command(args, V2AReader)
elif args.command == "play":
play_command(args, V2APlayer)
else:
parser.print_help()
sys.exit(1)
def info_command(args, reader_class):
try:
with reader_class(args.file) as reader:
print(f"File: {args.file}")
print(f" Magic: {reader.header.magic!r}")
print(f" Version: {reader.header.version}")
print(f" Frame count: {reader.header.frame_count}")
print(f" Original resolution: {reader.header.original_width}x{reader.header.original_height}")
print(f" FPS: {reader.header.fps:.2f}")
print(f" Audio size: {reader.header.audio_size} bytes")
first_frame = reader.read_frame()
if first_frame:
print(f" Frame dimensions: {first_frame.width}x{first_frame.height} characters")
print(f" Pixel pairs: {len(first_frame.pixel_pairs)}")
if reader.audio:
print(f" Audio: Available ({len(reader.audio)} bytes)")
if len(reader.audio) >= 44:
try:
import struct
if reader.audio[0:4] == b'RIFF':
fmt = reader.audio[8:12]
if fmt == b'WAVE':
print(f" Audio format: WAV")
except:
pass
else:
print(f" Audio: Not present")
except Exception as e:
print(f"Error reading {args.file}: {e}", file=sys.stderr)
sys.exit(1)
def play_command(args, player_class):
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
try:
player = player_class(args.file)
player.load()
if args.speed:
player.playback_speed = args.speed
print(f"Starting playback...")
player.play()
except KeyboardInterrupt:
print("\nPlayback interrupted")
except Exception as e:
print(f"Error during playback: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,205 @@
import sys
import time
import threading
import select
import tty
import termios
from typing import Optional
from .reader import V2AReader
from .terminal import TerminalRenderer
from .audio_player import create_audio_player
class V2APlayer:
def __init__(self, filepath: str):
self.filepath = filepath
self.reader: Optional[V2AReader] = None
self.renderer: Optional[TerminalRenderer] = None
self.audio_player = None
self.playing = False
self.paused = False
self.current_frame = 0
self.frame_delay = 0.0
self.control_thread: Optional[threading.Thread] = None
self.stop_event = threading.Event()
self.paused_event = threading.Event()
self.paused_event.set()
self.original_termios = None
def load(self):
self.reader = V2AReader(self.filepath)
first_frame = self.reader.read_frame()
if first_frame is None:
raise ValueError("No frames in V2A file")
self.reader.reset()
self.renderer = TerminalRenderer()
self.renderer.update_layout(first_frame.width, first_frame.height)
if self.reader.audio:
self.audio_player = create_audio_player(self.reader.audio)
self.frame_delay = 1.0 / self.reader.frame_rate if self.reader.frame_rate > 0 else 0.1
print(f"Loaded: {self.reader.header.frame_count} frames, "
f"{self.reader.frame_rate:.1f} fps, "
f"{first_frame.width}x{first_frame.height} chars")
if self.audio_player and self.audio_player.is_valid():
print("Audio: Available")
else:
print("Audio: Not available (install pygame for audio)")
def _setup_terminal(self):
if not sys.stdin.isatty():
return
self.original_termios = termios.tcgetattr(sys.stdin)
tty.setraw(sys.stdin.fileno())
def _restore_terminal(self):
if self.original_termios:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.original_termios)
self.original_termios = None
def _handle_input(self):
while not self.stop_event.is_set():
ready, _, _ = select.select([sys.stdin], [], [], 0.1)
if ready:
ch = sys.stdin.read(1)
self._process_key(ch)
def _process_key(self, key: str):
if key == ' ':
self.toggle_pause()
elif key == 'q' or key == '\x03':
self.stop()
elif key == 'f':
if self.reader:
first_frame = None
pos = self.reader.file.tell()
self.reader.reset()
first_frame = self.reader.read_frame()
self.reader.reset()
if first_frame:
self.renderer.update_layout(first_frame.width, first_frame.height)
print(f"\rResized to: {self.renderer.scaled_width}x{self.renderer.scaled_height}")
def toggle_pause(self):
self.paused = not self.paused
if self.paused:
self.paused_event.clear()
if self.audio_player:
self.audio_player.pause()
print("\rPaused", end='')
else:
self.paused_event.set()
if self.audio_player:
self.audio_player.resume()
print("\rPlaying", end='')
sys.stdout.flush()
def _playback_loop(self):
frame_count = self.reader.header.frame_count
start_time = time.time()
expected_frame = 0
while (not self.stop_event.is_set() and
self.current_frame < frame_count):
self.paused_event.wait()
if self.stop_event.is_set():
break
elapsed = time.time() - start_time
expected_frame = int(elapsed / self.frame_delay)
if self.current_frame > expected_frame:
time.sleep(0.001)
continue
while self.current_frame < expected_frame and self.current_frame < frame_count - 1:
frame = self.reader.read_frame()
if frame is None:
break
self.current_frame += 1
if self.renderer.check_resize():
sys.stdout.write(self.renderer.prepare_display())
sys.stdout.flush()
frame = self.reader.read_frame()
if frame is None:
break
output = self.renderer.render_frame(frame.pixel_pairs, frame.width, frame.height)
sys.stdout.write(self.renderer.clear_video_area() + self.renderer.frame_prefix() + output)
sys.stdout.flush()
self.current_frame += 1
target_time = start_time + (self.current_frame * self.frame_delay)
sleep_time = target_time - time.time()
if sleep_time > 0:
time.sleep(sleep_time)
def play(self):
if self.playing:
return
self.playing = True
self.stop_event.clear()
self.paused_event.set()
try:
self._setup_terminal()
if self.audio_player and self.audio_player.is_valid():
self.audio_player.start()
sys.stdout.write(self.renderer.prepare_display())
sys.stdout.flush()
if sys.stdin.isatty():
self.control_thread = threading.Thread(target=self._handle_input)
self.control_thread.start()
else:
self.control_thread = None
self._playback_loop()
except KeyboardInterrupt:
pass
finally:
self.stop()
def stop(self):
if not self.playing:
return
self.stop_event.set()
self.paused_event.set()
if self.audio_player:
self.audio_player.stop()
if self.control_thread:
self.control_thread.join(timeout=0.5)
self._restore_terminal()
if self.renderer:
sys.stdout.write(self.renderer.restore_display())
sys.stdout.flush()
self.playing = False
print(f"\nPlayback stopped at frame {self.current_frame}/{self.reader.header.frame_count}")
def close(self):
self.stop()
if self.reader:
self.reader.close()

View File

@@ -0,0 +1,179 @@
import struct
import gzip
import io
import zlib
from dataclasses import dataclass
from typing import BinaryIO, Iterator, Tuple, Optional
MAGIC = b"V2A\0"
VERSION = 2
@dataclass
class V2AHeader:
magic: bytes
version: int
frame_count: int
original_width: int
original_height: int
fps: float
audio_size: int
padding: bytes
@classmethod
def read(cls, f: BinaryIO) -> "V2AHeader":
magic = f.read(4)
if magic != MAGIC:
raise ValueError(f"Invalid magic: {magic!r}")
version = struct.unpack("<H", f.read(2))[0]
if version != VERSION:
raise ValueError(f"Unsupported version: {version}")
frame_count = struct.unpack("<I", f.read(4))[0]
original_width = struct.unpack("<I", f.read(4))[0]
original_height = struct.unpack("<I", f.read(4))[0]
fps = struct.unpack("<f", f.read(4))[0]
audio_size = struct.unpack("<Q", f.read(8))[0]
padding = f.read(2)
return cls(
magic=magic,
version=version,
frame_count=frame_count,
original_width=original_width,
original_height=original_height,
fps=fps,
audio_size=audio_size,
padding=padding,
)
def write(self, f: BinaryIO) -> None:
f.write(self.magic)
f.write(struct.pack("<H", self.version))
f.write(struct.pack("<I", self.frame_count))
f.write(struct.pack("<I", self.original_width))
f.write(struct.pack("<I", self.original_height))
f.write(struct.pack("<f", self.fps))
f.write(struct.pack("<Q", self.audio_size))
f.write(self.padding)
@dataclass
class V2AFrame:
width: int
height: int
pixel_pairs: list
@classmethod
def read_compressed(cls, f: BinaryIO) -> "V2AFrame":
import zlib
d = zlib.decompressobj(wbits=31)
decompressed = bytearray()
chunk_size = 4096
while True:
chunk = f.read(chunk_size)
if not chunk:
raise EOFError("End of file while reading gzip stream")
try:
decompressed.extend(d.decompress(chunk))
except zlib.error as e:
raise ValueError(f"zlib decompression error: {e}")
if d.eof:
unused_data = d.unused_data
if unused_data:
f.seek(-len(unused_data), 1)
if len(decompressed) < 4:
raise ValueError(f"Decompressed data too short: {len(decompressed)}")
width = struct.unpack("<H", decompressed[0:2])[0]
height = struct.unpack("<H", decompressed[2:4])[0]
pixel_count = width * height
expected_len = 4 + pixel_count * 2
if len(decompressed) < expected_len:
raise ValueError(f"Decompressed data too short: expected {expected_len}, got {len(decompressed)}")
data = bytes(decompressed[4:expected_len])
pixel_pairs = [list(data[i:i+2]) for i in range(0, len(data), 2)]
return cls(width, height, pixel_pairs)
if len(decompressed) > 8192 * 1024:
raise ValueError(f"Decompressed data too large ({len(decompressed)} > 8MB), likely corrupted data")
def write_compressed(self, f: BinaryIO) -> None:
with gzip.GzipFile(fileobj=f, mode='wb') as gz:
gz.write(struct.pack("<H", self.width))
gz.write(struct.pack("<H", self.height))
for pair in self.pixel_pairs:
gz.write(bytes(pair))
class V2AReader:
def __init__(self, path: str):
self.path = path
self.file = open(path, 'rb')
self.header = V2AHeader.read(self.file)
self.audio_data = self.file.read(self.header.audio_size)
if len(self.audio_data) != self.header.audio_size:
raise ValueError(f"Incomplete audio data: expected {self.header.audio_size}, got {len(self.audio_data)}")
self.current_frame = 0
def close(self):
self.file.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def read_frame(self) -> Optional[V2AFrame]:
if self.current_frame >= self.header.frame_count:
return None
try:
frame = V2AFrame.read_compressed(self.file)
self.current_frame += 1
return frame
except EOFError:
return None
def frames(self) -> Iterator[V2AFrame]:
while True:
frame = self.read_frame()
if frame is None:
break
yield frame
def reset(self):
self.file.seek(32 + self.header.audio_size)
self.current_frame = 0
@property
def frame_rate(self) -> float:
return self.header.fps
@property
def original_dimensions(self) -> Tuple[int, int]:
return (self.header.original_width, self.header.original_height)
@property
def frame_dimensions(self) -> Tuple[int, int]:
pos = self.file.tell()
self.file.seek(32 + self.header.audio_size)
try:
frame = V2AFrame.read_compressed(self.file)
self.file.seek(pos)
return (frame.width, frame.height)
except Exception:
self.file.seek(pos)
raise
@property
def audio(self) -> bytes:
return self.audio_data

View File

@@ -0,0 +1,165 @@
import os
import shutil
import sys
import math
from typing import Tuple, Optional
def get_terminal_size() -> Tuple[int, int]:
size = shutil.get_terminal_size()
return (size.columns, size.lines)
def calculate_scaled_dimensions(
src_width: int,
src_height: int,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
) -> Tuple[int, int]:
if max_width is None or max_height is None:
max_width, max_height = get_terminal_size()
max_height = max_height - 2
if src_width <= max_width and src_height <= max_height:
return (src_width, src_height)
width_scale = max_width / (2.0 * src_width)
height_scale = max_height / src_height
scale = min(width_scale, height_scale)
scaled_width = int(2.0 * scale * src_width)
scaled_height = int(scale * src_height)
scaled_width = max(1, scaled_width)
scaled_height = max(1, scaled_height)
return (scaled_width, scaled_height)
def calculate_centering_offset(
src_width: int,
src_height: int,
container_width: int,
container_height: int,
) -> Tuple[int, int]:
x = (container_width - src_width) // 2
y = (container_height - src_height) // 2
return (max(0, x), max(0, y))
def ansi_color_fg(index: int) -> str:
return f"\x1b[38;5;{index}m"
def ansi_color_bg(index: int) -> str:
return f"\x1b[48;5;{index}m"
def ansi_reset() -> str:
return "\x1b[0m"
def ansi_clear_screen() -> str:
return "\x1b[2J\x1b[H"
def ansi_move_cursor(row: int, col: int) -> str:
return f"\x1b[{row};{col}H"
def ansi_hide_cursor() -> str:
return "\x1b[?25l"
def ansi_show_cursor() -> str:
return "\x1b[?25h"
def render_half_block(top_color: int, bottom_color: int) -> str:
if top_color == bottom_color:
return f"{ansi_color_fg(top_color)}{ansi_reset()}"
else:
return f"{ansi_color_fg(top_color)}{ansi_color_bg(bottom_color)}{ansi_reset()}"
class TerminalRenderer:
def __init__(self):
self.term_width, self.term_height = get_terminal_size()
self.scaled_width = 0
self.scaled_height = 0
self.src_width = 0
self.src_height = 0
self.offset_x = 0
self.offset_y = 0
def update_layout(self, src_width: int, src_height: int):
self.src_width = src_width
self.src_height = src_height
self.scaled_width, self.scaled_height = calculate_scaled_dimensions(
src_width, src_height, self.term_width, self.term_height
)
self.offset_x, self.offset_y = calculate_centering_offset(
self.scaled_width, self.scaled_height, self.term_width, self.term_height
)
def check_resize(self) -> bool:
new_width, new_height = get_terminal_size()
if new_width != self.term_width or new_height != self.term_height:
self.term_width, self.term_height = new_width, new_height
if self.src_width > 0 and self.src_height > 0:
self.update_layout(self.src_width, self.src_height)
return True
return False
def render_frame(self, frame_pixel_pairs, frame_width: int, frame_height: int) -> str:
if (self.scaled_width, self.scaled_height) != (frame_width, frame_height):
return self._render_scaled_frame(frame_pixel_pairs, frame_width, frame_height)
else:
return self._render_exact_frame(frame_pixel_pairs, frame_width, frame_height)
def _render_exact_frame(self, pixel_pairs, width: int, height: int) -> str:
result = []
for y in range(height):
row = self.offset_y + 1 + y
col = self.offset_x + 1
result.append(ansi_move_cursor(row, col))
line_parts = []
for x in range(width):
idx = y * width + x
top, bottom = pixel_pairs[idx]
line_parts.append(render_half_block(top, bottom))
result.append("".join(line_parts))
return "".join(result)
def _render_scaled_frame(self, pixel_pairs, src_width: int, src_height: int) -> str:
dst_width, dst_height = self.scaled_width, self.scaled_height
result = []
for dy in range(dst_height):
row = self.offset_y + 1 + dy
col = self.offset_x + 1
result.append(ansi_move_cursor(row, col))
sy = int(dy * src_height / dst_height)
line_parts = []
for dx in range(dst_width):
sx = int(dx * src_width / dst_width)
idx = sy * src_width + sx
top, bottom = pixel_pairs[idx]
line_parts.append(render_half_block(top, bottom))
result.append("".join(line_parts))
return "".join(result)
def prepare_display(self) -> str:
return ansi_clear_screen() + ansi_hide_cursor()
def restore_display(self) -> str:
return ansi_show_cursor() + ansi_clear_screen()
def frame_prefix(self) -> str:
return ansi_move_cursor(self.offset_y + 1, self.offset_x + 1)
def clear_video_area(self) -> str:
if self.scaled_width <= 0 or self.scaled_height <= 0:
return ""
result = []
for row in range(self.offset_y + 1, self.offset_y + self.scaled_height + 1):
result.append(ansi_move_cursor(row, self.offset_x + 1))
result.append(ansi_reset())
result.append(" " * self.scaled_width)
return "".join(result)