Skip to content

Commit dd34dc1

Browse files
committed
playback support
1 parent 39afa44 commit dd34dc1

File tree

2 files changed

+160
-30
lines changed

2 files changed

+160
-30
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ Just download the right executable for your OS from the [Releases](https://githu
2525
### No FFmpeg Setup!
2626
You don't need to install ffmpeg yourself . This repo ships with a compressed `ffmpeg_bin.7z` containing all the ffmpeg binaries for Windows, Mac, and Linux (even ARM stuff). The script will extract and use the right one for your system, automatically. So, yeah, just run it.
2727

28+
## 🆕 Playback Controls
29+
- **Space**: Pause/Resume
30+
- **Q**: Quit
31+
- **A/D**: Rewind/Forward 5 seconds
32+
- **←/→ (Arrow keys)**: Skip 1 frame
33+
2834
## 🍿 Usage: "Watch" a Video
2935
Just run it. The script will ask you for everything (video file, width, fps, temp folder) like a lazy wizard. ✨
3036
```bash
@@ -51,6 +57,7 @@ Just press Enter to accept the defaults, or type your own values. Easy.
5157
- Temporary Files: It creates a bunch of image files and an audio file. It doesn't clean them up automatically. Why? **Because I'm lazy. Delete them yourself!** 🔥🗑️
5258
- ffmpeg will be extracted to the script's root folder if not already there. If you delete it, it'll just get extracted again. Magic.
5359
- **Full video path is required (No relative path)**
60+
- Subtitles must be in `.srt` or `.ass` format and named the same as your video file to be detected.
5461

5562
## Contributing (LOL)
5663
Sure, if you really wanna make this "better," feel free. But honestly, it works, right? So why bother? Issues and pull requests are technically welcome, I guess. 🙄

vidminal.py

Lines changed: 153 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import py7zr
1212
import queue
1313
import colorama
14+
import multiprocessing
1415

1516
# finds stuff, works with PyInstaller too (hopefully)
1617
def 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
103133
def play_sound(audio, pause_flag, stop_flag):
@@ -150,7 +180,7 @@ def clear_terminal():
150180
def 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
292415
def 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

Comments
 (0)