diff --git a/scripts/README.md b/scripts/README.md index c307eb3..14c63b9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,7 @@ These are tools for the repository. None of them is in the wheel. | `cardputer-zero/` | `build-deb.sh` builds the Debian package for M5Stack's app store. Run it on the Cardputer Zero or on another arm64 Debian 13 system. `store/` holds the listing for the store (`app-builder.json`, which `czdev publish` reads), its icon, and its screenshots. `screenshot.py` saves the display of a Cardputer as a 320×170 PNG over SSH, as the store wants a screenshot. `store-publish.py` sends a package to the store with M5Stack's `czdev`, with two corrections. The release workflow runs it after each release. `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. `meshterm.png` is the package's icon: the website's logo, at the launcher's 100×100 size. `meshterm-dev.png` is its negative, for the checkout's entry, so that the two entries look different. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH | | `screenshots.py` | Makes the screenshots of the website from the demo mesh (`--demo`): the main menu, the chat, the paths of a message, and the map, for the terminal, the Cardputer Zero, and the PicoCalc. They are the same at each run. The result goes to `docs/screenshots//`. It also draws the two wordmarks of the splash into `docs/screenshots/splash/`, which the website uses as its splash | | `video.py` | Makes the demo video of the website from the demo mesh: a tour of less than two minutes in the terminal, with its poster and its play card. The result goes to `src/` of the `meshterm.net` checkout beside this one | +| `reels.py` | Makes the videos for Instagram from the demo mesh: one vertical video for each feature, with subtitles in the picture and no sound, a cover, and a caption for the post. The result goes to `output/reels/` | | `picocalc-lyra/` | Setup of the Calculinux device: console font, palette, Wi-Fi. `xiao-radio/` is the kit for the XIAO radio (firmware build, flashing, UART setup) | | `uconsole/` | The uConsole's always-on SPI bridge service. MeshTerm can also control that radio directly, with no script (refer to `docs/devices/uconsole.md`) | @@ -114,3 +115,28 @@ the screen on the real clock. Each release makes the video again, after the scre and pushes it to the website repository before the tag. After a change to the screens between two releases, run the two scripts, then rebuild the website (refer to the README of the `meshterm.net` repository). + +## reels.py + +This script makes the videos for Instagram. Each video shows one feature of MeshTerm, in +the demo mesh. It is a Reel: a vertical video of 1080×1920 pixels, at 30 video frames each +second. The terminal has 72 columns, so that its text is as large as the width of a phone +lets it be. Above the terminal is the name of the series (`MeshTerm · Chat`), and under it +are the subtitles. The video has no sound, because many people watch Instagram with the +sound off. It ends on a card with the wordmark and the address of the website. + +```bash +python scripts/reels.py chat # into output/reels/ +python scripts/reels.py chat --out some/folder +python scripts/reels.py chat --recording chat.pickle # keep the recording +``` + +`chat` is the only video now: the list of the conversations, a message that arrives, the +paths of a message, a mention and its answer, and a link as a QR code. To add a video, write +its tour and add it to `REELS`. A tour types the keys, as in `video.py`, and also writes the +subtitles (`Director.subtitle`). + +The script writes three files: `.mp4` (the video, with a silent audio track), +`-cover.png` (the cover of the Reel), and `.txt` (a caption for the post). It +needs the same things as `video.py`, but not the website checkout. A run takes +approximately two minutes. diff --git a/scripts/reels.py b/scripts/reels.py new file mode 100644 index 0000000..54df3c6 --- /dev/null +++ b/scripts/reels.py @@ -0,0 +1,579 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Make the videos of MeshTerm for Instagram from the demo mesh. + + python scripts/reels.py NAME [--out DIR] [--recording FILE] + +Each video shows one feature of MeshTerm. ``NAME`` is one of :data:`REELS`. 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 · 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. Thus the screen does not change while the +tour waits off camera for a live message, and the cut does not show. + +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. + +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 dataclasses import dataclass +from pathlib import Path + +import screenshots +import video +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. +GRID = (72, 28) + +#: 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 Instagram does not cover: its top bar is above, and the +#: name of the account and the caption of the post are below. +SAFE_TOP, SAFE_BOTTOM = 220, 1480 + +#: 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: + """Record the tour of one video in a child process, then paint and encode it.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("name", choices=sorted(REELS)) + parser.add_argument("--out", type=Path, default=ROOT / "output" / "reels") + 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("--child", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + reel = REELS[args.name] + if args.child: + video._watch_live_start() + return screenshots.run_demo( + "terminal", + lambda capture, keys: _record(capture, keys, reel, args.child), + background=False, + grid=GRID, + ) + args.out.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="meshterm-reel-") as work: + recording = args.recording or Path(work) / "recording.pickle" + if not recording.exists(): + print(f"recording the tour of {args.name}...", flush=True) + home = Path(tempfile.gettempdir()) / "meshterm-reels" + done = subprocess.run( + [sys.executable, __file__, args.name, "--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()) + states = _states(tape["frames"], tape["cues"]) + print(f"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 = args.out / f"{args.name}.mp4" + print("encoding...", flush=True) + _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 = args.out / f"{args.name}-cover.png" + Composer(reel.title).frame(poster, (tape["cues"][0][1], 1.0, True)).save(cover) + caption = args.out / f"{args.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 0 + + +# --------------------------------------------------------------------------- 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 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) + + +def _chat(d: Director) -> None: + """The chat: the conversations, a live message, the paths, a mention, and a QR code.""" + from meshterm.demo import script + + capture = d.capture + capture.wait_for("Chat", timeout=60) + capture.settle(quiet=0.5) + # The video starts on the list of the conversations. The main menu is not in it. + d.type("chat") + d.key("enter") + d.wait_for("pick a conversation") + capture.settle(quiet=0.8) + + print("the conversations", flush=True) + d.subtitle("Off-grid chat over *LoRa radio*,\nright in your terminal") + d.camera.roll() + d.hold(2.8) + d.type("pub") + d.hold(0.5) + d.key("enter", pause=0.0) + d.wait_for(screenshots.PATHS_MESSAGE[2]) + d.subtitle("Everyone gets\ntheir own *colour*") + d.hold(2.6) + + print("a live message", flush=True) + offset, _, line = next(item for item in script.LIVE_CHANNEL if item[1] == "Public") + with d.off_camera(): + d.wait_until(video._start() + offset - 0.8) + d.wait_for(line.text, timeout=20) + d.subtitle("New messages arrive *live*,\nover the air") + d.hold(2.4) + + print("the paths of a message", flush=True) + for _ in range(3): # Chloé, Félix, then Gabriel + d.key("up", pause=0.45) + d.hold(0.5) + # 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 d.off_camera(): + video._open_paths(d) + d.subtitle("See *every path* a message\ntook to reach you") + d.hold(2.2) + d.key("down", pause=1.4) + d.key("up", pause=1.4) + d.key("down", pause=0.5) + d.camera.mark("poster") + d.hold(1.6) + d.key("escape") + d.key("escape") # the first Esc closes the dialog, the second clears the selection + + print("a mention, and its answer", flush=True) + d.subtitle("*Mentions* turn into\nname tags as you type") + d.hold(0.6) + # The mention is typed in full: the ] makes it a chip. + d.type("Hey @[Dave-Lachine], are you coming Saturday?") + d.hold(0.8) + d.key("enter", pause=0.0) + sent = time.monotonic() + d.wait_for("0/130") # the compose line is empty: the message went + d.hold(0.8) + # Dave answers after ``script.REPLY_DELAY_S``. The camera does not show most of the wait. + with d.off_camera(): + d.wait_until(sent + script.REPLY_DELAY_S - 0.6) + d.wait_for("be there at 2", timeout=30) + d.subtitle("When someone *mentions you*,\nyou get a ★") + d.hold(2.8) + + print("a link as a QR code", flush=True) + d.subtitle("Turn any link into a\n*QR code* for your phone") + d.hold(0.4) + d.walk_to("11:04") # our message with the link to the map + d.hold(0.7) + d.key(text="u", ctrl=True, pause=0.0) + d.hold(3.4) + + +# --------------------------------------------------------------------------- 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 the safe part. + tw, th = self.term_size + block = 44 + 36 + th + 44 + 2 * self.line_height + top = SAFE_TOP + (SAFE_BOTTOM - SAFE_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) + self._spans(draw, [("MeshTerm", BRAND), (f" · {title}", MUTED)], 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 = SAFE_TOP + (SAFE_BOTTOM - SAFE_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) + + +# --------------------------------------------------------------------------- the reels + + +@dataclass(frozen=True) +class Reel: + """One video of the series. + + Attributes: + title: The name of the feature, in the series line above the terminal. + tour: The keys of the tour, and the subtitles. + caption: The text of the post on Instagram. + """ + + title: str + tour: Callable[[Director], None] + caption: str + + +REELS: dict[str, Reel] = { + "chat": Reel( + "Chat", + _chat, + "Off-grid chat over LoRa radio, right in your terminal.\n" + "\n" + "MeshTerm is a free, open-source MeshCore client for Windows, macOS, and Linux. " + "Chat on channels, in direct messages, and in rooms. See every path a message took " + "across the mesh. Mention a friend, and their name turns into a tag. Turn any link " + "into a QR code for your phone.\n" + "\n" + "Filmed in MeshTerm's demo mesh over Montréal (meshterm --demo).\n" + "\n" + "meshterm.net\n" + "\n" + "#MeshCore #LoRa #OffGrid #MeshNetwork #Terminal #TUI #OpenSource\n", + ), +} + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/screenshots.py b/scripts/screenshots.py index 876ea37..b5253ac 100644 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -48,6 +48,7 @@ import tempfile import threading import time import traceback +import unicodedata from collections.abc import Callable from datetime import datetime, timedelta, timezone from pathlib import Path @@ -224,7 +225,9 @@ def _child(name: str, out: Path) -> int: return run_demo(name, tour, background=False) -def run_demo(name: str, tour: Tour, *, background: bool) -> int: +def run_demo( + name: str, tour: Tour, *, background: bool, grid: tuple[int, int] = TERMINAL_GRID +) -> int: """Run MeshTerm with ``--demo`` in this process, as the set ``name``, and do ``tour``. The tour runs in a thread of its own while MeshTerm runs in the main thread. When the @@ -236,6 +239,8 @@ def run_demo(name: str, tour: Tour, *, background: bool) -> int: tour: What to do with MeshTerm. background: Whether the demo device plays the background traffic of the world (:data:`meshterm.demo.device.PLAY_BACKGROUND`). + grid: The columns and the rows of the terminal set. A handheld set always has the + grid of its display. Returns: 1, which comes only if MeshTerm stopped before the tour ended it. @@ -261,7 +266,7 @@ def run_demo(name: str, tour: Tour, *, background: bool) -> int: cols, rows = handheld.platform.readable_cols, handheld.platform.readable_rows depth, platform = handheld.color_depth, handheld.id else: - cols, rows = TERMINAL_GRID + cols, rows = grid depth, platform = ColorDepth.TRUE_COLOR, "regular" lock = threading.Lock() with create_pipe_input() as pipe: @@ -569,6 +574,14 @@ class TerminalPainter: return font = self.bold if bold and code in self.fonts[0][1] else None if font is None: + font = next((f for f, cmap in self.fonts if code in cmap), None) + if font is None: + # No font has the character. Draw its compatibility form, if a font has that: a + # full-width form (the "#" of a hashtag channel) as its ASCII character, centred + # in its two cells. A terminal gets the same shape from a CJK font. + folded = unicodedata.normalize("NFKC", base) + if len(folded) == 1 and any(ord(folded) in cmap for _, cmap in self.fonts): + char, code = folded, ord(folded) font = next((f for f, cmap in self.fonts if code in cmap), self.fonts[0][0]) span = self.cw * (2 if wide else 1) draw.text((x + span / 2, y + self.baseline), char, font=font, fill=fg, anchor="ms")