diff --git a/src/demo/devel/animated_ascii.py b/src/demo/devel/animated_ascii.py new file mode 100644 index 00000000..7c78e453 --- /dev/null +++ b/src/demo/devel/animated_ascii.py @@ -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")