#!/usr/bin/env python3
"""demoreel — record a GUI app on a private virtual X display.

Only the app is in frame. The display it runs on has no desktop, no
notifications and no magnifier, so nothing of the user's real session can
end up in the video. README.md explains why that is the whole point.
"""

import argparse
import codecs
import contextlib
import ctypes
import fcntl
import itertools
import json
import math
import os
import re
import resource
import secrets
import select
import shlex
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

__version__ = "0.9.0"

SIZE_RE = re.compile(r"^(\d+)x(\d+)$")
DISPLAY_READY_TIMEOUT = 15.0

# The one definition of "nothing is on this display". Measured: an empty Xvfb
# is 0.99994, and a window as small as 200x100 on a 1280x800 display already
# drops to 0.98, so there is room on both sides. ci.sh imports this rather than
# restating it -- a threshold with two homes is one a person has to remember to
# move, and this project has already answered that question the same way twice
# (`ci.sh --ruff-version`, `ci.sh --docs-glob`).
BLANK_THRESHOLD = 0.999
# How long one xdotool call or one frame grab may take before the display is
# taken to have stopped answering (DEMO-0079). Each normally returns in a few
# milliseconds -- a grab costs about 112ms of CPU on a 1600x1000 display -- so
# this is a hang detector, not a performance budget. Typing is paced by xdotool
# at 12ms a key, so a `type` step gets TYPE_SECONDS_PER_CHAR more per character.
CALL_TIMEOUT = 10
TYPE_SECONDS_PER_CHAR = 0.05

# Both backends need this. Unset, wl_display_connect() falls back to the
# hardcoded $XDG_RUNTIME_DIR/wayland-0, which exists on a Wayland session — so
# the app connects to the user's REAL compositor, renders there, and the
# private display records black while every signal says success. A name that
# cannot resolve fails the connection instead, and the toolkit falls back to
# X11. It is the non-Flatpak twin of --nosocket=wayland, and unlike
# QT_QPA_PLATFORM=xcb it does not need to know the app's toolkit.
NO_WAYLAND = "demoreel-no-wayland"

# An Event, not a flag, so a wait on it ends the moment a stop arrives. A
# plain sleep resumes after the handler runs (PEP 475), and a stop landed up
# to one sleep late: a median 107 ms, measured, all of it in the video
# (DEMO-0075).
_stop = threading.Event()

# Set once a call to the display has timed out (DEMO-0079): the run has failed
# and hands back no video. The recorder and the display are then ended at
# once, since each waits on the frozen display and a graceful stop only runs
# out its timeouts, 25 seconds of them (DEMO-0078).
_display_gone = threading.Event()


def _request_stop(signum, frame):
    """SIGUSR1 (from `demoreel stop`), SIGINT or SIGTERM: finish the video now."""
    _stop.set()


def _exit_on_signal(signum, frame):
    """SIGTERM or SIGHUP anywhere but a recording: exit through die(), so the
    finally blocks that end the display and the app still run. Left at their
    default, either signal ended Python on the spot and leaked Xvfb, which
    holds its display number for good (DEMO-0115)."""
    die(tr("ended by {signal} before it finished, so there is nothing to hand back.",
           signal=signal.Signals(signum).name))


_progress_line = ""


def progress(text):
    """Show `text` as one line updated in place on a terminal; None clears it.

    Only on a terminal: a script, a log or a Claude session reading stderr
    gets exactly what it got before this existed (DEMO-0053). One short line
    and no colour, because the screen it is read on may be magnified.
    """
    global _progress_line  # noqa: PLW0603 - one line on one terminal
    text = text or ""
    if not sys.stderr.isatty() or text == _progress_line:
        return
    try:
        # \r goes back to the start of the line; \033[K clears the rest.
        sys.stderr.write("\r\033[K" + (f"demoreel: {text}" if text else ""))
        sys.stderr.flush()
    except OSError:
        # A closed terminal, as in note(): nobody is reading, and a line
        # about the recording must not end it (DEMO-0115).
        sys.stderr = open(os.devnull, "w")  # noqa: SIM115 - lives to exit
    _progress_line = text


def die(msg):
    sys.exit(f"demoreel: {msg}")


def note(msg):
    """Tell the caller something on stderr. A note is advice: if the terminal
    has gone (a hung-up run finishing its video), losing it must not fail
    the recording it was about (DEMO-0115)."""
    try:
        progress(None)
        print(f"demoreel: {msg}", file=sys.stderr)
    except OSError:
        # What failed to write is still buffered, and flushing it at exit
        # fails again and turns the exit status to 120. Nobody is reading.
        sys.stderr = open(os.devnull, "w")  # noqa: SIM115 - lives to exit


# Translation: docs/specs/DEMO-0060-translation-catalogs.md is the contract.
# Every message is English in this file and looked up in po/<code>.po, which
# demoreel reads itself. Nothing is looked up until main() chooses a language,
# so a module-level tr() would always be English -- and is refused by the gate.

PLACEHOLDER_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
# A backticked span, a long flag with any =value, or a short flag on its own.
# Only ASCII joins onto a flag: Chinese and Japanese write a flag straight
# against their own letters and punctuation, and it is still its own word.
CODE_TOKEN_RE = re.compile(
    r"`[^`\n]*`|(?<![A-Za-z0-9_-])"
    r"(?:--[a-z][a-z0-9-]*(?:=[^\s`,;)\x80-\U0010ffff]*)?|-[A-Za-z](?![A-Za-z0-9_-]))")
PERCENT_RE = re.compile(r"%%|%\([a-z_]+\)[a-z]|%[a-z]")
RTL_LANGUAGES = ("ar", "fa", "he", "ps", "ur", "yi")
FIRST_STRONG_ISOLATE, POP_DIRECTIONAL_ISOLATE = "\u2068", "\u2069"
# What a catalog's file name may be: ll, ll_CC, either with an @modifier. A
# LANGUAGE entry is not trusted to name a file, or `../x` would reach outside
# po/ (INV-9).
CODE_RE = re.compile(r"[A-Za-z0-9]+(?:_[A-Za-z0-9]+)?(?:@[A-Za-z0-9]+)?")
CATALOG_LIMIT = 1024 * 1024
# C0 controls but newline and tab, DEL, C1 controls, and the bidi embeddings,
# overrides and isolates. A terminal obeys the first three; the last are
# demoreel's own to insert (§ 4.3).
REFUSED_CHARACTERS = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]")
# argparse's own strings that a demoreel user meets, identical in every Python
# the gate runs (3.12 and 3.13). One a new Python rewords is removed, not
# reworded, so the older Python keeps it (§ 6).
ARGPARSE_MESSAGES = (
    "usage: ",
    "options",
    "positional arguments",
    "show this help message and exit",
    "show program's version number and exit",
    "the following arguments are required: %s",
    "unrecognized arguments: %s",
    "invalid choice: %(value)r (choose from %(choices)s)",
    "%(prog)s: error: %(message)s\n",
)

# The chosen catalogs, best first: (code, translations, messages, draft).
_catalogs = []
# The codes of the draft catalogs that have supplied a message in this run.
_drafts_used = set()


class CatalogError(Exception):
    """A catalog outside § 4.3's subset, refused whole."""

    def __init__(self, reason, line=None):
        super().__init__(f"line {line}: {reason}" if line else reason)


def po_string(text, line):
    """One quoted .po string, with § 4.3's four escapes and no other."""
    text = text.strip()
    if len(text) < 2 or text[0] != '"' or text[-1] != '"':
        raise CatalogError("a string is not in double quotes", line)
    out, chars = [], iter(text[1:-1])
    for char in chars:
        if char == '"':
            raise CatalogError("a double quote inside a string is not escaped", line)
        if char == "\\":
            escape = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(next(chars, ""))
            if escape is None:
                raise CatalogError("unknown escape sequence", line)
            out.append(escape)
        else:
            out.append(char)
    return "".join(out)


def read_po(text):
    """The entries of a .po file: [{msgid, plural, forms, fuzzy, line}].

    The grammar is § 4.3's subset; anything outside it refuses the whole file,
    since a feature demoreel does not implement must not be half-read. The
    header is the entry whose msgid is empty. Nothing here judges content.
    """
    entries, entry, field, flags = [], None, None, set()

    def close():
        if entry is None:
            return
        if not entry["forms"]:
            raise CatalogError("a msgid has no msgstr", entry["line"])
        entries.append(entry)

    for number, raw in enumerate(text.split("\n"), 1):
        line = raw.strip()
        if not line or line.startswith("#~"):
            continue
        if line.startswith("#,"):
            flags |= {flag.strip() for flag in line[2:].split(",")}
            continue
        if line.startswith("#"):
            continue
        if line.startswith('"'):
            if field is None:
                raise CatalogError("a string belongs to no keyword", number)
            field[-1] += po_string(line, number)
            continue
        keyword, _, rest = line.partition(" ")
        if keyword == "msgctxt":
            raise CatalogError("msgctxt is not supported", number)
        if keyword == "msgid":
            close()
            entry = {"msgid": [po_string(rest, number)], "plural": None, "forms": [],
                     "fuzzy": "fuzzy" in flags, "line": number}
            field, flags = entry["msgid"], set()
        elif entry is None or (entry["forms"] and keyword == "msgid_plural"):
            raise CatalogError(f"{keyword} comes before its msgid", number)
        elif keyword == "msgid_plural" and entry["plural"] is None:
            entry["plural"] = field = [po_string(rest, number)]
        elif (keyword == "msgstr" and entry["plural"] is None and not entry["forms"]) or (
                keyword == f"msgstr[{len(entry['forms'])}]" and entry["plural"] is not None):
            entry["forms"].append(po_string(rest, number))
            field = entry["forms"]
        else:
            raise CatalogError(f"unexpected {keyword!r}", number)
    close()
    seen = set()
    for entry in entries:
        entry["msgid"] = entry["msgid"][0]
        entry["plural"] = entry["plural"][0] if entry["plural"] else None
        key = (entry["msgid"], entry["plural"])
        if key in seen:
            raise CatalogError("a message is in the file twice", entry["line"])
        seen.add(key)
    return entries


def mo_image(entries):
    """The .mo file GNUTranslations reads, built in memory from (key, value)
    pairs of bytes: a plural's key is msgid NUL msgid_plural, its value the
    forms joined by NUL."""
    import struct

    entries = sorted(entries)
    count = len(entries)
    ids, strs = 28, 28 + 8 * count
    data = bytearray()
    offset = 28 + 16 * count
    id_table, str_table = [], []
    for table, column in ((id_table, 0), (str_table, 1)):
        for entry in entries:
            table += [len(entry[column]), offset + len(data)]
            data += entry[column] + b"\0"
    return (struct.pack("<7I", 0x950412DE, 0, count, ids, strs, 0, 0)
            + struct.pack(f"<{4 * count}I", *id_table, *str_table) + bytes(data))


def load_catalog(path, code):
    """(code, translations, messages, draft) for one catalog, or CatalogError.

    `messages` holds only what may be used: no header, nothing fuzzy, and no
    entry with an empty form, so a lookup there falls through to the next
    catalog. An OSError is the caller's: a catalog that cannot be read is one
    that is not there (§ 6).
    """
    import gettext
    import io

    data = path.read_bytes()
    if len(data) > CATALOG_LIMIT:
        raise CatalogError("larger than 1 MiB")
    if data.startswith(b"\xef\xbb\xbf"):
        raise CatalogError("a byte-order mark", 1)
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError as exc:
        raise CatalogError("not UTF-8", data[:exc.start].count(b"\n") + 1) from None
    entries = read_po(text)
    header = next((e for e in entries if e["msgid"] == "" and e["plural"] is None), None)
    if header is None:
        raise CatalogError("no header")
    for entry in entries:
        if any(REFUSED_CHARACTERS.search(form) for form in entry["forms"]):
            raise CatalogError("a control or text-direction character", entry["line"])
    fields = {}
    for line in header["forms"][0].splitlines():
        name, colon, value = line.partition(":")
        if colon:
            fields[name.strip().lower()] = value.strip()
    charset = re.search(r"charset=([^\s;]+)", fields.get("content-type", ""))
    if not charset or charset[1].lower() not in ("utf-8", "utf8"):
        raise CatalogError("its charset is not UTF-8", header["line"])
    if fields.get("language") != code:
        raise CatalogError(f"its Language is not {code}", header["line"])
    plural = re.fullmatch(r"nplurals\s*=\s*([0-9]+)\s*;\s*plural\s*=\s*([^;]+);?",
                          fields.get("plural-forms", ""))
    if not plural:
        raise CatalogError("it has no Plural-Forms", header["line"])
    pairs = [(e["msgid"].encode() + (b"\0" + e["plural"].encode() if e["plural"] else b""),
              b"\0".join(form.encode() for form in e["forms"]))
             for e in entries if e is header or (not e["fuzzy"] and all(e["forms"]))]
    try:
        book = gettext.GNUTranslations(io.BytesIO(mo_image(pairs)))
        if not all(0 <= book.plural(n) < int(plural[1]) for n in range(1001)):
            raise CatalogError("its plural expression gives a form it does not have",
                               header["line"])
    except (ArithmeticError, LookupError, RecursionError, SyntaxError, TypeError,
            ValueError):
        raise CatalogError("its plural expression cannot be used", header["line"]) from None
    messages = {(e["msgid"], e["plural"]): e["forms"] for e in entries
                if e is not header and not e["fuzzy"] and all(e["forms"])}
    review = fields.get("x-demoreel-review", "")
    return code, book, messages, not re.fullmatch(r"confirmed [0-9a-f]{16}", review)


def chosen_languages(env):
    """The catalog codes to try, best first; [] means English (§ 4.4).

    The C rule comes first, as in GNU gettext: under LC_ALL=C nothing is
    translated whatever LANGUAGE says. Python's gettext.find reads LANGUAGE
    first, which is why it is not used.
    """
    chosen = next((env[k] for k in ("LC_ALL", "LC_MESSAGES", "LANG") if env.get(k)), "")
    if re.split(r"[.@]", chosen)[0] in ("", "C", "POSIX"):
        return []
    wanted = [w for w in env.get("LANGUAGE", "").split(":") if w] or [chosen]
    codes = []
    for name in wanted:
        if re.split(r"[_.@]", name)[0] == "en":
            break  # English asked for: stop here
        base, _, modifier = name.partition("@")
        language, _, country = base.partition(".")[0].partition("_")
        modifier = f"@{modifier}" if modifier else ""
        codes += ([f"{language}_{country}{modifier}", f"{language}{modifier}",
                   f"{language}_{country}", language] if country
                  else [f"{language}{modifier}", language])
    return list(dict.fromkeys(codes))


def _is_utf8(stream):
    try:
        return codecs.lookup(stream.encoding).name == "utf-8"
    except (AttributeError, LookupError, TypeError):
        return False


def use_languages(codes):
    """Load the catalogs for `codes`, best first, in place of any loaded before.

    Nothing under po/ is opened for an empty list, or when stdout or stderr
    cannot carry UTF-8 (§ 4.4). A catalog is looked for in one place only, the
    po/ beside this script once its symlink is followed (§ 4.2). A refused one
    costs its language and one English note, never the run.
    """
    _catalogs.clear()
    _drafts_used.clear()
    here = globals().get("__file__")
    if not codes or not here or not (_is_utf8(sys.stdout) and _is_utf8(sys.stderr)):
        return
    folder = Path(here).resolve().parent / "po"
    for code in codes:
        if not CODE_RE.fullmatch(code):
            continue
        try:
            _catalogs.append(load_catalog(folder / f"{code}.po", code))
        except OSError:
            continue
        except CatalogError as exc:
            note(f"po/{code}.po could not be read ({exc}), so messages are in English.")


def _usable(text, english, plural_form, argparse_formats):
    """Whether a translation may stand in for `english` (§ 4.5, § 4.6)."""
    names, wanted = set(PLACEHOLDER_RE.findall(text)), set(PLACEHOLDER_RE.findall(english))
    if not names <= wanted or not wanted - ({"count"} if plural_form else set()) <= names:
        return False
    if "{" in PLACEHOLDER_RE.sub("", text) or "}" in PLACEHOLDER_RE.sub("", text):
        return False
    if argparse_formats:
        tokens = set(PERCENT_RE.findall(text)) - {"%%"}
        if "%" in PERCENT_RE.sub("", text) or not tokens <= set(PERCENT_RE.findall(english)):
            return False
    return True


def _lookup(singular, plural, count, argparse_formats):
    """(text, right-to-left) from the first catalog holding the message, or
    None for English."""
    for code, book, messages, draft in _catalogs:
        forms = messages.get((singular, plural))
        if not forms:
            continue
        try:
            text = (book.gettext(singular) if plural is None
                    else book.ngettext(singular, plural, count))
        except (ArithmeticError, LookupError, RecursionError, TypeError, ValueError):
            return None
        # A form the catalog lacks comes back as the English: use English.
        english = singular if plural is None else singular + plural
        if text not in forms or not _usable(text, english, plural is not None,
                                            argparse_formats):
            return None
        if draft:
            _drafts_used.add(code)
        return text, re.split(r"[_@]", code)[0] in RTL_LANGUAGES
    return None


def _fill(text, values, marks):
    """Plain named substitution: nothing in a template can reach an attribute
    or an index. With `marks`, each value and each code token is isolated, the
    marks outside a token and never inside it (§ 4.5)."""
    def plain(match):
        return str(values[match[1]])

    def isolated(match):
        return f"{FIRST_STRONG_ISOLATE}{values[match[1]]}{POP_DIRECTIONAL_ISOLATE}"

    if not marks:
        return PLACEHOLDER_RE.sub(plain, text)
    out, at = [], 0
    for token in CODE_TOKEN_RE.finditer(text):
        out += [PLACEHOLDER_RE.sub(isolated, text[at:token.start()]), FIRST_STRONG_ISOLATE,
                PLACEHOLDER_RE.sub(plain, token[0]), POP_DIRECTIONAL_ISOLATE]
        at = token.end()
    out.append(PLACEHOLDER_RE.sub(isolated, text[at:]))
    return "".join(out)


def tr(msgid, **values):
    """The message in the chosen language, its {name}s filled from `values`."""
    found = _lookup(msgid, None, None, argparse_formats=False)
    return _fill(found[0] if found else msgid, values, bool(found and found[1]))


def trn(singular, plural, count, **values):
    """tr for a message whose wording depends on a number, also given as {count}."""
    found = _lookup(singular, plural, count, argparse_formats=False)
    text = found[0] if found else singular if count == 1 else plural
    return _fill(text, {"count": count, **values}, bool(found and found[1]))


def tr_help(msgid, **values):
    """tr for text handed to argparse, which formats it again with `%`: its
    translation may hold `%` only as English does (§ 4.6)."""
    found = _lookup(msgid, None, None, argparse_formats=True)
    return _fill(found[0] if found else msgid, values, bool(found and found[1]))


def _argparse_gettext(message):
    """argparse's `_`: a string in ARGPARSE_MESSAGES translated, any other as
    it is. argparse fills its own values, so they get no isolation marks."""
    found = message in ARGPARSE_MESSAGES and _lookup(message, None, None, True)
    return found[0] if found else message


def _argparse_ngettext(singular, plural, count):
    found = singular in ARGPARSE_MESSAGES and _lookup(singular, plural, count, True)
    return found[0] if found else singular if count == 1 else plural


class Parser(argparse.ArgumentParser):
    """argparse's parser, with the draft line at the end of --help (§ 4.9)."""

    def format_help(self):
        text = super().format_help()
        if _drafts_used:
            text += "\n" + tr_help("This translation is a draft and has not yet been "
                                   "checked by a native speaker.") + "\n"
        return text


def poll(check, timeout, interval=0.05, grow=1.0, ceiling=None):
    """Call check() until it returns something true, and return that.

    None once `timeout` seconds pass without it, or never with timeout=None.
    Every wait for a condition goes through here, so none can sleep past its
    own deadline and tuning an interval is one argument (DEMO-0080). The pipe
    reads wait in select instead, and the recording loop is its own. check() runs
    at least once, and once more at the deadline, however short the timeout.
    `grow` widens the interval after each miss, up to `ceiling`.
    """
    deadline = None if timeout is None else time.monotonic() + timeout
    while True:
        result = check()
        if result:
            return result
        if deadline is None:
            time.sleep(interval)
        else:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return None
            time.sleep(min(interval, remaining))
        interval = interval * grow if ceiling is None else min(interval * grow, ceiling)


def state_dir():
    base = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
    d = base / f"demoreel-{os.getuid()}"
    try:
        d.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        die(tr("could not use {path}: {reason}.\n"
               "  demoreel keeps its run files there. Point XDG_RUNTIME_DIR at a "
               "directory you can write to.", path=d, reason=exc.strerror))
    # A desktop session sets XDG_RUNTIME_DIR, so the /tmp fallback is the
    # unusual path — which is also why it would go unnoticed. The name there is
    # predictable, so another user can create it first and exist_ok would adopt
    # it. lstat, not stat: a symlink planted at that name is followed by both
    # stat and chmod, so checking through it proves nothing.
    st = os.lstat(d)
    if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid():
        die(tr("{path} is not a directory you own, so demoreel will not use it.\n"
               "  On a shared machine another user can create that name first.\n"
               "  Remove it, or point XDG_RUNTIME_DIR at a directory you own.", path=d))
    d.chmod(0o700)
    return d


def alive(pid):
    try:
        os.kill(pid, 0)
    except (ProcessLookupError, PermissionError):
        return False
    return True


def proc_stat(pid, field):
    """One field of /proc/<pid>/stat, numbered as proc(5) numbers them.

    Field 2 is the command name in parentheses and may itself contain spaces
    and parentheses, so the fields are split after the LAST ')' rather than on
    the whole line. Only fields from 3 on can be asked for.
    """
    try:
        line = Path(f"/proc/{pid}/stat").read_text()
    except OSError:
        return None
    try:
        return line[line.rindex(")") + 1:].split()[field - 3]
    except (ValueError, IndexError):
        return None


def proc_starttime(pid):
    """The process's start time, field 22 of /proc/<pid>/stat.

    Unique per pid incarnation, which is what tells a recycled pid apart from
    the process we started.
    """
    return proc_stat(pid, 22)


def is_our_run(entry):
    """True when the process a state file names is still the run that wrote it.

    alive() alone asks only whether SOME process holds that pid. Every run's
    state file outlives it, left for `stop` to read the verdict from -- and
    Linux recycles pids.
    Acting on alive() alone sends SIGUSR1 to whatever inherited the number, and
    the default action for SIGUSR1 is to terminate.

    Comparing the start time recorded when the file was written closes that.
    A race remains in principle -- the process could exit between this check
    and the signal -- but that window is microseconds, against however long a
    stale file has been lying around.
    """
    pid = entry.get("pid")
    if not isinstance(pid, int) or not alive(pid):
        return False
    # A file written without a start time cannot be checked, and an entry that
    # cannot be checked is the one this exists to refuse.
    recorded = entry.get("starttime")
    return recorded is not None and proc_starttime(pid) == recorded


def write_state(statefile, entry):
    """Replace the state file in one step, so `stop` never reads half of it.

    It is written twice per run -- once at start, again when recording begins
    -- and a reader landing mid-write would see invalid JSON and skip the run.
    """
    tmp = statefile.with_suffix(".tmp")
    tmp.write_text(json.dumps(entry))
    os.replace(tmp, statefile)


def claim_name(name):
    """Hold the name for this run's whole life, or die if a live run holds it.

    The lock is what makes the duplicate check atomic (DEMO-0011). Checking the
    state file and writing it later left a gap that two runs started together
    both passed, and the later one's state file hid the earlier from `stop`.
    flock has no such gap, and the kernel drops it when the process dies, even
    to SIGKILL -- so a crashed run cannot hold its name hostage.

    The lock file is never removed. Unlinking it would let a later run lock a
    fresh inode while an earlier one still holds the old, which is two owners.
    Returns the open descriptor, which must stay open until the run ends.
    Children do not inherit it: Popen closes descriptors by default, and an
    app left running must not keep the name.
    """
    fd = os.open(state_dir() / f"{name}.lock", os.O_RDWR | os.O_CREAT, 0o600)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        os.close(fd)
        try:
            pid = json.loads((state_dir() / f"{name}.json").read_text())["pid"]
        except (OSError, ValueError, KeyError, TypeError):
            die(tr("a recording named {name} is already running.\n"
                   "  Use --name to tell this run apart from it.", name=repr(name)))
        die(tr("a recording named {name} is already running (pid {pid}).\n"
               "  Use --name to tell this run apart from it.", name=repr(name), pid=pid))
    return fd


def start_xvfb(width, height, base, logfile):
    """Start Xvfb on a display number it picks itself, behind an auth cookie.

    -displayfd is why two sessions can record at once: the server finds a free
    number and reports it back, so there is no window between "looks free" and
    "is ours" for a second run to lose.

    The cookie is why another local process cannot watch the recording. Measured
    on this machine: without one, a client with no credential at all reads the
    geometry and grabs a frame of whatever is on the display. Socket permissions
    are no answer — Xvfb also listens on an abstract socket, which has none.

    The number has to come first and the cookie is keyed to it, so the order is:
    start with an empty auth file, learn the number, write the cookie for it,
    and SIGHUP the server to re-read. Measured: Xvfb loads the file at its
    first connection, and none is made before the cookie is written, so the
    lock is on from the first. The SIGHUP covers a server that loaded the file
    earlier, and wait_until_locked proves the lock rather than trusting either.

    The server's stderr goes to a file, never to a pipe. It writes there for
    the whole run — every keymap an app loads adds xkbcomp's warnings — and a
    pipe nobody drains fills, then blocks the server mid-write. The display
    freezes, and so does every X call waiting on it: GIMP did this in the gate.
    """
    auth = Path(f"{base}.Xvfb.Xauthority")
    auth.unlink(missing_ok=True)
    auth.touch(mode=0o600)

    read_fd, write_fd = os.pipe()
    os.set_inheritable(write_fd, True)
    with open(logfile, "wb") as log:
        proc = subprocess.Popen(
            ["Xvfb", "-displayfd", str(write_fd), "-screen", "0",
             f"{width}x{height}x24", "-nolisten", "tcp", "-auth", str(auth),
             # Without it the server resets whenever its last client leaves,
             # which puts the pointer back at the centre the moment the
             # xdotool that parked it exits (DEMO-0103). SIGHUP still resets
             # it, which is how the cookie gets read.
             "-noreset"],
            pass_fds=(write_fd,),
            stdout=subprocess.DEVNULL,
            stderr=log,
            # Its own session, so Ctrl+C at a terminal does not reach it. It
            # did: Xvfb went down with the run's own SIGINT, the app with it,
            # and the end-of-run blank check was skipped as if the app had
            # closed itself (DEMO-0054).
            start_new_session=True,
        )
    os.close(write_fd)

    buf = b""
    deadline = time.monotonic() + DISPLAY_READY_TIMEOUT
    while b"\n" not in buf:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            break
        if not select.select([read_fd], [], [], remaining)[0]:
            continue
        chunk = os.read(read_fd, 64)
        if not chunk:
            break
        buf += chunk
    os.close(read_fd)

    number = buf.decode(errors="replace").strip()
    if not number.isdigit():
        proc.kill()
        auth.unlink(missing_ok=True)
        proc.wait()
        stderr = Path(logfile).read_text(errors="replace").strip()
        die(tr("Xvfb did not start, so there is no private display to record.")
            + ("\n  " + tr("It said: {text}", text=stderr) if stderr else "")
            + "\n  " + tr("`demoreel check` tests this start on its own."))

    display = f":{number}"
    cookie = secrets.token_hex(16)
    added = subprocess.run(["xauth", "-f", str(auth), "add",
                            display, "MIT-MAGIC-COOKIE-1", cookie],
                           capture_output=True, text=True, check=False)
    if added.returncode != 0:
        proc.kill()
        auth.unlink(missing_ok=True)
        die(tr("could not write the display's auth cookie, so the display "
               "cannot be made private.")
            + ("\n  " + tr("xauth said: {text}", text=added.stderr.strip())
               if added.stderr.strip() else "")
            + "\n  " + tr("`demoreel check` tests this on its own."))
    proc.send_signal(signal.SIGHUP)  # the server re-reads its auth file on reset

    # Do not report success until the cookie is actually in force. A client
    # holding the cookie connects before the lock as well as after it
    # (measured), so it alone proves nothing (DEMO-0113).
    if wait_until_locked(display, auth):
        return proc, display, auth
    proc.kill()
    auth.unlink(missing_ok=True)
    die(tr("the display did not lock: a client without its auth cookie "
           "could still connect, or none could."))
    return None  # unreachable; die() exits


def wait_until_locked(display, auth):
    """True once a client without the cookie is refused and one with it gets in.

    Both halves are needed. Before the cookie is in force both clients get in,
    so the holder connecting proves nothing; a dead display refuses both, so
    the stranger's refusal alone proves nothing either.
    """
    locked = dict(os.environ, DISPLAY=display, XAUTHORITY=str(auth))
    stranger = dict(os.environ, DISPLAY=display, XAUTHORITY=os.devnull)
    return bool(poll(
        lambda: (xdotool(stranger, "getdisplaygeometry").returncode != 0
                 and xdotool(locked, "getdisplaygeometry").returncode == 0),
        DISPLAY_READY_TIMEOUT, interval=0.1))


def start_gpu_display(width, height, base, logfile):
    """Start a GPU-capable private display: (cage, display, auth, wayland).

    Xvfb is a software X server with no DRI, so an OpenGL or Vulkan app cannot
    render on it at all — it is not slow there, it is absent. A rootful
    Xwayland on a headless `cage` compositor reaches the GPU. Same privacy
    property: cage shows one client and nothing else, and it is a separate
    compositor from the user's session.

    The order is fixed. cage's headless output starts at 1280x720, and an
    Xwayland already running keeps its first size however the output is
    resized later, so cage's one child resizes the output and only then
    becomes Xwayland (DEMO-0111). `xwfb-run` starts the two as one step, with
    no place for the resize, which is why it is not used.

    Nothing is guessed: cage picks its socket name and Xwayland its display
    number, and both come back on one pipe, in that order. -noreset keeps the
    pointer where park_pointer puts it, as on Xvfb (DEMO-0103).

    The cookie goes in after -displayfd reports the number, as on Xvfb, and
    Xwayland re-reads the changed file without being signalled. So the lock is
    proven by a client without the cookie being refused while one holding it
    connects: before the cookie both get in, and a dead display refuses both.
    """
    auth = Path(f"{base}.Xauthority")
    auth.unlink(missing_ok=True)
    auth.touch(mode=0o600)

    read_fd, write_fd = os.pipe()
    os.set_inheritable(write_fd, True)
    inner = (f'printf "%s\\n" "$WAYLAND_DISPLAY" >&{write_fd} && '
             f"wlr-randr --output HEADLESS-1 --custom-mode {width}x{height} && "
             f"exec Xwayland -displayfd {write_fd} -auth {shlex.quote(str(auth))} "
             f"-geometry {width}x{height} -fullscreen -noreset -nolisten tcp")
    with open(logfile, "wb") as log:
        # Its own session, so ending it takes Xwayland with it. The log is a
        # file for the reason start_xvfb gives: a pipe nobody drains blocks.
        proc = subprocess.Popen(
            ["cage", "--", "sh", "-c", inner],
            env=dict(os.environ, WLR_BACKENDS="headless"),
            pass_fds=(write_fd,), stdout=subprocess.DEVNULL, stderr=log,
            start_new_session=True)
    os.close(write_fd)

    buf = b""
    deadline = time.monotonic() + DISPLAY_READY_TIMEOUT
    while buf.count(b"\n") < 2:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            break
        if not select.select([read_fd], [], [], remaining)[0]:
            continue
        chunk = os.read(read_fd, 64)
        if not chunk:
            break
        buf += chunk
    os.close(read_fd)

    lines = buf.decode(errors="replace").split("\n")
    wayland = lines[0].strip()
    number = lines[1].strip() if len(lines) > 1 else ""
    if not wayland or not number.isdigit():
        end_process(proc, group=True)
        auth.unlink(missing_ok=True)
        tail = "\n  ".join(Path(logfile).read_text(errors="replace")
                           .strip().splitlines()[-3:])
        die(tr("the GPU display did not start, so there is nothing to record on.")
            + ("\n  " + tr("The end of its log:") + f"\n  {tail}" if tail else "")
            + "\n  " + tr("`demoreel check` says whether --gpu has what it needs."))

    display = f":{number}"
    added = subprocess.run(["xauth", "-f", str(auth), "add", display,
                            "MIT-MAGIC-COOKIE-1", secrets.token_hex(16)],
                           capture_output=True, text=True, check=False)
    if added.returncode != 0:
        end_process(proc, group=True)
        auth.unlink(missing_ok=True)
        die(tr("could not write the display's auth cookie, so the display "
               "cannot be made private.")
            + ("\n  " + tr("xauth said: {text}", text=added.stderr.strip())
               if added.stderr.strip() else "")
            + "\n  " + tr("`demoreel check` tests this on its own."))

    if wait_until_locked(display, auth):
        return proc, display, auth, wayland
    end_process(proc, group=True)
    auth.unlink(missing_ok=True)
    die(tr("the GPU display did not lock: a client without its auth cookie "
           "could still connect, or none could."))
    return None  # unreachable; die() exits


def xdotool(env, *args, check=False, stdin_text=None):
    """stdin_text feeds `type --file -`, which is how text stays out of the
    process list; every other call leaves stdin alone.

    Every call is bounded (DEMO-0079). A display that stops answering used to
    hang the run for good, because the startup deadline is checked between
    calls and nothing bounded one call.
    """
    timeout = CALL_TIMEOUT + TYPE_SECONDS_PER_CHAR * len(stdin_text or "")
    try:
        return subprocess.run(["xdotool", *[str(a) for a in args]], env=env,
                              input=stdin_text, capture_output=True, text=True,
                              check=check, timeout=timeout)
    except subprocess.TimeoutExpired:
        _display_gone.set()
        die(tr("the private display {display} stopped answering: "
               "`xdotool {command}` did not return within {seconds}s.",
               display=env.get("DISPLAY"), command=args[0], seconds=f"{timeout:g}"))


def window_geometry(env, wid):
    """(x, y, width, height), or None if the window vanished before the query."""
    out = xdotool(env, "getwindowgeometry", "--shell", wid).stdout
    dims = dict(line.split("=", 1) for line in out.split() if "=" in line)
    try:
        return tuple(int(dims[k]) for k in ("X", "Y", "WIDTH", "HEIGHT"))
    except (KeyError, ValueError):
        return None


def window_area(env, wid):
    geometry = window_geometry(env, wid)
    return geometry[2] * geometry[3] if geometry else 0


def window_off_frame(env, window, width, height):
    """Say how the window no longer fills the frame, or return None if it does.

    demoreel sizes the window once, before recording. An app that then sets
    its own size records cropped or off to one side, and the picture is not
    flat, so the blank check passes it (DEMO-0099). This is checked beside
    each blank sample, and it warns rather than fails: a window that moved is
    still a video of the app.
    """
    geometry = window_geometry(env, window)
    if geometry is None or geometry == (0, 0, width, height):
        return None
    x, y, w, h = geometry
    return tr("the app resized its window to {size} at {place}, so it no longer "
              "fills the {frame} frame and part of the video is cropped "
              "or empty.\n  Record at the app's own size with -s {size}.",
              size=f"{w}x{h}", place=f"{x},{y}", frame=f"{width}x{height}")


# A --gpu video whose frames changed less often than this is noted. Measured
# with changed_share on real --gpu recordings on 2026-10-02 (DEMO-0134):
# vkcube 100%; an xterm printing about 11.5 lines a second, which is about
# 39% new frames, 39%.
CHANGED_NOTE_BELOW = 0.7
CHANGED_WINDOW = 10
# "The picture changed", for `motion` and for the note above. The filter
# compares each frame with the last NEW one, not the one before, and counts it
# new when any 8x8 block moved by more than 640.
#
# Below 640, x264's own sharpening of a picture, over the frames after it
# changes, counts as new frames. Measured 2026-09-30 on patterns with a known
# number of new frames, encoded as the recorder encodes: 9 in 90 scored 44 at
# 256 and 9 at 640; 36 in 360 scored 167 and 36; 12 in 120 at 1280x800 scored
# 31 and 12. On the real recordings above, 256 scored that xterm 73%, so the
# note stayed silent on a video that stutters. That is the dangerous way to be
# wrong here. What 640 costs is faint, slow movement: a gradient drifting a
# little each frame scored 360 of 360 at 256 and 145 at 640, noticed only as
# it adds up, so such a video can be noted when it is smooth. The note never
# fails a run, which is why that is the side to err on. Movement with edges in
# it is unaffected: a moving pattern scored 60 of 60 at both, and a real
# recording 282 and 244 of 646.
NEW_FRAME = "mpdecimate=hi=640:lo=640:frac=0"


def changed_share(output, framerate):
    """The share of frames that differ from the one before, over a window from
    the middle of the video; None if it could not be measured.

    On --gpu a moving app that draws few frames records as runs of repeated
    ones, and nothing else notices. It was written when the X side's stale
    picture caused exactly that (DEMO-0109); record --gpu now captures the
    compositor's, so what is left is the app's own rate. A still app repeats
    frames too, which is why this only ever produces a note. One window, not the
    whole video: decoding a long recording to count would add most of its
    length.
    """
    try:
        probe = subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
             "-of", "csv=p=0", str(output)],
            capture_output=True, text=True, timeout=CALL_TIMEOUT, check=True)
        duration = float(probe.stdout.strip())
        window = min(CHANGED_WINDOW, duration)
        count = subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
             "-ss", f"{(duration - window) / 2:.3f}", "-t", f"{window:.3f}",
             "-i", str(output), "-vf", NEW_FRAME,
             "-f", "null", "-",
             "-progress", "pipe:1", "-nostats"],
            capture_output=True, text=True, timeout=6 * CALL_TIMEOUT, check=True)
    except (OSError, ValueError, subprocess.SubprocessError):
        return None
    kept = [ln for ln in count.stdout.splitlines() if ln.startswith("frame=")]
    frames = window * framerate
    if not kept or frames < framerate:
        return None
    return min(1.0, int(kept[-1].split("=", 1)[1]) / frames)


def named_windows(env):
    """The visible windows that carry a title, under either title property.

    `search --name .` reads only the old WM_NAME, and some apps set nothing but
    _NET_WM_NAME -- vkcube among them, so its window was never found and every
    run waited out the whole startup timeout (DEMO-0043). getwindowname reads
    both. The unfiltered list is no substitute: it also holds a nameless
    window the size of the display, which is not the app and would win on
    size. Two xdotool spawns per window, at about 2ms each, measured.
    """
    ids = xdotool(env, "search", "--onlyvisible", "").stdout.split()
    return [wid for wid in ids if xdotool(env, "getwindowname", wid).stdout.strip()]


def wait_for_window(env, app, timeout):
    """Return the id of the app's main window, or None if it never showed one.

    Biggest window wins. An app that opens a modal dialog at startup has two,
    and picking by search order gets the dialog about as often as not — which
    then gets stretched to fill the frame while the real window is left alone
    behind it. Size is the one signal that separates them without knowing
    anything about the app.
    """
    def biggest():
        ids = named_windows(env)
        if ids:
            return max(ids, key=lambda wid: window_area(env, wid))
        if app.poll() is not None:
            if app.returncode == 0:
                # The first line is what happened; the rest is a guess, and is
                # written as one. The hand-over is the common case here, but a
                # command that legitimately does its work and exits looks
                # identical from the outside, and so does one that failed for
                # its own reasons and still returned 0.
                die(tr("the app exited immediately (status 0) without opening a window.\n"
                       "  The likely cause is a single-instance app: it finds a copy already\n"
                       "  running on your real desktop, hands the request over to it and "
                       "exits\n"
                       "  successfully. If a copy is running, close it and try again.\n"
                       "  If not, the command may simply have finished without needing a\n"
                       "  window — in which case there is nothing to record."))
            die(tr("the app exited with status {status} before opening a "
                   "window.\n  Its own output, above or in --app-log, says why. To "
                   "see it on its own, run: {command}",
                   status=app.returncode, command=shlex.join(app.args)))
        return None
    # About 2ms of CPU per xdotool spawn, measured, so a tight poll costs
    # nothing and a loose one costs up to its whole interval in wall time.
    return poll(biggest, timeout, interval=0.05)


def wait_for_size(env, window, area, timeout=2.0):
    """Wait until a resize has landed, rather than sleeping a guessed interval.

    No window manager runs here, so the request is applied directly and this
    normally returns on the first check. The timeout is only for an app that
    refuses the size; recording goes ahead either way, as it always has.
    """
    poll(lambda: window_area(env, window) == area, timeout, interval=0.05)


# Every action verb, with the example a malformed step is told to follow.
ACTION_EXAMPLES = {"wait": "wait 2", "move": "move 400 300",
                   "click": "click 400 300", "type": "type some text",
                   "key": "key Return", "hold": "hold w 3",
                   "drag": "drag 200 300 600 300"}
# How long a drag takes when its step does not say, and how many times a
# second it moves the pointer on the way: often enough to look smooth.
DRAG_SECONDS = 0.5
DRAG_STEPS_PER_SECOND = 60


def parse_step(spec):
    """Read one -a step into (verb, values), or die saying how to write it.

    Every step is read before the display starts (prepare), so a typo in the
    last step fails the run at once rather than after most of a recording.
    None for an empty step, which does nothing.
    """
    # `type` takes the rest of the step exactly, after the verb and one space:
    # it is text to type, not arguments. Splitting it like a shell command
    # dropped its quotes, collapsed its spaces and died on an apostrophe
    # (DEMO-0101). The other verbs take short arguments, and keep the split.
    head, text = re.match(r"(\S*)\s?(.*)\Z", spec.lstrip(), re.DOTALL).groups()
    if head == "type":
        return "type", {"text": text}
    try:
        parts = shlex.split(spec)
    except ValueError as e:
        die(tr("cannot read the step {step}: {reason}.", step=repr(spec), reason=e))
    if not parts:
        return None
    verb, args = parts[0], parts[1:]
    if verb not in ACTION_EXAMPLES:
        die(tr("unknown action {action}. The steps are: {steps}.", action=repr(verb),
               steps=", ".join(repr(e) for e in ACTION_EXAMPLES.values())))
    # A malformed step used to reach float() or args[1] unchecked and end
    # the run with a Python traceback.
    try:
        if verb == "wait":
            return verb, {"seconds": float(args[0])}
        if verb == "hold":
            return verb, {"key": args[0], "seconds": float(args[1])}
        if verb == "drag":
            if len(args) not in (4, 5):
                raise IndexError
            seconds = float(args[4]) if len(args) == 5 else DRAG_SECONDS
            if not 0 <= seconds < math.inf:
                raise ValueError
            return verb, {"from": (int(args[0]), int(args[1])),
                          "to": (int(args[2]), int(args[3])), "seconds": seconds}
        if verb == "move" or (verb == "click" and args):
            return verb, {"at": (str(int(args[0])), str(int(args[1])))}
        if verb == "key" and not args:
            raise IndexError
    except (IndexError, ValueError):
        die(tr("cannot read the step {step}: write it like {example}.",
               step=repr(spec), example=repr(ACTION_EXAMPLES[verb])))
    return verb, {"keys": args, "at": None}


def read_steps(path):
    """The steps in a --steps file, or on stdin for `-`: one per line, as -a
    takes them. A step read this way is never in demoreel's own command line,
    which any local user can read for the whole run (DEMO-0025). A blank line
    and a line starting with `#` are skipped, as in an `edit` script."""
    try:
        text = sys.stdin.read() if path == "-" else Path(
            path).expanduser().read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        die(tr("could not read the steps file {path}: {reason}.",
               path=path, reason=getattr(exc, "strerror", None) or exc))
    return [line for line in text.splitlines()
            if line.strip() and not line.lstrip().startswith("#")]


def run_action(spec, env):
    """One scripted step: wait / move / click / type / key / hold / drag."""
    step = parse_step(spec)
    if step is None:
        return
    verb, v = step
    if verb == "wait":
        _stop.wait(v["seconds"])  # a stop ends the wait, as it ends the run
    elif verb == "move":
        xdotool(env, "mousemove", "--sync", *v["at"])
    elif verb == "click":
        if v["at"]:
            xdotool(env, "mousemove", "--sync", *v["at"])
        xdotool(env, "click", "1")
    elif verb == "type":
        # --file - takes the text on stdin. As an argument it sits in the
        # process list, readable by any other local user, for as long as the
        # typing lasts — and at 60ms a keystroke that is seconds. A demo that
        # types into a login form is the obvious thing to script.
        xdotool(env, "type", "--delay", "60", "--file", "-",
                stdin_text=v["text"])
    elif verb == "key":
        xdotool(env, "key", *v["keys"])
    elif verb == "hold":
        # The keyup runs however the hold ends -- time up, a stop, an error --
        # or the key stays down on the display for the rest of the run.
        xdotool(env, "keydown", v["key"])
        try:
            _stop.wait(v["seconds"])
        finally:
            xdotool(env, "keyup", v["key"])
    elif verb == "drag":
        (x1, y1), (x2, y2) = v["from"], v["to"]
        xdotool(env, "mousemove", "--sync", str(x1), str(y1))
        steps = max(1, round(v["seconds"] * DRAG_STEPS_PER_SECOND))
        started = time.monotonic()
        # The button comes up however the drag ends, as a held key does.
        xdotool(env, "mousedown", "1")
        try:
            for i in range(1, steps + 1):
                # Each move is timed from the start, so a slow xdotool shortens
                # the waits rather than stretching the drag.
                if _stop.wait(max(0, started + v["seconds"] * i / steps
                                  - time.monotonic())):
                    break
                # No --sync: it waits for the pointer to move, and a short
                # drag lands on the same pixel twice.
                xdotool(env, "mousemove", str(round(x1 + (x2 - x1) * i / steps)),
                        str(round(y1 + (y2 - y1) * i / steps)))
        finally:
            xdotool(env, "mouseup", "1")


# How ffmpeg encodes every video demoreel writes with it: the recording on
# Xvfb and every finishing command, so one command's file is the same kind as
# another's. record --gpu's recorder is told the same in its own options.
ENCODER = [
    "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
    # x264's default is a multiple of the cores, and its buffers set most
    # of a recording's memory: about 530 MB peak on twelve cores, about
    # 270 MB at four. Four threads pinned to four cores still encoded a
    # full-frame glxgears at 1600x1000 with no dropped or duplicated
    # frame (DEMO-0073).
    "-threads", "4",
    "-pix_fmt", "yuv420p", "-movflags", "+faststart",
]
# And the sound, for record --audio.
AUDIO_ENCODER = ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2"]


def start_ffmpeg(env, display, width, height, framerate, output, logfile, cursor,
                 monitor=None):
    """Record the display. env matters: the GPU backend's Xwayland wants an
    auth cookie, and ffmpeg is as much a client of that display as xdotool.

    With `monitor`, a sink's monitor, the sound is recorded too, into the part
    file, and finish_audio_video puts it in step with the picture.

    Returns the process, its log, and how many seconds before this returns
    the video's first frame was grabbed."""
    # level+info marks each log line with its level: the info lines carry the
    # first frame's time, and the failure message below leaves them out.
    # -nostats, or the status line fills the log twenty times a second.
    cmd = [
        "ffmpeg", "-hide_banner", "-loglevel", "level+info", "-nostats",
        "-progress", "pipe:1", "-stats_period", "0.05",
        # A deep queue on each input, so neither drops packets while ffmpeg
        # reads the other.
        *(["-thread_queue_size", "1024"] if monitor else []),
        "-f", "x11grab", "-framerate", str(framerate),
        "-draw_mouse", "1" if cursor else "0",
        "-video_size", f"{width}x{height}", "-i", f"{display}.0",
        *(["-thread_queue_size", "1024", "-f", "pulse", "-i", monitor]
          if monitor else []),
        *ENCODER, *(AUDIO_ENCODER if monitor else []),
        "-y", str(partial_video(output) if monitor else output),
    ]
    # Deliberate: the handle is owned by the ffmpeg process and closed in
    # record()'s finally block, so it must outlive this statement.
    log = open(logfile, "wb")  # noqa: SIM115
    spawned = time.time()
    # Its own session, so a terminal's Ctrl+C or hang-up cannot end it before
    # stop_recorder does: a SIGHUP killed it with no index written, and the
    # run printed the path of a file nothing could play (DEMO-0115).
    proc = subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE, stderr=log,
                            start_new_session=True)
    # Return when ffmpeg is actually capturing, not after a guessed interval.
    # The first progress report is that signal: it arrives once the input is
    # open and frames are flowing. A blind sleep in its place was recorded
    # into every video (DEMO-0012). The frame count in those reports is no
    # use as the signal -- it counts ENCODED frames and sits at 0 for most of
    # a second while x264 fills its look-ahead.
    ready = select.select([proc.stdout], [], [], DISPLAY_READY_TIMEOUT)[0]
    if not ready or not proc.stdout.readline():
        proc.kill()
        proc.wait()
        log.close()
        partial_video(output).unlink(missing_ok=True)
        tail = "\n".join(
            line for line in Path(logfile).read_text(errors="replace").splitlines()
            if not line.startswith("[info]")).strip()
        die(tr("ffmpeg could not start recording.")
            + ("\n  " + tr("It said: {text}", text=tail) if tail else "")
            + "\n  " + tr("`demoreel check` tests the programs and the encoder it needs."))
    # The video runs from the first frame grabbed, and the report comes later:
    # 0.15s on an idle machine, 0.4s with two cores loaded, every bit of it
    # added to the video when the countdown started here (DEMO-0169). x11grab
    # stamps frames with the wall clock, and ffmpeg names the first one's time
    # as "start:" when it opens the display, before any report. A time
    # outside this call cannot be that frame, so it is not trusted.
    now = time.time()
    found = re.search(rb"start: (\d+\.\d+)", Path(logfile).read_bytes())
    lag = now - float(found[1]) if found and spawned <= float(found[1]) <= now else 0.0
    # Keep draining the reports, or ffmpeg blocks once the pipe fills.
    threading.Thread(target=proc.stdout.read, daemon=True).start()
    return proc, log, lag


def partial_video(output):
    """Where a recording is written before it is finished -- record --gpu's,
    and an --audio one's: beside the output, on its disk, and not in the
    state directory, which is RAM."""
    return output.with_name(f".{output.stem}.demoreel-part.mp4")


def start_wf_recorder(env, wayland, framerate, output, logfile, monitor=None):
    """Record cage's own output, for record --gpu (DEMO-0111).

    Under a busy 3D app the X side's copy of the picture goes stale, so x11grab
    records a slideshow: 156 new frames of 588 where this got 643 of 643, over
    the same run (DEMO-0109). -D asks for every frame rather than only changed
    ones, without which SIGINT is also ignored while the screen is still, and
    -y stops it prompting before an overwrite. The encoder settings match
    start_ffmpeg's, so the two backends hand back the same kind of file. With
    `monitor` it records the sound as well; whether that track is in step is
    not measured, as wf-recorder would not start here (DEMO-0106 § 13).

    It prints no progress reports, so the start signal is the line announcing
    its output, printed once the encoder has its first frame. The countdown is
    counted from there (DEMO-0012). Unlike ffmpeg it names no time for that
    frame, so start_ffmpeg's correction for a late signal (DEMO-0169) has no
    counterpart here.
    """
    part = partial_video(output)
    cmd = ["wf-recorder", "-D", "-y", "-r", str(framerate),
           "-c", "libx264", "-p", "preset=veryfast", "-p", "crf=23",
           "-x", "yuv420p", "-f", str(part)]
    if monitor:
        # One argument: the device is optional (-a, --audio [=DEVICE]), so a
        # separate one would be read as the next option.
        cmd += [f"--audio={monitor}", "-C", "aac"]
    # Deliberate: closed in record()'s finally block, as start_ffmpeg's is.
    log = open(logfile, "wb")  # noqa: SIM115
    # Unbuffered, or a readline that pulls in several lines leaves the rest in
    # Python's buffer, where select cannot see them: the start line sat there
    # unread until the deadline and the run failed, one run in three.
    proc = subprocess.Popen(cmd, env=dict(env, WAYLAND_DISPLAY=wayland),
                            stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT, bufsize=0,
                            start_new_session=True)  # as ffmpeg's, DEMO-0115
    deadline = time.monotonic() + DISPLAY_READY_TIMEOUT
    while True:
        remaining = deadline - time.monotonic()
        line = (proc.stdout.readline()
                if remaining > 0 and select.select([proc.stdout], [], [], remaining)[0]
                else b"")
        log.write(line)
        if line.startswith(b"Output #0"):
            break
        if not line:
            proc.kill()
            proc.wait()
            log.close()
            part.unlink(missing_ok=True)
            tail = Path(logfile).read_text(errors="replace").strip()
            die(tr("wf-recorder could not start recording.")
                + ("\n  " + tr("It said: {text}", text=tail) if tail else "")
                + "\n  " + tr("`demoreel check` says whether --gpu has what it needs."))
    # Keep copying its output to the log, or it blocks once the pipe fills.
    threading.Thread(target=lambda: log.write(proc.stdout.read()),
                     daemon=True).start()
    return proc, log


def finish_gpu_video(output, logfile):
    """Give record --gpu's file the +faststart the Xvfb path's has.

    wf-recorder has no option for muxer flags, so a stream copy moves the index
    to the front. Nothing is re-encoded. The partial file is kept if the copy
    fails, and the message names it.
    """
    finish_part(output, logfile, ["-i", str(partial_video(output)), "-c", "copy"])


def input_starts(logfile):
    """Each input's wallclock start in ffmpeg's log, by kind: x11grab, pulse."""
    starts, kind = {}, None
    for line in Path(logfile).read_text(errors="replace").splitlines():
        opened = re.search(r"Input #\d+, (\w+), from", line)
        if opened:
            kind = opened[1]
            continue
        found = re.search(r"\bstart: (-?\d+\.\d+)", line)
        if kind and found:
            starts.setdefault(kind, float(found[1]))
    return starts


def finish_audio_video(output, logfile):
    """Put an --audio recording's sound in step with its picture (DEMO-0106).

    ffmpeg starts each stream at zero, but the pulse input opens later than
    x11grab, so the sound came out early (measured: a beep 0.53 s before the
    flash played with it). Each input's wallclock start is in the log, and
    shifting the sound by the gap brought the two to 14 ms apart.

    The sound is then fitted to the picture rather than the picture to the
    sound. The pulse input stops with a third of a second or so still
    buffered, so its track ends early, and -shortest cut that much off the
    picture: a -d 6 run came out 5.67 s long. So the sound is padded with
    silence to the picture's end and trimmed there, and only the sound is
    encoded again. The picture is copied as it was recorded.
    """
    part = partial_video(output)
    if not part.exists():
        return
    starts = input_starts(logfile)
    length = stream_length(part, "v:0")
    if "x11grab" not in starts or "pulse" not in starts or length is None:
        die(tr("could not put the sound in step with the picture: the recorder's "
               "log does not say when each started.\n  What was recorded is kept "
               "in {part}, and the log is {log}.", part=part, log=logfile))
    shift = starts["pulse"] - starts["x11grab"]
    fit = f"{max(0.0, length - shift):.6f}"
    finish_part(output, logfile, [
        "-i", str(part), "-itsoffset", f"{shift:.6f}", "-i", str(part),
        "-map", "0:v", "-map", "1:a", "-c:v", "copy",
        "-af", f"apad=whole_dur={fit},atrim=duration={fit}", *AUDIO_ENCODER])


def stream_length(path, stream):
    """How long `stream` of the file at `path` runs, in seconds; None if
    ffprobe cannot say."""
    try:
        return float(subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", stream,
             "-show_entries", "stream=duration", "-of", "csv=p=0", str(path)],
            capture_output=True, text=True, timeout=CALL_TIMEOUT, check=True).stdout)
    except (subprocess.SubprocessError, ValueError):
        return None


def finish_part(output, logfile, inputs):
    """Write the part file to the output with `inputs`, then remove it."""
    part = partial_video(output)
    if not part.exists():
        return
    copy = subprocess.run(  # noqa: PLW1510 - a failed copy is handled below
        ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
         *inputs, "-movflags", "+faststart", "-y", str(output)],
        capture_output=True, timeout=6 * CALL_TIMEOUT)
    if copy.returncode != 0:
        with open(logfile, "ab") as log:
            log.write(copy.stderr)
        die(tr("could not finish the video: turning the recording into {name}"
               " failed.\n  What was recorded is kept in {part}, and {log}"
               " says why.", name=output.name, part=part, log=logfile))
    part.unlink()


class SampleError(Exception):
    """The display could not be sampled, so whether it is blank is unknown."""


def display_is_blank(env, display, width, height):
    """True if the display is one flat colour, i.e. nothing is drawn on it.

    Silently recording nothing is the worst failure this tool has: valid file,
    exit 0, black picture, every signal saying success. So grab one frame and
    measure how much of it is a single grey level. Measured here: an empty Xvfb
    is 0.99994, and a window as small as 200x100 on a 1280x800 display already
    drops it to 0.98 — so the threshold has room on both sides.

    Raises SampleError when no frame comes back. That is the third answer,
    "could not tell", and it must not collapse into either of the other two:
    reading it as "not blank" is how this guard used to pass everything the
    moment its own measurement broke (DEMO-0033). A grab that does not return
    in time is that answer too (DEMO-0079): the display stopped answering.
    """
    try:
        grab = subprocess.run(  # noqa: PLW1510 - a failed grab is handled below
            ["ffmpeg", "-hide_banner", "-loglevel", "error",
             "-f", "x11grab", "-video_size", f"{width}x{height}",
             "-i", f"{display}.0", "-frames:v", "1",
             "-pix_fmt", "gray", "-f", "rawvideo", "-"],
            env=env, capture_output=True, timeout=CALL_TIMEOUT)
    except subprocess.TimeoutExpired:
        _display_gone.set()
        raise SampleError(tr("the display did not give up a frame within "
                             "{seconds}s; it has stopped answering",
                             seconds=CALL_TIMEOUT)) from None
    if grab.returncode != 0 or len(grab.stdout) != width * height:
        lines = [ln for ln in grab.stderr.decode(errors="replace").splitlines()
                 if ln.strip()]
        raise SampleError("\n  ".join(lines[-3:])
                          or tr("ffmpeg exited {status} with no frame",
                                status=grab.returncode))
    return frame_is_flat(grab.stdout)


def video_is_blank(output, at, width, height):
    """True if the finished video is one flat colour at `at` seconds in, or in
    its last half second when `at` is None.

    display_is_blank reads the X side, and on record --gpu the video comes from
    the compositor instead, so a recorder writing black under a drawn display
    would pass it. This asks the file the same question, at the moments the
    display was asked, with the same test. Raises SampleError, as that does.
    """
    seek = ["-sseof", "-0.5"] if at is None else ["-ss", f"{at:.3f}"]
    try:
        grab = subprocess.run(  # noqa: PLW1510 - a failed read is handled below
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
             *seek, "-i", str(output), "-frames:v", "1",
             "-pix_fmt", "gray", "-f", "rawvideo", "-"],
            capture_output=True, timeout=CALL_TIMEOUT)
    except subprocess.TimeoutExpired:
        raise SampleError(tr("reading a frame of {path} took over {seconds}s",
                             path=output, seconds=CALL_TIMEOUT)) from None
    if grab.returncode != 0 or len(grab.stdout) != width * height:
        lines = [ln for ln in grab.stderr.decode(errors="replace").splitlines()
                 if ln.strip()]
        raise SampleError("\n  ".join(lines[-3:])
                          or tr("no frame of {path} could be read", path=output))
    return frame_is_flat(grab.stdout)


def dominant_fraction(frame):
    """The share of a raw grey frame taken by its commonest byte value.

    One pass per distinct byte value, which looks worse than Counter's single
    pass and is not. Measured on a 1600x1000 frame: on a blank display, one
    distinct value, this form is about five times faster; on a busy screen with
    every value present it is about twice as slow. The case it wins is what
    wait_until_drawn polls against, and the case it loses is sampled once at the
    end of a run. Do not tidy it into Counter.most_common.
    """
    return max(frame.count(v) for v in set(frame)) / len(frame)


def frame_is_flat(frame):
    """True if the frame is one flat colour by BLANK_THRESHOLD's measure.

    The threshold AND its comparator live here together, because splitting them
    is what let a copy drift: a frame measuring exactly the threshold would pass
    one side and fail the other.

    The question is yes or no, so it seldom needs every value counted
    (DEMO-0076). Count one likely candidate, the commonest value in a thin
    sample. Over the threshold: flat. At least the rest (1 - threshold): not
    flat, since no other value can then reach the threshold. Only a sample
    that picked a rare value falls back to dominant_fraction. The answer is
    the same either way; on a busy 1600x1000 frame it takes a fraction of the
    time, which the end-of-run check waits on.
    """
    sample = frame[::997]
    if sample:
        candidate = max(set(sample), key=sample.count)
        share = frame.count(candidate) / len(frame)
        if share > BLANK_THRESHOLD:
            return True
        if share >= 1 - BLANK_THRESHOLD:
            return False
    return dominant_fraction(frame) > BLANK_THRESHOLD


def wait_until_drawn(env, display, width, height, timeout):
    """Hold the recorder until the app has actually drawn something.

    A GPU app can spend seconds on a black screen while it builds acceleration
    structures, and recording that means the caller trims the head off
    afterwards, which costs a second encode. Same uniform-frame test as the
    end-of-run check, used here as a gate rather than as an assertion.
    """
    # Back the poll off as the wait lengthens. Each sample spawns an ffmpeg and
    # moves a whole frame across a process boundary -- about 112ms of CPU on a
    # 1600x1000 display -- so a fixed quarter-second poll burns most of a core.
    # --settle exists for apps that spend seconds building GPU acceleration
    # structures, which means the sampler was competing for the machine with
    # the very work it was waiting for.
    #
    # Starting tighter than the old fixed interval and widening to a ceiling
    # answers a fast-drawing app sooner and a slow one far more cheaply. The
    # ceiling is what it costs: up to that long between the app drawing and the
    # recorder noticing. That is the right trade here, because the alternative
    # is taking CPU from the app to find out a second earlier.
    #
    # A smaller frame is NOT the lever, and was measured: decimating to a
    # hundredth of the data gives an all but identical answer and saves about
    # 10%, because the cost is the process spawn and the capture, not the data.
    return bool(poll(lambda: not display_is_blank(env, display, width, height),
                     timeout, interval=0.1, grow=1.5, ceiling=1.0))


def stop_recorder(proc):
    """Ask the recorder to finish so the file gets its index written; a killed
    recorder leaves an mp4 that most players refuse to open. ffmpeg takes `q`
    on its stdin, wf-recorder takes SIGINT."""
    if proc.poll() is not None:
        return
    if _display_gone.is_set():  # no video to save, and it cannot finish
        proc.kill()
        proc.wait()
        return
    try:
        if proc.args[0] == "wf-recorder":
            proc.send_signal(signal.SIGINT)
        else:
            proc.stdin.write(b"q")
            proc.stdin.flush()
            proc.stdin.close()
    except OSError:
        proc.terminate()
    try:
        proc.wait(timeout=15)
    except subprocess.TimeoutExpired:
        proc.terminate()
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()


def end_process(proc, group=False):
    if proc is None or proc.poll() is not None:
        return
    try:
        if group:
            os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
        else:
            proc.terminate()
        proc.wait(timeout=5)
    except (subprocess.TimeoutExpired, ProcessLookupError, PermissionError):
        try:
            if group:
                os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
            else:
                proc.kill()
        except (ProcessLookupError, PermissionError):
            pass


# Every record and shot gives its app a sound output of its own, a null sink
# that nothing plays, so the app's sound never reaches the speakers; --audio
# records that sink's monitor (DEMO-0105, DEMO-0106). The name carries the
# run's pid, which is how a sink left by a killed run is told from a live one's.
SINK_PREFIX = f"demoreel-{os.getuid()}-"
# volumedetect's loudest moment at or below this is a silent track. A null
# sink with nothing playing records digital silence, far below it.
SILENT_DB = -60.0


class SoundUnavailable(Exception):
    """No private sound output could be made; the message says why."""


def pactl(*args):
    """Run pactl and return what it printed; SoundUnavailable if it failed.
    It talks to PulseAudio and to PipeWire's PulseAudio server alike."""
    if shutil.which("pactl") is None:
        raise SoundUnavailable(tr("pactl is not installed."))
    try:
        done = subprocess.run(["pactl", *map(str, args)], capture_output=True,
                              text=True, timeout=CALL_TIMEOUT, check=False)
    except subprocess.TimeoutExpired:
        raise SoundUnavailable(tr("pactl did not answer within {seconds}s.",
                                  seconds=CALL_TIMEOUT)) from None
    except OSError as exc:
        raise SoundUnavailable(exc.strerror) from None
    if done.returncode != 0:
        said = (done.stderr or done.stdout).strip() or f"exit {done.returncode}"
        raise SoundUnavailable(tr("pactl failed: {text}", text=said))
    return done.stdout


class Sound:
    """A run's private sink: the name the app is sent to, and the module index
    that unloads it. Only the run that loaded it unloads it, in its finally;
    `stop` never does, and a sink left behind is found by name alone."""

    def __init__(self, sink, index):
        self.sink = sink
        self.index = index

    def env(self):
        # Both. With PULSE_SINK alone, aplay's tone went through PipeWire's
        # ALSA plugin to the speakers; PIPEWIRE_NODE catches it and pw-play
        # (measured 2026-10-10). A Flatpak passes both into its sandbox.
        return {"PULSE_SINK": self.sink, "PIPEWIRE_NODE": self.sink}

    def unload(self):
        """Unload the sink; a second call does nothing."""
        if self.index is not None:
            with contextlib.suppress(SoundUnavailable):
                pactl("unload-module", self.index)
            self.index = None


def sweep_sinks():
    """Unload every sink a dead run of this user left behind.

    A run killed with SIGKILL runs no finally, and its sink stays loaded
    (measured). Only names starting with this user's prefix are touched, and
    only those whose pid is gone. A pid reused by another process keeps its
    sink until that process ends: a sink nothing plays to costs nothing, and
    unloading a live run's would cut its sound off.
    """
    for line in pactl("list", "short", "modules").splitlines():
        fields = line.split("\t")
        if len(fields) < 3 or fields[1] != "module-null-sink":
            continue
        found = re.search(rf"(?:^|\s)sink_name={re.escape(SINK_PREFIX)}(\d+)-",
                          fields[2])
        if found and not alive(int(found[1])):
            with contextlib.suppress(SoundUnavailable):
                pactl("unload-module", fields[0])


def load_sink(name):
    """Load this run's private sink and prove it is the only one of its name.

    The server does not make sink names unique: two loads of one name both
    succeeded (measured). So the name is not trusted to be free beforehand;
    it is checked after loading, as claim_name checks a run name.
    """
    sweep_sinks()
    sink = f"{SINK_PREFIX}{os.getpid()}-{secrets.token_hex(8)}"
    label = re.sub(r"[^A-Za-z0-9._-]", "_", name)
    sound = Sound(sink, pactl(
        "load-module", "module-null-sink", f"sink_name={sink}",
        f"sink_properties=device.description=demoreel-{label}").strip())
    try:
        names = [line.split("\t")[1]
                 for line in pactl("list", "short", "sinks").splitlines()
                 if "\t" in line]
    except SoundUnavailable:
        sound.unload()
        raise
    if names.count(sink) != 1:
        sound.unload()
        raise SoundUnavailable(tr("the sound server does not list exactly one "
                                  "output named {sink}.", sink=sink))
    return sound


def private_sound(audio, name):
    """The run's private sink, or None where none can be made.

    Without --audio that is a note, and the app's sound may be heard. With it
    the run ends here, before the app starts.
    """
    try:
        return load_sink(name)
    except SoundUnavailable as exc:
        if audio:
            die(tr("--audio needs a private sound output, and none could be "
                   "made.\n  {reason}", reason=exc))
        note(tr("no private sound output could be made, so the app's sound may "
                "play on your speakers.\n  {reason}", reason=exc))
        return None


def has_pulse_input():
    """Whether this ffmpeg can read from a sound server; True if it cannot
    tell, as has_libx264 does."""
    try:
        devices = subprocess.run(["ffmpeg", "-hide_banner", "-devices"],
                                 capture_output=True, text=True,
                                 timeout=CALL_TIMEOUT, check=False).stdout
    except (OSError, subprocess.TimeoutExpired):
        return True
    return re.search(r"^ D\S* +pulse\b", devices, re.MULTILINE) is not None


def loudest(output):
    """The loudest moment of the video's sound in dB; None if it cannot tell."""
    try:
        done = subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-i", str(output), "-map", "0:a",
             "-af", "volumedetect", "-f", "null", "-"],
            capture_output=True, text=True, timeout=6 * CALL_TIMEOUT, check=False)
    except (OSError, subprocess.TimeoutExpired):
        return None
    found = re.search(r"max_volume: (-?[\d.]+|-inf) dB", done.stderr)
    return float(found[1]) if found else None


FLATPAK_FLAGS = ("--socket=x11", "--nosocket=wayland", "--filesystem=/tmp/.X11-unix")


def warn_if_flatpak_underflagged(command):
    """Say up front when a Flatpak target is missing the flags it needs.

    Without them the app renders on the user's real compositor, the virtual
    display stays empty, and the run fails the blank check with a message
    listing two possible causes. We can tell which one it is before spending
    the recording on it.

    A warning, not an error: a manifest with no wayland socket does not need
    --nosocket=wayland, and refusing would break an invocation that works. This
    reads the caller's own command line and knows nothing about any
    application, which is what keeps it out of per-app profile territory.
    """
    if Path(command[0]).name != "flatpak" or "run" not in command:
        return
    missing = [f for f in FLATPAK_FLAGS
               if not any(arg.startswith(f) for arg in command)]
    if missing:
        note(tr("this is a Flatpak target and the command is missing: {flags}",
                flags=", ".join(missing)))
        # One sentence over two lines, each a note of its own. Splitting it is
        # layout: nothing reads a meaning out of the parts.
        for line in tr("A missing one can send it to your real compositor instead, "
                       "leaving this\nrecording empty. README.md says what each "
                       "flag is for.").split("\n"):
            note(f"  {line}")


# Which package gives each program, per package manager, looked up in each
# distro's own package index on 2026-09-27 (DEMO-0051): openSUSE from rpm on
# this machine, the rest with apt-file, dnf repoquery and pacman -F in their
# own containers. pactl's, for --audio, the same way on 2026-10-10
# (DEMO-0106). Knowledge about distros, not about apps.
PACKAGES = {
    "zypper": ("sudo zypper install", {
        "ffmpeg": "ffmpeg", "ffprobe": "ffmpeg", "xdotool": "xdotool",
        "xauth": "xauth", "Xvfb": "xorg-x11-server-Xvfb", "cage": "cage",
        "Xwayland": "xwayland", "wlr-randr": "wlr-randr",
        "wf-recorder": "wf-recorder", "pactl": "pulseaudio-utils"}),
    "apt": ("sudo apt install", {
        "ffmpeg": "ffmpeg", "ffprobe": "ffmpeg", "xdotool": "xdotool",
        "xauth": "xauth", "Xvfb": "xvfb", "cage": "cage",
        "Xwayland": "xwayland", "wlr-randr": "wlr-randr",
        "wf-recorder": "wf-recorder", "pactl": "pulseaudio-utils"}),
    "dnf": ("sudo dnf install", {
        "ffmpeg": "ffmpeg", "ffprobe": "ffmpeg", "xdotool": "xdotool",
        "xauth": "xorg-x11-xauth", "Xvfb": "xorg-x11-server-Xvfb",
        "cage": "cage", "Xwayland": "xorg-x11-server-Xwayland",
        "wlr-randr": "wlr-randr", "wf-recorder": "wf-recorder", "pactl": "pulseaudio-utils"}),
    "pacman": ("sudo pacman -S", {
        "ffmpeg": "ffmpeg", "ffprobe": "ffmpeg", "xdotool": "xdotool",
        "xauth": "xorg-xauth", "Xvfb": "xorg-server-xvfb", "cage": "cage",
        "Xwayland": "xorg-xwayland", "wlr-randr": "wlr-randr",
        "wf-recorder": "wf-recorder", "pactl": "libpulse"}),
}
# /etc/os-release ID or ID_LIKE -> package manager.
DISTRO_FAMILIES = {"opensuse": "zypper", "suse": "zypper", "debian": "apt",
                   "ubuntu": "apt", "fedora": "dnf", "arch": "pacman"}
# Measured in containers the same day: the distros' own ffmpeg on openSUSE
# (ffmpeg-8) and Fedora (ffmpeg-free) has no libx264 encoder; Packman's and
# RPM Fusion's do (DEMO-0117). x264_advice says what to do about it.
X264_FAMILIES = ("zypper", "dnf")


def x264_advice(family):
    """How to get an ffmpeg with libx264 on this distro, as lines to append."""
    if family == "zypper":
        return "\n  " + tr("openSUSE's own ffmpeg leaves it out. Install ffmpeg from the "
                           "Packman repository,\n  which has it.")
    if family == "dnf":
        return ("\n  " + tr("Fedora's ffmpeg-free leaves it out. Enable RPM Fusion, then:")
                + "\n    sudo dnf swap ffmpeg-free ffmpeg --allowerasing")
    return "\n  " + tr("Install a build of ffmpeg that includes libx264.")


def packman_advice(library, family):
    """On openSUSE, how to take the libavcodec at `library` from Packman, whose
    build has libx264, as lines to append; "" elsewhere (DEMO-0174). The
    package is named from the file: libavcodec.so.62 is libavcodec62."""
    soname = re.match(r"(lib[a-z]+)\.so\.(\d+)$", Path(library).name)
    if family != "zypper" or soname is None:
        return ""
    return ("\n  " + tr("Packman's build of that library has libx264. Install it with:")
            + "\n    sudo zypper install --from packman --allow-vendor-change "
            + soname[1] + soname[2])


def distro_family():
    """The package manager this distro uses, from /etc/os-release; None if
    it is not one the table knows."""
    try:
        lines = Path("/etc/os-release").read_text().splitlines()
    except OSError:
        return None
    fields = dict(line.split("=", 1) for line in lines if "=" in line)
    ids = [fields.get("ID", ""), *fields.get("ID_LIKE", "").split()]
    for name in (i.strip('"') for i in ids):
        family = DISTRO_FAMILIES.get(name)
        if family:
            return family
    return None


def install_hint(programs):
    """The command that installs `programs` here, or "" on an unknown distro."""
    family = distro_family()
    if family is None:
        return ""
    command, names = PACKAGES[family]
    packages = list(dict.fromkeys(names[p] for p in programs if p in names))
    hint = "\n  " + tr("Install with: {command}",
                       command=f"{command} {' '.join(packages)}")
    if "ffmpeg" in packages and family in X264_FAMILIES:
        hint += x264_advice(family)
    return hint


def has_libx264():
    try:
        encoders = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
                                  capture_output=True, text=True,
                                  timeout=CALL_TIMEOUT, check=False).stdout
    except subprocess.TimeoutExpired:
        return True  # cannot tell; let the recording itself say what is wrong
    return " libx264 " in encoders


def linked_library(program, stem):
    """The file `program` loads for the library named `stem` (such as
    "libavcodec.so"), from ldd; None if it cannot tell."""
    path = shutil.which(program)
    if path is None:
        return None
    try:
        listing = subprocess.run(["ldd", path], capture_output=True, text=True,
                                 timeout=CALL_TIMEOUT, check=False).stdout
    except (OSError, subprocess.TimeoutExpired):
        return None
    found = re.search(rf"^\s*{re.escape(stem)}\S* => (/\S+)", listing, re.MULTILINE)
    return found[1] if found else None


def start_failure(program):
    """What `program --version` printed if it failed, such as a library it
    cannot load, or its exit status if it printed nothing; None if it ran or
    if it cannot tell (DEMO-0176)."""
    try:
        done = subprocess.run([program, "--version"], capture_output=True, text=True,
                              timeout=CALL_TIMEOUT, check=False)
    except (OSError, subprocess.TimeoutExpired):
        return None
    if done.returncode == 0:
        return None
    return (done.stderr or done.stdout).strip() or f"exit {done.returncode}"


def has_encoder(library, encoder):
    """Whether the libavcodec at `library` has `encoder`. wf-recorder loads
    libavcodec itself rather than running ffmpeg, so the ffmpeg program having
    libx264 says nothing about it (DEMO-0171). True if it cannot tell, as
    has_libx264 does."""
    try:
        find = ctypes.CDLL(library).avcodec_find_encoder_by_name
    except (OSError, AttributeError):
        return True
    find.restype = ctypes.c_void_p
    find.argtypes = [ctypes.c_char_p]
    return find(encoder.encode()) is not None


def required_programs(gpu, recording):
    """What a run needs installed, for its backend and for record or shot."""
    required = ["ffmpeg", "xdotool", "xauth"] + (["ffprobe"] if recording else [])
    if gpu:
        return required + ["cage", "Xwayland", "wlr-randr"] + (
            ["wf-recorder"] if recording else [])
    return [*required, "Xvfb"]


def prepare(a, suffix):
    """What `record` and `shot` both check before starting anything: the tools,
    the app command, the size, and where the output and the app log go."""
    recording = suffix == ".mp4"
    missing = [t for t in required_programs(a.gpu, recording)
               if shutil.which(t) is None]
    if missing:
        die(tr("missing required program(s): {programs}", programs=", ".join(missing))
            + install_hint(missing))
    if recording and not has_libx264():
        die(tr("this ffmpeg cannot encode H.264 with libx264, which every "
               "recording uses.") + x264_advice(distro_family()))
    if getattr(a, "audio", False):
        if shutil.which("pactl") is None:
            die(tr("missing required program(s): {programs}", programs="pactl")
                + install_hint(["pactl"]))
        if not has_pulse_input():
            die(tr("--audio needs an ffmpeg that can read from a sound server, "
                   "and this one has no pulse input."))
    if recording and a.gpu and a.cursor:
        die(tr("--cursor is not available with record --gpu: that backend records "
               "the compositor's picture, which never has the pointer in it."))

    if a.steps is not None:
        if a.action:
            die(tr("give the steps with -a or with --steps, not both."))
        a.action = read_steps(a.steps)
    for spec in a.action:
        parse_step(spec)  # every step is readable before anything starts

    command = list(a.command)
    if command and command[0] == "--":
        command.pop(0)
    if not command:
        die(tr("give the app to run after `--`, e.g. {example}",
               example=f"demoreel {a.cmd} -- xclock"))
    warn_if_flatpak_underflagged(command)

    match = SIZE_RE.match(a.size)
    if not match:
        die(tr("--size takes a width and a height in pixels, like 1600x1000, "
               "not {size}.", size=repr(a.size)))
    width, height = int(match[1]), int(match[2])
    if suffix == ".mp4" and (width % 2 or height % 2):
        die(tr("--size needs even numbers for a video, since H.264 cannot encode "
               "an odd width or height.\n  Try {size}.",
               size=f"{width + width % 2}x{height + height % 2}"))

    if a.output:
        output = own_path(a.output, "output")
    else:
        stem = re.sub(r"[^A-Za-z0-9._-]", "_", Path(command[0]).name) or "demo"
        output = Path.cwd() / f"{stem}-{time.strftime('%Y%m%d-%H%M%S')}{suffix}"
    output.parent.mkdir(parents=True, exist_ok=True)

    applog = own_path(a.app_log, "app log") if a.app_log else None
    if applog:
        applog.parent.mkdir(parents=True, exist_ok=True)
    return command, width, height, output, applog


def own_path(raw, what):
    """Resolve a path demoreel will write, refusing a symlink someone else made.

    resolve() follows the link here, in demoreel, so the writer is handed its
    target and the kernel's fs.protected_symlinks never gets to refuse it.
    Measured: a link at the -o path had its target overwritten, exit 0. So
    another local user could plant /tmp/demo.mp4 pointing at one of your files
    and have your run overwrite it (DEMO-0082). A link you made yourself is
    yours to follow. A link planted after this check is opened by the writer,
    and there the kernel's protection applies. `what` is "output" or "app log".
    """
    path = Path(os.path.abspath(Path(raw).expanduser()))
    if path.is_symlink() and path.lstat().st_uid != os.getuid():
        if what == "app log":
            die(tr("the app log {path} is a symbolic link another user made, so "
                   "demoreel\n  will not write through it: it could point at a file "
                   "of yours.\n  Choose another path, or remove the link if it is "
                   "not meant to be there.", path=path))
        die(tr("the output {path} is a symbolic link another user made, so demoreel\n"
               "  will not write through it: it could point at a file of yours.\n"
               "  Choose another path, or remove the link if it is not meant to be "
               "there.", path=path))
    return path.resolve()


def park_pointer(env, width, height):
    """Put the pointer in the bottom-right corner. A fresh display starts it
    at the centre, where it lights up whatever the app draws under it before
    any step runs, drawn pointer or not (DEMO-0103)."""
    xdotool(env, "mousemove", width - 1, height - 1)


def launch(a, command, width, height, base, displaylog, appout, sound):
    """Start the private display and the app on it, sent to `sound`'s sink.

    Returns (server, app, display, auth, env, wayland). server is Xvfb, or on
    --gpu the cage that holds Xwayland, and ends with end_server. wayland is
    cage's socket name on --gpu, which only the recorder uses, and None on
    Xvfb. Both backends start the app afterwards, so the pointer is parked
    before the app first sees it. Nothing is left running if the app cannot
    be started, the sink included.
    """
    if a.gpu:
        server, display, auth, wayland = start_gpu_display(width, height, base,
                                                           displaylog)
    else:
        server, display, auth = start_xvfb(width, height, base, displaylog)
        wayland = None
    env = dict(os.environ, DISPLAY=display, WAYLAND_DISPLAY=NO_WAYLAND,
               XAUTHORITY=str(auth), **(sound.env() if sound else {}))
    park_pointer(env, width, height)
    try:
        app = subprocess.Popen(command, env=env, start_new_session=True,
                               stdout=appout, stderr=subprocess.STDOUT)
    except (FileNotFoundError, PermissionError) as exc:
        if sound:
            sound.unload()
        end_server(server)
        auth.unlink(missing_ok=True)
        die(tr("could not run {command}: {reason}.\n"
               "  Check that the command after `--` is installed and spelled as you "
               "would type it.", command=repr(command[0]), reason=exc.strerror))
    return server, app, display, auth, env, wayland


def end_server(server):
    """End the display. Both servers run in a session of their own, and the
    group goes, so on --gpu Xwayland goes with cage."""
    if server is None or server.poll() is not None:
        return
    if _display_gone.is_set():  # a hung server may not act on SIGTERM
        with contextlib.suppress(ProcessLookupError, PermissionError):
            os.killpg(os.getpgid(server.pid), signal.SIGKILL)
        server.wait()
        return
    end_process(server, group=True)


def place_window(env, app, a, width, height, picture):
    """Find the app's window and make it fill the frame; None if none appeared.
    `picture` is True for a shot, False for a recording."""
    window = wait_for_window(env, app, a.startup_timeout)
    if window is None and picture:
        note(tr("no window appeared within {seconds}s; taking the picture anyway.\n"
                "  If the app is slow to start, give it longer with --startup-timeout.",
                seconds=a.startup_timeout))
    elif window is None:
        note(tr("no window appeared within {seconds}s; recording anyway.\n"
                "  If the app is slow to start, give it longer with --startup-timeout.",
                seconds=a.startup_timeout))
    else:
        fill_frame(env, window, width, height)
    return window


def fill_frame(env, window, width, height):
    """Move the window to the corner and size it to the frame."""
    # No window manager runs here, so no windowactivate: the only
    # window already has focus.
    xdotool(env, "windowmove", window, 0, 0)
    xdotool(env, "windowsize", window, width, height)
    wait_for_size(env, window, width * height)


def blank_causes(gpu):
    """What usually made the display blank, for the backend that ran. On
    --gpu the first Xvfb cause cannot apply, and telling that run to use
    --gpu was advice to do what it had just done (DEMO-0110)."""
    first = (tr("It had not drawn yet. A GPU app can spend seconds on a black\n"
                "    screen while it starts: --settle waits for the first picture.")
             if gpu else
             tr("It needs the GPU, and Xvfb has none. An OpenGL or Vulkan app\n"
                "    cannot render on it at all: record it with --gpu instead."))
    return ("  " + tr("The app ran but drew nothing here. Two causes account for most "
                      "of it:")
            + "\n  - " + first
            + "\n  - " + tr("It rendered somewhere else. A Flatpak needs "
                            "--nosocket=wayland on\n    its command line; see README.md."))


class Checks:
    """What the display samples found during a run, read after teardown.

    blank: when the display was found blank, "halfway" or "at the end".
    unsampled: why a sample could not be taken, which fails the run too.
    sampled: when each display sample was taken, as (when, seconds into the
    video). record --gpu asks the finished file the same question at the same
    moments, so the skips that decided whether a sample was taken carry over.
    None means the end of the video.
    moved: how the window stopped filling the frame, said once.
    put_back: whether the window was sized to the frame again mid-run.
    """

    def __init__(self):
        self.blank = None
        self.unsampled = None
        self.sampled = []
        self.moved = None
        self.put_back = False

    def failed(self):
        return self.blank is not None or self.unsampled is not None

    def sample(self, when, at, env, display, width, height):
        """Sample the display now, and record what was found."""
        self.sampled.append((when, at))
        try:
            if display_is_blank(env, display, width, height):
                self.blank = when
        except SampleError as exc:
            self.unsampled = str(exc)

    def look_at_window(self, env, window, width, height, at=None):
        """Warn once if the window no longer fills the frame. `at` is how far
        into the video this is, or None once recording has ended. While it is
        recording the size is put back, once (DEMO-0121): an app that takes
        its own size again is warned about, so a resize fight cannot loop."""
        if self.moved is not None or window is None:
            return
        moved = window_off_frame(env, window, width, height)
        if moved and at is not None and not self.put_back:
            self.put_back = True
            fill_frame(env, window, width, height)
            moved = window_off_frame(env, window, width, height)
            if moved is None:
                note(tr("the app resized its window {seconds}s into the video, "
                        "and demoreel put it back to fill the {frame} frame.\n"
                        "  The video before then may be cropped or have an "
                        "empty band.", seconds=f"{at:.1f}",
                        frame=f"{width}x{height}"))
                return
        self.moved = moved
        if moved:
            note(moved)


def settle(a, env, display, width, height):
    """--settle: wait for the app's first picture before recording."""
    try:
        drawn = not a.settle or wait_until_drawn(env, display, width,
                                                 height, a.settle)
    except SampleError as exc:
        die(tr("--settle could not sample the display, so it cannot tell "
               "when the app has drawn.") + f"\n  {exc}")
    if not drawn:
        note(tr("nothing was drawn within {seconds}s; recording anyway.\n"
                "  If the app takes longer to show its first picture, raise --settle.",
                seconds=a.settle))


def start_recorder(a, env, display, wayland, width, height, output, logpath,
                   sound):
    """The recorder, its log, and how long ago its first frame was taken."""
    monitor = f"{sound.sink}.monitor" if a.audio else None
    if a.gpu:
        return (*start_wf_recorder(env, wayland, a.framerate, output, logpath,
                                   monitor), 0.0)
    return start_ffmpeg(env, display, width, height, a.framerate, output,
                        logpath, a.cursor, monitor)


def run_countdown(a, env, app, window, display, recorder, width, height,
                  logpath, started, checks):
    """Play the scripted actions, then record until the run should end.

    It ends at the -d deadline, on a stop request, when the recorder or the
    app goes away, or when the halfway sample finds the display blank.
    """
    for action in a.action:
        if _stop.is_set():
            break
        run_action(action, env)

    # Counted from when the steps finish, or with no steps from the first
    # frame, which `started` is (DEMO-0169).
    begin = time.monotonic() if a.action else started
    deadline = begin + a.duration if a.duration else None
    # A second look, halfway through the countdown (DEMO-0008). The end
    # sample alone passes an app that draws only in the final moments,
    # which is a video mostly empty. Blank at halfway means at least half
    # of the countdown is empty, and the run fails there rather than
    # recording the rest. A -d 0 run has no halfway point.
    halfway = deadline - a.duration / 2 if deadline else None
    try:
        while not _stop.is_set():
            now = time.monotonic()
            if deadline and now >= deadline:
                break
            if halfway and now >= halfway:
                halfway = None
                if app.poll() is None:
                    checks.look_at_window(env, window, width, height,
                                          now - started)
                    checks.sample("halfway", now - started, env, display,
                                  width, height)
                    if checks.failed():
                        break
            if recorder.poll() is not None:
                note(tr("the recorder stopped early; see {log}", log=logpath))
                break
            if deadline:
                progress(tr("recording, {seconds}s left",
                            seconds=math.ceil(deadline - now)))
            else:
                seconds = int(now - started)
                # translators: {time} is the time recorded so far, minutes:seconds, such as 1:05.
                progress(tr("recording for {time} (Ctrl+C to finish)",
                            time=f"{seconds // 60}:{seconds % 60:02d}"))
            if app.poll() is not None:
                note(tr("the app closed; ending the recording."))
                break
            # Never wait past the deadline: a fixed interval here added up to
            # its own length to every video. A stop ends the wait at once.
            _stop.wait(0.2 if deadline is None
                       else max(0.0, min(0.2, deadline - time.monotonic())))
    finally:
        progress(None)  # the line goes before anything else is said


def check_at_end(env, app, window, display, width, height, checks):
    """The end-of-run look, taken after the recorder stops.

    Stop recording before sampling. The display does not change when the
    recorder stops, and sampling first put a third of a second of overshoot
    into every video (DEMO-0012). Only meaningful while the app is still up:
    one that closed itself mid-run leaves an empty display behind, and that
    video is fine.
    """
    if app.poll() is not None:
        return
    checks.look_at_window(env, window, width, height)
    if not checks.failed():
        checks.sample("at the end", None, env, display, width, height)


def vouch_for(a, output, logpath, width, height, checks):
    """Fail the run unless the finished video is one it can stand behind."""
    if not output.exists() or output.stat().st_size == 0:
        die(tr("no video was written to {output}: the recorder produced nothing."
               "\n  Its log, {log}, says why.", output=output, log=logpath))
    unreadable = video_error(output)
    if unreadable:
        die(tr("the video cannot be played: {output}", output=output)
            + f"\n  {unreadable}\n  "
            + tr("The recorder most likely stopped before it finished the file. "
                 "See {log}", log=logpath))
    if checks.blank == "halfway":
        die(tr("the virtual display was blank halfway, so at least half of it is a "
               "flat colour: {output}", output=output) + "\n" + blank_causes(a.gpu))
    if checks.blank:
        die(tr("the virtual display was blank at the end, so it ends on a flat "
               "colour: {output}", output=output) + "\n" + blank_causes(a.gpu))
    if checks.unsampled is not None:
        die(tr("could not sample the virtual display to check {output} is not blank,\n"
               "  so this run cannot vouch for it.", output=output)
            + f"\n  {checks.unsampled}")
    if a.gpu:
        for when, at in checks.sampled:
            try:
                flat = video_is_blank(output, at, width, height)
            except SampleError as exc:
                die(tr("could not read {output} back to check it is not blank,\n"
                       "  so this run cannot vouch for it.", output=output)
                    + f"\n  {exc}")
            if flat and when == "halfway":
                die(tr("the video is a flat colour halfway, though the private "
                       "screen was not: {output}\n  The recorder captured "
                       "something other than the screen. See {log}",
                       output=output, log=logpath))
            if flat:
                die(tr("the video is a flat colour at the end, though the private "
                       "screen was not: {output}\n  The recorder captured "
                       "something other than the screen. See {log}",
                       output=output, log=logpath))
        share = changed_share(output, a.framerate)
        if share is not None and share < CHANGED_NOTE_BELOW:
            # translators: {share} is a percentage, such as 48%; {output} is the video's path.
            note(tr("only {share} of the frames in the middle of {output} "
                    "changed from the one before.\n  If the app was moving the "
                    "whole time, it drew that few new frames itself: the video "
                    "has every frame it drew.", share=f"{share:.0%}", output=output))


def video_error(output):
    """Why the finished video cannot be read back, or None if it can.

    A file that is there and not empty is not yet a video: a recorder killed
    mid-run leaves an mp4 with no index, and the run printed its path and
    exited 0 (DEMO-0116).
    """
    try:
        probe = subprocess.run(["ffprobe", "-v", "error", str(output)],
                               capture_output=True, text=True,
                               timeout=CALL_TIMEOUT, check=False)
    except subprocess.TimeoutExpired:
        return tr("reading it back took over {seconds}s.", seconds=CALL_TIMEOUT)
    if probe.returncode == 0:
        return None
    lines = probe.stderr.strip().splitlines()
    return lines[-1] if lines else tr("ffprobe could not read it.")


def summarise(output):
    """On a terminal, the video's length and size once it is done."""
    if not sys.stderr.isatty():
        return
    try:
        seconds = float(subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
             "-of", "csv=p=0", str(output)], capture_output=True, text=True,
            timeout=CALL_TIMEOUT, check=True).stdout)
    except (subprocess.SubprocessError, ValueError):
        return  # the video was already vouched for; this is only a courtesy
    size = output.stat().st_size
    note(tr("{seconds}s of video, {size}", seconds=f"{seconds:.1f}",
            size=f"{size / 1e6:.1f} MB" if size >= 1e6 else f"{size / 1e3:.0f} kB"))


def cmd_record(a):
    command, width, height, output, applog = prepare(a, ".mp4")

    # SIGHUP is a closed terminal: finish the video as for Ctrl+C, rather
    # than dying where we stand and leaking the display (DEMO-0115).
    for sig in (signal.SIGUSR1, signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
        signal.signal(sig, _request_stop)

    # The run is addressable from here, before anything slow happens, so a
    # `stop` issued straight after launching it finds it (DEMO-0034). Holding
    # the name makes any state file already there a dead run's, so it is
    # simply replaced. "recording" flips to true once ffmpeg has its first
    # frame; `stop` waits for that rather than ending a run with no video.
    # "result" is written as "ok" just before the path is printed, and the
    # file is left in place so `stop` can read it after this process has gone
    # (DEMO-0047). A run that fails never writes it.
    claim_name(a.name)
    statefile = state_dir() / f"{a.name}.json"
    state = {"name": a.name, "pid": os.getpid(),
             "starttime": proc_starttime(os.getpid()),
             "output": str(output), "recording": False}

    logpath = state_dir() / f"{a.name}.ffmpeg.log"
    displaylog = state_dir() / f"{a.name}.display.log"
    # The app's own output goes to our stderr, or to --app-log, but never to
    # our stdout: this command prints the finished path and nothing else, and a
    # caller writing `out=$(demoreel record ...)` must not collect the app's
    # chatter along with it. Worth keeping somewhere when the app says whether
    # what it drew was the real thing — a wrong-but-not-blank recording passes
    # every check this tool can make.
    # Deliberate: as above - closed in the finally block, not here.
    appout = open(applog, "wb") if applog else sys.stderr  # noqa: SIM115
    server = app = recorder = log = auth = sound = None
    checks = Checks()
    try:
        write_state(statefile, state)
        sound = private_sound(a.audio, a.name)
        server, app, display, auth, env, wayland = launch(
            a, command, width, height, state_dir() / a.name, displaylog, appout,
            sound)
        window = place_window(env, app, a, width, height, picture=False)
        settle(a, env, display, width, height)

        recorder, log, lag = start_recorder(a, env, display, wayland, width,
                                            height, output, logpath, sound)
        started = time.monotonic() - lag
        write_state(statefile, dict(state, display=display, recording=True))
        note(tr("recording {display} to {output} (until stopped)",
                display=display, output=output) if a.duration == 0 else
             tr("recording {display} to {output} ({seconds}s)",
                display=display, output=output, seconds=a.duration))

        run_countdown(a, env, app, window, display, recorder, width, height,
                      logpath, started, checks)
        stop_recorder(recorder)
        check_at_end(env, app, window, display, width, height, checks)
    finally:
        # Order matters: finish the video before the app and display go away,
        # or the last frames catch a half-dead app.
        if recorder is not None:
            stop_recorder(recorder)
        if log is not None:
            log.close()
        if appout is not sys.stderr:
            appout.close()
        end_process(app, group=True)
        if sound is not None:
            sound.unload()
        end_server(server)
        if auth is not None:
            auth.unlink(missing_ok=True)
        if a.gpu:
            finish_gpu_video(output, logpath)
        elif a.audio:
            finish_audio_video(output, logpath)

    vouch_for(a, output, logpath, width, height, checks)
    if a.audio:
        level = loudest(output)
        if level is not None and level <= SILENT_DB:
            note(tr("the sound track is silent: the app made no sound that reached "
                    "demoreel."))
    summarise(output)
    for stale in (logpath, displaylog):
        stale.unlink(missing_ok=True)  # only worth keeping when something failed
    write_state(statefile, dict(state, result="ok"))
    print(output)


def take_picture(env, display, width, height, cursor, output, gpu):
    """Save one PNG of the display and check it; the reason it failed, or None.

    The check reads the saved file back rather than grabbing a second frame, so
    the picture judged is the picture handed over. The test is frame_is_flat,
    the same one that judges a recording.
    """
    grab = subprocess.run(  # noqa: PLW1510 - a failed grab is handled below
        ["ffmpeg", "-hide_banner", "-loglevel", "error",
         "-f", "x11grab", "-draw_mouse", "1" if cursor else "0",
         "-video_size", f"{width}x{height}", "-i", f"{display}.0",
         "-frames:v", "1", "-y", str(output)],
        env=env, capture_output=True, timeout=CALL_TIMEOUT)
    if grab.returncode != 0 or not output.exists():
        tail = grab.stderr.decode(errors="replace").strip().splitlines()[-3:]
        return tr("could not take the picture.") + "\n  " + "\n  ".join(tail)
    gray = subprocess.run(  # noqa: PLW1510 - a failed read is handled below
        ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(output),
         "-pix_fmt", "gray", "-f", "rawvideo", "-"],
        capture_output=True, timeout=CALL_TIMEOUT)
    if gray.returncode != 0 or len(gray.stdout) != width * height:
        return tr("could not read back {output} to check it is not blank.", output=output)
    if frame_is_flat(gray.stdout):
        return (tr("the picture is one flat colour: {output}", output=output) + "\n"
                + blank_causes(gpu))
    return None


def cmd_shot(a):
    """One picture of the app, on the same private display `record` uses.

    No name and no state file: there is nothing to stop, so a shot keeps its
    files in a folder of its own and `stop` never sees it. The folder goes when
    the shot succeeds and is kept, and named, when it fails.
    """
    command, width, height, output, applog = prepare(a, ".png")
    rundir = Path(tempfile.mkdtemp(prefix="shot-", dir=state_dir()))
    displaylog = rundir / "display.log"
    # Deliberate: closed in the finally block, as in cmd_record.
    appout = open(applog, "wb") if applog else sys.stderr  # noqa: SIM115
    server = app = auth = sound = None
    try:
        sound = private_sound(False, "shot")
        server, app, display, auth, env, _ = launch(a, command, width, height,
                                                    rundir / "shot", displaylog,
                                                    appout, sound)
        window = place_window(env, app, a, width, height, picture=True)
        try:
            drawn = wait_until_drawn(env, display, width, height, a.settle)
        except SampleError as exc:
            die(tr("--settle could not sample the display, so it cannot tell "
                   "when the app has drawn.") + f"\n  {exc}")
        if not drawn:
            note(tr("nothing was drawn within {seconds}s; taking the picture "
                    "anyway.\n  If the app takes longer to show its first picture, "
                    "raise --settle.", seconds=a.settle))
        for action in a.action:
            run_action(action, env)
        if window is not None:
            moved = window_off_frame(env, window, width, height)
            if moved:
                note(moved)
        failed = take_picture(env, display, width, height, a.cursor, output,
                              a.gpu)
    except subprocess.TimeoutExpired:
        failed = tr("could not take the picture: the display did not answer "
                    "within {seconds}s.", seconds=CALL_TIMEOUT)
    except SystemExit as exc:
        # Every die() above lands here, so every failure names the folder it
        # keeps -- which is in RAM, under /run/user (DEMO-0107).
        raise SystemExit(f"{exc.code}\n  "
                         + tr("Its display log is kept in {folder}", folder=rundir)) from None
    finally:
        if appout is not sys.stderr:
            appout.close()
        end_process(app, group=True)
        if sound is not None:
            sound.unload()
        end_server(server)
        if auth is not None:
            auth.unlink(missing_ok=True)
    if failed:
        die(f"{failed}\n  " + tr("Its display log is kept in {folder}", folder=rundir))
    shutil.rmtree(rundir, ignore_errors=True)
    print(output)


def cmd_check(a):
    """Say whether this machine can record, on each backend (DEMO-0052).

    Everything goes to stderr, like every other message: stdout carries a
    finished path and nothing else, and a check has no path to print.
    """
    ready = True
    for gpu in (False, True):
        name = "--gpu" if gpu else tr("record and shot")
        missing = [t for t in dict.fromkeys(required_programs(gpu, True))
                   if shutil.which(t) is None]
        problems = [tr("missing {programs}", programs=", ".join(missing))
                    + install_hint(missing)] if missing else []
        if "ffmpeg" not in missing and not has_libx264():
            problems.append(tr("this ffmpeg has no libx264, which every recording "
                               "uses.") + x264_advice(distro_family()))
        failure = (start_failure("wf-recorder")
                   if gpu and "wf-recorder" not in missing else None)
        if failure:
            problems.append(tr("wf-recorder could not start recording.") + "\n  "
                            + tr("It said: {text}", text=failure))
        library = (linked_library("wf-recorder", "libavcodec.so")
                   if gpu and "wf-recorder" not in missing else None)
        if library and not has_encoder(library, "libx264"):
            problems.append(tr("wf-recorder loads {library}, which has no libx264, so "
                               "it cannot write the video.", library=library)
                            + packman_advice(library, distro_family()))
        if gpu and not list(Path("/dev/dri").glob("renderD*")):
            problems.append(tr("no graphics render node in /dev/dri, so there is "
                               "no GPU for it to use."))
        if not gpu and not missing:
            problems += display_problem()
        if problems:
            note(tr("{name}: NOT READY", name=name) + "\n  - " + "\n  - ".join(
                problem.replace("\n", "\n    ") for problem in problems))
            if not gpu:  # only the default backend decides the exit status
                ready = False
        else:
            note(tr("{name}: ready", name=name))
    # Like --gpu's, this line never changes the exit status (DEMO-0106 INV-8).
    problems = audio_problems()
    note(tr("{name}: NOT READY", name="--audio") + "\n  - " + "\n  - ".join(
        problem.replace("\n", "\n    ") for problem in problems) if problems
         else tr("{name}: ready", name="--audio"))
    # The finishing commands need no display, and nothing here changes the
    # exit status: that still says only whether the default backend records.
    videos, words = finishing_problems()
    for name, problem in ((tr("edit, trim, caption, join, card, motion and "
                              "poster"), videos),
                          (tr("text on a card or a caption"), words)):
        note(tr("{name}: NOT READY", name=name) + "\n  - "
             + problem.replace("\n", "\n    ") if problem
             else tr("{name}: ready", name=name))
    if not ready:
        sys.exit(1)


def audio_problems():
    """Why record --audio cannot run here; [] if it can. Sweeps leftover
    sinks on the way, as every run does."""
    if shutil.which("pactl") is None:
        return [tr("missing {programs}", programs="pactl") + install_hint(["pactl"])]
    problems = []
    if not has_pulse_input():
        problems.append(tr("--audio needs an ffmpeg that can read from a sound server, "
                           "and this one has no pulse input."))
    try:
        sweep_sinks()
    except SoundUnavailable as exc:
        problems.append(str(exc))
    return problems


def display_problem():
    """Start and end a private display, the way a run does; [] if that works."""
    base = Path(tempfile.mkdtemp(prefix="check-", dir=state_dir()))
    server = auth = None
    try:
        server, _, auth = start_xvfb(320, 240, base / "check", base / "display.log")
    except SystemExit as exc:
        reason = str(exc.code).removeprefix("demoreel: ")
        return [tr("a private display did not start: {reason}", reason=reason)]
    finally:
        end_server(server)
        if auth is not None:
            auth.unlink(missing_ok=True)
        shutil.rmtree(base, ignore_errors=True)
    return []


# How long `stop` looks for a run before saying there is none. A run registers
# itself before anything slow, but not before Python has started, so a stop
# launched on the very next line of a script can still arrive first -- measured
# on the second of ten back-to-back tries. A second is far more than that
# startup, and it is only ever spent when the answer is going to be "none".
STOP_GRACE = 1.0


def live_runs():
    """Every run whose state file names a process that is still that run.

    A dead run's file is left where it is. The next run of that name replaces
    it under the name lock, and removing it here could race that run and
    delete the file it has just written.
    """
    live = []
    for f in sorted(state_dir().glob("*.json")):
        try:
            entry = json.loads(f.read_text())
        except (OSError, ValueError):
            continue  # gone since the glob, or not ours to read
        if isinstance(entry, dict) and is_our_run(entry):
            live.append(entry)
    return live


def cmd_stop(a):
    def matching():
        return [e for e in live_runs() if not a.name or e.get("name") == a.name]
    live = poll(matching, STOP_GRACE, interval=0.05) or []

    if a.name:
        if not live:
            others = sorted(e["name"] for e in live_runs())
            die(tr("no recording named {name} is running.", name=repr(a.name))
                + ("\n  " + tr("Running now: {names}.", names=", ".join(others))
                   if others else ""))
    elif not live:
        die(tr("no recording is running, so there is nothing to stop."))
    elif len(live) > 1:
        die(trn("{count} recording is running: {names}.\n"
                "  Name the one to stop, e.g. {example}",
                "{count} recordings are running: {names}.\n"
                "  Name the one to stop, e.g. {example}", len(live),
                names=", ".join(sorted(e["name"] for e in live)),
                example=f"demoreel stop {min(e['name'] for e in live)}"))

    for found in live:
        entry = wait_until_recording(found)
        os.kill(entry["pid"], signal.SIGUSR1)
        if not run_succeeded(entry):
            die(tr("the recording failed after it was stopped, so there is no "
                   "video to hand back.\n  Its own error message says why."))
        print(entry["output"])


def run_succeeded(entry):
    """Wait for a stopped run to exit, then say whether it succeeded.

    A stopped run still has work to do: finish the video, then check the
    display was not blank. Printing its path before that handed the caller a
    half-written file, or a path for a run that went on to fail (DEMO-0047).
    The run writes "result": "ok" to its state file just before it prints its
    own path. Anything else -- a failure, a kill, the file already replaced by
    a later run of the same name -- is not a success.
    """
    pid = entry["pid"]
    # A zombie keeps its pid and start time until whatever started it reaps
    # it, which may be the same script that is waiting on this stop.
    poll(lambda: not is_our_run(entry) or proc_stat(pid, 3) == "Z", None)
    try:
        final = json.loads((state_dir() / f"{entry['name']}.json").read_text())
    except (OSError, ValueError):
        return False
    return (isinstance(final, dict) and final.get("pid") == pid
            and final.get("starttime") == entry.get("starttime")
            and final.get("result") == "ok")


def wait_until_recording(entry):
    """Hold a stop aimed at a run that is still starting, until it records.

    A run is addressable from its first moment, but stopping it before ffmpeg
    has a frame would leave no video at all -- and stop's one line of stdout
    is a video path. So wait, and stop it the moment it is recording. That
    gives a very short video rather than none (DEMO-0034).

    No timeout of its own: the run has its own startup timeouts, and it
    either starts recording or exits. A run that exits first is reported as
    such, since there is no path to print.
    """
    statefile = state_dir() / f"{entry['name']}.json"
    pid = entry["pid"]
    # A file written by 0.1.x has no "recording" key, and existed only once
    # that run was already recording.
    if entry.get("recording", True):
        return entry

    def recording():
        try:
            current = json.loads(statefile.read_text())
        except (OSError, ValueError):
            current = {}
        # Same pid, or a new run that took the name after this one died would
        # be stopped in its place.
        if (not isinstance(current, dict) or current.get("pid") != pid
                or not is_our_run(current)):
            die(tr("the recording ended before it started recording, so there "
                   "is no video to stop."))
        return current if current.get("recording", True) else None
    return poll(recording, None)


# Defaults of the finishing commands (README, Finishing a recording).
FADE_LENGTH = 0.5
CARD_TEXT_SIZE = 7
CAPTION_TEXT_SIZE = 5
CARD_BACKGROUND = "#101418"


class _CaptionText(argparse.Action):
    """`caption --text`: each one starts a new text."""

    def __call__(self, parser, namespace, values, option_string=None):
        texts = list(getattr(namespace, self.dest) or [])
        texts.append({"words": values})
        setattr(namespace, self.dest, texts)


class _CaptionField(argparse.Action):
    """`caption --from`, `--to` and `--text-size` belong to the --text before
    them. One given before any --text is refused: caption never cuts."""

    def __call__(self, parser, namespace, values, option_string=None):
        if not namespace.texts:
            parser.error(tr("{option} belongs to a --text, and none comes "
                            "before it. caption never cuts the video; use trim.",
                            option=option_string))
        namespace.texts[-1][self.dest] = values


_text_fonts = {}


def text_font(name=None, where=None):
    """The drawtext option naming a text's font: the one its line names, else
    the machine's own bold sans, so the same line measures differently on two
    machines (README says so).

    Asked of fontconfig's fc-match and passed as a file. drawtext's own `font`
    option takes a fontconfig pattern, and measured here it ignores the weight
    in one: Sans:bold, Sans:style=Bold and Sans:weight=bold all drew the
    regular face, 310 pixels wide where the bold file draws 332. Without
    fc-match, or with a path the filter syntax cannot carry, the machine's
    font is drawn in the regular face rather than not at all.

    A font the line names is refused rather than swapped. fc-match never says
    no: asked for a font the machine lacks it answers with another (measured:
    "Noto Sans Mono" came back as Liberation Mono). So the family it answers
    with is compared with the one asked for. A name with a slash in it is a
    font file and is used as it is.
    """
    if name in _text_fonts:
        return _text_fonts[name]
    families = ""
    if name and "/" in name:
        found = str(Path(name).expanduser())
        if not Path(found).is_file():
            die(tr("{where}: there is no font file {path}", where=where, path=found))
    else:
        # A hyphen in a fontconfig pattern starts a size, so one in a family's
        # name is escaped: unescaped, "Goha-Tibeb Zemen" came back as Roboto.
        family, colon, rest = (name or "sans:bold").partition(":")
        pattern = family.replace("-", "\\-") + colon + rest
        try:
            answer = subprocess.run(
                ["fc-match", "-f", "%{file}\n%{family}", pattern],
                capture_output=True, text=True, timeout=CALL_TIMEOUT,
                check=False).stdout
        except (OSError, subprocess.TimeoutExpired):
            answer = ""
        found, _, families = answer.strip().partition("\n")
    if name and "/" not in name:
        asked = name.split(":")[0].strip()
        if not found:
            die(tr("{where}: a font is found by its name with fc-match, which "
                   "gave no answer here. Give the font's file instead: "
                   "font /path/to/font.ttf", where=where))
        if asked.lower() not in [f.strip().lower() for f in families.split(",")]:
            die(tr("{where}: this machine has no font named {font}; the "
                   "nearest it offers is {nearest}.\n"
                   "  `fc-list : family` lists the names it has.", where=where,
                   font=repr(asked), nearest=repr(families.split(",")[0])))
    usable = bool(found) and Path(found).is_file() \
        and not set(found) & set("':\\,;[]")
    if name and not usable:
        die(tr("{where}: the font file {path} has a character in its path that "
               "ffmpeg's filters cannot carry. Copy it to a plainer path.",
               where=where, path=found))
    _text_fonts[name] = f"fontfile='{found}'" if usable else "font='Sans'"
    return _text_fonts[name]


_box_can_be_sized = None


def box_can_be_sized():
    """Whether this ffmpeg's drawtext can make its box a size of our choosing
    (`boxw`), which is how a band fades with its text: drawbox cannot change
    with time. Present in ffmpeg 6.1.1 and 8.1.2, the two measured. An ffmpeg
    without it draws its bands with drawbox, and a band that fades is
    refused."""
    global _box_can_be_sized  # noqa: PLW0603 - asked once per run
    if _box_can_be_sized is None:
        try:
            told = subprocess.run(
                ["ffmpeg", "-hide_banner", "-h", "filter=drawtext"],
                capture_output=True, text=True, timeout=CALL_TIMEOUT,
                check=False).stdout
        except (OSError, subprocess.TimeoutExpired):
            told = ""
        _box_can_be_sized = bool(re.search(r"^\s+boxw\s", told, re.MULTILINE))
    return _box_can_be_sized


# A caption's band is this many times its text's height, and a card keeps this
# share of the frame clear at each edge.
CAPTION_BAND = 2.0
CARD_MARGIN = 0.05
# Texts on screen together at one place are stacked (DEMO-0153): two lines off
# a band are this share of the taller one's size apart, and a sliding text
# rises this share of its size into place. The Slipcase builder's 30 px gap
# under 92 px lines, and its 36 px rise on lines of 64 to 92 px.
LINE_GAP = 0.3
SLIDE_RISE = 0.5
# A time may overshoot the end of its file by this much and still mean the
# end: `motion` prints three decimals, so its own figure can round up.
TIME_SLACK = 0.0006


def frame_at(moment, step):
    """The number of the frame on screen at `moment`, with frames `step`
    apart. A moment up to TIME_SLACK before a frame starts means that frame:
    `motion` prints three decimals, so a frame starting at 1.33333 comes back
    as 1.333, and README says its numbers can be used as they are
    (DEMO-0137)."""
    return math.floor((moment + TIME_SLACK) / step + 1e-9)


SCENE_WORDS = {
    "clip": ("from", "to", "fade-in", "fade-out", "fade-at", "fade-length",
             "crossfade", "sound", "volume", "speed", "hold", "window",
             "background", "push"),
    "card": ("background", "fade-in", "fade-out", "crossfade", "reveal"),
    "text": ("from", "to", "size", "font", "colour", "outline", "shadow",
             "fade-in", "fade-out", "band", "at", "slide-in"),
    "zoom": ("x", "y", "from", "to"),
    "music": ("level", "normalise", "fade-in", "fade-out"),
    "level": ("from", "to", "ramp"),
    "sound": ("at", "volume"),
}
# The ready-made pictures a crossfade may take, after its seconds: xfade's own
# names, those ffmpeg 6.1.1 and 9.0.2 both have, less `custom`.
TRANSITIONS = (
    "fade", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft",
    "slideright", "slideup", "slidedown", "circlecrop", "rectcrop", "distance",
    "fadeblack", "fadewhite", "radial", "smoothleft", "smoothright",
    "smoothup", "smoothdown", "circleopen", "circleclose", "vertopen",
    "vertclose", "horzopen", "horzclose", "dissolve", "pixelize", "diagtl",
    "diagtr", "diagbl", "diagbr", "hlslice", "hrslice", "vuslice", "vdslice",
    "hblur", "fadegrays", "wipetl", "wipetr", "wipebl", "wipebr", "squeezeh",
    "squeezev", "zoomin", "fadefast", "fadeslow", "hlwind", "hrwind",
    "vuwind", "vdwind", "coverleft", "coverright", "coverup", "coverdown",
    "revealleft", "revealright", "revealup", "revealdown")
# The words of a text line whose value is not a number.
TEXT_NAMES = ("font", "colour", "outline", "shadow", "band", "at")
TEXT_PLACES = ("top", "middle", "bottom")
COLOUR_RE = re.compile(r"#[0-9a-fA-F]{6}")
# An outline is this share of its text's height thick, and a shadow falls this
# share of it down and to the right.
OUTLINE_SHARE = 1 / 16
SHADOW_SHARE = 1 / 12


class Scene:
    """One scene of a film: part of a video, or a card.

    `where` names it in an error: "line 3" for a script, the file's name for a
    shortcut. Times on a clip are in its file's own time, as the caller wrote
    them; check_film turns them into times from the scene's start.
    """

    def __init__(self, kind, where):
        self.kind = kind
        self.where = where
        self.path = None        # a clip's video; a card's picture, or None
        self.start = 0.0        # a clip: the part kept
        self.end = None
        self.seconds = None     # a card: how long it lasts
        # A card's colour; a clip's, round its window, only when given.
        self.background = CARD_BACKGROUND if kind == "card" else None
        self.fade_in = self.fade_out = self.crossfade = 0.0
        self.transition = "fade"  # the picture a crossfade takes
        self.fade_at = []
        self.fade_length = FADE_LENGTH
        self.texts = []         # each: words, start, end, size, where
        self.info = None        # what ffprobe says of `path`
        self.frames = 1         # a card's picture: more than 1 if animated
        self.sound = "on"       # a clip: keep its own sound
        self.volume = None      # a clip: dB on its own sound
        self.has_sound = False  # a clip: its file has a sound stream
        self.speed = 1.0        # a clip: 2 plays it twice as fast
        self.hold = 0.0         # a clip: seconds its last frame stays after it
        self.reveal = 0.0       # a card: seconds its picture takes to arrive
        self.window = None      # a clip: its window's share of the room, or None
        self.box = None         # a windowed clip: (x, y, w, h) of its window
        self.push = 0.0         # a windowed clip: % its window grows over the scene
        self.zoom = None        # a windowed clip: its zoom line, a dict, or None
        self.stacks = []        # its texts' stacks, laid out by check_texts
        self.sounds = []        # its sound lines: each path, at, volume, where


# The film's sound: every stream is made this before it is joined or mixed.
SOUND_RATE = 48000
SOUND_FORMAT = f"aresample={SOUND_RATE},aformat=sample_fmts=fltp:channel_layouts=stereo"
# The music's defaults, which follow the Slipcase trailer (DEMO-0177 § 3).
MUSIC_LEVEL = -30.0
MUSIC_FADE_IN = 1.5
MUSIC_FADE_OUT = 3.0
LEVEL_RAMP = 0.5
# No level puts the music's peak above this, and a film whose loudest moment
# reaches FULL_SCALE gets a note that it may be distorted.
MUSIC_PEAK = -1.0
FULL_SCALE = -0.1


class Music:
    """A film's music: one file under the whole film, repeated to fill it."""

    def __init__(self, path, where):
        self.path = path
        self.where = where
        self.level = None       # None: MUSIC_LEVEL normalised, 0 dB as it is
        self.normalise = "on"
        self.fade_in = self.fade_out = None  # None: the defaults, which shrink to fit
        self.levels = []        # each: level, start, end, ramp, where


def script_number(word, where, what):
    try:
        value = float(word)
    except ValueError:
        value = -1.0
    if value < 0 or not math.isfinite(value):
        # translators: {word} is an edit-script word, such as from or fade-in, and stays
        # in English; {value} is what was written after it, in quotes.
        die(tr("{where}: {word} takes a number of seconds, not {value}.",
               where=where, word=what, value=repr(word)))
    return value


def script_decibels(word, where, what):
    """A level or a volume: a number of dB, which may be below zero."""
    try:
        value = float(word)
    except ValueError:
        value = math.nan
    if not math.isfinite(value):
        # translators: {word} is level or volume, an edit-script word, and stays in
        # English; {value} is what was written after it, in quotes.
        die(tr("{where}: {word} takes a number of decibels, like -6, not {value}.",
               where=where, word=what, value=repr(word)))
    return value


def script_on_off(word, where, what):
    if word not in ("on", "off"):
        # translators: {word} is sound or normalise, an edit-script word; on and off
        # stay in English too.
        die(tr("{where}: {word} is on or off, not {value}.", where=where, word=what,
               value=repr(word)))
    return word


def script_words(target, words, kind, where):
    """Read `word value` pairs after a scene's, a text's or the music's first
    part."""
    names = {"from": "start", "to": "end", "fade-in": "fade_in", "slide-in": "slide_in",
             "fade-out": "fade_out", "fade-length": "fade_length"}
    at = 0
    while at < len(words):
        word = words[at]
        if word not in SCENE_WORDS[kind] and at >= 2 and words[at - 2] == "crossfade":
            # translators: {name} is the word written after a crossfade's seconds, in
            # quotes; {names} lists xfade's transitions. All stay in English.
            die(tr("{where}: {name} is not a transition a crossfade takes. It "
                   "takes: {names}.", where=where, name=repr(word),
                   names=", ".join(TRANSITIONS)))
        if word not in SCENE_WORDS[kind]:
            # translators: {word} is the word written, in quotes; {kind} is clip, card or
            # text; {words} lists the words that line takes. All stay in English.
            die(tr("{where}: {word} is not something a {kind} line takes. "
                   "It takes: {words}.", where=where, word=repr(word), kind=kind,
                   words=", ".join(SCENE_WORDS[kind])))
        if at + 1 >= len(words):
            # translators: {word} is an edit-script word, in quotes, and stays in English.
            die(tr("{where}: {word} needs a value after it.", where=where,
                   word=repr(word)))
        value = words[at + 1]
        at += 2
        if word == "crossfade" and at < len(words) and words[at] in TRANSITIONS:
            target.transition = words[at]
            at += 1
        if word == "window":
            try:
                target.window = float(value)
            except ValueError:
                refuse_window(where, repr(value))
            if not math.isfinite(target.window):
                refuse_window(where, repr(value))
        elif word == "background":
            target.background = value
        elif kind == "sound":
            target[word] = (script_decibels if word == "volume" else script_number)(
                value, where, word)
        elif word in ("sound", "normalise"):
            setattr(target, word, script_on_off(value, where, word))
        elif word in ("volume", "level"):
            setattr(target, word, script_decibels(value, where, word))
        elif kind in ("level", "zoom"):
            target[names.get(word, word)] = script_number(value, where, word)
        elif word == "fade-at":
            target.fade_at.append(script_number(value, where, word))
        elif kind == "text" and word in TEXT_NAMES:
            target[word] = value
        elif kind == "text":
            target[names.get(word, word)] = script_number(value, where, word)
        else:
            setattr(target, names.get(word, word),
                    script_number(value, where, word))


def parse_script(text):
    """Read an `edit` script into scenes, or die naming the line.

    One scene a line, `clip FILE ...` or `card SECONDS [PICTURE] ...`; a
    `text "WORDS" ...` line belongs to the scene above it, and a `zoom FACTOR
    ...` line to the clip above it. A line is split as
    a shell splits one, except that `#` starts a comment only at the start of
    a line: a colour is written `#101418`.

    A `sound FILE ...` line belongs to the scene above it, as a text does.
    One `music FILE ...` line may stand anywhere among the scenes, with its
    `level DB ...` lines straight after it. Returns (scenes, music or None).
    """
    scenes, music, above = [], None, None
    for number, raw in enumerate(text.splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        where = tr("line {number}", number=number)
        try:
            words = shlex.split(line)
        except ValueError as exc:
            die(tr("{where} cannot be read: {reason}.", where=where, reason=exc)
                + f"\n  {line}")
        verb, rest = words[0], words[1:]
        if verb in ("text", "zoom") and above in ("music", "level"):
            die(tr("{where}: a text belongs to the scene above it, and the line "
                   "above is a music or level line.", where=where))
        if verb == "music":
            if music is not None:
                die(tr("{where}: a film has one music line, and {first} is already "
                       "one.", where=where, first=music.where))
            if not rest:
                die(tr("{where}: music needs a sound file after it.", where=where))
            music = Music(Path(rest[0]).expanduser(), where)
            script_words(music, rest[1:], "music", where)
        elif verb == "level":
            if above not in ("music", "level"):
                die(tr("{where}: a level line belongs to the music line, and comes "
                       "straight after it or after another level line.", where=where))
            if not rest:
                die(tr("{where}: level needs a number of decibels after it: "
                       "level -40", where=where))
            entry = {"level": script_decibels(rest[0], where, "level"),
                     "where": where}
            script_words(entry, rest[1:], "level", where)
            music.levels.append(entry)
        elif verb == "text":
            if not scenes:
                die(tr("{where}: a text belongs to the scene above it, and no "
                       "scene comes before this one.", where=where))
            if not rest:
                die(tr("{where}: text needs its words, in quotes: "
                       "text \"Saving a file\"", where=where))
            entry = {"words": rest[0], "where": where}
            script_words(entry, rest[1:], "text", where)
            scenes[-1].texts.append(entry)
        elif verb == "sound":
            if not scenes:
                die(tr("{where}: a sound belongs to the scene above it, and no "
                       "scene comes before this one.", where=where))
            if above in ("music", "level"):
                die(tr("{where}: a sound belongs to the scene above it, and the line "
                       "above is a music or level line.", where=where))
            if not rest:
                die(tr("{where}: sound needs a sound file after it: "
                       "sound click.wav at 2", where=where))
            entry = {"path": Path(rest[0]).expanduser(), "where": where}
            script_words(entry, rest[1:], "sound", where)
            scenes[-1].sounds.append(entry)
        elif verb == "zoom":
            if not scenes or scenes[-1].kind != "clip" or above in ("music", "level"):
                # translators: zoom is an edit-script word and stays in English.
                die(tr("{where}: a zoom line belongs to the clip above it, and the "
                       "line above is not part of a clip.", where=where))
            if scenes[-1].zoom is not None:
                die(tr("{where}: a clip takes one zoom line, and {first} is already "
                       "one.", where=where, first=scenes[-1].zoom["where"]))
            try:
                factor = float(rest[0]) if rest else math.nan
            except ValueError:
                factor = math.nan
            if not ZOOM_LOW < factor <= ZOOM_HIGH:
                refuse_zoom(where, repr(rest[0]) if rest else "''")
            entry = {"factor": factor, "where": where}
            script_words(entry, rest[1:], "zoom", where)
            scenes[-1].zoom = entry
        elif verb == "clip":
            if not rest:
                die(tr("{where}: clip needs a video file after it.", where=where))
            scene = Scene("clip", where)
            scene.path = Path(rest[0]).expanduser()
            script_words(scene, rest[1:], "clip", where)
            scenes.append(scene)
        elif verb == "card":
            if not rest:
                die(tr("{where}: card needs its length in seconds: card 3", where=where))
            scene = Scene("card", where)
            scene.seconds = script_number(rest[0], where, "card")
            rest = rest[1:]
            if rest and rest[0] not in SCENE_WORDS["card"]:
                scene.path = Path(rest[0]).expanduser()
                rest = rest[1:]
            script_words(scene, rest, "card", where)
            scenes.append(scene)
        else:
            # translators: {word} is the first word of the line, in quotes.
            die(tr("{where}: a line starts with clip, card, text, zoom, sound, music "
                   "or level, not {word}.", where=where, word=repr(verb)) + f"\n  {line}")
        above = verb if verb not in ("text", "sound") else above
    if not scenes:
        die(tr("the script has no scenes in it. A scene is a line starting with "
               "clip or card."))
    return scenes, music


def probe(path, where):
    """What ffprobe says of a video or a picture: its size, frame rate, length
    and format. Dies if it cannot be read, naming the scene that asked."""
    if not Path(path).is_file():
        die(tr("{where}: there is no file {path}", where=where, path=path))
    try:
        out = subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", "v:0",
             "-show_entries",
             "stream=width,height,avg_frame_rate:format=duration,format_name",
             "-of", "json", str(path)],
            capture_output=True, text=True, timeout=CALL_TIMEOUT, check=False)
    except subprocess.TimeoutExpired:
        die(tr("{where}: reading {path} took over {seconds}s.", where=where,
               path=path, seconds=CALL_TIMEOUT))
    try:
        info = json.loads(out.stdout) if out.returncode == 0 else {}
        stream = info["streams"][0]
        num, _, den = stream.get("avg_frame_rate", "0/1").partition("/")
        rate = float(num) / float(den) if float(den or 0) else 0.0
        return {"width": int(stream["width"]), "height": int(stream["height"]),
                "rate": rate,
                "duration": float(info["format"].get("duration") or 0),
                "format": info["format"].get("format_name", "")}
    except (KeyError, IndexError, ValueError):
        lines = out.stderr.strip().splitlines()
        die(tr("{where}: {path} is not a video or picture ffmpeg can read.",
               where=where, path=path) + (f"\n  {lines[-1]}" if lines else ""))
    return None  # unreachable; die() exits


# A card's picture is held in memory to be shown again and again, so an
# animation is refused past this many bytes of decoded frames.
PICTURE_BYTES = 512 * 1024 * 1024


def picture_frames(scene, where):
    """How many frames a card's picture has: 1 for a still, more for an
    animated .gif or .png. Which it is comes from its content, not its name:
    an animated .png is format apng."""
    if scene.info["format"] not in ("apng", "gif"):
        return 1
    try:
        counted = subprocess.run(
            ["ffprobe", "-v", "error", "-count_frames", "-select_streams",
             "v:0", "-show_entries", "stream=nb_read_frames", "-of",
             "csv=p=0", str(scene.path)],
            capture_output=True, text=True, timeout=6 * CALL_TIMEOUT,
            check=False)
        frames = int(counted.stdout.strip().split(",")[0])
    except (subprocess.TimeoutExpired, ValueError):
        die(tr("{where}: could not count the frames of {path}.", where=where,
               path=scene.path))
    if frames * scene.info["width"] * scene.info["height"] * 4 > PICTURE_BYTES:
        die(trn("{where}: {path} is too big an animation to hold in memory "
                "and play again and again ({count} frame of {size}).\n  Turn it "
                "into a video and use it as a clip instead.",
                "{where}: {path} is too big an animation to hold in memory "
                "and play again and again ({count} frames of {size}).\n  Turn it "
                "into a video and use it as a clip instead.", frames, where=where,
                path=scene.path, size=f"{scene.info['width']}x{scene.info['height']}"))
    return max(1, frames)


def memory_cap():
    """Run in the ffmpeg that makes a film, before it starts: refuse it more
    than half this machine's memory, or 4 GB where that is more.

    A filter graph whose input never ends buffers frames without limit. One
    did, while this was being built: ffmpeg reached 14 GB in six minutes on a
    machine other sessions were using. Every stream in the graph is bounded
    now, and this is what stops the next such mistake from taking the machine
    down with it.
    """
    cap = max(os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") // 2,
              4 * 1024 ** 3)
    # Only ever lower it: a caller may already hold this process to less.
    _, hard = resource.getrlimit(resource.RLIMIT_AS)
    if hard != resource.RLIM_INFINITY:
        cap = min(cap, hard)
    resource.setrlimit(resource.RLIMIT_AS, (cap, hard))


def rate_text(rate):
    """A frame rate as ffmpeg takes it: whole where it is whole."""
    return str(round(rate)) if abs(rate - round(rate)) < 0.01 else f"{rate:.3f}"


class TextUnavailable(Exception):
    """This machine cannot draw text. `reason` says why, and `checked` whether
    `demoreel check` tests the cause on its own. A structure rather than a
    message to take apart: the message is translated (§ 4.5)."""

    def __init__(self, reason, checked):
        super().__init__(reason)
        self.reason, self.checked = reason, checked


def text_size(textfile, fontsize, font=None, border=0):
    """(width, height) in pixels of the text in `textfile` at `fontsize`, as
    ffmpeg will draw it in `font` with an outline `border` pixels thick; raises
    TextUnavailable if this machine cannot draw text at all.

    Measured by drawing it: white on a black strip, then ffmpeg's own bounding
    box of what is not black. Nothing else knows the font's metrics, and there
    is no Python library here to ask (standard library only).
    """
    try:
        drawn = subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "info",
             "-f", "lavfi", "-i",
             f"color=black:s={fontsize * 80}x{fontsize * 3}:d=1",
             "-vf", f"drawtext=textfile='{textfile}':expansion=none:"
                    f"{font or text_font()}:fontsize={fontsize}:"
                    f"fontcolor=white:borderw={border}:bordercolor=white:"
                    f"x={border}:y={border},bbox",
             "-frames:v", "1", "-f", "null", "-"],
            capture_output=True, text=True, timeout=CALL_TIMEOUT, check=False)
    except subprocess.TimeoutExpired:
        raise TextUnavailable(tr("drawing a line of text took over {seconds}s.",
                                 seconds=CALL_TIMEOUT), checked=False) from None
    if drawn.returncode != 0:
        lines = [ln for ln in drawn.stderr.splitlines() if ln.strip()]
        raise TextUnavailable(tr("this machine cannot draw text on a video: ffmpeg "
                                 "needs its drawtext filter and a font.")
                              + "\n  " + "\n  ".join(lines[-2:]), checked=True)
    boxes = re.findall(r" w:(\d+) h:(\d+) ", drawn.stderr)
    return (int(boxes[-1][0]), int(boxes[-1][1])) if boxes else (0, 0)


# A clip's speed: below SPEED_LOW a slowed sound needs more than two atempo
# stages, and above SPEED_HIGH most frames are dropped.
SPEED_LOW = 0.25
SPEED_HIGH = 4.0


def scene_time(scene, moment):
    """A moment in a clip's own file, as seconds from its scene's start. Times
    are written in the file's time whatever the clip's speed (DEMO-0152); a
    card's start is 0 and its speed 1."""
    return (moment - scene.start) / scene.speed


def tempo_filters(speed):
    """atempo stages that play a sound `speed` times as fast. One stage takes
    0.5 to 100, on ffmpeg 6.1.1 and 9.0.2 alike."""
    if speed == 1:
        return []
    if speed < 0.5:
        return ["atempo=0.5", f"atempo={speed / 0.5:.9g}"]
    return [f"atempo={speed:.9g}"]


def check_zoom(scene, length):
    """Check a clip's zoom line and turn its times into times from the
    scene's start. Left out, it zooms over the whole clip."""
    zoom, where = scene.zoom, scene.zoom["where"]
    for word in ("x", "y"):
        if not 0 <= zoom.get(word, 50) <= 100:
            # translators: {word} is x or y, an edit-script word that stays in English.
            die(tr("{where}: a zoom's {word} is a percentage of the picture, from "
                   "0 to 100, not {value}.", where=where, word=word,
                   value=f"{zoom[word]:g}"))
    played = length - scene.hold
    begin = scene_time(scene, zoom.get("start", scene.start))
    if -TIME_SLACK <= begin < 0:
        begin = 0.0
    finish = scene_time(scene, zoom["end"]) if "end" in zoom else played
    if begin < 0 or finish > played + TIME_SLACK or begin >= finish:
        die(tr("{where}: the zoom is asked to run from {start} to {end}, which "
               "is not inside its clip ({scene_start} to {scene_end}).",
               where=where, start=f"{zoom.get('start', scene.start):g}",
               end=f"{zoom.get('end', scene.start + played * scene.speed):g}",
               scene_start=f"{scene.start:g}",
               scene_end=f"{scene.start + played * scene.speed:.3f}"))
    zoom["begin"], zoom["finish"] = begin, min(finish, played)


def check_texts(scene, length, width, height, workdir):
    """Check a scene's texts and turn their times into times from its start.

    A text shows from its `from` up to its `to`, and not on the frame at `to`,
    so one may start where another ends.
    """
    default = CARD_TEXT_SIZE if scene.kind == "card" else CAPTION_TEXT_SIZE
    for text in scene.texts:
        where = text.get("where", scene.where)
        words = text["words"]
        if not words.strip():
            die(tr("{where}: a text is one line of words, and this one is empty.",
                   where=where))
        if "\n" in words:
            die(tr("{where}: a text is one line of words, and this one is more "
                   "than one line.", where=where))
        # A time printed just short of a frame's start means that frame, so a
        # text may start that much before its scene does (DEMO-0136).
        begin = scene_time(scene, text.get("start", scene.start))
        if -TIME_SLACK <= begin < 0:
            begin = 0.0
        finish = scene_time(scene, text["end"]) if "end" in text else length
        if begin < 0 or finish > length + TIME_SLACK or begin >= finish:
            die(tr("{where}: the text {words} is asked to show from "
                   "{start} to {end}, which is not inside its scene "
                   "({scene_start} to {scene_end}).", where=where, words=repr(words),
                   start=f"{text.get('start', scene.start):g}",
                   end=f"{text.get('end', scene.start + length * scene.speed):g}",
                   scene_start=f"{scene.start:g}",
                   scene_end=f"{scene.start + length * scene.speed:.3f}"))
        size = text.get("size", default)
        if not 0 < size <= 50:
            die(tr("{where}: a text's size is a percentage of the frame's "
                   "height, between 0 and 50, not {size}.", where=where,
                   size=f"{size:g}"))
        for what in ("colour", "outline", "shadow"):
            if what in text and not COLOUR_RE.fullmatch(text[what]):
                # translators: {word} is colour, outline or shadow: an edit-script word, which
                # stays in English.
                die(tr("{where}: a text's {word} is a colour written #RRGGBB, "
                       "like #FFD040, not {value}.", where=where, word=what,
                       value=repr(text[what])))
        band = text.get("band", "on" if scene.kind == "clip" else "off")
        if band not in ("on", "off"):
            die(tr("{where}: a text's band is on or off, not {value}.", where=where,
                   value=repr(band)))
        place = text.get("at", "bottom" if scene.kind == "clip"
                         else "top" if scene.path is not None else "middle")
        if place not in TEXT_PLACES:
            die(tr("{where}: a text sits at {places} or {last}, not at {value}.",
                   where=where, places=", ".join(TEXT_PLACES[:-1]),
                   last=TEXT_PLACES[-1], value=repr(place)))
        if text.get("slide_in") and text.get("fade_in"):
            # translators: slide-in and fade-in are edit-script words and stay in English.
            die(tr("{where}: the text {words} takes slide-in or fade-in, not both: "
                   "a sliding text fades in as it rises.", where=where,
                   words=repr(words)))
        fades = text.get("fade_in", 0) + text.get("slide_in", 0) + text.get("fade_out", 0)
        if fades > min(finish, length) - begin + 1e-6:
            die(tr("{where}: the text {words} fades for {fades} seconds, "
                   "and shows for only {seconds}.", where=where, words=repr(words),
                   fades=f"{fades:g}", seconds=f"{min(finish, length) - begin:g}"))
        if fades and band == "on" and not box_can_be_sized():
            die(tr("{where}: a band that fades with its text needs a newer "
                   "ffmpeg than this one: its text filter has no `boxw`.\n"
                   "  Give the text `band off`, or take its fade away.", where=where))
        fontsize = max(1, round(size / 100 * height))
        font = text_font(text.get("font"), where)
        border = max(1, round(fontsize * OUTLINE_SHARE)) \
            if "outline" in text else 0
        textfile = workdir / f"text-{len(list(workdir.iterdir()))}.txt"
        textfile.write_text(words, encoding="utf-8")
        try:
            wide, _ = text_size(textfile, fontsize, font, border)
        except TextUnavailable as exc:
            die(exc.reason + ("\n  " + tr("`demoreel check` tests this on its own.")
                              if exc.checked else ""))
        room = width - 2 * round(CARD_MARGIN * width)
        if wide > room:
            die(tr("{where}: the text {words} is {wide} pixels wide at size "
                   "{size}, and the frame has room for {room}.\n  Shorten it, "
                   "or give it a smaller size: about {fits} would fit.",
                   where=where, words=repr(words), wide=wide, size=f"{size:g}",
                   room=room, fits=f"{size * room / wide:.1f}"))
        text.update(begin=begin, finish=min(finish, length), fontsize=fontsize,
                    textfile=textfile, fontfile=font, border=border,
                    banded=band == "on", place=place)
    scene.stacks = [place_stack(scene, lines, width, height)
                    for lines in text_stacks(scene.texts)]


def text_stacks(texts):
    """The texts at each place that are on screen together, joined one to the
    next, each stack in the order the script writes its lines. Two texts
    meeting only at a moment, one's `to` the other's `from`, are not."""
    stacks = []
    for text in texts:
        meets = [stack for stack in stacks if stack[0]["place"] == text["place"]
                 and any(text["begin"] < other["finish"] - 1e-6
                         and other["begin"] < text["finish"] - 1e-6
                         for other in stack)]
        stacks = [stack for stack in stacks if all(stack is not m for m in meets)]
        stacks.append(sorted([text, *(t for m in meets for t in m)], key=texts.index))
    return stacks


def place_stack(scene, lines, width, height):
    """Give each line of a stack its `y`, fixed for the stack's whole life,
    and return the stack's place, band and height for `picture_room`. A
    stack sits where one text of its height sits, so one line is placed as
    a text on its own is."""
    banded = lines[0]["banded"]
    for line in lines:
        if line["banded"] != banded:
            # translators: band is an edit-script word and stays in English.
            die(tr("{where}: the text {words} is on screen with {first} at the "
                   "same place, and one has a band and the other not. Texts "
                   "stacked together all have a band or none do.",
                   where=line.get("where", scene.where), words=repr(line["words"]),
                   first=repr(lines[0]["words"])))
    margin = round(CARD_MARGIN * height)
    offsets, total = [], 0
    for number, line in enumerate(lines):
        if number and not banded:
            total += round(LINE_GAP * max(line["fontsize"], lines[number - 1]["fontsize"]))
        offsets.append(total)
        total += round(line["fontsize"] * CAPTION_BAND) if banded else line["fontsize"]
        if total > height - 2 * margin:
            die(tr("{where}: the text {words} does not fit: with the texts "
                   "stacked above it, on screen at the same time, it is taller "
                   "than the frame. Give them a smaller size, or show them at "
                   "different times.", where=line.get("where", scene.where),
                   words=repr(line["words"])))
    edge = margin if not banded else 0
    top = {"top": edge, "middle": (height - total) // 2,
           "bottom": height - edge - total}[lines[0]["place"]]
    for line, offset in zip(lines, offsets, strict=True):
        line["y"] = top + offset
    return {"place": lines[0]["place"], "banded": banded, "height": total,
            "last": lines[-1]["fontsize"]}


def check_scene(scene, width, height, rate, workdir):
    """Check one scene against the film's size and return its length.

    A clip's cut is snapped to its file's frames: the frame on screen at
    `from` is the first one kept and the frame on screen at `to` the last
    (README), so the numbers `motion` prints can be used as they are.
    """
    where = scene.where
    if scene.kind == "card":
        if scene.seconds <= 0:
            die(tr("{where}: a card needs a length above zero.", where=where))
        if scene.path is not None:
            scene.info = probe(scene.path, where)
            scene.frames = picture_frames(scene, where)
        elif scene.reveal:
            # translators: reveal is an edit-script word and stays in English.
            die(tr("{where}: reveal brings in a card's picture, and this card has "
                   "none.", where=where))
        length = scene.seconds
    else:
        info = scene.info = probe(scene.path, where)
        whole = info["duration"]
        step = 1 / (info["rate"] or rate)
        if whole <= 0:
            die(tr("{where}: {path} has no length ffmpeg can tell.", where=where,
                   path=scene.path))
        if scene.window is None and scene.background is not None:
            # translators: background and window are edit-script words and stay in
            # English.
            die(tr("{where}: background on a clip colours the room round its "
                   "window, and this clip has no window.", where=where))
        if scene.window is not None and not WINDOW_LOW <= scene.window <= 100:
            refuse_window(where, f"{scene.window:g}")
        if scene.window is not None:
            scene.background = scene.background or CARD_BACKGROUND
        for word, given in (("push", scene.push), ("zoom", scene.zoom)):
            if scene.window is None and given:
                # translators: {word} is push or zoom, and window an edit-script word;
                # both stay in English.
                die(tr("{where}: {word} acts on a clip's window, and this clip has "
                       "no window.", where=where, word=word))
        if not 0 <= scene.push <= PUSH_HIGH:
            # translators: push is an edit-script word and stays in English.
            die(tr("{where}: push is how much the window grows, in percent, from "
                   "0 to {high}, not {value}.", where=where, high=PUSH_HIGH,
                   value=f"{scene.push:g}"))
        if scene.push and scene.window * (1 + scene.push / 100) > 100 + 1e-9:
            # translators: window and push are edit-script words and stay in English.
            die(tr("{where}: window {window} grown by push {push} is larger than "
                   "the room it sits in. Make one of them smaller.", where=where,
                   window=f"{scene.window:g}", push=f"{scene.push:g}"))
        if scene.window is None and abs(info["width"] * height - info["height"] * width) \
                > 0.01 * info["height"] * width:
            die(tr("{where}: {path} is {size}, a different shape from the film's "
                   "{film}, and demoreel will not stretch it.", where=where,
                   path=scene.path, size=f"{info['width']}x{info['height']}",
                   film=f"{width}x{height}"))
        for what, value in (("from", scene.start), ("to", scene.end)):
            if value is not None and value > whole + TIME_SLACK:
                # translators: `{word} {value}` is what was written, such as `to 12`; {word} is
                # from or to and stays in English.
                die(tr("{where}: `{word} {value}` is past the end of "
                       "{path}, which is {seconds} seconds long.", where=where,
                       word=what, value=f"{value:g}", path=scene.path,
                       seconds=f"{whole:.3f}"))
        first = frame_at(scene.start, step)
        scene.start = first * step
        if scene.end is None or scene.end + TIME_SLACK >= whole - step:
            last = None
            length = whole - scene.start
        else:
            last = frame_at(scene.end, step)
            length = (last + 1) * step - scene.start
        if length <= 0:
            die(tr("{where}: `from` must come before `to`, and both inside "
                   "{path} ({seconds} seconds long).", where=where, path=scene.path,
                   seconds=f"{whole:.3f}"))
        # Seek half a frame early, and read half a frame past the last one
        # kept, so neither edge turns on how a float rounds.
        scene.seek = max(0.0, scene.start - step / 2)
        scene.read = None if last is None else (last + 0.5) * step - scene.seek
        # The sound is read to the last kept frame's end, or the film is
        # silent for the half frame before each cut (DEMO-0177 § 4.3); trim
        # still bounds the picture.
        scene.read_sound = None if last is None else (last + 1) * step - scene.seek
        if not SPEED_LOW <= scene.speed <= SPEED_HIGH:
            # translators: speed is an edit-script word and stays in English.
            die(tr("{where}: speed is between {low} and {high}, not {value}: 2 plays "
                   "the clip twice as fast, 0.5 at half speed.", where=where,
                   low=f"{SPEED_LOW:g}", high=f"{SPEED_HIGH:g}",
                   value=f"{scene.speed:g}"))
        length = length / scene.speed + scene.hold
        for moment in scene.fade_at:
            if not (scene.fade_length <= scene_time(scene, moment)
                    <= length - scene.fade_length):
                die(tr("{where}: `fade-at {moment}` needs "
                       "{seconds} seconds of the scene on each "
                       "side, and the scene runs from {start} to {end}.",
                       where=where, moment=f"{moment:g}",
                       seconds=f"{scene.fade_length:g}", start=f"{scene.start:.3f}",
                       end=f"{scene.start + length * scene.speed:.3f}"))
    if scene.background is not None and not (
            1 <= len(scene.background.split(",")) <= GRADIENT_MOST
            and all(COLOUR_RE.fullmatch(c) for c in scene.background.split(","))):
        die(tr("{where}: a background is a colour written #RRGGBB, like "
               "{example}, or two to {most} of them joined by commas for a "
               "moving gradient, not {value}.", where=where,
               example=CARD_BACKGROUND, most=GRADIENT_MOST,
               value=repr(scene.background)))
    if scene.fade_in + scene.fade_out > length + 1e-6:
        die(tr("{where}: its fades come to {fades} seconds, and the scene is only "
               "{seconds} long.", where=where,
               fades=f"{scene.fade_in + scene.fade_out:g}", seconds=f"{length:.3f}"))
    check_texts(scene, length, width, height, workdir)
    if scene.zoom is not None:
        check_zoom(scene, length)
    if scene.kind == "card" and scene.path is not None \
            and picture_room(scene, width, height)[2] < 2:
        die(tr("{where}: its texts leave no room for the picture between them. "
               "Give them a smaller size.", where=where))
    if scene.window is not None:
        scene.box = window_box(scene, width, height)
        if scene.box[3] < 2:
            die(tr("{where}: its texts leave no room for the picture between them. "
                   "Give them a smaller size.", where=where))
    return length


def film_shape(scenes, size, rate):
    """(width, height, rate) of the film: what was asked for, else the first
    clip's, else what `record` defaults to."""
    clips = [s for s in scenes if s.kind == "clip"]
    first = probe(clips[0].path, clips[0].where) if clips else None
    if size:
        match = SIZE_RE.match(size)
        if not match:
            die(tr("--size takes a width and a height in pixels, like 1600x1000, "
                   "not {size}.", size=repr(size)))
        width, height = int(match[1]), int(match[2])
    elif first:
        width, height = first["width"], first["height"]
    else:
        width, height = 1600, 1000
    if width % 2 or height % 2 or not width or not height:
        die(tr("a video's width and height must be even, since H.264 cannot "
               "encode an odd one, and this film would be {size}.\n"
               "  Set the size with -s {even}.", size=f"{width}x{height}",
               even=f"{width + width % 2}x{height + height % 2}"))
    if rate is None:
        rate = first["rate"] if first and first["rate"] else 30
    if rate <= 0:
        die(tr("--framerate takes a number above zero, not {rate}.", rate=f"{rate:g}"))
    return width, height, rate


def text_filters(scene, width, height):
    """The filters that draw a scene's texts, each in its own time."""
    if not scene.texts:
        return []
    dark = True
    # A gradient is darkened to GRADIENT_DARK, so it is always dark.
    if scene.background is not None and "," not in scene.background:
        red, green, blue = (int(scene.background[i:i + 2], 16) for i in (1, 3, 5))
        dark = 0.299 * red + 0.587 * green + 0.114 * blue < 140
    filters = []
    for text in scene.texts:
        begin, finish, size = text["begin"], text["finish"], text["fontsize"]
        colour = ("0x" + text["colour"][1:] if "colour" in text
                  else "white" if dark or text["banded"] else "0x101418")
        draw = [f"drawtext=textfile='{text['textfile']}'", "expansion=none",
                text["fontfile"], f"fontsize={size}", f"fontcolor={colour}"]
        if text["border"]:
            draw += [f"borderw={text['border']}",
                     f"bordercolor=0x{text['outline'][1:]}"]
        if "shadow" in text:
            off = max(1, round(size * SHADOW_SHARE))
            draw += [f"shadowx={off}", f"shadowy={off}",
                     f"shadowcolor=0x{text['shadow'][1:]}"]
        fade_in, fade_out = text.get("fade_in", 0), text.get("fade_out", 0)
        slide = text.get("slide_in", 0)
        when = f"enable='gte(t,{begin:.4f})*lt(t,{finish:.4f})'"
        top = text["y"]
        # A sliding text starts SLIDE_RISE of its size low and rises into
        # place, easing out (a cubic), as it fades in; its band comes with it.
        place = (f"'{top}+{round(SLIDE_RISE * size)}*"
                 f"pow(1-clip((t-{begin:.4f})/{slide:.4f},0,1),3)'" if slide
                 else f"{top}")
        if text["banded"]:
            band = round(size * CAPTION_BAND)
            if box_can_be_sized():
                # The text's own box, which fades with it. An ffmpeg that
                # cannot size that box gets drawbox, which cannot fade.
                draw += ["box=1", "boxcolor=black@0.75", f"boxw={width}",
                         f"boxh={band}", "text_align=C+M", "x=0", f"y={place}"]
            else:
                filters.append(f"drawbox=x=0:y={top}:w={width}:h={band}:"
                               f"color=black@0.75:t=fill:{when}")
                draw += ["x=(w-text_w)/2", f"y={top + (band - size) // 2}"]
        else:
            draw += ["x=(w-text_w)/2", f"y={place}"]
        if fade_in or slide or fade_out:
            arrive = fade_in or slide
            rise = f"(t-{begin:.4f})/{arrive:.4f}" if arrive else "1"
            fall = f"({finish:.4f}-t)/{fade_out:.4f}" if fade_out else "1"
            draw.append(f"alpha='min(1,min({rise},{fall}))'")
        filters.append(":".join([*draw, when]))
    return filters


def picture_room(scene, width, height):
    """(top, width, height) of the room a card leaves its picture: the frame
    inside its margins, less what a text at the top or the bottom takes. A
    text in the middle takes none and lies over the picture."""
    margin = round(CARD_MARGIN * height)
    edge = {"top": margin, "bottom": margin}
    for stack in scene.stacks:
        if stack["place"] != "middle":
            taken = stack["height"] if stack["banded"] \
                else margin + stack["height"] + round(stack["last"] * 0.3)
            edge[stack["place"]] = max(edge[stack["place"]], taken + margin // 2)
    return edge["top"], width - 2 * margin, height - edge["top"] - edge["bottom"]


def fade_filters(scene, length):
    """A scene's fades, to and from black. A fade-at is a fade out then a fade
    in, each switched on only for its own half: left on, the first would hold
    everything after it black and the second everything before it."""
    filters = []
    if scene.fade_in:
        filters.append(f"fade=t=in:st=0:d={scene.fade_in:.4f}")
    for moment in scene.fade_at:
        at, half = scene_time(scene, moment), scene.fade_length
        filters.append(f"fade=t=out:st={at - half:.4f}:d={half:.4f}:"
                       f"enable='between(t,{at - half:.4f},{at:.4f})'")
        filters.append(f"fade=t=in:st={at:.4f}:d={half:.4f}:"
                       f"enable='gt(t,{at:.4f})*lte(t,{at + half:.4f})'")
    if scene.fade_out:
        filters.append(f"fade=t=out:st={length - scene.fade_out:.4f}:"
                       f"d={scene.fade_out:.4f}")
    return filters


def sound_fades(scene, length):
    """fade_filters for the scene's sound: the same fades over the same
    seconds, a fade-at's two halves each switched on for its own half."""
    filters = []
    if scene.fade_in:
        filters.append(f"afade=t=in:st=0:d={scene.fade_in:.4f}")
    for moment in scene.fade_at:
        at, half = scene_time(scene, moment), scene.fade_length
        filters.append(f"afade=t=out:st={at - half:.4f}:d={half:.4f}:"
                       f"enable='between(t,{at - half:.4f},{at:.4f})'")
        filters.append(f"afade=t=in:st={at:.4f}:d={half:.4f}:"
                       f"enable='gt(t,{at:.4f})*lte(t,{at + half:.4f})'")
    if scene.fade_out:
        filters.append(f"afade=t=out:st={length - scene.fade_out:.4f}:"
                       f"d={scene.fade_out:.4f}")
    return filters


def keeps_sound(scene):
    """Whether the film takes this scene's own sound."""
    return scene.kind == "clip" and scene.sound == "on" and scene.has_sound


def scene_sound(scene, length, number, first_input):
    """The chain that turns one scene's sound into `[a<number>]`, exactly
    `length` seconds long: the clip's own sound, or silence."""
    if not keeps_sound(scene):
        return (f"anullsrc=r={SOUND_RATE}:cl=stereo:d={length:.6f},{SOUND_FORMAT},"
                f"atrim=duration={length:.6f}[a{number}]")
    # The input seeks half a frame early (check_scene), so the sound is
    # trimmed to begin at `from` itself. It is moved back by that much and
    # no more: a sound stream that starts after its picture keeps the gap,
    # which aresample fills with silence, where PTS-STARTPTS would pull it
    # early (measured 2026-10-11: 0.48 s). A recording's sound may end before
    # its picture (DEMO-0106), so it is padded to the picture's length.
    trim = scene.start - scene.seek
    chain = [f"atrim=start={trim:.6f}", f"asetpts=PTS-{trim:.6f}/TB",
             f"aresample={SOUND_RATE}:async=1:first_pts=0",
             SOUND_FORMAT, *tempo_filters(scene.speed),
             f"apad=whole_dur={length:.6f}",
             f"atrim=duration={length:.6f}"]
    if scene.volume is not None:
        chain.append(f"volume={scene.volume:.4f}dB")
    chain += sound_fades(scene, length)
    return f"[{first_input}:a:0]{','.join(chain)}[a{number}]"


def sound_info(path, where):
    """(whether a file has a sound stream, its length in seconds or 0). The
    length is the sound's own, not the file's, which a longer picture sets
    (DEMO-0191); the file's stands in where the stream gives none, as in a
    .mkv. Dies if ffprobe cannot read it, naming the line or option that
    asked."""
    if not Path(path).is_file():
        die(tr("{where}: there is no file {path}", where=where, path=path))
    try:
        out = subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
             "stream=index,duration:format=duration", "-of", "json", str(path)],
            capture_output=True, text=True, timeout=CALL_TIMEOUT, check=False)
    except subprocess.TimeoutExpired:
        die(tr("{where}: reading {path} took over {seconds}s.", where=where,
               path=path, seconds=CALL_TIMEOUT))
    try:
        info = json.loads(out.stdout) if out.returncode == 0 else None
        streams = info.get("streams") or []
        own = streams[0].get("duration") if streams else None
        return bool(streams), float(
            own or info.get("format", {}).get("duration") or 0)
    except (AttributeError, TypeError, ValueError):
        lines = out.stderr.strip().splitlines()
        die(tr("{where}: {path} is not a file ffmpeg can read.", where=where,
               path=path) + (f"\n  {lines[-1]}" if lines else ""))
    return None  # unreachable; die() exits


def music_loudness(music):
    """(mean, peak) of the music in dB, measured as the film will hear it:
    made stereo first, since a mono file reads 3 dB quieter once it is."""
    try:
        done = subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-i", str(music.path), "-map",
             "0:a:0", "-af", f"{SOUND_FORMAT},volumedetect", "-f", "null", "-"],
            capture_output=True, text=True, timeout=60 + music.length / 5,
            check=False)
    except subprocess.TimeoutExpired:
        die(tr("{where}: measuring the sound of {path} took too long, so it was "
               "stopped.", where=music.where, path=music.path))
    mean = re.search(r"mean_volume: (-?[\d.]+) dB", done.stderr)
    peak = re.search(r"max_volume: (-?[\d.]+) dB", done.stderr)
    if done.returncode or not mean or not peak:
        lines = [ln for ln in done.stderr.splitlines() if ln.strip()]
        die(tr("{where}: ffmpeg could not measure the sound of {path}.",
               where=music.where, path=music.path)
            + (f"\n  {lines[-1]}" if lines and done.returncode else ""))
    return float(mean[1]), float(peak[1])


def check_music(music, total):
    """Check the music against a film `total` seconds long, measure it, and
    set the gain each level needs. Dies naming the line or option."""
    found, music.length = sound_info(music.path, music.where)
    if not found:
        die(tr("{where}: {path} has no sound in it.", where=music.where,
               path=music.path))
    if music.length <= 0:
        die(tr("{where}: {path} has no length ffmpeg can tell.", where=music.where,
               path=music.path))
    normalised = music.normalise == "on"
    low, high = (-60, 0) if normalised else (-60, 20)
    if music.level is None:
        music.level = MUSIC_LEVEL if normalised else 0.0
    for level, where in [(music.level, music.where),
                         *((e["level"], e["where"]) for e in music.levels)]:
        if not low <= level <= high:
            # translators: {state} is on or off, and stays in English.
            die(tr("{where}: a level with normalise {state} is between {low} and "
                   "{high} dB, not {value}.", where=where,
                   state=music.normalise, low=low, high=high, value=f"{level:g}"))
    written = [f for f in (music.fade_in, music.fade_out) if f is not None]
    if sum(written) > total + 1e-6:
        die(tr("{where}: the music's fades come to {fades} seconds, and the film "
               "is only {seconds} long.", where=music.where,
               fades=f"{sum(written):g}", seconds=f"{total:.3f}"))
    # A written fade is kept; the defaults share what room is left, shrinking
    # in proportion where they do not fit.
    defaults = [MUSIC_FADE_IN if music.fade_in is None else 0.0,
                MUSIC_FADE_OUT if music.fade_out is None else 0.0]
    shrink = min(1.0, (total - sum(written)) / sum(defaults)) if sum(defaults) else 1.0
    music.fades = tuple(given if given is not None else default * shrink
                        for given, default in zip((music.fade_in, music.fade_out),
                                                  defaults, strict=True))
    spans = []
    for entry in music.levels:
        start, end = entry.get("start", 0.0), entry.get("end", total)
        entry.setdefault("ramp", LEVEL_RAMP)
        if not start < end or end > total + TIME_SLACK:
            die(tr("{where}: a level line's from must come before its to, and both "
                   "inside the film, which is {seconds} seconds long.",
                   where=entry["where"], seconds=f"{total:.3f}"))
        entry["start"], entry["end"] = start, min(end, total)
        spans.append((max(0.0, start - entry["ramp"]),
                      min(total, entry["end"] + entry["ramp"]), entry))
    spans.sort(key=lambda span: span[0])
    for (_, until, before), (since, _, after) in itertools.pairwise(spans):
        if since < until - 1e-6:
            die(tr("{where}: this level line and {other} overlap, counting their "
                   "ramps.", where=after["where"], other=before["where"]))
    mean, peak = music_loudness(music)
    if not (math.isfinite(mean) and math.isfinite(peak)):
        die(tr("{where}: {path} is silent all through.", where=music.where,
               path=music.path))
    music.mean = mean
    for target in (music, *music.levels):
        level = target.level if target is music else target["level"]
        gain = level - mean if normalised else level
        if gain > MUSIC_PEAK - peak:
            gain = MUSIC_PEAK - peak
            reached = gain + mean if normalised else gain
            note(tr("{where}: the music at level {level} would peak above {peak} "
                    "dB, so it plays at level {reached} instead.",
                    where=target.where if target is music else target["where"],
                    level=f"{level:g}", peak=f"{MUSIC_PEAK:g}",
                    reached=f"{reached:.1f}"))
        if target is music:
            music.gain = gain
        else:
            target["gain"] = gain


def music_graph(music, input_number, total):
    """The chain that makes `[m]`: the music, set to its level, repeated to
    fill the film, softer or louder for each level line, faded in and out."""
    chain = [SOUND_FORMAT, f"volume={music.gain:.4f}dB"]
    if music.length < total:
        # Repeated by the filter, never by the demuxer: an input with no end
        # is buffered without limit (memory_cap). size counts samples after
        # the resampling, and a size past the input's end loops what it held.
        chain.append(f"aloop=loop=-1:size={math.ceil(music.length * SOUND_RATE)}")
    chain += [f"atrim=duration={total:.6f}", "asetpts=N/SR/TB"]
    terms = []
    for entry in music.levels:
        change = entry["gain"] - music.gain
        start, end, ramp = entry["start"], entry["end"], entry["ramp"]
        if ramp:
            terms.append(f"{change:.4f}*max(0,min(1,min((t-{start - ramp:.4f})/"
                         f"{ramp:.4f},({end + ramp:.4f}-t)/{ramp:.4f})))")
        else:
            terms.append(f"{change:.4f}*between(t,{start:.4f},{end:.4f})")
    if terms:
        chain.append(f"volume=eval=frame:volume='pow(10,({'+'.join(terms)})/20)'")
    fade_in, fade_out = music.fades
    if fade_in:
        chain.append(f"afade=t=in:st=0:d={fade_in:.4f}")
    if fade_out:
        chain.append(f"afade=t=out:st={total - fade_out:.4f}:d={fade_out:.4f}")
    return f"[{input_number}:a:0]{','.join(chain)}[m]"


def check_sounds(scene, length, begins):
    """Check a scene's sound lines, and set when each starts in a film where
    the scene begins at `begins`. Dies naming the line."""
    for entry in scene.sounds:
        where = entry["where"]
        if not sound_info(entry["path"], where)[0]:
            die(tr("{where}: {path} has no sound in it.", where=where,
                   path=entry["path"]))
        volume = entry.setdefault("volume", 0.0)
        if not -60 <= volume <= 20:
            die(tr("{where}: volume is between {low} and {high} dB, not "
                   "{value}.", where=where, low=-60, high=20, value=f"{volume:g}"))
        # As a text's from, a time just short of the scene's start means it.
        at = scene_time(scene, entry.get("at", scene.start))
        if -TIME_SLACK <= at < 0:
            at = 0.0
        if not 0 <= at < length:
            # translators: {name} is a file name; the times are seconds.
            die(tr("{where}: the sound {name} is asked to play at {at}, which is "
                   "not inside its scene ({scene_start} to {scene_end}).",
                   where=where, name=entry["path"].name,
                   at=f"{entry.get('at', scene.start):g}",
                   scene_start=f"{scene.start:g}",
                   scene_end=f"{scene.start + length * scene.speed:.3f}"))
        entry["film_at"] = begins + at


def sound_graph(entry, input_number, number, total):
    """The chain that makes `[e<number>]`: a sound line's file at its
    volume, starting at its moment in the film. It is silent before and after,
    so it lasts the film, and cut where the film ends."""
    chain = [SOUND_FORMAT, "asetpts=N/SR/TB"]
    if entry["volume"]:
        chain.append(f"volume={entry['volume']:.4f}dB")
    chain += [f"adelay=delays={round(entry['film_at'] * SOUND_RATE)}S:all=1",
              f"apad=whole_dur={total:.6f}", f"atrim=duration={total:.6f}"]
    return f"[{input_number}:a:0]{','.join(chain)}[e{number}]"


def plan_music(music):
    """The music's lines of the plan on stderr."""
    if music.normalise == "on":
        note(tr("music {name}: averages {level} dB; its own average is {mean} dB, "
                "so it is turned {change} dB", name=music.path.name,
                level=f"{music.gain + music.mean:.1f}", mean=f"{music.mean:.1f}",
                change=f"{music.gain:+.1f}"))
    else:
        note(tr("music {name}: its own level, turned {change} dB",
                name=music.path.name, change=f"{music.gain:+.1f}"))
    for entry in music.levels:
        note(tr("  level {level} dB from {start} to {end}",
                level=f"{entry['level']:g}", start=f"{entry['start']:.3f}",
                end=f"{entry['end']:.3f}"))


def scene_graph(scene, length, number, first_input, width, height, rate):
    """(ffmpeg inputs, filter chains) that turn one scene into `[v<number>]`,
    a stream of the film's size and rate starting at time zero."""
    fps = rate_text(rate)
    # trim bounds every scene to its own frames, whatever its input does: a
    # stream that never ends is buffered without limit by whatever waits for
    # it (see memory_cap).
    # A held clip is cut to its own frames before its last is cloned: a clip
    # keeping its sound reads one frame past `to`, which tpad would hold.
    hold = (f"trim=end_frame={max(1, round((length - scene.hold) * rate))},"
            f"tpad=stop_mode=clone:stop_duration={scene.hold:.6f},"
            if scene.hold else "")
    shape = (f"fps={fps},scale={width}:{height},setsar=1,format=yuv420p,{hold}"
             f"trim=end_frame={max(1, round(length * rate))}")
    tail = ",".join([*text_filters(scene, width, height),
                     *fade_filters(scene, length)])
    tail = f",{tail}" if tail else ""
    out = f"[v{number}]"
    if scene.kind == "clip":
        inputs = ["-ss", f"{scene.seek:.6f}"]
        read = scene.read_sound if keeps_sound(scene) else scene.read
        if read is not None:
            inputs += ["-t", f"{read:.6f}"]
        inputs += ["-i", str(scene.path)]
        pts = "PTS-STARTPTS" if scene.speed == 1 else f"(PTS-STARTPTS)/{scene.speed:.9g}"
        if scene.window is None:
            return inputs, [f"[{first_input}:v]setpts={pts},{shape}{tail}{out}"]
        # The background runs a frame past the scene, and `shape` trims it
        # back. Ending with the clip, ffmpeg 6.1.1's overlay hands on its end
        # at its last frame's own time, and the fps after it then drops that
        # frame: a windowed scene came out a frame short there (9.0.2 kept it).
        inputs += ["-f", "lavfi", "-i",
                   background_source(scene, width, height, rate, length + 1 / rate,
                                     number)]
        return inputs, window_graph(scene, length, number, first_input, pts,
                                    f"{shape}{tail}{out}", rate)
    inputs = ["-f", "lavfi", "-i",
              background_source(scene, width, height, rate, length, number)]
    if scene.path is None:
        return inputs, [f"[{first_input}:v]{shape}{tail}{out}"]
    # The picture is read once and shown again and again by the loop filter,
    # which hands on a frame only when one is wanted. Asking the demuxer to
    # loop instead (-ignore_loop 0, -loop 1) makes an input with no end: an
    # animated .png read that way, waiting behind another scene, is what
    # filled the memory.
    inputs += ["-i", str(scene.path)]
    top, room_w, room_h = picture_room(scene, width, height)
    return inputs, [
        # Fitted into the room left for it, keeping its shape, never enlarged.
        f"[{first_input + 1}:v]loop=loop=-1:size={scene.frames}:start=0,"
        f"scale=w='min({room_w},iw)':h='min({room_h},ih)':"
        f"force_original_aspect_ratio=decrease,format=rgba{reveal_filters(scene)}"
        f"[p{number}]",
        f"[{first_input}:v][p{number}]overlay=x='(W-w)/2':"
        f"y='{top}+({room_h}-h)/2':format=auto,{shape}{tail}{out}"]


# A moving background (DEMO-0178), after the Slipcase trailer: ffmpeg's
# gradients in the caller's colours (at most GRADIENT_MOST, ffmpeg's limit),
# turning GRADIENT_TURN radians a second (its 0.012 a frame at 30 a second),
# darkened to GRADIENT_DARK of their brightness, with a vignette.
GRADIENT_TURN = 0.36
GRADIENT_DARK = 0.38
GRADIENT_MOST = 8


def background_source(scene, width, height, rate, length, number):
    """The lavfi source behind a card or a clip's window: one colour, or a
    slowly moving gradient through several. The gradient's seed is the
    scene's number, so the same script makes the same film."""
    colours = scene.background.split(",")
    shape = f"s={width}x{height}:r={rate_text(rate)}:d={length:.6f}"
    if len(colours) == 1:
        return f"color=c=0x{colours[0][1:]}:{shape}"
    stops = ":".join(f"c{i}=0x{c[1:]}" for i, c in enumerate(colours))
    dark = GRADIENT_DARK
    return (f"gradients={shape}:n={len(colours)}:{stops}:"
            f"speed={GRADIENT_TURN / rate:.6g}:seed={number},"
            f"colorchannelmixer=rr={dark}:gg={dark}:bb={dark},vignette=PI/4")


# A clip's window (DEMO-0183): the smallest share of the room it may take,
# and its look as shares of the window's height, from the Slipcase builder's
# 18 px radius, 0x22 blur and 10 px drop on an 860 px window, at 75%.
WINDOW_LOW = 20
WINDOW_CORNER = 0.021
WINDOW_BLUR = 0.026
WINDOW_DROP = 0.012
WINDOW_SHADOW = 0.75


# How far a window may grow over its scene, in percent (DEMO-0184; the
# Slipcase trailer pushed in 3.5% on a clip and 6% on a picture), and how many
# times larger a zoom may make the picture.
PUSH_HIGH = 20
ZOOM_LOW = 1
ZOOM_HIGH = 4


def refuse_zoom(where, value):
    # translators: zoom is an edit-script word and stays in English.
    die(tr("{where}: zoom is how many times larger the picture grows, above "
           "{low} and at most {high}, not {value}.", where=where, low=ZOOM_LOW,
           high=ZOOM_HIGH, value=value))


def refuse_window(where, value):
    # translators: window is an edit-script word and stays in English.
    die(tr("{where}: window is a share of the room in percent, from {low} to "
           "{high}, not {value}.", where=where, low=WINDOW_LOW, high=100,
           value=value))


def window_box(scene, width, height):
    """(x, y, w, h) of a clip's window: fitted, its shape kept, into its share
    of the room a card leaves its picture, and centred there. Even sizes."""
    top, room_w, room_h = picture_room(scene, width, height)
    box_w, box_h = room_w * scene.window / 100, room_h * scene.window / 100
    clip_w, clip_h = scene.info["width"], scene.info["height"]
    if clip_w * box_h <= clip_h * box_w:
        w, h = clip_w * box_h / clip_h, box_h
    else:
        w, h = box_w, clip_h * box_w / clip_w
    w, h = (int(v + 1e-6) // 2 * 2 for v in (w, h))
    left = round(CARD_MARGIN * height)
    return left + (room_w - w) // 2, top + (room_h - h) // 2, w, h


def rounded(x0, y0, w, h, r):
    """A geq expression: 1 inside a w by h rectangle at (x0, y0) whose
    corners are rounded to radius r, else 0."""
    x, y = f"(X-{x0})", f"(Y-{y0})"
    dx = f"max(0,max({r}-{x},{x}-{w - 1 - r}))"
    dy = f"max(0,max({r}-{y},{y}-{h - 1 - r}))"
    return (f"lte({dx}*{dx}+{dy}*{dy},{r * r})"
            f"*between({x},0,{w - 1})*between({y},0,{h - 1})")


def window_graph(scene, length, number, first_input, pts, rest, rate):
    """Filter chains for a windowed clip, from the back: its background (the
    input after the clip), a blurred shadow dropped below the window, and the
    clip with its corners rounded. The mask and the shadow are one frame
    each, repeated by `loop`; every chain is trimmed to the scene's frames,
    since a loop never ends by itself (DEMO-0183 § 4.4)."""
    x, y, w, h = scene.box
    fps, frames = rate_text(rate), max(1, round(length * rate))
    clip_frames = max(1, round((length - scene.hold) * rate))
    one = f"r={fps}:d={1 / rate:.6f}"
    r = max(1, round(WINDOW_CORNER * h))
    sigma, drop = WINDOW_BLUR * h, round(WINDOW_DROP * h)
    pad = math.ceil(3 * sigma) + drop
    n = number
    # A push grows the window and its shadow together, about the window's
    # middle, from their size to push% more as the clip plays (DEMO-0184).
    grow = ""
    if scene.push:
        k = f"(1+{scene.push / 100:.6g}*min(n,{clip_frames})/{clip_frames})"
        grow = f",scale=w='trunc(iw*{k}/2)*2':h='trunc(ih*{k}/2)*2':eval=frame"
    cx, cy = x + w // 2, y + h // 2
    centre = f"x='{cx}-w/2':y='{cy}-h/2'"
    return [
        f"[{first_input}:v]setpts={pts},fps={fps},{zoom_filters(scene, w, h, rate)},"
        f"setsar=1,format=rgba,trim=end_frame={clip_frames}[c{n}]",
        f"color=c=white:s={w}x{h}:{one},format=gray,"
        f"geq=lum='255*{rounded(0, 0, w, h, r)}',"
        f"loop=loop=-1:size=1,trim=end_frame={frames}[m{n}]",
        f"[c{n}][m{n}]alphamerge{grow}[w{n}]",
        f"color=c=black:s={w + 2 * pad}x{h + 2 * pad}:{one},format=rgba,"
        f"geq=r=0:g=0:b=0:a='{round(255 * WINDOW_SHADOW)}*"
        f"{rounded(pad, pad + drop, w, h, r)}',gblur=sigma={sigma:.3f},"
        f"loop=loop=-1:size=1,trim=end_frame={frames}{grow}[s{n}]",
        f"[{first_input + 1}:v][s{n}]overlay={centre}[b{n}]",
        f"[b{n}][w{n}]overlay={centre}:format=auto,{rest}"]


def zoom_filters(scene, w, h, rate):
    """Scale a windowed clip to its window, zooming as its zoom line says:
    the view closes in on a point of the picture and drifts towards it,
    never past the picture's edge, eased in and out (smoothstep) between two
    times, and stays there. The
    clip is first made twice the window's size, so the view moves by half
    pixels and does not jitter (DEMO-0184, after the Slipcase trailer)."""
    zoom = scene.zoom
    if zoom is None:
        return f"scale={w}:{h}"
    begin, finish = zoom["begin"], zoom["finish"]
    p = f"clip((on/{rate:.9g}-{begin:.6f})/{finish - begin:.6f},0,1)"
    z = f"1+{zoom['factor'] - 1:.6g}*{p}*{p}*(3-2*{p})"
    px, py = 2 * w * zoom.get("x", 50) / 100, 2 * h * zoom.get("y", 50) / 100
    return (f"scale={2 * w}:{2 * h},zoompan=z='{z}':d=1:fps={rate_text(rate)}:"
            f"s={w}x{h}:x='max(0,min(iw-iw/zoom,{px:.3f}-iw/zoom/2))':"
            f"y='max(0,min(ih-ih/zoom,{py:.3f}-ih/zoom/2))'")


# A revealed picture starts at this share of its size (DEMO-0185, after the
# Slipcase trailer).
REVEAL_FROM = 0.82


def reveal_filters(scene):
    """A card picture's reveal: it fades in and grows to its full size over
    `reveal` seconds, slowing as it arrives (a cubic ease-out). Sizes stay
    even, and a reveal longer than its card is cut off with the card."""
    if not scene.reveal:
        return ""
    done = f"clip(t/{scene.reveal:.6f},0,1)"
    grow = f"({REVEAL_FROM}+{1 - REVEAL_FROM:.2f}*(1-pow(1-{done},3)))"
    return (f",scale=w='max(2,trunc(iw*{grow}/2)*2)':"
            f"h='max(2,trunc(ih*{grow}/2)*2)':eval=frame,"
            f"fade=t=in:st=0:d={scene.reveal:.6f}:alpha=1")


def finishing_output(raw, inputs, endings):
    """Where a finishing command writes: a path ending as README says, and
    never one of the files it reads."""
    output = own_path(raw, "output")
    if output.suffix.lower() not in endings and len(endings) == 1:
        die(tr("-o must end in {ending}, and {name} does not.", ending=endings[0],
               name=output.name))
    if output.suffix.lower() not in endings:
        die(tr("-o must end in {ending} or {other}, and {name} does not.",
               ending=endings[0], other=endings[1], name=output.name))
    for source in inputs:
        if Path(source).expanduser().resolve() == output:
            die(tr("-o is {output}, which is also a file this command reads. "
                   "demoreel never writes over its own input.", output=output))
    output.parent.mkdir(parents=True, exist_ok=True)
    return output


def finishing_tools():
    """What every finishing command needs installed."""
    missing = [t for t in ("ffmpeg", "ffprobe") if shutil.which(t) is None]
    if missing:
        die(tr("missing required program(s): {programs}", programs=", ".join(missing))
            + install_hint(missing))


def make_film(scenes, raw_output, size=None, rate=None, also_read=(), music=None):
    """Check every scene, then encode the film once and print its path.

    Everything is checked before ffmpeg is started, so a mistake in the last
    scene costs nothing. The film is written beside its output and moved into
    place only when it is whole, so a failure leaves nothing at -o.
    """
    finishing_tools()
    if not has_libx264():
        die(tr("this ffmpeg cannot encode H.264 with libx264, which every video "
               "uses.") + x264_advice(distro_family()))
    output = finishing_output(
        raw_output, [*(s.path for s in scenes if s.path), *also_read,
                     *(e["path"] for s in scenes for e in s.sounds),
                     *([music.path] if music else [])],
        (".mp4",))
    workdir = Path(tempfile.mkdtemp(prefix="edit-", dir=state_dir()))
    part = partial_video(output)
    try:
        width, height, rate = film_shape(scenes, size, rate)
        inputs, chains, lengths = [], [], []
        for number, scene in enumerate(scenes):
            # A scene lasts its picture's whole frames, which trim keeps, so
            # its sound and the scenes after it land where its picture does.
            length = check_scene(scene, width, height, rate, workdir)
            lengths.append(max(1, round(length * rate)) / rate)
            if scene.kind == "clip":
                scene.has_sound = sound_info(scene.path, scene.where)[0]
                if scene.volume is not None and not scene.has_sound:
                    die(tr("{where}: volume changes a clip's own sound, and {path} "
                           "has none.", where=scene.where, path=scene.path))
                if scene.volume is not None and not -60 <= scene.volume <= 20:
                    die(tr("{where}: volume is between {low} and {high} dB, not "
                           "{value}.", where=scene.where, low=-60, high=20,
                           value=f"{scene.volume:g}"))
            if scene.crossfade:
                if number == 0:
                    die(tr("{where}: a crossfade fades from the scene "
                           "before, and this is the first scene.", where=scene.where))
                if scene.fade_in:
                    die(tr("{where}: a scene takes a crossfade or a "
                           "fade-in, not both.", where=scene.where))
                spent = scene.crossfade + scenes[number - 1].crossfade
                if scene.crossfade > lengths[-1] or spent > lengths[-2]:
                    die(tr("{where}: a crossfade of {seconds} "
                           "seconds does not fit: the two scenes overlap for "
                           "that long, and one of them is shorter.",
                           where=scene.where, seconds=f"{scene.crossfade:g}"))
        clip_sound = any(keeps_sound(scene) for scene in scenes)
        film, sound, total, effects = "[v0]", "[a0]", 0.0, []
        for number, (scene, length) in enumerate(zip(scenes, lengths, strict=True)):
            first_input = inputs.count("-i")
            more, graph = scene_graph(scene, length, number, first_input,
                                      width, height, rate)
            inputs += more
            chains += graph
            if clip_sound:
                chains.append(scene_sound(scene, length, number, first_input))
            begins = total - scene.crossfade
            if scene.crossfade:
                chains.append(f"{film}[v{number}]xfade=transition={scene.transition}:"
                              f"duration={scene.crossfade:.4f}:"
                              f"offset={begins:.4f}[f{number}]")
                if clip_sound:
                    chains.append(f"{sound}[a{number}]acrossfade="
                                  f"d={scene.crossfade:.6f}[g{number}]")
            elif number:
                # concat hands on a microsecond timebase, and xfade refuses two
                # inputs whose timebases differ; fps puts the film's back.
                chains.append(f"{film}[v{number}]concat=n=2:v=1:a=0,"
                              f"fps={rate_text(rate)}[f{number}]")
                if clip_sound:
                    chains.append(f"{sound}[a{number}]concat=n=2:v=0:a=1[g{number}]")
            if number:
                film, sound = f"[f{number}]", f"[g{number}]"
            total = begins + length
            name = scene.path.name if scene.path else tr("text")
            # translators: {kind} is clip or card, in English; {name} is a file name, or the
            # translation of "text".
            note(tr("scene {number}: {kind} {name}, {start} to {end}",
                    number=number + 1, kind=scene.kind, name=name,
                    start=f"{begins:.3f}", end=f"{total:.3f}"))
            check_sounds(scene, length, begins)
            for entry in scene.sounds:
                note(tr("  sound {name} at {start}", name=entry["path"].name,
                        start=f"{entry['film_at']:.3f}"))
            effects += scene.sounds
        if music:
            check_music(music, total)
            chains.append(music_graph(music, inputs.count("-i"), total))
            inputs += ["-i", str(music.path)]
            plan_music(music)
        for number, entry in enumerate(effects):
            chains.append(sound_graph(entry, inputs.count("-i"), number, total))
            inputs += ["-i", str(entry["path"])]
        mixed = [*([sound] if clip_sound else []), *(["[m]"] if music else []),
                 *(f"[e{number}]" for number in range(len(effects)))]
        if len(mixed) > 1:
            # normalize=0: the default divides each input by the number of
            # inputs, so none would sit at the level the caller set.
            chains.append(f"{''.join(mixed)}amix=inputs={len(mixed)}:normalize=0:"
                          f"duration=longest,atrim=duration={total:.6f}[s]")
        elif clip_sound:
            chains.append(f"{sound}atrim=duration={total:.6f}[s]")
        audio = (["-map", "[s]" if len(mixed) > 1 or clip_sound else mixed[0],
                  *AUDIO_ENCODER] if mixed else ["-an"])
        note(tr("making {output}, {seconds} seconds at {size}", output=output,
                seconds=f"{total:.3f}", size=f"{width}x{height}"))
        made = subprocess.run(  # noqa: PLW1510 - a failure is handled below
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
             *inputs, "-filter_complex", ";".join(chains), "-map", film,
             # The film ends at its planned length even if a stream does not.
             "-t", f"{total + 0.5 / rate:.4f}",
             *audio, *ENCODER, "-y", str(part)],
            capture_output=True, text=True, timeout=60 + 20 * total,
            preexec_fn=memory_cap)
        unreadable = None if made.returncode else video_error(part)
        if made.returncode or unreadable:
            lines = [ln for ln in made.stderr.splitlines() if ln.strip()]
            die(tr("ffmpeg could not make the film.") + "\n  "
                + ("\n  ".join(lines[-4:]) or unreadable))
        peak = loudest(part) if mixed else None
        if peak is not None and peak >= FULL_SCALE:
            note(tr("the film's sound reaches full scale, so it may be distorted. "
                    "Turn the music, a clip or a sound down to keep it clean."))
        os.replace(part, output)
    except subprocess.TimeoutExpired:
        die(tr("ffmpeg did not finish making the film in the time allowed for "
               "its length, so it was stopped."))
    finally:
        part.unlink(missing_ok=True)
        shutil.rmtree(workdir, ignore_errors=True)
    summarise(output)
    print(output)


def cmd_edit(a):
    """A film from a script (README, Finishing a recording)."""
    try:
        text = sys.stdin.read() if a.script == "-" else Path(
            a.script).expanduser().read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        die(tr("could not read the script {path}: {reason}.", path=a.script,
               reason=getattr(exc, "strerror", None) or exc))
    scenes, music = parse_script(text)
    make_film(scenes, a.output, a.size, a.framerate, music=music)


def shortcut_music(a):
    """The music a shortcut's --music options ask for, as the script's music
    line would say it, or None."""
    given = [(flag, value) for flag, value in (
        ("--music-level", a.music_level), ("--music-fade-in", a.music_fade_in),
        ("--music-fade-out", a.music_fade_out)) if value is not None]
    if a.music is None:
        if given:
            die(tr("{option} sets the music, and needs --music FILE as well.",
                   option=given[0][0]))
        return None
    for flag, value in given:
        if flag != "--music-level" and (value < 0 or not math.isfinite(value)):
            die(tr("{option} takes a number of seconds, not {value}.", option=flag,
                   value=f"{value:g}"))
    music = Music(Path(a.music).expanduser(), "--music")
    music.level, music.fade_in, music.fade_out = (
        a.music_level, a.music_fade_in, a.music_fade_out)
    return music


def without_sound(scenes, a):
    """--no-sound: `sound off` on every clip."""
    if a.no_sound:
        for scene in scenes:
            scene.sound = "off"
    return scenes


def one_clip(path, a):
    scene = Scene("clip", Path(path).name)
    scene.path = Path(path).expanduser()
    scene.fade_in, scene.fade_out = a.fade_in, a.fade_out
    return scene


def cmd_trim(a):
    """One clip, with its from and to."""
    if a.start is None and a.end is None and not (
            a.fade_in or a.fade_out or a.fade_at or a.music or a.no_sound):
        die(tr("trim needs something to do: --from, --to, a fade, --music or "
               "--no-sound."))
    scene = one_clip(a.input, a)
    scene.start, scene.end = a.start or 0.0, a.end
    scene.fade_at, scene.fade_length = a.fade_at, a.fade_length
    make_film(without_sound([scene], a), a.output, music=shortcut_music(a))


def cmd_caption(a):
    """One clip, whole, with its texts."""
    scene = one_clip(a.input, a)
    scene.texts = a.texts
    make_film(without_sound([scene], a), a.output, music=shortcut_music(a))


def cmd_join(a):
    """One clip scene for each video, in order."""
    if len(a.inputs) < 2:
        die(tr("join takes two or more videos; one on its own is already whole."))
    scenes = []
    for path in a.inputs:
        scene = Scene("clip", Path(path).name)
        scene.path = Path(path).expanduser()
        scene.crossfade = a.crossfade if scenes else 0.0
        scenes.append(scene)
    scenes[0].fade_in, scenes[-1].fade_out = a.fade_in, a.fade_out
    make_film(without_sound(scenes, a), a.output, music=shortcut_music(a))


def cmd_card(a):
    """One card. Its size and rate come from --like unless -s and -r say."""
    scene = Scene("card", tr("the card"))
    scene.seconds, scene.background = a.duration, a.background
    scene.path = Path(a.image).expanduser() if a.image else None
    scene.fade_in, scene.fade_out = a.fade_in, a.fade_out
    if a.text is not None:
        scene.texts = [{"words": a.text}
                       | ({"size": a.text_size} if a.text_size else {})]
    size, rate = a.size, a.framerate
    if a.like:
        finishing_tools()
        like = probe(a.like, "--like")
        size = size or f"{like['width']}x{like['height']}"
        rate = rate or like["rate"] or None
    make_film([scene], a.output, size, rate, [a.like] if a.like else [],
              music=shortcut_music(a))


def cmd_motion(a):
    """Report how often a video's picture changes, and where it stands still.

    Read-only, and its report is what goes to stdout. "New" is NEW_FRAME,
    the --gpu note's test as well, whose comment holds its measurements.
    A new frame is one that differs from the last new frame, not from the
    frame before it: a slow drift is noticed once it has added up.
    """
    finishing_tools()
    info = probe(a.input, Path(a.input).name)
    whole = info["duration"]
    start = a.start or 0.0
    end = whole if a.end is None else min(a.end, whole)
    if (a.end is not None and a.end > whole + TIME_SLACK) or start >= end:
        die(tr("--from and --to must be inside {path}, which is "
               "{seconds} seconds long, with --from before --to.", path=a.input,
               seconds=f"{whole:.3f}"))
    try:
        seen = subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "info",
             "-ss", f"{start:.6f}", "-t", f"{end - start:.6f}",
             "-i", str(Path(a.input).expanduser()),
             "-vf", f"showinfo=checksum=0,{NEW_FRAME},"
                    "showinfo=checksum=0",
             "-an", "-f", "null", "-"],
            capture_output=True, text=True, timeout=60 + 10 * (end - start),
            check=False)
    except subprocess.TimeoutExpired:
        die(tr("reading {path} took too long for its length, so it was "
               "stopped.", path=a.input))
    # Two showinfo filters, one each side of the test: the first sees every
    # frame, the second only the new ones.
    every = re.findall(r"Parsed_showinfo_0 .* pts_time:([0-9.]+)", seen.stderr)
    new = [start + float(t) for t in
           re.findall(r"Parsed_showinfo_2 .* pts_time:([0-9.]+)", seen.stderr)]
    if seen.returncode != 0 or not every or not new:
        lines = [ln for ln in seen.stderr.splitlines() if ln.strip()]
        die(tr("could not read the frames of {path}.", path=a.input) + "\n  "
            + "\n  ".join(lines[-2:]))
    stills = [(before, after) for before, after in itertools.pairwise([*new, end])
              if after - before > a.still]
    report = {"duration": round(end - start, 3), "frames": len(every),
              "new_frames": len(new),
              "new_frames_per_second": round(len(new) / (end - start), 2),
              "last_change": round(new[-1], 3),
              "still": [{"from": round(b, 3), "to": round(e, 3),
                         "seconds": round(e - b, 3)} for b, e in stills]}
    if a.json:
        print(json.dumps(report))
        return
    print(f"duration: {report['duration']:.3f}")
    print(f"frames: {report['frames']}")
    print(f"new frames: {report['new_frames']}")
    print(f"new frames per second: {report['new_frames_per_second']:.2f}")
    print(f"last change: {report['last_change']:.3f}")
    for still in report["still"]:
        print(f"still: {still['from']:.3f} to {still['to']:.3f} "
              f"({still['seconds']:.3f})")


def cmd_poster(a):
    """The frame on screen at -t, as a picture."""
    finishing_tools()
    output = finishing_output(a.output, [a.input], (".png", ".jpg"))
    info = probe(a.input, Path(a.input).name)
    whole, step = info["duration"], 1 / (info["rate"] or 30)
    if a.time < 0 or a.time > whole + TIME_SLACK:
        die(tr("-t {time} is not inside {path}, which is {seconds} "
               "seconds long.", time=f"{a.time:g}", path=a.input,
               seconds=f"{whole:.3f}"))
    # The frame on screen at a time is the last one that started at or before
    # it; seeking lands on the first frame at or after the point given, so
    # aim half a frame before that frame's own start.
    frame = min(frame_at(a.time, step),
                max(0, math.ceil(whole / step - 1e-6) - 1))
    part = output.with_name(f".{output.stem}.demoreel-part{output.suffix}")
    try:
        grab = subprocess.run(  # noqa: PLW1510 - a failure is handled below
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
             "-ss", f"{max(0.0, (frame - 0.5) * step):.6f}",
             "-i", str(Path(a.input).expanduser()), "-frames:v", "1",
             *(["-q:v", "3"] if output.suffix.lower() == ".jpg" else []),
             "-y", str(part)],
            capture_output=True, text=True, timeout=6 * CALL_TIMEOUT)
        if grab.returncode != 0 or not part.exists() or not part.stat().st_size:
            lines = [ln for ln in grab.stderr.splitlines() if ln.strip()]
            die(tr("could not take the frame at {time} from {path}.",
                   time=f"{a.time:g}", path=a.input)
                + ("\n  " + "\n  ".join(lines[-2:]) if lines else ""))
        os.replace(part, output)
    except subprocess.TimeoutExpired:
        die(tr("taking the frame took over {seconds}s.", seconds=6 * CALL_TIMEOUT))
    finally:
        part.unlink(missing_ok=True)
    print(output)


def finishing_problems():
    """(why the finishing commands cannot run, why text cannot be drawn), each
    None when it can. `check` reports both; neither decides its exit status."""
    missing = [t for t in ("ffmpeg", "ffprobe") if shutil.which(t) is None]
    if missing:
        why = tr("missing {programs}", programs=", ".join(missing)) + install_hint(missing)
        return why, tr("needs ffmpeg first")
    videos = None if has_libx264() else (
        tr("this ffmpeg has no libx264, which every video uses.")
        + x264_advice(distro_family()))
    workdir = Path(tempfile.mkdtemp(prefix="check-", dir=state_dir()))
    try:
        (workdir / "text.txt").write_text("demoreel")
        wide, _ = text_size(workdir / "text.txt", 40)
        words = None if wide else tr(
            "ffmpeg drew nothing for a line of text: no font was found.")
    except TextUnavailable as exc:
        words = exc.reason
    finally:
        shutil.rmtree(workdir, ignore_errors=True)
    return videos, words


# The examples at the end of --help (DEMO-0050), as (comment, command) pairs:
# the comment is translated and the command never is (§ 4.5). ci.sh parses
# every line that starts with `demoreel `, so each command must stay one the
# tool accepts. A command's later lines are indented two more spaces.
EXAMPLES = [
    ("can this machine record? If not, it says what to install",
     "demoreel check"),
    ("record Kate for 20 seconds",
     "demoreel record -o demo.mp4 -d 20 -- kate"),
    ("one picture instead of a video",
     "demoreel shot -o kate.png -- kate"),
    ("cut the end off a recording, where `demoreel motion demo.mp4` says to",
     "demoreel trim demo.mp4 -o short.mp4 --to 29.8"),
    ("make a film from a script of scenes: clips, cards, text and fades",
     "demoreel edit film.txt -o film.mp4"),
    ("record until you say stop, then stop it from any terminal",
     "demoreel record -o demo.mp4 -d 0 -n mydemo -- kate &\ndemoreel stop mydemo"),
]

RECORD_EXAMPLES = [
    ("record Kate for 20 seconds",
     "demoreel record -o demo.mp4 -d 20 -- kate"),
    ("record until you say stop, then stop it from any terminal",
     "demoreel record -o demo.mp4 -d 0 -n mydemo -- kate &\ndemoreel stop mydemo"),
    ("show the app being used: these steps run while it records",
     "demoreel record -o demo.mp4 -d 20 --cursor \\\n"
     "  -a 'wait 2' -a 'click 400 300' -a 'type hello' -a 'key Return' -- kate"),
    ("an app that needs the graphics card (OpenGL, Vulkan)",
     "demoreel record -o cube.mp4 -d 10 --gpu -- vkcube"),
    ("a Flatpak app, which needs three flags on its own command line",
     "demoreel record -o demo.mp4 -d 20 -- \\\n"
     "  flatpak run --socket=x11 --nosocket=wayland \\\n"
     "  --filesystem=/tmp/.X11-unix org.kde.kate"),
]


def tr_listed(msgid):
    """tr_help for a message held in a table the template extraction reads by
    name -- EXAMPLES, RECORD_EXAMPLES -- where no call can name it as a
    literal. Anything else goes through tr, trn or tr_help."""
    found = _lookup(msgid, None, None, argparse_formats=True)
    return _fill(found[0] if found else msgid, {}, bool(found and found[1]))


def examples_text(examples):
    """The examples section of a --help page, put together at run time, once
    the language is chosen."""
    return tr_help("examples:") + "".join(
        f"\n  # {tr_listed(comment)}\n  " + command.replace("\n", "\n  ") + "\n"
        for comment, command in examples).removesuffix("\n")


def build_parser():
    """The command line. Separate from main() so ci.sh can parse the --help
    examples with it without running anything."""
    parser = Parser(
        prog="demoreel",
        description=tr_help("Record a GUI app on a private virtual display, so nothing "
                            "of your real desktop\nis in frame."),
        epilog=examples_text(EXAMPLES) + "\n\n" + tr_help("more examples:")
        + " demoreel record --help",
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--version", action="version",
                        version=f"demoreel {__version__}")
    sub = parser.add_subparsers(dest="cmd", required=True)

    rec = sub.add_parser("record", help=tr_help("record an app and write a video file"),
                         epilog=examples_text(RECORD_EXAMPLES),
                         formatter_class=argparse.RawDescriptionHelpFormatter)
    rec.add_argument("-o", "--output",
                     help=tr_help("video file to write (default: "
                                  "./<app>-<timestamp>.mp4)"))
    rec.add_argument("-d", "--duration", type=float, default=30,
                     help=tr_help("seconds to record; 0 means until `demoreel stop` "
                                  "(default: 30)"))
    rec.add_argument("-s", "--size", default="1600x1000",
                     help=tr_help("virtual display size, even numbers "
                                  "(default: 1600x1000)"))
    rec.add_argument("-r", "--framerate", type=int, default=30,
                     help=tr_help("frames per second (default: 30)"))
    rec.add_argument("-n", "--name", default="default",
                     help=tr_help("name for this run, so `demoreel stop` can find it "
                                  "(default: default)"))
    rec.add_argument("-a", "--action", action="append", default=[], metavar="ACTION",
                     help=tr_help("scripted step, repeatable and run in order: "
                                  "{steps}", steps="'wait 2', 'move X Y', 'click [X Y]', "
                                  "'type some text', 'key Return', 'hold w 3', "
                                  "'drag X1 Y1 X2 Y2'"))
    rec.add_argument("--steps", metavar="FILE",
                     help=tr_help("read the scripted steps from FILE, one per line, "
                                  "or from standard input for -; keeps typed text "
                                  "out of the process list. Not with -a"))
    rec.add_argument("--app-log", metavar="PATH",
                     help=tr_help("keep the app's own output in PATH; otherwise it goes "
                                  "to this terminal's stderr. Useful when the app prints "
                                  "whether it drew the real thing, which no recording "
                                  "check here can tell"))
    rec.add_argument("--settle", type=float, default=0, metavar="SECONDS",
                     help=tr_help("wait up to SECONDS for the app to draw its first "
                                  "frame before recording starts, instead of filming "
                                  "its startup black screen (default: 0, don't wait)"))
    rec.add_argument("--gpu", action="store_true",
                     help=tr_help("for an app that needs the graphics card (OpenGL, "
                                  "Vulkan): record it on a headless compositor instead "
                                  "of Xvfb, which has no GPU. Needs cage, Xwayland, "
                                  "wlr-randr and wf-recorder; not with --cursor"))
    rec.add_argument("--cursor", action="store_true",
                     help=tr_help("show the mouse pointer; off by default, since with "
                                  "no actions it just sits in the corner"))
    rec.add_argument("--audio", action="store_true",
                     help=tr_help("put the app's own sound in the video. Off by "
                                  "default; either way the app's sound never plays "
                                  "on your speakers. Needs pactl"))
    rec.add_argument("--startup-timeout", type=float, default=20, metavar="SECONDS",
                     help=tr_help("how long to wait for the app's window (default: 20)"))
    rec.add_argument("command", nargs=argparse.REMAINDER,
                     help=tr_help("-- followed by the command that starts the app"))
    rec.set_defaults(func=cmd_record)

    # The options `record` takes, less the ones about time and names: a shot
    # has no duration, no frame rate and nothing to stop (README, Taking a
    # picture). --settle defaults to 10 here, since a picture of a startup
    # screen is never what anyone wanted.
    shot = sub.add_parser("shot", help=tr_help("take one picture of an app and write "
                                               "a PNG"))
    shot.add_argument("-o", "--output",
                      help=tr_help("picture file to write (default: "
                                   "./<app>-<timestamp>.png)"))
    shot.add_argument("-s", "--size", default="1600x1000",
                      help=tr_help("virtual display size (default: 1600x1000)"))
    shot.add_argument("-a", "--action", action="append", default=[],
                      metavar="ACTION",
                      help=tr_help("scripted step run before the picture, repeatable: "
                                   "{steps}", steps="'wait 2', 'move X Y', 'click [X Y]', "
                                   "'type some text', 'key Return', 'hold w 3', "
                                   "'drag X1 Y1 X2 Y2'"))
    shot.add_argument("--steps", metavar="FILE",
                      help=tr_help("read the scripted steps from FILE, one per line, "
                                   "or from standard input for -. Not with -a"))
    shot.add_argument("--app-log", metavar="PATH",
                      help=tr_help("keep the app's own output in PATH; otherwise it "
                                   "goes to this terminal's stderr"))
    shot.add_argument("--settle", type=float, default=10, metavar="SECONDS",
                      help=tr_help("wait up to SECONDS for the app to draw before the "
                                   "steps and the picture (default: 10)"))
    shot.add_argument("--gpu", action="store_true",
                      help=tr_help("for an app that needs the graphics card: use a "
                                   "headless compositor instead of Xvfb. Needs "
                                   "cage, Xwayland and wlr-randr"))
    shot.add_argument("--cursor", action="store_true",
                      help=tr_help("show the mouse pointer in the picture"))
    shot.add_argument("--startup-timeout", type=float, default=20,
                      metavar="SECONDS",
                      help=tr_help("how long to wait for the app's window (default: 20)"))
    shot.add_argument("command", nargs=argparse.REMAINDER,
                      help=tr_help("-- followed by the command that starts the app"))
    shot.set_defaults(func=cmd_shot)

    check = sub.add_parser(
        "check", help=tr_help("test whether this machine can record, without recording"),
        description=tr_help("Report what each backend needs and whether it is "
                            "installed, start a private display to prove it works, "
                            "and name the install command for anything missing. "
                            "Exits non-zero if the default backend cannot record."))
    check.set_defaults(func=cmd_check)

    stop = sub.add_parser("stop", help=tr_help("end a running recording early"))
    stop.add_argument("name", nargs="?", help=tr_help("which run to stop (default: the "
                                                      "only one running)"))
    stop.set_defaults(func=cmd_stop)

    # The finishing commands (README, Finishing a recording). They read and
    # write files; none starts a display or takes a run name. The summary and
    # the description are two messages, so no full stop is joined on.
    def finishing(name, summary, description, output=True):
        p = sub.add_parser(name, help=summary, description=description)
        if output is True:
            output = tr_help("video file to write, ending in .mp4")
        if output:
            p.add_argument("-o", "--output", required=True, help=output)
        return p

    def fades(p):
        p.add_argument("--fade-in", type=float, default=0, metavar="SECONDS",
                       help=tr_help("start black; the picture arrives over SECONDS"))
        p.add_argument("--fade-out", type=float, default=0, metavar="SECONDS",
                       help=tr_help("go to black over the last SECONDS"))

    # The music line's words, and sound off on every clip (DEMO-0177 § 4.2).
    def sound(p, clips=True):
        p.add_argument("--music", metavar="FILE",
                       help=tr_help("lay this sound file under the film, repeated "
                                    "to fill it"))
        p.add_argument("--music-level", type=float, metavar="DB",
                       help=tr_help("the level the music averages, in dB "
                                    "(default: {level})", level=f"{MUSIC_LEVEL:g}"))
        p.add_argument("--music-fade-in", type=float, metavar="SECONDS",
                       help=tr_help("the music arrives over SECONDS (default: "
                                    "{seconds})", seconds=MUSIC_FADE_IN))
        p.add_argument("--music-fade-out", type=float, metavar="SECONDS",
                       help=tr_help("the music goes over the last SECONDS "
                                    "(default: {seconds})", seconds=f"{MUSIC_FADE_OUT:g}"))
        if clips:
            p.add_argument("--no-sound", action="store_true",
                           help=tr_help("leave out the videos' own sound"))

    edit = finishing("edit", tr_help("make a film from a script of scenes"),
                     tr_help("make a film from a script of scenes."))
    edit.add_argument("script", help=tr_help("the script file, or - for standard input"))
    edit.add_argument("-s", "--size", help=tr_help("the film's picture size, like "
                                                   "1280x800 (default: the first clip's)"))
    edit.add_argument("-r", "--framerate", type=int,
                      help=tr_help("the film's frames per second (default: the first "
                                   "clip's)"))
    edit.set_defaults(func=cmd_edit)

    trim = finishing("trim", tr_help("cut the start and the end off a video"),
                     tr_help("cut the start and the end off a video."))
    trim.add_argument("input", help=tr_help("the video to cut"))
    trim.add_argument("--from", dest="start", type=float, metavar="SECONDS",
                      help=tr_help("where the kept part starts (default: the start)"))
    trim.add_argument("--to", dest="end", type=float, metavar="SECONDS",
                      help=tr_help("the last moment kept (default: the end)"))
    fades(trim)
    sound(trim)
    trim.add_argument("--fade-at", type=float, action="append", default=[],
                      metavar="SECONDS",
                      help=tr_help("fade to black and back around this moment; "
                                   "repeatable"))
    trim.add_argument("--fade-length", type=float, default=FADE_LENGTH,
                      metavar="SECONDS",
                      help=tr_help("how long each half of a --fade-at takes "
                                   "(default: {seconds})", seconds=FADE_LENGTH))
    trim.set_defaults(func=cmd_trim)

    caption = finishing("caption", tr_help("put text over part of a video"),
                        tr_help("put text over part of a video."))
    caption.add_argument("input", help=tr_help("the video to put text on"))
    caption.add_argument("--text", action=_CaptionText, dest="texts",
                         default=[], required=True, metavar="WORDS",
                         help=tr_help("one line of text; repeatable"))
    for flag, key, about in (
            ("--from", "start", tr_help("for the --text before it: when it appears")),
            ("--to", "end", tr_help("for the --text before it: when it goes")),
            ("--text-size", "size", tr_help("for the --text before it: its height, as "
                                            "a percentage of the frame's"))):
        caption.add_argument(flag, action=_CaptionField, dest=key, type=float,
                             metavar="PERCENT" if key == "size" else "SECONDS",
                             help=about)
    fades(caption)
    sound(caption)
    caption.set_defaults(func=cmd_caption)

    join = finishing("join", tr_help("put videos end to end"),
                     tr_help("put videos end to end."))
    join.add_argument("inputs", nargs="+", metavar="input",
                      help=tr_help("two or more videos, in order"))
    join.add_argument("--crossfade", type=float, default=0, metavar="SECONDS",
                      help=tr_help("fade from each video into the next over SECONDS "
                                   "(default: a plain cut)"))
    fades(join)
    sound(join)
    join.set_defaults(func=cmd_join)

    card = finishing("card", tr_help("make a clip from a picture, or from text"),
                     tr_help("make a clip from a picture, or from text."))
    card.add_argument("image", nargs="?", help=tr_help("a picture to show; a .gif or "
                                                       "an animated .png plays"))
    card.add_argument("-d", "--duration", type=float, required=True,
                      help=tr_help("how many seconds the card lasts"))
    card.add_argument("--text", metavar="WORDS", help=tr_help("one line of text"))
    card.add_argument("--text-size", type=float, metavar="PERCENT",
                      help=tr_help("the text's height, as a percentage of the "
                                   "frame's (default: {size})", size=CARD_TEXT_SIZE))
    card.add_argument("--background", default=CARD_BACKGROUND,
                      metavar="#RRGGBB",
                      help=tr_help("the background colour (default: {colour})",
                                   colour=CARD_BACKGROUND))
    card.add_argument("--like", metavar="VIDEO",
                      help=tr_help("take the size and frame rate from this video"))
    card.add_argument("-s", "--size", help=tr_help("the picture size, like 1280x800 "
                                                   "(default: 1600x1000)"))
    card.add_argument("-r", "--framerate", type=int,
                      help=tr_help("frames per second (default: 30)"))
    fades(card)
    sound(card, clips=False)
    card.set_defaults(func=cmd_card)

    motion = finishing("motion", tr_help("report how smooth a video is and where it "
                                         "stands still"),
                       tr_help("report how smooth a video is and where it stands "
                               "still."), output=None)
    motion.add_argument("input", help=tr_help("the video to look at"))
    motion.add_argument("--from", dest="start", type=float, metavar="SECONDS",
                        help=tr_help("look from here (default: the start)"))
    motion.add_argument("--to", dest="end", type=float, metavar="SECONDS",
                        help=tr_help("look up to here (default: the end)"))
    motion.add_argument("--still", type=float, default=1.0, metavar="SECONDS",
                        help=tr_help("report a stretch with no change longer than "
                                     "this (default: 1)"))
    motion.add_argument("--json", action="store_true",
                        help=tr_help("print the report as one JSON object"))
    motion.set_defaults(func=cmd_motion)

    poster = finishing("poster", tr_help("save one frame of a video as a picture"),
                       tr_help("save one frame of a video as a picture."),
                       output=tr_help("picture file to write, ending in .png or .jpg"))
    poster.add_argument("input", help=tr_help("the video to take the frame from"))
    poster.add_argument("-t", "--time", type=float, required=True,
                        metavar="SECONDS", help=tr_help("which moment to save"))
    poster.set_defaults(func=cmd_poster)
    return parser


def main():
    # The language is chosen before the parser exists: argparse translates its
    # group titles as the parser is built (§ 4.6).
    use_languages(chosen_languages(os.environ))
    if _catalogs:
        argparse._ = _argparse_gettext
        argparse.ngettext = _argparse_ngettext
    args = build_parser().parse_args()
    for sig in (signal.SIGTERM, signal.SIGHUP):
        signal.signal(sig, _exit_on_signal)
    args.func(args)


if __name__ == "__main__":
    main()
