1111import py7zr
1212import queue
1313import colorama
14+ import multiprocessing
1415
1516# finds stuff, works with PyInstaller too (hopefully)
1617def find_resource_path (rel ):
@@ -57,25 +58,41 @@ def extract_and_set_ffmpeg_bin():
5758 os .environ ['FFMPEG_BINARY' ] = 'ffmpeg' # fallback, yolo
5859
5960# turns video into frames & audio, dumps in folder
60- def get_stuff_from_video (vid , out , speed = 24 ):
61+ def get_stuff_from_video (vid , out , speed = 24 , wide = 80 ):
6162 if not os .path .exists (out ):
6263 os .makedirs (out )
6364 audio = os .path .join (out , 'audio.ogg' )
6465 print ('Doing video things...' )
6566 clip = VideoFileClip (vid )
6667 with contextlib .redirect_stdout (io .StringIO ()), contextlib .redirect_stderr (io .StringIO ()):
6768 clip .audio .write_audiofile (audio , codec = 'libvorbis' )
68- for i , frame in enumerate (clip .iter_frames (fps = speed , dtype = 'uint8' )):
69- Image .fromarray (frame ).save (os .path .join (out , f'frame_{ i + 1 :05d} .png' ))
69+ frame_paths = []
70+ for i , frame in enumerate (clip .iter_frames (fps = speed , dtype = 'uint8' )):
71+ img = Image .fromarray (frame )
72+ # Reduce image size early
73+ ratio = img .height / img .width
74+ tall = int (ratio * wide * 0.55 )
75+ img = img .resize ((wide , tall ))
76+ frame_path = os .path .join (out , f'frame_{ i + 1 :05d} .png' )
77+ img .save (frame_path )
78+ frame_paths .append (frame_path )
79+ # Multiprocessing: convert all frames to ASCII in parallel
80+ with multiprocessing .Pool () as pool :
81+ ascii_frames = pool .map (convert_frame_to_ascii , [(fp , wide ) for fp in frame_paths ])
82+ # Save ASCII frames as .txt for fast playback
83+ for i , ascii_txt in enumerate (ascii_frames ):
84+ txt_path = os .path .join (out , f'frame_{ i + 1 :05d} .txt' )
85+ with open (txt_path , 'w' , encoding = 'utf-8' ) as f :
86+ f .write (ascii_txt )
7087 print ('Done.' )
7188 return out , audio
7289
73- # image to ascii, not rocket science
74- def pic_to_ascii (img , wide = 80 ):
75- # Extended ASCII ramp for more depth
76- chars = "@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?i!lI;:,\" •`'. ☐"
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
7795 from colorama import Style
78- pic = Image .open (img )
7996 gray = pic .convert ('L' )
8097 color = pic .convert ('RGB' )
8198 ratio = gray .height / gray .width
@@ -87,17 +104,30 @@ def pic_to_ascii(img, wide=80):
87104 out = ''
88105 for i , (p , rgb ) in enumerate (zip (px , px_color )):
89106 r , g , b = rgb
107+ r = int (255 * pow ((r / 255 ), gamma ) * contrast )
108+ g = int (255 * pow ((g / 255 ), gamma ) * contrast )
109+ b = int (255 * pow ((b / 255 ), gamma ) * contrast )
110+ r = min (max (r , 0 ), 255 )
111+ g = min (max (g , 0 ), 255 )
112+ b = min (max (b , 0 ), 255 )
90113 out += f'\033 [38;2;{ r } ;{ g } ;{ b } m{ chars [p * (len (chars ) - 1 ) // 255 ]} '
91114 if (i + 1 ) % wide == 0 :
92115 out += Style .RESET_ALL + '\n '
93116 out += Style .RESET_ALL
94117 return out
95118
96- # yields ascii frames, lazy style
97- def prepare_ascii_frames_stream (folder , wide = 80 , buffer_size = 24 ):
98- frames = sorted ([f for f in os .listdir (folder ) if f .startswith ('frame_' ) and f .endswith ('.png' )])
99- for f in frames :
100- yield pic_to_ascii (os .path .join (folder , f ), wide )
119+ def pic_to_ascii (img , wide = 80 ):
120+ from PIL import Image
121+ pic = Image .open (img )
122+ return pic_to_ascii_from_pil (pic , wide )
123+
124+ # Multiprocessing helper for frame conversion
125+
126+ def convert_frame_to_ascii (args ):
127+ frame_path , wide = args
128+ from PIL import Image
129+ pic = Image .open (frame_path )
130+ return pic_to_ascii_from_pil (pic , wide )
101131
102132# plays sound, can pause/stop, whatever
103133def play_sound (audio , pause_flag , stop_flag ):
@@ -150,7 +180,7 @@ def clear_terminal():
150180def play_ascii_video_stream (folder , audio , speed = 24 , wide = 80 , buffer_size = 24 ):
151181 pygame .mixer .init ()
152182 delay = 1.0 / speed
153- frames = sorted ([f for f in os .listdir (folder ) if f .startswith ('frame_' ) and f .endswith ('.png ' )])
183+ frames = sorted ([f for f in os .listdir (folder ) if f .startswith ('frame_' ) and f .endswith ('.txt ' )])
154184 total = len (frames )
155185 stop_flag = threading .Event ()
156186 pause_flag = threading .Event ()
@@ -194,8 +224,9 @@ def play_audio_from(pos):
194224 sleep_for = tgt - now
195225 if sleep_for > 0 :
196226 time .sleep (sleep_for )
197- print ('\x1b [H' , end = '' ) # move cursor home, don't ask how it works
198- print (pic_to_ascii (os .path .join (folder , frames [i ]), wide ), end = '' )
227+ print ('\x1b [H' , end = '' ) # move cursor home
228+ # Batch terminal output: print whole frame at once
229+ print (pic_from_ascii_txt (os .path .join (folder , frames [i ])), end = '' )
199230 i += 1
200231 stop_flag .set ()
201232 pygame .mixer .music .stop ()
@@ -217,29 +248,61 @@ def extract_frames():
217248 for i , frame in enumerate (clip .iter_frames (fps = speed , dtype = 'uint8' )):
218249 frame_path = os .path .join (out , f'frame_{ i + 1 :05d} .png' )
219250 Image .fromarray (frame ).save (frame_path )
220- frame_queue .put (frame_path )
251+ try :
252+ frame_queue .put_nowait (frame_path )
253+ except queue .Full :
254+ pass # Don't block extraction, just skip putting in queue
221255 frame_queue .put (None ) # Sentinel for end
222256 threading .Thread (target = extract_frames , daemon = True ).start ()
223- return out , audio , frame_queue
257+ return out , audio , frame_queue , total_frames
224258
225259# plays ascii video + audio from stream, handles pause/quit
226- def play_ascii_video_stream_streaming (folder , audio , frame_queue , speed = 24 , wide = 80 , buffer_size = 24 ):
260+ def play_ascii_video_stream_streaming (folder , audio , frame_queue , total_frames , speed = 24 , wide = 80 , buffer_size = 24 ):
261+ import queue as pyqueue
227262 pygame .mixer .init ()
228263 delay = 1.0 / speed
229264 stop_flag = threading .Event ()
230265 pause_flag = threading .Event ()
231266 pause_flag .clear ()
267+ rewind_forward = pyqueue .Queue ()
232268
233269 def keyboard_listener ():
270+ import platform
271+ is_windows = platform .system () == 'Windows'
234272 while not stop_flag .is_set ():
235273 key = getch ()
274+ if not key :
275+ time .sleep (0.05 )
276+ continue
236277 if key == ' ' :
237278 if pause_flag .is_set ():
238279 pause_flag .clear ()
239280 else :
240281 pause_flag .set ()
241- if key in ('q' , 'Q' ):
282+ elif key in ('q' , 'Q' ):
242283 stop_flag .set ()
284+ elif key in ('a' , 'A' ):
285+ rewind_forward .put (- 5 * speed ) # rewind 5s
286+ elif key in ('d' , 'D' ):
287+ rewind_forward .put (5 * speed ) # forward 5s
288+ elif is_windows :
289+ # Windows arrow keys: first getch() returns '\xe0', next is code
290+ if key in ('\xe0 ' , '\x00 ' ):
291+ next_key = getch ()
292+ if next_key == 'M' : # right arrow
293+ rewind_forward .put (speed )
294+ elif next_key == 'K' : # left arrow
295+ rewind_forward .put (- speed )
296+ else :
297+ # Unix: arrow keys are '\x1b', '[', 'C'/'D'
298+ if key == '\x1b ' :
299+ next1 = getch ()
300+ if next1 == '[' :
301+ next2 = getch ()
302+ if next2 == 'C' : # right arrow
303+ rewind_forward .put (speed )
304+ elif next2 == 'D' : # left arrow
305+ rewind_forward .put (- speed )
243306 time .sleep (0.05 )
244307
245308 key_thread = threading .Thread (target = keyboard_listener , daemon = True )
@@ -249,6 +312,10 @@ def play_audio_from(pos):
249312 pygame .mixer .music .load (audio )
250313 pygame .mixer .music .play (start = pos )
251314
315+ def format_time (t ):
316+ t = int (t )
317+ return f"{ t // 3600 :02} :{ (t % 3600 )// 60 :02} :{ t % 60 :02} "
318+
252319 print ('\x1b [2J' , end = '' ) # clear screen
253320 start = time .time ()
254321 play_audio_from (0 )
@@ -260,7 +327,44 @@ def play_audio_from(pos):
260327 if frame_path is None :
261328 break
262329 frames_buffer .append (frame_path )
330+ total_time = total_frames / speed if total_frames > 0 else 0
263331 while not stop_flag .is_set () and frames_buffer :
332+ # Handle rewind/forward requests
333+ jump = 0
334+ while not rewind_forward .empty ():
335+ jump += rewind_forward .get ()
336+ if jump != 0 :
337+ # --- Begin robust seek logic: buffer only target frame, not intermediates ---
338+ target_i = max (0 , min (i + jump , total_frames - 1 ))
339+ pygame .mixer .music .pause ()
340+ frames_buffer .clear ()
341+ # Wait for the target frame to be extracted (but do not display intermediates)
342+ frame_path = os .path .join (folder , f'frame_{ target_i + 1 :05d} .png' )
343+ wait_count = 0
344+ while not os .path .exists (frame_path ):
345+ print ('\x1b [H' , end = '' )
346+ print ('Buffering...' .center (wide ), end = '\n ' )
347+ time .sleep (0.05 )
348+ wait_count += 1
349+ if wait_count > 400 :
350+ break
351+ if os .path .exists (frame_path ):
352+ frames_buffer .append (frame_path )
353+ # Fill buffer with next frames if available, always from disk
354+ for idx in range (target_i + 1 , min (target_i + 1 + buffer_size , total_frames )):
355+ next_path = os .path .join (folder , f'frame_{ idx + 1 :05d} .png' )
356+ if os .path .exists (next_path ):
357+ frames_buffer .append (next_path )
358+ else :
359+ break
360+ i = target_i
361+ # Set start time so seek bar is correct
362+ start = time .time () - i * delay
363+ # Resume audio at the new position only when frame is ready
364+ if frames_buffer :
365+ play_audio_from (i * delay )
366+ time .sleep (0.1 )
367+ # --- End robust seek logic ---
264368 if pause_flag .is_set ():
265369 pygame .mixer .music .pause ()
266370 paused_at = i * delay
@@ -276,24 +380,43 @@ def play_audio_from(pos):
276380 if sleep_for > 0 :
277381 time .sleep (sleep_for )
278382 print ('\x1b [H' , end = '' )
279- print (pic_to_ascii (frames_buffer [0 ], wide ), end = '' )
383+ if frames_buffer :
384+ print (pic_to_ascii (frames_buffer [0 ], wide ), end = '' )
385+ else :
386+ print ('Buffering...' .center (wide ), end = '\n ' )
387+ # --- Seek bar with play/pause and time ---
388+ play_emoji = '⏸️' if not pause_flag .is_set () else '▶️'
389+ time_str = f"{ format_time (i / speed )} / { format_time (total_time )} "
390+ fixed_len = len (play_emoji ) + 2 + 2 + len (time_str )
391+ bar_width = max (1 , wide - fixed_len )
392+ bar_pos = int ((i / (total_frames - 1 )) * bar_width ) if total_frames > 1 else 0
393+ bar = '█' * bar_pos + '-' * (bar_width - bar_pos )
394+ print (f"{ play_emoji } [{ bar } ] { time_str } " )
395+ # --- End seek bar ---
280396 i += 1
281- frames_buffer .pop (0 )
282- next_frame = frame_queue .get ()
283- if next_frame is None :
284- continue
285- frames_buffer .append (next_frame )
397+ if frames_buffer :
398+ frames_buffer .pop (0 )
399+ # Always refill buffer from disk after skip
400+ next_idx = i + len (frames_buffer )
401+ if next_idx < total_frames :
402+ next_path = os .path .join (folder , f'frame_{ next_idx + 1 :05d} .png' )
403+ if os .path .exists (next_path ):
404+ frames_buffer .append (next_path )
286405 stop_flag .set ()
287406 pygame .mixer .music .stop ()
288407 key_thread .join ()
289408 pygame .mixer .quit ()
290409
410+ def pic_from_ascii_txt (txt_path ):
411+ with open (txt_path , 'r' , encoding = 'utf-8' ) as f :
412+ return f .read ()
413+
291414# main thing, asks stuff, runs stuff
292415def main ():
293416 extract_and_set_ffmpeg_bin () # pulls ffmpeg from zip, sets env, whatever
294417 print ('Turns videos into ugly terminal art. With sound.' )
295418 print ('Made by a lazy coder. @github/SajagIN' )
296- vid_input = input ('Video file? Please Enter full path (default: BadApple.mp4): ' ).strip ()
419+ vid_input = input ('Video file? (default: BadApple.mp4): ' ).strip ()
297420 vid = find_resource_path (vid_input ) if vid_input else find_resource_path ('BadApple.mp4' )
298421 temp = input ('Temp folder? (default: temp): ' ).strip () or 'temp'
299422 try :
@@ -304,11 +427,11 @@ def main():
304427 fps = int (input ('FPS? (default: 24): ' ).strip () or 24 )
305428 except ValueError :
306429 fps = 24
307- print ('Space = pause, Q = quit' )
430+ print ('Space = pause, Q = quit A/D = rewind/forward 5s, ←/→ = skip 1s ' )
308431 try :
309- frames , audio , frame_queue = get_stuff_from_video_stream (vid , temp , speed = fps , buffer_size = fps )
432+ frames , audio , frame_queue , total_frames = get_stuff_from_video_stream (vid , temp , speed = fps , buffer_size = fps )
310433 print ('Streaming ASCII video...' )
311- play_ascii_video_stream_streaming (frames , audio , frame_queue , speed = fps , wide = width , buffer_size = fps )
434+ play_ascii_video_stream_streaming (frames , audio , frame_queue , total_frames , speed = fps , wide = width , buffer_size = fps )
312435 except Exception as e :
313436 print (f'Nope, broke: { e } ' )
314437 sys .exit (1 )
0 commit comments