Files
MeshTerm/scripts/video.py
T
Jean-Pierre MartineauandClaude Opus 5.5 37aa4ed5be The README's video is uploaded by a script
scripts/video.py --readme uploads the demo video to GitHub as an attachment of the repository and puts the new URL on the README's video line. It uses the endpoint that the web page uses when a file is dropped into a comment, with the gh token, so no issue and no browser are necessary. The README now plays the demo video, and its caption says so.

The website-media skill runs it once the video is checked, and the release commit stages README.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 10:37:43 -04:00

767 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Make the demo video of the website from the demo mesh.
python scripts/video.py [--out DIR] [--recording FILE]
python scripts/video.py --readme [--out DIR]
The script runs MeshTerm with ``--demo`` in an 80×24 terminal, as the terminal set of
``scripts/screenshots.py`` does, and types the keys of a tour of less than two minutes:
1. The main menu.
2. The chat on Public, and the two paths of a message from Beloeil, each selected in turn.
3. A message that arrives on Public. Then a question that mentions Dave, typed so that the
mention becomes a chip, and his answer, which mentions our node.
4. A trace to Brossard, across the river, and back.
5. The map, from the city down to the streets and the buildings around our node.
6. Repeater admin on Mont-Royal: a read of its settings, then an advert.
7. The live feed, and one packet from it.
A recorder (the camera) copies the screen at each video frame, 30 times each second, while
it rolls. The tour stops the camera while it waits for something slow (the full read of the
settings, the tiles of the map), so the video jumps over the wait. After the tour, the
script paints each different copy in parallel, as the terminal set of the screenshots
paints a screen, and ffmpeg encodes the video.
The script writes three files into ``DIR``. By default, ``DIR`` is ``src/`` of the website
checkout beside this one (``../meshterm.net/src``), where the build of the website reads
them:
* ``demo.mp4``: the video, H.264 High in ``yuv420p``, with its ``moov`` box at the start.
Thus a browser can start to play it before all of it arrives.
* ``demo-poster.png``: the video frame that the website shows before the video plays (the
paths of the message).
* ``demo-play.png``: the same image with a play mark and the length of the video. A page
that cannot play the video links to it with this image.
Each run gives the same video to the eye, but not the same file. The tour types the same
keys at the same pace, and the demo plays the same messages and packets at the same times
after its start. But the camera copies the screen on the real clock, so a change can come
one video frame earlier or later than in another run.
With ``--readme``, the script makes no video. It uploads ``DIR/demo.mp4`` to GitHub for
the README of MeshTerm, and puts the URL of the upload in ``README.md`` (refer to
:func:`_publish_readme`).
What it needs, beside the needs of ``scripts/screenshots.py``: ffmpeg, with libx264, on the
``PATH``. With ``--readme``, the GitHub CLI (``gh``), logged in to an account that can
write to MeshTerm.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import pickle
import random
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
import urllib.request
from collections.abc import Callable, Iterator
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import screenshots
from screenshots import ROOT, Capture
#: The video frames in each second.
FPS = 30
#: The default folder of the three files: ``src/`` of the website checkout.
SITE_SRC = ROOT.parent / "meshterm.net" / "src"
#: The colours of the play mark and of the length on the card: the ``brand`` and
#: ``title.brand`` teals of the theme.
BRAND = (0x5E, 0xEA, 0xD4)
BRAND_LIGHT = (0x99, 0xF6, 0xE4)
#: One copy of the screen: the rows of ``(character, style)`` cells of the VT parser.
Grid = tuple[tuple[tuple[str, object], ...], ...]
#: The repository whose README plays the video.
REPOSITORY = "jpmartineau/MeshTerm"
#: The line of the README that GitHub shows as a video player: the URL of an attachment,
#: alone on its line.
ATTACHMENT_LINE = re.compile(r"^https://github\.com/user-attachments/assets/[0-9a-f-]+$", re.M)
def main(argv: list[str]) -> int:
"""Record the tour in a child process, then paint and encode the video."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--out", type=Path, default=SITE_SRC)
parser.add_argument(
"--recording",
type=Path,
help="keep the recording in this file. If the file exists, use it and do not run MeshTerm",
)
parser.add_argument(
"--readme",
action="store_true",
help="make no video: upload DIR/demo.mp4 for the README, and put its URL in README.md",
)
parser.add_argument("--child", type=Path, help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if args.child:
_watch_live_start()
return screenshots.run_demo(
"terminal", lambda capture, keys: _record(capture, keys, args.child), background=True
)
if args.readme:
print(_publish_readme(args.out / "demo.mp4"), flush=True)
return 0
if not args.out.is_dir():
raise SystemExit(f"video.py: {args.out} is not a folder. Pass --out.")
if shutil.which("ffmpeg") is None:
raise SystemExit("video.py: ffmpeg is not on the PATH")
with tempfile.TemporaryDirectory(prefix="meshterm-video-") as work:
recording = args.recording or Path(work) / "recording.pickle"
if not recording.exists():
print("recording the tour...", flush=True)
home = Path(tempfile.gettempdir()) / "meshterm-video"
done = subprocess.run(
[sys.executable, __file__, "--child", str(recording)],
env=screenshots.child_env(home),
check=False,
)
if done.returncode or not recording.exists():
print(f"the recording failed ({done.returncode})", flush=True)
return 1
tape = pickle.loads(recording.read_bytes())
grids, frames = tape["grids"], tape["frames"]
seconds = len(frames) / FPS
print(f"painting {len(grids)} screens for {seconds:.1f} s of video...", flush=True)
paths = _paint(grids, Path(work))
video = args.out / "demo.mp4"
print("encoding...", flush=True)
_encode(frames, paths, Path(work), video)
poster = grids[frames[min(tape["marks"]["poster"], len(frames) - 1)]]
written = [video, *_stills(poster, seconds, args.out)]
for path in written:
print(f"{path} ({path.stat().st_size // 1024} KB)", flush=True)
return 0
# --------------------------------------------------------------------------- the child
#: The time on the monotonic clock at which the demo device started its live traffic,
#: or ``None`` before that. The times of the lines of the demo script count from it.
_live_start: float | None = None
def _watch_live_start() -> None:
"""Keep the time at which the demo device starts its live traffic.
The device counts the time of each live line from the moment that the app subscribes
to its events, on the clock of the event loop (the monotonic clock). The tour uses this
time to start the camera a moment before a line arrives.
"""
from meshterm.demo.device import DemoDevice
subscribe = DemoDevice.subscribe_events
async def subscribe_events(self, on_event): # noqa: ANN001, ANN202 - the same as the original
global _live_start
if _live_start is None:
_live_start = time.monotonic()
return await subscribe(self, on_event)
DemoDevice.subscribe_events = subscribe_events
class Recorder:
"""The camera: a copy of the screen at each video frame, while it rolls.
Each different copy is kept one time (:attr:`grids`), and each video frame is the index
of its copy (:attr:`frames`). When the camera is late, it gives the same copy to each
video frame that it missed. Thus the video keeps the time of the real clock.
"""
def __init__(self, capture: Capture) -> None:
"""Start the thread of the camera. It does not roll yet."""
self.capture = capture
self.grids: list[Grid] = []
self.frames: list[int] = []
self.marks: dict[str, int] = {}
self._index: dict[Grid, int] = {}
self._last: tuple[Grid, int] | None = None
self._rolling = False
self._stopped = False
self._thread = threading.Thread(target=self._run, name="camera", daemon=True)
self._thread.start()
def roll(self) -> None:
"""Start to record."""
self._rolling = True
def cut(self) -> None:
"""Stop to record. The video continues from here at the next roll."""
self._rolling = False
def mark(self, name: str) -> None:
"""Give the next video frame a name."""
self.marks[name] = len(self.frames)
def stop(self) -> None:
"""Stop the camera for good."""
self._stopped = True
self._thread.join()
def _run(self) -> None:
period = 1.0 / FPS
due: float | None = None
while not self._stopped:
if not self._rolling:
due = None
time.sleep(period / 2)
continue
now = time.monotonic()
if due is None:
due = now
if now < due:
time.sleep(due - now)
continue
count = int((now - due) / period) + 1
self.frames.extend([self._copy()] * count)
due += count * period
def _copy(self) -> int:
"""The index of the copy of the screen as it is now."""
with self.capture.lock:
grid = tuple(tuple(row) for row in self.capture.terminal.screen)
# The cells that did not change are the same objects, so this test is fast.
if self._last is not None and grid == self._last[0]:
return self._last[1]
index = self._index.get(grid)
if index is None:
index = self._index[grid] = len(self.grids)
self.grids.append(grid)
self._last = (grid, index)
return index
class Presenter:
"""The person at the keyboard: key presses at a human pace, and the camera."""
def __init__(self, capture: Capture, type_text: Callable[[str], None], camera: Recorder):
"""Keep the capture, the input of MeshTerm, and the camera."""
self.capture = capture
self.camera = camera
self._type_text = type_text
# A fixed seed: the pace of the typing is the same at each run.
self._rng = random.Random("meshterm-video")
def key(self, name: str = "", text: str = "", *, ctrl: bool = False, pause: float = 0.35):
"""Press one keyboard key, then wait for ``pause`` seconds.
After Esc, the wait is at least 0.7 s. prompt_toolkit waits a moment to see if an
Esc starts a longer sequence, and a key that comes in that time joins the Esc.
"""
from meshterm.emulator.keys import Key, encode
self._type_text(encode(Key(name=name, text=text, ctrl=ctrl)))
time.sleep(max(pause, 0.7) if name == "escape" else pause)
def type(self, text: str) -> None:
"""Type ``text``, one character at a time, at the pace of a person."""
for char in text:
self._type_text(char)
time.sleep(self._rng.uniform(0.06, 0.14))
def hold(self, seconds: float) -> None:
"""Wait, while the camera records what is on the screen."""
time.sleep(seconds)
def wait_for(self, text: str, timeout: float = 30.0) -> None:
"""Wait until ``text`` is on the screen."""
self.capture.wait_for(text, timeout)
def wait_until(self, moment: float) -> None:
"""Wait until ``moment`` on the monotonic clock."""
time.sleep(max(0.0, moment - time.monotonic()))
def shows(self, text: str) -> bool:
"""Whether ``text`` is on the screen now."""
with self.capture.lock:
return text in self.capture.terminal.text()
def gone(self, text: str, timeout: float = 120.0) -> None:
"""Wait until ``text`` is not on the screen.
Raises:
TimeoutError: The text stayed.
"""
start = time.monotonic()
while time.monotonic() - start < timeout:
if not self.shows(text):
return
time.sleep(0.1)
raise TimeoutError(f"{text!r} stayed on the screen")
def highlight(self, label: str, key: str, tries: int = 12) -> None:
"""Press ``key`` until the highlight is on the row of ``label``.
Raises:
TimeoutError: The highlight did not come to the row.
"""
for _ in range(tries):
if self._highlighted(label):
return
self.key(key, pause=0.3)
self.capture.settle(quiet=0.3, timeout=5.0)
if not self._highlighted(label):
raise TimeoutError(f"the highlight did not come to {label!r}")
def _highlighted(self, label: str) -> bool:
with self.capture.lock:
lines = self.capture.terminal.text().splitlines()
return any("❯" in line and label in line for line in lines)
@contextlib.contextmanager
def off_camera(self) -> Iterator[None]:
"""Stop the camera for the body of a ``with`` block, then roll it again."""
self.camera.cut()
try:
yield
finally:
self.camera.roll()
def _record(capture: Capture, type_text: Callable[[str], None], path: Path) -> None:
"""Record the tour, and write the recording to ``path``."""
camera = Recorder(capture)
_tour(Presenter(capture, type_text, camera))
camera.cut()
camera.stop()
tape = {"grids": camera.grids, "frames": camera.frames, "marks": camera.marks}
path.write_bytes(pickle.dumps(tape, protocol=pickle.HIGHEST_PROTOCOL))
print(f"recorded {len(camera.frames) / FPS:.1f} s, {len(camera.grids)} screens", flush=True)
def _tour(p: Presenter) -> None:
"""Type the keys of the tour, and roll the camera on what the video shows."""
from meshterm.core import preferences
from meshterm.demo import script
capture = p.capture
capture.wait_for("Chat", timeout=60)
capture.settle(quiet=0.5)
print("the main menu", flush=True)
p.camera.roll()
p.hold(3.0)
print("the chat, and the paths of a message", flush=True)
p.type("chat")
p.hold(0.5)
p.key("enter")
p.wait_for("pick a conversation")
p.hold(1.2)
p.type("pub")
p.hold(0.4)
p.key("enter")
p.wait_for(screenshots.PATHS_MESSAGE[2])
p.hold(2.0)
p.key("up", pause=0.6)
p.key("up", pause=1.2)
# A key press can be lost while the transcript draws. The camera does not show the
# tries: it shows the dialog of the right message as it opens.
with p.off_camera():
_open_paths(p)
p.hold(2.0)
# The two paths in turn, two times, so that the graph shows which line is which. The
# video stops on the second path: the first one is often a straight line.
p.key("down", pause=1.6)
p.key("up", pause=1.6)
p.key("down", pause=0.6)
p.camera.mark("poster")
p.hold(2.6)
p.key("escape")
p.key("escape") # the first Esc closes the dialog, the second unpicks the message
print("a message arrives, a question, and its answer", flush=True)
offset, _, line = next(item for item in script.LIVE_CHANNEL if item[1] == "Public")
with p.off_camera():
p.wait_until(_start() + offset - 0.6)
p.wait_for(line.text, timeout=20)
p.hold(1.5)
# The mention is typed in full: the ] makes it a chip, as the video shows.
p.type("Hey, I wonder if @[Dave-Lachine] is coming Saturday?")
p.hold(0.8)
p.key("enter", pause=0.0)
p.wait_for("0/130") # the compose line is empty: the message went
# Dave answers a few seconds later (``meshterm.demo.script.REPLIES``). His answer
# mentions our node, so it has our star.
p.wait_for("be there at 2", timeout=30)
p.hold(3.5)
print("a trace", flush=True)
_menu(p, "trace t")
p.wait_for("pick a target")
p.hold(1.2)
p.type("brossard")
p.hold(0.4)
p.key("enter")
p.wait_for("Trace — Brossard")
p.hold(2.0)
p.key("enter", pause=0.0)
p.wait_for("Per-hop medians", timeout=30)
p.hold(1.0)
# The history stores walks of one byte for each hop. Thus this walk of two bytes is new,
# and its records go to the trophy case.
if p.shows("new record"):
p.hold(3.0)
p.key("enter", pause=0.0) # Close
p.hold(1.5)
p.key("pagedown", pause=0.0)
p.hold(3.0)
print("the map", flush=True)
_menu(p, "map", enter=False)
with p.off_camera():
p.key("enter", pause=0.0)
capture.settle(quiet=3.0, timeout=120.0)
p.hold(3.0)
for _ in range(3):
p.key("right", pause=0.4)
capture.settle(quiet=0.8, timeout=30.0)
p.hold(1.2)
p.key(text="l", ctrl=True, pause=0.0) # back to our node
capture.settle(quiet=0.8, timeout=30.0)
p.hold(1.5)
# Zoom in to the streets around our node, one step at a time, down to zoom 16: the
# names of the streets and the buildings.
for _ in range(6):
p.key("pageup", pause=0.0)
capture.settle(quiet=0.6, timeout=30.0)
p.hold(0.9)
p.hold(3.0)
print("a repeater administered over the air", flush=True)
_menu(p, "repeater a")
p.wait_for("node to manage")
p.hold(1.2)
p.type("mont-r")
p.hold(0.4)
p.key("enter")
p.wait_for("Repeater admin — Mont-Royal")
p.hold(1.5)
p.key("end", pause=0.6)
p.highlight("Read settings", "up")
p.hold(0.8)
p.key("enter", pause=0.0)
p.wait_for("Reading — Mont-Royal")
p.hold(2.0)
# The read is one round trip for each setting, with the transmit cooldown between two
# reads: more than three minutes in all. The camera shows the first reads at the real
# pace. Then, off camera, a short cooldown makes the run shorter, and the video
# does not show it.
with p.off_camera():
cooldown = preferences.current().trace_cooldown_s
preferences.current().set("trace_cooldown_s", 0.2)
try:
p.gone("Reading — Mont-Royal")
finally:
preferences.current().set("trace_cooldown_s", cooldown)
capture.settle(quiet=0.5)
if p.shows("No reply from"):
p.key("enter") # the list of the settings that did not answer
capture.settle(quiet=0.5)
p.key("home", pause=0.0)
capture.settle(quiet=0.5)
p.hold(3.0)
p.key("end", pause=0.6)
p.highlight("Send advert", "up")
p.hold(0.8)
p.key("enter", pause=0.0)
p.wait_for("announce itself")
p.hold(1.2)
p.key("enter", pause=0.0)
p.wait_for("Advert sent", timeout=30)
p.hold(1.5)
p.key("enter", pause=0.8)
print("the live feed, and one packet", flush=True)
_menu(p, "live f")
p.wait_for("Live feed")
p.hold(3.0)
for _ in range(3):
p.key("down", pause=0.5)
p.hold(0.5)
p.key("enter", pause=0.0)
p.hold(3.5)
p.key("escape")
p.hold(0.5)
# The end: the main menu, as at the start, with no filter.
_back_to_menu(p)
p.hold(2.0)
def _start() -> float:
"""The start of the live traffic of the demo device (refer to :func:`_watch_live_start`)."""
if _live_start is None:
raise RuntimeError("the demo device did not start its live traffic")
return _live_start
def _back_to_menu(p: Presenter) -> None:
"""Go back to the main menu (^W), and clear the filter that it can still hold.
The Esc waits until the menu is on the screen. If it comes while ^W still unwinds the
screens, a screen that closes gets it, and the filter stays.
"""
p.key(text="w", ctrl=True, pause=0.0)
p.wait_for("What would you like to do?")
p.hold(0.6)
p.key("escape")
def _menu(p: Presenter, name: str, *, enter: bool = True) -> None:
"""Go back to the main menu, and type ``name`` to filter it (then Enter)."""
_back_to_menu(p)
p.type(name)
p.hold(0.4)
if enter:
p.key("enter")
def _open_paths(p: Presenter) -> None:
"""Open the paths dialog of the message of :data:`screenshots.PATHS_MESSAGE`.
The message is selected before the call. If the dialog shows another message, close it
and select the next older message.
Raises:
TimeoutError: No dialog showed the message.
"""
snippet = screenshots.PATHS_MESSAGE[2]
for _ in range(6):
p.key(text="p", ctrl=True, pause=0.0)
p.capture.settle(quiet=1.0)
quote = p.capture.quote()
if snippet in quote:
return
if quote:
p.key("escape")
p.capture.settle(quiet=0.6)
p.key("up")
p.capture.settle(quiet=0.6)
raise TimeoutError(f"no paths dialog showed {snippet!r}")
# --------------------------------------------------------------------------- the video
#: The painter of each worker process (refer to :func:`_paint`).
_painter = None
def _start_painter() -> None:
global _painter
_painter = screenshots.TerminalPainter()
def _paint_one(job: tuple[int, Grid, str]) -> str:
index, grid, folder = job
path = Path(folder) / f"{index:05d}.png"
_painter.render(grid).save(path, compress_level=1)
return str(path)
def _paint(grids: list[Grid], folder: Path) -> list[Path]:
"""Paint each copy of the screen into a PNG file in ``folder``, in parallel.
Returns:
The path of the image of each copy, in the order of ``grids``.
"""
jobs = ((index, grid, str(folder)) for index, grid in enumerate(grids))
with ProcessPoolExecutor(initializer=_start_painter) as pool:
return [Path(path) for path in pool.map(_paint_one, jobs, chunksize=8)]
def _encode(frames: list[int], paths: list[Path], work: Path, target: Path) -> None:
"""Encode the video frames into ``target`` with ffmpeg.
The video frames go to ffmpeg as a list of images, each with the time that it stays on
the screen (the concat demuxer). Thus each different image goes to ffmpeg one time, and
the ``fps`` filter makes the video frames again. The RGB to YUV conversion uses the
BT.709 matrix, which is the matrix that a browser uses for an HD video. The default
of ffmpeg is the BT.601 matrix, which would change the colours a little.
The settings come from a comparison on one recording. A keyframe costs approximately
120 KB (300 KB on the map), and the keyframes were half of the file. Thus there is one
keyframe each 30 s, and one at each change of scene. CRF 22 looks the same as CRF 20,
and the file is 3.4 MB instead of 3.8 MB.
"""
runs: list[list[int]] = []
for index in frames:
if runs and runs[-1][0] == index:
runs[-1][1] += 1
else:
runs.append([index, 1])
lines = ["ffconcat version 1.0"]
for index, count in runs:
lines += [f"file '{paths[index].as_posix()}'", f"duration {count / FPS:.6f}"]
# The concat demuxer uses the duration of the last image only if the image comes again.
# The image that comes again adds its own time, and -t cuts it.
lines.append(f"file '{paths[runs[-1][0]].as_posix()}'")
listing = work / "frames.ffconcat"
listing.write_text("\n".join(lines) + "\n", encoding="utf-8")
colours = "color_primaries=bt709:color_trc=iec61966-2-1:colorspace=bt709:range=tv"
command = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(listing),
"-vf", f"fps={FPS},scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,"
f"setparams={colours}",
"-t", f"{len(frames) / FPS:.3f}",
"-c:v", "libx264", "-preset", "veryslow", "-tune", "animation", "-crf", "22",
"-profile:v", "high", "-g", str(FPS * 30),
"-movflags", "+faststart", "-an", str(target),
] # fmt: skip
subprocess.run(command, check=True)
def _stills(grid: Grid, seconds: float, folder: Path) -> list[Path]:
"""Write the poster and the card of the video into ``folder``.
Returns:
The paths of the two images.
"""
from PIL import Image
painter = screenshots.TerminalPainter()
image = painter.render(grid)
poster = folder / "demo-poster.png"
card = folder / "demo-play.png"
quantize = {"method": Image.Quantize.FASTOCTREE, "dither": Image.Dither.NONE}
image.quantize(256, **quantize).save(poster, optimize=True)
_card(image, seconds).quantize(256, **quantize).save(card, optimize=True)
return [poster, card]
def _card(image, seconds: float): # noqa: ANN001, ANN202 - an Image, an Image
"""The poster with a play mark and the length of the video.
The image is a little darker, so that the mark is clear on it. The length is on it,
because a person who decides to click must know that the video is short.
"""
from PIL import Image, ImageDraw, ImageEnhance, ImageFont
width, height = image.size
ss = 4 # draw at four times the size, then scale down, for smooth edges
layer = Image.new("RGBA", (width * ss, height * ss), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
cx, cy = width * ss / 2, height * ss / 2
radius = width * ss * 0.055
ring = radius * 0.07
draw.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(0, 0, 0, 150),
outline=(*BRAND, 255),
width=round(ring),
)
draw.polygon(
[
(cx - radius * 0.3, cy - radius * 0.42),
(cx + radius * 0.46, cy),
(cx - radius * 0.3, cy + radius * 0.42),
],
fill=(*BRAND, 255),
)
minutes, rest = divmod(round(seconds), 60)
label = f"{minutes}:{rest:02d}"
font = ImageFont.truetype(screenshots.TEXT_FONTS[0], round(width * ss * 0.024))
left, top, right, bottom = draw.textbbox((0, 0), label, font=font)
pad = font.size * 0.45
margin = width * ss * 0.012
box = [
width * ss - margin - (right - left) - 2 * pad,
height * ss - margin - (bottom - top) - 2 * pad,
width * ss - margin,
height * ss - margin,
]
draw.rounded_rectangle(box, radius=font.size * 0.3, fill=(12, 12, 12, 225))
draw.text((box[0] + pad - left, box[1] + pad - top), label, font=font, fill=(*BRAND_LIGHT, 255))
layer = layer.resize((width, height), Image.Resampling.LANCZOS)
base = ImageEnhance.Brightness(image.convert("RGB")).enhance(0.75).convert("RGBA")
return Image.alpha_composite(base, layer).convert("RGB")
# --------------------------------------------------------------------------- the README
def _publish_readme(video: Path) -> str:
"""Upload ``video`` to GitHub for the README, and put its URL in ``README.md``.
GitHub plays a video in a README only when the video is one of its own attachments.
In the web page, a file becomes an attachment when a person drops it into a comment.
This function does the same upload through an endpoint that GitHub does not document:
a POST of the file to ``uploads.github.com/user-attachments/assets``, with the token of
the GitHub CLI (https://island94.org/2026/08/programmatically-upload-attachments-to-github-issues-pull-requests-comments).
The attachment belongs to the repository, not to an issue or a comment. Thus no issue
is necessary, and nothing is left to delete. GitHub shows it to each visitor of the
README, also one who is not logged in. The previous attachment stays on GitHub, but
nothing links to it. GitHub has no way to delete an attachment.
The function replaces the one line of the README that holds the URL of an attachment
(:data:`ATTACHMENT_LINE`), and nothing else.
Returns:
The URL of the new attachment.
Raises:
SystemExit: The video, the GitHub CLI, or the line of the README is missing, or
GitHub refused the upload.
"""
if not video.is_file():
raise SystemExit(f"video.py: {video} does not exist. Make the video first.")
readme = ROOT / "README.md"
text = readme.read_text(encoding="utf-8")
if len(ATTACHMENT_LINE.findall(text)) != 1:
raise SystemExit("video.py: README.md must have exactly one line with an attachment URL")
if shutil.which("gh") is None:
raise SystemExit("video.py: the GitHub CLI (gh) is not on the PATH")
def gh(*args: str) -> str:
done = subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
if done.returncode:
raise SystemExit(f"video.py: gh {args[0]} failed: {done.stderr.strip()}")
return done.stdout.strip()
token = gh("auth", "token")
query = urllib.parse.urlencode(
{
"name": video.name,
"content_type": "video/mp4",
"repository_id": gh("api", f"repos/{REPOSITORY}", "--jq", ".id"),
}
)
request = urllib.request.Request(
f"https://uploads.github.com/user-attachments/assets?{query}",
data=video.read_bytes(),
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "video/mp4",
},
)
try:
with urllib.request.urlopen(request, timeout=300) as reply:
url = json.load(reply)["url"]
except (OSError, KeyError, ValueError) as exc:
raise SystemExit(f"video.py: GitHub refused the upload: {exc}") from None
if not ATTACHMENT_LINE.fullmatch(url):
raise SystemExit(f"video.py: GitHub returned an unexpected URL: {url}")
readme.write_bytes(ATTACHMENT_LINE.sub(url, text).encode("utf-8"))
return url
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))