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>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-17 10:37:43 -04:00
1 parent daf16505d7
commit 37aa4ed5be
5 files changed
+137 -20

No files matched your search

+3 -2
View File
@@ -147,7 +147,8 @@ at before anything is committed.
The two land in different repositories:
- The screenshots (`docs/screenshots/`) ride in the release commit, so the tag carries
them and the `website` job builds meshterm.net from them.
them and the `website` job builds meshterm.net from them. So does `README.md`, whose
video line `scripts/video.py --readme` points at the new upload.
- The video, its poster, and its play card go to the site checkout beside this one
(`../meshterm.net/src/`) and get a commit there ("The demo video of MeshTerm X.Y.Z"). The
`website` job builds from the site's `main`, so that commit is **pushed before the tag**
@@ -162,7 +163,7 @@ Stage by name, not with `-a`: other sessions work in the same tree, and `-a` wou
their unfinished edits into the release.
```
git add CHANGELOG.md meshterm/__init__.py docs/cli/README.md docs/screenshots
git add CHANGELOG.md meshterm/__init__.py docs/cli/README.md docs/screenshots README.md
git commit -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
```
+29 -15
View File
@@ -1,6 +1,6 @@
---
name: website-media
description: Make the website's demo video and screenshots again from the demo mesh — the terminal video on meshterm.net (scripts/video.py) and the twelve screenshots of the README and the website (scripts/screenshots.py) — check them, and commit them in both repositories. Use when JP asks to make, redo, refresh, or update the video, the demo recording, the screenshots, the website's images, or the website after a visible change; the release skill also runs it after each version bump.
description: Make the website's demo video and screenshots again from the demo mesh — the terminal video on meshterm.net and in MeshTerm's README (scripts/video.py) and the twelve screenshots of the README and the website (scripts/screenshots.py) — check them, upload the video for the README, and commit them in both repositories. Use when JP asks to make, redo, refresh, or update the video, the demo recording, the screenshots, the website's images, or the website after a visible change; the release skill also runs it after each version bump.
---
# Making the website's video and screenshots
@@ -14,6 +14,7 @@ made before a bump is already stale.
|---|---|---|---|
| 12 screenshots + 2 splash wordmarks | `scripts/screenshots.py` | MeshTerm `docs/screenshots/` | ~1 min |
| The demo video, its poster, its play card | `scripts/video.py` | meshterm.net `src/demo.mp4`, `src/demo-poster.png`, `src/demo-play.png` | ~4 min |
| The README's copy of the video | `scripts/video.py --readme` | a GitHub attachment, and its URL in MeshTerm `README.md` | ~10 s |
**The video is terminal-only, on purpose** (JP, 2026-10-17: "it's marketing after all").
Don't offer handheld videos again.
@@ -86,6 +87,28 @@ A green run is not a correct picture. Check both:
Also open `src/demo-poster.png`: it must show the paths dialog with the **second** path
selected.
## 2b. Upload it for the README — only once the video is right
MeshTerm's README plays its own copy of the video: GitHub plays a video in a README only
when the file is one of its own attachments. Once the video passed step 2:
```
.venv/Scripts/python scripts/video.py --readme
git diff README.md
```
It uploads `../meshterm.net/src/demo.mp4` through GitHub's undocumented attachment endpoint
(`uploads.github.com/user-attachments/assets`, with the `gh` token: no issue, no browser),
and replaces the one `https://github.com/user-attachments/assets/…` line of `README.md`.
The diff must be that one line. Each upload is public and permanent (GitHub cannot delete
an attachment, and the old ones stay, unlinked), so **never run it on a video you haven't
checked**, and never in a loop.
If GitHub refuses it (the endpoint is undocumented: it can change or go away), the README
keeps the previous video, which is wrong but not broken. Tell JP. The fallback is the web
page: he drops `src/demo.mp4` into a comment box on the repository and gives you the URL to
put on that line.
## 3. When it fails
`video.py` prints `the recording failed (1)` after a traceback. Read the traceback's last
@@ -112,7 +135,7 @@ half a set.
MeshTerm, by name (other sessions share the tree):
```
git add docs/screenshots
git add docs/screenshots README.md
```
The site, the three source files only:
@@ -126,7 +149,8 @@ Then one of the two modes.
### Manual mode — JP asked for the website now
Commit the screenshots on their own in MeshTerm ("The screenshots show …", what changed).
Commit the screenshots and the README's new video URL in MeshTerm ("The screenshots
show …", what changed).
Then rebuild the site so its `docs/` serves the new files:
```
@@ -149,20 +173,10 @@ working tree's, not a release's.
Don't build the site locally: the release workflow's `website` job builds it from the tag
and from the site's `main`. Commit the site's three source files as above, and the
screenshots ride in the release commit (the release skill stages `docs/screenshots`).
screenshots and the README ride in the release commit (the release skill stages
`docs/screenshots` and `README.md`).
**The site's commit must be pushed before the tag**, or the job builds with last
release's video. The release skill's push step does it, in the same confirmed push. After
the job has run, `git -C ../meshterm.net pull --ff-only` picks up its "MeshTerm X.Y.Z"
commit.
## The one step no script can do
MeshTerm's README plays its own copy of the video: a `user-attachments` upload, because
GitHub plays nothing else in a README. GitHub has no API for that upload. After a new
video, say in one line that the README still plays the previous upload; never try to
upload it yourself. If JP drops `../meshterm.net/src/demo.mp4` into a GitHub comment box
and gives you the new URL, swap it in `README.md` (the bare `user-attachments` line near
the top). Its caption on the next line must then say the video comes from the demo mesh:
the one written for the first upload says "made on a real mesh", which is true only of
that upload.
+2 -2
View File
@@ -27,9 +27,9 @@ run MeshTerm, the more the history of your mesh is worth.
> change something, first say hello on [Discord](https://discord.gg/AZwe5Uvb3S).
> [Read more about how the project is run](#how-this-project-is-run).
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
https://github.com/user-attachments/assets/a9ce1f53-36ed-475a-9629-f82d4267a6b5
<p align="center"><sub>A video of less than two minutes, made on a real mesh.</sub></p>
<p align="center"><sub>A video of less than two minutes, in the demo mesh over Montréal (<code>meshterm --demo</code>).</sub></p>
<p align="center">
<img src="docs/screenshots/terminal/menu.png" alt="The main menu: the tools in groups (Message, Watch, Explore), and a header that shows one unread message and three alerts" width="49%">
+7
View File
@@ -88,6 +88,7 @@ the map, Repeater admin, and the live feed. A camera copies the screen 30 times
python scripts/video.py # into ../meshterm.net/src/
python scripts/video.py --out some/folder
python scripts/video.py --recording tour.pickle # keep the recording
python scripts/video.py --readme # the README's copy, after a check
```
It needs ffmpeg with libx264 on the `PATH`, and the needs of `screenshots.py`. A run takes
@@ -102,6 +103,12 @@ play mark and the length of the video). The camera stops while the tour waits fo
something slow: the tiles of the map, and most of the read of the repeater's settings.
Thus the video jumps over the wait.
With `--readme`, the script makes no video. It uploads `demo.mp4` to GitHub as an
attachment of the repository, because GitHub plays a video in a README only when it is one
of its own attachments. Then it puts the new URL on the attachment line of `README.md`. It
uses an endpoint that GitHub does not document, with the token of the GitHub CLI. Each
upload stays on GitHub, so use it only on a video that you examined.
Each run gives the same video to the eye, but not the same file, because the camera copies
the screen on the real clock. Each release makes the video again, after the screenshots,
and pushes it to the website repository before the tag. After a change to the screens
+96 -1
View File
@@ -2,6 +2,7 @@
"""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:
@@ -37,22 +38,31 @@ keys at the same pace, and the demo plays the same messages and packets at the s
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``.
``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
@@ -74,6 +84,13 @@ 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."""
@@ -84,6 +101,11 @@ def main(argv: list[str]) -> int:
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:
@@ -91,6 +113,9 @@ def main(argv: list[str]) -> int:
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:
@@ -667,5 +692,75 @@ def _card(image, seconds: float): # noqa: ANN001, ANN202 - an Image, an Image
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:]))