@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.
- package/dist/{chunk-7IC2QCOL.cjs → chunk-3RM5Z2SH.cjs} +73 -73
- package/dist/{chunk-7IC2QCOL.cjs.map → chunk-3RM5Z2SH.cjs.map} +1 -1
- package/dist/{chunk-L276GOMD.cjs → chunk-5Z3YOBRE.cjs} +40 -40
- package/dist/{chunk-L276GOMD.cjs.map → chunk-5Z3YOBRE.cjs.map} +1 -1
- package/dist/{chunk-MS5CEBBQ.cjs → chunk-7YSVW2JG.cjs} +5 -5
- package/dist/{chunk-MS5CEBBQ.cjs.map → chunk-7YSVW2JG.cjs.map} +1 -1
- package/dist/{chunk-YLMD4UFA.js → chunk-AEPR6AXO.js} +3 -3
- package/dist/{chunk-SAV4U37G.js → chunk-AMS55EES.js} +2 -2
- package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
- package/dist/chunk-AN7MCJTV.cjs.map +1 -0
- package/dist/{chunk-5YQSRQHP.js → chunk-ANJ56NCM.js} +6 -6
- package/dist/{chunk-MR7BMJHI.js → chunk-CAEQ5BQO.js} +6 -6
- package/dist/{chunk-ZKYEMGMD.cjs → chunk-CCSCZSXS.cjs} +29 -29
- package/dist/{chunk-ZKYEMGMD.cjs.map → chunk-CCSCZSXS.cjs.map} +1 -1
- package/dist/{chunk-6XZNIPXP.cjs → chunk-HKMQ3LHY.cjs} +18 -18
- package/dist/{chunk-6XZNIPXP.cjs.map → chunk-HKMQ3LHY.cjs.map} +1 -1
- package/dist/{chunk-N3Y6JYH5.cjs → chunk-JM5VSYQB.cjs} +15 -15
- package/dist/{chunk-N3Y6JYH5.cjs.map → chunk-JM5VSYQB.cjs.map} +1 -1
- package/dist/{chunk-GTHA2SYB.js → chunk-KP2E22VP.js} +5 -5
- package/dist/{chunk-BAUWT7PV.cjs → chunk-M2U5KN4Q.cjs} +1057 -952
- package/dist/chunk-M2U5KN4Q.cjs.map +1 -0
- package/dist/{chunk-5WX2V6CY.cjs → chunk-NQTASSKI.cjs} +12 -12
- package/dist/{chunk-5WX2V6CY.cjs.map → chunk-NQTASSKI.cjs.map} +1 -1
- package/dist/{chunk-SA7AHDS6.cjs → chunk-P62PIQSY.cjs} +7 -7
- package/dist/{chunk-SA7AHDS6.cjs.map → chunk-P62PIQSY.cjs.map} +1 -1
- package/dist/{chunk-XB6D7B3I.js → chunk-PFRUS5M4.js} +3 -3
- package/dist/{chunk-BNYI2FVM.js → chunk-PHH7M3CF.js} +4 -4
- package/dist/{chunk-K6JIBV3X.js → chunk-PJ27XJRQ.js} +2 -2
- package/dist/{chunk-MAB4TQCO.js → chunk-QJN4XSP4.js} +414 -309
- package/dist/chunk-QJN4XSP4.js.map +1 -0
- package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
- package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
- package/dist/{chunk-N4CCB33C.js → chunk-T245XRIS.js} +3 -3
- package/dist/{chunk-3AFV2YIC.cjs → chunk-TGEAT3G4.cjs} +40 -40
- package/dist/{chunk-3AFV2YIC.cjs.map → chunk-TGEAT3G4.cjs.map} +1 -1
- package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
- package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
- package/dist/{chunk-SCKAD2MY.js → chunk-UAOR2SRY.js} +5 -5
- package/dist/{chunk-NQLY27GH.cjs → chunk-X62WEO5Z.cjs} +73 -73
- package/dist/{chunk-NQLY27GH.cjs.map → chunk-X62WEO5Z.cjs.map} +1 -1
- package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
- package/dist/{chunk-DH3DUY6Q.cjs → chunk-ZGERD4OP.cjs} +32 -32
- package/dist/{chunk-DH3DUY6Q.cjs.map → chunk-ZGERD4OP.cjs.map} +1 -1
- package/dist/{chunk-I7IOEXRJ.js → chunk-ZRYCV52Q.js} +2 -2
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/chat/types/message.types.d.ts +13 -0
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +14 -4
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/highlight-video-section.d.ts.map +1 -1
- package/dist/components/features/index.cjs +5 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +4 -2
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/mux-origins.cjs +14 -0
- package/dist/components/features/mux-origins.cjs.map +1 -1
- package/dist/components/features/mux-origins.d.ts +26 -2
- package/dist/components/features/mux-origins.d.ts.map +1 -1
- package/dist/components/features/mux-origins.js +14 -1
- package/dist/components/features/mux-origins.js.map +1 -1
- package/dist/components/features/release-media-manager.d.ts.map +1 -1
- package/dist/components/features/use-video-warmup.d.ts +12 -0
- package/dist/components/features/use-video-warmup.d.ts.map +1 -1
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-source-selector.d.ts +1 -11
- package/dist/components/features/video-source-selector.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +14 -1
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +137 -135
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/use-near-viewport.d.ts +7 -0
- package/dist/hooks/use-near-viewport.d.ts.map +1 -1
- package/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/types/message.types.ts +13 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
- package/src/components/chat/utils/history-merge.ts +157 -14
- package/src/components/chat/utils/process-historical-messages.ts +45 -6
- package/src/components/features/cards-strip.tsx +2 -1
- package/src/components/features/highlight-video-section.tsx +10 -7
- package/src/components/features/media-gallery-manager.tsx +6 -8
- package/src/components/features/mux-origins.ts +40 -2
- package/src/components/features/release-media-manager.tsx +6 -8
- package/src/components/features/use-video-warmup.ts +19 -3
- package/src/components/features/video-bites-strip.tsx +24 -4
- package/src/components/features/video-source-selector.tsx +16 -56
- package/src/components/features/video.tsx +103 -8
- package/src/components/ui/rich-markdown-renderer.tsx +20 -0
- package/src/components/ui/simple-markdown-renderer.tsx +9 -5
- package/src/hooks/use-near-viewport.ts +9 -1
- package/dist/chunk-BAUWT7PV.cjs.map +0 -1
- package/dist/chunk-MAB4TQCO.js.map +0 -1
- package/dist/chunk-VIJARDBM.cjs.map +0 -1
- /package/dist/{chunk-YLMD4UFA.js.map → chunk-AEPR6AXO.js.map} +0 -0
- /package/dist/{chunk-SAV4U37G.js.map → chunk-AMS55EES.js.map} +0 -0
- /package/dist/{chunk-5YQSRQHP.js.map → chunk-ANJ56NCM.js.map} +0 -0
- /package/dist/{chunk-MR7BMJHI.js.map → chunk-CAEQ5BQO.js.map} +0 -0
- /package/dist/{chunk-GTHA2SYB.js.map → chunk-KP2E22VP.js.map} +0 -0
- /package/dist/{chunk-XB6D7B3I.js.map → chunk-PFRUS5M4.js.map} +0 -0
- /package/dist/{chunk-BNYI2FVM.js.map → chunk-PHH7M3CF.js.map} +0 -0
- /package/dist/{chunk-K6JIBV3X.js.map → chunk-PJ27XJRQ.js.map} +0 -0
- /package/dist/{chunk-N4CCB33C.js.map → chunk-T245XRIS.js.map} +0 -0
- /package/dist/{chunk-SCKAD2MY.js.map → chunk-UAOR2SRY.js.map} +0 -0
- /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
- /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:
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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 <
|
|
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
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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:
|
|
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={
|
|
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={
|
|
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
|
|
4
|
-
import { Upload,
|
|
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
|
-
<
|
|
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
|
|
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
|
|
289
|
-
* `VideoPreviewComponent` is supplied.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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,
|
|
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
|
-
{
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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}
|