sanity-plugin-mux-input 3.0.5 → 4.0.1

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 (123) hide show
  1. package/dist/index.js +20 -92
  2. package/dist/index.js.map +1 -1
  3. package/package.json +5 -15
  4. package/dist/index.cjs +0 -5746
  5. package/dist/index.cjs.map +0 -1
  6. package/dist/index.d.cts +0 -288
  7. package/dist/index.d.cts.map +0 -1
  8. package/sanity.json +0 -8
  9. package/src/_exports/index.ts +0 -73
  10. package/src/actions/assets.ts +0 -152
  11. package/src/actions/secrets.ts +0 -110
  12. package/src/actions/upload.ts +0 -308
  13. package/src/clients/upChunkObservable.ts +0 -54
  14. package/src/components/AddCaptionDialog.tsx +0 -440
  15. package/src/components/CaptionsDialog.tsx +0 -23
  16. package/src/components/ConfigureApi.styled.tsx +0 -19
  17. package/src/components/ConfigureApi.tsx +0 -296
  18. package/src/components/DraggableWatermark.tsx +0 -885
  19. package/src/components/EditCaptionDialog.tsx +0 -511
  20. package/src/components/EditThumbnailDialog.tsx +0 -121
  21. package/src/components/ErrorBoundaryCard.tsx +0 -97
  22. package/src/components/FileInputButton.tsx +0 -54
  23. package/src/components/FileInputMenuItem.styled.tsx +0 -36
  24. package/src/components/FileInputMenuItem.tsx +0 -85
  25. package/src/components/FormField.tsx +0 -38
  26. package/src/components/IconInfo.tsx +0 -22
  27. package/src/components/ImportVideosFromMux.tsx +0 -339
  28. package/src/components/Input.styled.tsx +0 -22
  29. package/src/components/Input.tsx +0 -78
  30. package/src/components/InputBrowser.tsx +0 -41
  31. package/src/components/MuxLogo.tsx +0 -42
  32. package/src/components/Onboard.tsx +0 -65
  33. package/src/components/PageSelector.tsx +0 -54
  34. package/src/components/Player.styled.tsx +0 -11
  35. package/src/components/Player.tsx +0 -117
  36. package/src/components/PlayerActionsMenu.tsx +0 -191
  37. package/src/components/ResyncMetadata.tsx +0 -278
  38. package/src/components/SelectAsset.tsx +0 -39
  39. package/src/components/SelectSortOptions.tsx +0 -45
  40. package/src/components/SpinnerBox.tsx +0 -16
  41. package/src/components/StudioTool.tsx +0 -24
  42. package/src/components/TextTracksEditor.tsx +0 -117
  43. package/src/components/TextTracksManager.tsx +0 -738
  44. package/src/components/UploadConfiguration.tsx +0 -696
  45. package/src/components/UploadPlaceholder.tsx +0 -88
  46. package/src/components/UploadProgress.tsx +0 -80
  47. package/src/components/Uploader.styled.tsx +0 -65
  48. package/src/components/Uploader.tsx +0 -499
  49. package/src/components/VideoDetails/DeleteDialog.tsx +0 -148
  50. package/src/components/VideoDetails/VideoDetails.tsx +0 -358
  51. package/src/components/VideoDetails/VideoReferences.tsx +0 -63
  52. package/src/components/VideoDetails/useVideoDetails.ts +0 -103
  53. package/src/components/VideoInBrowser.tsx +0 -245
  54. package/src/components/VideoMetadata.tsx +0 -45
  55. package/src/components/VideoPlayer.tsx +0 -241
  56. package/src/components/VideoThumbnail.tsx +0 -139
  57. package/src/components/VideosBrowser.tsx +0 -100
  58. package/src/components/documentPreview/DocumentPreview.tsx +0 -84
  59. package/src/components/documentPreview/DraftStatus.tsx +0 -34
  60. package/src/components/documentPreview/MissingSchemaType.tsx +0 -32
  61. package/src/components/documentPreview/PaneItemPreview.tsx +0 -67
  62. package/src/components/documentPreview/PublishedStatus.tsx +0 -35
  63. package/src/components/documentPreview/TimeAgo.tsx +0 -12
  64. package/src/components/icons/Audio.tsx +0 -13
  65. package/src/components/icons/Resolution.tsx +0 -12
  66. package/src/components/icons/StopWatch.tsx +0 -20
  67. package/src/components/icons/ToolIcon.tsx +0 -19
  68. package/src/components/uploadConfiguration/PlaybackPolicy.tsx +0 -133
  69. package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +0 -76
  70. package/src/components/uploadConfiguration/PlaybackPolicyWarning.tsx +0 -29
  71. package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +0 -72
  72. package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +0 -180
  73. package/src/components/withFocusRing/helpers.ts +0 -24
  74. package/src/components/withFocusRing/index.ts +0 -1
  75. package/src/components/withFocusRing/withFocusRing.ts +0 -30
  76. package/src/context/DialogStateContext.tsx +0 -33
  77. package/src/context/DrmPlaybackWarningContext.tsx +0 -97
  78. package/src/hooks/useAccessControl.ts +0 -13
  79. package/src/hooks/useAssetDocumentValues.ts +0 -11
  80. package/src/hooks/useAssets.ts +0 -73
  81. package/src/hooks/useCancelUpload.ts +0 -22
  82. package/src/hooks/useClient.ts +0 -8
  83. package/src/hooks/useDialogState.ts +0 -11
  84. package/src/hooks/useDocReferences.ts +0 -21
  85. package/src/hooks/useFetchFileSize.ts +0 -55
  86. package/src/hooks/useImportMuxAssets.ts +0 -132
  87. package/src/hooks/useInView.ts +0 -41
  88. package/src/hooks/useMediaMetadata.ts +0 -104
  89. package/src/hooks/useMuxAssets.ts +0 -179
  90. package/src/hooks/useMuxPolling.ts +0 -52
  91. package/src/hooks/useResyncAsset.ts +0 -110
  92. package/src/hooks/useResyncMuxMetadata.ts +0 -169
  93. package/src/hooks/useSaveSecrets.ts +0 -78
  94. package/src/hooks/useSecretsDocumentValues.ts +0 -38
  95. package/src/hooks/useSecretsFormState.ts +0 -47
  96. package/src/plugin.tsx +0 -31
  97. package/src/sanity-ui.d.ts +0 -5
  98. package/src/schema.ts +0 -196
  99. package/src/util/addKeysToMuxData.ts +0 -30
  100. package/src/util/asserters.ts +0 -23
  101. package/src/util/assetTitlePlaceholder.ts +0 -31
  102. package/src/util/constants.ts +0 -15
  103. package/src/util/convertWatermarkToMux.ts +0 -160
  104. package/src/util/createSearchFilter.ts +0 -76
  105. package/src/util/createUrlParamsObject.ts +0 -29
  106. package/src/util/extractFiles.ts +0 -67
  107. package/src/util/formatBytes.ts +0 -31
  108. package/src/util/formatDriveShareLink.ts +0 -64
  109. package/src/util/formatSeconds.ts +0 -48
  110. package/src/util/generateJwt.ts +0 -57
  111. package/src/util/getAnimatedPosterSrc.ts +0 -26
  112. package/src/util/getPlaybackPolicy.ts +0 -69
  113. package/src/util/getPosterSrc.ts +0 -28
  114. package/src/util/getVideoMetadata.ts +0 -23
  115. package/src/util/getVideoSrc.ts +0 -23
  116. package/src/util/parsers.ts +0 -5
  117. package/src/util/pluginVersion.ts +0 -5
  118. package/src/util/readSecrets.ts +0 -38
  119. package/src/util/roundPxString.ts +0 -16
  120. package/src/util/textTracks.ts +0 -222
  121. package/src/util/tryWithSuspend.ts +0 -22
  122. package/src/util/types.ts +0 -566
  123. package/v2-incompatible.js +0 -11
@@ -1,885 +0,0 @@
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
- // oxlint-disable-next-line react/react-compiler
225
- setLocalPosition(watermark.position)
226
- }
227
- }, [watermark.position, isDragging])
228
-
229
- const handleMouseDown = useCallback(
230
- (e: React.MouseEvent) => {
231
- e.preventDefault()
232
- setIsDragging(true)
233
- setDragStart({x: e.clientX, y: e.clientY})
234
- setStartPosition({x: position.x, y: position.y})
235
- },
236
- [position],
237
- )
238
-
239
- const handleMouseMove = useCallback(
240
- (e: MouseEvent) => {
241
- if (!isDragging || !containerRef?.current) return
242
-
243
- const container = containerRef.current
244
- const rect = container.getBoundingClientRect()
245
- const content = getVideoContentBox()
246
- const contentW = content.width || rect.width
247
- const contentH = content.height || rect.height
248
- const dx = e.clientX - dragStart.x
249
- const dy = e.clientY - dragStart.y
250
-
251
- const deltaXPercent = (dx / contentW) * 100
252
- const deltaYPercent = (dy / contentH) * 100
253
-
254
- let newX = startPosition.x + deltaXPercent
255
- let newY = startPosition.y + deltaYPercent
256
-
257
- newX = Math.max(0, Math.min(100, newX))
258
- newY = Math.max(0, Math.min(100, newY))
259
-
260
- setLocalPosition({x: newX, y: newY})
261
-
262
- debouncedOnChange({
263
- ...watermark,
264
- position: {x: newX, y: newY},
265
- })
266
- },
267
- [
268
- isDragging,
269
- dragStart,
270
- startPosition,
271
- containerRef,
272
- watermark,
273
- debouncedOnChange,
274
- getVideoContentBox,
275
- ],
276
- )
277
-
278
- const handleMouseUp = useCallback(() => {
279
- setIsDragging(false)
280
- if (debounceTimeoutRef.current) {
281
- clearTimeout(debounceTimeoutRef.current)
282
- debounceTimeoutRef.current = null
283
- }
284
- onChange({
285
- ...watermark,
286
- position: localPosition,
287
- })
288
- }, [watermark, localPosition, onChange])
289
-
290
- useEffect(() => {
291
- if (isDragging) {
292
- document.addEventListener('mousemove', handleMouseMove)
293
- document.addEventListener('mouseup', handleMouseUp)
294
- return () => {
295
- document.removeEventListener('mousemove', handleMouseMove)
296
- document.removeEventListener('mouseup', handleMouseUp)
297
- }
298
- }
299
- return undefined
300
- }, [isDragging, handleMouseMove, handleMouseUp])
301
-
302
- if (!watermark.imageUrl) {
303
- return null
304
- }
305
-
306
- const hasManualOverlay = Boolean(watermark.overlay_settings)
307
- const opacityForRender = hasManualOverlay
308
- ? (parseOpacityPercent(watermark.overlay_settings?.opacity) ?? opacity)
309
- : opacity
310
- // oxlint-disable-next-line react/react-compiler
311
- const contentBox = getVideoContentBox()
312
- const hasContentBox = contentBox.width > 0 && contentBox.height > 0
313
-
314
- const computeWatermarkStyle = () => {
315
- if (hasManualOverlay) {
316
- return computeManualStyle(watermark.overlay_settings!)
317
- }
318
- if (hasContentBox) {
319
- return {
320
- left: `${contentBox.x + (position.x / 100) * contentBox.width}px`,
321
- top: `${contentBox.y + (position.y / 100) * contentBox.height}px`,
322
- transform: 'translate(-50%, -50%)',
323
- width: `${Math.max(1, (size / 100) * contentBox.width)}px`,
324
- cursor: isDragging ? 'grabbing' : 'grab',
325
- }
326
- }
327
- return {
328
- left: `${position.x}%`,
329
- top: `${position.y}%`,
330
- transform: 'translate(-50%, -50%)',
331
- width: `${size}%`,
332
- cursor: isDragging ? 'grabbing' : 'grab',
333
- }
334
- }
335
-
336
- return (
337
- <WatermarkOverlay
338
- ref={watermarkRef}
339
- $opacity={opacityForRender}
340
- onMouseDown={hasManualOverlay ? undefined : handleMouseDown}
341
- // oxlint-disable-next-line react/react-compiler
342
- style={computeWatermarkStyle()}
343
- >
344
- <img src={watermark.imageUrl} alt="Watermark" draggable={false} />
345
- </WatermarkOverlay>
346
- )
347
- }
348
-
349
- interface WatermarkControlsProps {
350
- watermark: WatermarkConfig
351
- onChange: (watermark: WatermarkConfig) => void
352
- onValidationChange?: (error: string | null) => void
353
- previewContainerRef?: React.RefObject<HTMLDivElement | null>
354
- previewVideoRef?: React.RefObject<HTMLVideoElement | null>
355
- }
356
-
357
- export function WatermarkControls({
358
- watermark,
359
- onChange,
360
- onValidationChange,
361
- previewContainerRef,
362
- previewVideoRef,
363
- }: WatermarkControlsProps) {
364
- const [urlInput, setUrlInput] = useState(watermark.imageUrl || '')
365
- const [urlError, setUrlError] = useState<string | null>(null)
366
- const [isValidating, setIsValidating] = useState(false)
367
- const [isValid, setIsValid] = useState<boolean | null>(null)
368
- const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
369
- const [mode, setMode] = useState<'canvas' | 'manual'>(
370
- watermark.overlay_settings ? 'manual' : 'canvas',
371
- )
372
-
373
- const isUpdatingRef = useRef(false)
374
-
375
- const isValidExtension = useCallback((extension: string) => {
376
- return extension.endsWith('.png') || extension.endsWith('.jpg') || extension.endsWith('.jpeg')
377
- }, [])
378
-
379
- const validateUrl = useCallback(
380
- (url: string) => {
381
- if (validationTimeoutRef.current) {
382
- clearTimeout(validationTimeoutRef.current)
383
- }
384
-
385
- if (!url) {
386
- setUrlError(null)
387
- setIsValid(null)
388
- setIsValidating(false)
389
- onValidationChange?.(null)
390
- isUpdatingRef.current = true
391
- onChange({
392
- ...watermark,
393
- enabled: false,
394
- imageUrl: undefined,
395
- overlay_settings: undefined,
396
- })
397
- return
398
- }
399
-
400
- setIsValidating(true)
401
- setIsValid(null)
402
- setUrlError(null)
403
-
404
- validationTimeoutRef.current = setTimeout(() => {
405
- try {
406
- const urlObj = new URL(url)
407
- const pathname = urlObj.pathname.toLowerCase()
408
- if (isValidExtension(pathname)) {
409
- setIsValid(true)
410
- setUrlError(null)
411
- onValidationChange?.(null)
412
- const img = new Image()
413
- img.onload = () => {
414
- const imageAspectRatio =
415
- img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1
416
- isUpdatingRef.current = true
417
- onChange({
418
- ...watermark,
419
- enabled: true,
420
- imageUrl: url,
421
- imageAspectRatio,
422
- })
423
- }
424
- img.onerror = () => {
425
- isUpdatingRef.current = true
426
- onChange({
427
- ...watermark,
428
- enabled: true,
429
- imageUrl: url,
430
- imageAspectRatio: watermark.imageAspectRatio,
431
- })
432
- }
433
- img.src = url
434
- } else {
435
- const errorMsg =
436
- 'Mux only supports PNG and JPG watermark images. Please use a .png or .jpg file.'
437
- setIsValid(false)
438
- setUrlError(errorMsg)
439
- onValidationChange?.(errorMsg)
440
- isUpdatingRef.current = true
441
- onChange({
442
- ...watermark,
443
- enabled: false,
444
- imageUrl: undefined,
445
- imageAspectRatio: undefined,
446
- overlay_settings: undefined,
447
- })
448
- }
449
- } catch {
450
- setIsValid(false)
451
- const errorMsg = 'Please enter a valid URL (e.g., https://example.com/watermark.png)'
452
- setUrlError(errorMsg)
453
- onValidationChange?.(errorMsg)
454
- isUpdatingRef.current = true
455
- onChange({
456
- ...watermark,
457
- enabled: false,
458
- imageUrl: undefined,
459
- imageAspectRatio: undefined,
460
- overlay_settings: undefined,
461
- })
462
- } finally {
463
- setIsValidating(false)
464
- }
465
- }, 500)
466
- },
467
- [watermark, onChange, onValidationChange, isValidExtension],
468
- )
469
-
470
- useEffect(() => {
471
- return () => {
472
- if (validationTimeoutRef.current) {
473
- clearTimeout(validationTimeoutRef.current)
474
- }
475
- }
476
- }, [])
477
-
478
- useEffect(() => {
479
- // oxlint-disable-next-line react/react-compiler
480
- setMode(watermark.overlay_settings ? 'manual' : 'canvas')
481
- }, [watermark.overlay_settings])
482
-
483
- const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
484
- const url = e.target.value
485
- setUrlInput(url)
486
-
487
- if (watermark.imageUrl && url !== watermark.imageUrl) {
488
- isUpdatingRef.current = true
489
- onChange({
490
- ...watermark,
491
- enabled: false,
492
- imageUrl: undefined,
493
- imageAspectRatio: undefined,
494
- overlay_settings: undefined,
495
- })
496
- }
497
-
498
- validateUrl(url)
499
- }
500
-
501
- const normalizeZeroPercent = (value: string | undefined) => {
502
- if (!value) return value
503
- const trimmed = value.trim()
504
- if (!trimmed.endsWith('%')) return value
505
- const n = Number(trimmed.slice(0, -1))
506
- if (!Number.isFinite(n)) return value
507
- const epsilon = 1e-9
508
- if (n === 0 || Object.is(n, -0) || Math.abs(n) < epsilon) return '0.01%'
509
- return `${n}%`
510
- }
511
-
512
- const updateOverlaySettings = (next: Partial<MuxOverlaySettings>) => {
513
- const prev = watermark.overlay_settings
514
- const base: MuxOverlaySettings = prev ?? {
515
- vertical_align: 'bottom',
516
- vertical_margin: '2%',
517
- horizontal_align: 'right',
518
- horizontal_margin: '2%',
519
- width: `${watermark.size ?? 20}%`,
520
- opacity: `${Math.round((watermark.opacity ?? 0.7) * 100)}%`,
521
- }
522
-
523
- const merged: MuxOverlaySettings = {
524
- ...base,
525
- ...next,
526
- }
527
-
528
- onChange({
529
- ...watermark,
530
- enabled: true,
531
- overlay_settings: {
532
- ...merged,
533
- horizontal_margin:
534
- normalizeZeroPercent(merged.horizontal_margin) || merged.horizontal_margin,
535
- vertical_margin: normalizeZeroPercent(merged.vertical_margin) || merged.vertical_margin,
536
- },
537
- })
538
- }
539
-
540
- const getVideoContentBox = () => {
541
- const container = previewContainerRef?.current
542
- if (!container) return {x: 0, y: 0, width: 0, height: 0}
543
-
544
- const rect = container.getBoundingClientRect()
545
- const containerW = rect.width
546
- const containerH = rect.height
547
-
548
- const videoEl = previewVideoRef?.current
549
- const videoW = videoEl?.videoWidth || 0
550
- const videoH = videoEl?.videoHeight || 0
551
-
552
- if (!videoW || !videoH || !containerW || !containerH) {
553
- return {x: 0, y: 0, width: containerW, height: containerH}
554
- }
555
-
556
- const scale = Math.min(containerW / videoW, containerH / videoH)
557
- const contentW = videoW * scale
558
- const contentH = videoH * scale
559
- const offsetX = (containerW - contentW) / 2
560
- const offsetY = (containerH - contentH) / 2
561
-
562
- return {x: offsetX, y: offsetY, width: contentW, height: contentH}
563
- }
564
-
565
- return (
566
- <Stack space={3}>
567
- <Stack space={2}>
568
- <Text size={1} weight="medium">
569
- Watermark Image URL
570
- </Text>
571
- <Text size={0} muted>
572
- Enter a URL to a PNG or JPG image. Mux will download this image and overlay it on your
573
- video.
574
- </Text>
575
- <Box style={{position: 'relative', width: '100%'}}>
576
- <input
577
- type="url"
578
- value={urlInput}
579
- onChange={handleUrlChange}
580
- placeholder="https://example.com/watermark.png"
581
- style={{
582
- padding: '8px 12px',
583
- paddingRight: (() => {
584
- if (urlInput) return '96px'
585
- if (isValid !== null) return '36px'
586
- return '12px'
587
- })(),
588
- border: (() => {
589
- if (urlError || isValid === false) return '1px solid #e74c3c'
590
- if (isValid === true) return '1px solid #4caf50'
591
- return '1px solid #ccc'
592
- })(),
593
- borderRadius: '4px',
594
- width: '100%',
595
- maxWidth: '100%',
596
- boxSizing: 'border-box',
597
- fontSize: '14px',
598
- }}
599
- />
600
- {(urlInput || isValidating || isValid !== null) && (
601
- <Box
602
- style={{
603
- position: 'absolute',
604
- right: '8px',
605
- top: '50%',
606
- transform: 'translateY(-50%)',
607
- display: 'flex',
608
- alignItems: 'center',
609
- gap: '4px',
610
- }}
611
- >
612
- {urlInput && (
613
- <Button
614
- text="Clear"
615
- mode="bleed"
616
- tone="critical"
617
- onClick={() => {
618
- setUrlInput('')
619
- validateUrl('')
620
- }}
621
- disabled={isValidating}
622
- style={{fontSize: '11px', height: '24px'}}
623
- />
624
- )}
625
- {isValidating && (
626
- <Text size={0} muted>
627
- Validating...
628
- </Text>
629
- )}
630
- {isValid === true && !isValidating && (
631
- <CheckmarkCircleIcon style={{color: '#4caf50', fontSize: '18px'}} />
632
- )}
633
- {isValid === false && !isValidating && (
634
- <ErrorOutlineIcon style={{color: '#e74c3c', fontSize: '18px'}} />
635
- )}
636
- </Box>
637
- )}
638
- </Box>
639
- {urlError && (
640
- <Card padding={2} tone="critical" radius={2}>
641
- <Flex align="center" gap={2}>
642
- <ErrorOutlineIcon style={{color: '#e74c3c', flexShrink: 0}} />
643
- <Text size={0} style={{color: '#e74c3c'}}>
644
- {urlError}
645
- </Text>
646
- </Flex>
647
- </Card>
648
- )}
649
- </Stack>
650
-
651
- {watermark.imageUrl && (
652
- <Stack space={2}>
653
- <Card padding={3} tone="transparent" border radius={2}>
654
- <Flex
655
- align="center"
656
- justify="space-between"
657
- gap={3}
658
- style={{flexWrap: 'wrap', alignItems: 'flex-start'}}
659
- >
660
- <Stack space={2} style={{minWidth: 240, flex: 1}}>
661
- <Text size={1} weight="medium">
662
- Positioning mode
663
- </Text>
664
- <Text size={0} muted>
665
- Choose between dragging on the canvas or manually editing the Mux{' '}
666
- <code>overlay_settings</code> fields (as in{' '}
667
- <a
668
- href="https://www.mux.com/docs/guides/add-watermarks-to-your-videos"
669
- target="_blank"
670
- rel="noopener noreferrer"
671
- >
672
- the docs
673
- </a>
674
- ).
675
- </Text>
676
- </Stack>
677
- <Flex gap={2} style={{flexWrap: 'wrap'}}>
678
- <Button
679
- text="Canvas"
680
- mode={mode === 'canvas' ? 'default' : 'ghost'}
681
- onClick={() => {
682
- setMode('canvas')
683
- onChange({...watermark, enabled: true, overlay_settings: undefined})
684
- }}
685
- />
686
- <Button
687
- text="Manual"
688
- mode={mode === 'manual' ? 'default' : 'ghost'}
689
- onClick={() => {
690
- setMode('manual')
691
- const overlay = convertWatermarkToMuxOverlay({...watermark, enabled: true})
692
- updateOverlaySettings(overlay ?? {})
693
- }}
694
- />
695
- </Flex>
696
- </Flex>
697
- </Card>
698
-
699
- {mode === 'manual' && (
700
- <Card padding={3} tone="transparent" border radius={2}>
701
- <Stack space={3}>
702
- <Text size={1} weight="medium">
703
- Mux overlay_settings
704
- </Text>
705
- <Grid columns={[1, 2]} gap={3} style={{width: '100%'}}>
706
- <Stack space={2} style={{minWidth: 0}}>
707
- <Text size={0} muted>
708
- horizontal_align
709
- </Text>
710
- <select
711
- value={watermark.overlay_settings?.horizontal_align || 'right'}
712
- onChange={(e) =>
713
- updateOverlaySettings({
714
- horizontal_align: (e.target.value ||
715
- 'right') as MuxOverlaySettings['horizontal_align'],
716
- })
717
- }
718
- style={{
719
- width: '100%',
720
- padding: '8px 10px',
721
- border: '1px solid #ccc',
722
- borderRadius: 4,
723
- }}
724
- >
725
- <option value="left">left</option>
726
- <option value="center">center</option>
727
- <option value="right">right</option>
728
- </select>
729
- </Stack>
730
- <Stack space={2} style={{minWidth: 0}}>
731
- <Text size={0} muted>
732
- horizontal_margin (e.g. 2% or 40px)
733
- </Text>
734
- <TextInput
735
- value={watermark.overlay_settings?.horizontal_margin || '2%'}
736
- onChange={(e) =>
737
- updateOverlaySettings({horizontal_margin: e.currentTarget.value})
738
- }
739
- />
740
- </Stack>
741
- <Stack space={2} style={{minWidth: 0}}>
742
- <Text size={0} muted>
743
- vertical_align
744
- </Text>
745
- <select
746
- value={watermark.overlay_settings?.vertical_align || 'bottom'}
747
- onChange={(e) =>
748
- updateOverlaySettings({
749
- vertical_align: (e.target.value ||
750
- 'bottom') as MuxOverlaySettings['vertical_align'],
751
- })
752
- }
753
- style={{
754
- width: '100%',
755
- padding: '8px 10px',
756
- border: '1px solid #ccc',
757
- borderRadius: 4,
758
- }}
759
- >
760
- <option value="top">top</option>
761
- <option value="middle">middle</option>
762
- <option value="bottom">bottom</option>
763
- </select>
764
- </Stack>
765
- <Stack space={2} style={{minWidth: 0}}>
766
- <Text size={0} muted>
767
- vertical_margin (e.g. 2% or 40px)
768
- </Text>
769
- <TextInput
770
- value={watermark.overlay_settings?.vertical_margin || '2%'}
771
- onChange={(e) =>
772
- updateOverlaySettings({vertical_margin: e.currentTarget.value})
773
- }
774
- />
775
- </Stack>
776
- <Stack space={2} style={{minWidth: 0}}>
777
- <Text size={0} muted>
778
- width (e.g. 25% or 80px)
779
- </Text>
780
- <TextInput
781
- value={watermark.overlay_settings?.width || `${watermark.size ?? 20}%`}
782
- onChange={(e) => updateOverlaySettings({width: e.currentTarget.value})}
783
- />
784
- </Stack>
785
- <Stack space={2} style={{minWidth: 0}}>
786
- <Text size={0} muted>
787
- opacity (e.g. 90%)
788
- </Text>
789
- <TextInput
790
- value={
791
- watermark.overlay_settings?.opacity ||
792
- `${Math.round((watermark.opacity ?? 0.7) * 100)}%`
793
- }
794
- onChange={(e) => updateOverlaySettings({opacity: e.currentTarget.value})}
795
- />
796
- </Stack>
797
- </Grid>
798
- <Text size={0} muted>
799
- Margins and width accept either percentages or pixels, per the Mux guide.
800
- </Text>
801
- </Stack>
802
- </Card>
803
- )}
804
-
805
- {mode === 'canvas' && (
806
- <>
807
- <Box>
808
- <Text size={1} weight="medium">
809
- {/* oxlint-disable-next-line react/react-compiler */}
810
- {(() => {
811
- const sizePct = watermark.size || 20
812
- const contentW = getVideoContentBox().width
813
- if (!contentW) return `Size: ${sizePct}%`
814
- const px = Math.max(1, Math.round((sizePct / 100) * contentW))
815
- return `Size: ${px}px`
816
- })()}
817
- </Text>
818
- <RangeInput
819
- type="range"
820
- // oxlint-disable-next-line react/react-compiler
821
- value={(() => {
822
- const sizePct = watermark.size || 20
823
- const contentW = getVideoContentBox().width
824
- if (!contentW) return sizePct
825
- return Math.max(1, Math.round((sizePct / 100) * contentW))
826
- })()}
827
- // oxlint-disable-next-line react/react-compiler
828
- min={(() => {
829
- const contentW = getVideoContentBox().width
830
- if (!contentW) return 5
831
- return Math.max(1, Math.round(contentW * 0.05))
832
- })()}
833
- // oxlint-disable-next-line react/react-compiler
834
- max={(() => {
835
- const contentW = getVideoContentBox().width
836
- if (!contentW) return 50
837
- return Math.max(1, Math.round(contentW * 0.5))
838
- })()}
839
- step={1}
840
- onChange={(e) => {
841
- const raw = Number(e.target.value)
842
- const contentW = getVideoContentBox().width
843
- const nextPct = contentW ? (raw / contentW) * 100 : raw
844
- const clampedPct = Math.max(5, Math.min(50, nextPct))
845
- onChange({
846
- ...watermark,
847
- size: clampedPct,
848
- })
849
- }}
850
- />
851
- </Box>
852
-
853
- <Box>
854
- <Text size={1} weight="medium">
855
- Opacity: {Math.round((watermark.opacity ?? 0.7) * 100)}%
856
- </Text>
857
- <RangeInput
858
- type="range"
859
- value={watermark.opacity ?? 0.7}
860
- min={0}
861
- max={1}
862
- step={0.05}
863
- onChange={(e) =>
864
- onChange({
865
- ...watermark,
866
- opacity: Number(e.target.value),
867
- })
868
- }
869
- />
870
- </Box>
871
- </>
872
- )}
873
-
874
- <Card padding={2} tone="transparent" border radius={2}>
875
- <Text size={0} muted>
876
- {mode === 'manual'
877
- ? 'Manual mode: edit the overlay_settings fields above'
878
- : '💡 Drag the watermark on the preview to position it'}
879
- </Text>
880
- </Card>
881
- </Stack>
882
- )}
883
- </Stack>
884
- )
885
- }