Skip to content

PRD-031 · Video & Live Feeds — motion in the media layer

Status · Draft (epic kickoff) · 2026-07 Owner · Marko Builds on · ADR-046 (image provenance) · the link-provenance manifest + build-link-provenance.ts · PRD-020 / $lib/launches (global launch pipeline, LL2/GCAT/SpaceX/NASA/ESA) · PRD-018 (image pipeline v2 galleries) · /credits + /colophon disclosure surfaces Closes into · RFC-033 (media-provenance manifest + click-to-load player + live-feed pipeline — the technical design) May spawn · an ADR to lock the video-provenance schema + the canonical-channel allowlist once the shape settles

Why this is a PRD. Orrery's media layer is entirely still-image today. Adding motion — curated launch/landing/mission footage and live feeds (ISS, launch broadcasts) — is a product-shape decision that touches galleries, the data model, provenance/credits, a brand-new embedded-player surface, the launches pipeline, i18n, and performance discipline that has bitten us before. It needs an agreed frame — what we show, how we host it (we don't), how we keep it honest, and how we keep it fast — before any code lands.

Problem

We celebrate spaceflight with a wall of gorgeous stills, but spaceflight moves: the Saturn V clearing the tower, Challenger, Perseverance's skycrane, Starship's belly-flop, a Soyuz rolling out at Baikonur. Those moments only exist as video, and today Orrery has no way to show them. Worse, the two obvious ways to add video are both traps:

  • Redirecting to YouTube dumps the user out of the app into an ad-laden, tracking-heavy context — the opposite of Orrery's calm, no-tracking ethos.
  • Self-hosting is legally impossible for almost all of this footage (launch/broadcast video is copyrighted; unlike NASA stills, it is not blanket public-domain) and operationally heavy (storage, bandwidth, CDN).

Separately, there is a whole live dimension we ignore: the ISS carries a permanent live Earth-view / interior stream, and on any given week there are live launch broadcasts from agencies worldwide. Our launches pipeline already knows when a launch is live (webcast_live) and already records its webcast URL — we just throw that signal away.

Vision

Bring motion into the media layer without ever hosting a byte of it, and without ever redirecting the user out. Two things:

  1. Curated videos, in-gallery. The famous moments — per mission, launch site/pad, fleet vehicle, landing site — appear alongside the stills, play in-app in a lightweight embedded player, and carry the same rigorous provenance as everything else even though we only link to them.
  2. Live feeds, honestly framed. A /live surface: the ISS permanent stream pinned at the top, plus any launch broadcasts that are live-or-imminent, auto-populated from the launches pipeline and time-gated so we never show a dead player.

Principles (non-negotiable)

  1. Link + embed, never host. No video files enter the repo. Every clip is an external asset (agency/official channel) shown through an embedded player. This is a hard architectural line, decided at kickoff.
  2. In-app, never redirect. The default action opens an embedded player in place. A "watch on <source>" link-out is always offered as a secondary, honest escape hatch — but it is never the default.
  3. Click-to-load facade — no eager iframes, ever. A gallery shows a poster image + play affordance; the real <iframe>/player mounts only on click. A wall of auto-loaded live embeds would repeat the eager-imagery render-storm that near-crashed a MacBook (#360) — an order of magnitude worse with live video. This is a performance non-negotiable, not a nicety.
  4. Provenance even when we only link. Every video has a manifest entry — channel/uploader, official source URL, license or fair-use rationale, last_verified — mirroring the link-provenance discipline. It shows on /credits and /colophon.
  5. Privacy-preserving embeds. Cookie-less/no-tracking player variants (e.g. youtube-nocookie), no autoplay, no recommendations chrome where the provider allows suppressing it. Respects prefers-reduced-motion (facade never auto-animates).
  6. Curated, not dumped — global, not NASA-only. A canonical-channel allowlist and a taste bar (see below). Actively surface CNSA/CCTV, ISRO, JAXA, Roscosmos, ESA, SpaceX, Blue Origin footage alongside NASA — never default to NASA-only. No reaction/compilation/re-upload channels.
  7. Honesty over spectacle. Real footage vs. animation/CGI is labeled. A "live" badge means actually live now; an offline stream says so plainly rather than showing a spinner.
  8. i18n + a11y from line one. All chrome ×14 locales; titles/captions authored + translated; every video carries a text title + description (and transcript/caption link where the source provides one) so it is not an opaque black box to a screen reader.

Two phases

Phase 1 — Curated videos in galleries

The static, hand-vetted layer. Rides the link-provenance side of the house (we link, we don't download).

  • video-provenance.json manifest — a sibling to the link-provenance manifest, built by a build-video-provenance.ts script and validated by validate-data. Per-entry: stable id, entity_id, provider (youtube | vimeo | agency-hls | agency-mp4), provider video id / URL, canonical source page, channel/uploader, agency, title, caption, license_or_fair_use, last_verified, poster/thumbnail, duration, start_seconds?, kind (launch | landing | edl | milestone | accident | rollout | broadcast-archive | animation), and a content_advisory? flag for loss-of-life footage (Challenger, Columbia, etc.).
  • Uniform videos[] attach point on the four entity families — missions, launch sites/pads, fleet assets, landing sites — referenced from entity data the same way gallery[*] is, resolving into the manifest by id. (All four ship together; we do not carve the set down — full coverage is the point.)
  • <MediaPlayer> — the click-to-load facade component. Renders poster + play button; on click swaps in the provider-appropriate embed (nocookie iframe / HLS <video> / mp4). Provider-abstracted so a new source is a new adapter, not a new component. Modal/lightbox over the surface, dismissible (and its Escape handling must respect the capture-phase surface-route Escape contract in panorama-keys.ts).
  • Gallery integration — videos interleave with stills in the existing gallery rail, visually distinguished (play badge + duration chip). Ordering/curation follows the existing gallery numbering discipline.
  • Sourcing discipline — a canonical-channel allowlist (official agency/operator channels only) analogous to the image sourcing rules; no web-search-for-URLs free-for-all. Fair-use rationale recorded per clip. Global-agency parity enforced in review.
  • Credits/colophon — video entries surface on /credits grouped by source-family, consistent with the image-credits grouping rules.

Phase 2 — Live feeds

The dynamic layer. Rides the launches pipeline + a small curated set of permanent streams.

  • /live route — a dedicated surface (linked from nav Learn/Catalog grouping TBD in UXS).
  • ISS permanent feed, pinned — the always-on NASA ISS live stream (external Earth-view / internal), rendered through the same click-to-load facade with an honest "live" badge and a graceful "stream currently offline / between passes" state.
  • Launch broadcasts, auto-populated + time-gated — from $lib/launches: entries expose webcast_live and a LaunchProvenanceLink (webcast URL, role, fetched_at). Show a launch's webcast only when it is live or imminent (status + T-0 window); collapse/hide it otherwise. Never render a player for a stream that has ended.
  • Graceful degradation everywhere — offline, ended, geo-blocked, and provider-error states are first-class, not spinners. "Live" is truthful.
  • Provenance — live entries carry the same manifest discipline (channel, official URL, fair-use), reusing the launch provenance chain where the launch pipeline already has it.

Data model sketch (frozen in RFC-033)

jsonc
// static/data/video-provenance.json  (built; sibling to link-provenance.json)
{
  "schema_version": 1,
  "generated_at": "…",
  "entries": [
    {
      "id": "vid-<hash>",
      "entity_id": "apollo-11",           // mission | launch-site | fleet | landing-site id
      "entity_kind": "mission",
      "provider": "youtube",              // youtube | vimeo | agency-hls | agency-mp4
      "provider_ref": "<video-id-or-url>",
      "source_url": "https://www.youtube.com/watch?v=…",  // canonical page
      "channel": "NASA",
      "agency": "NASA",
      "title": "Apollo 11 launch — Saturn V liftoff",
      "caption": "…",                     // authored, translated ×14
      "kind": "launch",
      "poster": "/images/…/poster.jpg",   // a PD/CC still we CAN host, or provider thumb
      "duration_seconds": 132,
      "start_seconds": 0,
      "license_or_fair_use": "NASA video — U.S. Government work, public domain",
      "content_advisory": null,           // e.g. "loss-of-life footage" for Challenger
      "last_verified": "2026-07-16"
    }
  ]
}
jsonc
// on an entity (missions/*.json etc.) — reference by id, mirrors gallery[]
"videos": [ { "id": "vid-<hash>" },  ]

Slice roadmap (one direction; full detail + architecture in RFC-033)

  • P1 — curated gallery videos.
    • S0video-provenance schema + build-video-provenance.ts + validate-data gating + the channel allowlist. No UI. (The spine.)
    • S1<MediaPlayer> click-to-load facade + gallery interleave on one entity family (missions) end-to-end, incl. /credits surfacing. Prove the pattern.
    • S2 — roll the videos[] attach point to launch sites, fleet, landing sites; author the first curated set (global-agency parity).
    • S3content_advisory UX, transcript/caption links, colophon wall entries, i18n pass.
  • P2 — live feeds.
    • S4/live route + ISS permanent feed (pinned, facade, offline state).
    • S5 — launch-broadcast auto-population + time-gating off $lib/launches; live badge truthfulness; error/ended states.
    • S6 — polish: "what's live now" surfacing from other routes (e.g. a live pill on /missions launch calendar), reduced-motion + a11y sweep.

Decisions locked at kickoff (2026-07)

  1. Link + embed only. No self-hosting of video, ever, in v1 — including "PD NASA clips" (the operational cost of a video CDN isn't worth it; we embed the official upload and host only the poster still).
  2. In-app embedded player is the default; redirect is never the default (secondary link-out always present).
  3. Click-to-load facade is mandatory — no eager iframes, on galleries or /live.
  4. One PRD, two phases — curated gallery videos first (leans on link-provenance), live feeds second (leans on the launches pipeline).
  5. All four attach points ship (missions, launch sites, fleet, landing sites) — no scope carve-down.
  6. Provenance is first-class from S0 — the manifest + validator land before any player UI.
  7. Global-agency parity is a review gate, not an aspiration.
  8. Player is a hand-rolled nocookie-iframe facade — no new dependency (multi-provider from the start; we own the perf + privacy behavior).
  9. Loss-of-life footage (Challenger, Columbia, …) requires a click-through interstitial — an explicit "this shows a fatal accident — continue?" confirm before the embed mounts, not merely a badge.

Non-goals

  • No self-hosted video, no video CDN, no transcoding pipeline. (Explicitly out — see decision 1.)
  • No user uploads, comments, or social features.
  • No autoplay, no background video, no video as decorative page chrome.
  • No exhaustive launch-webcast archive — P2 shows live/imminent broadcasts, not a historical webcast library (curated archival clips belong in P1 galleries instead).
  • No in-app video editing / clipping — we link to a moment (start_seconds), we don't re-cut.
  • No offline/PWA caching of video (the PWA precaches app shell + stills only).

Open questions (resolve in RFC-033 / with Marko)

  1. Poster stills — do we host a curated poster per video (adds to image-provenance), or use the provider thumbnail (lighter, but a third-party fetch at render)? Leaning: host a poster for hero clips, provider-thumb for the long tail.
  2. /live nav placement — its own top-level entry vs. under Catalog/Learn vs. surfaced only contextually. Needs a UXS note.
  3. ISS feed source of truth — pin a single canonical NASA stream, or rotate among agency ISS feeds (Roscosmos/ESA) when NASA's is down?
  4. Geo-blocking / provider outage — detection + fallback copy. How hard do we try to detect vs. just offer the link-out?

Resolved at kickoff: player = hand-rolled nocookie facade, no dependency (decision 8); loss-of-life footage = click-through interstitial, not just a badge (decision 9).

Risks & mitigations

  • Dead links rot. External videos get taken down / re-uploaded. → last_verified + a periodic link-check (extend the existing link-verification tooling); graceful "video unavailable — here's the source page" state; provenance-first so a dead embed still credits correctly.
  • Perf regression from embeds. → click-to-load facade is mandatory (decision 3); e2e asserts no <iframe> mounts before interaction.
  • Fair-use overreach. → allowlist + per-clip rationale + review gate; when in doubt, link-out only (no embed).
  • "Live" lying. → time-gating + real offline states; a "live" badge is load-bearing and tested.
  • Scope creep toward a video platform. → the non-goals above are the fence.

Success signals

  • Curated videos live on all four entity families with full provenance on /credits + /colophon.
  • Zero video bytes in the repo; zero eager iframes (e2e-enforced).
  • /live shows the ISS feed and correctly reflects real launch-broadcast state (live/imminent/none) without ever showing a dead player.
  • Global-agency parity visible in the curated set (not NASA-only).
  • No measurable render-storm regression on gallery routes (perf benchmark stays green).

Tracking

  • Epic tracking issue: #413 (per the work-block pattern: PRD + RFC-033 + tracking issue).
  • Pairs with RFC-033 for the technical design (manifest schema, player component, live pipeline, validators).

Orrery — architecture documentation · MIT · No tracking