mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 21:43:55 -06:00
feat: record an installer demo with vhs
scripts/make_demo.py builds an ArkOS card under tmp/demo/, mounts it in a user mount namespace, serves the checkout on loopback and runs docs_assets/demo/install.tape with VHS, then encodes the GIF, WebM, MP4 and poster and holds them to their size targets.
This commit is contained in:
1 parent
268c974e28
commit
e310723893
8 files changed
+351
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"width": 800,
|
||||
"height": 460,
|
||||
"setup": "ArkOS",
|
||||
"games": 6,
|
||||
"systems": 6,
|
||||
"files": 43,
|
||||
"size": "15.1 MB"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,46 @@
|
||||
# The installer demo of the README and the site. Run scripts/make_demo.py,
|
||||
# which builds the card, serves the repository on loopback, starts VHS on
|
||||
# this file from the repository root and encodes what it writes.
|
||||
|
||||
Output tmp/demo/recorded.mp4
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontFamily "DejaVu Sans Mono"
|
||||
Set FontSize 15
|
||||
Set LineHeight 1.15
|
||||
Set Width 800
|
||||
Set Height 460
|
||||
Set Padding 16
|
||||
Set Framerate 25
|
||||
Set TypingSpeed 35ms
|
||||
Set CursorBlink false
|
||||
Set Theme { "name": "RetroBIOS", "background": "#12131c", "foreground": "#e6e6f0", "cursor": "#aebcff", "selection": "#3a3d52", "black": "#12131c", "red": "#ef9a9a", "green": "#a5d6a7", "yellow": "#ffcc80", "blue": "#90caf9", "magenta": "#aebcff", "cyan": "#aebcff", "white": "#cfd8dc", "brightBlack": "#7c82a3", "brightRed": "#ef9a9a", "brightGreen": "#a5d6a7", "brightYellow": "#ffcc80", "brightBlue": "#90caf9", "brightMagenta": "#c9d2ff", "brightCyan": "#c9d2ff", "brightWhite": "#e6e6f0" }
|
||||
|
||||
# The card mounted at /media/EASYROMS, an empty HOME, the prompt "$ ".
|
||||
Hide
|
||||
Type 'exec "$RETROBIOS_DEMO_SESSION"'
|
||||
Enter
|
||||
Wait+Screen@20s /\$/
|
||||
Sleep 500ms
|
||||
Show
|
||||
|
||||
Sleep 700ms
|
||||
Type "curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --for-games"
|
||||
Sleep 600ms
|
||||
Screenshot tmp/demo/poster.png
|
||||
Sleep 600ms
|
||||
Enter
|
||||
|
||||
# The first screen: the card found.
|
||||
Wait+Screen@30s /ArkOS SD card \(EASYROMS\)/
|
||||
Sleep 2500ms
|
||||
Enter
|
||||
|
||||
# The plan: only the files the games on the card need.
|
||||
Wait+Screen@60s /For the games on this card: yes/
|
||||
Sleep 4500ms
|
||||
Enter
|
||||
|
||||
# The summary.
|
||||
Wait+Screen@60s /RetroBIOS: Done/
|
||||
Sleep 4500ms
|
||||
Binary file not shown.
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record the installer demo shown in the README and on the site, once.
|
||||
|
||||
The recording is the full-screen installer run from the one-line command on
|
||||
an ArkOS SD card that holds six games: it finds the card, plans only the BIOS
|
||||
files those games need, installs them and shows the summary. Everything it
|
||||
reads is local and the same on every machine:
|
||||
|
||||
- The card is built under tmp/demo/ (bios/, the folders ArkOS writes on its
|
||||
games partition, one game per console) and mounted at /media/EASYROMS in a
|
||||
mount namespace of this user (unshare), on a tmpfs of a card's size. /media
|
||||
and /mnt are emptied there, so no drive of this machine is shown.
|
||||
- The repository is served on 127.0.0.1 and named with RETROBIOS_BASE_URL:
|
||||
the file lists, install/games/ and the BIOS files are this checkout's.
|
||||
- The one-line command runs this checkout's install.sh and install.py. The
|
||||
shell of the recording answers `curl -fsSL <site>/i` with a line that
|
||||
starts the local install.sh, which skips the download of the installer and
|
||||
its SHA-256 check and runs the rest of the bootstrap as the site's copy does.
|
||||
- HOME is an empty folder and the prompt is "$ ".
|
||||
|
||||
docs_assets/demo/install.tape drives VHS (github.com/charmbracelet/vhs, with
|
||||
ttyd, ffmpeg and a Chromium on PATH); this script encodes what it records:
|
||||
|
||||
docs_assets/demo/install.gif the README
|
||||
docs_assets/demo/install.webm the site, with install.mp4 as fallback
|
||||
docs_assets/demo/install.png the first frame shown, the video's poster
|
||||
docs_assets/demo/demo.json what the recording shows, for the alt text
|
||||
|
||||
The site build copies the video files and never runs this script. Run it
|
||||
again when a screen of the installer or the file list of its games changes:
|
||||
|
||||
python scripts/make_demo.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import hashlib
|
||||
import http.server
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "docs_assets" / "demo"
|
||||
TAPE = OUT / "install.tape"
|
||||
WORK = ROOT / "tmp" / "demo"
|
||||
# What the tape writes, read back here (paths relative to ROOT, the tape's
|
||||
# working directory).
|
||||
RECORDED = WORK / "recorded.mp4"
|
||||
POSTER_SHOT = WORK / "poster.png"
|
||||
MOUNT = "/media/EASYROMS"
|
||||
CARD_SIZE = "29g"
|
||||
SETUP = "ArkOS"
|
||||
|
||||
# The card: the folders ArkOS's default tree writes on EASYROMS (install.py
|
||||
# _GAMES_PART_RULES reads launchimages/), and one game per console folder,
|
||||
# named as the folder's extensions in install/games/setups/arkos.json allow.
|
||||
CARD_FOLDERS = ("bios", "bgmusic", "launchimages", "themes/es-theme-nes-box")
|
||||
GAMES = {
|
||||
"psx": "Sky Courier (USA).iso",
|
||||
"segacd": "Night Shift (USA).iso",
|
||||
"megadrive": "Old Harbour (World).md",
|
||||
"mastersystem": "Tide Runner (Europe).sms",
|
||||
"gamegear": "Pocket Quest (World).gg",
|
||||
"pcengine": "Star Lancer (Japan).pce",
|
||||
}
|
||||
GAME_BYTES = 256 * 1024
|
||||
# A PlayStation disc carries Sony's licence text in sector 4, the text the
|
||||
# cores read the region from (install/games/systems/sony-playstation.json).
|
||||
PSX_LICENCE = b" Licensed by Sony Computer Entertainment Amer ica "
|
||||
|
||||
# Size targets of the outputs, in bytes: the README and two site pages.
|
||||
LIMITS = {"install.gif": 1_500_000, "install.webm": 1_000_000,
|
||||
"install.mp4": 1_000_000, "install.png": 150_000}
|
||||
GIF_FPS = 10
|
||||
VIDEO_FPS = 25
|
||||
|
||||
|
||||
def game_bytes(name: str) -> bytes:
|
||||
"""Bytes of a game no catalog names, the same on every run."""
|
||||
seed = hashlib.sha256(name.encode("utf-8")).digest()
|
||||
blocks, counter = [], 0
|
||||
while len(blocks) * 32 < GAME_BYTES:
|
||||
blocks.append(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
|
||||
counter += 1
|
||||
return b"".join(blocks)[:GAME_BYTES]
|
||||
|
||||
|
||||
def build_card(card: Path) -> None:
|
||||
if card.exists():
|
||||
shutil.rmtree(card)
|
||||
for folder in CARD_FOLDERS:
|
||||
(card / folder).mkdir(parents=True)
|
||||
(card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8\xff\xe0" + bytes(60) + b"\xff\xd9")
|
||||
(card / "themes" / "es-theme-nes-box" / "theme.xml").write_text(
|
||||
"<theme>\n</theme>\n", encoding="utf-8")
|
||||
for folder, name in GAMES.items():
|
||||
data = bytearray(game_bytes(name))
|
||||
if folder == "psx":
|
||||
data[4 * 2048:4 * 2048 + len(PSX_LICENCE)] = PSX_LICENCE
|
||||
(card / folder).mkdir()
|
||||
(card / folder / name).write_bytes(bytes(data))
|
||||
|
||||
|
||||
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def served_repository() -> Iterator[str]:
|
||||
"""The repository on a loopback port, the base URL install.py accepts
|
||||
without HTTPS."""
|
||||
handler = functools.partial(QuietHandler, directory=str(ROOT))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def write_session(work: Path, names: list[str]) -> Path:
|
||||
"""The program the recording's shell becomes: the card mounted in a
|
||||
namespace of its own, then a clean interactive bash, or the command
|
||||
given as arguments, with only the variables names lists."""
|
||||
rc = work / "bashrc"
|
||||
rc.write_text(
|
||||
"PS1='$ '\n"
|
||||
"unset HISTFILE PROMPT_COMMAND\n"
|
||||
"curl() {\n"
|
||||
' if [ "$*" = "-fsSL https://abdess.github.io/retrobios/i" ]; then\n'
|
||||
' printf \'exec %s "$@"\\n\' "$RETROBIOS_DEMO_BOOTSTRAP"\n'
|
||||
" else\n"
|
||||
' command curl "$@"\n'
|
||||
" fi\n"
|
||||
"}\n"
|
||||
"clear\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
session = work / "session.sh"
|
||||
session.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Written by scripts/make_demo.py.\n"
|
||||
"set -eu\n"
|
||||
"inner='set -eu\n"
|
||||
"mount -t tmpfs -o mode=0755 demo /media\n"
|
||||
"mount -t tmpfs -o mode=0755 demo /mnt\n"
|
||||
f"mkdir {MOUNT}\n"
|
||||
f"mount -t tmpfs -o size={CARD_SIZE},mode=0755 EASYROMS {MOUNT}\n"
|
||||
f'cp -a "$RETROBIOS_DEMO_CARD/." {MOUNT}/\n'
|
||||
'cd "$HOME"\n'
|
||||
'exec unshare --user --map-user="$RETROBIOS_DEMO_UID" '
|
||||
'--map-group="$RETROBIOS_DEMO_GID" env -i '
|
||||
+ " ".join(f'{name}="${name}"' for name in names) + ' "$@"\'\n'
|
||||
'if [ "$#" -eq 0 ]; then\n'
|
||||
f' set -- bash --noprofile --rcfile "{rc}" -i\n'
|
||||
"fi\n"
|
||||
'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
session.chmod(0o755)
|
||||
return session
|
||||
|
||||
|
||||
def session_env(base_url: str, work: Path, card: Path) -> dict[str, str]:
|
||||
home = work / "home"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
return {
|
||||
"HOME": str(home),
|
||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"TERM": "xterm-256color",
|
||||
"TMPDIR": str(work / "scratch"),
|
||||
"RETROBIOS_OS": "linux",
|
||||
"RETROBIOS_BASE_URL": base_url,
|
||||
# What the downloaded bootstrap sets: commands given back are the
|
||||
# one-line command, not a path to a temporary copy.
|
||||
"RETROBIOS_BOOTSTRAP": "sh",
|
||||
"RETROBIOS_DEMO_BOOTSTRAP": str(ROOT / "install.sh"),
|
||||
"RETROBIOS_DEMO_CARD": str(card),
|
||||
"RETROBIOS_DEMO_UID": str(os.getuid()),
|
||||
"RETROBIOS_DEMO_GID": str(os.getgid()),
|
||||
}
|
||||
|
||||
|
||||
def format_size(size: int) -> str:
|
||||
"""The size as the installer writes it."""
|
||||
spec = importlib.util.spec_from_file_location("retrobios_install", ROOT / "install.py")
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("install.py cannot be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return str(module.format_size(size))
|
||||
|
||||
|
||||
def measure(session: Path, env: dict[str, str]) -> dict:
|
||||
"""What the recording shows, read from the same run without the screen:
|
||||
the games read on the card and the files planned for them."""
|
||||
command = [str(session), sys.executable, str(ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", f"{MOUNT}/bios", "--for-games", "--dry-run", "--events"]
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"the dry run failed ({result.returncode}):\n{result.stderr}")
|
||||
events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
|
||||
games = next(e for e in events if e["event"] == "games")
|
||||
check = next(e for e in events if e["event"] == "check")
|
||||
size = {key.lower(): int(value) for key, value in
|
||||
re.findall(r"^Set (Width|Height) (\d+)$", TAPE.read_text(encoding="utf-8"), re.M)}
|
||||
return {
|
||||
"width": size["width"],
|
||||
"height": size["height"],
|
||||
"setup": SETUP,
|
||||
"games": games["games"],
|
||||
"systems": len(games["found"]),
|
||||
"files": check["missing"],
|
||||
"size": format_size(check["bytes_needed"]),
|
||||
}
|
||||
|
||||
|
||||
def ffmpeg(*args: str) -> None:
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args], check=True)
|
||||
|
||||
|
||||
def encode(recorded: Path, poster: Path, out: Path, scratch: Path) -> None:
|
||||
palette = scratch / "palette.png"
|
||||
ffmpeg("-i", str(recorded), "-vf", f"fps={GIF_FPS},palettegen=max_colors=48:stats_mode=full",
|
||||
str(palette))
|
||||
ffmpeg("-i", str(recorded), "-i", str(palette), "-lavfi",
|
||||
f"fps={GIF_FPS}[v];[v][1:v]paletteuse=dither=none:diff_mode=rectangle",
|
||||
"-loop", "0", str(out / "install.gif"))
|
||||
ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libvpx-vp9",
|
||||
"-b:v", "0", "-crf", "42", "-row-mt", "1", "-deadline", "good",
|
||||
"-pix_fmt", "yuv420p", str(out / "install.webm"))
|
||||
ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libx264",
|
||||
"-preset", "veryslow", "-crf", "30", "-tune", "animation", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart", str(out / "install.mp4"))
|
||||
ffmpeg("-i", str(poster), "-pred", "mixed", "-compression_level", "100",
|
||||
str(out / "install.png"))
|
||||
|
||||
|
||||
def check_sizes(out: Path) -> list[str]:
|
||||
return [f"{name}: {(out / name).stat().st_size:,} bytes, over {limit:,}"
|
||||
for name, limit in LIMITS.items() if (out / name).stat().st_size > limit]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False,
|
||||
description="Record the installer demo (VHS).")
|
||||
parser.parse_args()
|
||||
missing = [tool for tool in ("vhs", "ttyd", "ffmpeg", "unshare") if shutil.which(tool) is None]
|
||||
if missing:
|
||||
sys.exit(f"Missing on PATH: {', '.join(missing)} (VHS: github.com/charmbracelet/vhs)")
|
||||
if WORK.exists():
|
||||
shutil.rmtree(WORK)
|
||||
(WORK / "scratch").mkdir(parents=True)
|
||||
try:
|
||||
card = WORK / "card"
|
||||
build_card(card)
|
||||
with served_repository() as base_url:
|
||||
env = session_env(base_url, WORK, card)
|
||||
session = write_session(WORK, sorted(env))
|
||||
facts = measure(session, env)
|
||||
vhs_env = {**os.environ, **env, "PATH": os.environ.get("PATH", ""),
|
||||
"HOME": os.environ.get("HOME", ""),
|
||||
"RETROBIOS_DEMO_SESSION": str(session)}
|
||||
subprocess.run(["vhs", str(TAPE.relative_to(ROOT))], cwd=ROOT, env=vhs_env,
|
||||
check=True)
|
||||
encode(RECORDED, POSTER_SHOT, OUT, WORK / "scratch")
|
||||
(OUT / "demo.json").write_text(json.dumps(facts, indent=2) + "\n", encoding="utf-8")
|
||||
for name in (*LIMITS, "demo.json"):
|
||||
print(f"{(OUT / name).relative_to(ROOT)} {(OUT / name).stat().st_size:,} bytes")
|
||||
over = check_sizes(OUT)
|
||||
if over:
|
||||
sys.exit("Over the size targets:\n " + "\n ".join(over))
|
||||
finally:
|
||||
shutil.rmtree(WORK, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from common import run_cli
|
||||
|
||||
run_cli(main)
|
||||
@@ -1371,6 +1371,7 @@ before a release (wiki/release-process.md). Its lines stay in
|
||||
| `generate_site.py` | Generate the site (this documentation) into the Starlight project in `website/` |
|
||||
| `sitesources.py` | Record which cited source paths each pinned upstream tree holds, for the site's source links (`--fetch` reads a tree no cache holds) |
|
||||
| `site_insights.py` | What readers look for and do not find, from totals only: searches that show the site but are rarely clicked, file names searched and whether the collection holds them, site searches that found nothing, broken addresses, "Did this page help?" answers, GitHub traffic and release downloads (`--days`, `--skip gsc,goatcounter,github`, `--output`). Reads Search Console, GoatCounter and GitHub with credentials kept outside the repository. [What the site counts](privacy.md) |
|
||||
| `make_demo.py` | Record the installer demo of the README and the site into `docs_assets/demo/`: an ArkOS card with six games built under `tmp/demo/` and mounted in a mount namespace of the user, this checkout served on loopback, `docs_assets/demo/install.tape` run by VHS (needs `vhs`, `ttyd`, `ffmpeg`, `unshare`), then the GIF, WebM, MP4 and poster encoded and held to their size targets. `demo.json` records what the recording shows, for the alt text. Never run by the build |
|
||||
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
|
||||
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held |
|
||||
| `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit |
|
||||
|
||||
Reference in new issue
Block a user