Initial Commit
This commit is contained in:
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