@flamingo-stack/openframe-frontend-core 0.0.446-snapshot.20260717214810 → 0.0.446

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-X4XHW3OV.cjs → chunk-3AFV2YIC.cjs} +40 -40
  2. package/dist/{chunk-X4XHW3OV.cjs.map → chunk-3AFV2YIC.cjs.map} +1 -1
  3. package/dist/{chunk-TKGCTDQY.js → chunk-5NYXGWBN.js} +2 -4
  4. package/dist/{chunk-TKGCTDQY.js.map → chunk-5NYXGWBN.js.map} +1 -1
  5. package/dist/{chunk-S7VMUDUB.cjs → chunk-5WX2V6CY.cjs} +12 -12
  6. package/dist/{chunk-S7VMUDUB.cjs.map → chunk-5WX2V6CY.cjs.map} +1 -1
  7. package/dist/{chunk-3GBLPPSW.js → chunk-5YQSRQHP.js} +6 -6
  8. package/dist/{chunk-FR6NVZVM.cjs → chunk-6XZNIPXP.cjs} +18 -18
  9. package/dist/{chunk-FR6NVZVM.cjs.map → chunk-6XZNIPXP.cjs.map} +1 -1
  10. package/dist/{chunk-2PNWAAU4.cjs → chunk-7IC2QCOL.cjs} +73 -73
  11. package/dist/{chunk-2PNWAAU4.cjs.map → chunk-7IC2QCOL.cjs.map} +1 -1
  12. package/dist/{chunk-RNXMP7GB.cjs → chunk-BAUWT7PV.cjs} +941 -986
  13. package/dist/chunk-BAUWT7PV.cjs.map +1 -0
  14. package/dist/{chunk-ZYB3BYBA.js → chunk-BNYI2FVM.js} +4 -4
  15. package/dist/{chunk-EZMM2OMP.cjs → chunk-DH3DUY6Q.cjs} +32 -32
  16. package/dist/{chunk-EZMM2OMP.cjs.map → chunk-DH3DUY6Q.cjs.map} +1 -1
  17. package/dist/{chunk-YHNBJ3F3.js → chunk-G2KXNMFQ.js} +2 -2
  18. package/dist/{chunk-TMZYFYSS.js → chunk-GTHA2SYB.js} +5 -5
  19. package/dist/{chunk-D2NLNBLH.js → chunk-I7IOEXRJ.js} +2 -2
  20. package/dist/{chunk-6TAIXSF5.js → chunk-K6JIBV3X.js} +2 -2
  21. package/dist/{chunk-ITJMUH4Q.cjs → chunk-L276GOMD.cjs} +40 -40
  22. package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-L276GOMD.cjs.map} +1 -1
  23. package/dist/{chunk-YCSNDUDE.js → chunk-MAB4TQCO.js} +297 -342
  24. package/dist/chunk-MAB4TQCO.js.map +1 -0
  25. package/dist/{chunk-ZQVEB6KT.js → chunk-MR7BMJHI.js} +6 -6
  26. package/dist/{chunk-PF6CKZIH.cjs → chunk-MS5CEBBQ.cjs} +5 -5
  27. package/dist/{chunk-PF6CKZIH.cjs.map → chunk-MS5CEBBQ.cjs.map} +1 -1
  28. package/dist/{chunk-XUS75SQW.cjs → chunk-N3Y6JYH5.cjs} +15 -15
  29. package/dist/{chunk-XUS75SQW.cjs.map → chunk-N3Y6JYH5.cjs.map} +1 -1
  30. package/dist/{chunk-5BJJRPDH.js → chunk-N4CCB33C.js} +3 -3
  31. package/dist/{chunk-WLU37UZO.cjs → chunk-NQLY27GH.cjs} +73 -73
  32. package/dist/{chunk-WLU37UZO.cjs.map → chunk-NQLY27GH.cjs.map} +1 -1
  33. package/dist/{chunk-ROCJMSJI.cjs → chunk-PTT4J5GF.cjs} +3 -3
  34. package/dist/{chunk-ROCJMSJI.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
  35. package/dist/{chunk-E6HKLQ3Q.cjs → chunk-SA7AHDS6.cjs} +7 -7
  36. package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-SA7AHDS6.cjs.map} +1 -1
  37. package/dist/{chunk-4ONE3HQP.js → chunk-SAV4U37G.js} +2 -2
  38. package/dist/{chunk-WMOF3WQT.js → chunk-SCKAD2MY.js} +5 -5
  39. package/dist/{chunk-AN7MCJTV.cjs → chunk-VIJARDBM.cjs} +3 -5
  40. package/dist/chunk-VIJARDBM.cjs.map +1 -0
  41. package/dist/{chunk-G7QSLGMI.js → chunk-XB6D7B3I.js} +3 -3
  42. package/dist/{chunk-SHC5NFOH.js → chunk-YLMD4UFA.js} +3 -3
  43. package/dist/{chunk-KYLOPUIH.cjs → chunk-ZKYEMGMD.cjs} +29 -29
  44. package/dist/{chunk-KYLOPUIH.cjs.map → chunk-ZKYEMGMD.cjs.map} +1 -1
  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 +3 -5
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +2 -4
  62. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  63. package/dist/components/features/mux-origins.cjs +0 -14
  64. package/dist/components/features/mux-origins.cjs.map +1 -1
  65. package/dist/components/features/mux-origins.d.ts +2 -26
  66. package/dist/components/features/mux-origins.d.ts.map +1 -1
  67. package/dist/components/features/mux-origins.js +1 -14
  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 +0 -12
  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 +11 -1
  74. package/dist/components/features/video-source-selector.d.ts.map +1 -1
  75. package/dist/components/features/video.d.ts +1 -14
  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 +135 -137
  80. package/dist/components/index.cjs.map +1 -1
  81. package/dist/components/index.js +11 -13
  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 +2 -4
  95. package/dist/hooks/index.cjs.map +1 -1
  96. package/dist/hooks/index.js +1 -3
  97. package/dist/hooks/use-near-viewport.d.ts +0 -7
  98. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  99. package/dist/index.cjs +3 -7
  100. package/dist/index.cjs.map +1 -1
  101. package/dist/index.js +2 -6
  102. package/package.json +1 -1
  103. package/src/components/features/cards-strip.tsx +1 -2
  104. package/src/components/features/highlight-video-section.tsx +7 -10
  105. package/src/components/features/media-gallery-manager.tsx +8 -6
  106. package/src/components/features/mux-origins.ts +2 -40
  107. package/src/components/features/release-media-manager.tsx +8 -6
  108. package/src/components/features/use-video-warmup.ts +3 -19
  109. package/src/components/features/video-bites-strip.tsx +4 -24
  110. package/src/components/features/video-source-selector.tsx +56 -16
  111. package/src/components/features/video.tsx +8 -103
  112. package/src/components/ui/rich-markdown-renderer.tsx +0 -20
  113. package/src/components/ui/simple-markdown-renderer.tsx +5 -9
  114. package/src/hooks/use-near-viewport.ts +1 -9
  115. package/dist/chunk-AN7MCJTV.cjs.map +0 -1
  116. package/dist/chunk-RNXMP7GB.cjs.map +0 -1
  117. package/dist/chunk-YCSNDUDE.js.map +0 -1
  118. /package/dist/{chunk-3GBLPPSW.js.map → chunk-5YQSRQHP.js.map} +0 -0
  119. /package/dist/{chunk-ZYB3BYBA.js.map → chunk-BNYI2FVM.js.map} +0 -0
  120. /package/dist/{chunk-YHNBJ3F3.js.map → chunk-G2KXNMFQ.js.map} +0 -0
  121. /package/dist/{chunk-TMZYFYSS.js.map → chunk-GTHA2SYB.js.map} +0 -0
  122. /package/dist/{chunk-D2NLNBLH.js.map → chunk-I7IOEXRJ.js.map} +0 -0
  123. /package/dist/{chunk-6TAIXSF5.js.map → chunk-K6JIBV3X.js.map} +0 -0
  124. /package/dist/{chunk-ZQVEB6KT.js.map → chunk-MR7BMJHI.js.map} +0 -0
  125. /package/dist/{chunk-5BJJRPDH.js.map → chunk-N4CCB33C.js.map} +0 -0
  126. /package/dist/{chunk-4ONE3HQP.js.map → chunk-SAV4U37G.js.map} +0 -0
  127. /package/dist/{chunk-WMOF3WQT.js.map → chunk-SCKAD2MY.js.map} +0 -0
  128. /package/dist/{chunk-G7QSLGMI.js.map → chunk-XB6D7B3I.js.map} +0 -0
  129. /package/dist/{chunk-SHC5NFOH.js.map → chunk-YLMD4UFA.js.map} +0 -0
@@ -1,17 +1,12 @@
1
1
  /**
2
- * Mux CDN origins + playback-URL helpers — single source of truth, server-safe.
2
+ * Mux CDN origin constants — 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 without
6
+ * `lib/config/mux-config.ts`) can import these strings 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
- *
15
10
  * Bug history (2026-05-29): when these constants lived in
16
11
  * `use-video-warmup.ts` (which is `'use client'`), the hub's
17
12
  * server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed
@@ -30,36 +25,3 @@ export const MUX_STREAM_ORIGIN = 'https://stream.mux.com'
30
25
 
31
26
  /** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */
32
27
  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,9 +2,8 @@
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 as VideoIcon, Upload, Loader2, GripVertical } from 'lucide-react';
5
+ import { Trash2, Plus, Image as ImageIcon, Video, Upload, Loader2, GripVertical } from 'lucide-react';
6
6
  import Image from '../../embed-shims/next-image';
7
- import { Video } from './video';
8
7
 
9
8
  export interface ReleaseMediaItem {
10
9
  media_type: 'image' | 'video' | 'screenshot' | 'demo';
@@ -114,7 +113,7 @@ export function ReleaseMediaManager({
114
113
  switch (type) {
115
114
  case 'video':
116
115
  case 'demo':
117
- return <VideoIcon className="w-5 h-5 text-ods-text-secondary" />;
116
+ return <Video className="w-5 h-5 text-ods-text-secondary" />;
118
117
  default:
119
118
  return <ImageIcon className="w-5 h-5 text-ods-text-secondary" />;
120
119
  }
@@ -206,9 +205,12 @@ export function ReleaseMediaManager({
206
205
  {item.media_url && (
207
206
  <div className="aspect-video relative bg-ods-bg">
208
207
  {item.media_type === 'video' || item.media_type === 'demo' ? (
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" />
208
+ <video
209
+ src={item.media_url}
210
+ className="w-full h-full object-cover"
211
+ controls
212
+ preload="metadata"
213
+ />
212
214
  ) : (
213
215
  <Image
214
216
  src={item.media_url}
@@ -48,18 +48,6 @@ 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
-
63
51
  /**
64
52
  * Preconnect-only variant — fires the three video-bearing origin
65
53
  * preconnects (Supabase Storage + Mux stream + Mux image) without
@@ -80,12 +68,6 @@ export function saveDataEnabled(): boolean {
80
68
  * any equivalent provider that wires the field) get the origin
81
69
  * automatically. The explicit `supabaseStorageOrigin` argument
82
70
  * 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.
89
71
  */
90
72
  export function useVideoOriginPreconnect({
91
73
  supabaseStorageOrigin,
@@ -157,7 +139,9 @@ export function useVideoWarmup<T extends Element = HTMLDivElement>({
157
139
  if (!isNear || !videoUrl || !resolvedOrigin) return
158
140
 
159
141
  // Save-Data gate — metered connections skip preload.
160
- if (saveDataEnabled()) return
142
+ type Connection = { saveData?: boolean }
143
+ const conn = (navigator as Navigator & { connection?: Connection }).connection
144
+ if (conn?.saveData === true) return
161
145
 
162
146
  // Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms
163
147
  // via the manifest fetch when MuxPlayer mounts; YouTube has no
@@ -29,8 +29,6 @@ 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';
34
32
  import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
35
33
  import type { VideoTeaserWithRatio } from './video-ratio-tabs';
36
34
  import {
@@ -229,16 +227,7 @@ export function VideoBiteCard({
229
227
  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
230
228
  const targetHref = bite.href ?? sectionHref;
231
229
  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.
235
230
  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);
242
231
 
243
232
  // Hover activation: controlled by the strip (activeKey) or self-managed
244
233
  // when rendered standalone (admin editor).
@@ -261,7 +250,7 @@ export function VideoBiteCard({
261
250
  if (!el || typeof IntersectionObserver === 'undefined') return;
262
251
  const io = new IntersectionObserver(
263
252
  entries => setIsNear(entries[0]?.isIntersecting ?? false),
264
- { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
253
+ { rootMargin: '500px' },
265
254
  );
266
255
  io.observe(el);
267
256
  return () => io.disconnect();
@@ -403,7 +392,7 @@ export function VideoBiteCard({
403
392
  className="object-cover"
404
393
  />
405
394
  ) : (
406
- <Video kind="file" url={previewUrl} firstFrameOnly layout="fill" />
395
+ <Video kind="file" url={bite.url} firstFrameOnly layout="fill" />
407
396
  )}
408
397
 
409
398
  {/* Play affordance for every non-playing state (resting poster,
@@ -426,19 +415,10 @@ export function VideoBiteCard({
426
415
  it keeps playing. Sound at 50% (pre-activation: muted start +
427
416
  live unmute on the user's first gesture); CHROMELESS — the
428
417
  card's mux-replica badge above is the play affordance for
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. */}
418
+ every non-playing state. */}
439
419
  <Video
440
420
  kind="file"
441
- url={previewUrl}
421
+ url={bite.url}
442
422
  poster={bite.thumbnail_url}
443
423
  playWhenHovered={isActive}
444
424
  chromeless
@@ -1,14 +1,13 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useCallback } from 'react';
4
- import { Upload, X, Video as VideoIcon } from 'lucide-react';
3
+ import React, { useState, useCallback, useEffect } from 'react';
4
+ import { Upload, Sparkles, X, Video, Loader2 } 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';
12
11
 
13
12
  export type VideoSourceType = 'youtube' | 'uploaded';
14
13
 
@@ -49,6 +48,16 @@ export interface VideoSourceSelectorProps {
49
48
  onDelete: () => void;
50
49
  isAIGenerated?: boolean;
51
50
  }>;
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>;
52
61
  /** Optional: Upload progress bar component */
53
62
  UploadProgressComponent?: React.ComponentType<{
54
63
  progress: number;
@@ -86,6 +95,7 @@ export function VideoSourceSelector({
86
95
  uploadEmptyText = 'No video uploaded yet. Click "Upload Video" to add one.',
87
96
  disabled = false,
88
97
  VideoPreviewComponent,
98
+ resolveVideoUrl,
89
99
  UploadProgressComponent,
90
100
  title = 'Video',
91
101
  showTitle = true,
@@ -139,7 +149,7 @@ export function VideoSourceSelector({
139
149
  {/* Section Title */}
140
150
  {showTitle && (
141
151
  <h3 className="text-h5 text-ods-text-primary flex items-center gap-2">
142
- <VideoIcon className="h-5 w-5" />
152
+ <Video className="h-5 w-5" />
143
153
  {title}
144
154
  </h3>
145
155
  )}
@@ -246,8 +256,10 @@ export function VideoSourceSelector({
246
256
  isAIGenerated={isAIGenerated}
247
257
  />
248
258
  ) : (
259
+ // Default simple preview (resolver-aware — see DefaultVideoPreview)
249
260
  <DefaultVideoPreview
250
261
  videoUrl={mainVideoUrl}
262
+ resolveVideoUrl={resolveVideoUrl}
251
263
  onDelete={handleDeleteVideo}
252
264
  disabled={disabled}
253
265
  />
@@ -264,27 +276,55 @@ export function VideoSourceSelector({
264
276
  }
265
277
 
266
278
  interface DefaultVideoPreviewProps {
267
- /** Uploaded video URL — MP4 or Mux HLS manifest, both playable via <Video>. */
279
+ /** Uploaded video URL (possibly an HLS manifest a native `<video>` can't play). */
268
280
  videoUrl: string;
281
+ /** Optional resolver → playable MP4 URL; omit for as-is (backward-compatible) behaviour. */
282
+ resolveVideoUrl?: (url: string) => Promise<string>;
269
283
  onDelete: () => void;
270
284
  disabled?: boolean;
271
285
  }
272
286
 
273
287
  /**
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.
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.
278
293
  */
279
- function DefaultVideoPreview({ videoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
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
+
280
315
  return (
281
316
  <div className="relative rounded-lg border border-ods-border overflow-hidden">
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>
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
+ )}
288
328
  <button
289
329
  type="button"
290
330
  onClick={onDelete}
@@ -34,21 +34,6 @@ 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
- }
52
37
 
53
38
  // =============================================================================
54
39
  // Suppress Google Cast SDK loading (CSP-friendly)
@@ -268,19 +253,8 @@ interface VideoFileProps extends VideoCommonProps {
268
253
  * seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
269
254
  * fragmentless metadata load stays blank). No chrome, no playback, no
270
255
  * MuxPlayer cost — the resting facade layer under strip cards when no
271
- * poster asset exists. `poster`/`fit`/`className` are honored; all other
272
- * player props are ignored. */
256
+ * poster asset exists. All player props are ignored. */
273
257
  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';
284
258
  }
285
259
 
286
260
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -301,8 +275,6 @@ interface VideoAutoProps extends VideoCommonProps {
301
275
  playOnHover?: boolean;
302
276
  playWhenHovered?: boolean;
303
277
  firstFrameOnly?: boolean;
304
- preload?: 'none' | 'metadata' | 'auto';
305
- fit?: 'contain' | 'cover';
306
278
  }
307
279
 
308
280
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -328,12 +300,7 @@ export function Video(props: VideoProps): React.ReactElement | null {
328
300
  minimalControls={props.minimalControls}
329
301
  />
330
302
  ) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
331
- <FirstFramePreview
332
- url={url}
333
- poster={props.poster}
334
- fit={'fit' in props ? props.fit : undefined}
335
- className={props.className}
336
- />
303
+ <FirstFramePreview url={url} className={props.className} />
337
304
  ) : (
338
305
  <FilePlayer
339
306
  url={url}
@@ -346,8 +313,6 @@ export function Video(props: VideoProps): React.ReactElement | null {
346
313
  chromeless={'chromeless' in props ? props.chromeless : undefined}
347
314
  playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
348
315
  playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
349
- preload={'preload' in props ? props.preload : undefined}
350
- fit={'fit' in props ? props.fit : undefined}
351
316
  className={props.className}
352
317
  />
353
318
  );
@@ -422,30 +387,16 @@ function wrapWithLayout(
422
387
  * first frame (also fixes iOS Safari, which stays blank on a fragmentless
423
388
  * metadata load). The transparent media background keeps the box showing the
424
389
  * card surface (never black) until the frame decodes. */
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 {
390
+ function FirstFramePreview({ url, className }: { url: string; className?: string }): React.ReactElement {
436
391
  return (
437
392
  <div
438
393
  aria-hidden
439
394
  className="h-full w-full"
440
395
  style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
441
396
  >
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. */}
445
397
  <FilePlayer
446
398
  url={`${url}#t=0.1`}
447
- poster={poster}
448
- fit={fit}
399
+ preload="metadata"
449
400
  muted
450
401
  chromeless
451
402
  className={className}
@@ -469,11 +420,8 @@ interface FilePlayerProps {
469
420
  chromeless?: boolean;
470
421
  playOnHover?: boolean;
471
422
  playWhenHovered?: boolean;
472
- /** Media preload hint — see the public `VideoFileProps.preload` JSDoc.
473
- * When omitted, defaults to 'metadata' ('none' under Save-Data). */
423
+ /** Media preload hint — the firstFrameOnly facade passes 'metadata'. */
474
424
  preload?: 'none' | 'metadata' | 'auto';
475
- /** Object-fit — 'cover' maps to media-chrome's `--media-object-fit`. */
476
- fit?: 'contain' | 'cover';
477
425
  className?: string;
478
426
  }
479
427
 
@@ -489,21 +437,8 @@ function FilePlayer({
489
437
  playOnHover,
490
438
  playWhenHovered,
491
439
  preload,
492
- fit,
493
440
  className,
494
441
  }: 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');
507
442
  // True while hover playback is running MUTED because the browser's autoplay
508
443
  // policy blocked sound (no user activation yet). Drives the center unmute
509
444
  // control — the industry pattern (muted autoplay + explicit unmute button)
@@ -519,22 +454,7 @@ function FilePlayer({
519
454
  pause?: () => void;
520
455
  muted?: boolean;
521
456
  volume?: number;
522
- addEventListener?: (type: string, listener: () => void) => void;
523
- removeEventListener?: (type: string, listener: () => void) => void;
524
457
  } | 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]);
538
458
  // Tracks whether the pointer is STILL over the player. A fast hover-out
539
459
  // pauses the in-flight play(), which rejects it with AbortError — that must
540
460
  // NOT trigger the muted retry (it would restart playback after the pointer
@@ -561,17 +481,6 @@ function FilePlayer({
561
481
  const generation = ++hoverGenerationRef.current;
562
482
  const el = hoverPlayerRef.current;
563
483
  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
- }
575
484
  try {
576
485
  el.volume = 0.5;
577
486
  if (userHasInteracted) {
@@ -614,14 +523,13 @@ function FilePlayer({
614
523
  activationWaiters.add(waiter);
615
524
  }
616
525
  } catch { /* ignore */ }
617
- }, [clearActivationWaiter, clearPerfListener]);
526
+ }, []);
618
527
  const stopHoverPlayback = useCallback(() => {
619
528
  hoverActiveRef.current = false;
620
529
  setHoverMutedFallback(false);
621
530
  clearActivationWaiter();
622
- clearPerfListener();
623
531
  try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
624
- }, [clearActivationWaiter, clearPerfListener]);
532
+ }, [clearActivationWaiter]);
625
533
 
626
534
  // Explicit unmute affordance: the click IS the user activation the autoplay
627
535
  // policy wants, so unmuting here always succeeds (and the window-level
@@ -671,7 +579,7 @@ function FilePlayer({
671
579
  src={url}
672
580
  poster={poster || undefined}
673
581
  streamType="on-demand"
674
- preload={effectivePreload}
582
+ preload={preload}
675
583
  playsInline
676
584
  muted={muted}
677
585
  preferCmcd="header"
@@ -698,13 +606,10 @@ function FilePlayer({
698
606
  // hides only the bottom bar (center play/pause stays) — the
699
607
  // bite-strip card look per Figma. Merged (never replacing) into the
700
608
  // 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.
703
609
  style={{
704
610
  width: '100%',
705
611
  height: '100%',
706
612
  ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
707
- ...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
708
613
  }}
709
614
  >
710
615
  {captionsUrl ? (
@@ -1100,26 +1100,6 @@ 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
-
1123
1103
  // Style lists
1124
1104
  ul: ({ children }: any) => (
1125
1105
  <ul className={`list-disc list-outside my-4 ml-8 space-y-2 ${
@@ -200,11 +200,7 @@ 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, 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.
203
+ * (details/summary, video, iframe, kbd, mark, etc.).
208
204
  *
209
205
  * The allow-list mirrors HTML5 + the elements the chat shell wires
210
206
  * component overrides for. Kept lower-case; matched case-insensitively.
@@ -219,8 +215,8 @@ const SAFE_HTML_TAGS = new Set([
219
215
  'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
220
216
  'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
221
217
  'ul', 'var', 'wbr',
222
- // Media ('video' intentionally excluded — see the header comment)
223
- 'img', 'picture', 'source', 'audio', 'iframe', 'track',
218
+ // Media
219
+ 'img', 'picture', 'source', 'audio', 'video', 'iframe', 'track',
224
220
  // Forms (rehype-raw allows them; mostly harmless for chat output)
225
221
  'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
226
222
  ])
@@ -656,8 +652,8 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
656
652
  // logs a noisy "tag is unrecognized in this browser" warning AND can
657
653
  // crash the SimpleMarkdownRenderer when the tag has a kebab-case
658
654
  // form React rejects outright). Allow-listed tags pass through
659
- // unchanged so legitimate inline HTML (details/summary, iframe,
660
- // etc.) still renders ('video' is excluded — see SAFE_HTML_TAGS).
655
+ // unchanged so legitimate inline HTML (details/summary, video,
656
+ // iframe, etc.) still renders.
661
657
  const processedContent = useMemo(
662
658
  () => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
663
659
  [preprocessContent, content],
@@ -58,14 +58,6 @@ 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
-
69
61
  export interface UseNearViewportResult<T extends Element = HTMLElement> {
70
62
  /** Ref to attach to the element you want to gate on visibility. */
71
63
  ref: (node: T | null) => void;
@@ -80,7 +72,7 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
80
72
  * '0px' = strict on-screen detection.
81
73
  */
82
74
  export function useNearViewport<T extends Element = HTMLElement>(
83
- rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
75
+ rootMargin: string = '500px'
84
76
  ): UseNearViewportResult<T> {
85
77
  const [isNear, setIsNear] = useState(false);
86
78
  const elRef = useRef<T | null>(null);