11import os
22import sys
3- import time
4- from PIL import Image
5- import threading
6- import platform
7- import pygame
8- from moviepy import VideoFileClip
3+ import warnings
4+ warnings .filterwarnings ('ignore' )
5+ os .environ ['PYGAME_HIDE_SUPPORT_PROMPT' ] = '1'
96import contextlib
107import io
11- import py7zr
12- import queue
13- import colorama
14- import multiprocessing
8+ # Suppress stderr globally (for moviepy, pygame, etc.)
9+ class SuppressStderr :
10+ def __enter__ (self ):
11+ self ._stderr = sys .stderr
12+ sys .stderr = open (os .devnull , 'w' )
13+ def __exit__ (self , exc_type , exc_val , exc_tb ):
14+ sys .stderr .close ()
15+ sys .stderr = self ._stderr
16+
17+ with SuppressStderr ():
18+ import time
19+ from PIL import Image
20+ import threading
21+ import platform
22+ import pygame
23+ from moviepy import VideoFileClip
24+ import py7zr
25+ import queue
26+ import colorama
27+ import multiprocessing
28+ import json
1529
1630# finds stuff, works with PyInstaller too (hopefully)
1731def find_resource_path (rel ):
@@ -23,42 +37,45 @@ def find_resource_path(rel):
2337
2438# pulls ffmpeg from zip, dumps in root, sets env, done
2539def extract_and_set_ffmpeg_bin ():
26- zip_path = find_resource_path ('ffmpeg_bin.7z' )
2740 sysname = platform .system ().lower ()
2841 arch = platform .machine ().lower ()
2942 if sysname == 'windows' :
30- ffmpeg_in_zip = 'windows/ffmpeg.exe'
43+ zip_path = find_resource_path ('windows.7z' )
44+ ffmpeg_in_zip = 'ffmpeg.exe'
3145 out_name = 'ffmpeg.exe'
3246 elif sysname == 'darwin' :
33- ffmpeg_in_zip = 'mac/ffmpeg'
47+ zip_path = find_resource_path ('mac.7z' )
48+ ffmpeg_in_zip = 'ffmpeg'
3449 out_name = 'ffmpeg'
3550 elif sysname == 'linux' :
51+ zip_path = find_resource_path ('linux.7z' )
3652 if 'arm' in arch :
3753 if '64' in arch :
38- ffmpeg_in_zip = 'linux/linux -arm-64/ffmpeg'
54+ ffmpeg_in_zip = 'linux-arm-64/ffmpeg'
3955 else :
40- ffmpeg_in_zip = 'linux/linux -armhf-32/ffmpeg'
56+ ffmpeg_in_zip = 'linux-armhf-32/ffmpeg'
4157 elif '64' in arch :
42- ffmpeg_in_zip = 'linux/linux -64/ffmpeg'
58+ ffmpeg_in_zip = 'linux-64/ffmpeg'
4359 else :
44- ffmpeg_in_zip = 'linux/linux -32/ffmpeg'
60+ ffmpeg_in_zip = 'linux-32/ffmpeg'
4561 out_name = 'ffmpeg'
4662 else :
63+ zip_path = None
4764 ffmpeg_in_zip = None
4865 out_name = 'ffmpeg'
49- if ffmpeg_in_zip :
66+ if zip_path and ffmpeg_in_zip :
5067 out_path = os .path .abspath (out_name )
5168 if not os .path .exists (out_path ):
5269 with py7zr .SevenZipFile (zip_path , 'r' ) as archive :
5370 archive .extract (targets = [ffmpeg_in_zip ], path = '.' )
5471 if sysname != 'windows' :
55- os .chmod (out_path , 0o755 ) # make executable, I guess
72+ os .chmod (out_path , 0o755 ) # make executable
5673 os .environ ['FFMPEG_BINARY' ] = out_path
5774 else :
5875 os .environ ['FFMPEG_BINARY' ] = 'ffmpeg' # fallback, yolo
5976
6077# turns video into frames & audio, dumps in folder
61- def get_stuff_from_video (vid , out , speed = 24 , wide = 80 ):
78+ def get_stuff_from_video (vid , out , speed = 24 , wide = 160 ):
6279 if not os .path .exists (out ):
6380 os .makedirs (out )
6481 audio = os .path .join (out , 'audio.ogg' )
@@ -87,18 +104,60 @@ def get_stuff_from_video(vid, out, speed=24, wide=80):
87104 print ('Done.' )
88105 return out , audio
89106
90- def pic_to_ascii_from_pil (pic , wide = 80 ):
91- # High-fidelity ASCII ramp using only block and shading characters for smooth gradients
92- chars = "█▓▒░▌▖ " # 7 levels, all block/shading for best depth
93- gamma = 0.8
94- contrast = 1.2
107+ def load_options (options_path = 'options.json' ):
108+ # Load options from options.json, fallback to defaults if missing/empty
109+ defaults = {
110+ 'chars' : "█▓▒░" ,
111+ 'gamma' : 1.2 ,
112+ 'contrast' : 1.5 ,
113+ 'temp' : 'temp' ,
114+ 'wide' : 160 ,
115+ 'fps' : 24
116+ }
117+ if not os .path .exists (options_path ):
118+ with open (options_path , 'w' , encoding = 'utf-8' ) as f :
119+ json .dump (defaults , f , indent = 2 )
120+ return defaults
121+ try :
122+ with open (options_path , 'r' , encoding = 'utf-8' ) as f :
123+ opts = json .load (f )
124+ for k in defaults :
125+ if k not in opts or opts [k ] == '' or opts [k ] is None :
126+ opts [k ] = defaults [k ]
127+ return opts
128+ except Exception :
129+ return defaults
130+
131+ def pic_to_ascii_from_pil (pic , wide = None , high = None ):
132+ import shutil
95133 from colorama import Style
96- gray = pic .convert ('L' )
97- color = pic .convert ('RGB' )
98- ratio = gray .height / gray .width
134+ opts = load_options ()
135+ chars = opts ['chars' ]
136+ gamma = float (opts ['gamma' ])
137+ contrast = float (opts ['contrast' ])
138+ # Dynamically determine width and height as 90% of terminal size if not provided
139+ if wide is None or high is None :
140+ try :
141+ size = shutil .get_terminal_size ()
142+ term_w = int (size .columns * 0.9 )
143+ term_h = int (size .lines * 0.9 )
144+ if wide is None :
145+ wide = max (20 , term_w )
146+ if high is None :
147+ high = max (10 , term_h )
148+ except Exception :
149+ if wide is None :
150+ wide = 160
151+ if high is None :
152+ high = 24
153+ # Maintain aspect ratio
154+ ratio = pic .height / pic .width
99155 tall = int (ratio * wide * 0.55 )
100- gray = gray .resize ((wide , tall ))
101- color = color .resize ((wide , tall ))
156+ if tall > high :
157+ tall = high
158+ wide = int (tall / (ratio * 0.55 ))
159+ gray = pic .convert ('L' ).resize ((wide , tall ))
160+ color = pic .convert ('RGB' ).resize ((wide , tall ))
102161 px = list (gray .getdata ())
103162 px_color = list (color .getdata ())
104163 out = ''
@@ -116,10 +175,10 @@ def pic_to_ascii_from_pil(pic, wide=80):
116175 out += Style .RESET_ALL
117176 return out
118177
119- def pic_to_ascii (img , wide = 80 ):
178+ def pic_to_ascii (img , wide = None , high = None ):
120179 from PIL import Image
121180 pic = Image .open (img )
122- return pic_to_ascii_from_pil (pic , wide )
181+ return pic_to_ascii_from_pil (pic , wide , high )
123182
124183# Multiprocessing helper for frame conversion
125184
@@ -177,7 +236,7 @@ def clear_terminal():
177236 os .system ('cls' if platform .system () == 'Windows' else 'clear' )
178237
179238# plays ascii frames + sound, handles pause/quit
180- def play_ascii_video_stream (folder , audio , speed = 24 , wide = 80 , buffer_size = 24 ):
239+ def play_ascii_video_stream (folder , audio , speed = 24 , wide = 160 , buffer_size = 24 ):
181240 pygame .mixer .init ()
182241 delay = 1.0 / speed
183242 frames = sorted ([f for f in os .listdir (folder ) if f .startswith ('frame_' ) and f .endswith ('.txt' )])
@@ -244,6 +303,7 @@ def get_stuff_from_video_stream(vid, out, speed=24, buffer_size=24):
244303 clip .audio .write_audiofile (audio , codec = 'libvorbis' )
245304 frame_queue = queue .Queue (maxsize = buffer_size * 2 )
246305 total_frames = int (clip .fps * clip .duration )
306+ video_duration = clip .duration # <-- add this
247307 def extract_frames ():
248308 for i , frame in enumerate (clip .iter_frames (fps = speed , dtype = 'uint8' )):
249309 frame_path = os .path .join (out , f'frame_{ i + 1 :05d} .png' )
@@ -254,10 +314,10 @@ def extract_frames():
254314 pass # Don't block extraction, just skip putting in queue
255315 frame_queue .put (None ) # Sentinel for end
256316 threading .Thread (target = extract_frames , daemon = True ).start ()
257- return out , audio , frame_queue , total_frames
317+ return out , audio , frame_queue , total_frames , video_duration
258318
259319# plays ascii video + audio from stream, handles pause/quit
260- def play_ascii_video_stream_streaming (folder , audio , frame_queue , total_frames , speed = 24 , wide = 80 , buffer_size = 24 ):
320+ def play_ascii_video_stream_streaming (folder , audio , frame_queue , total_frames , speed = 24 , wide = 160 , buffer_size = 24 , video_duration = None ):
261321 import queue as pyqueue
262322 pygame .mixer .init ()
263323 delay = 1.0 / speed
@@ -327,7 +387,8 @@ def format_time(t):
327387 if frame_path is None :
328388 break
329389 frames_buffer .append (frame_path )
330- total_time = total_frames / speed if total_frames > 0 else 0
390+ # Use actual video duration if provided
391+ total_time = video_duration if video_duration is not None else (total_frames / speed if total_frames > 0 else 0 )
331392 while not stop_flag .is_set () and frames_buffer :
332393 # Handle rewind/forward requests
333394 jump = 0
@@ -414,26 +475,40 @@ def pic_from_ascii_txt(txt_path):
414475# main thing, asks stuff, runs stuff
415476def main ():
416477 extract_and_set_ffmpeg_bin () # pulls ffmpeg from zip, sets env, whatever
417- print ('Turns videos into ugly terminal art. With sound.' )
418- print ('Made by a lazy coder. @github/SajagIN' )
419- vid_input = input ('Video file? (default: BadApple.mp4): ' ).strip ()
478+ from colorama import Fore , Style
479+ box_width = 64
480+ box_color = Fore .CYAN + Style .BRIGHT
481+ text_color = Fore .YELLOW + Style .BRIGHT
482+ reset = Style .RESET_ALL
483+ def box_line (text , color = text_color ):
484+ # Remove ANSI codes for length calculation
485+ import re
486+ ansi_escape = re .compile (r'\x1b\[[0-9;]*m' )
487+ visible = ansi_escape .sub ('' , text )
488+ pad = box_width - 2 - len (visible )
489+ return f"{ box_color } ║{ reset } { color } { text } { ' ' * pad } { reset } { box_color } ║{ reset } "
490+ lines = [
491+ "" ,
492+ f"{ box_color } ╔{ '═' * (box_width - 2 )} ╗{ reset } " ,
493+ box_line (" Turns videos into ugly terminal art. With sound. " ),
494+ box_line (" Made by a lazy coder. " + Fore .MAGENTA + "@github/SajagIN" + text_color + " " ),
495+ box_line (f" { Fore .GREEN } Space{ reset } { text_color } = pause { Fore .GREEN } Q{ reset } { text_color } = quit { Fore .GREEN } A/D{ reset } { text_color } = rewind/forward 5s " ),
496+ f"{ box_color } ╚{ '═' * (box_width - 2 )} ╝{ reset } " ,
497+ ""
498+ ]
499+ print ("\n " .join (lines ))
500+ opts = load_options ('options.json' )
501+ vid_input = input (Fore .CYAN + Style .BRIGHT + 'Video file?' + reset + f' (default: { Fore .YELLOW } BadApple.mp4{ reset } ): ' ).strip ()
420502 vid = find_resource_path (vid_input ) if vid_input else find_resource_path ('BadApple.mp4' )
421- temp = input ('Temp folder? (default: temp): ' ).strip () or 'temp'
422- try :
423- width = int (input ('How wide? (default: 80): ' ).strip () or 80 )
424- except ValueError :
425- width = 80
426- try :
427- fps = int (input ('FPS? (default: 24): ' ).strip () or 24 )
428- except ValueError :
429- fps = 24
430- print ('Space = pause, Q = quit A/D = rewind/forward 5s, ←/→ = skip 1s' )
503+ temp = opts ['temp' ]
504+ width = int (opts ['wide' ])
505+ fps = int (opts ['fps' ])
431506 try :
432- frames , audio , frame_queue , total_frames = get_stuff_from_video_stream (vid , temp , speed = fps , buffer_size = fps )
433- print ('Streaming ASCII video...' )
434- play_ascii_video_stream_streaming (frames , audio , frame_queue , total_frames , speed = fps , wide = width , buffer_size = fps )
507+ frames , audio , frame_queue , total_frames , video_duration = get_stuff_from_video_stream (vid , temp , speed = fps , buffer_size = fps )
508+ print (Fore . GREEN + Style . BRIGHT + 'Streaming ASCII video...' + reset )
509+ play_ascii_video_stream_streaming (frames , audio , frame_queue , total_frames , speed = fps , wide = width , buffer_size = fps , video_duration = video_duration )
435510 except Exception as e :
436- print (f'Nope, broke: { e } ' )
511+ print (Fore . RED + f'Nope, broke: { e } ' + reset )
437512 sys .exit (1 )
438513
439514if __name__ == '__main__' :
0 commit comments