diff --git a/.gitignore b/.gitignore index 0ec7c741..c2341e80 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ coverage.out ui/coverage/ web/coverage/ .gitnexus + +# Astro build cache and dev log. Regenerated on every build; dev.log in +# particular churns on every run. +web/.astro/ diff --git a/docs/media/01-dashboard.png b/docs/media/01-dashboard.png index 1fec97ae..e4737134 100644 Binary files a/docs/media/01-dashboard.png and b/docs/media/01-dashboard.png differ diff --git a/docs/media/02-routing.png b/docs/media/02-routing.png index fb25479a..2b8160fa 100644 Binary files a/docs/media/02-routing.png and b/docs/media/02-routing.png differ diff --git a/docs/media/03-routing-filtergraph.png b/docs/media/03-routing-filtergraph.png index 5b751183..a6622b22 100644 Binary files a/docs/media/03-routing-filtergraph.png and b/docs/media/03-routing-filtergraph.png differ diff --git a/docs/media/04-meters.png b/docs/media/04-meters.png index daf2aad3..5fbf0044 100644 Binary files a/docs/media/04-meters.png and b/docs/media/04-meters.png differ diff --git a/docs/media/05-sources.png b/docs/media/05-sources.png index 387bcf1e..1b7eec2b 100644 Binary files a/docs/media/05-sources.png and b/docs/media/05-sources.png differ diff --git a/docs/media/06-renditions.png b/docs/media/06-renditions.png index 847b2f64..44201192 100644 Binary files a/docs/media/06-renditions.png and b/docs/media/06-renditions.png differ diff --git a/docs/media/07-monitoring.png b/docs/media/07-monitoring.png index 2f50e8ff..b541c02f 100644 Binary files a/docs/media/07-monitoring.png and b/docs/media/07-monitoring.png differ diff --git a/docs/media/08-mix-matrix.png b/docs/media/08-mix-matrix.png index e2e3caf7..109b28b3 100644 Binary files a/docs/media/08-mix-matrix.png and b/docs/media/08-mix-matrix.png differ diff --git a/docs/media/09-chat.png b/docs/media/09-chat.png index f75e84c5..1a488ce6 100644 Binary files a/docs/media/09-chat.png and b/docs/media/09-chat.png differ diff --git a/docs/media/10-playout.png b/docs/media/10-playout.png index 58d072be..a6b78edf 100644 Binary files a/docs/media/10-playout.png and b/docs/media/10-playout.png differ diff --git a/docs/media/11-library.png b/docs/media/11-library.png index 9ff56f71..a26b9ebc 100644 Binary files a/docs/media/11-library.png and b/docs/media/11-library.png differ diff --git a/docs/media/12-jobs.png b/docs/media/12-jobs.png index 5ddd271b..4d24f766 100644 Binary files a/docs/media/12-jobs.png and b/docs/media/12-jobs.png differ diff --git a/docs/media/13-automation.png b/docs/media/13-automation.png index b70a3378..9ab41d53 100644 Binary files a/docs/media/13-automation.png and b/docs/media/13-automation.png differ diff --git a/docs/media/14-clips.png b/docs/media/14-clips.png index 237133ea..25680102 100644 Binary files a/docs/media/14-clips.png and b/docs/media/14-clips.png differ diff --git a/docs/media/15-recordings.png b/docs/media/15-recordings.png index 844ead10..aca20423 100644 Binary files a/docs/media/15-recordings.png and b/docs/media/15-recordings.png differ diff --git a/docs/media/16-settings.png b/docs/media/16-settings.png index 36b66e9c..0f6b751c 100644 Binary files a/docs/media/16-settings.png and b/docs/media/16-settings.png differ diff --git a/docs/media/17-settings-full.png b/docs/media/17-settings-full.png index bf9cbc9d..920eade4 100644 Binary files a/docs/media/17-settings-full.png and b/docs/media/17-settings-full.png differ diff --git a/docs/media/18-destinations.png b/docs/media/18-destinations.png new file mode 100644 index 00000000..5e2e8184 Binary files /dev/null and b/docs/media/18-destinations.png differ diff --git a/scripts/demo-seed.fixture.json b/scripts/demo-seed.fixture.json new file mode 100644 index 00000000..f7008be0 --- /dev/null +++ b/scripts/demo-seed.fixture.json @@ -0,0 +1,418 @@ +{ + "_": [ + "The demo installation, as data. scripts/demo-seed.sh stands it up and", + "scripts/demo_seed_driver.go applies it; neither of them decides what is in", + "it, which is why this file exists separately from both.", + "", + "TWO RULES GOVERN EVERY VALUE BELOW, and they are not style preferences.", + "", + "1. NO URL HERE MAY RESOLVE. Every hostname is under .invalid, which RFC", + " 2606 reserves so that it can never be delegated. A demo seed that", + " publishes to a real ingest is a broadcast nobody meant to make, and the", + " person running it would find out from their audience.", + "", + "2. NO VALUE HERE MAY RESEMBLE A CREDENTIAL. This repository runs gitleaks", + " over its whole history and has already published an advisory about", + " stream keys escaping into logs. The keys below are deliberately", + " repetitive, low-entropy and self-describing so that a human reading a", + " screenshot, a log line or this file cannot mistake one for the real", + " thing -- and so the scanner does not have to be told to ignore them.", + " scripts/demo-seed.sh runs gitleaks against this file when it is", + " installed, rather than trusting that the rule above was followed.", + "", + "The platform destinations are created DISABLED and that is the honest", + "consequence of rule 1: an enabled destination pointing at a name that", + "cannot resolve spends the whole capture in failed/reconnecting, and a", + "dashboard of red cards is a photograph of a broken product. Disabled they", + "read 'Stopped' -- configured, armed, not started -- which is the state an", + "install is genuinely in ten minutes before a show. Everything that CAN run", + "without leaving the machine does run: the ingests, the rendition ladder,", + "the archive and compliance file destinations, the podcast mix, the", + "meters." + ], + + "settings": { + "recording": { + "enabled": false, + "segmentSeconds": 600, + "maxGb": 500, + "maxAgeHours": 8760, + "minFreeGb": 1 + }, + "preview": { "enabled": true }, + "playout": { "enabled": true } + }, + + "sources": [ + { + "name": "Main Programme — Studio A", + "video": { "lavfi": "testsrc2=size=1920x1080:rate=30", "kbps": 6000 }, + "audio": [ + { + "label": "Host mic", + "role": "mic", + "language": "en", + "lavfi": "sine=frequency=180:sample_rate=48000,volume=volume='0.40+0.28*sin(2*PI*t/4.3)':eval=frame" + }, + { + "label": "Co-host mic", + "role": "commentary", + "language": "en", + "lavfi": "sine=frequency=430:sample_rate=48000,volume=volume='0.30+0.24*sin(2*PI*t/6.7)':eval=frame" + }, + { + "label": "Music bed (licensed)", + "role": "music", + "lavfi": "sine=frequency=980:sample_rate=48000,volume=volume='0.22+0.10*sin(2*PI*t/11.0)':eval=frame" + }, + { + "label": "Crowd / room", + "role": "other", + "lavfi": "sine=frequency=2400:sample_rate=48000,volume=volume='0.10+0.07*sin(2*PI*t/8.1)':eval=frame" + } + ], + "renditions": [ + { + "name": "1080p — primary", + "width": 1920, "height": 1080, "fps": 30, + "videoBitrate": 6000, "maxrateKbps": 6600, "bufsizeKbps": 12000, + "encoder": "libx264", "preset": "ultrafast", "gopSeconds": 2, + "note": "Full-resolution tier. YouTube and the contribution feed take this one." + }, + { + "name": "720p — secondary", + "width": 1280, "height": 720, "fps": 30, + "videoBitrate": 3000, "maxrateKbps": 3300, "bufsizeKbps": 6000, + "encoder": "libx264", "preset": "ultrafast", "gopSeconds": 2, + "note": "The tier most platforms actually want. Twitch and Facebook share it." + }, + { + "name": "480p — mobile", + "width": 854, "height": 480, "fps": 30, + "videoBitrate": 1200, "maxrateKbps": 1400, "bufsizeKbps": 2400, + "encoder": "libx264", "preset": "ultrafast", "gopSeconds": 2, + "note": "Low-bitrate tier for constrained uplinks and the compliance log." + } + ], + "destinations": [ + { + "name": "YouTube — main channel", + "kind": "rtmp", "platform": "youtube", + "url": "rtmp://a.rtmp.ingest.demo.invalid/live2", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "rendition": "1080p — primary", + "enabled": false, + "audioBitrate": 192, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 0.8 }, + { "track": 3, "enabled": true, "gain": 0.45 } + ], + "normalize": "loudnorm", + "sampleRate": 48000, + "loudness": { "targetLufs": -14, "truePeakDb": -1 } + } + }, + { + "name": "Twitch — primary", + "kind": "rtmp", "platform": "twitch", + "url": "rtmp://lhr.contribute.demo.invalid/app", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "rendition": "720p — secondary", + "enabled": false, + "audioBitrate": 160, + "profile": { + "mode": "matrix", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": false, "gain": 1.0 }, + { "track": 3, "enabled": true, "gain": 0.4 } + ], + "matrix": [ + { "track": 0, "channel": 0, "out": 0, "gain": 1.0 }, + { "track": 0, "channel": 0, "out": 1, "gain": 1.0 }, + { "track": 1, "channel": 0, "out": 0, "gain": 0.85 }, + { "track": 1, "channel": 0, "out": 1, "gain": 0.85 }, + { "track": 3, "channel": 0, "out": 0, "gain": 0.4 }, + { "track": 3, "channel": 1, "out": 1, "gain": 0.4 } + ], + "normalize": "loudnorm", + "sampleRate": 48000, + "loudness": { "targetLufs": -14, "truePeakDb": -1 }, + "excludeRoles": ["music"] + } + }, + { + "name": "Facebook Live — page", + "kind": "rtmp", "platform": "facebook", + "url": "rtmps://live-api-s.demo.invalid:443/rtmp/", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "rendition": "720p — secondary", + "enabled": false, + "audioBitrate": 128, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": false, "gain": 1.0 }, + { "track": 3, "enabled": true, "gain": 0.4 } + ], + "normalize": "loudnorm", + "sampleRate": 48000, + "loudness": { "targetLufs": -16, "truePeakDb": -1 }, + "excludeRoles": ["music"] + } + }, + { + "name": "Kick — mobile tier", + "kind": "rtmp", "platform": "kick", + "url": "rtmp://ingest.demo.invalid/live", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "rendition": "480p — mobile", + "enabled": false, + "audioBitrate": 128, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 0.7 }, + { "track": 3, "enabled": true, "gain": 0.35 } + ], + "normalize": "auto", + "sampleRate": 48000 + } + }, + { + "name": "Partner CDN — custom RTMP", + "kind": "rtmp", "platform": "custom", + "url": "rtmp://origin.partner-cdn.demo.invalid/live", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "rendition": "1080p — primary", + "enabled": false, + "audioBitrate": 192, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 0.8 }, + { "track": 3, "enabled": true, "gain": 0.45 } + ], + "normalize": "auto", + "sampleRate": 48000 + } + }, + { + "name": "Master control — SRT contribution", + "kind": "srt", "platform": "custom", + "url": "srt://master-control.demo.invalid:9001?mode=caller&latency=200000", + "rendition": "1080p — primary", + "enabled": false, + "audioBitrate": 256, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 1.0 }, + { "track": 3, "enabled": true, "gain": 1.0 } + ], + "normalize": "off", + "sampleRate": 48000 + } + }, + { + "name": "Archive — as-transmitted master", + "kind": "file", "platform": "custom", + "url": "archive-main-full-mix.mkv", + "rendition": "1080p — primary", + "enabled": true, + "audioBitrate": 256, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 1.0 }, + { "track": 3, "enabled": true, "gain": 1.0 } + ], + "normalize": "off", + "sampleRate": 48000 + } + }, + { + "name": "Compliance log — 480p", + "kind": "file", "platform": "custom", + "url": "compliance-log.mkv", + "rendition": "480p — mobile", + "enabled": true, + "audioBitrate": 96, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": true, "gain": 1.0 }, + { "track": 3, "enabled": true, "gain": 1.0 } + ], + "normalize": "auto", + "sampleRate": 48000 + } + }, + { + "name": "Podcast mix — mics only", + "kind": "file", "platform": "custom", + "url": "podcast-mix-mics-only.mkv", + "rendition": "720p — secondary", + "enabled": true, + "audioBitrate": 192, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 }, + { "track": 2, "enabled": false, "gain": 1.0 }, + { "track": 3, "enabled": false, "gain": 1.0 } + ], + "normalize": "loudnorm", + "sampleRate": 48000, + "loudness": { "targetLufs": -16, "truePeakDb": -1 }, + "excludeRoles": ["music"] + } + } + ] + }, + + { + "name": "Commentary Booth — Studio B", + "video": { "lavfi": "testsrc2=size=1280x720:rate=30", "kbps": 3000 }, + "audio": [ + { + "label": "Commentary A", + "role": "commentary", + "language": "en", + "lavfi": "sine=frequency=320:sample_rate=48000,volume=volume='0.34+0.24*sin(2*PI*t/5.1)':eval=frame" + }, + { + "label": "Commentary B (es)", + "role": "commentary", + "language": "es", + "lavfi": "sine=frequency=760:sample_rate=48000,volume=volume='0.26+0.20*sin(2*PI*t/7.9)':eval=frame" + } + ], + "renditions": [], + "destinations": [ + { + "name": "YouTube — Spanish commentary", + "kind": "rtmp", "platform": "youtube", + "url": "rtmp://b.rtmp.ingest.demo.invalid/live2", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "enabled": false, + "audioBitrate": 128, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": false, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 } + ], + "normalize": "auto", + "sampleRate": 48000 + } + }, + { + "name": "Archive — commentary booth", + "kind": "file", "platform": "custom", + "url": "archive-studio-b.mkv", + "enabled": true, + "audioBitrate": 192, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 1.0 } + ], + "normalize": "off", + "sampleRate": 48000 + } + } + ] + }, + + { + "name": "Vertical Cut — 9:16", + "video": { "lavfi": "testsrc2=size=720x1280:rate=30", "kbps": 2500 }, + "audio": [ + { + "label": "Programme mix", + "role": "clean", + "language": "en", + "lavfi": "sine=frequency=250:sample_rate=48000,volume=volume='0.36+0.26*sin(2*PI*t/3.7)':eval=frame" + }, + { + "label": "Music bed (licensed)", + "role": "music", + "lavfi": "sine=frequency=1400:sample_rate=48000,volume=volume='0.20+0.12*sin(2*PI*t/9.3)':eval=frame" + } + ], + "renditions": [], + "destinations": [ + { + "name": "Kick — vertical", + "kind": "rtmp", "platform": "kick", + "url": "rtmp://ingest.demo.invalid/live", + "streamKey": "demo-not-a-real-key-demo-not-a-real-key", + "enabled": false, + "audioBitrate": 128, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": true, "gain": 0.7 } + ], + "normalize": "auto", + "sampleRate": 48000 + } + }, + { + "name": "Archive — vertical clean", + "kind": "file", "platform": "custom", + "url": "archive-vertical-clean.mkv", + "enabled": true, + "audioBitrate": 160, + "profile": { + "mode": "simple", + "tracks": [ + { "track": 0, "enabled": true, "gain": 1.0 }, + { "track": 1, "enabled": false, "gain": 1.0 } + ], + "normalize": "auto", + "sampleRate": 48000, + "excludeRoles": ["music"] + } + } + ] + } + ], + + "_recordings": [ + "CONTIGUOUS ON PURPOSE: each entry starts exactly where the previous one", + "ended. The library groups segments into sessions by the gap between them,", + "so five recordings an hour apart are five one-segment sessions -- which is", + "what a recorder that crashed four times looks like, not what a two-night", + "archive looks like. Two runs here: last night in three segments, and the", + "night before in two." + ], + "recordings": [ + { "startedAgoMinutes": 1290, "minutes": 10 }, + { "startedAgoMinutes": 1280, "minutes": 10 }, + { "startedAgoMinutes": 1270, "minutes": 6 }, + { "startedAgoMinutes": 4020, "minutes": 10 }, + { "startedAgoMinutes": 4010, "minutes": 4 } + ] +} diff --git a/scripts/demo-seed.sh b/scripts/demo-seed.sh new file mode 100755 index 00000000..b04aa4a7 --- /dev/null +++ b/scripts/demo-seed.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# +# Stand up a believable polyemesis installation locally, and photograph it. +# +# ./scripts/demo-seed.sh seed, capture, leave it running +# ./scripts/demo-seed.sh --no-capture seed only, then hand it over +# ./scripts/demo-seed.sh --out DIR write the screenshots somewhere else +# ./scripts/demo-seed.sh --down remove everything this created +# +# WHY THIS EXISTS. capture.spec.ts drives a real browser against a real server +# and waits for real state before every shot, which is the right design and is +# worth nothing pointed at an empty install. Every install available to point it +# at IS empty -- the OVH box has one source, no destinations and no recordings -- +# so the committed screenshots are of a product with nothing in it. This builds +# the thing worth photographing: three programmes, a rendition ladder, +# destinations across every platform the product supports, live multitrack +# audio, and a recording library. +# +# WHAT IT WILL NOT DO. +# +# NOTHING REACHES A REAL PLATFORM. Every destination URL in the fixture is +# under .invalid, which RFC 2606 reserves so it can never be delegated, and +# the platform destinations are created DISABLED. A demo seed that publishes +# to a real ingest is a broadcast nobody meant to make. +# +# NOTHING RUNS AGAINST YOUR DATA. The server this starts uses a data +# directory THIS SCRIPT CREATES, under $TMPDIR, and refuses to reuse one it +# finds already there without --reset. That is the answer to "run by someone +# on a machine that may already have data": there is nothing to collide with, +# because none of it is where anything else lives. The seeder carries a +# second, independent refusal for the --base case -- see refuseIfOccupied in +# demo_seed_driver.go. +# +# IT LEAVES NOTHING BEHIND. --down removes the containers, the network and +# the data directory. That is the whole of what this creates. +# +# Nothing here runs in CI. It builds an image, renders half a gigabyte of media +# and takes several minutes. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE="$ROOT/scripts/demo-seed.fixture.json" +DRIVER="$ROOT/scripts/demo_seed_driver.go" + +PORT="${DEMO_PORT:-8098}" +SRT_PORT="${DEMO_SRT_PORT:-6000}" +WORK="${DEMO_WORK:-${TMPDIR:-/tmp}/polyemesis-demo-seed}" +DATA="$WORK/data" +STAGE="$DATA/.demo-seed" +# docs/media is where capture.spec.ts has always written and where +# docs/media/README.md says the shots live. Overridable because the verification +# run for a change to this script should not overwrite committed artefacts to +# prove it worked. +OUT="${SHOT_DIR:-$ROOT/docs/media}" + +IMAGE="${IMAGE:-polyemesis-demo-seed:local}" +NET="polyemesis-demo-net" +SRV="polyemesis-demo-server" +PUB_PREFIX="polyemesis-demo-pub" + +CAPTURE=yes +RESET=no +DOWN=no +BASE="" + +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + --no-capture) CAPTURE=no; shift ;; + --reset) RESET=yes; shift ;; + --down) DOWN=yes; shift ;; + --base) BASE="$2"; shift 2 ;; + -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +# Everything this script starts is a container or a directory it made, so +# teardown is exactly those two things. No pkill: there are no host processes to +# kill, and the ones the previous generation of this script tried to kill had +# stopped existing when it moved to containers. +teardown() { + # Named one at a time rather than piped into xargs: BSD xargs has no -r, so + # an empty list would run `docker rm -f` with no arguments and print an error + # on the ordinary path where there is nothing to remove. + local name + while read -r name; do + [ -n "$name" ] && docker rm -f "$name" >/dev/null 2>&1 + done < <(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E "^${PUB_PREFIX}-|^${SRV}\$" || true) + docker network rm "$NET" >/dev/null 2>&1 || true +} + +if [ "$DOWN" = yes ]; then + echo "==> removing the demo install" + teardown + command rm -rf "$WORK" + echo " containers, network and $WORK are gone" + exit 0 +fi + +command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; } +docker info >/dev/null 2>&1 || { echo "docker is not running" >&2; exit 1; } +command -v go >/dev/null || { echo "go is required (it runs the seeder)" >&2; exit 1; } + +# THE FIXTURE IS SCANNED BEFORE IT IS USED, not after it is committed. +# +# It carries stream keys, and this repository already has a published advisory +# about stream keys escaping. The keys in it are deliberately unconvincing, and +# "deliberately" is a claim that has to be checked by the thing that will fail +# CI rather than by the person who wrote them. Skipped, loudly, when gitleaks is +# not installed: a missing local tool should cost a warning, not the run. +if command -v gitleaks >/dev/null 2>&1; then + if gitleaks detect --no-git --source "$FIXTURE" -c "$ROOT/.gitleaks.toml" >/dev/null 2>&1; then + echo "==> fixture is clean under gitleaks" + else + echo "the demo fixture trips gitleaks. Its fake keys must not resemble real" >&2 + echo "ones -- make them more obviously fake, do not allowlist them." >&2 + gitleaks detect --no-git --source "$FIXTURE" -c "$ROOT/.gitleaks.toml" -v 2>&1 | tail -20 >&2 + exit 1 + fi +else + echo "==> gitleaks not installed; fixture not scanned locally (CI still will)" +fi + +# One password for the seeder and for Playwright's auth setup, generated per +# run. They each used to carry their own literal, and when the two disagreed the +# seeder created the account and the browser then failed on a missing