Files
Jean-Pierre MartineauandClaude Opus 5.5 1239242bb4 An Instagram video for each page of MeshTerm
scripts/reel_tours.py holds 21 tours, one for each page of the menu except the four written pages: chat, channels, rooms, courier, contacts, the dashboard, the live feed, the watchtower, the time machine, the map, the mesh walk, both traces, the trophy case, device info and config, send advert, repeater admin, TX optimize, preferences, and diagnostics. Each tour opens its page off camera, so the video starts on the feature, and has its own subtitles and post caption.

scripts/reels.py now takes several names or "all", keeps each recording in a folder with --recordings, plays the background traffic for the pages that show it, and gives the recording process the size of its terminal, so Diagnostics shows 72x38.

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

553 lines
23 KiB
Python
Raw Permalink 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 videos of MeshTerm for Instagram from the demo mesh.
python scripts/reels.py NAME ... [--out DIR] [--recordings DIR]
python scripts/reels.py all [--out DIR] [--recordings DIR]
Each video shows one page of MeshTerm. ``NAME`` is a key of ``reel_tours.REELS``, where
the tours are, and ``all`` makes each video, one after the other. The video is a Reel: a
vertical video of 1080×1920 pixels (9:16), at 30 video frames each second. It has
subtitles in the picture and no sound. Many people watch Instagram with the sound off,
and Instagram cannot show a subtitle file on a Reel.
The script runs MeshTerm with ``--demo`` in this process, as ``scripts/video.py`` does.
The terminal has 72 columns, the narrowest terminal where each screen of the regular
platform stays readable. Thus the text is as large as the width of a phone lets it be.
Above the terminal is the name of the series (``MeshTerm for MeshCore · Chat``), and
under it are the subtitles. The bottom of the video stays empty, because Instagram puts
the name of the account and the caption of the post there. The video ends on a card with
the wordmark and the address of the website.
The demo device plays no background traffic, except for a page that shows the traffic
(``Reel.background``). Thus the screen does not change while the tour waits off camera
for a live message, and the cut does not show.
For each video, the script writes three files into ``DIR`` (by default ``output/reels``,
which git does not track):
* ``NAME.mp4``: the video, H.264 High in ``yuv420p``, with its ``moov`` box at the
start. It has a silent AAC track: the track costs almost nothing, and some apps that
edit or post a video may expect one.
* ``NAME-cover.png``: the cover of the Reel, 1080×1920. The grid of a profile shows its
centre, at 3:4.
* ``NAME.txt``: a caption for the post.
With ``--recordings``, the script keeps the recording of each video in that folder
(``NAME.pickle``). When the file exists, a later run paints and encodes it again, and does
not run MeshTerm. Never keep a recording across a change to the app.
What it needs: the needs of ``scripts/video.py``, but not the website checkout.
"""
from __future__ import annotations
import argparse
import pickle
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable, Iterable
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import screenshots
import video
from reel_tours import REELS, Reel
from screenshots import ROOT, Capture
FPS = video.FPS
#: The size of the video: a Reel, 9:16.
WIDTH, HEIGHT = 1080, 1920
#: The terminal: 72 columns, the narrowest one where each screen stays readable. With the
#: cell of :data:`FONT_SIZE`, it is the full width of the video. Its 38 rows fill the
#: height of the video down to the subtitles (refer to :data:`TOP`).
GRID = (72, 38)
#: The size of the font of the terminal painter. It draws a cell of 14×29 pixels.
FONT_SIZE = 12
#: The part of the video that the series line, the terminal, and the subtitles fill.
#: Instagram's top bar is above it. The name of the account and the caption of the post
#: are below it, in the bottom 20%. Some guides keep the bottom 35% free, but then the
#: terminal fills less than half of the height. With this layout, a long caption can
#: cover the subtitles, but not the terminal (JP chose this layout, 2026-10-17).
TOP, BOTTOM = 150, 1530
#: The colours. ``INK`` is the bright white of the website, ``MUTED`` the muted grey of
#: the theme, and ``BORDER`` the dark grey of the theme.
BLACK = (0, 0, 0)
INK = (242, 242, 242)
SOFT = (204, 204, 204)
MUTED = (162, 162, 162)
BORDER = (64, 64, 64)
BRAND = video.BRAND
#: The fade of a subtitle, in video frames: the old one goes out, then the new one comes in
#: and moves up by :data:`SLIDE` pixels.
FADE_OUT, FADE_IN, SLIDE = 4, 8, 14
#: The end card: the fade to it, and the time that it stays, in video frames.
CARD_FADE, CARD_HOLD = 15, int(3.5 * FPS)
#: The wordmark of the splash, which the end card shows.
LOGO = ROOT / "docs" / "screenshots" / "splash" / "logo.71.png"
#: A subtitle cue: the video frame where it starts, and its text. ``*text*`` is in the
#: brand colour, and ``\n`` starts the second line. ``None`` clears the subtitle.
Cue = tuple[int, str | None]
def main(argv: list[str]) -> int:
"""Make each video that ``argv`` names, one after the other.
Returns:
0 if each video was made, 1 if one or more failed. A failure does not stop the
videos after it.
"""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("names", nargs="+", metavar="NAME", help=f"all, or: {', '.join(REELS)}")
parser.add_argument("--out", type=Path, default=ROOT / "output" / "reels")
parser.add_argument(
"--recordings",
type=Path,
help="keep each recording in this folder. If it has one, use it and do not run MeshTerm",
)
parser.add_argument("--child", type=Path, help=argparse.SUPPRESS)
args = parser.parse_args(argv)
names = list(REELS) if args.names == ["all"] else args.names
unknown = [name for name in names if name not in REELS]
if unknown:
parser.error(f"no video named {', '.join(unknown)}")
if args.child:
reel = REELS[names[0]]
video._watch_live_start()
return screenshots.run_demo(
"terminal",
lambda capture, keys: _record(capture, keys, reel, args.child),
background=reel.background,
grid=GRID,
)
args.out.mkdir(parents=True, exist_ok=True)
if args.recordings:
args.recordings.mkdir(parents=True, exist_ok=True)
failed = [name for name in names if not _make(name, args.out, args.recordings)]
if failed:
print(f"failed: {', '.join(failed)}", flush=True)
return 1 if failed else 0
def _make(name: str, out: Path, recordings: Path | None) -> bool:
"""Record the tour of one video in a child process, then paint and encode it.
Returns:
Whether the video was made.
"""
reel = REELS[name]
with tempfile.TemporaryDirectory(prefix="meshterm-reel-") as work:
recording = (recordings or Path(work)) / f"{name}.pickle"
if not recording.exists():
print(f"{name}: recording the tour...", flush=True)
home = Path(tempfile.gettempdir()) / "meshterm-reels"
done = subprocess.run(
[sys.executable, __file__, name, "--child", str(recording)],
# The size of the terminal for what reads it from the environment, such as
# Diagnostics (shutil.get_terminal_size). The process has no terminal.
env={
**screenshots.child_env(home),
"COLUMNS": str(GRID[0]),
"LINES": str(GRID[1]),
},
check=False,
)
if done.returncode or not recording.exists():
print(f"{name}: the recording failed ({done.returncode})", flush=True)
return False
tape = pickle.loads(recording.read_bytes())
states = _states(tape["frames"], tape["cues"])
print(
f"{name}: painting {len(set(states))} images for {len(states) / FPS:.1f} s...",
flush=True,
)
images = _paint(reel.title, tape["grids"], tape["cues"], states, Path(work))
target = out / f"{name}.mp4"
_encode([images[state] for state in states], Path(work), target)
frames = tape["frames"]
mark = tape["marks"].get("poster", len(frames) // 2)
poster = tape["grids"][frames[min(mark, len(frames) - 1)]]
cover = out / f"{name}-cover.png"
Composer(reel.title).frame(poster, (tape["cues"][0][1], 1.0, True)).save(cover)
caption = out / f"{name}.txt"
caption.write_text(reel.caption, encoding="utf-8")
for path in (target, cover, caption):
print(f"{path} ({path.stat().st_size // 1024} KB)", flush=True)
return True
# --------------------------------------------------------------------------- the child
class Director(video.Presenter):
"""The person at the keyboard, who also writes the subtitles."""
def __init__(self, capture: Capture, type_text: Callable[[str], None], camera) -> None: # noqa: ANN001 - a Recorder
"""Keep the capture, the input of MeshTerm, and the camera."""
super().__init__(capture, type_text, camera)
self.cues: list[Cue] = []
def subtitle(self, text: str | None) -> None:
"""Show ``text`` from the next video frame (refer to :data:`Cue`)."""
self.cues.append((len(self.camera.frames), text))
def open_page(
self, name: str, wait: str, *, settle: float = 0.8, timeout: float = 30.0
) -> None:
"""Open a page from the main menu, off camera: type ``name`` to filter, then Enter.
Args:
name: The start of the name of the page in the menu.
wait: A text that the page shows when it is open.
settle: How long the screen must stay the same before the tour goes on.
timeout: How long to wait for the page (the map loads its tiles).
"""
self.capture.wait_for("Chat", timeout=60)
self.capture.settle(quiet=0.5)
self.type(name)
self.key("enter")
self.wait_for(wait, timeout=timeout)
self.capture.settle(quiet=settle, timeout=timeout)
def start(self, text: str) -> None:
"""Show the first subtitle, and roll the camera."""
self.subtitle(text)
self.camera.roll()
def count(self, text: str) -> int:
"""How many times ``text`` is on the screen now."""
with self.capture.lock:
return self.capture.terminal.text().count(text)
def wait_until_shows(self, test: Callable[[], bool], timeout: float = 30.0) -> None:
"""Wait until ``test`` is true.
Raises:
TimeoutError: The test stayed false.
"""
start = time.monotonic()
while time.monotonic() - start < timeout:
if test():
return
time.sleep(0.1)
with self.capture.lock:
screen = self.capture.terminal.text()
raise TimeoutError(f"the screen did not change as expected. The screen:\n{screen}")
def walk_to(self, label: str, key: str = "up", tries: int = 16, pause: float = 0.22) -> None:
"""Press ``key`` at a steady pace until the highlight is on the line of ``label``.
Raises:
TimeoutError: The highlight did not come to the line.
"""
for _ in range(tries):
if self._highlighted(label):
return
self.key(key, pause=pause)
if not self._highlighted(label):
raise TimeoutError(f"the highlight did not come to {label!r}")
def _record(capture: Capture, type_text: Callable[[str], None], reel: Reel, path: Path) -> None:
"""Record the tour of ``reel``, and write the recording to ``path``."""
camera = video.Recorder(capture)
director = Director(capture, type_text, camera)
reel.tour(director)
camera.cut()
camera.stop()
tape = {
"grids": camera.grids,
"frames": camera.frames,
"marks": camera.marks,
"cues": director.cues,
}
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)
# --------------------------------------------------------------------------- the video
#: The state of one video frame: the copy of the screen and the subtitle, or the end card.
#: A subtitle is ``(cue, level, entering)``: the index of its cue, how far it faded in or
#: out (``0`` to ``FADE_IN`` or ``FADE_OUT``), and whether it comes in. ``("card", step)`` is
#: a step of the fade to the end card, and ``CARD_FADE`` is the card alone.
State = tuple
def _states(frames: list[int], cues: list[Cue]) -> list[State]:
"""The state of each video frame, with the end card after the last one."""
starts = [start for start, _ in cues]
states: list[State] = []
current = -1
for frame, grid in enumerate(frames):
while current + 1 < len(cues) and starts[current + 1] <= frame:
current += 1
states.append(("tour", grid, _subtitle_state(frame, current, cues)))
last = states[-1]
states += [("card", step, last) for step in range(1, CARD_FADE)]
states += [("card", CARD_FADE, None)] * CARD_HOLD
return states
def _subtitle_state(frame: int, current: int, cues: list[Cue]) -> tuple | None:
"""The subtitle of a video frame (refer to :data:`State`), or ``None``."""
if current < 0:
return None
start, text = cues[current]
age = frame - start
previous = cues[current - 1][1] if current else None
if previous is not None and age < FADE_OUT:
return (current - 1, FADE_OUT - age, False)
if text is None:
return None
if current == 0 and start == 0:
return (current, FADE_IN, True) # the first subtitle is there from the start
age -= FADE_OUT if previous is not None else 0
return (current, min(FADE_IN, age + 1), True)
class Composer:
"""Draw one video frame: the series line, the terminal, and the subtitle."""
def __init__(self, title: str) -> None:
"""Load the fonts, measure the layout, and draw the parts that never change."""
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont
self.painter = screenshots.TerminalPainter(FONT_SIZE)
cols, rows = GRID
p = self.painter
self.term_size = ((cols * p.cw + 2 * p.pad) // 2, (rows * p.ch + 2 * p.pad) // 2)
mono = screenshots.TEXT_FONTS[0]
self.cmap = set(TTFont(mono, lazy=True).getBestCmap())
self.sub_font = self._bold(mono, 48)
self.title_font = self._bold(mono, 40)
self.symbol_font = ImageFont.truetype(screenshots.TEXT_FONTS[1], 46)
self.advance = self.sub_font.getlength("M")
self.line_height = 64
# The block from the series line to the subtitles, in the middle of its part.
tw, th = self.term_size
block = 44 + 36 + th + 44 + 2 * self.line_height
top = TOP + (BOTTOM - TOP - block) // 2
self.term_at = ((WIDTH - tw) // 2, top + 44 + 36)
self.sub_top = self.term_at[1] + th + 44
self.base = Image.new("RGB", (WIDTH, HEIGHT), BLACK)
draw = ImageDraw.Draw(self.base)
series = [("MeshTerm", BRAND), (" for MeshCore", SOFT), (f" · {title}", MUTED)]
self._spans(draw, series, top + 36, self.title_font)
self.mask = Image.new("L", self.term_size, 0)
ImageDraw.Draw(self.mask).rounded_rectangle([0, 0, tw - 1, th - 1], radius=18, fill=255)
@staticmethod
def _bold(path: str, size: int): # noqa: ANN205 - a FreeTypeFont
from PIL import ImageFont
font = ImageFont.truetype(path, size)
try:
font.set_variation_by_name("Bold")
except (OSError, ValueError):
pass
return font
def frame(self, grid, subtitle: tuple[str | None, float, bool] | None): # noqa: ANN001, ANN201
"""The video frame of ``grid``, with ``(text, level, entering)`` as its subtitle.
``level`` goes from 0 (not visible) to 1 (all visible).
"""
return self.compose(self.painter.render(grid), subtitle)
def compose(self, terminal, subtitle: tuple[str | None, float, bool] | None): # noqa: ANN001, ANN201
"""The video frame of a painted terminal (refer to :meth:`frame`)."""
from PIL import Image, ImageDraw
image = self.base.copy()
image.paste(terminal, self.term_at, self.mask)
x, y = self.term_at
tw, th = self.term_size
ImageDraw.Draw(image).rounded_rectangle(
[x - 1, y - 1, x + tw, y + th], radius=19, outline=BORDER, width=2
)
if subtitle is not None and subtitle[0] and subtitle[1] > 0:
text, level, entering = subtitle
height = 2 * self.line_height + SLIDE + 16
layer = Image.new("RGBA", (WIDTH, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
ease = 1 - (1 - level) ** 3
shift = round((1 - ease) * SLIDE) if entering else 0
alpha = round(255 * level)
for row, line in enumerate(text.split("\n")):
baseline = shift + 48 + row * self.line_height
self._spans(draw, _markup(line), baseline, self.sub_font, alpha)
region = (0, self.sub_top, WIDTH, self.sub_top + height)
crop = image.crop(region).convert("RGBA")
image.paste(Image.alpha_composite(crop, layer).convert("RGB"), region[:2])
return image
def _spans(self, draw, spans, baseline: float, font, alpha: int = 255) -> None: # noqa: ANN001
"""Draw coloured spans of text as one centred line of the monospace font.
A character that the font does not have (``★``) comes from the symbol font, centred
in its cell.
"""
advance = font.getlength("M")
count = sum(len(text) for text, _ in spans)
x = (WIDTH - count * advance) / 2
for text, colour in spans:
for char in text:
face = font if ord(char) in self.cmap else self.symbol_font
draw.text(
(x + advance / 2, baseline), char, font=face, fill=(*colour, alpha), anchor="ms"
)
x += advance
def card(self): # noqa: ANN201 - an Image
"""The end card: the wordmark, what MeshTerm is, and the address of the website."""
from PIL import Image, ImageDraw
logo = Image.open(LOGO).convert("RGB")
# The black of the art is the Campbell black. The video is pure black.
pixels = logo.load()
for y in range(logo.height):
for x in range(logo.width):
if pixels[x, y] == screenshots.CAMPBELL[0]:
pixels[x, y] = BLACK
logo = logo.resize((logo.width * 2, logo.height * 2), Image.Resampling.NEAREST)
lines = [
(self.title_font, [("A full-featured MeshCore client", INK)], 58),
(self.title_font, [("for your terminal", INK)], 82),
(self.title_font, [("Free and open source", MUTED)], 58),
(self.title_font, [("Windows, macOS, and Linux", MUTED)], 110),
(self._bold(screenshots.TEXT_FONTS[0], 64), [("meshterm.net", BRAND)], 0),
]
block = logo.height + 90 + sum(gap for *_, gap in lines) + 40
top = TOP + (BOTTOM - TOP - block) // 2
image = Image.new("RGB", (WIDTH, HEIGHT), BLACK)
image.paste(logo, ((WIDTH - logo.width) // 2, top))
draw = ImageDraw.Draw(image)
baseline = top + logo.height + 90 + 40
for font, spans, gap in lines:
self._spans(draw, spans, baseline, font)
baseline += gap
return image
def _markup(line: str) -> list[tuple[str, tuple[int, int, int]]]:
"""The coloured spans of one subtitle line: ``*text*`` is in the brand colour."""
parts = line.split("*")
return [(part, BRAND if index % 2 else INK) for index, part in enumerate(parts) if part]
#: The composer of each worker process (refer to :func:`_paint`).
_composer: Composer | None = None
def _start_composer(title: str) -> None:
global _composer
_composer = Composer(title)
def _paint_grid(job: tuple[object, list[tuple[str, tuple | None]]]) -> None:
"""Paint one copy of the screen, and draw each of its video frames into a file."""
grid, outputs = job
terminal = _composer.painter.render(grid)
for path, subtitle in outputs:
_composer.compose(terminal, subtitle).save(path, compress_level=1)
def _paint(
title: str, grids: list, cues: list[Cue], states: Iterable[State], folder: Path
) -> dict[State, Path]:
"""Draw each different video frame into a PNG file in ``folder``, in parallel.
Returns:
The path of the image of each state.
"""
paths: dict[State, Path] = {}
jobs: dict[int, list[tuple[str, tuple | None]]] = {}
cards: list[State] = []
for state in states:
if state in paths:
continue
paths[state] = folder / f"{len(paths):05d}.png"
if state[0] == "card":
cards.append(state)
continue
_, grid, subtitle = state
jobs.setdefault(grid, []).append((str(paths[state]), _resolve(subtitle, cues)))
with ProcessPoolExecutor(initializer=_start_composer, initargs=(title,)) as pool:
list(pool.map(_paint_grid, ((grids[g], out) for g, out in jobs.items()), chunksize=4))
from PIL import Image
composer = Composer(title)
card = composer.card()
for state in cards:
_, step, last = state
if last is None:
card.save(paths[state], compress_level=1)
continue
before = Image.open(paths[last]).convert("RGB")
t = step / CARD_FADE
Image.blend(before, card, t * t * (3 - 2 * t)).save(paths[state], compress_level=1)
return paths
def _resolve(subtitle: tuple | None, cues: list[Cue]) -> tuple[str | None, float, bool] | None:
"""The text and the level (0 to 1) of a subtitle state."""
if subtitle is None:
return None
cue, level, entering = subtitle
return (cues[cue][1], level / (FADE_IN if entering else FADE_OUT), entering)
def _encode(images: list[Path], work: Path, target: Path) -> None:
"""Encode the video frames into ``target`` with ffmpeg, with a silent audio track.
As in :func:`video._encode`, each different image goes to ffmpeg one time, with the
time that it stays on the screen, and the RGB to YUV conversion uses the BT.709
matrix. Instagram encodes each upload again, so this file is made for quality, not
for size: CRF 16 and a keyframe each two seconds.
"""
runs: list[list] = []
for path in images:
if runs and runs[-1][0] == path:
runs[-1][1] += 1
else:
runs.append([path, 1])
lines = ["ffconcat version 1.0"]
for path, count in runs:
lines += [f"file '{path.as_posix()}'", f"duration {count / FPS:.6f}"]
lines.append(f"file '{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),
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000",
"-map", "0:v", "-map", "1:a",
"-vf", f"fps={FPS},scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,"
f"setparams={colours}",
"-t", f"{len(images) / FPS:.3f}",
"-c:v", "libx264", "-preset", "slow", "-tune", "animation", "-crf", "16",
"-profile:v", "high", "-level", "4.1", "-g", str(FPS * 2),
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", str(target),
] # fmt: skip
subprocess.run(command, check=True)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))