termview 0.1.0__tar.gz
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-0.1.0/.gitignore +28 -0
- termview-0.1.0/LICENSE +21 -0
- termview-0.1.0/PKG-INFO +148 -0
- termview-0.1.0/README.md +117 -0
- termview-0.1.0/pyproject.toml +57 -0
- termview-0.1.0/termview/__init__.py +55 -0
- termview-0.1.0/termview/audio.py +141 -0
- termview-0.1.0/termview/cli.py +215 -0
- termview-0.1.0/termview/controls.py +114 -0
- termview-0.1.0/termview/detect.py +188 -0
- termview-0.1.0/termview/loader.py +90 -0
- termview-0.1.0/termview/renderers/__init__.py +37 -0
- termview-0.1.0/termview/renderers/base.py +14 -0
- termview-0.1.0/termview/renderers/block.py +185 -0
- termview-0.1.0/termview/renderers/iterm2.py +34 -0
- termview-0.1.0/termview/renderers/kitty.py +64 -0
- termview-0.1.0/termview/renderers/sixel.py +88 -0
- termview-0.1.0/termview/resize.py +120 -0
- termview-0.1.0/termview/stream.py +410 -0
- termview-0.1.0/termview/terminal.py +143 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
wheels/
|
|
7
|
+
.eggs/
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.py[cod]
|
|
12
|
+
*$py.class
|
|
13
|
+
*.so
|
|
14
|
+
.Python
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# Virtualenvs
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
env/
|
|
23
|
+
|
|
24
|
+
# OS / editor
|
|
25
|
+
.DS_Store
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
termview-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zain ul Wahaj
|
|
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.
|
termview-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: View images, animated GIFs, and videos in the terminal — with audio and keyboard controls
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/termview
|
|
6
|
+
Project-URL: Repository, https://github.com/yourusername/termview
|
|
7
|
+
Project-URL: Issues, https://github.com/yourusername/termview/issues
|
|
8
|
+
Author: Zain ul Wahaj
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ansi,image,iterm2,kitty,sixel,terminal,video,viewer
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics :: Viewers
|
|
23
|
+
Classifier: Topic :: Multimedia :: Video :: Display
|
|
24
|
+
Classifier: Topic :: Terminals
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: numpy>=1.24
|
|
27
|
+
Requires-Dist: pillow>=10.0
|
|
28
|
+
Provides-Extra: video
|
|
29
|
+
Requires-Dist: opencv-python-headless>=4.8; extra == 'video'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# termview
|
|
33
|
+
|
|
34
|
+
View images, animated GIFs, and videos in your terminal. Audio + keyboard controls included.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
tv photo.jpg
|
|
38
|
+
tv animation.gif
|
|
39
|
+
tv movie.mp4
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e ".[video]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Video playback also wants `ffmpeg` for audio. Without it, video plays silently:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
brew install ffmpeg # macOS
|
|
52
|
+
apt install ffmpeg # Debian/Ubuntu
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it picks a renderer
|
|
56
|
+
|
|
57
|
+
`tv` auto-detects the best graphics protocol your terminal supports and falls
|
|
58
|
+
back gracefully. The four paths, in quality order:
|
|
59
|
+
|
|
60
|
+
| Renderer | Used when | Quality |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| **kitty** | Kitty, WezTerm, Ghostty (sets `$KITTY_WINDOW_ID` or `$TERM_PROGRAM`) | pixel-perfect |
|
|
63
|
+
| **iterm2** | iTerm2, Warp (sets `$TERM_PROGRAM=iTerm.app`) | pixel-perfect |
|
|
64
|
+
| **sixel** | xterm, foot, Windows Terminal, mlterm (queried via DA1) | pixel-perfect |
|
|
65
|
+
| **block** | everywhere else (universal fallback) | ANSI background fills, one image pixel per cell |
|
|
66
|
+
|
|
67
|
+
The **block** renderer auto-switches between truecolor (`\033[48;2;R;G;Bm`) and
|
|
68
|
+
xterm 256-color with Floyd-Steinberg dithering depending on what your terminal
|
|
69
|
+
actually supports — macOS Terminal.app gets dithered output, everything else
|
|
70
|
+
gets full 24-bit.
|
|
71
|
+
|
|
72
|
+
Force a renderer:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
tv photo.jpg --renderer kitty
|
|
76
|
+
tv photo.jpg --renderer block --depth 256
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Video playback
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
tv movie.mp4 # plays with audio (if ffmpeg installed)
|
|
83
|
+
tv movie.mp4 --no-audio # silent
|
|
84
|
+
tv movie.mp4 --fps 8 # throttle frame rate
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Keyboard controls
|
|
88
|
+
|
|
89
|
+
| Key | Action |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `space` | play / pause |
|
|
92
|
+
| `←` `→` | seek -5s / +5s |
|
|
93
|
+
| `↓` `↑` | seek -30s / +30s |
|
|
94
|
+
| `,` `.` | previous / next frame (while paused) |
|
|
95
|
+
| `m` | mute / unmute |
|
|
96
|
+
| `+` `-` | volume up / down |
|
|
97
|
+
| `0` | restart from beginning |
|
|
98
|
+
| `q` / `esc` | quit |
|
|
99
|
+
|
|
100
|
+
Add `--no-controls` to disable for scripting / asciinema recording.
|
|
101
|
+
|
|
102
|
+
## Cross-terminal notes
|
|
103
|
+
|
|
104
|
+
| Environment | Behavior |
|
|
105
|
+
|---|---|
|
|
106
|
+
| **tmux** | Forces the block renderer. Pixel-protocol passthrough is fragile across tmux versions; `--renderer kitty` etc. can still be forced if you've enabled `allow-passthrough on` (tmux 3.4+). |
|
|
107
|
+
| **SSH** | Forces the block renderer. Inline-image protocols don't survive most SSH chains. |
|
|
108
|
+
| **macOS Terminal.app** | Auto-detected as 256-color. Floyd-Steinberg dithering kicks in for stills; video uses no-dither for stability and an automatic 12fps cap. |
|
|
109
|
+
| **Windows Terminal** | Auto-detected via `$WT_SESSION`, uses sixel. |
|
|
110
|
+
| **non-TTY stdout** (`tv x.png > out`) | Video playback refuses. Images write a renderable stream that's only meaningful when re-played to a terminal. |
|
|
111
|
+
|
|
112
|
+
## CLI reference
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
usage: tv [-h] [--renderer NAME] [--depth DEPTH] [--width COLS] [--no-crop]
|
|
116
|
+
[--fps N] [--no-audio] [--no-controls] [--loop] [-v]
|
|
117
|
+
file
|
|
118
|
+
|
|
119
|
+
rendering:
|
|
120
|
+
--renderer NAME kitty | iterm2 | sixel | block (default: auto)
|
|
121
|
+
--depth DEPTH truecolor | 256 (default: auto)
|
|
122
|
+
--width COLS override terminal width
|
|
123
|
+
--no-crop disable automatic border cropping
|
|
124
|
+
|
|
125
|
+
video / animation:
|
|
126
|
+
--fps N limit playback frame rate
|
|
127
|
+
--no-audio disable audio
|
|
128
|
+
--no-controls disable keyboard controls
|
|
129
|
+
--loop loop animated images (default: on)
|
|
130
|
+
|
|
131
|
+
-v, --verbose print detection diagnostics
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Library use
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from termview import load_image, fit_image, get_renderer, detect_renderer, terminal_size
|
|
138
|
+
|
|
139
|
+
img = load_image("photo.jpg")
|
|
140
|
+
cols, rows = terminal_size()
|
|
141
|
+
renderer_type = detect_renderer()
|
|
142
|
+
fitted = fit_image(img, cols, rows, renderer_type)
|
|
143
|
+
get_renderer(renderer_type).display(fitted)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Video and animation playback have higher-level entry points
|
|
147
|
+
(`stream_video`, `stream_animation`) that bundle the playback loop, audio
|
|
148
|
+
process management, keyboard input, and terminal state restoration.
|
termview-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# termview
|
|
2
|
+
|
|
3
|
+
View images, animated GIFs, and videos in your terminal. Audio + keyboard controls included.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
tv photo.jpg
|
|
7
|
+
tv animation.gif
|
|
8
|
+
tv movie.mp4
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[video]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Video playback also wants `ffmpeg` for audio. Without it, video plays silently:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
brew install ffmpeg # macOS
|
|
21
|
+
apt install ffmpeg # Debian/Ubuntu
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## How it picks a renderer
|
|
25
|
+
|
|
26
|
+
`tv` auto-detects the best graphics protocol your terminal supports and falls
|
|
27
|
+
back gracefully. The four paths, in quality order:
|
|
28
|
+
|
|
29
|
+
| Renderer | Used when | Quality |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| **kitty** | Kitty, WezTerm, Ghostty (sets `$KITTY_WINDOW_ID` or `$TERM_PROGRAM`) | pixel-perfect |
|
|
32
|
+
| **iterm2** | iTerm2, Warp (sets `$TERM_PROGRAM=iTerm.app`) | pixel-perfect |
|
|
33
|
+
| **sixel** | xterm, foot, Windows Terminal, mlterm (queried via DA1) | pixel-perfect |
|
|
34
|
+
| **block** | everywhere else (universal fallback) | ANSI background fills, one image pixel per cell |
|
|
35
|
+
|
|
36
|
+
The **block** renderer auto-switches between truecolor (`\033[48;2;R;G;Bm`) and
|
|
37
|
+
xterm 256-color with Floyd-Steinberg dithering depending on what your terminal
|
|
38
|
+
actually supports — macOS Terminal.app gets dithered output, everything else
|
|
39
|
+
gets full 24-bit.
|
|
40
|
+
|
|
41
|
+
Force a renderer:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
tv photo.jpg --renderer kitty
|
|
45
|
+
tv photo.jpg --renderer block --depth 256
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Video playback
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
tv movie.mp4 # plays with audio (if ffmpeg installed)
|
|
52
|
+
tv movie.mp4 --no-audio # silent
|
|
53
|
+
tv movie.mp4 --fps 8 # throttle frame rate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Keyboard controls
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `space` | play / pause |
|
|
61
|
+
| `←` `→` | seek -5s / +5s |
|
|
62
|
+
| `↓` `↑` | seek -30s / +30s |
|
|
63
|
+
| `,` `.` | previous / next frame (while paused) |
|
|
64
|
+
| `m` | mute / unmute |
|
|
65
|
+
| `+` `-` | volume up / down |
|
|
66
|
+
| `0` | restart from beginning |
|
|
67
|
+
| `q` / `esc` | quit |
|
|
68
|
+
|
|
69
|
+
Add `--no-controls` to disable for scripting / asciinema recording.
|
|
70
|
+
|
|
71
|
+
## Cross-terminal notes
|
|
72
|
+
|
|
73
|
+
| Environment | Behavior |
|
|
74
|
+
|---|---|
|
|
75
|
+
| **tmux** | Forces the block renderer. Pixel-protocol passthrough is fragile across tmux versions; `--renderer kitty` etc. can still be forced if you've enabled `allow-passthrough on` (tmux 3.4+). |
|
|
76
|
+
| **SSH** | Forces the block renderer. Inline-image protocols don't survive most SSH chains. |
|
|
77
|
+
| **macOS Terminal.app** | Auto-detected as 256-color. Floyd-Steinberg dithering kicks in for stills; video uses no-dither for stability and an automatic 12fps cap. |
|
|
78
|
+
| **Windows Terminal** | Auto-detected via `$WT_SESSION`, uses sixel. |
|
|
79
|
+
| **non-TTY stdout** (`tv x.png > out`) | Video playback refuses. Images write a renderable stream that's only meaningful when re-played to a terminal. |
|
|
80
|
+
|
|
81
|
+
## CLI reference
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
usage: tv [-h] [--renderer NAME] [--depth DEPTH] [--width COLS] [--no-crop]
|
|
85
|
+
[--fps N] [--no-audio] [--no-controls] [--loop] [-v]
|
|
86
|
+
file
|
|
87
|
+
|
|
88
|
+
rendering:
|
|
89
|
+
--renderer NAME kitty | iterm2 | sixel | block (default: auto)
|
|
90
|
+
--depth DEPTH truecolor | 256 (default: auto)
|
|
91
|
+
--width COLS override terminal width
|
|
92
|
+
--no-crop disable automatic border cropping
|
|
93
|
+
|
|
94
|
+
video / animation:
|
|
95
|
+
--fps N limit playback frame rate
|
|
96
|
+
--no-audio disable audio
|
|
97
|
+
--no-controls disable keyboard controls
|
|
98
|
+
--loop loop animated images (default: on)
|
|
99
|
+
|
|
100
|
+
-v, --verbose print detection diagnostics
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Library use
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from termview import load_image, fit_image, get_renderer, detect_renderer, terminal_size
|
|
107
|
+
|
|
108
|
+
img = load_image("photo.jpg")
|
|
109
|
+
cols, rows = terminal_size()
|
|
110
|
+
renderer_type = detect_renderer()
|
|
111
|
+
fitted = fit_image(img, cols, rows, renderer_type)
|
|
112
|
+
get_renderer(renderer_type).display(fitted)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Video and animation playback have higher-level entry points
|
|
116
|
+
(`stream_video`, `stream_animation`) that bundle the playback loop, audio
|
|
117
|
+
process management, keyboard input, and terminal state restoration.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "termview"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "View images, animated GIFs, and videos in the terminal — with audio and keyboard controls"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Zain ul Wahaj" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["terminal", "image", "video", "viewer", "ansi", "sixel", "kitty", "iterm2"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: End Users/Desktop",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Multimedia :: Graphics :: Viewers",
|
|
29
|
+
"Topic :: Multimedia :: Video :: Display",
|
|
30
|
+
"Topic :: Terminals",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"pillow>=10.0",
|
|
34
|
+
"numpy>=1.24",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
video = ["opencv-python-headless>=4.8"]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/yourusername/termview"
|
|
42
|
+
Repository = "https://github.com/yourusername/termview"
|
|
43
|
+
Issues = "https://github.com/yourusername/termview/issues"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
tv = "termview.cli:main"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["termview"]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
include = [
|
|
53
|
+
"termview",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"pyproject.toml",
|
|
57
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -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()
|