sanity-plugin-mux-input 2.16.0 → 2.18.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.
@@ -0,0 +1,877 @@
1
+ import {CheckmarkCircleIcon, ErrorOutlineIcon} from '@sanity/icons'
2
+ import {Box, Button, Card, Flex, Grid, Stack, Text, TextInput} from '@sanity/ui'
3
+ import {useCallback, useEffect, useRef, useState} from 'react'
4
+ import {styled} from 'styled-components'
5
+
6
+ import {convertWatermarkToMuxOverlay} from '../util/convertWatermarkToMux'
7
+ import type {MuxOverlaySettings, WatermarkConfig} from '../util/types'
8
+
9
+ const RangeInput = styled.input`
10
+ width: 100%;
11
+ height: 4px;
12
+ border-radius: 2px;
13
+ background: var(--card-border-color);
14
+ outline: none;
15
+ -webkit-appearance: none;
16
+ appearance: none;
17
+
18
+ &::-webkit-slider-thumb {
19
+ -webkit-appearance: none;
20
+ appearance: none;
21
+ width: 16px;
22
+ height: 16px;
23
+ border-radius: 50%;
24
+ background: var(--card-focus-ring-color, #2276fc);
25
+ cursor: pointer;
26
+ border: 2px solid white;
27
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
28
+ }
29
+
30
+ &::-moz-range-thumb {
31
+ width: 16px;
32
+ height: 16px;
33
+ border-radius: 50%;
34
+ background: var(--card-focus-ring-color, #2276fc);
35
+ cursor: pointer;
36
+ border: 2px solid white;
37
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
38
+ }
39
+
40
+ &:hover::-webkit-slider-thumb {
41
+ background: var(--card-focus-ring-color, #1a5fc7);
42
+ }
43
+
44
+ &:hover::-moz-range-thumb {
45
+ background: var(--card-focus-ring-color, #1a5fc7);
46
+ }
47
+ `
48
+
49
+ const WatermarkOverlay = styled.div<{$opacity: number}>`
50
+ position: absolute;
51
+ max-width: 200px;
52
+ opacity: ${(props) => props.$opacity};
53
+ cursor: move;
54
+ user-select: none;
55
+ z-index: 10;
56
+ pointer-events: auto;
57
+
58
+ img {
59
+ width: 100%;
60
+ height: auto;
61
+ display: block;
62
+ pointer-events: none;
63
+ }
64
+
65
+ &:hover {
66
+ outline: 2px dashed rgba(255, 255, 255, 0.8);
67
+ outline-offset: 4px;
68
+ }
69
+ `
70
+
71
+ interface DraggableWatermarkProps {
72
+ watermark: WatermarkConfig
73
+ onChange: (watermark: WatermarkConfig) => void
74
+ containerRef?: React.RefObject<HTMLDivElement>
75
+ videoElementRef?: React.RefObject<HTMLVideoElement>
76
+ }
77
+
78
+ export default function DraggableWatermark({
79
+ watermark,
80
+ onChange,
81
+ containerRef,
82
+ videoElementRef,
83
+ }: DraggableWatermarkProps) {
84
+ const [isDragging, setIsDragging] = useState(false)
85
+ const [dragStart, setDragStart] = useState({x: 0, y: 0})
86
+ const [startPosition, setStartPosition] = useState({x: 0, y: 0})
87
+ const watermarkRef = useRef<HTMLDivElement>(null)
88
+ const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null)
89
+ const [localPosition, setLocalPosition] = useState(watermark.position || {x: 50, y: 50})
90
+
91
+ const position = localPosition
92
+ const size = watermark.size || 20
93
+ const opacity = watermark.opacity ?? 0.7
94
+
95
+ const parseOpacityPercent = (value: string | undefined): number | null => {
96
+ if (!value) return null
97
+ const trimmed = value.trim()
98
+ if (!trimmed.endsWith('%')) return null
99
+ const num = Number(trimmed.slice(0, -1))
100
+ if (!Number.isFinite(num)) return null
101
+ return Math.max(0, Math.min(1, num / 100))
102
+ }
103
+
104
+ const getVideoContentBox = useCallback(() => {
105
+ const container = containerRef?.current
106
+ if (!container) return {x: 0, y: 0, width: 0, height: 0}
107
+
108
+ const rect = container.getBoundingClientRect()
109
+ const containerW = rect.width
110
+ const containerH = rect.height
111
+
112
+ const videoEl = videoElementRef?.current
113
+ const videoW = videoEl?.videoWidth || 0
114
+ const videoH = videoEl?.videoHeight || 0
115
+
116
+ if (!videoW || !videoH || !containerW || !containerH) {
117
+ return {x: 0, y: 0, width: containerW, height: containerH}
118
+ }
119
+
120
+ // object-fit: contain sizing
121
+ const scale = Math.min(containerW / videoW, containerH / videoH)
122
+ const contentW = videoW * scale
123
+ const contentH = videoH * scale
124
+ const offsetX = (containerW - contentW) / 2
125
+ const offsetY = (containerH - contentH) / 2
126
+
127
+ return {x: offsetX, y: offsetY, width: contentW, height: contentH}
128
+ }, [containerRef, videoElementRef])
129
+
130
+ const parseOverlayValue = (value: string | undefined): {n: number; unit: '%' | 'px'} | null => {
131
+ if (!value) return null
132
+ const trimmed = value.trim()
133
+ const px = trimmed.endsWith('px')
134
+ const pct = trimmed.endsWith('%')
135
+ const num = Number(trimmed.replace(/px|%/g, ''))
136
+ if (!Number.isFinite(num)) return null
137
+ if (px) return {n: num, unit: 'px'}
138
+ if (pct) return {n: num, unit: '%'}
139
+ return null
140
+ }
141
+
142
+ const computeManualStyle = (overlay: MuxOverlaySettings) => {
143
+ const rect = containerRef?.current?.getBoundingClientRect()
144
+ const w = rect?.width ?? 0
145
+ const h = rect?.height ?? 0
146
+ const isVertical = h > w
147
+ const baseW = isVertical ? 1080 : 1920
148
+ const baseH = isVertical ? 1920 : 1080
149
+
150
+ const hm = parseOverlayValue(overlay.horizontal_margin)
151
+ const vm = parseOverlayValue(overlay.vertical_margin)
152
+ const ww = parseOverlayValue(overlay.width)
153
+ const manualOpacity = parseOpacityPercent(overlay.opacity)
154
+
155
+ const toCss = (v: {n: number; unit: '%' | 'px'} | null, axis: 'x' | 'y') => {
156
+ if (!v) return undefined
157
+ if (v.unit === '%') return `${v.n}%`
158
+ if (axis === 'x') return `${(v.n * w) / baseW}px`
159
+ return `${(v.n * h) / baseH}px`
160
+ }
161
+
162
+ const computeHorizontalStyle = () => {
163
+ if (overlay.horizontal_align === 'left') {
164
+ return {left: toCss(hm, 'x'), right: undefined, transform: 'translate(0, 0)'}
165
+ }
166
+ if (overlay.horizontal_align === 'right') {
167
+ return {right: toCss(hm, 'x'), left: undefined, transform: 'translate(0, 0)'}
168
+ }
169
+ return {left: '50%', right: undefined, transform: 'translate(-50%, 0)'}
170
+ }
171
+
172
+ const computeVerticalStyle = () => {
173
+ if (overlay.vertical_align === 'top') {
174
+ return {top: toCss(vm, 'y'), bottom: undefined}
175
+ }
176
+ if (overlay.vertical_align === 'bottom') {
177
+ return {bottom: toCss(vm, 'y'), top: undefined}
178
+ }
179
+ return {top: '50%', bottom: undefined}
180
+ }
181
+
182
+ const hStyle = computeHorizontalStyle()
183
+ const vStyle = computeVerticalStyle()
184
+
185
+ let transform = hStyle.transform
186
+ if (overlay.vertical_align === 'middle') {
187
+ transform =
188
+ overlay.horizontal_align === 'center' ? 'translate(-50%, -50%)' : 'translate(0, -50%)'
189
+ }
190
+
191
+ return {
192
+ position: 'absolute' as const,
193
+ ...hStyle,
194
+ ...vStyle,
195
+ transform,
196
+ width: ww ? toCss(ww, 'x') : `${size}%`,
197
+ opacity: manualOpacity ?? opacity,
198
+ cursor: 'default',
199
+ }
200
+ }
201
+
202
+ const debouncedOnChange = useCallback(
203
+ (newWatermark: WatermarkConfig) => {
204
+ if (debounceTimeoutRef.current) {
205
+ clearTimeout(debounceTimeoutRef.current)
206
+ }
207
+ debounceTimeoutRef.current = setTimeout(() => {
208
+ onChange(newWatermark)
209
+ }, 300)
210
+ },
211
+ [onChange]
212
+ )
213
+
214
+ useEffect(() => {
215
+ return () => {
216
+ if (debounceTimeoutRef.current) {
217
+ clearTimeout(debounceTimeoutRef.current)
218
+ }
219
+ }
220
+ }, [])
221
+
222
+ useEffect(() => {
223
+ if (!isDragging && watermark.position) {
224
+ setLocalPosition(watermark.position)
225
+ }
226
+ }, [watermark.position, isDragging])
227
+
228
+ const handleMouseDown = useCallback(
229
+ (e: React.MouseEvent) => {
230
+ e.preventDefault()
231
+ setIsDragging(true)
232
+ setDragStart({x: e.clientX, y: e.clientY})
233
+ setStartPosition({x: position.x, y: position.y})
234
+ },
235
+ [position]
236
+ )
237
+
238
+ const handleMouseMove = useCallback(
239
+ (e: MouseEvent) => {
240
+ if (!isDragging || !containerRef?.current) return
241
+
242
+ const container = containerRef.current
243
+ const rect = container.getBoundingClientRect()
244
+ const content = getVideoContentBox()
245
+ const contentW = content.width || rect.width
246
+ const contentH = content.height || rect.height
247
+ const dx = e.clientX - dragStart.x
248
+ const dy = e.clientY - dragStart.y
249
+
250
+ const deltaXPercent = (dx / contentW) * 100
251
+ const deltaYPercent = (dy / contentH) * 100
252
+
253
+ let newX = startPosition.x + deltaXPercent
254
+ let newY = startPosition.y + deltaYPercent
255
+
256
+ newX = Math.max(0, Math.min(100, newX))
257
+ newY = Math.max(0, Math.min(100, newY))
258
+
259
+ setLocalPosition({x: newX, y: newY})
260
+
261
+ debouncedOnChange({
262
+ ...watermark,
263
+ position: {x: newX, y: newY},
264
+ })
265
+ },
266
+ [
267
+ isDragging,
268
+ dragStart,
269
+ startPosition,
270
+ containerRef,
271
+ watermark,
272
+ debouncedOnChange,
273
+ getVideoContentBox,
274
+ ]
275
+ )
276
+
277
+ const handleMouseUp = useCallback(() => {
278
+ setIsDragging(false)
279
+ if (debounceTimeoutRef.current) {
280
+ clearTimeout(debounceTimeoutRef.current)
281
+ debounceTimeoutRef.current = null
282
+ }
283
+ onChange({
284
+ ...watermark,
285
+ position: localPosition,
286
+ })
287
+ }, [watermark, localPosition, onChange])
288
+
289
+ useEffect(() => {
290
+ if (isDragging) {
291
+ document.addEventListener('mousemove', handleMouseMove)
292
+ document.addEventListener('mouseup', handleMouseUp)
293
+ return () => {
294
+ document.removeEventListener('mousemove', handleMouseMove)
295
+ document.removeEventListener('mouseup', handleMouseUp)
296
+ }
297
+ }
298
+ return undefined
299
+ }, [isDragging, handleMouseMove, handleMouseUp])
300
+
301
+ if (!watermark.imageUrl) {
302
+ return null
303
+ }
304
+
305
+ const hasManualOverlay = Boolean(watermark.overlay_settings)
306
+ const opacityForRender = hasManualOverlay
307
+ ? (parseOpacityPercent(watermark.overlay_settings?.opacity) ?? opacity)
308
+ : opacity
309
+ const contentBox = getVideoContentBox()
310
+ const hasContentBox = contentBox.width > 0 && contentBox.height > 0
311
+
312
+ const computeWatermarkStyle = () => {
313
+ if (hasManualOverlay) {
314
+ return computeManualStyle(watermark.overlay_settings!)
315
+ }
316
+ if (hasContentBox) {
317
+ return {
318
+ left: `${contentBox.x + (position.x / 100) * contentBox.width}px`,
319
+ top: `${contentBox.y + (position.y / 100) * contentBox.height}px`,
320
+ transform: 'translate(-50%, -50%)',
321
+ width: `${Math.max(1, (size / 100) * contentBox.width)}px`,
322
+ cursor: isDragging ? 'grabbing' : 'grab',
323
+ }
324
+ }
325
+ return {
326
+ left: `${position.x}%`,
327
+ top: `${position.y}%`,
328
+ transform: 'translate(-50%, -50%)',
329
+ width: `${size}%`,
330
+ cursor: isDragging ? 'grabbing' : 'grab',
331
+ }
332
+ }
333
+
334
+ return (
335
+ <WatermarkOverlay
336
+ ref={watermarkRef}
337
+ $opacity={opacityForRender}
338
+ onMouseDown={hasManualOverlay ? undefined : handleMouseDown}
339
+ style={computeWatermarkStyle()}
340
+ >
341
+ <img src={watermark.imageUrl} alt="Watermark" draggable={false} />
342
+ </WatermarkOverlay>
343
+ )
344
+ }
345
+
346
+ interface WatermarkControlsProps {
347
+ watermark: WatermarkConfig
348
+ onChange: (watermark: WatermarkConfig) => void
349
+ onValidationChange?: (error: string | null) => void
350
+ previewContainerRef?: React.RefObject<HTMLDivElement | null>
351
+ previewVideoRef?: React.RefObject<HTMLVideoElement | null>
352
+ }
353
+
354
+ export function WatermarkControls({
355
+ watermark,
356
+ onChange,
357
+ onValidationChange,
358
+ previewContainerRef,
359
+ previewVideoRef,
360
+ }: WatermarkControlsProps) {
361
+ const [urlInput, setUrlInput] = useState(watermark.imageUrl || '')
362
+ const [urlError, setUrlError] = useState<string | null>(null)
363
+ const [isValidating, setIsValidating] = useState(false)
364
+ const [isValid, setIsValid] = useState<boolean | null>(null)
365
+ const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
366
+ const [mode, setMode] = useState<'canvas' | 'manual'>(
367
+ watermark.overlay_settings ? 'manual' : 'canvas'
368
+ )
369
+
370
+ const isUpdatingRef = useRef(false)
371
+
372
+ const isValidExtension = (extension: string) => {
373
+ return extension.endsWith('.png') || extension.endsWith('.jpg') || extension.endsWith('.jpeg')
374
+ }
375
+
376
+ const validateUrl = useCallback(
377
+ (url: string) => {
378
+ if (validationTimeoutRef.current) {
379
+ clearTimeout(validationTimeoutRef.current)
380
+ }
381
+
382
+ if (!url) {
383
+ setUrlError(null)
384
+ setIsValid(null)
385
+ setIsValidating(false)
386
+ onValidationChange?.(null)
387
+ isUpdatingRef.current = true
388
+ onChange({
389
+ ...watermark,
390
+ enabled: false,
391
+ imageUrl: undefined,
392
+ overlay_settings: undefined,
393
+ })
394
+ return
395
+ }
396
+
397
+ setIsValidating(true)
398
+ setIsValid(null)
399
+ setUrlError(null)
400
+
401
+ validationTimeoutRef.current = setTimeout(() => {
402
+ try {
403
+ const urlObj = new URL(url)
404
+ const pathname = urlObj.pathname.toLowerCase()
405
+ if (isValidExtension(pathname)) {
406
+ setIsValid(true)
407
+ setUrlError(null)
408
+ onValidationChange?.(null)
409
+ const img = new Image()
410
+ img.onload = () => {
411
+ const imageAspectRatio =
412
+ img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1
413
+ isUpdatingRef.current = true
414
+ onChange({
415
+ ...watermark,
416
+ enabled: true,
417
+ imageUrl: url,
418
+ imageAspectRatio,
419
+ })
420
+ }
421
+ img.onerror = () => {
422
+ isUpdatingRef.current = true
423
+ onChange({
424
+ ...watermark,
425
+ enabled: true,
426
+ imageUrl: url,
427
+ imageAspectRatio: watermark.imageAspectRatio,
428
+ })
429
+ }
430
+ img.src = url
431
+ } else {
432
+ const errorMsg =
433
+ 'Mux only supports PNG and JPG watermark images. Please use a .png or .jpg file.'
434
+ setIsValid(false)
435
+ setUrlError(errorMsg)
436
+ onValidationChange?.(errorMsg)
437
+ isUpdatingRef.current = true
438
+ onChange({
439
+ ...watermark,
440
+ enabled: false,
441
+ imageUrl: undefined,
442
+ imageAspectRatio: undefined,
443
+ overlay_settings: undefined,
444
+ })
445
+ }
446
+ } catch {
447
+ setIsValid(false)
448
+ const errorMsg = 'Please enter a valid URL (e.g., https://example.com/watermark.png)'
449
+ setUrlError(errorMsg)
450
+ onValidationChange?.(errorMsg)
451
+ isUpdatingRef.current = true
452
+ onChange({
453
+ ...watermark,
454
+ enabled: false,
455
+ imageUrl: undefined,
456
+ imageAspectRatio: undefined,
457
+ overlay_settings: undefined,
458
+ })
459
+ } finally {
460
+ setIsValidating(false)
461
+ }
462
+ }, 500)
463
+ },
464
+ [watermark, onChange, onValidationChange]
465
+ )
466
+
467
+ useEffect(() => {
468
+ return () => {
469
+ if (validationTimeoutRef.current) {
470
+ clearTimeout(validationTimeoutRef.current)
471
+ }
472
+ }
473
+ }, [])
474
+
475
+ useEffect(() => {
476
+ setMode(watermark.overlay_settings ? 'manual' : 'canvas')
477
+ }, [watermark.overlay_settings])
478
+
479
+ const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
480
+ const url = e.target.value
481
+ setUrlInput(url)
482
+
483
+ if (watermark.imageUrl && url !== watermark.imageUrl) {
484
+ isUpdatingRef.current = true
485
+ onChange({
486
+ ...watermark,
487
+ enabled: false,
488
+ imageUrl: undefined,
489
+ imageAspectRatio: undefined,
490
+ overlay_settings: undefined,
491
+ })
492
+ }
493
+
494
+ validateUrl(url)
495
+ }
496
+
497
+ const normalizeZeroPercent = (value: string | undefined) => {
498
+ if (!value) return value
499
+ const trimmed = value.trim()
500
+ if (!trimmed.endsWith('%')) return value
501
+ const n = Number(trimmed.slice(0, -1))
502
+ if (!Number.isFinite(n)) return value
503
+ const epsilon = 1e-9
504
+ if (n === 0 || Object.is(n, -0) || Math.abs(n) < epsilon) return '0.01%'
505
+ return `${n}%`
506
+ }
507
+
508
+ const updateOverlaySettings = (next: Partial<MuxOverlaySettings>) => {
509
+ const prev = watermark.overlay_settings
510
+ const base: MuxOverlaySettings = prev ?? {
511
+ vertical_align: 'bottom',
512
+ vertical_margin: '2%',
513
+ horizontal_align: 'right',
514
+ horizontal_margin: '2%',
515
+ width: `${watermark.size ?? 20}%`,
516
+ opacity: `${Math.round((watermark.opacity ?? 0.7) * 100)}%`,
517
+ }
518
+
519
+ const merged: MuxOverlaySettings = {
520
+ ...base,
521
+ ...next,
522
+ }
523
+
524
+ onChange({
525
+ ...watermark,
526
+ enabled: true,
527
+ overlay_settings: {
528
+ ...merged,
529
+ horizontal_margin:
530
+ normalizeZeroPercent(merged.horizontal_margin) || merged.horizontal_margin,
531
+ vertical_margin: normalizeZeroPercent(merged.vertical_margin) || merged.vertical_margin,
532
+ },
533
+ })
534
+ }
535
+
536
+ const getVideoContentBox = () => {
537
+ const container = previewContainerRef?.current
538
+ if (!container) return {x: 0, y: 0, width: 0, height: 0}
539
+
540
+ const rect = container.getBoundingClientRect()
541
+ const containerW = rect.width
542
+ const containerH = rect.height
543
+
544
+ const videoEl = previewVideoRef?.current
545
+ const videoW = videoEl?.videoWidth || 0
546
+ const videoH = videoEl?.videoHeight || 0
547
+
548
+ if (!videoW || !videoH || !containerW || !containerH) {
549
+ return {x: 0, y: 0, width: containerW, height: containerH}
550
+ }
551
+
552
+ const scale = Math.min(containerW / videoW, containerH / videoH)
553
+ const contentW = videoW * scale
554
+ const contentH = videoH * scale
555
+ const offsetX = (containerW - contentW) / 2
556
+ const offsetY = (containerH - contentH) / 2
557
+
558
+ return {x: offsetX, y: offsetY, width: contentW, height: contentH}
559
+ }
560
+
561
+ return (
562
+ <Stack space={3}>
563
+ <Stack space={2}>
564
+ <Text size={1} weight="medium">
565
+ Watermark Image URL
566
+ </Text>
567
+ <Text size={0} muted>
568
+ Enter a URL to a PNG or JPG image. Mux will download this image and overlay it on your
569
+ video.
570
+ </Text>
571
+ <Box style={{position: 'relative', width: '100%'}}>
572
+ <input
573
+ type="url"
574
+ value={urlInput}
575
+ onChange={handleUrlChange}
576
+ placeholder="https://example.com/watermark.png"
577
+ style={{
578
+ padding: '8px 12px',
579
+ paddingRight: (() => {
580
+ if (urlInput) return '96px'
581
+ if (isValid !== null) return '36px'
582
+ return '12px'
583
+ })(),
584
+ border: (() => {
585
+ if (urlError || isValid === false) return '1px solid #e74c3c'
586
+ if (isValid === true) return '1px solid #4caf50'
587
+ return '1px solid #ccc'
588
+ })(),
589
+ borderRadius: '4px',
590
+ width: '100%',
591
+ maxWidth: '100%',
592
+ boxSizing: 'border-box',
593
+ fontSize: '14px',
594
+ }}
595
+ />
596
+ {(urlInput || isValidating || isValid !== null) && (
597
+ <Box
598
+ style={{
599
+ position: 'absolute',
600
+ right: '8px',
601
+ top: '50%',
602
+ transform: 'translateY(-50%)',
603
+ display: 'flex',
604
+ alignItems: 'center',
605
+ gap: '4px',
606
+ }}
607
+ >
608
+ {urlInput && (
609
+ <Button
610
+ text="Clear"
611
+ mode="bleed"
612
+ tone="critical"
613
+ onClick={() => {
614
+ setUrlInput('')
615
+ validateUrl('')
616
+ }}
617
+ disabled={isValidating}
618
+ style={{fontSize: '11px', height: '24px'}}
619
+ />
620
+ )}
621
+ {isValidating && (
622
+ <Text size={0} muted>
623
+ Validating...
624
+ </Text>
625
+ )}
626
+ {isValid === true && !isValidating && (
627
+ <CheckmarkCircleIcon style={{color: '#4caf50', fontSize: '18px'}} />
628
+ )}
629
+ {isValid === false && !isValidating && (
630
+ <ErrorOutlineIcon style={{color: '#e74c3c', fontSize: '18px'}} />
631
+ )}
632
+ </Box>
633
+ )}
634
+ </Box>
635
+ {urlError && (
636
+ <Card padding={2} tone="critical" radius={2}>
637
+ <Flex align="center" gap={2}>
638
+ <ErrorOutlineIcon style={{color: '#e74c3c', flexShrink: 0}} />
639
+ <Text size={0} style={{color: '#e74c3c'}}>
640
+ {urlError}
641
+ </Text>
642
+ </Flex>
643
+ </Card>
644
+ )}
645
+ </Stack>
646
+
647
+ {watermark.imageUrl && (
648
+ <Stack space={2}>
649
+ <Card padding={3} tone="transparent" border radius={2}>
650
+ <Flex
651
+ align="center"
652
+ justify="space-between"
653
+ gap={3}
654
+ style={{flexWrap: 'wrap', alignItems: 'flex-start'}}
655
+ >
656
+ <Stack space={2} style={{minWidth: 240, flex: 1}}>
657
+ <Text size={1} weight="medium">
658
+ Positioning mode
659
+ </Text>
660
+ <Text size={0} muted>
661
+ Choose between dragging on the canvas or manually editing the Mux{' '}
662
+ <code>overlay_settings</code> fields (as in{' '}
663
+ <a
664
+ href="https://www.mux.com/docs/guides/add-watermarks-to-your-videos"
665
+ target="_blank"
666
+ rel="noopener noreferrer"
667
+ >
668
+ the docs
669
+ </a>
670
+ ).
671
+ </Text>
672
+ </Stack>
673
+ <Flex gap={2} style={{flexWrap: 'wrap'}}>
674
+ <Button
675
+ text="Canvas"
676
+ mode={mode === 'canvas' ? 'default' : 'ghost'}
677
+ onClick={() => {
678
+ setMode('canvas')
679
+ onChange({...watermark, enabled: true, overlay_settings: undefined})
680
+ }}
681
+ />
682
+ <Button
683
+ text="Manual"
684
+ mode={mode === 'manual' ? 'default' : 'ghost'}
685
+ onClick={() => {
686
+ setMode('manual')
687
+ const overlay = convertWatermarkToMuxOverlay({...watermark, enabled: true})
688
+ updateOverlaySettings(overlay ?? {})
689
+ }}
690
+ />
691
+ </Flex>
692
+ </Flex>
693
+ </Card>
694
+
695
+ {mode === 'manual' && (
696
+ <Card padding={3} tone="transparent" border radius={2}>
697
+ <Stack space={3}>
698
+ <Text size={1} weight="medium">
699
+ Mux overlay_settings
700
+ </Text>
701
+ <Grid columns={[1, 2]} gap={3} style={{width: '100%'}}>
702
+ <Stack space={2} style={{minWidth: 0}}>
703
+ <Text size={0} muted>
704
+ horizontal_align
705
+ </Text>
706
+ <select
707
+ value={watermark.overlay_settings?.horizontal_align || 'right'}
708
+ onChange={(e) =>
709
+ updateOverlaySettings({
710
+ horizontal_align: (e.target.value ||
711
+ 'right') as MuxOverlaySettings['horizontal_align'],
712
+ })
713
+ }
714
+ style={{
715
+ width: '100%',
716
+ padding: '8px 10px',
717
+ border: '1px solid #ccc',
718
+ borderRadius: 4,
719
+ }}
720
+ >
721
+ <option value="left">left</option>
722
+ <option value="center">center</option>
723
+ <option value="right">right</option>
724
+ </select>
725
+ </Stack>
726
+ <Stack space={2} style={{minWidth: 0}}>
727
+ <Text size={0} muted>
728
+ horizontal_margin (e.g. 2% or 40px)
729
+ </Text>
730
+ <TextInput
731
+ value={watermark.overlay_settings?.horizontal_margin || '2%'}
732
+ onChange={(e) =>
733
+ updateOverlaySettings({horizontal_margin: e.currentTarget.value})
734
+ }
735
+ />
736
+ </Stack>
737
+ <Stack space={2} style={{minWidth: 0}}>
738
+ <Text size={0} muted>
739
+ vertical_align
740
+ </Text>
741
+ <select
742
+ value={watermark.overlay_settings?.vertical_align || 'bottom'}
743
+ onChange={(e) =>
744
+ updateOverlaySettings({
745
+ vertical_align: (e.target.value ||
746
+ 'bottom') as MuxOverlaySettings['vertical_align'],
747
+ })
748
+ }
749
+ style={{
750
+ width: '100%',
751
+ padding: '8px 10px',
752
+ border: '1px solid #ccc',
753
+ borderRadius: 4,
754
+ }}
755
+ >
756
+ <option value="top">top</option>
757
+ <option value="middle">middle</option>
758
+ <option value="bottom">bottom</option>
759
+ </select>
760
+ </Stack>
761
+ <Stack space={2} style={{minWidth: 0}}>
762
+ <Text size={0} muted>
763
+ vertical_margin (e.g. 2% or 40px)
764
+ </Text>
765
+ <TextInput
766
+ value={watermark.overlay_settings?.vertical_margin || '2%'}
767
+ onChange={(e) =>
768
+ updateOverlaySettings({vertical_margin: e.currentTarget.value})
769
+ }
770
+ />
771
+ </Stack>
772
+ <Stack space={2} style={{minWidth: 0}}>
773
+ <Text size={0} muted>
774
+ width (e.g. 25% or 80px)
775
+ </Text>
776
+ <TextInput
777
+ value={watermark.overlay_settings?.width || `${watermark.size ?? 20}%`}
778
+ onChange={(e) => updateOverlaySettings({width: e.currentTarget.value})}
779
+ />
780
+ </Stack>
781
+ <Stack space={2} style={{minWidth: 0}}>
782
+ <Text size={0} muted>
783
+ opacity (e.g. 90%)
784
+ </Text>
785
+ <TextInput
786
+ value={
787
+ watermark.overlay_settings?.opacity ||
788
+ `${Math.round((watermark.opacity ?? 0.7) * 100)}%`
789
+ }
790
+ onChange={(e) => updateOverlaySettings({opacity: e.currentTarget.value})}
791
+ />
792
+ </Stack>
793
+ </Grid>
794
+ <Text size={0} muted>
795
+ Margins and width accept either percentages or pixels, per the Mux guide.
796
+ </Text>
797
+ </Stack>
798
+ </Card>
799
+ )}
800
+
801
+ {mode === 'canvas' && (
802
+ <>
803
+ <Box>
804
+ <Text size={1} weight="medium">
805
+ {(() => {
806
+ const sizePct = watermark.size || 20
807
+ const contentW = getVideoContentBox().width
808
+ if (!contentW) return `Size: ${sizePct}%`
809
+ const px = Math.max(1, Math.round((sizePct / 100) * contentW))
810
+ return `Size: ${px}px`
811
+ })()}
812
+ </Text>
813
+ <RangeInput
814
+ type="range"
815
+ value={(() => {
816
+ const sizePct = watermark.size || 20
817
+ const contentW = getVideoContentBox().width
818
+ if (!contentW) return sizePct
819
+ return Math.max(1, Math.round((sizePct / 100) * contentW))
820
+ })()}
821
+ min={(() => {
822
+ const contentW = getVideoContentBox().width
823
+ if (!contentW) return 5
824
+ return Math.max(1, Math.round(contentW * 0.05))
825
+ })()}
826
+ max={(() => {
827
+ const contentW = getVideoContentBox().width
828
+ if (!contentW) return 50
829
+ return Math.max(1, Math.round(contentW * 0.5))
830
+ })()}
831
+ step={1}
832
+ onChange={(e) => {
833
+ const raw = Number(e.target.value)
834
+ const contentW = getVideoContentBox().width
835
+ const nextPct = contentW ? (raw / contentW) * 100 : raw
836
+ const clampedPct = Math.max(5, Math.min(50, nextPct))
837
+ onChange({
838
+ ...watermark,
839
+ size: clampedPct,
840
+ })
841
+ }}
842
+ />
843
+ </Box>
844
+
845
+ <Box>
846
+ <Text size={1} weight="medium">
847
+ Opacity: {Math.round((watermark.opacity ?? 0.7) * 100)}%
848
+ </Text>
849
+ <RangeInput
850
+ type="range"
851
+ value={watermark.opacity ?? 0.7}
852
+ min={0}
853
+ max={1}
854
+ step={0.05}
855
+ onChange={(e) =>
856
+ onChange({
857
+ ...watermark,
858
+ opacity: Number(e.target.value),
859
+ })
860
+ }
861
+ />
862
+ </Box>
863
+ </>
864
+ )}
865
+
866
+ <Card padding={2} tone="transparent" border radius={2}>
867
+ <Text size={0} muted>
868
+ {mode === 'manual'
869
+ ? 'Manual mode: edit the overlay_settings fields above'
870
+ : '💡 Drag the watermark on the preview to position it'}
871
+ </Text>
872
+ </Card>
873
+ </Stack>
874
+ )}
875
+ </Stack>
876
+ )
877
+ }