├── ptmv ├── __init__.py ├── yt.py ├── snd.py ├── img.py ├── vid.py ├── console.py └── __main__.py ├── assets ├── image_demo.gif └── video_demo.gif ├── .gitignore ├── makefile ├── setup.py ├── LICENSE ├── README-PIP.md └── README.md /ptmv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/image_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kai-kj/ptmv/HEAD/assets/image_demo.gif -------------------------------------------------------------------------------- /assets/video_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kai-kj/ptmv/HEAD/assets/video_demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .idea 3 | build 4 | bin 5 | dist 6 | ptmv.spec 7 | ptmv.egg-info 8 | 9 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | default: clean 2 | python3 setup.py sdist bdist_wheel 3 | twine upload dist/* 4 | 5 | clean: 6 | @$(RM) -rf bin/ build/ dist/ ptmv.egg-info -------------------------------------------------------------------------------- /ptmv/yt.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import yt_dlp as ytdl 3 | import os 4 | import tempfile 5 | import time 6 | 7 | def download(url): 8 | ptmv_tempdir = os.path.join(tempfile.gettempdir(), "ptmv") 9 | if not os.path.exists(ptmv_tempdir): os.makedirs(ptmv_tempdir) 10 | file = ptmv_tempdir + str(int(time.time())) 11 | 12 | ydl_opts = { 13 | "format": "worst[ext=mp4]", 14 | "outtmpl": file + ".%(ext)s", 15 | "quiet": True, 16 | "no_warnings": True, 17 | } 18 | 19 | ytdl.YoutubeDL(ydl_opts).download([url]) 20 | return file + ".mp4" 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from setuptools import setup 3 | 4 | HERE = pathlib.Path(__file__).parent 5 | README = (HERE / "README-PIP.md").read_text() 6 | 7 | setup( 8 | name="ptmv", 9 | version="1.0.1", 10 | description="An utf-8/truecolor image and video viewer for the terminal", 11 | long_description=README, 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/kal39/ptmv", 14 | author="kal39", 15 | author_email="kaikitagawajones@gmail.com", 16 | license="MIT", 17 | packages=["ptmv"], 18 | include_package_data=True, 19 | install_requires=["wheel", "opencv-python", "simpleaudio", "yt_dlp"], 20 | entry_points={ 21 | "console_scripts": [ 22 | "ptmv=ptmv.__main__:main", 23 | ] 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /ptmv/snd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | import time 5 | import wave 6 | import simpleaudio 7 | 8 | def extract(file): 9 | ptmv_tempdir = os.path.join(tempfile.gettempdir(), "ptmv") 10 | if not os.path.exists(ptmv_tempdir): os.makedirs(ptmv_tempdir) 11 | snd_file = ptmv_tempdir + str(int(time.time())) + ".wav" 12 | command = "ffmpeg -i " + file + " -b:a 48k -ac 1 " + snd_file 13 | subprocess.run(command.split(), stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL) 14 | return snd_file 15 | 16 | def play(file, start_time): 17 | if not os.path.exists(file): return 18 | wave_raw = wave.open(file) 19 | frame_rate = wave_raw.getframerate() 20 | wave_raw.setpos(int(frame_rate * start_time)) 21 | return simpleaudio.WaveObject.from_wave_read(wave_raw).play() 22 | 23 | def stop(play_obj): play_obj.stop() -------------------------------------------------------------------------------- /ptmv/img.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | 3 | from . import console 4 | 5 | def display(file, user_width, user_height): 6 | image = cv2.imread(file) 7 | if image is None: print("Could not read [%s]" % file); return 8 | image = resize(image, user_width, user_height) 9 | console.set_image_height(image.shape[0]) 10 | console.clear() 11 | console.draw_image(image) 12 | 13 | def resize(image, user_width, user_height): 14 | image_height, image_width, _ = image.shape 15 | 16 | if user_width is None and user_height is None: 17 | scale_x = min(console.width() / image_width, console.height() / image_height) 18 | scale_y = scale_x 19 | elif user_width is not None: 20 | scale_x = user_width / image_width 21 | scale_y = scale_x 22 | elif user_height is not None: 23 | scale_y = user_height / image_height 24 | scale_x = scale_y 25 | else: 26 | scale_x = user_width / image_width 27 | scale_y = user_height / image_height 28 | 29 | return cv2.resize(image, (0, 0), interpolation = cv2.INTER_AREA, fx = scale_x, fy = scale_y) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kai Kitagawa-Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ptmv/vid.py: -------------------------------------------------------------------------------- 1 | import time 2 | import cv2 3 | 4 | from . import console 5 | from . import img 6 | from . import snd 7 | 8 | def play(file, width, height, target_fps, start_offset): 9 | video = cv2.VideoCapture(file) 10 | audio = snd.extract(file) 11 | if video is None: print("Could not read [%s]" % file); return 12 | 13 | orig_height, orig_width, _ = get_frame(video, 0).shape 14 | out_height, out_width, _ = img.resize(get_frame(video, 0), width, height).shape 15 | 16 | console.set_image_height(out_height) 17 | video_loop(video, audio, out_width / orig_width, out_height / orig_height, target_fps, start_offset) 18 | 19 | def video_loop(video, audio, scale_x, scale_y, target_fps, start_offset): 20 | frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) 21 | fps = int(video.get(cv2.CAP_PROP_FPS)) 22 | dt = 1 / target_fps 23 | 24 | console.hide_cursor() 25 | snd.play(audio, start_offset) 26 | console.clear() 27 | 28 | prev_frame = None 29 | start_time = time.time() - start_offset 30 | prev_time = time.time() - dt 31 | 32 | while True: 33 | current_time = time.time() 34 | if current_time - prev_time > dt: 35 | prev_time = current_time 36 | current_frame = get_frame(video, int((current_time - start_time) * fps)) 37 | if current_frame is None: break 38 | 39 | current_frame = cv2.resize(current_frame, (0, 0), fx = scale_x, fy = scale_y) 40 | console.draw_frame(prev_frame, current_frame) 41 | prev_frame = current_frame 42 | 43 | def get_frame(video, frame_num): 44 | video.set(1, frame_num) 45 | return video.read()[1] -------------------------------------------------------------------------------- /README-PIP.md: -------------------------------------------------------------------------------- 1 |

2 |

Python Terminal Media Viewer

3 |

View images and videos without leaving the console

4 |

More info on GitHub

5 |

6 | 7 | 8 | 9 | 10 | ---- 11 | 12 | ### Features 13 | 14 | * View **images** form any terminal 15 | * Watch **videos** from any terminal 16 | * Watch **youtube** videos from any terminal (`-y`, `--youtube`) 17 | * Play videos at **any fps** (`--fps`) with sound 18 | * **Resize** images / videos (`--width`, `--height`) 19 | * Easy to use 20 | 21 | ---- 22 | 23 | ### Requirements 24 | 25 | * A terminal that supports **truecolor** ([list](https://gist.github.com/XVilka/8346728)) and **utf-8** (most terminals should support utf-8). 26 | 27 | ---- 28 | 29 | ### Usage 30 | 31 | ```shell 32 | ptmv FILE [OPTIONS] 33 | ``` 34 | 35 | * **Required arguments** 36 | 37 | * `FILE` 38 | 39 | File to display/play or youtube url 40 | 41 | * **Optional arguments** 42 | 43 | * `-y`. `--youtube` 44 | View youtube videos 45 | 46 | * `--height` 47 | Set height (setting both `width` and `height` will ignore original aspect ratio) 48 | 49 | * `--width` 50 | Set width (setting both `width` and `height` will ignore original aspect ratio) 51 | 52 | * `--start-time` 53 | 54 | Set start position for video. 55 | 56 | * `-f`, `--fps` 57 | Set fps (default 15 fps) 58 | 59 | * `-m`, `--mute` 60 | Mute audio 61 | 62 | * `-h`, `--help ` 63 | Display help 64 | -------------------------------------------------------------------------------- /ptmv/console.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import numpy 4 | import threading 5 | 6 | def width(): return int(os.popen('stty size', 'r').read().split()[1]) 7 | def height(): return int(os.popen('stty size', 'r').read().split()[0]) * 2 8 | 9 | image_height = height() 10 | 11 | def _hide_cursor(): return "\033[?25l" 12 | def _show_cursor(): return "\033[?25h" 13 | def _move_cursor(x, y): return "\033[%d;%dH" % (y, x) 14 | def _fg_color(r, g, b): return "\x1b[48;2;%d;%d;%dm" % (r, g, b) 15 | def _bg_color(r, g, b): return "\x1b[38;2;%d;%d;%dm" % (r, g, b) 16 | def _reset_colors(): return "\x1b[0m" 17 | def _clear(): return "\n" * int(height() / 2) 18 | 19 | def hide_cursor(): print(_hide_cursor(), end = "") 20 | def show_cursor(): print(_show_cursor(), end = "") 21 | def move_cursor(x, y): print(_move_cursor(x, y), end = "") 22 | def fg_color(r, g, b): print(_fg_color(r, g, b), end = "") 23 | def bg_color(r, g, b): print(_bg_color(r, g, b), end = "") 24 | def reset_colors(): print(_reset_colors(), end = "") 25 | def clear(): print(_clear(), end = "") 26 | def set_image_height(height): global image_height; image_height = height 27 | 28 | def cleanup(): 29 | reset_colors() 30 | show_cursor() 31 | move_cursor(0, int(image_height / 2)) 32 | print() 33 | 34 | def draw_image(image): draw_frame(None, image) 35 | 36 | def draw_frame(prev, current): 37 | instructions = [] 38 | add = instructions.append 39 | 40 | nextPos = (-1, -1) 41 | for i in range(0, current.shape[0] - 1, 2): 42 | for j in range(0, current.shape[1] - 1): 43 | if prev is None or not (pixel_equals(prev, current, i, j) and pixel_equals(prev, current, i + 1, j)): 44 | add(_fg_color(current[i, j, 2], current[i, j, 1], current[i, j, 0])) 45 | add(_bg_color(current[i + 1, j, 2], current[i + 1, j, 1], current[i + 1, j, 0])) 46 | if not (i, j) == nextPos: add(_move_cursor(j + 1, i / 2 + 1)) 47 | add("▄") 48 | nextPos = (i, j + 1) 49 | 50 | sys.stdout.write("".join(instructions)) 51 | 52 | def pixel_equals(a, b, i, j): return a[i, j, 0] == b[i, j, 0] and a[i, j, 1] == b[i, j, 1] and a[i, j, 2] == b[i, j, 2] 53 | -------------------------------------------------------------------------------- /ptmv/__main__.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import sys 3 | import argparse 4 | import mimetypes 5 | import os 6 | import threading 7 | 8 | from . import console 9 | from . import img 10 | from . import snd 11 | from . import vid 12 | from . import yt 13 | 14 | def get_args(): 15 | doc = """ 16 | View images and videos without leaving the console.\n 17 | Requires a terminal that supports truecolor and utf-8\n 18 | For more info visit 19 | """ 20 | 21 | parser = argparse.ArgumentParser(description = doc) 22 | parser.add_argument("FILE") 23 | parser.add_argument("-y", "--youtube", help = "Play video from youtube.", action = "store_true") 24 | parser.add_argument("--width", help = "Set output width.", type = int) 25 | parser.add_argument("--height", help = "Set output height.", type = int) 26 | parser.add_argument("--fps", help = "Set target fps; Default 15 fps.", type = int, default = 15) 27 | parser.add_argument("--start-time", help = "Set start time (seconds)", type = float, default = 0) 28 | parser.add_argument("-m", "--mute", help = "Mute audio", action = "store_true") 29 | 30 | parsed_args = parser.parse_args() 31 | parsed_args.FILE = os.path.expanduser(parsed_args.FILE) 32 | return parsed_args 33 | 34 | def main(): 35 | args = get_args() 36 | 37 | signal.signal(signal.SIGINT, set_exit_flag) 38 | threading.Thread(target = exit_flag_watcher).start() 39 | 40 | if args.youtube: args.FILE = yt.download(args.FILE) 41 | if not os.path.isfile(args.FILE): 42 | print("[" + args.FILE + "] does not exist"); os._exit(-1) 43 | 44 | if file_type(args.FILE) == "image": img.display(args.FILE, args.width, args.height) 45 | elif file_type(args.FILE) == "video": vid.play(args.FILE, args.width, args.height, args.fps, args.start_time) 46 | else: print("[" + args.FILE + "] is not a supperted file type"); os._exit(-1) 47 | 48 | set_exit_flag() 49 | 50 | def file_type(file): 51 | mimetypes.init() 52 | return mimetypes.guess_type(file)[0].split('/')[0] 53 | 54 | exit_flag = False 55 | 56 | def set_exit_flag(*_): global exit_flag; exit_flag = True 57 | 58 | def exit_flag_watcher(): 59 | while True: 60 | if exit_flag: 61 | console.cleanup() 62 | os._exit(0) 63 | 64 | if __name__ == "__main__": main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

Python Terminal Media Viewer

3 |

View images and videos without leaving the console

4 |

5 | 6 | 7 | 8 |

9 |

10 | 11 | 12 | ---- 13 | 14 | ### Features 15 | 16 | * View **images** form any terminal 17 | * Watch **videos** from any terminal 18 | * Watch **youtube** videos from any terminal (`-y`, `--youtube`) 19 | * Play videos at **any fps** (`--fps`) with sound 20 | * **Resize** images / videos (`--width`, `--height`) 21 | * Easy to use 22 | 23 | ---- 24 | 25 | ### Examples 26 | 27 | **Viewing an image** 28 | 29 | 30 | 31 | **Watching a video** 32 | 33 | 34 | 35 | ---- 36 | 37 | ### Requirements 38 | 39 | * A terminal that supports **truecolor** ([list](https://gist.github.com/XVilka/8346728)) and **utf-8** (most terminals should support utf-8). 40 | * `libasound2-dev` / `alsa-lib` 41 | * for Ubuntu: `apt install libasound2-dev` 42 | * for Arch Linux: `pacman -S alsa-lib` 43 | * `ffmpeg` 44 | * for Ubuntu: `apt install ffmpeg` 45 | * for Arch Linux: `pacman -S ffmpeg` 46 | 47 | ---- 48 | 49 | ### Installation 50 | 51 | ```shell 52 | pip install ptmv 53 | ``` 54 | 55 | ---- 56 | 57 | ### Usage 58 | 59 | ```shell 60 | ptmv FILE [OPTIONS] 61 | ``` 62 | 63 | * **Required arguments** 64 | 65 | * `FILE` 66 | 67 | File to display/play or youtube url 68 | 69 | * **Optional arguments** 70 | 71 | * `-y`. `--youtube` 72 | View youtube videos 73 | 74 | * `--height` 75 | Set height (setting both `width` and `height` will ignore original aspect ratio) 76 | 77 | * `--width` 78 | Set width (setting both `width` and `height` will ignore original aspect ratio) 79 | 80 | * `--start-time` 81 | 82 | Set start position for video. 83 | 84 | * `-f`, `--fps` 85 | Set fps (default 15 fps) 86 | 87 | * `-m`, `--mute` 88 | Mute audio 89 | 90 | * `-h`, `--help ` 91 | Display help 92 | 93 | ---- 94 | 95 | ### Contributing 96 | 97 | Any contributions are greatly appreciated. 98 | 99 | ---- 100 | 101 | **kal39**(https://github.com/kal39) - kaikitagawajones@gmail.com 102 | Distributed under the MIT license. See `LICENSE` for more information. 103 | --------------------------------------------------------------------------------