pycvr 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.
cvr/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """CVR: turn source code into a typewriter video in your real VS Code theme.
2
+
3
+ import cvr
4
+ cvr.render("script.py", "script.mp4", theme="Dark Modern", duration=20)
5
+
6
+ The GUI lives in `cvr.gui` and is only imported when you ask for it, so the
7
+ library works on machines without Tk.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from .palette import DEFAULT_PALETTE, Palette
13
+ from .renderer import (ANIMATION_STYLES, CodeTypewriterVideoRenderer,
14
+ RenderCancelled, Renderer, RenderError, render)
15
+ from .themes import (ThemeInfo, active_vscode_theme_label, default_theme,
16
+ find_theme, list_themes, load_theme)
17
+
18
+ __all__ = [
19
+ "ANIMATION_STYLES",
20
+ "CodeTypewriterVideoRenderer",
21
+ "DEFAULT_PALETTE",
22
+ "Palette",
23
+ "RenderCancelled",
24
+ "RenderError",
25
+ "Renderer",
26
+ "ThemeInfo",
27
+ "active_vscode_theme_label",
28
+ "default_theme",
29
+ "find_theme",
30
+ "list_themes",
31
+ "load_theme",
32
+ "render",
33
+ "__version__",
34
+ ]
cvr/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m cvr`: same as the `cvr` command."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
cvr/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """Command line: `cvr render`, `cvr themes`, and `cvr` alone for the GUI."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from typing import List, Optional, Tuple
7
+
8
+ from . import __version__
9
+ from .palette import DEFAULT_PALETTE
10
+ from .renderer import ANIMATION_STYLES, RenderError, render
11
+ from .themes import default_theme, list_themes
12
+
13
+
14
+ def _resolution(text: str) -> Tuple[int, int]:
15
+ try:
16
+ w, h = (int(v) for v in text.lower().split("x"))
17
+ except ValueError:
18
+ raise argparse.ArgumentTypeError(f"expected WIDTHxHEIGHT, got {text!r}")
19
+ if w <= 0 or h <= 0:
20
+ raise argparse.ArgumentTypeError("width and height must be positive")
21
+ return w, h
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="cvr",
27
+ description="Turn source code into a typewriter video in your VS Code "
28
+ "theme. Run with no command to open the app.")
29
+ parser.add_argument("--version", action="version",
30
+ version=f"%(prog)s {__version__}")
31
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
32
+
33
+ sub.add_parser("gui", help="open the app (the default)")
34
+
35
+ r = sub.add_parser("render", help="render a source file to a video",
36
+ description="Render a source file to a typewriter video.")
37
+ r.add_argument("file", help="source file to type out")
38
+ r.add_argument("-o", "--output", metavar="PATH",
39
+ help="video to write (default: FILE_typewriter.mp4 here)")
40
+ r.add_argument("-t", "--theme", metavar="NAME",
41
+ help="installed VS Code theme or a theme json path "
42
+ "(default: the theme VS Code is set to)")
43
+ r.add_argument("-d", "--duration", type=float, metavar="SECONDS",
44
+ help="fixed length in seconds; without it the delays decide")
45
+ r.add_argument("-r", "--resolution", type=_resolution, default=(1280, 720),
46
+ metavar="WxH", help="frame size (default: 1280x720)")
47
+ r.add_argument("--fps", type=int, default=30,
48
+ help="frames per second (default: 30)")
49
+ r.add_argument("--font", metavar="PATH", help="TrueType font file")
50
+ r.add_argument("--font-size", type=int, default=18, metavar="PX",
51
+ help="font size in pixels (default: 18)")
52
+ r.add_argument("--style", choices=ANIMATION_STYLES, default="smooth",
53
+ help="animation style (default: smooth)")
54
+ r.add_argument("--char-delay", type=float, default=0.05, metavar="SECONDS",
55
+ help="pause after each character (default: 0.05)")
56
+ r.add_argument("--line-delay", type=float, default=0.30, metavar="SECONDS",
57
+ help="pause after each line (default: 0.30)")
58
+ r.add_argument("--language", metavar="NAME",
59
+ help="Pygments lexer name, when the file extension is not enough")
60
+ r.add_argument("--no-line-numbers", action="store_true",
61
+ help="hide the line number gutter")
62
+ r.add_argument("--no-tab", action="store_true",
63
+ help="hide the file name tab")
64
+ r.add_argument("--no-cursor", action="store_true", help="hide the cursor")
65
+ r.add_argument("--no-blink", action="store_true",
66
+ help="keep the cursor solid")
67
+ r.add_argument("--no-follow", action="store_true",
68
+ help="do not scroll to follow the cursor")
69
+ r.add_argument("--no-smooth-scroll", action="store_true",
70
+ help="jump instead of gliding when scrolling")
71
+ r.add_argument("--no-highlight", action="store_true",
72
+ help="plain text, no syntax colours")
73
+ r.add_argument("--type-indent", action="store_true",
74
+ help="type leading spaces instead of indenting instantly")
75
+ r.add_argument("--compatible", action="store_true",
76
+ help="4:2:0 video that Windows Media Player can open "
77
+ "(about one level off on dark tones)")
78
+ r.add_argument("--open", action="store_true",
79
+ help="open the video when it is done")
80
+ r.add_argument("-q", "--quiet", action="store_true",
81
+ help="no progress bar")
82
+ r.add_argument("-v", "--verbose", action="store_true",
83
+ help="print timing details")
84
+
85
+ t = sub.add_parser("themes", help="list installed VS Code themes",
86
+ description="List the colour themes VS Code has "
87
+ "installed. * marks the default.")
88
+ t.add_argument("filter", nargs="?", default="",
89
+ help="only show themes whose name contains this")
90
+ t.add_argument("--paths", action="store_true",
91
+ help="show each theme's json file")
92
+ return parser
93
+
94
+
95
+ def _progress_printer(stream):
96
+ def show(pct: float, _total: float, status: str, eta: float):
97
+ width = 28
98
+ filled = int(width * pct / 100)
99
+ bar = "#" * filled + "-" * (width - filled)
100
+ eta_s = f" ETA {eta:.0f}s" if eta > 0 else ""
101
+ stream.write(f"\r[{bar}] {pct:5.1f}% {status}{eta_s}".ljust(79))
102
+ stream.flush()
103
+ return show
104
+
105
+
106
+ def _cmd_render(args) -> int:
107
+ show_progress = not args.quiet and sys.stderr.isatty()
108
+ try:
109
+ path = render(
110
+ args.file, args.output,
111
+ theme = args.theme,
112
+ duration = args.duration,
113
+ language = args.language,
114
+ progress = _progress_printer(sys.stderr) if show_progress else None,
115
+ resolution = args.resolution,
116
+ fps = args.fps,
117
+ font_path = args.font,
118
+ font_size = args.font_size,
119
+ animation_type = args.style,
120
+ char_delay = args.char_delay,
121
+ line_delay = args.line_delay,
122
+ show_line_numbers = not args.no_line_numbers,
123
+ show_filename_bar = not args.no_tab,
124
+ show_cursor = not args.no_cursor,
125
+ cursor_blink = not args.no_blink,
126
+ follow_cursor = not args.no_follow,
127
+ smooth_scroll = not args.no_smooth_scroll,
128
+ syntax_highlighting = not args.no_highlight,
129
+ instant_indent = not args.type_indent,
130
+ exact_colour = not args.compatible,
131
+ )
132
+ except KeyboardInterrupt:
133
+ if show_progress:
134
+ sys.stderr.write("\n")
135
+ print("cvr: cancelled", file=sys.stderr)
136
+ return 130
137
+ except (RenderError, LookupError, OSError, ValueError) as exc:
138
+ if show_progress:
139
+ sys.stderr.write("\n")
140
+ print(f"cvr: {exc}", file=sys.stderr)
141
+ return 1
142
+
143
+ if show_progress:
144
+ sys.stderr.write("\n")
145
+ print(path)
146
+ if args.open:
147
+ from .players import Player
148
+ try:
149
+ Player("System default", "default").launch(path)
150
+ except OSError as exc:
151
+ print(f"cvr: could not open the video: {exc}", file=sys.stderr)
152
+ return 0
153
+
154
+
155
+ def _cmd_themes(args) -> int:
156
+ themes = list_themes()
157
+ default = default_theme(themes)
158
+ wanted = args.filter.lower()
159
+ rows = [(t.label, t.path) for t in themes] + [(DEFAULT_PALETTE.name, "(built in)")]
160
+ for label, path in rows:
161
+ if wanted and wanted not in label.lower():
162
+ continue
163
+ mark = "*" if default and label == default.label else " "
164
+ print(f"{mark} {label}" + (f"\n {path}" if args.paths else ""))
165
+ if not themes:
166
+ print("No VS Code themes found; only the built-in palette is available.",
167
+ file=sys.stderr)
168
+ return 0
169
+
170
+
171
+ def _cmd_gui(_args) -> int:
172
+ try:
173
+ from .gui import main as gui_main
174
+ except ImportError as exc:
175
+ print(f"cvr: the app needs Tkinter, which this Python lacks ({exc}).\n"
176
+ "On Debian or Ubuntu: sudo apt install python3-tk", file=sys.stderr)
177
+ return 1
178
+ gui_main()
179
+ return 0
180
+
181
+
182
+ def main(argv: Optional[List[str]] = None) -> int:
183
+ args = build_parser().parse_args(argv)
184
+ logging.basicConfig(
185
+ format="cvr: %(message)s",
186
+ level=logging.INFO if getattr(args, "verbose", False) else logging.WARNING)
187
+ command = args.command or "gui"
188
+ return {"render": _cmd_render, "themes": _cmd_themes,
189
+ "gui": _cmd_gui}[command](args)
cvr/encoders.py ADDED
@@ -0,0 +1,130 @@
1
+ """Video writers. Frames go in as RGB uint8 arrays of shape (height, width, 3)."""
2
+
3
+ import logging
4
+ import shutil
5
+ import subprocess
6
+ from typing import Optional, Tuple
7
+
8
+ import numpy as np
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ def ffmpeg_exe() -> Optional[str]:
14
+ """ffmpeg on PATH, else the copy bundled with the imageio-ffmpeg package."""
15
+ exe = shutil.which("ffmpeg")
16
+ if exe:
17
+ return exe
18
+ try:
19
+ import imageio_ffmpeg
20
+ return imageio_ffmpeg.get_ffmpeg_exe()
21
+ except Exception:
22
+ return None
23
+
24
+
25
+ class OpenCVWriter:
26
+ """Fallback encoder. Goes through YUV, so dark colours shift slightly."""
27
+
28
+ def __init__(self, writer, cv2_module):
29
+ self._writer = writer
30
+ self._cv2 = cv2_module
31
+ self.exact_colour = False
32
+
33
+ @classmethod
34
+ def open(cls, path: str, fps: int, size: Tuple[int, int]):
35
+ try:
36
+ import cv2
37
+ except ImportError:
38
+ return None
39
+ fourcc_of = getattr(cv2, "VideoWriter_fourcc", None) or cv2.VideoWriter.fourcc
40
+ for fourcc_str in ("avc1", "mp4v"):
41
+ w = cv2.VideoWriter(path, fourcc_of(*fourcc_str), float(fps), size)
42
+ if w.isOpened():
43
+ return cls(w, cv2)
44
+ w.release()
45
+ return None
46
+
47
+ def write(self, frame: np.ndarray):
48
+ self._writer.write(self._cv2.cvtColor(frame, self._cv2.COLOR_RGB2BGR))
49
+
50
+ def release(self):
51
+ self._writer.release()
52
+
53
+
54
+ class FFmpegWriter:
55
+ """Encoder that preserves colours exactly.
56
+
57
+ OpenCV converts every frame to limited range YUV, which moves the
58
+ theme colours. Encoding through ffmpeg keeps them intact, so what the
59
+ palette says is what the player shows.
60
+ """
61
+
62
+ def __init__(self, proc, exact_colour: bool):
63
+ self._proc = proc
64
+ self.exact_colour = exact_colour
65
+
66
+ @classmethod
67
+ def open(cls, path: str, fps: int, size: Tuple[int, int],
68
+ exact_colour: bool = False):
69
+ exe = ffmpeg_exe()
70
+ if not exe:
71
+ return None
72
+
73
+ if exact_colour:
74
+ # RGB in, RGB out: no colour conversion, no chroma subsampling.
75
+ # Exact, but this is H.264 High 4:4:4 Predictive, which Windows
76
+ # Media Player refuses to open. VLC and mpv play it.
77
+ codec = ["-c:v", "libx264rgb", "-crf", "12", "-pix_fmt", "bgr0"]
78
+ else:
79
+ # Ordinary High profile that plays everywhere. Full range keeps
80
+ # the luma untouched, where limited range would compress it into
81
+ # 16..235 and lose a level on the dark tones a code theme uses.
82
+ codec = ["-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p",
83
+ "-color_range", "pc", "-colorspace", "bt709",
84
+ "-color_primaries", "bt709", "-color_trc", "bt709",
85
+ "-vf", "scale=in_range=full:out_range=full"]
86
+
87
+ cmd = [
88
+ exe, "-y", "-loglevel", "error",
89
+ "-f", "rawvideo", "-pix_fmt", "rgb24",
90
+ "-s", f"{size[0]}x{size[1]}", "-r", str(fps),
91
+ "-i", "-",
92
+ "-an", *codec,
93
+ "-preset", "medium",
94
+ "-movflags", "+faststart",
95
+ path,
96
+ ]
97
+ try:
98
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
99
+ stdout=subprocess.DEVNULL,
100
+ stderr=subprocess.PIPE)
101
+ except OSError:
102
+ return None
103
+ return cls(proc, exact_colour)
104
+
105
+ def write(self, frame: np.ndarray):
106
+ self._proc.stdin.write(frame.tobytes())
107
+
108
+ def release(self):
109
+ try:
110
+ if self._proc.stdin:
111
+ self._proc.stdin.close()
112
+ except OSError:
113
+ pass
114
+ try:
115
+ self._proc.wait(timeout=60)
116
+ except subprocess.TimeoutExpired:
117
+ self._proc.kill()
118
+
119
+
120
+ def open_writer(path: str, fps: int, size: Tuple[int, int],
121
+ exact_colour: bool = True):
122
+ """ffmpeg if there is one, else OpenCV, else None."""
123
+ writer = FFmpegWriter.open(path, fps, size, exact_colour=exact_colour)
124
+ if writer is None:
125
+ writer = OpenCVWriter.open(path, fps, size)
126
+ if writer is not None:
127
+ log.warning("ffmpeg not found; using the OpenCV writer, which "
128
+ "shifts dark colours slightly. pip install imageio-ffmpeg "
129
+ "to fix that.")
130
+ return writer
cvr/fonts.py ADDED
@@ -0,0 +1,121 @@
1
+ """Finding a monospace font, and measuring text with it."""
2
+
3
+ import functools
4
+ import logging
5
+ import os
6
+ import platform
7
+ from typing import List, Optional
8
+
9
+ from PIL import ImageFont
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def font_candidates() -> List[str]:
15
+ """Monospace fonts worth trying on this OS, best first."""
16
+ sys_name = platform.system()
17
+ if sys_name == "Windows":
18
+ return [
19
+ r"C:\Windows\Fonts\CascadiaCode.ttf",
20
+ r"C:\Windows\Fonts\CascadiaCode-Regular.ttf",
21
+ r"C:\Windows\Fonts\CascadiaMono.ttf",
22
+ r"C:\Windows\Fonts\consola.ttf",
23
+ r"C:\Windows\Fonts\cour.ttf",
24
+ r"C:\Windows\Fonts\lucon.ttf",
25
+ ]
26
+ if sys_name == "Darwin":
27
+ return [
28
+ "/Library/Fonts/Courier New.ttf",
29
+ "/System/Library/Fonts/Monaco.ttf",
30
+ "/System/Library/Fonts/Menlo.ttc",
31
+ "/System/Library/Fonts/SFMono-Regular.otf",
32
+ ]
33
+ return [
34
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
35
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
36
+ "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf",
37
+ ]
38
+
39
+
40
+ def _open(path: str, size: int) -> ImageFont.FreeTypeFont:
41
+ kwargs = {"index": 0} if path.lower().endswith(".ttc") else {}
42
+ return ImageFont.truetype(path, size, **kwargs)
43
+
44
+
45
+ @functools.lru_cache(maxsize=16)
46
+ def _font_file(font_path: Optional[str]) -> Optional[str]:
47
+ """First font file that actually opens, `font_path` first.
48
+
49
+ Cached because the last resort walks the whole Windows font folder.
50
+ """
51
+ candidates = ([font_path] if font_path else []) + font_candidates()
52
+ for path in candidates:
53
+ if not path or not os.path.exists(path):
54
+ continue
55
+ try:
56
+ _open(path, 12)
57
+ return path
58
+ except (IOError, OSError):
59
+ continue
60
+
61
+ fonts_dir = r"C:\Windows\Fonts"
62
+ if platform.system() == "Windows" and os.path.exists(fonts_dir):
63
+ for fname in os.listdir(fonts_dir):
64
+ if fname.lower().endswith((".ttf", ".ttc")):
65
+ path = os.path.join(fonts_dir, fname)
66
+ try:
67
+ _open(path, 12)
68
+ return path
69
+ except (IOError, OSError):
70
+ continue
71
+ return None
72
+
73
+
74
+ def load_font(font_path: Optional[str], size: int) -> ImageFont.FreeTypeFont:
75
+ """A TrueType font at `size` px: `font_path` if it opens, else a system mono.
76
+
77
+ Each call returns a fresh font object, so the GUI thread and a render
78
+ thread never share one FreeType face.
79
+ """
80
+ if font_path and not os.path.exists(font_path):
81
+ log.warning("Font %s not found; using a system font.", font_path)
82
+ path = _font_file(font_path)
83
+ if path:
84
+ try:
85
+ return _open(path, size)
86
+ except (IOError, OSError):
87
+ pass
88
+ log.warning("No TrueType font found; falling back to bitmap default.")
89
+ return ImageFont.load_default()
90
+
91
+
92
+ _TW_CACHE: dict = {}
93
+
94
+
95
+ def text_width(font, text: str) -> float:
96
+ """Advance width of `text` in pixels.
97
+
98
+ `getbbox` returns *ink* extents, which drift from the real pen advance and
99
+ would slowly push the cursor away from the text it follows; `getlength`
100
+ is the advance, so widths stay additive across tokens.
101
+ """
102
+ if not text:
103
+ return 0.0
104
+ key = (font, text)
105
+ width = _TW_CACHE.get(key)
106
+ if width is None:
107
+ try:
108
+ width = float(font.getlength(text))
109
+ except (AttributeError, TypeError):
110
+ bb = font.getbbox(text)
111
+ width = float(bb[2] - bb[0])
112
+ if len(_TW_CACHE) > 100_000:
113
+ _TW_CACHE.clear()
114
+ _TW_CACHE[key] = width
115
+ return width
116
+
117
+
118
+ def text_height(font, text: str = "Mg") -> int:
119
+ """Ink height of `text` in pixels."""
120
+ bb = font.getbbox(text)
121
+ return bb[3] - bb[1]
cvr/gui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """The Tkinter desktop app. Start it with `cvr`, `cvr-gui` or `python -m cvr.gui`."""
2
+
3
+ from .app import TypewriterVideoApp, main
4
+
5
+ __all__ = ["TypewriterVideoApp", "main"]
cvr/gui/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m cvr.gui` opens the app."""
2
+
3
+ from .app import main
4
+
5
+ main()