sanity-plugin-mux-input 2.13.0 → 2.15.0

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 (56) hide show
  1. package/README.md +25 -24
  2. package/dist/index.d.mts +35 -2
  3. package/dist/index.d.ts +35 -2
  4. package/dist/index.js +2176 -461
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +2178 -463
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +1 -1
  9. package/src/_exports/index.ts +1 -0
  10. package/src/actions/assets.ts +75 -0
  11. package/src/actions/secrets.ts +6 -1
  12. package/src/actions/upload.ts +1 -1
  13. package/src/components/AddCaptionDialog.tsx +421 -0
  14. package/src/components/CaptionsDialog.tsx +23 -0
  15. package/src/components/ConfigureApi.tsx +51 -5
  16. package/src/components/EditCaptionDialog.tsx +508 -0
  17. package/src/components/InputBrowser.tsx +8 -2
  18. package/src/components/Onboard.tsx +2 -2
  19. package/src/components/PageSelector.tsx +54 -0
  20. package/src/components/Player.styled.tsx +7 -2
  21. package/src/components/PlayerActionsMenu.tsx +14 -6
  22. package/src/components/SelectAsset.tsx +9 -3
  23. package/src/components/StudioTool.tsx +2 -2
  24. package/src/components/TextTracksManager.tsx +781 -0
  25. package/src/components/UploadConfiguration.tsx +104 -343
  26. package/src/components/Uploader.styled.tsx +8 -15
  27. package/src/components/Uploader.tsx +25 -7
  28. package/src/components/VideoDetails/VideoDetails.tsx +43 -7
  29. package/src/components/VideoInBrowser.tsx +53 -6
  30. package/src/components/VideoPlayer.tsx +122 -47
  31. package/src/components/VideoThumbnail.tsx +84 -72
  32. package/src/components/VideosBrowser.tsx +15 -5
  33. package/src/components/uploadConfiguration/PlaybackPolicy.tsx +95 -6
  34. package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +26 -10
  35. package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +71 -0
  36. package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +179 -0
  37. package/src/context/DrmPlaybackWarningContext.tsx +93 -0
  38. package/src/hooks/useAccessControl.ts +1 -0
  39. package/src/hooks/useDialogState.ts +1 -1
  40. package/src/hooks/useFetchFileSize.ts +54 -0
  41. package/src/hooks/useMediaMetadata.ts +100 -0
  42. package/src/hooks/useSaveSecrets.ts +10 -3
  43. package/src/hooks/useSecretsDocumentValues.ts +9 -1
  44. package/src/hooks/useSecretsFormState.ts +6 -3
  45. package/src/util/asserters.ts +14 -0
  46. package/src/util/createUrlParamsObject.ts +7 -3
  47. package/src/util/generateJwt.ts +11 -2
  48. package/src/util/getPlaybackPolicy.ts +63 -4
  49. package/src/util/getStoryboardSrc.ts +7 -3
  50. package/src/util/getVideoMetadata.ts +4 -1
  51. package/src/util/getVideoSrc.ts +9 -9
  52. package/src/util/readSecrets.ts +3 -1
  53. package/src/util/textTracks.ts +222 -0
  54. package/src/util/tryWithSuspend.ts +22 -0
  55. package/src/util/types.ts +39 -6
  56. package/src/util/getPlaybackId.ts +0 -9
@@ -9,8 +9,9 @@ import {PatchEvent, set, setIfMissing} from 'sanity'
9
9
  import {uploadFile, uploadUrl} from '../actions/upload'
10
10
  import {DialogStateProvider} from '../context/DialogStateContext'
11
11
  import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
12
- import {isValidUrl} from '../util/asserters'
12
+ import {isServerError, isValidUrl} from '../util/asserters'
13
13
  import {extractDroppedFiles} from '../util/extractFiles'
14
+ import {hasPlaybackPolicy} from '../util/getPlaybackPolicy'
14
15
  import type {
15
16
  MuxInputProps,
16
17
  MuxNewAssetSettings,
@@ -66,7 +67,7 @@ type UploaderStateAction =
66
67
  | Extract<UploadUrlEvent, {type: 'url'}>
67
68
  ))
68
69
  | {action: 'progress'; percent: number}
69
- | {action: 'error'; error: Error}
70
+ | {action: 'error'; error: Error; settings: MuxNewAssetSettings}
70
71
  | {action: 'complete' | 'reset'}
71
72
 
72
73
  /**
@@ -125,12 +126,21 @@ export default function Uploader(props: Props) {
125
126
  uploadRef.current = null
126
127
  uploadingDocumentId.current = null
127
128
  return INITIAL_STATE
128
- case 'error':
129
+ case 'error': {
129
130
  // Clear upload observable on error
130
131
  uploadRef.current?.unsubscribe()
131
132
  uploadRef.current = null
132
133
  uploadingDocumentId.current = null
133
- return Object.assign({}, INITIAL_STATE, {error: action.error})
134
+
135
+ let error = action.error
136
+ if (isServerError(action.error) && hasPlaybackPolicy(action.settings, 'drm')) {
137
+ error = new Error(
138
+ 'Unknown Error while uploading DRM protected content. Make sure your DRM configuration ID is valid and set correctly'
139
+ )
140
+ }
141
+
142
+ return Object.assign({}, INITIAL_STATE, {error: error})
143
+ }
134
144
  default:
135
145
  return prev
136
146
  }
@@ -254,7 +264,7 @@ export default function Uploader(props: Props) {
254
264
  }
255
265
  },
256
266
  complete: () => dispatch({action: 'complete'}),
257
- error: (error) => dispatch({action: 'error', error}),
267
+ error: (error) => dispatch({action: 'error', error, settings}),
258
268
  })
259
269
  }
260
270
 
@@ -297,6 +307,13 @@ export default function Uploader(props: Props) {
297
307
 
298
308
  // Stages and validates an upload from pasting an asset URL
299
309
  const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
310
+ const target = event.target as HTMLElement
311
+
312
+ // Ignore paste coming from the VTT URL input
313
+ if (target.closest('#vtt-url')) {
314
+ return
315
+ }
316
+
300
317
  event.preventDefault()
301
318
  event.stopPropagation()
302
319
  const clipboardData =
@@ -365,7 +382,7 @@ export default function Uploader(props: Props) {
365
382
 
366
383
  // Upload has errored
367
384
  if (state.error !== null) {
368
- const error = {state}
385
+ const error = state.error
369
386
  return (
370
387
  <Flex gap={3} direction="column" justify="center" align="center">
371
388
  <Text size={5} muted>
@@ -373,7 +390,7 @@ export default function Uploader(props: Props) {
373
390
  </Text>
374
391
  <Text>Something went wrong</Text>
375
392
  {error instanceof Error && error.message && (
376
- <Text size={1} muted>
393
+ <Text size={1} muted weight="semibold" style={{textAlign: 'center'}}>
377
394
  {error.message}
378
395
  </Text>
379
396
  )}
@@ -464,6 +481,7 @@ export default function Uploader(props: Props) {
464
481
  </UploadCard>
465
482
  {props.dialogState === 'select-video' && (
466
483
  <InputBrowser
484
+ config={props.config}
467
485
  asset={props.asset}
468
486
  onChange={props.onChange}
469
487
  setDialogState={props.setDialogState}
@@ -27,10 +27,12 @@ import {
27
27
  import React, {useEffect, useState} from 'react'
28
28
 
29
29
  import {DIALOGS_Z_INDEX} from '../../util/constants'
30
+ import {MuxPlaybackId, MuxTextTrack, PlaybackPolicy} from '../../util/types'
30
31
  import FormField from '../FormField'
31
32
  import IconInfo from '../IconInfo'
32
33
  import {ResolutionIcon} from '../icons/Resolution'
33
34
  import {StopWatchIcon} from '../icons/StopWatch'
35
+ import TextTracksManager from '../TextTracksManager'
34
36
  import VideoPlayer from '../VideoPlayer'
35
37
  import DeleteDialog from './DeleteDialog'
36
38
  import useVideoDetails, {VideoDetailsProps} from './useVideoDetails'
@@ -203,6 +205,20 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
203
205
  >
204
206
  <Stack space={4} flex={1} sizing="border">
205
207
  <VideoPlayer asset={props.asset} autoPlay={props.asset.autoPlay || false} />
208
+ {tab === 'details' && (
209
+ <TextTracksManager
210
+ asset={props.asset}
211
+ iconOnly
212
+ collapseTracks
213
+ tracks={
214
+ displayInfo?.text_tracks ||
215
+ props.asset.data?.tracks?.filter(
216
+ (track): track is MuxTextTrack => track.type === 'text'
217
+ ) ||
218
+ []
219
+ }
220
+ />
221
+ )}
206
222
  </Stack>
207
223
  <Stack space={4} flex={1} sizing="border">
208
224
  <TabList space={2}>
@@ -279,13 +295,7 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
279
295
  size={2}
280
296
  />
281
297
  <IconInfo text={`Mux ID: \n${displayInfo.id}`} icon={TagIcon} size={2} />
282
- {displayInfo?.playbackId && (
283
- <IconInfo
284
- text={`Playback ID: ${displayInfo.playbackId}`}
285
- icon={TagIcon}
286
- size={2}
287
- />
288
- )}
298
+ <PlaybackIds playback_ids={displayInfo.playback_ids} />
289
299
  </Stack>
290
300
  </Stack>
291
301
  </TabPanel>
@@ -303,4 +313,30 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
303
313
  )
304
314
  }
305
315
 
316
+ const PlaybackIds = ({playback_ids}: {playback_ids?: MuxPlaybackId[]}) => {
317
+ if (playback_ids) {
318
+ return playback_ids.map((entry) => (
319
+ <IconInfo
320
+ key={entry.id}
321
+ text={`Playback ID [${policyToText(entry.policy)}]: ${entry.id}`}
322
+ icon={TagIcon}
323
+ size={2}
324
+ />
325
+ ))
326
+ }
327
+ return <IconInfo text={'No Playback ID'} icon={TagIcon} size={2} />
328
+ }
329
+
330
+ const policyToText = (policy: PlaybackPolicy) => {
331
+ switch (policy) {
332
+ case 'drm':
333
+ return 'DRM'
334
+ case 'signed':
335
+ return 'Signed'
336
+ case 'public':
337
+ return 'Public'
338
+ default:
339
+ return policy
340
+ }
341
+ }
306
342
  export default VideoDetails
@@ -3,6 +3,7 @@ import {Button, Card, Stack, Text, Tooltip} from '@sanity/ui'
3
3
  import React, {useState} from 'react'
4
4
  import {styled} from 'styled-components'
5
5
 
6
+ import {DRMWarningDialog, useDrmPlaybackWarningContext} from '../context/DrmPlaybackWarningContext'
6
7
  import {THUMBNAIL_ASPECT_RATIO} from '../util/constants'
7
8
  import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
8
9
  import {VideoAssetDocument} from '../util/types'
@@ -71,6 +72,8 @@ const PlayButton = styled.button`
71
72
  }
72
73
  `
73
74
 
75
+ type RenderState = 'render-video' | 'pre-render-warn' | false
76
+
74
77
  export default function VideoInBrowser({
75
78
  onSelect,
76
79
  onEdit,
@@ -80,16 +83,23 @@ export default function VideoInBrowser({
80
83
  onEdit?: (asset: VideoAssetDocument) => void
81
84
  asset: VideoAssetDocument
82
85
  }) {
83
- const [renderVideo, setRenderVideo] = useState(false)
86
+ const [renderVideo, setRenderVideo] = useState<RenderState>(false)
84
87
  const select = React.useCallback(() => onSelect?.(asset), [onSelect, asset])
85
88
  const edit = React.useCallback(() => onEdit?.(asset), [onEdit, asset])
89
+ const {hasShownWarning} = useDrmPlaybackWarningContext()
86
90
 
87
91
  if (!asset) {
88
92
  return null
89
93
  }
90
94
 
91
95
  const playbackPolicy = getPlaybackPolicy(asset)
92
-
96
+ const onClickPlay = () => {
97
+ if (playbackPolicy?.policy === 'drm' && !hasShownWarning) {
98
+ setRenderVideo('pre-render-warn')
99
+ } else {
100
+ setRenderVideo('render-video')
101
+ }
102
+ }
93
103
  return (
94
104
  <Card
95
105
  border
@@ -100,7 +110,7 @@ export default function VideoInBrowser({
100
110
  position: 'relative',
101
111
  }}
102
112
  >
103
- {playbackPolicy === 'signed' && (
113
+ {playbackPolicy?.policy === 'signed' && (
104
114
  <Tooltip
105
115
  animate
106
116
  content={
@@ -119,7 +129,7 @@ export default function VideoInBrowser({
119
129
  position: 'absolute',
120
130
  left: '1em',
121
131
  top: '1em',
122
- zIndex: 10,
132
+ zIndex: 11,
123
133
  }}
124
134
  padding={2}
125
135
  border
@@ -130,6 +140,36 @@ export default function VideoInBrowser({
130
140
  </Card>
131
141
  </Tooltip>
132
142
  )}
143
+ {playbackPolicy?.policy === 'drm' && (
144
+ <Tooltip
145
+ animate
146
+ content={
147
+ <Card padding={2} radius={2}>
148
+ <IconInfo icon={LockIcon} text="DRM playback policy" size={2} />
149
+ </Card>
150
+ }
151
+ placement="right"
152
+ fallbackPlacements={['top', 'bottom']}
153
+ portal
154
+ >
155
+ <Card
156
+ tone="caution"
157
+ style={{
158
+ borderRadius: '0.25rem',
159
+ position: 'absolute',
160
+ left: '1em',
161
+ top: '1em',
162
+ zIndex: 11,
163
+ }}
164
+ padding={2}
165
+ border
166
+ >
167
+ <Text muted size={1} weight="semibold" style={{color: 'var(--card-icon-color)'}}>
168
+ DRM
169
+ </Text>
170
+ </Card>
171
+ </Tooltip>
172
+ )}
133
173
  <Stack
134
174
  space={3}
135
175
  height="fill"
@@ -137,10 +177,17 @@ export default function VideoInBrowser({
137
177
  gridTemplateRows: 'min-content min-content 1fr',
138
178
  }}
139
179
  >
140
- {renderVideo ? (
180
+ {renderVideo === 'pre-render-warn' && (
181
+ <DRMWarningDialog
182
+ onClose={() => {
183
+ setRenderVideo('render-video')
184
+ }}
185
+ />
186
+ )}
187
+ {renderVideo === 'render-video' ? (
141
188
  <VideoPlayer asset={asset} autoPlay forceAspectRatio={THUMBNAIL_ASPECT_RATIO} />
142
189
  ) : (
143
- <PlayButton onClick={() => setRenderVideo(true)}>
190
+ <PlayButton onClick={onClickPlay}>
144
191
  <div data-play>
145
192
  <PlayIcon />
146
193
  </div>
@@ -2,14 +2,19 @@ import {type MuxPlayerProps, type MuxPlayerRefAttributes} from '@mux/mux-player-
2
2
  import MuxPlayer from '@mux/mux-player-react/lazy'
3
3
  import {ErrorOutlineIcon} from '@sanity/icons'
4
4
  import {Card, Text} from '@sanity/ui'
5
- import {type PropsWithChildren, useMemo, useRef} from 'react'
5
+ import {type PropsWithChildren, Suspense, useMemo, useRef, useState} from 'react'
6
6
 
7
7
  import {useDialogStateContext} from '../context/DialogStateContext'
8
8
  import {useClient} from '../hooks/useClient'
9
9
  import {AUDIO_ASPECT_RATIO, MIN_ASPECT_RATIO} from '../util/constants'
10
+ import {generateJwt} from '../util/generateJwt'
11
+ import {getPlaybackId} from '../util/getPlaybackPolicy'
12
+ import {getPlaybackPolicyById} from '../util/getPlaybackPolicy'
10
13
  import {getPosterSrc} from '../util/getPosterSrc'
11
14
  import {getVideoSrc} from '../util/getVideoSrc'
15
+ import {tryWithSuspend} from '../util/tryWithSuspend'
12
16
  import type {VideoAssetDocument} from '../util/types'
17
+ import CaptionsDialog from './CaptionsDialog'
13
18
  import EditThumbnailDialog from './EditThumbnailDialog'
14
19
  import {AudioIcon} from './icons/Audio'
15
20
 
@@ -32,32 +37,101 @@ export default function VideoPlayer({
32
37
 
33
38
  const isAudio = assetIsAudio(asset)
34
39
  const muxPlayer = useRef<MuxPlayerRefAttributes>(null)
40
+ const [error, setError] = useState<Error>()
35
41
 
36
- const {
37
- src: videoSrc,
38
- thumbnail: thumbnailSrc,
39
- error,
40
- } = useMemo(() => {
42
+ /* Playback ID that will be used to play the video */
43
+ const playbackId = useMemo(() => {
41
44
  try {
42
- const thumbnail = getPosterSrc({asset, client, width: thumbnailWidth})
43
- const src = asset?.playbackId && getVideoSrc({client, asset})
44
- if (src) return {src: src, thumbnail}
45
-
46
- return {error: new TypeError('Asset has no playback ID')}
47
- // eslint-disable-next-line @typescript-eslint/no-shadow
48
- } catch (error) {
49
- return {error}
45
+ return getPlaybackId(asset, ['public', 'signed', 'drm'])
46
+ } catch (e) {
47
+ setError(new TypeError('Asset has no playback ID'))
48
+ return undefined
50
49
  }
50
+ }, [asset])
51
+
52
+ const muxPlaybackId = useMemo(() => {
53
+ if (!playbackId) return undefined
54
+ return getPlaybackPolicyById(asset, playbackId)
55
+ }, [asset, playbackId])
56
+
57
+ const src = useMemo(() => {
58
+ if (!playbackId) return undefined
59
+ if (!muxPlaybackId) return undefined
60
+ return tryWithSuspend(
61
+ () => getVideoSrc({muxPlaybackId, client}),
62
+ (e: Error) => {
63
+ setError(e)
64
+ return undefined
65
+ }
66
+ )
67
+ }, [muxPlaybackId, playbackId, client])
68
+
69
+ const poster = useMemo(() => {
70
+ return tryWithSuspend(
71
+ () => getPosterSrc({asset, client, width: thumbnailWidth}),
72
+ (e: Error) => {
73
+ setError(e)
74
+ return undefined
75
+ }
76
+ )
51
77
  }, [asset, client, thumbnailWidth])
52
78
 
53
79
  const signedToken = useMemo(() => {
54
80
  try {
55
- const url = new URL(videoSrc!)
81
+ const url = new URL(src!)
56
82
  return url.searchParams.get('token')
57
83
  } catch {
58
- return false
84
+ return undefined
85
+ }
86
+ }, [src])
87
+ const drmToken = useMemo(() => {
88
+ if (!playbackId) return undefined
89
+ if (muxPlaybackId?.policy !== 'drm') return undefined
90
+
91
+ return tryWithSuspend(
92
+ () => generateJwt(client, playbackId, 'd'),
93
+ (e: Error) => {
94
+ setError(e)
95
+ return undefined
96
+ }
97
+ )
98
+ }, [client, muxPlaybackId?.policy, playbackId])
99
+ const tokens:
100
+ | Partial<{
101
+ playback?: string
102
+ thumbnail?: string
103
+ storyboard?: string
104
+ drm?: string
105
+ }>
106
+ | undefined = useMemo(() => {
107
+ try {
108
+ const partialTokens: {
109
+ playback?: string
110
+ thumbnail?: string
111
+ storyboard?: string
112
+ drm?: string
113
+ } = {
114
+ playback: undefined,
115
+ thumbnail: undefined,
116
+ storyboard: undefined,
117
+ drm: undefined,
118
+ }
119
+
120
+ if (signedToken) {
121
+ partialTokens.playback = signedToken
122
+ partialTokens.thumbnail = signedToken
123
+ partialTokens.storyboard = signedToken
124
+ }
125
+
126
+ if (drmToken) {
127
+ partialTokens.drm = drmToken
128
+ }
129
+
130
+ return {...partialTokens}
131
+ } catch {
132
+ return undefined
59
133
  }
60
- }, [videoSrc])
134
+ }, [signedToken, drmToken])
61
135
 
62
136
  const [width, height] = (asset?.data?.aspect_ratio ?? '16:9').split(':').map(Number)
63
137
  const targetAspectRatio =
@@ -70,6 +144,8 @@ export default function VideoPlayer({
70
144
  : AUDIO_ASPECT_RATIO
71
145
  }
72
146
 
147
+ /* We use Suspense here because `generateJwt` and related functions use suspend()
148
+ under the hood */
73
149
  return (
74
150
  <>
75
151
  <Card
@@ -80,7 +156,7 @@ export default function VideoPlayer({
80
156
  ...(isAudio && {display: 'flex', alignItems: 'flex-end'}),
81
157
  }}
82
158
  >
83
- {videoSrc && (
159
+ {src && poster && (
84
160
  <>
85
161
  {isAudio && (
86
162
  <AudioIcon
@@ -95,35 +171,33 @@ export default function VideoPlayer({
95
171
  }}
96
172
  />
97
173
  )}
98
- <MuxPlayer
99
- poster={isAudio ? undefined : thumbnailSrc}
100
- ref={muxPlayer}
101
- {...props}
102
- playsInline
103
- playbackId={asset.playbackId}
104
- tokens={
105
- signedToken
106
- ? {playback: signedToken, thumbnail: signedToken, storyboard: signedToken}
107
- : undefined
108
- }
109
- preload="metadata"
110
- crossOrigin="anonymous"
111
- metadata={{
112
- player_name: 'Sanity Admin Dashboard',
113
- player_version: process.env.PKG_VERSION,
114
- page_type: 'Preview Player',
115
- }}
116
- audio={isAudio}
117
- _hlsConfig={hlsConfig}
118
- style={{
119
- ...(!isAudio && {height: '100%'}),
120
- width: '100%',
121
- display: 'block',
122
- objectFit: 'contain',
123
- ...(isAudio && {alignSelf: 'end'}),
124
- }}
125
- />
126
- {children}
174
+ <Suspense fallback={null}>
175
+ <MuxPlayer
176
+ poster={isAudio ? undefined : poster}
177
+ ref={muxPlayer}
178
+ {...props}
179
+ playsInline
180
+ playbackId={playbackId}
181
+ tokens={tokens}
182
+ preload="metadata"
183
+ crossOrigin="anonymous"
184
+ metadata={{
185
+ player_name: 'Sanity Admin Dashboard',
186
+ player_version: process.env.PKG_VERSION,
187
+ page_type: 'Preview Player',
188
+ }}
189
+ audio={isAudio}
190
+ _hlsConfig={hlsConfig}
191
+ style={{
192
+ ...(!isAudio && {height: '100%'}),
193
+ width: '100%',
194
+ display: 'block',
195
+ objectFit: 'contain',
196
+ ...(isAudio && {alignSelf: 'end'}),
197
+ }}
198
+ />
199
+ {children}
200
+ </Suspense>
127
201
  </>
128
202
  )}
129
203
  {error ? (
@@ -149,6 +223,7 @@ export default function VideoPlayer({
149
223
  {dialogState === 'edit-thumbnail' && (
150
224
  <EditThumbnailDialog asset={asset} currentTime={muxPlayer?.current?.currentTime} />
151
225
  )}
226
+ {dialogState === 'edit-captions' && <CaptionsDialog asset={asset} />}
152
227
  </>
153
228
  )
154
229
  }