RFC-033 — Video & Live Feeds: media-provenance manifest, click-to-load player, live-feed pipeline
Status: Draft (epic kickoff) · 2026-07 · Closes: PRD-031 · Tracking: #413 · target: v0.8.x
Why this is an RFC. Adding motion to the media layer binds commitments that must be right from slice 0 or every later slice forces a rewrite: (1) a
video-provenancemanifest + build/validate path that is the single source of truth for every clip we link; (2) a hand-rolled click-to-load player facade whose no-eager-iframe contract is load-bearing for perf and privacy; (3) a live-feed pipeline that reuses$lib/launchesfor launch broadcasts and pins the ISS stream, with truthful time-gating; (4) provenance/credits + i18n + a11y wiring consistent with the image/link systems. We host zero video bytes — everything is link-embed. UX/nav placement is specified separately (UXS note, PRD-031 open Q2).
1 · Architecture overview
Author time Build time Runtime
----------- ---------- -------
entity JSON videos:[{id}] ─┐
├─► build-video-provenance.ts ─► static/data/video-provenance.json
curated source rows ───────┘ │ (allowlist-checked, │
(channel/url/kind/license) │ canonicalised, hashed id) ▼
▼ $lib/video-provenance.ts
validate-data (schema + gates) getVideoProvenance(id) / byEntity(id)
│
┌──────────────────────┼───────────────────────┐
▼ ▼ ▼
<MediaPlayer> Gallery interleave /credits (Video section)
(facade → embed, (poster + play badge) (grouped by source-family)
interstitial, states)
Live (P2): $lib/launches (webcast_live + LaunchProvenanceLink) ─► $lib/live-feeds.ts
static curated pins (ISS) ─► time-gating + state machine ─► /liveTwo data spines, one player. P1 is static (manifest built at author time). P2 is dynamic (derived at request/build time from the launches manifest + a tiny curated pin list). Both render through the same <MediaPlayer> facade and the same state machine.
2 · Design decisions (to lock)
| id | Decision | Rationale |
|---|---|---|
| V-A | Link + embed only; zero video bytes in the repo | Launch/broadcast footage is copyrighted (unlike NASA stills). We host only poster stills. Kills storage/CDN/transcode + most legal risk. (PRD-031 decision 1.) |
| V-B | Click-to-load facade — the embed <iframe>/<video> mounts only on user interaction | A wall of eager live embeds repeats the #360 render-storm, ×N worse with video. Non-negotiable; e2e-enforced (§10). |
| V-C | Hand-rolled facade, no dependency; provider behind an adapter interface | Multi-provider (youtube-nocookie / vimeo / agency HLS+mp4) from day 1; we own perf + privacy. No supply-chain add. (PRD-031 decision 8.) |
| V-D | video-provenance.json is a sibling of link-provenance, not image-provenance | We link, we don't download. Reuse the link-provenance canonicalisation + last_verified + fair-use discipline; do not pollute the image manifest. |
| V-E | Manifest is the single source of truth; entities reference by id | videos:[{id}] on an entity resolves into the manifest, exactly like gallery[*]. Survives renames; one place to verify + credit. |
| V-F | Live = derived, never authored | Launch broadcasts come from $lib/launches (webcast_live, LaunchProvenanceLink); only the ISS pin is a hand-authored curated row. No manual launch-webcast list to rot. |
| V-G | Truthful state machine (idle → loading → live | playing → ended | offline | error | unavailable) | "Live" must mean live now; ended/offline/geo-blocked are first-class states, never spinners. Tested. |
| V-H | Loss-of-life footage gated by a click-through interstitial | content_advisory: "loss-of-life" forces an explicit continue-confirm before the embed mounts, not just a badge. (PRD-031 decision 9.) |
| V-I | Escape contract honored | The player modal registers Escape in the capture phase consistent with panorama-keys.ts, so on surface routes it closes the player without also closing the underlying Panel. (Carry-forward from the 2026-07-15 fix.) |
3 · The video-provenance manifest
static/data/video-provenance.json — built by scripts/build-video-provenance.ts, validated against static/data/schemas/video-provenance.schema.json, consumed by $lib/video-provenance.ts.
{
"schema_version": 1,
"generated_at": "<date-time>",
"script_version": "build-video-provenance@1.0.0",
"commit_sha": "<sha|null>",
"entries": [{
"id": "vid-<8+ hash>", // stable, hash-derived (survives reorder/rename)
"entity_id": "apollo-11",
"entity_kind": "mission", // mission | launch-site | fleet | landing-site | live-pin
"provider": "youtube", // youtube | vimeo | agency-hls | agency-mp4
"provider_ref": "<id-or-url>", // youtube video id, vimeo id, or absolute media URL
"source_url": "https://…", // canonical watch/detail page (canonicalised, utm-stripped)
"channel": "NASA", // uploading channel/account
"agency": "NASA", // operating agency/publisher (parity-checked)
"title": "Apollo 11 — Saturn V liftoff",
"caption": "…", // authored; translated ×14 via the i18n pipeline
"kind": "launch", // launch|landing|edl|milestone|accident|rollout|broadcast-archive|animation
"poster": "/images/…/poster.jpg", // hosted PD/CC still OR null → provider thumbnail at runtime
"duration_seconds": 132,
"start_seconds": 0, // deep-link into a moment; player seeks on load
"license_or_fair_use": "NASA video — U.S. Government work, public domain",
"content_advisory": null, // null | "loss-of-life" | "graphic"
"last_verified": "2026-07-16"
}]
}Notes:
idisvid-+ a hash of(provider, provider_ref)so the same source video dedupes and survives renumbering (mirrors image-provenance's rename-survival goal).source_urlruns through the samecanonicaliseLinkUrlused by link-provenance (utm/fbclid/gclid strip, fragment strip) so credits dedupe cleanly.animationkindis the honesty hook — CGI/animation is labeled distinctly from real footage (PRD-031 principle 7).
3.1 Build + validate
build-video-provenance.tsreads curated author rows (astatic/data/video-sources/*.jsoncor inline entity refs — chosen in §11), checks eachchannelagainst the canonical-channel allowlist (scripts/video-channel-allowlist.ts), canonicalises URLs, computes ids, and emits the manifest.validate-datagains avalidate-video-provenancestep: schema-valid; everyvideos:[{id}]on an entity resolves to a manifest entry; every entry'schannel ∈ allowlist; every entry has a non-emptylicense_or_fair_use+last_verified; global-agency parity report (warn if the curated set is >X% single-agency). Gate is hard on schema/allowlist/resolution, warn on parity.- Link-rot guard: extend the existing link-verification tooling to HEAD-check
source_url+ provider oEmbed availability; stale entries flagged bylast_verifiedage, not auto-deleted.
4 · $lib/video-provenance.ts (runtime)
Mirrors link-provenance.ts:
export type VideoProvider = 'youtube' | 'vimeo' | 'agency-hls' | 'agency-mp4';
export type VideoKind = 'launch'|'landing'|'edl'|'milestone'|'accident'|'rollout'|'broadcast-archive'|'animation';
export type ContentAdvisory = null | 'loss-of-life' | 'graphic';
export interface VideoProvenanceEntry { /* the manifest entry above */ }
export async function getVideoManifest(): Promise<VideoProvenanceManifest | null>;
export async function getVideo(id: string): Promise<VideoProvenanceEntry | null>;
export async function getVideosForEntity(entityId: string): Promise<VideoProvenanceEntry[]>;
export function embedUrlFor(entry: VideoProvenanceEntry): string; // provider adapter → nocookie/HLS urlCached module-singleton + __resetVideoProvenanceCache() for tests, exactly like link-provenance.
5 · <MediaPlayer> — the facade component
src/lib/components/MediaPlayer.svelte. Two visual forms sharing one core: inline card (in a gallery rail) and modal/lightbox (expanded).
┌ poster (hosted still or provider thumb) ────────┐
│ ▶ duration chip · kind icon · [advisory badge] │ state: idle
└──────────────────────────────────────────────────┘
click ─► (if content_advisory) interstitial confirm ─► mount embed (loading → playing)- Facade first (V-B). Renders an
<img>poster + a<button>play affordance. No iframe/HLS in the DOM until click.prefers-reduced-motion: poster never animates. - Provider adapter interface —
{ embedUrl(entry), mount(el, entry), destroy() }.youtube→youtube-nocookie.com/embed/<id>?start=…&rel=0&modestbranding=1;vimeo→player.vimeo.com/video/<id>;agency-hls→<video>+ a tiny HLS shim (or native on Safari);agency-mp4→ plain<video>. Adding a provider = a new adapter, not a component change (V-C). - Interstitial (V-H). If
content_advisory === 'loss-of-life', click opens a confirm ("This footage shows a fatal accident. Continue?") — the embed mounts only on explicit confirm.graphic→ same pattern, different copy. - State machine (V-G).
idle → loading → playing → ended; error paths →offline | error | unavailable, each with honest copy + a secondary "watch on<source>" link-out (never the default action, PRD-031 principle 2). - Escape (V-I). Modal registers Escape in capture phase,
stopPropagation()while open, so surface-route Panels don't also close. Reuses/extends thepanorama-keys.tspattern. - a11y —
<button>with the localized title as accessible name; modal is a focus-trapped dialog; posteralt= title; the interstitial is a proper alertdialog; caption/transcript link surfaced when the source provides one.
6 · Gallery integration (P1)
- Entities gain
videos:[{id}]alongsidegallery:[…]. Resolved viagetVideosForEntity. - Videos interleave in the existing gallery rail, visually marked (play badge + duration chip + kind icon), ordering per the existing gallery-numbering discipline.
- Clicking a video tile opens the
<MediaPlayer>modal over the surface — pointer-event discipline respected: hover/focus flip lightweight state only; the click is what gates the (heavy) embed mount (carry-forward from the render-storm lesson).
7 · Live-feed pipeline (P2)
src/lib/live-feeds.ts produces a unified LiveFeed[] from two sources:
export interface LiveFeed {
id: string;
kind: 'iss-permanent' | 'launch-broadcast';
title: string; agency: string; channel: string;
provider: VideoProvider; provider_ref: string; source_url: string;
state: 'live' | 'imminent' | 'offline' | 'ended';
starts_at?: string; // for imminent launch broadcasts (from launch NET)
license_or_fair_use: string; last_verified: string;
}
export async function getLiveFeeds(now: Date): Promise<LiveFeed[]>;- ISS pin — one (or a small ordered fallback list) hand-authored curated row (
entity_kind: 'live-pin'in the video manifest). State derived from provider liveness (oEmbed / a lightweight liveness probe) →live | offline. Neverended. - Launch broadcasts — read the launches manifest: an entry contributes a
launch-broadcastfeed whenwebcast_live === trueor its status is upcoming andNETis within the imminent window (e.g. T-60min → T+window). Webcast URL + credit come from itsLaunchProvenanceLink. Past its window → dropped (neverendedin the list; V-F: no archive here). - Time-gating is pure and testable:
getLiveFeeds(now)is a function of(launches manifest, curated pins, now). No wall-clock hidden in components.
7.1 /live route
- Server/
loadcomputesgetLiveFeeds(now); ISS pinned first, then launch broadcasts sorted by state (live→imminent) then time. - Each feed renders through
<MediaPlayer>(facade — nothing auto-plays). Empty launch state → honest "no live broadcasts right now — next up:<name>at<time>" using the launch calendar the site already has. - A small "live now" pill can surface on
/missions(the launch calendar from PRD-020) linking to/livewhen any feed islive— P2 S6 polish, not core.
8 · Credits integration
/creditsgains a Video section (per-clip rows: poster thumbnail, title, channel · agency, license/fair-use,source_url,last_verified). This is the correct surface:/creditsdiscloses reused / third-party material (images, text, audio, links), and linked video is third-party.- Not
/colophon./colophonis the bill-of-materials for Orrery's original work (our diagrams, posters, UI, writing, tours). Linked video is not original, so it is disclosed on/credits, not/colophon. (Supersedes an earlier draft that placed it on/colophon.)
9 · i18n & a11y
- Chrome strings (play, watch-on, live, offline, ended, advisory copy, empty states) go through the UI-string pipeline →
messages/en-US.json+m.key()→ translate ×14 →i18n:compile. - Per-video
title+captionare authored content, translated via the content translation path (not the UI-string path), consistent with how gallery captions/overlays are localized. - a11y per §5; additionally every video is announced as a titled, described control — never an opaque embed — satisfying the "not a black box to a screen reader" principle.
10 · Performance contract (enforced)
- No
<iframe>/HLS/<video>in the DOM before user interaction. e2e asserts: load a gallery route with videos →page.locator('iframe').count() === 0; click a video tile → confirm the embed mounts. Same assertion on/live(facades only at rest). - Poster stills follow the existing lazy-image discipline; provider-thumb posters are lazy-loaded too.
/livedoes not open more than one live embed at a time by default (poster grid; one active player).- Perf benchmark (
perf-*-iconic-clicks) must stay green on gallery routes that gain videos.
11 · Open questions (carried from PRD-031)
- Author-row source of truth — curated
static/data/video-sources/*.jsoncdecoded by the build script, vs. inlinevideos:[{…full…}]on entities decoded into the manifest. Leaning: central source files (keeps entity JSON lean; one place to run the allowlist + parity report). - Poster hosting — per-video hosted PD/CC poster (adds image-provenance entries, best quality/consistency) vs. provider thumbnail at runtime (lighter, a third-party fetch). Leaning: host posters for hero clips, provider-thumb for the long tail.
- HLS shim — native HLS on Safari + a tiny shim elsewhere, vs. requiring agency feeds to expose progressive mp4. Affects whether
agency-hlsis in v1 at all. - ISS liveness detection — oEmbed/liveness probe vs. assume-live-with-graceful-fallback. Also: single canonical NASA stream vs. an ordered agency fallback list.
/livenav placement — UXS note (PRD-031 open Q2).
12 · Slice mapping (to PRD-031)
| RFC section | PRD slice |
|---|---|
| §3, §3.1, §4 (manifest + build + validate + runtime) | S0 — the spine |
| §5, §6, §8 (player facade + gallery interleave on missions + credits) | S1 |
| §6 rollout to launch-site/fleet/landing + curated set | S2 |
| §5 interstitial + transcripts, §9 i18n, §13 e2e | S3 |
§7, §7.1 (ISS pin + /live) | S4 |
| §7 launch-broadcast time-gating + states | S5 |
| §7.1 "live now" pill + §10 a11y/reduced-motion sweep | S6 |
13 · Testing
- Unit —
video-provenanceresolution + canonicalisation;getLiveFeeds(now)time-gating across a fixture launch manifest (live / imminent / past / none); provider adapter URL builders. - Component — facade renders no embed at rest; click mounts embed; interstitial blocks mount until confirm; state-machine transitions; Escape capture-phase closes player not Panel.
- e2e — the no-eager-iframe perf assertions (§10) on a gallery route +
/live;/liveempty-state copy; credits Video section present. - validate-data — schema + allowlist + entity-resolution gates red on a bad fixture.