termview 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
termview/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """
2
+ termview — view images, animations, and videos in the terminal.
3
+
4
+ Public API:
5
+
6
+ from termview import detect_renderer, get_renderer, fit_image, load_image
7
+ from termview import stream_video, stream_animation
8
+
9
+ Most users want the `tv` CLI command rather than the library API.
10
+ """
11
+
12
+ from .audio import AudioPlayer, is_available as audio_available
13
+ from .controls import Key, read_key
14
+ from .detect import (
15
+ ColorDepth,
16
+ RendererType,
17
+ detect_color_depth,
18
+ detect_environment,
19
+ detect_renderer,
20
+ terminal_size,
21
+ )
22
+ from .loader import (
23
+ is_animated,
24
+ is_image,
25
+ is_video,
26
+ iter_frames,
27
+ load_image,
28
+ )
29
+ from .renderers import get_renderer
30
+ from .resize import fit_image
31
+ from .stream import stream_animation, stream_video
32
+ from .terminal import TerminalState
33
+
34
+ __all__ = [
35
+ "AudioPlayer",
36
+ "ColorDepth",
37
+ "Key",
38
+ "RendererType",
39
+ "TerminalState",
40
+ "audio_available",
41
+ "detect_color_depth",
42
+ "detect_environment",
43
+ "detect_renderer",
44
+ "fit_image",
45
+ "get_renderer",
46
+ "is_animated",
47
+ "is_image",
48
+ "is_video",
49
+ "iter_frames",
50
+ "load_image",
51
+ "read_key",
52
+ "stream_animation",
53
+ "stream_video",
54
+ "terminal_size",
55
+ ]
termview/audio.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ Audio playback for video files via an external subprocess.
3
+
4
+ We do *not* decode or mix audio in-process — that would require either PyAV
5
+ (50 MB+ C extension) or PyAudio + manual A/V sync, both of which are vastly
6
+ more complex than the actual problem. Instead we shell out to the first
7
+ available audio player on the system:
8
+
9
+ ffplay — cross-platform, ships with ffmpeg, handles every format
10
+ afplay — built into macOS, handles mp3/m4a/wav but not raw video
11
+ paplay — Linux PulseAudio, handles wav only
12
+
13
+ ffplay is the only one that decodes audio out of arbitrary video containers,
14
+ so it's the one we actively support. The others are documented fallbacks for
15
+ when the user has audio files (.mp3 etc.) rather than video.
16
+
17
+ Sync model
18
+ ----------
19
+ We don't try to read the audio process's clock. Instead:
20
+ - audio subprocess is started at wall-clock T₀ and paces itself.
21
+ - the video render loop also derives its target frame time from T₀.
22
+ - both run off the same origin → drift is bounded by the audio player's
23
+ internal A/V skew correction (ffplay's is good for hours of playback).
24
+
25
+ Pause / seek invalidate the audio subprocess and we respawn at the new
26
+ position with `-ss <seconds>`. Spawn latency is ~250ms, accepted as the
27
+ cost of audio sync.
28
+ """
29
+
30
+ import os
31
+ import shutil
32
+ import signal
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+
38
+ def _find_player() -> str | None:
39
+ """Return the path to ffplay if installed, else None."""
40
+ return shutil.which("ffplay")
41
+
42
+
43
+ def is_available() -> bool:
44
+ return _find_player() is not None
45
+
46
+
47
+ def install_hint() -> str:
48
+ if sys.platform == "darwin":
49
+ return "Install with: brew install ffmpeg"
50
+ if sys.platform.startswith("linux"):
51
+ return "Install with: apt install ffmpeg (or your distro's equivalent)"
52
+ return "Install ffmpeg from https://ffmpeg.org/download.html"
53
+
54
+
55
+ class AudioPlayer:
56
+ """
57
+ Wraps an ffplay subprocess. Each method is best-effort — audio is a
58
+ nice-to-have; failures must never crash video playback.
59
+ """
60
+
61
+ def __init__(self, path: Path, volume: int = 100) -> None:
62
+ self.path = path
63
+ self.volume = max(0, min(100, volume))
64
+ self.muted = False
65
+ self._proc: subprocess.Popen | None = None
66
+ self._start_offset: float = 0.0 # seconds into the file
67
+ self._player = _find_player()
68
+
69
+ @property
70
+ def available(self) -> bool:
71
+ return self._player is not None
72
+
73
+ # ------------------------------------------------------------------ control
74
+
75
+ def start(self, position_sec: float = 0.0) -> None:
76
+ """Start (or restart) audio at the given position."""
77
+ if not self.available:
78
+ return
79
+ self.stop()
80
+ self._start_offset = max(0.0, position_sec)
81
+
82
+ vol = 0 if self.muted else self.volume
83
+ cmd = [
84
+ self._player,
85
+ "-nodisp", # no video window
86
+ "-autoexit", # die when file ends
87
+ "-loglevel", "quiet",
88
+ "-volume", str(vol),
89
+ "-ss", f"{self._start_offset:.3f}",
90
+ str(self.path),
91
+ ]
92
+ try:
93
+ self._proc = subprocess.Popen(
94
+ cmd,
95
+ stdin=subprocess.DEVNULL,
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.DEVNULL,
98
+ # New process group so SIGINT to our terminal doesn't reach it
99
+ # before we get a chance to cleanly SIGTERM.
100
+ start_new_session=True,
101
+ )
102
+ except (OSError, FileNotFoundError):
103
+ self._proc = None
104
+
105
+ def stop(self) -> None:
106
+ """Terminate the audio subprocess if running. Idempotent."""
107
+ if self._proc is None:
108
+ return
109
+ try:
110
+ if self._proc.poll() is None:
111
+ # SIGTERM gives ffplay a chance to close its audio device cleanly,
112
+ # which avoids the "audio drop-out into next thing you play"
113
+ # CoreAudio bug on macOS.
114
+ os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
115
+ try:
116
+ self._proc.wait(timeout=0.5)
117
+ except subprocess.TimeoutExpired:
118
+ os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
119
+ except (ProcessLookupError, PermissionError, OSError):
120
+ pass
121
+ finally:
122
+ self._proc = None
123
+
124
+ def toggle_mute(self) -> bool:
125
+ """Toggle mute. Returns new muted state. Requires respawn."""
126
+ self.muted = not self.muted
127
+ return self.muted
128
+
129
+ def set_volume(self, vol: int) -> None:
130
+ self.volume = max(0, min(100, vol))
131
+
132
+ def is_running(self) -> bool:
133
+ return self._proc is not None and self._proc.poll() is None
134
+
135
+ # ------------------------------------------------------------------ context
136
+
137
+ def __enter__(self) -> "AudioPlayer":
138
+ return self
139
+
140
+ def __exit__(self, *exc) -> None:
141
+ self.stop()
termview/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ Command-line entry point for `tv`.
3
+
4
+ Responsibilities:
5
+ - parse arguments
6
+ - dispatch to the appropriate playback path (image / animation / video)
7
+ - handle stdout-not-a-TTY and broken-pipe scenarios cleanly
8
+ - keep the actual rendering / playback logic out of this file
9
+ """
10
+
11
+ import argparse
12
+ import errno
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .detect import (
17
+ ColorDepth,
18
+ RendererType,
19
+ detect_color_depth,
20
+ detect_environment,
21
+ detect_renderer,
22
+ terminal_size,
23
+ )
24
+ from .loader import is_animated, is_image, is_video, load_image
25
+ from .renderers import get_renderer
26
+ from .resize import fit_image
27
+ from .stream import stream_animation, stream_video
28
+
29
+ _MIN_COLS = 20
30
+ _MIN_ROWS = 8
31
+
32
+
33
+ def main() -> None:
34
+ try:
35
+ _main()
36
+ except BrokenPipeError:
37
+ # Piped output closed before we finished writing (e.g. `tv x.png | head`).
38
+ # Suppress and exit cleanly so we don't dump a traceback.
39
+ try:
40
+ sys.stdout.close()
41
+ except Exception:
42
+ pass
43
+ sys.exit(0)
44
+ except KeyboardInterrupt:
45
+ sys.exit(130)
46
+
47
+
48
+ def _main() -> None:
49
+ parser = _build_parser()
50
+ args = parser.parse_args()
51
+
52
+ path: Path = args.file
53
+ if not path.exists():
54
+ parser.error(f"{path}: no such file or directory")
55
+
56
+ renderer_type = detect_renderer(args.renderer)
57
+ color_depth = detect_color_depth(args.depth)
58
+ cols, rows = terminal_size()
59
+ if args.width:
60
+ cols = args.width
61
+
62
+ env = detect_environment()
63
+
64
+ # Refuse to draw graphics into a non-TTY stdout — we'd corrupt whatever
65
+ # file or pipe the user redirected to with cursor/clear escapes. Allow
66
+ # `--renderer block` + image to still work if user is piping (it's just
67
+ # text), but disable cursor sequences in that case.
68
+ if not env["tty_stdout"] and is_video(path):
69
+ parser.error("video playback requires stdout to be a terminal")
70
+
71
+ if args.verbose:
72
+ _print_diagnostics(renderer_type, color_depth, cols, rows, env)
73
+
74
+ # Tiny-terminal guard.
75
+ if cols < _MIN_COLS or rows < _MIN_ROWS:
76
+ sys.stderr.write(
77
+ f"tv: terminal too small ({cols}x{rows}); "
78
+ f"need at least {_MIN_COLS}x{_MIN_ROWS}.\n"
79
+ )
80
+ sys.exit(2)
81
+
82
+ if is_image(path):
83
+ img = load_image(path)
84
+ if is_animated(img):
85
+ stream_animation(
86
+ img,
87
+ renderer_type,
88
+ color_depth=color_depth,
89
+ enable_controls=not args.no_controls,
90
+ loop=args.loop,
91
+ )
92
+ else:
93
+ _display_image(img, renderer_type, color_depth, cols, rows, args)
94
+ elif is_video(path):
95
+ stream_video(
96
+ path,
97
+ renderer_type,
98
+ color_depth=color_depth,
99
+ fps_limit=args.fps,
100
+ enable_audio=not args.no_audio,
101
+ enable_controls=not args.no_controls,
102
+ verbose=args.verbose,
103
+ )
104
+ else:
105
+ parser.error(f"{path}: unsupported file type")
106
+
107
+
108
+ def _display_image(img, renderer_type, color_depth, cols, rows, args) -> None:
109
+ original_size = img.size
110
+ fitted = fit_image(img, cols, rows, renderer_type, crop=not args.no_crop)
111
+ if args.verbose:
112
+ print(
113
+ f"[tv] image {original_size[0]}x{original_size[1]} "
114
+ f"-> render {fitted.size[0]}x{fitted.size[1]}",
115
+ file=sys.stderr,
116
+ )
117
+ renderer = get_renderer(renderer_type, color_depth=color_depth)
118
+ renderer.display(fitted)
119
+
120
+
121
+ def _print_diagnostics(renderer_type, color_depth, cols, rows, env) -> None:
122
+ parts = [
123
+ f"renderer={renderer_type.value}",
124
+ f"color={color_depth.value}",
125
+ f"terminal={cols}x{rows}",
126
+ ]
127
+ if env["tmux"]:
128
+ parts.append("tmux=yes")
129
+ if env["ssh"]:
130
+ parts.append("ssh=yes")
131
+ print(f"[tv] {' '.join(parts)}", file=sys.stderr)
132
+
133
+
134
+ # ---------------------------------------------------------------------- argparse
135
+
136
+ def _build_parser() -> argparse.ArgumentParser:
137
+ p = argparse.ArgumentParser(
138
+ prog="tv",
139
+ description="View images, animations, and videos in the terminal.",
140
+ epilog=_CONTROLS_HELP,
141
+ formatter_class=argparse.RawDescriptionHelpFormatter,
142
+ )
143
+ p.add_argument("file", type=Path, help="Image, animated GIF, or video file")
144
+
145
+ g_render = p.add_argument_group("rendering")
146
+ g_render.add_argument(
147
+ "--renderer",
148
+ choices=[r.value for r in RendererType],
149
+ metavar="NAME",
150
+ help="Force renderer: kitty | iterm2 | sixel | block (default: auto)",
151
+ )
152
+ g_render.add_argument(
153
+ "--depth",
154
+ choices=["truecolor", "256"],
155
+ metavar="DEPTH",
156
+ help="Force color depth (default: auto)",
157
+ )
158
+ g_render.add_argument(
159
+ "--width",
160
+ type=int,
161
+ metavar="COLS",
162
+ help="Override terminal width in columns",
163
+ )
164
+ g_render.add_argument(
165
+ "--no-crop",
166
+ action="store_true",
167
+ help="Disable automatic border cropping (still images only)",
168
+ )
169
+
170
+ g_video = p.add_argument_group("video / animation")
171
+ g_video.add_argument(
172
+ "--fps",
173
+ type=float,
174
+ metavar="N",
175
+ help="Limit playback frame rate (default: auto; 12 on 256-color)",
176
+ )
177
+ g_video.add_argument(
178
+ "--no-audio",
179
+ action="store_true",
180
+ help="Disable audio playback (video only)",
181
+ )
182
+ g_video.add_argument(
183
+ "--no-controls",
184
+ action="store_true",
185
+ help="Disable keyboard controls (no pause/seek; for scripts/recording)",
186
+ )
187
+ g_video.add_argument(
188
+ "--loop",
189
+ action="store_true",
190
+ help="Loop animated images (GIF/WebP/APNG). Default: loop forever.",
191
+ default=True,
192
+ )
193
+
194
+ p.add_argument(
195
+ "-v",
196
+ "--verbose",
197
+ action="store_true",
198
+ help="Print detection results and per-frame info to stderr",
199
+ )
200
+ return p
201
+
202
+
203
+ _CONTROLS_HELP = """\
204
+ Playback controls (video):
205
+ space play / pause
206
+ ← / → seek -5s / +5s
207
+ ↓ / ↑ seek -30s / +30s
208
+ , / . previous / next frame (while paused)
209
+ m mute / unmute
210
+ + / - volume up / down
211
+ 0 restart from beginning
212
+ q / esc quit
213
+
214
+ Requires ffmpeg for audio. Without it, video plays silently.
215
+ """
termview/controls.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Non-blocking keystroke reading for interactive video playback.
3
+
4
+ Reads stdin one byte at a time and decodes:
5
+ - printable ASCII (space, q, m, f, comma, period, plus, minus, etc.)
6
+ - escape sequences for arrow keys + shift-arrows
7
+ - bare ESC (also returned for "quit")
8
+
9
+ Designed to be called once per frame with the frame-budget as timeout, so it
10
+ gives keystrokes near-immediate response while never blocking the render loop.
11
+ Assumes stdin is already in cbreak mode (see TerminalState).
12
+ """
13
+
14
+ import select
15
+ import sys
16
+ from enum import Enum
17
+
18
+
19
+ class Key(Enum):
20
+ NONE = "none"
21
+ QUIT = "quit"
22
+ PAUSE = "pause" # space
23
+ SEEK_BACK = "seek_back" # left arrow
24
+ SEEK_FWD = "seek_fwd" # right arrow
25
+ SEEK_BACK_BIG = "seek_back_big" # shift+left or down
26
+ SEEK_FWD_BIG = "seek_fwd_big" # shift+right or up
27
+ FRAME_PREV = "frame_prev" # ,
28
+ FRAME_NEXT = "frame_next" # .
29
+ MUTE = "mute" # m
30
+ VOL_UP = "vol_up" # + or =
31
+ VOL_DOWN = "vol_down" # -
32
+ RESTART = "restart" # 0 or home
33
+
34
+
35
+ def read_key(timeout: float) -> Key:
36
+ """
37
+ Read at most one key event from stdin, waiting up to *timeout* seconds.
38
+ Returns Key.NONE if nothing arrived in that window.
39
+
40
+ Caller must ensure stdin is in cbreak (or raw) mode and is a TTY.
41
+ """
42
+ if not sys.stdin.isatty():
43
+ return Key.NONE
44
+
45
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
46
+ if not ready:
47
+ return Key.NONE
48
+
49
+ ch = sys.stdin.read(1)
50
+ if not ch:
51
+ return Key.NONE
52
+
53
+ # Single-character bindings.
54
+ simple = {
55
+ " ": Key.PAUSE,
56
+ "q": Key.QUIT,
57
+ "Q": Key.QUIT,
58
+ ",": Key.FRAME_PREV,
59
+ "<": Key.FRAME_PREV,
60
+ ".": Key.FRAME_NEXT,
61
+ ">": Key.FRAME_NEXT,
62
+ "m": Key.MUTE,
63
+ "M": Key.MUTE,
64
+ "+": Key.VOL_UP,
65
+ "=": Key.VOL_UP,
66
+ "-": Key.VOL_DOWN,
67
+ "_": Key.VOL_DOWN,
68
+ "0": Key.RESTART,
69
+ }
70
+ if ch in simple:
71
+ return simple[ch]
72
+
73
+ # ESC starts either a bare escape (= quit) or a CSI sequence.
74
+ if ch != "\033":
75
+ return Key.NONE
76
+
77
+ # Peek for a follow-up byte; if none arrives in 50ms it was a bare ESC.
78
+ ready, _, _ = select.select([sys.stdin], [], [], 0.05)
79
+ if not ready:
80
+ return Key.QUIT
81
+
82
+ if sys.stdin.read(1) != "[":
83
+ return Key.NONE # unrecognized ESC-anything
84
+
85
+ # CSI sequence. Read the final byte (and the optional modifier digits).
86
+ seq = ""
87
+ while True:
88
+ ready, _, _ = select.select([sys.stdin], [], [], 0.05)
89
+ if not ready:
90
+ break
91
+ b = sys.stdin.read(1)
92
+ seq += b
93
+ # Final bytes of a CSI sequence are in the 0x40-0x7E range.
94
+ if "@" <= b <= "~":
95
+ break
96
+
97
+ # Common arrow keys
98
+ if seq == "A":
99
+ return Key.SEEK_FWD_BIG # up: +30s
100
+ if seq == "B":
101
+ return Key.SEEK_BACK_BIG # down: -30s
102
+ if seq == "C":
103
+ return Key.SEEK_FWD # right: +5s
104
+ if seq == "D":
105
+ return Key.SEEK_BACK # left: -5s
106
+ # Shift-arrows arrive as CSI 1;2A / 1;2B / 1;2C / 1;2D in xterm-style.
107
+ if seq.endswith("C") and "2" in seq:
108
+ return Key.SEEK_FWD_BIG
109
+ if seq.endswith("D") and "2" in seq:
110
+ return Key.SEEK_BACK_BIG
111
+ if seq == "H":
112
+ return Key.RESTART # Home
113
+
114
+ return Key.NONE