sanity-plugin-mux-input 2.15.0 → 2.17.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.
@@ -2,11 +2,12 @@ import {DocumentVideoIcon, ErrorOutlineIcon, UploadIcon} from '@sanity/icons'
2
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, useReducer, useRef, useState} from 'react'
5
+ import {memo, useEffect, useId, useReducer, useRef, useState} from 'react'
6
6
  import {FormField} from 'sanity'
7
7
 
8
8
  import {useFetchFileSize} from '../hooks/useFetchFileSize'
9
- import {useMediaMetadata} from '../hooks/useMediaMetadata'
9
+ import {useMediaMetadata, type VideoAssetMetadata} from '../hooks/useMediaMetadata'
10
+ import {convertWatermarkToMuxOverlay} from '../util/convertWatermarkToMux'
10
11
  import formatBytes from '../util/formatBytes'
11
12
  import {formatSeconds} from '../util/formatSeconds'
12
13
  import {
@@ -21,7 +22,9 @@ import {
21
22
  type SupportedMuxLanguage,
22
23
  type UploadConfig,
23
24
  type UploadTextTrack,
25
+ WatermarkConfig,
24
26
  } from '../util/types'
27
+ import DraggableWatermark, {WatermarkControls} from './DraggableWatermark'
25
28
  import TextTracksEditor, {type TrackAction} from './TextTracksEditor'
26
29
  import PlaybackPolicy from './uploadConfiguration/PlaybackPolicy'
27
30
  import {
@@ -39,6 +42,7 @@ export type UploadConfigurationStateAction =
39
42
  | {action: 'signed_policy'; value: UploadConfig['signed_policy']}
40
43
  | {action: 'public_policy'; value: UploadConfig['public_policy']}
41
44
  | {action: 'drm_policy'; value: UploadConfig['drm_policy']}
45
+ | {action: 'watermark'; value: WatermarkConfig}
42
46
  | TrackAction
43
47
 
44
48
  const VIDEO_QUALITY_LEVELS = [
@@ -80,10 +84,13 @@ export default function UploadConfiguration({
80
84
  stagedUpload: StagedUpload
81
85
  secrets: Secrets
82
86
  pluginConfig: PluginConfig
83
- startUpload: (settings: MuxNewAssetSettings) => void
87
+ startUpload: (settings: MuxNewAssetSettings, watermark: WatermarkConfig | undefined) => void
84
88
  onClose: () => void
85
89
  }) {
86
90
  const id = useId()
91
+ const [watermarkValidationError, setWatermarkValidationError] = useState<string | null>(null)
92
+ const watermarkPreviewContainerRef = useRef<HTMLDivElement>(null)
93
+ const watermarkPreviewVideoRef = useRef<HTMLVideoElement>(null)
87
94
  const autoTextTracks = useRef<NonNullable<UploadConfig['text_tracks']>>(
88
95
  pluginConfig.video_quality === 'plus' && pluginConfig.defaultAutogeneratedSubtitleLang
89
96
  ? [
@@ -130,6 +137,8 @@ export default function UploadConfiguration({
130
137
  return Object.assign({}, prev, {[action.action]: action.value})
131
138
  case 'drm_policy':
132
139
  return Object.assign({}, prev, {[action.action]: action.value})
140
+ case 'watermark':
141
+ return Object.assign({}, prev, {watermark: action.value})
133
142
  // Updating individual tracks
134
143
  case 'track': {
135
144
  const text_tracks = [...prev.text_tracks]
@@ -254,7 +263,12 @@ export default function UploadConfiguration({
254
263
  const {disableTextTrackConfig, disableUploadConfig} = pluginConfig
255
264
  const skipConfig = disableTextTrackConfig && disableUploadConfig
256
265
  useEffect(() => {
257
- if (skipConfig) startUpload(formatUploadConfig(config, secrets))
266
+ if (skipConfig) {
267
+ const {settings, watermark} = formatUploadConfig(config, secrets, {
268
+ videoAspectRatio: videoAssetMetadata?.aspectRatio,
269
+ })
270
+ startUpload(settings, watermark)
271
+ }
258
272
  // eslint-disable-next-line react-hooks/exhaustive-deps
259
273
  }, [])
260
274
  if (skipConfig) return null
@@ -275,7 +289,7 @@ export default function UploadConfiguration({
275
289
  onClose={onClose}
276
290
  >
277
291
  <Stack padding={4} space={2}>
278
- {validationError && (
292
+ {(validationError || watermarkValidationError) && (
279
293
  <Card padding={3} tone="critical" radius={2} marginBottom={2}>
280
294
  <Flex gap={2} align="flex-start">
281
295
  <ErrorOutlineIcon width={20} height={20} />
@@ -283,7 +297,7 @@ export default function UploadConfiguration({
283
297
  <Text size={1} weight="semibold">
284
298
  Validation Error
285
299
  </Text>
286
- <Text size={1}>{validationError}</Text>
300
+ <Text size={1}>{validationError || watermarkValidationError}</Text>
287
301
  </Stack>
288
302
  </Flex>
289
303
  </Card>
@@ -377,27 +391,38 @@ export default function UploadConfiguration({
377
391
  </FormField>
378
392
 
379
393
  {!basicConfig && (
380
- <FormField title="Additional Configuration">
381
- <Stack space={3}>
382
- <PlaybackPolicy id={id} config={config} secrets={secrets} dispatch={dispatch} />
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
- )}
399
- </Stack>
400
- </FormField>
394
+ <>
395
+ <FormField title="Additional Configuration">
396
+ <Stack space={3}>
397
+ <PlaybackPolicy id={id} config={config} secrets={secrets} dispatch={dispatch} />
398
+ {maxSupportedResolution > 0 && (
399
+ <ResolutionTierSelector
400
+ id={id}
401
+ config={config}
402
+ dispatch={dispatch}
403
+ maxSupportedResolution={maxSupportedResolution}
404
+ />
405
+ )}
406
+ <StaticRenditionSelector id={id} config={config} dispatch={dispatch} />
407
+ {!disableTextTrackConfig && (
408
+ <TextTracksEditor
409
+ tracks={config.text_tracks}
410
+ dispatch={dispatch}
411
+ defaultLang={pluginConfig.defaultAutogeneratedSubtitleLang}
412
+ />
413
+ )}
414
+ </Stack>
415
+ </FormField>
416
+ <WatermarkSection
417
+ config={config}
418
+ dispatch={dispatch}
419
+ stagedUpload={stagedUpload}
420
+ videoAssetMetadata={videoAssetMetadata}
421
+ watermarkPreviewContainerRef={watermarkPreviewContainerRef}
422
+ watermarkPreviewVideoRef={watermarkPreviewVideoRef}
423
+ onValidationChange={setWatermarkValidationError}
424
+ />
425
+ </>
401
426
  )}
402
427
  </Stack>
403
428
  )}
@@ -415,7 +440,10 @@ export default function UploadConfiguration({
415
440
  tone="positive"
416
441
  onClick={() => {
417
442
  if (!validationError) {
418
- startUpload(formatUploadConfig(config, secrets))
443
+ const {settings, watermark} = formatUploadConfig(config, secrets, {
444
+ videoAspectRatio: videoAssetMetadata?.aspectRatio,
445
+ })
446
+ startUpload(settings, watermark)
419
447
  }
420
448
  }}
421
449
  />
@@ -449,7 +477,14 @@ function setAdvancedPlaybackPolicy(
449
477
  return advanced_playback_policies
450
478
  }
451
479
 
452
- function formatUploadConfig(config: UploadConfig, secrets: Secrets): MuxNewAssetSettings {
480
+ function formatUploadConfig(
481
+ config: UploadConfig,
482
+ secrets: Secrets,
483
+ options?: {videoAspectRatio?: number | null}
484
+ ): {
485
+ settings: MuxNewAssetSettings
486
+ watermark?: WatermarkConfig
487
+ } {
453
488
  const generated_subtitles = config.text_tracks
454
489
  .filter<AutogeneratedTextTrack>(isAutogeneratedTrack)
455
490
  .map<{name: string; language_code: SupportedMuxLanguage}>((track) => ({
@@ -457,36 +492,201 @@ function formatUploadConfig(config: UploadConfig, secrets: Secrets): MuxNewAsset
457
492
  language_code: track.language_code,
458
493
  }))
459
494
 
460
- return {
461
- input: [
462
- {
463
- type: 'video',
464
- generated_subtitles: generated_subtitles.length > 0 ? generated_subtitles : undefined,
495
+ const inputs: NonNullable<MuxNewAssetSettings['input']> = [
496
+ {
497
+ type: 'video',
498
+ generated_subtitles: generated_subtitles.length > 0 ? generated_subtitles : undefined,
499
+ },
500
+ ...config.text_tracks.filter<CustomTextTrack>(isCustomTextTrack).reduce(
501
+ (acc, track) => {
502
+ if (track.language_code && track.file && track.name) {
503
+ acc.push({
504
+ url: track.file.contents,
505
+ type: 'text',
506
+ text_type: track.type === 'subtitles' ? 'subtitles' : undefined,
507
+ language_code: track.language_code,
508
+ name: track.name,
509
+ closed_captions: track.type === 'captions',
510
+ })
511
+ }
512
+ return acc
465
513
  },
466
- ...config.text_tracks.filter<CustomTextTrack>(isCustomTextTrack).reduce(
467
- (acc, track) => {
468
- if (track.language_code && track.file && track.name) {
469
- acc.push({
470
- url: track.file.contents,
471
- type: 'text',
472
- text_type: track.type === 'subtitles' ? 'subtitles' : undefined,
473
- language_code: track.language_code,
474
- name: track.name,
475
- closed_captions: track.type === 'captions',
476
- })
477
- }
478
- return acc
479
- },
480
- [] as NonNullable<MuxNewAssetSettings['input']>
481
- ),
482
- ],
483
- static_renditions:
484
- config.static_renditions.length > 0
485
- ? config.static_renditions.map((resolution) => ({resolution}))
486
- : undefined,
487
- advanced_playback_policies: setAdvancedPlaybackPolicy(config, secrets),
488
- max_resolution_tier: config.max_resolution_tier,
489
- video_quality: config.video_quality,
490
- normalize_audio: config.normalize_audio,
514
+ [] as NonNullable<MuxNewAssetSettings['input']>
515
+ ),
516
+ ]
517
+
518
+ if (config.watermark?.imageUrl) {
519
+ const watermarkForMux: WatermarkConfig = {...config.watermark, enabled: true}
520
+ const overlaySettings = convertWatermarkToMuxOverlay(watermarkForMux, {
521
+ videoAspectRatio: options?.videoAspectRatio ?? undefined,
522
+ units: 'px',
523
+ })
524
+ if (overlaySettings) {
525
+ inputs.push({
526
+ url: config.watermark.imageUrl,
527
+ overlay_settings: overlaySettings,
528
+ } as NonNullable<MuxNewAssetSettings['input']>[number])
529
+ }
491
530
  }
531
+
532
+ return {
533
+ settings: {
534
+ input: inputs,
535
+ static_renditions:
536
+ config.static_renditions.length > 0
537
+ ? config.static_renditions.map((resolution) => ({resolution}))
538
+ : undefined,
539
+ advanced_playback_policies: setAdvancedPlaybackPolicy(config, secrets),
540
+ max_resolution_tier: config.max_resolution_tier,
541
+ video_quality: config.video_quality,
542
+ normalize_audio: config.normalize_audio,
543
+ },
544
+ watermark: config.watermark?.imageUrl
545
+ ? ({...config.watermark, enabled: true} as WatermarkConfig)
546
+ : undefined,
547
+ }
548
+ }
549
+
550
+ function WatermarkSection({
551
+ config,
552
+ dispatch,
553
+ stagedUpload,
554
+ videoAssetMetadata,
555
+ watermarkPreviewContainerRef,
556
+ watermarkPreviewVideoRef,
557
+ onValidationChange,
558
+ }: {
559
+ config: UploadConfig
560
+ dispatch: (action: UploadConfigurationStateAction) => void
561
+ stagedUpload: StagedUpload
562
+ videoAssetMetadata: VideoAssetMetadata | null
563
+ watermarkPreviewContainerRef: React.RefObject<HTMLDivElement | null>
564
+ watermarkPreviewVideoRef: React.RefObject<HTMLVideoElement | null>
565
+ onValidationChange: (error: string | null) => void
566
+ }) {
567
+ if (videoAssetMetadata?.isAudioOnly !== false) return null
568
+ return (
569
+ <FormField
570
+ title="Watermark"
571
+ description={
572
+ <>
573
+ Add a watermark overlay to your video using Mux&apos;s native watermark support.{' '}
574
+ <a
575
+ href="https://www.mux.com/docs/guides/add-watermarks-to-your-videos"
576
+ target="_blank"
577
+ rel="noopener noreferrer"
578
+ >
579
+ Learn more about Mux watermarks.
580
+ </a>
581
+ </>
582
+ }
583
+ >
584
+ <Stack space={3}>
585
+ <WatermarkControls
586
+ watermark={config.watermark || {enabled: false}}
587
+ onChange={(watermark) => {
588
+ dispatch({action: 'watermark', value: watermark})
589
+ }}
590
+ onValidationChange={onValidationChange}
591
+ previewContainerRef={watermarkPreviewContainerRef}
592
+ previewVideoRef={watermarkPreviewVideoRef}
593
+ />
594
+ {config.watermark?.imageUrl &&
595
+ stagedUpload.type === 'file' &&
596
+ // Canvas preview is only shown in "Canvas" mode (no explicit overlay_settings)
597
+ !config.watermark.overlay_settings && (
598
+ <WatermarkPreview
599
+ stagedUpload={stagedUpload}
600
+ watermark={config.watermark}
601
+ videoAspectRatio={videoAssetMetadata.aspectRatio}
602
+ onWatermarkChange={(watermark) => {
603
+ dispatch({action: 'watermark', value: watermark})
604
+ }}
605
+ previewContainerRef={watermarkPreviewContainerRef}
606
+ videoRef={watermarkPreviewVideoRef}
607
+ />
608
+ )}
609
+ </Stack>
610
+ </FormField>
611
+ )
492
612
  }
613
+
614
+ // Memoized preview component to prevent unnecessary re-renders
615
+ const WatermarkPreview = memo(function WatermarkPreview({
616
+ stagedUpload,
617
+ watermark,
618
+ onWatermarkChange,
619
+ videoAspectRatio,
620
+ previewContainerRef,
621
+ videoRef,
622
+ }: {
623
+ stagedUpload: StagedUpload
624
+ watermark: WatermarkConfig
625
+ onWatermarkChange: (watermark: WatermarkConfig) => void
626
+ videoAspectRatio?: number | null
627
+ previewContainerRef: React.RefObject<HTMLDivElement | null>
628
+ videoRef: React.RefObject<HTMLVideoElement | null>
629
+ }) {
630
+ // Initialize video source only once
631
+ useEffect(() => {
632
+ if (videoRef.current && stagedUpload.type === 'file') {
633
+ const file = stagedUpload.files[0]
634
+ const url = URL.createObjectURL(file)
635
+ videoRef.current.src = url
636
+
637
+ return () => {
638
+ URL.revokeObjectURL(url)
639
+ }
640
+ }
641
+ return undefined
642
+ }, [stagedUpload, videoRef])
643
+
644
+ const isVertical =
645
+ videoAspectRatio !== null && videoAspectRatio !== undefined && videoAspectRatio < 1
646
+
647
+ return (
648
+ <Card
649
+ tone="transparent"
650
+ border
651
+ style={{
652
+ overflow: 'hidden',
653
+ // For vertical videos, center the preview and limit its width
654
+ display: 'flex',
655
+ justifyContent: 'center',
656
+ }}
657
+ >
658
+ {/* Inner container that exactly matches the video aspect ratio - no padding, no letterbox */}
659
+ <div
660
+ ref={previewContainerRef}
661
+ style={{
662
+ position: 'relative',
663
+ // For vertical videos: limit width so the preview doesn't get too tall
664
+ // For horizontal videos: use full width
665
+ width: isVertical ? 'auto' : '100%',
666
+ aspectRatio: videoAspectRatio ? String(videoAspectRatio) : '16/9',
667
+ ...(isVertical ? {height: '400px', maxHeight: '50vh'} : {minHeight: '200px'}),
668
+ overflow: 'hidden',
669
+ }}
670
+ >
671
+ <video
672
+ ref={videoRef}
673
+ style={{
674
+ position: 'absolute',
675
+ top: 0,
676
+ left: 0,
677
+ width: '100%',
678
+ height: '100%',
679
+ objectFit: 'fill',
680
+ display: 'block',
681
+ }}
682
+ />
683
+ <DraggableWatermark
684
+ watermark={watermark}
685
+ onChange={onWatermarkChange}
686
+ containerRef={previewContainerRef as React.RefObject<HTMLDivElement>}
687
+ videoElementRef={videoRef as React.RefObject<HTMLVideoElement>}
688
+ />
689
+ </div>
690
+ </Card>
691
+ )
692
+ })
@@ -197,9 +197,13 @@ export default function Uploader(props: Props) {
197
197
  * the Mux configuration for the direct asset upload.
198
198
  *
199
199
  * @param settings The Mux new_asset_settings object to send to Sanity
200
+ * @param watermark Optional watermark configuration
200
201
  * @returns
201
202
  */
202
- const startUpload = (settings: MuxNewAssetSettings) => {
203
+ const startUpload = (
204
+ settings: MuxNewAssetSettings,
205
+ watermark?: import('../util/types').WatermarkConfig
206
+ ) => {
203
207
  const {stagedUpload} = state
204
208
  if (!stagedUpload || uploadRef.current) return
205
209
  dispatch({action: 'commitUpload'})
@@ -211,6 +215,7 @@ export default function Uploader(props: Props) {
211
215
  client: props.client,
212
216
  url: stagedUpload.url,
213
217
  settings,
218
+ watermark,
214
219
  })
215
220
  break
216
221
  case 'file':
@@ -218,6 +223,7 @@ export default function Uploader(props: Props) {
218
223
  client: props.client,
219
224
  file: stagedUpload.files[0],
220
225
  settings,
226
+ watermark,
221
227
  }).pipe(
222
228
  takeUntil(
223
229
  cancelUploadButton.observable.pipe(
@@ -7,6 +7,7 @@ import {
7
7
  ErrorOutlineIcon,
8
8
  RevertIcon,
9
9
  SearchIcon,
10
+ SyncIcon,
10
11
  TagIcon,
11
12
  TrashIcon,
12
13
  } from '@sanity/icons'
@@ -71,6 +72,8 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
71
72
  handleClose,
72
73
  confirmClose,
73
74
  saveChanges,
75
+ handleResync,
76
+ isResyncing,
74
77
  } = useVideoDetails(props)
75
78
 
76
79
  const isSaving = state === 'saving'
@@ -97,16 +100,29 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
97
100
  footer={
98
101
  <Card padding={3}>
99
102
  <Flex justify="space-between" align="center">
100
- <Button
101
- icon={TrashIcon}
102
- fontSize={2}
103
- padding={3}
104
- mode="bleed"
105
- text="Delete"
106
- tone="critical"
107
- onClick={() => setState('deleting')}
108
- disabled={isSaving}
109
- />
103
+ <Flex gap={2}>
104
+ <Button
105
+ icon={TrashIcon}
106
+ fontSize={2}
107
+ padding={3}
108
+ mode="bleed"
109
+ text="Delete"
110
+ tone="critical"
111
+ onClick={() => setState('deleting')}
112
+ disabled={isSaving || isResyncing}
113
+ />
114
+ <Button
115
+ icon={SyncIcon}
116
+ fontSize={2}
117
+ padding={3}
118
+ mode="bleed"
119
+ text="Resync"
120
+ tone="primary"
121
+ onClick={handleResync}
122
+ disabled={isSaving || isResyncing}
123
+ iconRight={isResyncing && Spinner}
124
+ />
125
+ </Flex>
110
126
  {modified && (
111
127
  <Button
112
128
  icon={CheckmarkIcon}
@@ -117,7 +133,7 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
117
133
  tone="positive"
118
134
  onClick={saveChanges}
119
135
  iconRight={isSaving && Spinner}
120
- disabled={isSaving}
136
+ disabled={isSaving || isResyncing}
121
137
  />
122
138
  )}
123
139
  </Flex>
@@ -4,9 +4,12 @@ import {useDocumentStore} from 'sanity'
4
4
 
5
5
  import {useClient} from '../../hooks/useClient'
6
6
  import useDocReferences from '../../hooks/useDocReferences'
7
+ import {useResyncAsset} from '../../hooks/useResyncAsset'
7
8
  import getVideoMetadata from '../../util/getVideoMetadata'
8
9
  import {VideoAssetDocument} from '../../util/types'
9
10
 
11
+ type VideoDetailsState = 'idle' | 'saving' | 'deleting' | 'closing' | 'resyncing'
12
+
10
13
  export interface VideoDetailsProps {
11
14
  closeDialog: () => void
12
15
  asset: VideoAssetDocument & {autoPlay?: boolean}
@@ -27,7 +30,16 @@ export default function useVideoDetails(props: VideoDetailsProps) {
27
30
 
28
31
  const displayInfo = getVideoMetadata({...props.asset, filename})
29
32
 
30
- const [state, setState] = useState<'deleting' | 'closing' | 'idle' | 'saving'>('idle')
33
+ const [state, setState] = useState<VideoDetailsState>('idle')
34
+
35
+ const {resyncAsset, isResyncing} = useResyncAsset({showToast: true})
36
+
37
+ async function handleResync() {
38
+ if (state !== 'idle') return
39
+ setState('resyncing')
40
+ await resyncAsset(props.asset)
41
+ setState('idle')
42
+ }
31
43
 
32
44
  function handleClose() {
33
45
  if (state !== 'idle') return
@@ -85,5 +97,7 @@ export default function useVideoDetails(props: VideoDetailsProps) {
85
97
  handleClose,
86
98
  confirmClose,
87
99
  saveChanges,
100
+ handleResync,
101
+ isResyncing,
88
102
  }
89
103
  }
@@ -37,6 +37,7 @@ export default function VideoPlayer({
37
37
 
38
38
  const isAudio = assetIsAudio(asset)
39
39
  const muxPlayer = useRef<MuxPlayerRefAttributes>(null)
40
+ const playerContainerRef = useRef<HTMLDivElement>(null)
40
41
  const [error, setError] = useState<Error>()
41
42
 
42
43
  /* Playback ID that will be used to play the video */
@@ -149,6 +150,7 @@ export default function VideoPlayer({
149
150
  return (
150
151
  <>
151
152
  <Card
153
+ ref={playerContainerRef}
152
154
  tone="transparent"
153
155
  style={{
154
156
  aspectRatio: aspectRatio,
@@ -8,6 +8,7 @@ export interface VideoAssetMetadata {
8
8
  isAudioOnly?: boolean
9
9
  duration?: number
10
10
  size?: number
11
+ aspectRatio?: number
11
12
  }
12
13
 
13
14
  export function useMediaMetadata(stagedUpload: StagedUpload) {
@@ -48,6 +49,7 @@ export function useMediaMetadata(stagedUpload: StagedUpload) {
48
49
  const width = videoElement.videoWidth
49
50
  const height = videoElement.videoHeight
50
51
  const isAudioOnly = width <= 0 && height <= 0
52
+ const aspectRatio = width / height
51
53
  setVideoAssetMetadata((old) => {
52
54
  return {
53
55
  ...old,
@@ -55,6 +57,7 @@ export function useMediaMetadata(stagedUpload: StagedUpload) {
55
57
  width: width,
56
58
  height: height,
57
59
  isAudioOnly: isAudioOnly,
60
+ aspectRatio: aspectRatio,
58
61
  }
59
62
  })
60
63
  },