sanity-plugin-mux-input 2.16.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(
@@ -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
  },
@@ -0,0 +1,160 @@
1
+ import {roundPxString} from './roundPxString'
2
+ import type {MuxOverlaySettings, WatermarkConfig} from './types'
3
+
4
+ /**
5
+ * Converts a draggable watermark position (x, y percentages) to Mux's overlay_settings format.
6
+ *
7
+ * @param watermark - The watermark configuration with position, size, and opacity
8
+ * @returns Mux overlay_settings object
9
+ * @see {@link https://www.mux.com/docs/guides/add-watermarks-to-your-videos}
10
+ */
11
+ export function convertWatermarkToMuxOverlay(
12
+ watermark: WatermarkConfig,
13
+ options?: {
14
+ /**
15
+ * Video aspect ratio (width / height). Needed for correct vertical positioning,
16
+ * especially on vertical videos.
17
+ */
18
+ videoAspectRatio?: number
19
+ /**
20
+ * Unit to emit for margins/width when generating overlay_settings from Canvas mode.
21
+ * - 'px' will generate pixel strings according to Mux's scaling rules:
22
+ * values are applied as if the video were scaled to 1920x1080 (horizontal)
23
+ * or 1080x1920 (vertical).
24
+ * - '%' preserves existing behavior.
25
+ */
26
+ units?: '%' | 'px'
27
+ }
28
+ ): MuxOverlaySettings | null {
29
+ if (!watermark.enabled || !watermark.imageUrl) {
30
+ return null
31
+ }
32
+
33
+ const size = watermark.size || 20
34
+ const opacity = watermark.opacity ?? 0.7
35
+
36
+ /**
37
+ * Convert a percentage to whole-pixel string, using Mux's base dimensions:
38
+ * - Horizontal video: 1920x1080
39
+ * - Vertical video: 1080x1920
40
+ */
41
+ const toPxString = (valuePercent: number, axis: 'x' | 'y') => {
42
+ const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
43
+ const isVertical = videoAspectRatio > 0 && videoAspectRatio < 1
44
+ const baseW = isVertical ? 1080 : 1920
45
+ const baseH = isVertical ? 1920 : 1080
46
+ const base = axis === 'x' ? baseW : baseH
47
+ const px = (valuePercent / 100) * base
48
+ let rounded = Math.round(px)
49
+ // Avoid sending 0px (and JS -0); keep sign for negative margins.
50
+ if (rounded === 0) rounded = px < 0 ? -1 : 1
51
+ return `${rounded}px`
52
+ }
53
+
54
+ const normalizeToPixels = (value: string | undefined, axis: 'x' | 'y'): string | undefined => {
55
+ if (!value) return value
56
+ const trimmed = value.trim()
57
+ if (trimmed.endsWith('px')) {
58
+ return roundPxString(trimmed)
59
+ }
60
+ if (trimmed.endsWith('%')) {
61
+ const n = Number(trimmed.slice(0, -1))
62
+ if (!Number.isFinite(n)) return value
63
+ return toPxString(n, axis)
64
+ }
65
+ return value
66
+ }
67
+
68
+ // If user provided explicit overlay settings, use them (Mux-documented format).
69
+ // When `options.units === 'px'`, we normalize both % and px to whole-pixel strings,
70
+ // honoring vertical vs horizontal video bases.
71
+ if (watermark.overlay_settings) {
72
+ const widthValue = watermark.overlay_settings.width
73
+ const widthNormalized =
74
+ options?.units === 'px' ? normalizeToPixels(widthValue, 'x') : widthValue
75
+ return {
76
+ ...watermark.overlay_settings,
77
+ horizontal_margin:
78
+ options?.units === 'px'
79
+ ? (normalizeToPixels(watermark.overlay_settings.horizontal_margin, 'x') ??
80
+ watermark.overlay_settings.horizontal_margin)
81
+ : watermark.overlay_settings.horizontal_margin,
82
+ vertical_margin:
83
+ options?.units === 'px'
84
+ ? (normalizeToPixels(watermark.overlay_settings.vertical_margin, 'y') ??
85
+ watermark.overlay_settings.vertical_margin)
86
+ : watermark.overlay_settings.vertical_margin,
87
+ width: widthNormalized ?? `${size}%`,
88
+ opacity: watermark.overlay_settings.opacity ?? `${Math.round(opacity * 100)}%`,
89
+ }
90
+ }
91
+
92
+ const position = watermark.position || {x: 50, y: 50}
93
+
94
+ /**
95
+ * Our UI stores watermark position as the *center point* in percentages.
96
+ * Mux margins are interpreted relative to an *edge* (based on align).
97
+ *
98
+ * To make "corner" placements match what the user dragged, we convert from
99
+ * center-position to top-left margins by subtracting half the watermark size.
100
+ *
101
+ * Note: `size` is a percentage of video width. Mux `width` is also expressed
102
+ * as a percentage of the video width, so we can reuse it for horizontal math.
103
+ * For vertical math, we approximate using the same percentage to keep behavior
104
+ * consistent with the current draggable UI (which also uses `size` in both axes
105
+ * for bounds).
106
+ */
107
+ // Allow negative margins to compensate for rounding / letterboxing edge-cases.
108
+ // We still clamp to a sane range so values don't explode.
109
+ const clampPercent = (value: number) => Math.max(-100, Math.min(100, value))
110
+
111
+ // Mux accepts percentage strings; avoid sending an exact "0%" by nudging to 0.01%.
112
+ // This also handles the JS -0 edge case and tiny floating point remnants.
113
+ const toPercentString = (value: number) => {
114
+ const epsilon = 1e-9
115
+ const isZeroish = value === 0 || Object.is(value, -0) || Math.abs(value) < epsilon
116
+ return `${isZeroish ? 0.01 : value}%`
117
+ }
118
+
119
+ const watermarkWidthPercentOfVideoWidth = size
120
+
121
+ /**
122
+ * Convert watermark height into % of video height.
123
+ * height% = (watermarkWidthPx / imageAspectRatio) / videoHeightPx
124
+ * = (size% * videoWidthPx / imageAspectRatio) / videoHeightPx
125
+ * = size% * (videoWidthPx/videoHeightPx) / imageAspectRatio
126
+ * = size% * videoAspectRatio / imageAspectRatio
127
+ */
128
+ const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
129
+ const imageAspectRatio = watermark.imageAspectRatio ?? 1
130
+ const watermarkHeightPercentOfVideoHeight = Math.max(
131
+ 0,
132
+ Math.min(100, (size * videoAspectRatio) / imageAspectRatio)
133
+ )
134
+
135
+ const halfWidth = watermarkWidthPercentOfVideoWidth / 2
136
+ const halfHeight = watermarkHeightPercentOfVideoHeight / 2
137
+
138
+ const leftMargin = clampPercent(
139
+ Math.min(position.x - halfWidth, 100 - watermarkWidthPercentOfVideoWidth)
140
+ )
141
+ const topMargin = clampPercent(
142
+ Math.min(position.y - halfHeight, 100 - watermarkHeightPercentOfVideoHeight)
143
+ )
144
+
145
+ const units = options?.units ?? '%'
146
+ const marginX = units === 'px' ? toPxString(leftMargin, 'x') : toPercentString(leftMargin)
147
+ const marginY = units === 'px' ? toPxString(topMargin, 'y') : toPercentString(topMargin)
148
+ const width = units === 'px' ? toPxString(size, 'x') : `${size}%`
149
+
150
+ const overlaySettings: MuxOverlaySettings = {
151
+ vertical_align: 'top',
152
+ vertical_margin: marginY,
153
+ horizontal_align: 'left',
154
+ horizontal_margin: marginX,
155
+ width,
156
+ opacity: `${Math.round(opacity * 100)}%`,
157
+ }
158
+
159
+ return overlaySettings
160
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Rounds the numeric part of a px string to the nearest integer.
3
+ * Returns undefined if the value is not a valid px string or not a finite number.
4
+ * Avoids sending 0px (and JS -0); snaps to ±1 instead.
5
+ */
6
+ export function roundPxString(value: unknown): string | undefined {
7
+ if (typeof value !== 'string') return undefined
8
+ const trimmed = value.trim()
9
+ if (!trimmed.endsWith('px')) return undefined
10
+ const n = Number(trimmed.slice(0, -2))
11
+ if (!Number.isFinite(n)) return undefined
12
+ let rounded = Math.round(n)
13
+ // Avoid sending 0px (and JS -0); keep sign when negative.
14
+ if (rounded === 0) rounded = n < 0 ? -1 : 1
15
+ return `${rounded}px`
16
+ }