Skip to content

Commit

Permalink
Add ascii player
Browse files Browse the repository at this point in the history
  • Loading branch information
yorevs committed Jan 11, 2025
1 parent 9d6b2c3 commit b96625d
Showing 1 changed file with 201 additions and 0 deletions.
201 changes: 201 additions & 0 deletions src/demo/devel/animated_ascii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import shutil
import signal
import sys
import threading
import time
from os.path import dirname
import os
from pathlib import Path
from threading import Thread
from typing import Optional

from rich.console import Console
from rich.text import Text
import pause
from PIL import Image
from PIL.Image import Resampling
from clitt.core.term.cursor import cursor
from hspylib.modules.application.exit_status import ExitStatus
from clitt.core.term.terminal import terminal, Terminal

from askai.core.component.audio_player import player

PALETTES = {
1: " .:-=+*#%@",
2: " ▁▂▃▄▅▆▇█▊",
3: " ░▒▓█▓▒░▒▓",
}

FPS: int = 10

DEFAULT_PALETTE = PALETTES[1]

VIDEO_DIR: Path = Path("/Users/hjunior/GIT-Repository/GitHub/askai/assets/videos")
if not VIDEO_DIR.exists():
VIDEO_DIR.mkdir(parents=True, exist_ok=True)

DATA_PATH: Path = Path(os.path.join(dirname(__file__), 'AscVideos'))
if not DATA_PATH.exists():
DATA_PATH.mkdir(parents=True, exist_ok=True)


def frame_to_ascii(
frame_path: str,
width: int,
palette: str,
reverse: bool
) -> str:
"""Converts an image frame to an ASCII art representation.
:param frame_path: Path to the image file to be converted.
:param width: Width of the output ASCII art in characters.
:param palette: String of characters to use for ASCII art shading.
:param reverse: Whether to reverse the shading palette.
:return: A string containing the ASCII art representation of the image.
"""
num_chars = len(palette if not reverse else palette[::-1])
img: Image = Image.open(frame_path).convert("L")
aspect_ratio = img.height / img.width
new_height = int(width * aspect_ratio * 0.55)
img = img.resize((width, new_height), resample=Resampling.BILINEAR)
pixels = list(img.getdata())
ascii_str = "".join(palette[min(pixel * num_chars // 256, num_chars - 1)] for pixel in pixels)
ascii_lines = [ascii_str[i:i + width] for i in range(0, len(ascii_str), width)]

return "\n".join(ascii_lines)


def get_frames(frames_path: Path, width: int = 80, palette: str = DEFAULT_PALETTE, reverse: bool = True) -> list[str]:
"""Converts all frame files in the specified directory to ASCII format.
:param frames_path: Path to the directory containing frame files.
:param width: Width of the output ASCII art in characters. Defaults to 80.
:param palette: String of characters to use for ASCII art shading.
:param reverse: Whether to reverse the shading palette. Defaults to True.
:return: List of ASCII representations of the frames.
"""
ascii_frames: list[str] = []
for frame_file in sorted(os.listdir(frames_path)):
frame_path: str = os.path.join(frames_path, frame_file)
ascii_frame = frame_to_ascii(frame_path, width, palette, reverse)
ascii_frames.append(ascii_frame)

return ascii_frames


def extract_audio_and_video_frames(video_path: Path) -> Optional[tuple[Path, Path]]:
"""Extracts audio and video frames from the given video path.
:param video_path: Path to the video file to extract audio and frames from.
:return: A tuple containing the path to the extracted audio and a list of paths to the video frames, or None if
the extraction fails.
"""
video_name, _ = os.path.splitext(os.path.basename(video_path))
frame_dir: Path = Path(os.path.join(DATA_PATH, video_name, 'frames'))
audio_dir: Path = Path(os.path.join(DATA_PATH, video_name, 'audio'))
audio_path: Path = Path(os.path.join(audio_dir, "audio.mp3"))

# If output directory doesn't exist, perform extraction
if not frame_dir.exists():
frame_dir.mkdir(parents=True, exist_ok=True)
audio_dir.mkdir(parents=True, exist_ok=True)

# Extract frames
frame_command = f'ffmpeg -i "{video_path}" -vf "fps={FPS}" "{frame_dir}/frame%04d.png"'
_, _, exit_code = terminal.shell_exec(frame_command, shell=True)
if exit_code != ExitStatus.SUCCESS:
return None

# Extract audio
audio_command = f'ffmpeg -i "{video_path}" -q:a 0 -map a "{audio_path}"'
_, _, exit_code = terminal.shell_exec(audio_command, shell=True)
if exit_code != ExitStatus.SUCCESS:
return None

return audio_path, frame_dir


def play_ascii_frames(ascii_frames: list[str], fps: int) -> None:
"""Plays a sequence of ASCII art frames in the terminal with a specified delay.
:param ascii_frames: List of ASCII art frames to display.
:param fps: Frames per second to control the delay between frames.
:return: None
:raises OSError: If unable to get terminal size.
"""
console = Console()
delay_ms: int = int(1000 / fps)
cols, _ = shutil.get_terminal_size()
for f in ascii_frames:
cols, rows = shutil.get_terminal_size()
cursor.write("%HOM%")
start_time = time.perf_counter() # Record the start time
for line in f.splitlines()[:cols]:
console.print(Text(line, justify="center"), end='')
cursor.write(f"%EL0%%EOL%")
end_time = time.perf_counter() # Record the end time
render_time: int = int((end_time - start_time) * 1000)
pause.milliseconds(delay_ms - render_time)


def play_video(ascii_frames: list[str], fps: int) -> Thread:
"""Plays a list of ASCII art frames as a video in a separate thread.
:param ascii_frames: List of ASCII art frames to display.
:param fps: Frames per second at which to display the frames.
:return: Thread object running the video playback.
"""
thread = threading.Thread(target=play_ascii_frames, args=(ascii_frames, fps))
thread.daemon = True
thread.start()
return thread


def play_audio(audio_path: str) -> Thread:
"""Plays an audio file in a separate thread.
:param audio_path: Path to the audio file to be played.
:return: The thread running the audio playback.
"""
thread = threading.Thread(target=player.play_audio_file, args=(audio_path,))
thread.daemon = True
thread.start()
return thread


def setup_terminal() -> None:
"""Setup the terminal screen to render the video."""
Terminal.alternate_screen(True)
Terminal.clear()
Terminal.set_show_cursor(False)
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGABRT, cleanup)


def cleanup(*args) -> None:
"""Provide a cleanup for graceful exit."""
try:
Terminal.clear()
Terminal.alternate_screen(False)
Terminal.clear()
Terminal.set_show_cursor(True)
sys.exit()
except SystemExit:
exit()


def play(video_name: str) -> None:
"""Plays a video in ASCII format with synchronized audio.
:param video_name: The name of the video file to play.
"""
cols, rows = shutil.get_terminal_size()
print(cols, rows, cols / rows)
exit()
setup_terminal()
video_path: Path = Path(os.path.join(VIDEO_DIR, video_name))
audio_path, video_path = extract_audio_and_video_frames(video_path)
ascii_video = get_frames(video_path, 150, PALETTES[1], True)
thv = play_video(ascii_video, FPS)
tha = play_audio(audio_path)
thv.join()
tha.join()
cleanup()


if __name__ == '__main__':
play("AskAI-Trailer.mp4")

0 comments on commit b96625d

Please sign in to comment.