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.
- package/dist/index.js +916 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +916 -78
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/actions/upload.ts +47 -7
- package/src/components/AddCaptionDialog.tsx +28 -9
- package/src/components/DraggableWatermark.tsx +877 -0
- package/src/components/EditCaptionDialog.tsx +4 -2
- package/src/components/UploadConfiguration.tsx +259 -59
- package/src/components/Uploader.tsx +7 -1
- package/src/components/VideoPlayer.tsx +2 -0
- package/src/hooks/useMediaMetadata.ts +3 -0
- package/src/util/convertWatermarkToMux.ts +160 -0
- package/src/util/formatDriveShareLink.ts +64 -0
- package/src/util/roundPxString.ts +16 -0
- package/src/util/types.ts +43 -1
|
@@ -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
|
+
}
|