The videos for Instagram come from a script
scripts/reels.py makes one vertical video of MeshTerm for each feature, from the demo mesh: 1080×1920, with subtitles in the picture and no sound, then a card with the wordmark and the website. The terminal has 72 columns, so its text is as large as a phone lets it be. It also writes a cover for the Reel and a caption for the post. The first video is the chat: the conversations, a live message, the paths of a message, a mention and its answer, and a link as a QR code. screenshots.py now takes the grid of the terminal set as an argument, and its painter draws a full-width character that no font has (the "#" of a hashtag channel) as its ASCII form, instead of a box. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
37aa4ed5be
commit
fc7fd17554
3 files changed
+620
-2
No files matched your search
@@ -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/<set>/`. 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: `<name>.mp4` (the video, with a silent audio track),
|
||||
`<name>-cover.png` (the cover of the Reel), and `<name>.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.
|
||||
@@ -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:]))
|
||||
+15
-2
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user