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

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 (141) hide show
  1. package/dist/{chunk-7IC2QCOL.cjs → chunk-3RM5Z2SH.cjs} +73 -73
  2. package/dist/{chunk-7IC2QCOL.cjs.map → chunk-3RM5Z2SH.cjs.map} +1 -1
  3. package/dist/{chunk-L276GOMD.cjs → chunk-5Z3YOBRE.cjs} +40 -40
  4. package/dist/{chunk-L276GOMD.cjs.map → chunk-5Z3YOBRE.cjs.map} +1 -1
  5. package/dist/{chunk-MS5CEBBQ.cjs → chunk-7YSVW2JG.cjs} +5 -5
  6. package/dist/{chunk-MS5CEBBQ.cjs.map → chunk-7YSVW2JG.cjs.map} +1 -1
  7. package/dist/{chunk-YLMD4UFA.js → chunk-AEPR6AXO.js} +3 -3
  8. package/dist/{chunk-SAV4U37G.js → chunk-AMS55EES.js} +2 -2
  9. package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
  10. package/dist/chunk-AN7MCJTV.cjs.map +1 -0
  11. package/dist/{chunk-5YQSRQHP.js → chunk-ANJ56NCM.js} +6 -6
  12. package/dist/{chunk-MR7BMJHI.js → chunk-CAEQ5BQO.js} +6 -6
  13. package/dist/{chunk-ZKYEMGMD.cjs → chunk-CCSCZSXS.cjs} +29 -29
  14. package/dist/{chunk-ZKYEMGMD.cjs.map → chunk-CCSCZSXS.cjs.map} +1 -1
  15. package/dist/{chunk-6XZNIPXP.cjs → chunk-HKMQ3LHY.cjs} +18 -18
  16. package/dist/{chunk-6XZNIPXP.cjs.map → chunk-HKMQ3LHY.cjs.map} +1 -1
  17. package/dist/{chunk-N3Y6JYH5.cjs → chunk-JM5VSYQB.cjs} +15 -15
  18. package/dist/{chunk-N3Y6JYH5.cjs.map → chunk-JM5VSYQB.cjs.map} +1 -1
  19. package/dist/{chunk-GTHA2SYB.js → chunk-KP2E22VP.js} +5 -5
  20. package/dist/{chunk-BAUWT7PV.cjs → chunk-M2U5KN4Q.cjs} +1057 -952
  21. package/dist/chunk-M2U5KN4Q.cjs.map +1 -0
  22. package/dist/{chunk-5WX2V6CY.cjs → chunk-NQTASSKI.cjs} +12 -12
  23. package/dist/{chunk-5WX2V6CY.cjs.map → chunk-NQTASSKI.cjs.map} +1 -1
  24. package/dist/{chunk-SA7AHDS6.cjs → chunk-P62PIQSY.cjs} +7 -7
  25. package/dist/{chunk-SA7AHDS6.cjs.map → chunk-P62PIQSY.cjs.map} +1 -1
  26. package/dist/{chunk-XB6D7B3I.js → chunk-PFRUS5M4.js} +3 -3
  27. package/dist/{chunk-BNYI2FVM.js → chunk-PHH7M3CF.js} +4 -4
  28. package/dist/{chunk-K6JIBV3X.js → chunk-PJ27XJRQ.js} +2 -2
  29. package/dist/{chunk-MAB4TQCO.js → chunk-QJN4XSP4.js} +414 -309
  30. package/dist/chunk-QJN4XSP4.js.map +1 -0
  31. package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
  32. package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
  33. package/dist/{chunk-N4CCB33C.js → chunk-T245XRIS.js} +3 -3
  34. package/dist/{chunk-3AFV2YIC.cjs → chunk-TGEAT3G4.cjs} +40 -40
  35. package/dist/{chunk-3AFV2YIC.cjs.map → chunk-TGEAT3G4.cjs.map} +1 -1
  36. package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
  37. package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
  38. package/dist/{chunk-SCKAD2MY.js → chunk-UAOR2SRY.js} +5 -5
  39. package/dist/{chunk-NQLY27GH.cjs → chunk-X62WEO5Z.cjs} +73 -73
  40. package/dist/{chunk-NQLY27GH.cjs.map → chunk-X62WEO5Z.cjs.map} +1 -1
  41. package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
  42. package/dist/{chunk-DH3DUY6Q.cjs → chunk-ZGERD4OP.cjs} +32 -32
  43. package/dist/{chunk-DH3DUY6Q.cjs.map → chunk-ZGERD4OP.cjs.map} +1 -1
  44. package/dist/{chunk-I7IOEXRJ.js → chunk-ZRYCV52Q.js} +2 -2
  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/chat/types/message.types.d.ts +13 -0
  50. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  51. package/dist/components/chat/utils/history-merge.d.ts +14 -4
  52. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  53. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  54. package/dist/components/contact/index.cjs +4 -4
  55. package/dist/components/contact/index.js +3 -3
  56. package/dist/components/docs/index.cjs +6 -6
  57. package/dist/components/docs/index.js +5 -5
  58. package/dist/components/embeds/index.cjs +4 -4
  59. package/dist/components/embeds/index.js +3 -3
  60. package/dist/components/faq/index.cjs +5 -5
  61. package/dist/components/faq/index.js +4 -4
  62. package/dist/components/features/cards-strip.d.ts.map +1 -1
  63. package/dist/components/features/highlight-video-section.d.ts.map +1 -1
  64. package/dist/components/features/index.cjs +5 -3
  65. package/dist/components/features/index.cjs.map +1 -1
  66. package/dist/components/features/index.js +4 -2
  67. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  68. package/dist/components/features/mux-origins.cjs +14 -0
  69. package/dist/components/features/mux-origins.cjs.map +1 -1
  70. package/dist/components/features/mux-origins.d.ts +26 -2
  71. package/dist/components/features/mux-origins.d.ts.map +1 -1
  72. package/dist/components/features/mux-origins.js +14 -1
  73. package/dist/components/features/mux-origins.js.map +1 -1
  74. package/dist/components/features/release-media-manager.d.ts.map +1 -1
  75. package/dist/components/features/use-video-warmup.d.ts +12 -0
  76. package/dist/components/features/use-video-warmup.d.ts.map +1 -1
  77. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  78. package/dist/components/features/video-source-selector.d.ts +1 -11
  79. package/dist/components/features/video-source-selector.d.ts.map +1 -1
  80. package/dist/components/features/video.d.ts +14 -1
  81. package/dist/components/features/video.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/index.cjs +22 -22
  83. package/dist/components/help-center-pages/index.js +13 -13
  84. package/dist/components/index.cjs +137 -135
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +13 -11
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/navigation/index.cjs +3 -3
  89. package/dist/components/navigation/index.js +2 -2
  90. package/dist/components/onboarding-guides/index.cjs +7 -7
  91. package/dist/components/onboarding-guides/index.js +6 -6
  92. package/dist/components/related-content/index.cjs +5 -5
  93. package/dist/components/related-content/index.js +4 -4
  94. package/dist/components/tickets/index.cjs +6 -6
  95. package/dist/components/tickets/index.js +5 -5
  96. package/dist/components/ui/index.cjs +3 -3
  97. package/dist/components/ui/index.js +2 -2
  98. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  99. package/dist/hooks/index.cjs +4 -2
  100. package/dist/hooks/index.cjs.map +1 -1
  101. package/dist/hooks/index.js +3 -1
  102. package/dist/hooks/use-near-viewport.d.ts +7 -0
  103. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  104. package/dist/index.cjs +7 -3
  105. package/dist/index.cjs.map +1 -1
  106. package/dist/index.js +6 -2
  107. package/dist/utils/index.cjs.map +1 -1
  108. package/dist/utils/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/components/chat/types/message.types.ts +13 -0
  111. package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
  112. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
  113. package/src/components/chat/utils/history-merge.ts +157 -14
  114. package/src/components/chat/utils/process-historical-messages.ts +45 -6
  115. package/src/components/features/cards-strip.tsx +2 -1
  116. package/src/components/features/highlight-video-section.tsx +10 -7
  117. package/src/components/features/media-gallery-manager.tsx +6 -8
  118. package/src/components/features/mux-origins.ts +40 -2
  119. package/src/components/features/release-media-manager.tsx +6 -8
  120. package/src/components/features/use-video-warmup.ts +19 -3
  121. package/src/components/features/video-bites-strip.tsx +24 -4
  122. package/src/components/features/video-source-selector.tsx +16 -56
  123. package/src/components/features/video.tsx +103 -8
  124. package/src/components/ui/rich-markdown-renderer.tsx +20 -0
  125. package/src/components/ui/simple-markdown-renderer.tsx +9 -5
  126. package/src/hooks/use-near-viewport.ts +9 -1
  127. package/dist/chunk-BAUWT7PV.cjs.map +0 -1
  128. package/dist/chunk-MAB4TQCO.js.map +0 -1
  129. package/dist/chunk-VIJARDBM.cjs.map +0 -1
  130. /package/dist/{chunk-YLMD4UFA.js.map → chunk-AEPR6AXO.js.map} +0 -0
  131. /package/dist/{chunk-SAV4U37G.js.map → chunk-AMS55EES.js.map} +0 -0
  132. /package/dist/{chunk-5YQSRQHP.js.map → chunk-ANJ56NCM.js.map} +0 -0
  133. /package/dist/{chunk-MR7BMJHI.js.map → chunk-CAEQ5BQO.js.map} +0 -0
  134. /package/dist/{chunk-GTHA2SYB.js.map → chunk-KP2E22VP.js.map} +0 -0
  135. /package/dist/{chunk-XB6D7B3I.js.map → chunk-PFRUS5M4.js.map} +0 -0
  136. /package/dist/{chunk-BNYI2FVM.js.map → chunk-PHH7M3CF.js.map} +0 -0
  137. /package/dist/{chunk-K6JIBV3X.js.map → chunk-PJ27XJRQ.js.map} +0 -0
  138. /package/dist/{chunk-N4CCB33C.js.map → chunk-T245XRIS.js.map} +0 -0
  139. /package/dist/{chunk-SCKAD2MY.js.map → chunk-UAOR2SRY.js.map} +0 -0
  140. /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
  141. /package/dist/{chunk-I7IOEXRJ.js.map → chunk-ZRYCV52Q.js.map} +0 -0
@@ -47,6 +47,7 @@ function pushStandaloneMessages(
47
47
  name: data.text,
48
48
  authorType: 'system',
49
49
  timestamp: new Date(msg.createdAt),
50
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
50
51
  })
51
52
  }
52
53
  })
@@ -99,6 +100,9 @@ export function processHistoricalMessages(
99
100
  let currentAssistantId: string | null = null
100
101
  let currentAssistantTimestamp: Date | null = null
101
102
  let lastAssistantId: string | null = null
103
+ // MAX persisted seq across the rows grouped into the current assistant turn
104
+ // — carried onto the flushed message's streamSeq for per-role merge coverage.
105
+ let currentAssistantStreamSeq: number | undefined
102
106
 
103
107
  /**
104
108
  * Flush the current assistant message to processedMessages.
@@ -116,12 +120,21 @@ export function processHistoricalMessages(
116
120
  authorType: assistantType as AuthorType,
117
121
  timestamp: currentAssistantTimestamp || new Date(),
118
122
  avatar: assistantAvatar,
123
+ ...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
119
124
  })
120
125
  accumulator.resetSegments()
121
- currentAssistantId = null
122
- currentAssistantTimestamp = null
123
- lastAssistantId = null
124
126
  }
127
+ // Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
128
+ // assistant turn whose only data was a filtered/escalated approval renders
129
+ // nothing, so `hasContent()` is false and nothing is pushed). Left inside
130
+ // the push-block, a stale id/timestamp and (worse) a stale
131
+ // `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
132
+ // stays false so it keeps the old id/timestamp, and `Math.max` inflates its
133
+ // streamSeq — which then over-covers synthetics in the history merge.
134
+ currentAssistantId = null
135
+ currentAssistantTimestamp = null
136
+ lastAssistantId = null
137
+ currentAssistantStreamSeq = undefined
125
138
  }
126
139
 
127
140
  messages.forEach((msg, index) => {
@@ -171,6 +184,7 @@ export function processHistoricalMessages(
171
184
  authorType: userAuthorType,
172
185
  timestamp: new Date(msg.createdAt),
173
186
  ...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
187
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
174
188
  })
175
189
  }
176
190
  })
@@ -180,6 +194,12 @@ export function processHistoricalMessages(
180
194
  currentAssistantTimestamp = new Date(msg.createdAt)
181
195
  }
182
196
  lastAssistantId = msg.id
197
+ if (typeof msg.lastChunkStreamSeq === 'number') {
198
+ currentAssistantStreamSeq =
199
+ currentAssistantStreamSeq === undefined
200
+ ? msg.lastChunkStreamSeq
201
+ : Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
202
+ }
183
203
 
184
204
  messageDataArray.forEach((data) => {
185
205
  processMessageData(
@@ -501,6 +521,9 @@ export function processHistoricalMessagesWithErrors(
501
521
  let currentAssistantId: string | null = null
502
522
  let currentAssistantTimestamp: Date | null = null
503
523
  let lastAssistantId: string | null = null
524
+ // MAX persisted seq across the rows grouped into the current assistant turn
525
+ // — carried onto the flushed message's streamSeq for per-role merge coverage.
526
+ let currentAssistantStreamSeq: number | undefined
504
527
 
505
528
  const flushAssistantMessage = () => {
506
529
  const idToUse = lastAssistantId || currentAssistantId
@@ -514,12 +537,21 @@ export function processHistoricalMessagesWithErrors(
514
537
  authorType: assistantType as AuthorType,
515
538
  timestamp: currentAssistantTimestamp || new Date(),
516
539
  avatar: assistantAvatar,
540
+ ...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
517
541
  })
518
542
  accumulator.resetSegments()
519
- currentAssistantId = null
520
- currentAssistantTimestamp = null
521
- lastAssistantId = null
522
543
  }
544
+ // Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
545
+ // assistant turn whose only data was a filtered/escalated approval renders
546
+ // nothing, so `hasContent()` is false and nothing is pushed). Left inside
547
+ // the push-block, a stale id/timestamp and (worse) a stale
548
+ // `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
549
+ // stays false so it keeps the old id/timestamp, and `Math.max` inflates its
550
+ // streamSeq — which then over-covers synthetics in the history merge.
551
+ currentAssistantId = null
552
+ currentAssistantTimestamp = null
553
+ lastAssistantId = null
554
+ currentAssistantStreamSeq = undefined
523
555
  }
524
556
 
525
557
  messages.forEach((msg, index) => {
@@ -568,6 +600,7 @@ export function processHistoricalMessagesWithErrors(
568
600
  authorType: userAuthorType,
569
601
  timestamp: new Date(msg.createdAt),
570
602
  ...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
603
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
571
604
  })
572
605
  }
573
606
  })
@@ -577,6 +610,12 @@ export function processHistoricalMessagesWithErrors(
577
610
  currentAssistantTimestamp = new Date(msg.createdAt)
578
611
  }
579
612
  lastAssistantId = msg.id
613
+ if (typeof msg.lastChunkStreamSeq === 'number') {
614
+ currentAssistantStreamSeq =
615
+ currentAssistantStreamSeq === undefined
616
+ ? msg.lastChunkStreamSeq
617
+ : Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
618
+ }
580
619
 
581
620
  messageDataArray.forEach((data) => {
582
621
  processMessageData(
@@ -42,6 +42,7 @@
42
42
 
43
43
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
44
  import { cn } from '../../utils/cn';
45
+ import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
45
46
  import { Button } from '../ui/button';
46
47
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
47
48
  import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
@@ -473,7 +474,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
473
474
  }
474
475
  return next;
475
476
  });
476
- }, { rootMargin: '500px' });
477
+ }, { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN });
477
478
  cardObserverRef.current = io;
478
479
  cardElIdxRef.current.forEach((_idx, el) => io.observe(el));
479
480
  return () => { io.disconnect(); cardObserverRef.current = null; };
@@ -9,6 +9,7 @@ import { Badge } from '../ui/badge';
9
9
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
10
10
  import { AIEnrichSection } from './ai-enrich/AIEnrichSection';
11
11
  import type { AIRequiredField } from './ai-enrich/AIEnrichSection';
12
+ import { Video } from './video';
12
13
 
13
14
  export interface HighlightVideoSectionProps {
14
15
  /** Current highlight video URL */
@@ -228,14 +229,16 @@ export function HighlightVideoSection({
228
229
  onDelete={onDeleteHighlight}
229
230
  />
230
231
  ) : (
231
- // Default simple preview
232
+ // Default simple preview — the <Video> SSOT (MuxPlayer) plays
233
+ // MP4 and Mux HLS alike; aspect-video box reserves the height.
232
234
  <div className="relative rounded-lg border border-ods-border overflow-hidden bg-black">
233
- <video
234
- src={highlightVideoUrl}
235
- poster={highlightVideoThumbnail || undefined}
236
- className="w-full h-auto max-h-[300px] object-contain"
237
- controls
238
- />
235
+ <div className="w-full aspect-video max-h-[300px]">
236
+ <Video
237
+ kind="file"
238
+ url={highlightVideoUrl}
239
+ poster={highlightVideoThumbnail || undefined}
240
+ />
241
+ </div>
239
242
  {onDeleteHighlight && (
240
243
  <button
241
244
  type="button"
@@ -5,13 +5,14 @@ import { Modal, ModalHeader, ModalTitle, Button, Card } from '../ui';
5
5
  import {
6
6
  Upload,
7
7
  Image as ImageIcon,
8
- Video,
8
+ Video as VideoIcon,
9
9
  Trash2,
10
10
  Loader2,
11
11
  GripVertical,
12
12
  Plus
13
13
  } from 'lucide-react';
14
14
  import Image from '../../embed-shims/next-image';
15
+ import { Video } from './video';
15
16
 
16
17
  export interface MediaItem {
17
18
  id?: string | number; // Optional for new items
@@ -153,12 +154,9 @@ export function MediaGalleryManager({
153
154
  {/* Media Content */}
154
155
  <div className="aspect-video relative bg-ods-bg rounded-lg overflow-hidden">
155
156
  {mediaItem.media_type === 'video' || mediaItem.media_type === 'demo' ? (
156
- <video
157
- src={mediaItem.media_url}
158
- className="w-full h-full object-cover"
159
- controls
160
- preload="metadata"
161
- />
157
+ // <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
158
+ // fit="cover" crops to the aspect-video cell.
159
+ <Video kind="file" url={mediaItem.media_url} fit="cover" className="w-full h-full" />
162
160
  ) : (
163
161
  <Image
164
162
  src={mediaItem.media_url}
@@ -174,7 +172,7 @@ export function MediaGalleryManager({
174
172
  <div className="p-3">
175
173
  <div className="flex items-center gap-2 mb-1">
176
174
  {mediaItem.media_type === 'video' || mediaItem.media_type === 'demo' ? (
177
- <Video className="h-4 w-4 text-ods-text-secondary" />
175
+ <VideoIcon className="h-4 w-4 text-ods-text-secondary" />
178
176
  ) : (
179
177
  <ImageIcon className="h-4 w-4 text-ods-text-secondary" />
180
178
  )}
@@ -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}