@flamingo-stack/openframe-frontend-core 0.0.446 → 0.0.447

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/{chunk-7IC2QCOL.cjs → chunk-2PNWAAU4.cjs} +73 -73
  2. package/dist/{chunk-7IC2QCOL.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
  3. package/dist/{chunk-5YQSRQHP.js → chunk-3GBLPPSW.js} +6 -6
  4. package/dist/{chunk-SAV4U37G.js → chunk-4ONE3HQP.js} +2 -2
  5. package/dist/{chunk-N4CCB33C.js → chunk-5BJJRPDH.js} +3 -3
  6. package/dist/{chunk-K6JIBV3X.js → chunk-6TAIXSF5.js} +2 -2
  7. package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
  8. package/dist/chunk-AN7MCJTV.cjs.map +1 -0
  9. package/dist/{chunk-I7IOEXRJ.js → chunk-D2NLNBLH.js} +2 -2
  10. package/dist/{chunk-SA7AHDS6.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
  11. package/dist/{chunk-SA7AHDS6.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
  12. package/dist/{chunk-DH3DUY6Q.cjs → chunk-EZMM2OMP.cjs} +32 -32
  13. package/dist/{chunk-DH3DUY6Q.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
  14. package/dist/{chunk-6XZNIPXP.cjs → chunk-FR6NVZVM.cjs} +18 -18
  15. package/dist/{chunk-6XZNIPXP.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
  16. package/dist/{chunk-XB6D7B3I.js → chunk-G7QSLGMI.js} +3 -3
  17. package/dist/{chunk-L276GOMD.cjs → chunk-ITJMUH4Q.cjs} +40 -40
  18. package/dist/{chunk-L276GOMD.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
  19. package/dist/{chunk-ZKYEMGMD.cjs → chunk-KYLOPUIH.cjs} +29 -29
  20. package/dist/{chunk-ZKYEMGMD.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
  21. package/dist/{chunk-MS5CEBBQ.cjs → chunk-PF6CKZIH.cjs} +5 -5
  22. package/dist/{chunk-MS5CEBBQ.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
  23. package/dist/{chunk-BAUWT7PV.cjs → chunk-RNXMP7GB.cjs} +986 -941
  24. package/dist/chunk-RNXMP7GB.cjs.map +1 -0
  25. package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
  26. package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
  27. package/dist/{chunk-5WX2V6CY.cjs → chunk-S7VMUDUB.cjs} +12 -12
  28. package/dist/{chunk-5WX2V6CY.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
  29. package/dist/{chunk-YLMD4UFA.js → chunk-SHC5NFOH.js} +3 -3
  30. package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
  31. package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
  32. package/dist/{chunk-GTHA2SYB.js → chunk-TMZYFYSS.js} +5 -5
  33. package/dist/{chunk-NQLY27GH.cjs → chunk-WLU37UZO.cjs} +73 -73
  34. package/dist/{chunk-NQLY27GH.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
  35. package/dist/{chunk-SCKAD2MY.js → chunk-WMOF3WQT.js} +5 -5
  36. package/dist/{chunk-3AFV2YIC.cjs → chunk-X4XHW3OV.cjs} +40 -40
  37. package/dist/{chunk-3AFV2YIC.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
  38. package/dist/{chunk-N3Y6JYH5.cjs → chunk-XUS75SQW.cjs} +15 -15
  39. package/dist/{chunk-N3Y6JYH5.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
  40. package/dist/{chunk-MAB4TQCO.js → chunk-YCSNDUDE.js} +342 -297
  41. package/dist/chunk-YCSNDUDE.js.map +1 -0
  42. package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
  43. package/dist/{chunk-MR7BMJHI.js → chunk-ZQVEB6KT.js} +6 -6
  44. package/dist/{chunk-BNYI2FVM.js → chunk-ZYB3BYBA.js} +4 -4
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/cards-strip.d.ts.map +1 -1
  58. package/dist/components/features/highlight-video-section.d.ts.map +1 -1
  59. package/dist/components/features/index.cjs +5 -3
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +4 -2
  62. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  63. package/dist/components/features/mux-origins.cjs +14 -0
  64. package/dist/components/features/mux-origins.cjs.map +1 -1
  65. package/dist/components/features/mux-origins.d.ts +26 -2
  66. package/dist/components/features/mux-origins.d.ts.map +1 -1
  67. package/dist/components/features/mux-origins.js +14 -1
  68. package/dist/components/features/mux-origins.js.map +1 -1
  69. package/dist/components/features/release-media-manager.d.ts.map +1 -1
  70. package/dist/components/features/use-video-warmup.d.ts +12 -0
  71. package/dist/components/features/use-video-warmup.d.ts.map +1 -1
  72. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  73. package/dist/components/features/video-source-selector.d.ts +1 -11
  74. package/dist/components/features/video-source-selector.d.ts.map +1 -1
  75. package/dist/components/features/video.d.ts +14 -1
  76. package/dist/components/features/video.d.ts.map +1 -1
  77. package/dist/components/help-center-pages/index.cjs +22 -22
  78. package/dist/components/help-center-pages/index.js +13 -13
  79. package/dist/components/index.cjs +137 -135
  80. package/dist/components/index.cjs.map +1 -1
  81. package/dist/components/index.js +13 -11
  82. package/dist/components/index.js.map +1 -1
  83. package/dist/components/navigation/index.cjs +3 -3
  84. package/dist/components/navigation/index.js +2 -2
  85. package/dist/components/onboarding-guides/index.cjs +7 -7
  86. package/dist/components/onboarding-guides/index.js +6 -6
  87. package/dist/components/related-content/index.cjs +5 -5
  88. package/dist/components/related-content/index.js +4 -4
  89. package/dist/components/tickets/index.cjs +6 -6
  90. package/dist/components/tickets/index.js +5 -5
  91. package/dist/components/ui/index.cjs +3 -3
  92. package/dist/components/ui/index.js +2 -2
  93. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  94. package/dist/hooks/index.cjs +4 -2
  95. package/dist/hooks/index.cjs.map +1 -1
  96. package/dist/hooks/index.js +3 -1
  97. package/dist/hooks/use-near-viewport.d.ts +7 -0
  98. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  99. package/dist/index.cjs +7 -3
  100. package/dist/index.cjs.map +1 -1
  101. package/dist/index.js +6 -2
  102. package/package.json +1 -1
  103. package/src/components/features/cards-strip.tsx +2 -1
  104. package/src/components/features/highlight-video-section.tsx +10 -7
  105. package/src/components/features/media-gallery-manager.tsx +6 -8
  106. package/src/components/features/mux-origins.ts +40 -2
  107. package/src/components/features/release-media-manager.tsx +6 -8
  108. package/src/components/features/use-video-warmup.ts +19 -3
  109. package/src/components/features/video-bites-strip.tsx +24 -4
  110. package/src/components/features/video-source-selector.tsx +16 -56
  111. package/src/components/features/video.tsx +103 -8
  112. package/src/components/ui/rich-markdown-renderer.tsx +20 -0
  113. package/src/components/ui/simple-markdown-renderer.tsx +9 -5
  114. package/src/hooks/use-near-viewport.ts +9 -1
  115. package/dist/chunk-BAUWT7PV.cjs.map +0 -1
  116. package/dist/chunk-MAB4TQCO.js.map +0 -1
  117. package/dist/chunk-VIJARDBM.cjs.map +0 -1
  118. /package/dist/{chunk-5YQSRQHP.js.map → chunk-3GBLPPSW.js.map} +0 -0
  119. /package/dist/{chunk-SAV4U37G.js.map → chunk-4ONE3HQP.js.map} +0 -0
  120. /package/dist/{chunk-N4CCB33C.js.map → chunk-5BJJRPDH.js.map} +0 -0
  121. /package/dist/{chunk-K6JIBV3X.js.map → chunk-6TAIXSF5.js.map} +0 -0
  122. /package/dist/{chunk-I7IOEXRJ.js.map → chunk-D2NLNBLH.js.map} +0 -0
  123. /package/dist/{chunk-XB6D7B3I.js.map → chunk-G7QSLGMI.js.map} +0 -0
  124. /package/dist/{chunk-YLMD4UFA.js.map → chunk-SHC5NFOH.js.map} +0 -0
  125. /package/dist/{chunk-GTHA2SYB.js.map → chunk-TMZYFYSS.js.map} +0 -0
  126. /package/dist/{chunk-SCKAD2MY.js.map → chunk-WMOF3WQT.js.map} +0 -0
  127. /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
  128. /package/dist/{chunk-MR7BMJHI.js.map → chunk-ZQVEB6KT.js.map} +0 -0
  129. /package/dist/{chunk-BNYI2FVM.js.map → chunk-ZYB3BYBA.js.map} +0 -0
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Mux CDN origin constants — single source of truth, server-safe.
2
+ * Mux CDN origins + playback-URL helpers — single source of truth, server-safe.
3
3
  *
4
4
  * Lives in its own NON-`'use client'` module so server-side hub
5
5
  * modules (webhook handlers, URL builders, hostname comparisons in
6
- * `lib/config/mux-config.ts`) can import these strings without
6
+ * `lib/config/mux-config.ts`) can import these without
7
7
  * tripping Next.js's client-reference poisoning. Re-exported from
8
8
  * `use-video-warmup.ts` for backward-compat with client-side callers.
9
9
  *
10
+ * The Mux-HLS detection in `toMuxPreviewUrl` intentionally overlaps the
11
+ * hub's `lib/config/mux-config.ts` `isMuxHlsUrl` — same documented
12
+ * cross-repo duplication umbrella as the origin constants (the hub's
13
+ * server code cannot depend on this package's client-marked siblings).
14
+ *
10
15
  * Bug history (2026-05-29): when these constants lived in
11
16
  * `use-video-warmup.ts` (which is `'use client'`), the hub's
12
17
  * server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed
@@ -25,3 +30,36 @@ export const MUX_STREAM_ORIGIN = 'https://stream.mux.com'
25
30
 
26
31
  /** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */
27
32
  export const MUX_IMAGE_ORIGIN = 'https://image.mux.com'
33
+
34
+ /** Valid values for Mux's `max_resolution` playback modifier (720p is the floor). */
35
+ export type MuxMaxResolution = '720p' | '1080p' | '1440p' | '2160p'
36
+
37
+ /**
38
+ * Cap the renditions offered by a public Mux HLS manifest for small
39
+ * preview players (e.g. the ~234px video-bite hover cards) by appending
40
+ * Mux's `max_resolution` playback modifier.
41
+ *
42
+ * Why a URL param and not a player prop: MuxPlayer's `maxResolution`
43
+ * prop only applies when playing via `playbackId` — our SSOT passes
44
+ * `src` URLs. And on Safari (native HLS, no hls.js) manifest-level
45
+ * filtering is the ONLY rendition cap; hls.js's `capLevelToPlayerSize`
46
+ * doesn't exist there.
47
+ *
48
+ * Pass-through (returns `url` unchanged) for: non-Mux hosts (Supabase
49
+ * MP4s, YouTube), non-`.m3u8` paths (MP4 renditions), signed URLs
50
+ * (`?token=` — Mux forbids loose modifiers alongside a token; they must
51
+ * live in the JWT), and unparseable strings.
52
+ */
53
+ export function toMuxPreviewUrl(url: string, maxResolution: MuxMaxResolution = '720p'): string {
54
+ let parsed: URL
55
+ try {
56
+ parsed = new URL(url)
57
+ } catch {
58
+ return url
59
+ }
60
+ if (parsed.origin !== MUX_STREAM_ORIGIN) return url
61
+ if (!parsed.pathname.endsWith('.m3u8')) return url
62
+ if (parsed.searchParams.has('token')) return url
63
+ parsed.searchParams.set('max_resolution', maxResolution)
64
+ return parsed.toString()
65
+ }
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { useState, useRef } from 'react';
4
4
  import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from '../ui';
5
- import { Trash2, Plus, Image as ImageIcon, Video, Upload, Loader2, GripVertical } from 'lucide-react';
5
+ import { Trash2, Plus, Image as ImageIcon, Video as VideoIcon, Upload, Loader2, GripVertical } from 'lucide-react';
6
6
  import Image from '../../embed-shims/next-image';
7
+ import { Video } from './video';
7
8
 
8
9
  export interface ReleaseMediaItem {
9
10
  media_type: 'image' | 'video' | 'screenshot' | 'demo';
@@ -113,7 +114,7 @@ export function ReleaseMediaManager({
113
114
  switch (type) {
114
115
  case 'video':
115
116
  case 'demo':
116
- return <Video className="w-5 h-5 text-ods-text-secondary" />;
117
+ return <VideoIcon className="w-5 h-5 text-ods-text-secondary" />;
117
118
  default:
118
119
  return <ImageIcon className="w-5 h-5 text-ods-text-secondary" />;
119
120
  }
@@ -205,12 +206,9 @@ export function ReleaseMediaManager({
205
206
  {item.media_url && (
206
207
  <div className="aspect-video relative bg-ods-bg">
207
208
  {item.media_type === 'video' || item.media_type === 'demo' ? (
208
- <video
209
- src={item.media_url}
210
- className="w-full h-full object-cover"
211
- controls
212
- preload="metadata"
213
- />
209
+ // <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
210
+ // fit="cover" crops to the aspect-video cell.
211
+ <Video kind="file" url={item.media_url} fit="cover" className="w-full h-full" />
214
212
  ) : (
215
213
  <Image
216
214
  src={item.media_url}
@@ -48,6 +48,18 @@ import { useChatRuntime } from '../../contexts/chat-runtime-context'
48
48
  export { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
49
49
  import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
50
50
 
51
+ /**
52
+ * Save-Data detection — the ONE source of truth for "is this a metered
53
+ * connection". Consumed by the preload gate below and by `video.tsx`'s
54
+ * default `preload` policy. SSR-safe (returns false on the server).
55
+ */
56
+ export function saveDataEnabled(): boolean {
57
+ if (typeof navigator === 'undefined') return false
58
+ type Connection = { saveData?: boolean }
59
+ const conn = (navigator as Navigator & { connection?: Connection }).connection
60
+ return conn?.saveData === true
61
+ }
62
+
51
63
  /**
52
64
  * Preconnect-only variant — fires the three video-bearing origin
53
65
  * preconnects (Supabase Storage + Mux stream + Mux image) without
@@ -68,6 +80,12 @@ import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
68
80
  * any equivalent provider that wires the field) get the origin
69
81
  * automatically. The explicit `supabaseStorageOrigin` argument
70
82
  * overrides the runtime value when set.
83
+ *
84
+ * The hub's `app/layout.tsx` also emits STATIC `<link rel="preconnect">`
85
+ * tags for the same Mux origins (document-parse-time warmup; React
86
+ * dedupes against these runtime calls via the matching
87
+ * crossOrigin="anonymous"). A change to the origin list here must be
88
+ * mirrored there.
71
89
  */
72
90
  export function useVideoOriginPreconnect({
73
91
  supabaseStorageOrigin,
@@ -139,9 +157,7 @@ export function useVideoWarmup<T extends Element = HTMLDivElement>({
139
157
  if (!isNear || !videoUrl || !resolvedOrigin) return
140
158
 
141
159
  // Save-Data gate — metered connections skip preload.
142
- type Connection = { saveData?: boolean }
143
- const conn = (navigator as Navigator & { connection?: Connection }).connection
144
- if (conn?.saveData === true) return
160
+ if (saveDataEnabled()) return
145
161
 
146
162
  // Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms
147
163
  // via the manifest fetch when MuxPlayer mounts; YouTube has no
@@ -29,6 +29,8 @@ import React, { useMemo, useRef, useState, useEffect } from 'react';
29
29
  import { cn } from '../../utils/cn';
30
30
  import { Video } from './video';
31
31
  import { useVideoWarmup } from './use-video-warmup';
32
+ import { toMuxPreviewUrl } from './mux-origins';
33
+ import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
32
34
  import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
33
35
  import type { VideoTeaserWithRatio } from './video-ratio-tabs';
34
36
  import {
@@ -227,7 +229,16 @@ export function VideoBiteCard({
227
229
  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
228
230
  const targetHref = bite.href ?? sectionHref;
229
231
  const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
232
+ // Strip keys and navigation stay on the RAW bite.url — only playback gets
233
+ // the rendition-capped preview URL below, so keys are stable regardless of
234
+ // the cap and original + clone share one HTTP cache entry per URL.
230
235
  const key = cardKey ?? `${bite.url}__${index}`;
236
+ // Rendition-capped playback URL for this ~234px preview card: public Mux
237
+ // HLS manifests get `?max_resolution=720p` (the only Safari-side cap —
238
+ // native HLS ignores hls.js's player-size capping); everything else passes
239
+ // through unchanged. Used by BOTH the first-frame facade and the hover
240
+ // player so the two layers share the manifest fetch.
241
+ const previewUrl = toMuxPreviewUrl(bite.url);
231
242
 
232
243
  // Hover activation: controlled by the strip (activeKey) or self-managed
233
244
  // when rendered standalone (admin editor).
@@ -250,7 +261,7 @@ export function VideoBiteCard({
250
261
  if (!el || typeof IntersectionObserver === 'undefined') return;
251
262
  const io = new IntersectionObserver(
252
263
  entries => setIsNear(entries[0]?.isIntersecting ?? false),
253
- { rootMargin: '500px' },
264
+ { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
254
265
  );
255
266
  io.observe(el);
256
267
  return () => io.disconnect();
@@ -392,7 +403,7 @@ export function VideoBiteCard({
392
403
  className="object-cover"
393
404
  />
394
405
  ) : (
395
- <Video kind="file" url={bite.url} firstFrameOnly layout="fill" />
406
+ <Video kind="file" url={previewUrl} firstFrameOnly layout="fill" />
396
407
  )}
397
408
 
398
409
  {/* Play affordance for every non-playing state (resting poster,
@@ -415,10 +426,19 @@ export function VideoBiteCard({
415
426
  it keeps playing. Sound at 50% (pre-activation: muted start +
416
427
  live unmute on the user's first gesture); CHROMELESS — the
417
428
  card's mux-replica badge above is the play affordance for
418
- every non-playing state. */}
429
+ every non-playing state.
430
+
431
+ INVARIANTS (do not regress):
432
+ - No explicit `preload` — the SSOT default ('metadata',
433
+ 'none' under Save-Data) buffers manifest + ~1 segment on
434
+ mount, which is exactly what makes hover start instant.
435
+ - The poster <Image> layer above stays mounted UNDER this
436
+ player permanently, and play() is imperative — never gated
437
+ on poster load — so there is no black flash and no
438
+ loading state between hover and first frame. */}
419
439
  <Video
420
440
  kind="file"
421
- url={bite.url}
441
+ url={previewUrl}
422
442
  poster={bite.thumbnail_url}
423
443
  playWhenHovered={isActive}
424
444
  chromeless
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useCallback, useEffect } from 'react';
4
- import { Upload, Sparkles, X, Video, Loader2 } from 'lucide-react';
3
+ import React, { useState, useCallback } from 'react';
4
+ import { Upload, X, Video as VideoIcon } from 'lucide-react';
5
5
  import { AIGeneratedBadge } from '../ui/ai-generated-badge';
6
6
  import { Button } from '../ui/button';
7
7
  import { Input } from '../ui/input';
8
8
  import { Label } from '../ui/label';
9
9
  import { Badge } from '../ui/badge';
10
10
  import { YouTubeIcon } from '../icons/youtube-icon';
11
+ import { Video } from './video';
11
12
 
12
13
  export type VideoSourceType = 'youtube' | 'uploaded';
13
14
 
@@ -48,16 +49,6 @@ export interface VideoSourceSelectorProps {
48
49
  onDelete: () => void;
49
50
  isAIGenerated?: boolean;
50
51
  }>;
51
- /**
52
- * Optional: resolve the uploaded video URL before it's handed to the DEFAULT
53
- * `<video>` preview (no-op when a `VideoPreviewComponent` is supplied — that
54
- * component owns its own rendering). Lets a consumer swap a URL that a native
55
- * `<video>` can't play — e.g. a Mux HLS manifest (`stream.mux.com/{id}.m3u8`),
56
- * which Chrome/Firefox/Edge can't play natively — for a playable MP4. Without
57
- * it the URL is used as-is (backward compatible). A rejected resolve degrades
58
- * gracefully to the original URL.
59
- */
60
- resolveVideoUrl?: (url: string) => Promise<string>;
61
52
  /** Optional: Upload progress bar component */
62
53
  UploadProgressComponent?: React.ComponentType<{
63
54
  progress: number;
@@ -95,7 +86,6 @@ export function VideoSourceSelector({
95
86
  uploadEmptyText = 'No video uploaded yet. Click "Upload Video" to add one.',
96
87
  disabled = false,
97
88
  VideoPreviewComponent,
98
- resolveVideoUrl,
99
89
  UploadProgressComponent,
100
90
  title = 'Video',
101
91
  showTitle = true,
@@ -149,7 +139,7 @@ export function VideoSourceSelector({
149
139
  {/* Section Title */}
150
140
  {showTitle && (
151
141
  <h3 className="text-h5 text-ods-text-primary flex items-center gap-2">
152
- <Video className="h-5 w-5" />
142
+ <VideoIcon className="h-5 w-5" />
153
143
  {title}
154
144
  </h3>
155
145
  )}
@@ -256,10 +246,8 @@ export function VideoSourceSelector({
256
246
  isAIGenerated={isAIGenerated}
257
247
  />
258
248
  ) : (
259
- // Default simple preview (resolver-aware — see DefaultVideoPreview)
260
249
  <DefaultVideoPreview
261
250
  videoUrl={mainVideoUrl}
262
- resolveVideoUrl={resolveVideoUrl}
263
251
  onDelete={handleDeleteVideo}
264
252
  disabled={disabled}
265
253
  />
@@ -276,55 +264,27 @@ export function VideoSourceSelector({
276
264
  }
277
265
 
278
266
  interface DefaultVideoPreviewProps {
279
- /** Uploaded video URL (possibly an HLS manifest a native `<video>` can't play). */
267
+ /** Uploaded video URL MP4 or Mux HLS manifest, both playable via <Video>. */
280
268
  videoUrl: string;
281
- /** Optional resolver → playable MP4 URL; omit for as-is (backward-compatible) behaviour. */
282
- resolveVideoUrl?: (url: string) => Promise<string>;
283
269
  onDelete: () => void;
284
270
  disabled?: boolean;
285
271
  }
286
272
 
287
273
  /**
288
- * Default `<video>` preview for {@link VideoSourceSelector} when no
289
- * `VideoPreviewComponent` is supplied. When `resolveVideoUrl` is provided the URL
290
- * is resolved (e.g. Mux HLS MP4) before it reaches the native `<video>`, which
291
- * can't play HLS on Chrome/Firefox/Edge; a rejected resolve degrades to the
292
- * original URL. Without a resolver the URL is used synchronously, unchanged.
274
+ * Default preview for {@link VideoSourceSelector} when no
275
+ * `VideoPreviewComponent` is supplied. Renders through the `<Video>` SSOT
276
+ * (MuxPlayer), which plays Mux HLS manifests directly on every browser
277
+ * no HLS→MP4 resolve step needed for playback.
293
278
  */
294
- function DefaultVideoPreview({ videoUrl, resolveVideoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
295
- // `undefined` only while an async resolve is in flight (first paint); otherwise
296
- // the (possibly resolved) URL. Synchronous passthrough when there's no resolver.
297
- const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
298
- resolveVideoUrl ? undefined : videoUrl,
299
- );
300
-
301
- useEffect(() => {
302
- if (!resolveVideoUrl) {
303
- setResolvedUrl(videoUrl);
304
- return;
305
- }
306
- // Guard against a late resolve overwriting a newer URL / unmounted preview.
307
- let active = true;
308
- setResolvedUrl(undefined);
309
- resolveVideoUrl(videoUrl)
310
- .then((url) => { if (active) setResolvedUrl(url); })
311
- .catch(() => { if (active) setResolvedUrl(videoUrl); });
312
- return () => { active = false; };
313
- }, [videoUrl, resolveVideoUrl]);
314
-
279
+ function DefaultVideoPreview({ videoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
315
280
  return (
316
281
  <div className="relative rounded-lg border border-ods-border overflow-hidden">
317
- {resolvedUrl === undefined ? (
318
- <div className="w-full h-[200px] flex items-center justify-center bg-black">
319
- <Loader2 className="h-6 w-6 animate-spin text-ods-accent" />
320
- </div>
321
- ) : (
322
- <video
323
- src={resolvedUrl}
324
- className="w-full h-auto max-h-[300px] object-contain bg-black"
325
- controls
326
- />
327
- )}
282
+ {/* aspect-video box: MuxPlayer fills its container (width/height 100%),
283
+ so the wrapper must reserve the height the intrinsic-sized native
284
+ <video> used to provide. */}
285
+ <div className="w-full aspect-video max-h-[300px]">
286
+ <Video kind="file" url={videoUrl} />
287
+ </div>
328
288
  <button
329
289
  type="button"
330
290
  onClick={onDelete}
@@ -34,6 +34,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
34
  import MuxPlayer from '@mux/mux-player-react';
35
35
  import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
36
36
  import { fetchPriorityProp } from '../../utils/fetch-priority';
37
+ import { saveDataEnabled } from './use-video-warmup';
38
+
39
+ // =============================================================================
40
+ // Dev-only hover→playing latency instrumentation gate. Always on in dev
41
+ // builds; opt-in in production via `localStorage.VIDEO_PERF_DEBUG` so an
42
+ // "instant hover" regression can be measured on a live deployment.
43
+ // =============================================================================
44
+ function videoPerfDebugEnabled(): boolean {
45
+ if (process.env.NODE_ENV !== 'production') return true;
46
+ try {
47
+ return typeof localStorage !== 'undefined' && localStorage.getItem('VIDEO_PERF_DEBUG') !== null;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
37
52
 
38
53
  // =============================================================================
39
54
  // Suppress Google Cast SDK loading (CSP-friendly)
@@ -253,8 +268,19 @@ interface VideoFileProps extends VideoCommonProps {
253
268
  * seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
254
269
  * fragmentless metadata load stays blank). No chrome, no playback, no
255
270
  * MuxPlayer cost — the resting facade layer under strip cards when no
256
- * poster asset exists. All player props are ignored. */
271
+ * poster asset exists. `poster`/`fit`/`className` are honored; all other
272
+ * player props are ignored. */
257
273
  firstFrameOnly?: boolean;
274
+ /** Media preload hint. When omitted, the SSOT default applies:
275
+ * `'metadata'` (manifest + ~1 segment buffered on mount — instant
276
+ * hover/click start, bounded by playback-core's maxBufferLength=1 clamp),
277
+ * downgraded to `'none'` on Save-Data connections. Pass a value only to
278
+ * override the policy deliberately. */
279
+ preload?: 'none' | 'metadata' | 'auto';
280
+ /** Object-fit for the media inside the player box. Default `'contain'`
281
+ * (MuxPlayer default); `'cover'` crops to fill — aspect-cropped grid
282
+ * cells and social-post mockups. */
283
+ fit?: 'contain' | 'cover';
258
284
  }
259
285
 
260
286
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -275,6 +301,8 @@ interface VideoAutoProps extends VideoCommonProps {
275
301
  playOnHover?: boolean;
276
302
  playWhenHovered?: boolean;
277
303
  firstFrameOnly?: boolean;
304
+ preload?: 'none' | 'metadata' | 'auto';
305
+ fit?: 'contain' | 'cover';
278
306
  }
279
307
 
280
308
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -300,7 +328,12 @@ export function Video(props: VideoProps): React.ReactElement | null {
300
328
  minimalControls={props.minimalControls}
301
329
  />
302
330
  ) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
303
- <FirstFramePreview url={url} className={props.className} />
331
+ <FirstFramePreview
332
+ url={url}
333
+ poster={props.poster}
334
+ fit={'fit' in props ? props.fit : undefined}
335
+ className={props.className}
336
+ />
304
337
  ) : (
305
338
  <FilePlayer
306
339
  url={url}
@@ -313,6 +346,8 @@ export function Video(props: VideoProps): React.ReactElement | null {
313
346
  chromeless={'chromeless' in props ? props.chromeless : undefined}
314
347
  playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
315
348
  playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
349
+ preload={'preload' in props ? props.preload : undefined}
350
+ fit={'fit' in props ? props.fit : undefined}
316
351
  className={props.className}
317
352
  />
318
353
  );
@@ -387,16 +422,30 @@ function wrapWithLayout(
387
422
  * first frame (also fixes iOS Safari, which stays blank on a fragmentless
388
423
  * metadata load). The transparent media background keeps the box showing the
389
424
  * card surface (never black) until the frame decodes. */
390
- function FirstFramePreview({ url, className }: { url: string; className?: string }): React.ReactElement {
425
+ function FirstFramePreview({
426
+ url,
427
+ poster,
428
+ fit,
429
+ className,
430
+ }: {
431
+ url: string;
432
+ poster?: string | null;
433
+ fit?: 'contain' | 'cover';
434
+ className?: string;
435
+ }): React.ReactElement {
391
436
  return (
392
437
  <div
393
438
  aria-hidden
394
439
  className="h-full w-full"
395
440
  style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
396
441
  >
442
+ {/* No explicit `preload` — inherits the SSOT default ('metadata',
443
+ 'none' under Save-Data) so posterless facade cards honor the
444
+ Save-Data policy like every other surface. */}
397
445
  <FilePlayer
398
446
  url={`${url}#t=0.1`}
399
- preload="metadata"
447
+ poster={poster}
448
+ fit={fit}
400
449
  muted
401
450
  chromeless
402
451
  className={className}
@@ -420,8 +469,11 @@ interface FilePlayerProps {
420
469
  chromeless?: boolean;
421
470
  playOnHover?: boolean;
422
471
  playWhenHovered?: boolean;
423
- /** Media preload hint — the firstFrameOnly facade passes 'metadata'. */
472
+ /** Media preload hint — see the public `VideoFileProps.preload` JSDoc.
473
+ * When omitted, defaults to 'metadata' ('none' under Save-Data). */
424
474
  preload?: 'none' | 'metadata' | 'auto';
475
+ /** Object-fit — 'cover' maps to media-chrome's `--media-object-fit`. */
476
+ fit?: 'contain' | 'cover';
425
477
  className?: string;
426
478
  }
427
479
 
@@ -437,8 +489,21 @@ function FilePlayer({
437
489
  playOnHover,
438
490
  playWhenHovered,
439
491
  preload,
492
+ fit,
440
493
  className,
441
494
  }: FilePlayerProps): React.ReactElement {
495
+ // Explicit preload policy — never rely on the browser/MuxPlayer implicit
496
+ // default. 'metadata' makes playback-core load the manifest immediately
497
+ // and clamp buffering to maxBufferLength=1/maxBufferSize=1 (manifest +
498
+ // ~1 segment, then idle); play() restores full buffering automatically.
499
+ // That bounded prefetch is what makes hover/click start instant. Save-Data
500
+ // connections get 'none' (nothing until play()). SSR note: the server pass
501
+ // always emits 'metadata' (saveDataEnabled() is false server-side); for
502
+ // Save-Data users React reconciles the property to 'none' during hydration.
503
+ // The mux-player custom element upgrades from client JS, so the window for
504
+ // an early manifest fetch is at most one bounded manifest+segment — the
505
+ // Save-Data verification gate covers it (see plan A2.4).
506
+ const effectivePreload = preload ?? (saveDataEnabled() ? 'none' : 'metadata');
442
507
  // True while hover playback is running MUTED because the browser's autoplay
443
508
  // policy blocked sound (no user activation yet). Drives the center unmute
444
509
  // control — the industry pattern (muted autoplay + explicit unmute button)
@@ -454,7 +519,22 @@ function FilePlayer({
454
519
  pause?: () => void;
455
520
  muted?: boolean;
456
521
  volume?: number;
522
+ addEventListener?: (type: string, listener: () => void) => void;
523
+ removeEventListener?: (type: string, listener: () => void) => void;
457
524
  } | null>(null);
525
+ // Dev/opt-in hover→'playing' latency metric (see videoPerfDebugEnabled).
526
+ // One listener at a time — re-entering hover replaces it; hover-leave and
527
+ // unmount clear it so no stale listener survives across generations.
528
+ const perfListenerRef = useRef<(() => void) | null>(null);
529
+ const clearPerfListener = useCallback(() => {
530
+ if (perfListenerRef.current) {
531
+ try {
532
+ hoverPlayerRef.current?.removeEventListener?.('playing', perfListenerRef.current);
533
+ } catch { /* element already torn down */ }
534
+ perfListenerRef.current = null;
535
+ }
536
+ }, []);
537
+ useEffect(() => clearPerfListener, [clearPerfListener]);
458
538
  // Tracks whether the pointer is STILL over the player. A fast hover-out
459
539
  // pauses the in-flight play(), which rejects it with AbortError — that must
460
540
  // NOT trigger the muted retry (it would restart playback after the pointer
@@ -481,6 +561,17 @@ function FilePlayer({
481
561
  const generation = ++hoverGenerationRef.current;
482
562
  const el = hoverPlayerRef.current;
483
563
  if (!el) return;
564
+ if (videoPerfDebugEnabled()) {
565
+ clearPerfListener();
566
+ const startedAt = performance.now();
567
+ const onPlaying = () => {
568
+ clearPerfListener();
569
+ // eslint-disable-next-line no-console
570
+ console.debug('[Video] hover→playing %dms', Math.round(performance.now() - startedAt));
571
+ };
572
+ perfListenerRef.current = onPlaying;
573
+ try { el.addEventListener?.('playing', onPlaying); } catch { /* ignore */ }
574
+ }
484
575
  try {
485
576
  el.volume = 0.5;
486
577
  if (userHasInteracted) {
@@ -523,13 +614,14 @@ function FilePlayer({
523
614
  activationWaiters.add(waiter);
524
615
  }
525
616
  } catch { /* ignore */ }
526
- }, []);
617
+ }, [clearActivationWaiter, clearPerfListener]);
527
618
  const stopHoverPlayback = useCallback(() => {
528
619
  hoverActiveRef.current = false;
529
620
  setHoverMutedFallback(false);
530
621
  clearActivationWaiter();
622
+ clearPerfListener();
531
623
  try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
532
- }, [clearActivationWaiter]);
624
+ }, [clearActivationWaiter, clearPerfListener]);
533
625
 
534
626
  // Explicit unmute affordance: the click IS the user activation the autoplay
535
627
  // policy wants, so unmuting here always succeeds (and the window-level
@@ -579,7 +671,7 @@ function FilePlayer({
579
671
  src={url}
580
672
  poster={poster || undefined}
581
673
  streamType="on-demand"
582
- preload={preload}
674
+ preload={effectivePreload}
583
675
  playsInline
584
676
  muted={muted}
585
677
  preferCmcd="header"
@@ -606,10 +698,13 @@ function FilePlayer({
606
698
  // hides only the bottom bar (center play/pause stays) — the
607
699
  // bite-strip card look per Figma. Merged (never replacing) into the
608
700
  // sizing style; custom properties need the CSSProperties cast.
701
+ // `--media-object-fit: cover` is media-chrome's documented object-fit
702
+ // var — the `fit="cover"` crop for grid cells / mockups.
609
703
  style={{
610
704
  width: '100%',
611
705
  height: '100%',
612
706
  ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
707
+ ...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
613
708
  }}
614
709
  >
615
710
  {captionsUrl ? (
@@ -1100,6 +1100,26 @@ const RichMarkdownInner: React.FC<InnerProps> = ({
1100
1100
  return <MarkdownImage src={src.trim()} alt={alt} />;
1101
1101
  },
1102
1102
 
1103
+ // Raw <video> tags in stored content (e.g. the hub's blog publisher
1104
+ // injects `<video src class="w-full my-8 rounded-lg" controls>` via
1105
+ // injectVideoInBlogContent) pass through rehypeRaw — route them to the
1106
+ // <Video> SSOT (MuxPlayer) instead of a native element so Mux HLS
1107
+ // manifests play on every browser and playback UX is unified. The
1108
+ // incoming className is forwarded so existing embeds keep their
1109
+ // width/margin/rounding.
1110
+ video: ({ src, poster, className }: any) => {
1111
+ if (!src || typeof src !== 'string' || src.trim() === '') {
1112
+ return null;
1113
+ }
1114
+ return (
1115
+ <div className={`overflow-hidden ${className || 'w-full my-8 rounded-lg'}`}>
1116
+ <div className="w-full aspect-video">
1117
+ <Video kind="file" url={src.trim()} poster={typeof poster === 'string' ? poster : undefined} />
1118
+ </div>
1119
+ </div>
1120
+ );
1121
+ },
1122
+
1103
1123
  // Style lists
1104
1124
  ul: ({ children }: any) => (
1105
1125
  <ul className={`list-disc list-outside my-4 ml-8 space-y-2 ${
@@ -200,7 +200,11 @@ function cardAwareUrlTransform(url: string, key: string): string {
200
200
  * 18 logs "tag is unrecognized in this browser" for every unknown tag;
201
201
  * React 19 throws on tags with reserved kebab-case forms. We pre-escape
202
202
  * to keep the renderer pristine without losing legitimate inline HTML
203
- * (details/summary, video, iframe, kbd, mark, etc.).
203
+ * (details/summary, iframe, kbd, mark, etc.). `video` is deliberately
204
+ * NOT allow-listed: chat strips <video> tags server-side, and all video
205
+ * playback goes through the <Video> SSOT — a stray tag renders as
206
+ * escaped literal text (visible + debuggable) rather than a native
207
+ * player outside the unified pipeline.
204
208
  *
205
209
  * The allow-list mirrors HTML5 + the elements the chat shell wires
206
210
  * component overrides for. Kept lower-case; matched case-insensitively.
@@ -215,8 +219,8 @@ const SAFE_HTML_TAGS = new Set([
215
219
  'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
216
220
  'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
217
221
  'ul', 'var', 'wbr',
218
- // Media
219
- 'img', 'picture', 'source', 'audio', 'video', 'iframe', 'track',
222
+ // Media ('video' intentionally excluded — see the header comment)
223
+ 'img', 'picture', 'source', 'audio', 'iframe', 'track',
220
224
  // Forms (rehype-raw allows them; mostly harmless for chat output)
221
225
  'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
222
226
  ])
@@ -652,8 +656,8 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
652
656
  // logs a noisy "tag is unrecognized in this browser" warning AND can
653
657
  // crash the SimpleMarkdownRenderer when the tag has a kebab-case
654
658
  // form React rejects outright). Allow-listed tags pass through
655
- // unchanged so legitimate inline HTML (details/summary, video,
656
- // iframe, etc.) still renders.
659
+ // unchanged so legitimate inline HTML (details/summary, iframe,
660
+ // etc.) still renders ('video' is excluded — see SAFE_HTML_TAGS).
657
661
  const processedContent = useMemo(
658
662
  () => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
659
663
  [preprocessContent, content],
@@ -58,6 +58,14 @@ function getObserverFor(rootMargin: string): IntersectionObserver {
58
58
  return io;
59
59
  }
60
60
 
61
+ /**
62
+ * Default near-viewport lookahead — the SSOT for "mount media on approach".
63
+ * Shared by this hook's default AND the raw two-way IntersectionObservers in
64
+ * `cards-strip.tsx` / `video-bites-strip.tsx` (which need mount+unmount, not
65
+ * this hook's fire-once semantics, but must agree on the distance).
66
+ */
67
+ export const NEAR_VIEWPORT_ROOT_MARGIN = '500px';
68
+
61
69
  export interface UseNearViewportResult<T extends Element = HTMLElement> {
62
70
  /** Ref to attach to the element you want to gate on visibility. */
63
71
  ref: (node: T | null) => void;
@@ -72,7 +80,7 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
72
80
  * '0px' = strict on-screen detection.
73
81
  */
74
82
  export function useNearViewport<T extends Element = HTMLElement>(
75
- rootMargin: string = '500px'
83
+ rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
76
84
  ): UseNearViewportResult<T> {
77
85
  const [isNear, setIsNear] = useState(false);
78
86
  const elRef = useRef<T | null>(null);