| 1 | /** |
| 2 | * media-manifest.ts — the real-session media surface for codewhale.net. |
| 3 | * |
| 4 | * Every real-session asset the site may show is declared here, with its |
| 5 | * poster, captions, transcript, GIF fallback, and budgets. The manifest is |
| 6 | * the contract: `web/components/session-media.tsx` renders whatever is |
| 7 | * declared, and `web/lib/media-manifest.test.ts` enforces the rules below. |
| 8 | * |
| 9 | * THE HONESTY CONTRACT: |
| 10 | * - A `pending` entry declares intent only. It has NO asset fields, and no |
| 11 | * files may exist for it under `web/public/media/`. The component renders |
| 12 | * a visible "recording pending release candidate" state — never a mock, |
| 13 | * staged, or recycled clip. (Release issue #4906: the dogfood recording |
| 14 | * happens after the v0.9.2 candidate is stable.) |
| 15 | * - A `published` entry is complete or it does not ship: poster, video, |
| 16 | * per-locale captions (WebVTT), a transcript, and a GIF fallback are all |
| 17 | * required. Tests verify presence and byte budgets, inspect PNG poster |
| 18 | * dimensions, and compare declared video metadata with MEDIA_BUDGETS. |
| 19 | * Actual video duration/dimensions remain a recording-checklist gate. |
| 20 | * - Reduced motion is structural, not a media query patch: the video never |
| 21 | * autoplays (`preload="none"`, user-initiated only), the poster is the |
| 22 | * static default, and the GIF fallback link is always visible. |
| 23 | * |
| 24 | * The exact post-dogfood recording procedure lives in |
| 25 | * docs/releases/v0.9.2-media-plan.md. Flip an entry to `published` only by |
| 26 | * following that checklist. |
| 27 | */ |
| 28 | |
| 29 | import type { LocalizedText } from "./content/vocabulary"; |
| 30 | |
| 31 | /** Published-asset budgets; see the module contract for what tests inspect. */ |
| 32 | export const MEDIA_BUDGETS = { |
| 33 | poster: { width: 1280, height: 720, maxBytes: 500_000 }, |
| 34 | video: { width: 1280, height: 720, maxBytes: 10_000_000, maxDurationSeconds: 120 }, |
| 35 | gifFallback: { maxBytes: 6_000_000 }, |
| 36 | /** WebVTT caption tracks must exist per shipped locale and be non-empty. */ |
| 37 | captionLocales: ["en", "zh"], |
| 38 | } as const; |
| 39 | |
| 40 | /** |
| 41 | * The reduced-motion policy identifier, referenced by the component and |
| 42 | * asserted by the test: static poster, no autoplay, optional GIF link. |
| 43 | */ |
| 44 | export const REDUCED_MOTION_POLICY = "static-poster-no-autoplay" as const; |
| 45 | |
| 46 | /** Directory under web/public/ that holds published session media. */ |
| 47 | export const MEDIA_PUBLIC_DIR = "media"; |
| 48 | |
| 49 | export type MediaStatus = "pending" | "published"; |
| 50 | |
| 51 | export interface MediaPoster { |
| 52 | /** Path relative to web/public/ (e.g. "media/first-fleet-session.png"). */ |
| 53 | src: string; |
| 54 | width: number; |
| 55 | height: number; |
| 56 | alt: LocalizedText; |
| 57 | } |
| 58 | |
| 59 | export interface MediaVideo { |
| 60 | src: string; |
| 61 | /** Measured duration of the shipped file, seconds. */ |
| 62 | durationSeconds: number; |
| 63 | width: number; |
| 64 | height: number; |
| 65 | } |
| 66 | |
| 67 | export interface MediaCaptionsTrack { |
| 68 | /** Path relative to web/public/ (WebVTT). */ |
| 69 | src: string; |
| 70 | srclang: string; |
| 71 | label: string; |
| 72 | } |
| 73 | |
| 74 | export interface MediaAsset { |
| 75 | /** Stable identifier; also the file stem for every asset file. */ |
| 76 | id: string; |
| 77 | title: LocalizedText; |
| 78 | description: LocalizedText; |
| 79 | status: MediaStatus; |
| 80 | /** Shown in place of any imagery while status is "pending". */ |
| 81 | pendingLabel: LocalizedText; |
| 82 | /** Published-only fields; absent while pending (enforced by the test). */ |
| 83 | poster?: MediaPoster; |
| 84 | video?: MediaVideo; |
| 85 | captions?: MediaCaptionsTrack[]; |
| 86 | gifFallback?: { src: string }; |
| 87 | /** Repo-relative transcript document (e.g. "docs/evidence/..."). */ |
| 88 | transcript?: string; |
| 89 | } |
| 90 | |
| 91 | export const MEDIA_ASSETS: MediaAsset[] = [ |
| 92 | { |
| 93 | id: "first-fleet-session", |
| 94 | title: { |
| 95 | en: "A real Codewhale session, end to end", |
| 96 | zh: "一次真实的 Codewhale 端到端会话", |
| 97 | }, |
| 98 | description: { |
| 99 | en: "Will be recorded from the v0.9.4 release candidate during dogfood: install, first offline session, provider connection, and one Fleet workflow on a sealed local route. Until that recording exists, no footage is shown here — the manifest and budgets above are the contract it must satisfy.", |
| 100 | zh: "将在 dogfood 期间从 v0.9.4 发布候选版录制:安装、首次离线会话、连接提供商,以及在密封本地路由上运行一次 Fleet Workflow。在录制完成之前,这里不展示任何影像——上面的清单与预算就是它必须满足的契约。", |
| 101 | }, |
| 102 | status: "pending", |
| 103 | pendingLabel: { |
| 104 | en: "Recording pending release candidate", |
| 105 | zh: "待发布候选版录制", |
| 106 | }, |
| 107 | }, |
| 108 | ]; |
| 109 | |
| 110 | export function getMediaAsset(id: string): MediaAsset | undefined { |
| 111 | return MEDIA_ASSETS.find((asset) => asset.id === id); |
| 112 | } |
| 113 |