Skip to content

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-provenance manifest + 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/launches for 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  ─►  /live

Two 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)

idDecisionRationale
V-ALink + embed only; zero video bytes in the repoLaunch/broadcast footage is copyrighted (unlike NASA stills). We host only poster stills. Kills storage/CDN/transcode + most legal risk. (PRD-031 decision 1.)
V-BClick-to-load facade — the embed <iframe>/<video> mounts only on user interactionA wall of eager live embeds repeats the #360 render-storm, ×N worse with video. Non-negotiable; e2e-enforced (§10).
V-CHand-rolled facade, no dependency; provider behind an adapter interfaceMulti-provider (youtube-nocookie / vimeo / agency HLS+mp4) from day 1; we own perf + privacy. No supply-chain add. (PRD-031 decision 8.)
V-Dvideo-provenance.json is a sibling of link-provenance, not image-provenanceWe link, we don't download. Reuse the link-provenance canonicalisation + last_verified + fair-use discipline; do not pollute the image manifest.
V-EManifest is the single source of truth; entities reference by idvideos:[{id}] on an entity resolves into the manifest, exactly like gallery[*]. Survives renames; one place to verify + credit.
V-FLive = derived, never authoredLaunch 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-GTruthful 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-HLoss-of-life footage gated by a click-through interstitialcontent_advisory: "loss-of-life" forces an explicit continue-confirm before the embed mounts, not just a badge. (PRD-031 decision 9.)
V-IEscape contract honoredThe 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.

jsonc
{
  "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:

  • id is vid- + a hash of (provider, provider_ref) so the same source video dedupes and survives renumbering (mirrors image-provenance's rename-survival goal).
  • source_url runs through the same canonicaliseLinkUrl used by link-provenance (utm/fbclid/gclid strip, fragment strip) so credits dedupe cleanly.
  • animation kind is the honesty hook — CGI/animation is labeled distinctly from real footage (PRD-031 principle 7).

3.1 Build + validate

  • build-video-provenance.ts reads curated author rows (a static/data/video-sources/*.jsonc or inline entity refs — chosen in §11), checks each channel against the canonical-channel allowlist (scripts/video-channel-allowlist.ts), canonicalises URLs, computes ids, and emits the manifest.
  • validate-data gains a validate-video-provenance step: schema-valid; every videos:[{id}] on an entity resolves to a manifest entry; every entry's channel ∈ allowlist; every entry has a non-empty license_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 by last_verified age, not auto-deleted.

4 · $lib/video-provenance.ts (runtime)

Mirrors link-provenance.ts:

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 url

Cached 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() }. youtubeyoutube-nocookie.com/embed/<id>?start=…&rel=0&modestbranding=1; vimeoplayer.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 the panorama-keys.ts pattern.
  • a11y<button> with the localized title as accessible name; modal is a focus-trapped dialog; poster alt = title; the interstitial is a proper alertdialog; caption/transcript link surfaced when the source provides one.
  • Entities gain videos:[{id}] alongside gallery:[…]. Resolved via getVideosForEntity.
  • 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:

ts
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. Never ended.
  • Launch broadcasts — read the launches manifest: an entry contributes a launch-broadcast feed when webcast_live === true or its status is upcoming and NET is within the imminent window (e.g. T-60min → T+window). Webcast URL + credit come from its LaunchProvenanceLink. Past its window → dropped (never ended in 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/load computes getLiveFeeds(now); ISS pinned first, then launch broadcasts sorted by state (liveimminent) 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 /live when any feed is live — P2 S6 polish, not core.

8 · Credits integration

  • /credits gains a Video section (per-clip rows: poster thumbnail, title, channel · agency, license/fair-use, source_url, last_verified). This is the correct surface: /credits discloses reused / third-party material (images, text, audio, links), and linked video is third-party.
  • Not /colophon. /colophon is 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 + caption are 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.
  • /live does 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)

  1. Author-row source of truth — curated static/data/video-sources/*.jsonc decoded by the build script, vs. inline videos:[{…full…}] on entities decoded into the manifest. Leaning: central source files (keeps entity JSON lean; one place to run the allowlist + parity report).
  2. 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.
  3. HLS shim — native HLS on Safari + a tiny shim elsewhere, vs. requiring agency feeds to expose progressive mp4. Affects whether agency-hls is in v1 at all.
  4. ISS liveness detection — oEmbed/liveness probe vs. assume-live-with-graceful-fallback. Also: single canonical NASA stream vs. an ordered agency fallback list.
  5. /live nav placement — UXS note (PRD-031 open Q2).

12 · Slice mapping (to PRD-031)

RFC sectionPRD 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 setS2
§5 interstitial + transcripts, §9 i18n, §13 e2eS3
§7, §7.1 (ISS pin + /live)S4
§7 launch-broadcast time-gating + statesS5
§7.1 "live now" pill + §10 a11y/reduced-motion sweepS6

13 · Testing

  • Unitvideo-provenance resolution + 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; /live empty-state copy; credits Video section present.
  • validate-data — schema + allowlist + entity-resolution gates red on a bad fixture.

Orrery — architecture documentation · MIT · No tracking