sanity-plugin-mux-input 2.14.0 → 2.16.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 (55) hide show
  1. package/README.md +25 -24
  2. package/dist/index.d.mts +13 -1
  3. package/dist/index.d.ts +13 -1
  4. package/dist/index.js +1057 -470
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1059 -472
  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/secrets.ts +6 -1
  11. package/src/actions/upload.ts +1 -1
  12. package/src/components/ConfigureApi.tsx +51 -5
  13. package/src/components/EditCaptionDialog.tsx +2 -2
  14. package/src/components/InputBrowser.tsx +8 -2
  15. package/src/components/PageSelector.tsx +4 -7
  16. package/src/components/Player.styled.tsx +7 -2
  17. package/src/components/PlayerActionsMenu.tsx +15 -1
  18. package/src/components/ResyncMetadata.tsx +152 -73
  19. package/src/components/SelectAsset.tsx +9 -3
  20. package/src/components/StudioTool.tsx +2 -2
  21. package/src/components/TextTracksManager.tsx +11 -55
  22. package/src/components/UploadConfiguration.tsx +104 -343
  23. package/src/components/Uploader.tsx +18 -7
  24. package/src/components/VideoDetails/VideoDetails.tsx +55 -19
  25. package/src/components/VideoDetails/useVideoDetails.ts +15 -1
  26. package/src/components/VideoInBrowser.tsx +53 -6
  27. package/src/components/VideoPlayer.tsx +120 -47
  28. package/src/components/VideoThumbnail.tsx +84 -72
  29. package/src/components/VideosBrowser.tsx +7 -5
  30. package/src/components/uploadConfiguration/PlaybackPolicy.tsx +95 -6
  31. package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +26 -10
  32. package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +71 -0
  33. package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +179 -0
  34. package/src/context/DrmPlaybackWarningContext.tsx +93 -0
  35. package/src/hooks/useFetchFileSize.ts +54 -0
  36. package/src/hooks/useMediaMetadata.ts +100 -0
  37. package/src/hooks/useResyncAsset.ts +110 -0
  38. package/src/hooks/useResyncMuxMetadata.ts +33 -0
  39. package/src/hooks/useSaveSecrets.ts +10 -3
  40. package/src/hooks/useSecretsDocumentValues.ts +9 -1
  41. package/src/hooks/useSecretsFormState.ts +6 -3
  42. package/src/schema.ts +5 -0
  43. package/src/util/addKeysToMuxData.ts +30 -0
  44. package/src/util/asserters.ts +14 -0
  45. package/src/util/createUrlParamsObject.ts +7 -3
  46. package/src/util/generateJwt.ts +11 -2
  47. package/src/util/getPlaybackPolicy.ts +63 -4
  48. package/src/util/getStoryboardSrc.ts +7 -3
  49. package/src/util/getVideoMetadata.ts +1 -0
  50. package/src/util/getVideoSrc.ts +9 -9
  51. package/src/util/readSecrets.ts +3 -1
  52. package/src/util/textTracks.ts +6 -3
  53. package/src/util/tryWithSuspend.ts +22 -0
  54. package/src/util/types.ts +27 -2
  55. package/src/util/getPlaybackId.ts +0 -9
@@ -1,10 +1,12 @@
1
1
  import {DocumentVideoIcon, ErrorOutlineIcon, UploadIcon} from '@sanity/icons'
2
- import {Box, Button, Card, Checkbox, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
2
+ import {Box, Button, Card, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
3
3
  import {uuid} from '@sanity/uuid'
4
4
  import LanguagesList from 'iso-639-1'
5
- import {useEffect, useId, useMemo, useReducer, useRef, useState} from 'react'
5
+ import {useEffect, useId, useReducer, useRef, useState} from 'react'
6
6
  import {FormField} from 'sanity'
7
7
 
8
+ import {useFetchFileSize} from '../hooks/useFetchFileSize'
9
+ import {useMediaMetadata} from '../hooks/useMediaMetadata'
8
10
  import formatBytes from '../util/formatBytes'
9
11
  import {formatSeconds} from '../util/formatSeconds'
10
12
  import {
@@ -22,6 +24,11 @@ import {
22
24
  } from '../util/types'
23
25
  import TextTracksEditor, {type TrackAction} from './TextTracksEditor'
24
26
  import PlaybackPolicy from './uploadConfiguration/PlaybackPolicy'
27
+ import {
28
+ RESOLUTION_TIERS,
29
+ ResolutionTierSelector,
30
+ } from './uploadConfiguration/ResolutionTierSelector'
31
+ import {StaticRenditionSelector} from './uploadConfiguration/StaticRenditionSelector'
25
32
  import type {StagedUpload} from './Uploader'
26
33
 
27
34
  export type UploadConfigurationStateAction =
@@ -31,6 +38,7 @@ export type UploadConfigurationStateAction =
31
38
  | {action: 'normalize_audio'; value: UploadConfig['normalize_audio']}
32
39
  | {action: 'signed_policy'; value: UploadConfig['signed_policy']}
33
40
  | {action: 'public_policy'; value: UploadConfig['public_policy']}
41
+ | {action: 'drm_policy'; value: UploadConfig['drm_policy']}
34
42
  | TrackAction
35
43
 
36
44
  const VIDEO_QUALITY_LEVELS = [
@@ -39,23 +47,6 @@ const VIDEO_QUALITY_LEVELS = [
39
47
  {value: 'premium', label: 'Premium'},
40
48
  ] as const satisfies {value: UploadConfig['video_quality']; label: string}[]
41
49
 
42
- const RESOLUTION_TIERS = [
43
- {value: '1080p', label: '1080p'},
44
- {value: '1440p', label: '1440p (2k)'},
45
- {value: '2160p', label: '2160p (4k)'},
46
- ] as const satisfies {value: UploadConfig['max_resolution_tier']; label: string}[]
47
-
48
- const ADVANCED_RESOLUTIONS: {value: StaticRenditionResolution; label: string}[] = [
49
- {value: '270p', label: '270p'},
50
- {value: '360p', label: '360p'},
51
- {value: '480p', label: '480p'},
52
- {value: '540p', label: '540p'},
53
- {value: '720p', label: '720p'},
54
- {value: '1080p', label: '1080p'},
55
- {value: '1440p', label: '1440p'},
56
- {value: '2160p', label: '2160p'},
57
- ]
58
-
59
50
  /**
60
51
  * Sanitizes static renditions configuration to ensure 'highest' is not mixed with specific resolutions.
61
52
  * If both are present, only 'highest' (and 'audio-only' if present) will be kept.
@@ -119,6 +110,7 @@ export default function UploadConfiguration({
119
110
  text_tracks: prev.text_tracks?.filter(({type}) => type !== 'autogenerated'),
120
111
  public_policy: true,
121
112
  signed_policy: false,
113
+ drm_policy: false,
122
114
  })
123
115
  // If video quality level switches to plus, add back in default plus features
124
116
  }
@@ -136,6 +128,8 @@ export default function UploadConfiguration({
136
128
  return Object.assign({}, prev, {[action.action]: action.value})
137
129
  case 'public_policy':
138
130
  return Object.assign({}, prev, {[action.action]: action.value})
131
+ case 'drm_policy':
132
+ return Object.assign({}, prev, {[action.action]: action.value})
139
133
  // Updating individual tracks
140
134
  case 'track': {
141
135
  const text_tracks = [...prev.text_tracks]
@@ -174,88 +168,39 @@ export default function UploadConfiguration({
174
168
  static_renditions: sanitizeStaticRenditions(pluginConfig.static_renditions || []),
175
169
  signed_policy: secrets.enableSignedUrls && pluginConfig.defaultSigned,
176
170
  public_policy: pluginConfig.defaultPublic,
171
+ drm_policy: pluginConfig.defaultDrm && !!secrets.drmConfigId,
177
172
  normalize_audio: pluginConfig.normalize_audio,
178
173
  text_tracks: autoTextTracks,
179
174
  } as UploadConfig
180
175
  )
181
176
 
182
- // Determine if user is in advanced mode based on selected renditions
183
- const isAdvancedMode = useMemo(() => {
184
- const specificResolutions = config.static_renditions.filter(
185
- (r) => r !== 'highest' && r !== 'audio-only'
186
- )
187
- return specificResolutions.length > 0
188
- }, [config.static_renditions])
189
-
190
- const [renditionMode, setRenditionMode] = useState<'standard' | 'advanced'>(
191
- isAdvancedMode ? 'advanced' : 'standard'
192
- )
193
-
194
177
  // Video validations
195
- const [videoDuration, setVideoDuration] = useState<number | null>(null)
196
- const [urlFileSize, setUrlFileSize] = useState<number | null>(null)
197
- const [isLoadingDuration, setIsLoadingDuration] = useState(false)
198
- const [isLoadingFileSize, setIsLoadingFileSize] = useState(false)
199
178
  const [validationError, setValidationError] = useState<string | null>(null)
200
- const [canSkipFileSizeValidation, setCanSkipFileSizeValidation] = useState(false)
201
-
202
179
  const MAX_FILE_SIZE = pluginConfig.maxAssetFileSize
203
180
  const MAX_DURATION_SECONDS = pluginConfig.maxAssetDuration
204
181
 
205
- useEffect(() => {
206
- setVideoDuration(null)
207
- setUrlFileSize(null)
208
- setIsLoadingDuration(false)
209
- setIsLoadingFileSize(false)
210
- setValidationError(null)
211
- setCanSkipFileSizeValidation(false)
212
-
213
- let videoElement: HTMLVideoElement | null = null
214
- let currentVideoSrc: string | null = null
182
+ const {fileSize, isLoadingFileSize, canSkipFileSizeValidation} = useFetchFileSize(
183
+ stagedUpload,
184
+ MAX_FILE_SIZE
185
+ )
186
+ const {videoAssetMetadata, setVideoAssetMetadata, isLoadingMetadata} =
187
+ useMediaMetadata(stagedUpload)
215
188
 
216
- const cleanupVideo = (shouldRevokeUrl: boolean) => {
217
- if (videoElement) {
218
- videoElement.onloadedmetadata = null
219
- videoElement.onerror = null
220
- videoElement.src = ''
221
- videoElement.load()
222
- videoElement = null
223
- }
224
- if (shouldRevokeUrl && currentVideoSrc?.startsWith('blob:')) {
225
- URL.revokeObjectURL(currentVideoSrc)
226
- }
227
- currentVideoSrc = null
189
+ useEffect(() => {
190
+ if (fileSize) {
191
+ setVideoAssetMetadata((old) => ({...old, size: fileSize}))
228
192
  }
193
+ }, [fileSize, setVideoAssetMetadata])
229
194
 
230
- const validateDuration = (videoSrc: string, shouldRevokeUrl = false) => {
231
- if (!MAX_DURATION_SECONDS || MAX_DURATION_SECONDS <= 0) return
232
-
233
- setIsLoadingDuration(true)
234
- videoElement = document.createElement('video')
235
- videoElement.preload = 'metadata'
236
- currentVideoSrc = videoSrc
237
-
238
- videoElement.onloadedmetadata = () => {
239
- const duration = videoElement!.duration
240
- setVideoDuration(duration)
241
- setIsLoadingDuration(false)
242
-
243
- if (duration > MAX_DURATION_SECONDS) {
244
- setValidationError(
245
- `Video duration (${formatSeconds(duration)}) exceeds maximum allowed duration of ${formatSeconds(MAX_DURATION_SECONDS)}`
246
- )
247
- }
248
-
249
- cleanupVideo(shouldRevokeUrl)
250
- }
251
-
252
- videoElement.onerror = () => {
253
- setIsLoadingDuration(false)
254
- console.warn('Could not read video metadata for validation')
255
- cleanupVideo(shouldRevokeUrl)
195
+ useEffect(() => {
196
+ const validateDuration = (duration: number) => {
197
+ if (MAX_DURATION_SECONDS && duration > MAX_DURATION_SECONDS) {
198
+ setValidationError(
199
+ `Video duration (${formatSeconds(duration)}) exceeds maximum allowed duration of ${formatSeconds(MAX_DURATION_SECONDS)}`
200
+ )
201
+ return false
256
202
  }
257
-
258
- videoElement.src = videoSrc
203
+ return true
259
204
  }
260
205
 
261
206
  const validateFileSize = (size: number): boolean => {
@@ -269,95 +214,39 @@ export default function UploadConfiguration({
269
214
  return false
270
215
  }
271
216
 
272
- // Validate file uploads
273
- if (stagedUpload.type === 'file') {
274
- const file = stagedUpload.files[0]
275
- if (validateFileSize(file.size)) {
276
- validateDuration(URL.createObjectURL(file), true)
217
+ const validateDrmAvailability = (isAudioOnly: boolean) => {
218
+ if (config.drm_policy && isAudioOnly) {
219
+ setValidationError('Audio-only asset cannot be DRM protected')
220
+ return false
277
221
  }
222
+ return true
278
223
  }
279
224
 
280
- // Validate URL uploads
281
- if (stagedUpload.type === 'url') {
282
- const url = stagedUpload.url
283
-
284
- // Get file size from URL
285
- const fetchFileSize = async () => {
286
- setIsLoadingFileSize(true)
287
- try {
288
- const response = await fetch(url, {method: 'HEAD'})
289
- const contentLength = response.headers.get('content-length')
290
- const fileSize = contentLength ? parseInt(contentLength, 10) : null
291
-
292
- setIsLoadingFileSize(false)
293
- if (fileSize) {
294
- setUrlFileSize(fileSize)
295
- }
296
-
297
- // Validate file size if limit is configured and size is available
298
- const shouldValidateDuration =
299
- MAX_FILE_SIZE === undefined || fileSize === null || validateFileSize(fileSize)
300
-
301
- if (fileSize === null && MAX_FILE_SIZE !== undefined) {
302
- // Size unknown but size limit is configured - skip file size validation
303
- setCanSkipFileSizeValidation(true)
304
- }
305
-
306
- if (shouldValidateDuration) {
307
- validateDuration(url)
308
- }
309
- } catch {
310
- setIsLoadingFileSize(false)
311
- console.warn('Could not validate file size from URL')
312
- // Skip validation of file size, but still validate duration
313
- setCanSkipFileSizeValidation(true)
314
- validateDuration(url)
315
- }
316
- }
317
-
318
- fetchFileSize()
225
+ let valid = true
226
+ if (videoAssetMetadata?.size) {
227
+ valid = valid && (canSkipFileSizeValidation || validateFileSize(videoAssetMetadata.size))
319
228
  }
320
-
321
- return () => {
322
- cleanupVideo(true)
229
+ if (videoAssetMetadata?.duration) {
230
+ valid = valid && validateDuration(videoAssetMetadata.duration)
323
231
  }
324
- }, [stagedUpload, MAX_FILE_SIZE, MAX_DURATION_SECONDS])
325
-
326
- // Helper to toggle a rendition
327
- const toggleRendition = (rendition: StaticRenditionResolution) => {
328
- const current = config.static_renditions
329
- const hasRendition = current.includes(rendition)
330
-
331
- if (hasRendition) {
332
- dispatch({
333
- action: 'static_renditions',
334
- value: current.filter((r) => r !== rendition),
335
- })
336
- } else {
337
- dispatch({
338
- action: 'static_renditions',
339
- value: [...current, rendition],
340
- })
232
+ if (videoAssetMetadata?.isAudioOnly != undefined) {
233
+ valid = valid && validateDrmAvailability(videoAssetMetadata.isAudioOnly)
341
234
  }
342
- }
343
-
344
- // When switching modes, clear renditions that don't apply
345
- const handleModeChange = (mode: 'standard' | 'advanced') => {
346
- setRenditionMode(mode)
347
- if (mode === 'standard') {
348
- // Remove specific resolutions, keep only highest and audio-only
349
- dispatch({
350
- action: 'static_renditions',
351
- value: config.static_renditions.filter((r) => r === 'highest' || r === 'audio-only'),
352
- })
353
- } else {
354
- // Remove highest, keep specific resolutions and audio-only
355
- dispatch({
356
- action: 'static_renditions',
357
- value: config.static_renditions.filter((r) => r !== 'highest'),
358
- })
235
+ if (valid) {
236
+ setValidationError(null)
359
237
  }
360
- }
238
+ }, [
239
+ MAX_FILE_SIZE,
240
+ MAX_DURATION_SECONDS,
241
+ canSkipFileSizeValidation,
242
+ videoAssetMetadata?.duration,
243
+ videoAssetMetadata?.size,
244
+ videoAssetMetadata?.height,
245
+ videoAssetMetadata?.width,
246
+ videoAssetMetadata,
247
+ config.drm_policy,
248
+ validationError,
249
+ ])
361
250
 
362
251
  // If user-provided config is disabled, begin the upload immediately with
363
252
  // the developer-specified values from the schema or config or defaults.
@@ -365,12 +254,13 @@ export default function UploadConfiguration({
365
254
  const {disableTextTrackConfig, disableUploadConfig} = pluginConfig
366
255
  const skipConfig = disableTextTrackConfig && disableUploadConfig
367
256
  useEffect(() => {
368
- if (skipConfig) startUpload(formatUploadConfig(config))
257
+ if (skipConfig) startUpload(formatUploadConfig(config, secrets))
369
258
  // eslint-disable-next-line react-hooks/exhaustive-deps
370
259
  }, [])
371
260
  if (skipConfig) return null
372
261
 
373
262
  const basicConfig = config.video_quality !== 'plus' && config.video_quality !== 'premium'
263
+ const playbackPolicySelected = config.public_policy || config.signed_policy || config.drm_policy
374
264
  const maxSupportedResolution = RESOLUTION_TIERS.findIndex(
375
265
  (rt) => rt.value === pluginConfig.max_resolution_tier
376
266
  )
@@ -416,8 +306,8 @@ export default function UploadConfiguration({
416
306
  {stagedUpload.type === 'file'
417
307
  ? `Direct File Upload (${formatBytes(stagedUpload.files[0].size)})`
418
308
  : (() => {
419
- if (urlFileSize) {
420
- return `File From URL (${formatBytes(urlFileSize)})`
309
+ if (videoAssetMetadata?.size) {
310
+ return `File From URL (${formatBytes(videoAssetMetadata.size)})`
421
311
  }
422
312
  if (isLoadingFileSize) {
423
313
  return 'File From URL (Loading size...)'
@@ -427,14 +317,14 @@ export default function UploadConfiguration({
427
317
  </Text>
428
318
  {stagedUpload.type === 'file' && (
429
319
  <Stack space={1}>
430
- {isLoadingDuration && (
320
+ {isLoadingMetadata && (
431
321
  <Text as="p" size={1} muted>
432
322
  Reading video metadata...
433
323
  </Text>
434
324
  )}
435
- {videoDuration !== null && !validationError && (
325
+ {videoAssetMetadata?.duration && !validationError && (
436
326
  <Text as="p" size={1} muted>
437
- Duration: {formatSeconds(videoDuration)}
327
+ Duration: {formatSeconds(videoAssetMetadata.duration)}
438
328
  </Text>
439
329
  )}
440
330
  </Stack>
@@ -486,180 +376,38 @@ export default function UploadConfiguration({
486
376
  </Flex>
487
377
  </FormField>
488
378
 
489
- {!basicConfig && maxSupportedResolution > 0 && (
490
- <FormField
491
- title="Resolution Tier"
492
- description={
493
- <>
494
- The maximum{' '}
495
- <a
496
- href="https://docs.mux.com/api-reference#video/operation/create-direct-upload"
497
- target="_blank"
498
- rel="noopener noreferrer"
499
- >
500
- resolution_tier
501
- </a>{' '}
502
- your asset is encoded, stored, and streamed at.
503
- </>
504
- }
505
- >
506
- <Flex gap={3} wrap={'wrap'}>
507
- {RESOLUTION_TIERS.map(({value, label}, index) => {
508
- const inputId = `${id}--type-${value}`
509
-
510
- if (index > maxSupportedResolution) return null
511
-
512
- return (
513
- <Flex key={value} align="center" gap={2}>
514
- <Radio
515
- checked={config.max_resolution_tier === value}
516
- name="asset-resolutiontier"
517
- onChange={(e) =>
518
- dispatch({
519
- action: 'max_resolution_tier',
520
- value: e.currentTarget.value as UploadConfig['max_resolution_tier'],
521
- })
522
- }
523
- value={value}
524
- id={inputId}
525
- />
526
- <Text as="label" htmlFor={inputId}>
527
- {label}
528
- </Text>
529
- </Flex>
530
- )
531
- })}
532
- </Flex>
533
- </FormField>
534
- )}
535
-
536
379
  {!basicConfig && (
537
380
  <FormField title="Additional Configuration">
538
381
  <Stack space={3}>
539
382
  <PlaybackPolicy id={id} config={config} secrets={secrets} dispatch={dispatch} />
540
-
541
- <Stack space={3}>
542
- <FormField
543
- title="Static Renditions"
544
- description="Generate downloadable MP4 or M4A files. Note: Mux will not upscale to produce MP4 renditions - renditions that would cause upscaling are skipped."
545
- >
546
- <Stack space={3}>
547
- {/* Mode Selector */}
548
- <Flex gap={3}>
549
- <Flex align="center" gap={2}>
550
- <Radio
551
- checked={renditionMode === 'standard'}
552
- name="rendition-mode"
553
- onChange={() => handleModeChange('standard')}
554
- value="standard"
555
- id={`${id}--mode-standard`}
556
- />
557
- <Text as="label" htmlFor={`${id}--mode-standard`}>
558
- Standard
559
- </Text>
560
- </Flex>
561
- <Flex align="center" gap={2}>
562
- <Radio
563
- checked={renditionMode === 'advanced'}
564
- name="rendition-mode"
565
- onChange={() => handleModeChange('advanced')}
566
- value="advanced"
567
- id={`${id}--mode-advanced`}
568
- />
569
- <Text as="label" htmlFor={`${id}--mode-advanced`}>
570
- Advanced
571
- </Text>
572
- </Flex>
573
- </Flex>
574
-
575
- {/* Standard Mode Options */}
576
- {renditionMode === 'standard' && (
577
- <Stack space={2}>
578
- <Flex align="center" gap={2} padding={[0, 2]}>
579
- <Checkbox
580
- id={`${id}--highest`}
581
- style={{display: 'block'}}
582
- checked={config.static_renditions.includes('highest')}
583
- onChange={() => toggleRendition('highest')}
584
- />
585
- <Text as="label" htmlFor={`${id}--highest`}>
586
- Highest Resolution (up to 4K)
587
- </Text>
588
- </Flex>
589
- <Flex align="center" gap={2} padding={[0, 2]}>
590
- <Checkbox
591
- id={`${id}--audio-only-standard`}
592
- style={{display: 'block'}}
593
- checked={config.static_renditions.includes('audio-only')}
594
- onChange={() => toggleRendition('audio-only')}
595
- />
596
- <Text as="label" htmlFor={`${id}--audio-only-standard`}>
597
- Audio Only (M4A)
598
- </Text>
599
- </Flex>
600
- </Stack>
601
- )}
602
-
603
- {/* Advanced Mode Options */}
604
- {renditionMode === 'advanced' && (
605
- <Stack space={2}>
606
- <Label size={1} muted>
607
- Select specific resolutions:
608
- </Label>
609
- <Flex gap={2} wrap="wrap">
610
- {ADVANCED_RESOLUTIONS.map(({value, label}) => {
611
- const inputId = `${id}--resolution-${value}`
612
- return (
613
- <Flex key={value} align="center" gap={2}>
614
- <Checkbox
615
- id={inputId}
616
- style={{display: 'block'}}
617
- checked={config.static_renditions.includes(value)}
618
- onChange={() => toggleRendition(value)}
619
- />
620
- <Text as="label" htmlFor={inputId} size={1}>
621
- {label}
622
- </Text>
623
- </Flex>
624
- )
625
- })}
626
- </Flex>
627
- <Flex align="center" gap={2} padding={[2, 2, 0, 2]}>
628
- <Checkbox
629
- id={`${id}--audio-only-advanced`}
630
- style={{display: 'block'}}
631
- checked={config.static_renditions.includes('audio-only')}
632
- onChange={() => toggleRendition('audio-only')}
633
- />
634
- <Text as="label" htmlFor={`${id}--audio-only-advanced`}>
635
- Audio Only (M4A)
636
- </Text>
637
- </Flex>
638
- </Stack>
639
- )}
640
- </Stack>
641
- </FormField>
642
- </Stack>
383
+ {maxSupportedResolution > 0 && (
384
+ <ResolutionTierSelector
385
+ id={id}
386
+ config={config}
387
+ dispatch={dispatch}
388
+ maxSupportedResolution={maxSupportedResolution}
389
+ />
390
+ )}
391
+ <StaticRenditionSelector id={id} config={config} dispatch={dispatch} />
392
+ {!disableTextTrackConfig && (
393
+ <TextTracksEditor
394
+ tracks={config.text_tracks}
395
+ dispatch={dispatch}
396
+ defaultLang={pluginConfig.defaultAutogeneratedSubtitleLang}
397
+ />
398
+ )}
643
399
  </Stack>
644
400
  </FormField>
645
401
  )}
646
402
  </Stack>
647
403
  )}
648
404
 
649
- {!disableTextTrackConfig && !basicConfig && (
650
- <TextTracksEditor
651
- tracks={config.text_tracks}
652
- dispatch={dispatch}
653
- defaultLang={pluginConfig.defaultAutogeneratedSubtitleLang}
654
- />
655
- )}
656
-
657
405
  <Box marginTop={4}>
658
406
  <Button
659
407
  disabled={
660
- (!basicConfig && !config.public_policy && !config.signed_policy) ||
408
+ (!basicConfig && !playbackPolicySelected) ||
661
409
  validationError !== null ||
662
- isLoadingDuration ||
410
+ isLoadingMetadata ||
663
411
  (isLoadingFileSize && !canSkipFileSizeValidation)
664
412
  }
665
413
  icon={UploadIcon}
@@ -667,7 +415,7 @@ export default function UploadConfiguration({
667
415
  tone="positive"
668
416
  onClick={() => {
669
417
  if (!validationError) {
670
- startUpload(formatUploadConfig(config))
418
+ startUpload(formatUploadConfig(config, secrets))
671
419
  }
672
420
  }}
673
421
  />
@@ -677,18 +425,31 @@ export default function UploadConfiguration({
677
425
  )
678
426
  }
679
427
 
680
- function setPlaybackPolicy(config: UploadConfig): MuxNewAssetSettings['playback_policy'] {
681
- const playback_policy: MuxNewAssetSettings['playback_policy'] = []
428
+ function setAdvancedPlaybackPolicy(
429
+ config: UploadConfig,
430
+ secrets: Secrets
431
+ ): MuxNewAssetSettings['advanced_playback_policies'] {
432
+ const advanced_playback_policies: MuxNewAssetSettings['advanced_playback_policies'] = []
682
433
  if (config.public_policy) {
683
- playback_policy.push('public')
434
+ advanced_playback_policies.push({policy: 'public'})
684
435
  }
685
436
  if (config.signed_policy) {
686
- playback_policy.push('signed')
437
+ advanced_playback_policies.push({policy: 'signed'})
438
+ }
439
+ if (config.drm_policy) {
440
+ if (secrets.drmConfigId)
441
+ advanced_playback_policies.push({
442
+ policy: 'drm',
443
+ drm_configuration_id: secrets.drmConfigId ?? undefined,
444
+ })
445
+ else {
446
+ console.error('Selected DRM Policy but missing DRM Configuration Id')
447
+ }
687
448
  }
688
- return playback_policy
449
+ return advanced_playback_policies
689
450
  }
690
451
 
691
- function formatUploadConfig(config: UploadConfig): MuxNewAssetSettings {
452
+ function formatUploadConfig(config: UploadConfig, secrets: Secrets): MuxNewAssetSettings {
692
453
  const generated_subtitles = config.text_tracks
693
454
  .filter<AutogeneratedTextTrack>(isAutogeneratedTrack)
694
455
  .map<{name: string; language_code: SupportedMuxLanguage}>((track) => ({
@@ -723,7 +484,7 @@ function formatUploadConfig(config: UploadConfig): MuxNewAssetSettings {
723
484
  config.static_renditions.length > 0
724
485
  ? config.static_renditions.map((resolution) => ({resolution}))
725
486
  : undefined,
726
- playback_policy: setPlaybackPolicy(config),
487
+ advanced_playback_policies: setAdvancedPlaybackPolicy(config, secrets),
727
488
  max_resolution_tier: config.max_resolution_tier,
728
489
  video_quality: config.video_quality,
729
490
  normalize_audio: config.normalize_audio,
@@ -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
 
@@ -372,7 +382,7 @@ export default function Uploader(props: Props) {
372
382
 
373
383
  // Upload has errored
374
384
  if (state.error !== null) {
375
- const error = {state}
385
+ const error = state.error
376
386
  return (
377
387
  <Flex gap={3} direction="column" justify="center" align="center">
378
388
  <Text size={5} muted>
@@ -380,7 +390,7 @@ export default function Uploader(props: Props) {
380
390
  </Text>
381
391
  <Text>Something went wrong</Text>
382
392
  {error instanceof Error && error.message && (
383
- <Text size={1} muted>
393
+ <Text size={1} muted weight="semibold" style={{textAlign: 'center'}}>
384
394
  {error.message}
385
395
  </Text>
386
396
  )}
@@ -471,6 +481,7 @@ export default function Uploader(props: Props) {
471
481
  </UploadCard>
472
482
  {props.dialogState === 'select-video' && (
473
483
  <InputBrowser
484
+ config={props.config}
474
485
  asset={props.asset}
475
486
  onChange={props.onChange}
476
487
  setDialogState={props.setDialogState}