sanity-plugin-mux-input 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +866 -60
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +866 -60
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/actions/upload.ts +42 -4
- package/src/components/DraggableWatermark.tsx +877 -0
- 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/roundPxString.ts +16 -0
- package/src/util/types.ts +43 -1
|
@@ -2,11 +2,12 @@ import {DocumentVideoIcon, ErrorOutlineIcon, UploadIcon} from '@sanity/icons'
|
|
|
2
2
|
import {Box, Button, Card, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
|
|
3
3
|
import {uuid} from '@sanity/uuid'
|
|
4
4
|
import LanguagesList from 'iso-639-1'
|
|
5
|
-
import {useEffect, useId, useReducer, useRef, useState} from 'react'
|
|
5
|
+
import {memo, useEffect, useId, useReducer, useRef, useState} from 'react'
|
|
6
6
|
import {FormField} from 'sanity'
|
|
7
7
|
|
|
8
8
|
import {useFetchFileSize} from '../hooks/useFetchFileSize'
|
|
9
|
-
import {useMediaMetadata} from '../hooks/useMediaMetadata'
|
|
9
|
+
import {useMediaMetadata, type VideoAssetMetadata} from '../hooks/useMediaMetadata'
|
|
10
|
+
import {convertWatermarkToMuxOverlay} from '../util/convertWatermarkToMux'
|
|
10
11
|
import formatBytes from '../util/formatBytes'
|
|
11
12
|
import {formatSeconds} from '../util/formatSeconds'
|
|
12
13
|
import {
|
|
@@ -21,7 +22,9 @@ import {
|
|
|
21
22
|
type SupportedMuxLanguage,
|
|
22
23
|
type UploadConfig,
|
|
23
24
|
type UploadTextTrack,
|
|
25
|
+
WatermarkConfig,
|
|
24
26
|
} from '../util/types'
|
|
27
|
+
import DraggableWatermark, {WatermarkControls} from './DraggableWatermark'
|
|
25
28
|
import TextTracksEditor, {type TrackAction} from './TextTracksEditor'
|
|
26
29
|
import PlaybackPolicy from './uploadConfiguration/PlaybackPolicy'
|
|
27
30
|
import {
|
|
@@ -39,6 +42,7 @@ export type UploadConfigurationStateAction =
|
|
|
39
42
|
| {action: 'signed_policy'; value: UploadConfig['signed_policy']}
|
|
40
43
|
| {action: 'public_policy'; value: UploadConfig['public_policy']}
|
|
41
44
|
| {action: 'drm_policy'; value: UploadConfig['drm_policy']}
|
|
45
|
+
| {action: 'watermark'; value: WatermarkConfig}
|
|
42
46
|
| TrackAction
|
|
43
47
|
|
|
44
48
|
const VIDEO_QUALITY_LEVELS = [
|
|
@@ -80,10 +84,13 @@ export default function UploadConfiguration({
|
|
|
80
84
|
stagedUpload: StagedUpload
|
|
81
85
|
secrets: Secrets
|
|
82
86
|
pluginConfig: PluginConfig
|
|
83
|
-
startUpload: (settings: MuxNewAssetSettings) => void
|
|
87
|
+
startUpload: (settings: MuxNewAssetSettings, watermark: WatermarkConfig | undefined) => void
|
|
84
88
|
onClose: () => void
|
|
85
89
|
}) {
|
|
86
90
|
const id = useId()
|
|
91
|
+
const [watermarkValidationError, setWatermarkValidationError] = useState<string | null>(null)
|
|
92
|
+
const watermarkPreviewContainerRef = useRef<HTMLDivElement>(null)
|
|
93
|
+
const watermarkPreviewVideoRef = useRef<HTMLVideoElement>(null)
|
|
87
94
|
const autoTextTracks = useRef<NonNullable<UploadConfig['text_tracks']>>(
|
|
88
95
|
pluginConfig.video_quality === 'plus' && pluginConfig.defaultAutogeneratedSubtitleLang
|
|
89
96
|
? [
|
|
@@ -130,6 +137,8 @@ export default function UploadConfiguration({
|
|
|
130
137
|
return Object.assign({}, prev, {[action.action]: action.value})
|
|
131
138
|
case 'drm_policy':
|
|
132
139
|
return Object.assign({}, prev, {[action.action]: action.value})
|
|
140
|
+
case 'watermark':
|
|
141
|
+
return Object.assign({}, prev, {watermark: action.value})
|
|
133
142
|
// Updating individual tracks
|
|
134
143
|
case 'track': {
|
|
135
144
|
const text_tracks = [...prev.text_tracks]
|
|
@@ -254,7 +263,12 @@ export default function UploadConfiguration({
|
|
|
254
263
|
const {disableTextTrackConfig, disableUploadConfig} = pluginConfig
|
|
255
264
|
const skipConfig = disableTextTrackConfig && disableUploadConfig
|
|
256
265
|
useEffect(() => {
|
|
257
|
-
if (skipConfig)
|
|
266
|
+
if (skipConfig) {
|
|
267
|
+
const {settings, watermark} = formatUploadConfig(config, secrets, {
|
|
268
|
+
videoAspectRatio: videoAssetMetadata?.aspectRatio,
|
|
269
|
+
})
|
|
270
|
+
startUpload(settings, watermark)
|
|
271
|
+
}
|
|
258
272
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
259
273
|
}, [])
|
|
260
274
|
if (skipConfig) return null
|
|
@@ -275,7 +289,7 @@ export default function UploadConfiguration({
|
|
|
275
289
|
onClose={onClose}
|
|
276
290
|
>
|
|
277
291
|
<Stack padding={4} space={2}>
|
|
278
|
-
{validationError && (
|
|
292
|
+
{(validationError || watermarkValidationError) && (
|
|
279
293
|
<Card padding={3} tone="critical" radius={2} marginBottom={2}>
|
|
280
294
|
<Flex gap={2} align="flex-start">
|
|
281
295
|
<ErrorOutlineIcon width={20} height={20} />
|
|
@@ -283,7 +297,7 @@ export default function UploadConfiguration({
|
|
|
283
297
|
<Text size={1} weight="semibold">
|
|
284
298
|
Validation Error
|
|
285
299
|
</Text>
|
|
286
|
-
<Text size={1}>{validationError}</Text>
|
|
300
|
+
<Text size={1}>{validationError || watermarkValidationError}</Text>
|
|
287
301
|
</Stack>
|
|
288
302
|
</Flex>
|
|
289
303
|
</Card>
|
|
@@ -377,27 +391,38 @@ export default function UploadConfiguration({
|
|
|
377
391
|
</FormField>
|
|
378
392
|
|
|
379
393
|
{!basicConfig && (
|
|
380
|
-
|
|
381
|
-
<
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
394
|
+
<>
|
|
395
|
+
<FormField title="Additional Configuration">
|
|
396
|
+
<Stack space={3}>
|
|
397
|
+
<PlaybackPolicy id={id} config={config} secrets={secrets} dispatch={dispatch} />
|
|
398
|
+
{maxSupportedResolution > 0 && (
|
|
399
|
+
<ResolutionTierSelector
|
|
400
|
+
id={id}
|
|
401
|
+
config={config}
|
|
402
|
+
dispatch={dispatch}
|
|
403
|
+
maxSupportedResolution={maxSupportedResolution}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
<StaticRenditionSelector id={id} config={config} dispatch={dispatch} />
|
|
407
|
+
{!disableTextTrackConfig && (
|
|
408
|
+
<TextTracksEditor
|
|
409
|
+
tracks={config.text_tracks}
|
|
410
|
+
dispatch={dispatch}
|
|
411
|
+
defaultLang={pluginConfig.defaultAutogeneratedSubtitleLang}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
</Stack>
|
|
415
|
+
</FormField>
|
|
416
|
+
<WatermarkSection
|
|
417
|
+
config={config}
|
|
418
|
+
dispatch={dispatch}
|
|
419
|
+
stagedUpload={stagedUpload}
|
|
420
|
+
videoAssetMetadata={videoAssetMetadata}
|
|
421
|
+
watermarkPreviewContainerRef={watermarkPreviewContainerRef}
|
|
422
|
+
watermarkPreviewVideoRef={watermarkPreviewVideoRef}
|
|
423
|
+
onValidationChange={setWatermarkValidationError}
|
|
424
|
+
/>
|
|
425
|
+
</>
|
|
401
426
|
)}
|
|
402
427
|
</Stack>
|
|
403
428
|
)}
|
|
@@ -415,7 +440,10 @@ export default function UploadConfiguration({
|
|
|
415
440
|
tone="positive"
|
|
416
441
|
onClick={() => {
|
|
417
442
|
if (!validationError) {
|
|
418
|
-
|
|
443
|
+
const {settings, watermark} = formatUploadConfig(config, secrets, {
|
|
444
|
+
videoAspectRatio: videoAssetMetadata?.aspectRatio,
|
|
445
|
+
})
|
|
446
|
+
startUpload(settings, watermark)
|
|
419
447
|
}
|
|
420
448
|
}}
|
|
421
449
|
/>
|
|
@@ -449,7 +477,14 @@ function setAdvancedPlaybackPolicy(
|
|
|
449
477
|
return advanced_playback_policies
|
|
450
478
|
}
|
|
451
479
|
|
|
452
|
-
function formatUploadConfig(
|
|
480
|
+
function formatUploadConfig(
|
|
481
|
+
config: UploadConfig,
|
|
482
|
+
secrets: Secrets,
|
|
483
|
+
options?: {videoAspectRatio?: number | null}
|
|
484
|
+
): {
|
|
485
|
+
settings: MuxNewAssetSettings
|
|
486
|
+
watermark?: WatermarkConfig
|
|
487
|
+
} {
|
|
453
488
|
const generated_subtitles = config.text_tracks
|
|
454
489
|
.filter<AutogeneratedTextTrack>(isAutogeneratedTrack)
|
|
455
490
|
.map<{name: string; language_code: SupportedMuxLanguage}>((track) => ({
|
|
@@ -457,36 +492,201 @@ function formatUploadConfig(config: UploadConfig, secrets: Secrets): MuxNewAsset
|
|
|
457
492
|
language_code: track.language_code,
|
|
458
493
|
}))
|
|
459
494
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
495
|
+
const inputs: NonNullable<MuxNewAssetSettings['input']> = [
|
|
496
|
+
{
|
|
497
|
+
type: 'video',
|
|
498
|
+
generated_subtitles: generated_subtitles.length > 0 ? generated_subtitles : undefined,
|
|
499
|
+
},
|
|
500
|
+
...config.text_tracks.filter<CustomTextTrack>(isCustomTextTrack).reduce(
|
|
501
|
+
(acc, track) => {
|
|
502
|
+
if (track.language_code && track.file && track.name) {
|
|
503
|
+
acc.push({
|
|
504
|
+
url: track.file.contents,
|
|
505
|
+
type: 'text',
|
|
506
|
+
text_type: track.type === 'subtitles' ? 'subtitles' : undefined,
|
|
507
|
+
language_code: track.language_code,
|
|
508
|
+
name: track.name,
|
|
509
|
+
closed_captions: track.type === 'captions',
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
return acc
|
|
465
513
|
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
],
|
|
483
|
-
static_renditions:
|
|
484
|
-
config.static_renditions.length > 0
|
|
485
|
-
? config.static_renditions.map((resolution) => ({resolution}))
|
|
486
|
-
: undefined,
|
|
487
|
-
advanced_playback_policies: setAdvancedPlaybackPolicy(config, secrets),
|
|
488
|
-
max_resolution_tier: config.max_resolution_tier,
|
|
489
|
-
video_quality: config.video_quality,
|
|
490
|
-
normalize_audio: config.normalize_audio,
|
|
514
|
+
[] as NonNullable<MuxNewAssetSettings['input']>
|
|
515
|
+
),
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
if (config.watermark?.imageUrl) {
|
|
519
|
+
const watermarkForMux: WatermarkConfig = {...config.watermark, enabled: true}
|
|
520
|
+
const overlaySettings = convertWatermarkToMuxOverlay(watermarkForMux, {
|
|
521
|
+
videoAspectRatio: options?.videoAspectRatio ?? undefined,
|
|
522
|
+
units: 'px',
|
|
523
|
+
})
|
|
524
|
+
if (overlaySettings) {
|
|
525
|
+
inputs.push({
|
|
526
|
+
url: config.watermark.imageUrl,
|
|
527
|
+
overlay_settings: overlaySettings,
|
|
528
|
+
} as NonNullable<MuxNewAssetSettings['input']>[number])
|
|
529
|
+
}
|
|
491
530
|
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
settings: {
|
|
534
|
+
input: inputs,
|
|
535
|
+
static_renditions:
|
|
536
|
+
config.static_renditions.length > 0
|
|
537
|
+
? config.static_renditions.map((resolution) => ({resolution}))
|
|
538
|
+
: undefined,
|
|
539
|
+
advanced_playback_policies: setAdvancedPlaybackPolicy(config, secrets),
|
|
540
|
+
max_resolution_tier: config.max_resolution_tier,
|
|
541
|
+
video_quality: config.video_quality,
|
|
542
|
+
normalize_audio: config.normalize_audio,
|
|
543
|
+
},
|
|
544
|
+
watermark: config.watermark?.imageUrl
|
|
545
|
+
? ({...config.watermark, enabled: true} as WatermarkConfig)
|
|
546
|
+
: undefined,
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function WatermarkSection({
|
|
551
|
+
config,
|
|
552
|
+
dispatch,
|
|
553
|
+
stagedUpload,
|
|
554
|
+
videoAssetMetadata,
|
|
555
|
+
watermarkPreviewContainerRef,
|
|
556
|
+
watermarkPreviewVideoRef,
|
|
557
|
+
onValidationChange,
|
|
558
|
+
}: {
|
|
559
|
+
config: UploadConfig
|
|
560
|
+
dispatch: (action: UploadConfigurationStateAction) => void
|
|
561
|
+
stagedUpload: StagedUpload
|
|
562
|
+
videoAssetMetadata: VideoAssetMetadata | null
|
|
563
|
+
watermarkPreviewContainerRef: React.RefObject<HTMLDivElement | null>
|
|
564
|
+
watermarkPreviewVideoRef: React.RefObject<HTMLVideoElement | null>
|
|
565
|
+
onValidationChange: (error: string | null) => void
|
|
566
|
+
}) {
|
|
567
|
+
if (videoAssetMetadata?.isAudioOnly !== false) return null
|
|
568
|
+
return (
|
|
569
|
+
<FormField
|
|
570
|
+
title="Watermark"
|
|
571
|
+
description={
|
|
572
|
+
<>
|
|
573
|
+
Add a watermark overlay to your video using Mux's native watermark support.{' '}
|
|
574
|
+
<a
|
|
575
|
+
href="https://www.mux.com/docs/guides/add-watermarks-to-your-videos"
|
|
576
|
+
target="_blank"
|
|
577
|
+
rel="noopener noreferrer"
|
|
578
|
+
>
|
|
579
|
+
Learn more about Mux watermarks.
|
|
580
|
+
</a>
|
|
581
|
+
</>
|
|
582
|
+
}
|
|
583
|
+
>
|
|
584
|
+
<Stack space={3}>
|
|
585
|
+
<WatermarkControls
|
|
586
|
+
watermark={config.watermark || {enabled: false}}
|
|
587
|
+
onChange={(watermark) => {
|
|
588
|
+
dispatch({action: 'watermark', value: watermark})
|
|
589
|
+
}}
|
|
590
|
+
onValidationChange={onValidationChange}
|
|
591
|
+
previewContainerRef={watermarkPreviewContainerRef}
|
|
592
|
+
previewVideoRef={watermarkPreviewVideoRef}
|
|
593
|
+
/>
|
|
594
|
+
{config.watermark?.imageUrl &&
|
|
595
|
+
stagedUpload.type === 'file' &&
|
|
596
|
+
// Canvas preview is only shown in "Canvas" mode (no explicit overlay_settings)
|
|
597
|
+
!config.watermark.overlay_settings && (
|
|
598
|
+
<WatermarkPreview
|
|
599
|
+
stagedUpload={stagedUpload}
|
|
600
|
+
watermark={config.watermark}
|
|
601
|
+
videoAspectRatio={videoAssetMetadata.aspectRatio}
|
|
602
|
+
onWatermarkChange={(watermark) => {
|
|
603
|
+
dispatch({action: 'watermark', value: watermark})
|
|
604
|
+
}}
|
|
605
|
+
previewContainerRef={watermarkPreviewContainerRef}
|
|
606
|
+
videoRef={watermarkPreviewVideoRef}
|
|
607
|
+
/>
|
|
608
|
+
)}
|
|
609
|
+
</Stack>
|
|
610
|
+
</FormField>
|
|
611
|
+
)
|
|
492
612
|
}
|
|
613
|
+
|
|
614
|
+
// Memoized preview component to prevent unnecessary re-renders
|
|
615
|
+
const WatermarkPreview = memo(function WatermarkPreview({
|
|
616
|
+
stagedUpload,
|
|
617
|
+
watermark,
|
|
618
|
+
onWatermarkChange,
|
|
619
|
+
videoAspectRatio,
|
|
620
|
+
previewContainerRef,
|
|
621
|
+
videoRef,
|
|
622
|
+
}: {
|
|
623
|
+
stagedUpload: StagedUpload
|
|
624
|
+
watermark: WatermarkConfig
|
|
625
|
+
onWatermarkChange: (watermark: WatermarkConfig) => void
|
|
626
|
+
videoAspectRatio?: number | null
|
|
627
|
+
previewContainerRef: React.RefObject<HTMLDivElement | null>
|
|
628
|
+
videoRef: React.RefObject<HTMLVideoElement | null>
|
|
629
|
+
}) {
|
|
630
|
+
// Initialize video source only once
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
if (videoRef.current && stagedUpload.type === 'file') {
|
|
633
|
+
const file = stagedUpload.files[0]
|
|
634
|
+
const url = URL.createObjectURL(file)
|
|
635
|
+
videoRef.current.src = url
|
|
636
|
+
|
|
637
|
+
return () => {
|
|
638
|
+
URL.revokeObjectURL(url)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return undefined
|
|
642
|
+
}, [stagedUpload, videoRef])
|
|
643
|
+
|
|
644
|
+
const isVertical =
|
|
645
|
+
videoAspectRatio !== null && videoAspectRatio !== undefined && videoAspectRatio < 1
|
|
646
|
+
|
|
647
|
+
return (
|
|
648
|
+
<Card
|
|
649
|
+
tone="transparent"
|
|
650
|
+
border
|
|
651
|
+
style={{
|
|
652
|
+
overflow: 'hidden',
|
|
653
|
+
// For vertical videos, center the preview and limit its width
|
|
654
|
+
display: 'flex',
|
|
655
|
+
justifyContent: 'center',
|
|
656
|
+
}}
|
|
657
|
+
>
|
|
658
|
+
{/* Inner container that exactly matches the video aspect ratio - no padding, no letterbox */}
|
|
659
|
+
<div
|
|
660
|
+
ref={previewContainerRef}
|
|
661
|
+
style={{
|
|
662
|
+
position: 'relative',
|
|
663
|
+
// For vertical videos: limit width so the preview doesn't get too tall
|
|
664
|
+
// For horizontal videos: use full width
|
|
665
|
+
width: isVertical ? 'auto' : '100%',
|
|
666
|
+
aspectRatio: videoAspectRatio ? String(videoAspectRatio) : '16/9',
|
|
667
|
+
...(isVertical ? {height: '400px', maxHeight: '50vh'} : {minHeight: '200px'}),
|
|
668
|
+
overflow: 'hidden',
|
|
669
|
+
}}
|
|
670
|
+
>
|
|
671
|
+
<video
|
|
672
|
+
ref={videoRef}
|
|
673
|
+
style={{
|
|
674
|
+
position: 'absolute',
|
|
675
|
+
top: 0,
|
|
676
|
+
left: 0,
|
|
677
|
+
width: '100%',
|
|
678
|
+
height: '100%',
|
|
679
|
+
objectFit: 'fill',
|
|
680
|
+
display: 'block',
|
|
681
|
+
}}
|
|
682
|
+
/>
|
|
683
|
+
<DraggableWatermark
|
|
684
|
+
watermark={watermark}
|
|
685
|
+
onChange={onWatermarkChange}
|
|
686
|
+
containerRef={previewContainerRef as React.RefObject<HTMLDivElement>}
|
|
687
|
+
videoElementRef={videoRef as React.RefObject<HTMLVideoElement>}
|
|
688
|
+
/>
|
|
689
|
+
</div>
|
|
690
|
+
</Card>
|
|
691
|
+
)
|
|
692
|
+
})
|
|
@@ -197,9 +197,13 @@ export default function Uploader(props: Props) {
|
|
|
197
197
|
* the Mux configuration for the direct asset upload.
|
|
198
198
|
*
|
|
199
199
|
* @param settings The Mux new_asset_settings object to send to Sanity
|
|
200
|
+
* @param watermark Optional watermark configuration
|
|
200
201
|
* @returns
|
|
201
202
|
*/
|
|
202
|
-
const startUpload = (
|
|
203
|
+
const startUpload = (
|
|
204
|
+
settings: MuxNewAssetSettings,
|
|
205
|
+
watermark?: import('../util/types').WatermarkConfig
|
|
206
|
+
) => {
|
|
203
207
|
const {stagedUpload} = state
|
|
204
208
|
if (!stagedUpload || uploadRef.current) return
|
|
205
209
|
dispatch({action: 'commitUpload'})
|
|
@@ -211,6 +215,7 @@ export default function Uploader(props: Props) {
|
|
|
211
215
|
client: props.client,
|
|
212
216
|
url: stagedUpload.url,
|
|
213
217
|
settings,
|
|
218
|
+
watermark,
|
|
214
219
|
})
|
|
215
220
|
break
|
|
216
221
|
case 'file':
|
|
@@ -218,6 +223,7 @@ export default function Uploader(props: Props) {
|
|
|
218
223
|
client: props.client,
|
|
219
224
|
file: stagedUpload.files[0],
|
|
220
225
|
settings,
|
|
226
|
+
watermark,
|
|
221
227
|
}).pipe(
|
|
222
228
|
takeUntil(
|
|
223
229
|
cancelUploadButton.observable.pipe(
|
|
@@ -37,6 +37,7 @@ export default function VideoPlayer({
|
|
|
37
37
|
|
|
38
38
|
const isAudio = assetIsAudio(asset)
|
|
39
39
|
const muxPlayer = useRef<MuxPlayerRefAttributes>(null)
|
|
40
|
+
const playerContainerRef = useRef<HTMLDivElement>(null)
|
|
40
41
|
const [error, setError] = useState<Error>()
|
|
41
42
|
|
|
42
43
|
/* Playback ID that will be used to play the video */
|
|
@@ -149,6 +150,7 @@ export default function VideoPlayer({
|
|
|
149
150
|
return (
|
|
150
151
|
<>
|
|
151
152
|
<Card
|
|
153
|
+
ref={playerContainerRef}
|
|
152
154
|
tone="transparent"
|
|
153
155
|
style={{
|
|
154
156
|
aspectRatio: aspectRatio,
|
|
@@ -8,6 +8,7 @@ export interface VideoAssetMetadata {
|
|
|
8
8
|
isAudioOnly?: boolean
|
|
9
9
|
duration?: number
|
|
10
10
|
size?: number
|
|
11
|
+
aspectRatio?: number
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function useMediaMetadata(stagedUpload: StagedUpload) {
|
|
@@ -48,6 +49,7 @@ export function useMediaMetadata(stagedUpload: StagedUpload) {
|
|
|
48
49
|
const width = videoElement.videoWidth
|
|
49
50
|
const height = videoElement.videoHeight
|
|
50
51
|
const isAudioOnly = width <= 0 && height <= 0
|
|
52
|
+
const aspectRatio = width / height
|
|
51
53
|
setVideoAssetMetadata((old) => {
|
|
52
54
|
return {
|
|
53
55
|
...old,
|
|
@@ -55,6 +57,7 @@ export function useMediaMetadata(stagedUpload: StagedUpload) {
|
|
|
55
57
|
width: width,
|
|
56
58
|
height: height,
|
|
57
59
|
isAudioOnly: isAudioOnly,
|
|
60
|
+
aspectRatio: aspectRatio,
|
|
58
61
|
}
|
|
59
62
|
})
|
|
60
63
|
},
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {roundPxString} from './roundPxString'
|
|
2
|
+
import type {MuxOverlaySettings, WatermarkConfig} from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Converts a draggable watermark position (x, y percentages) to Mux's overlay_settings format.
|
|
6
|
+
*
|
|
7
|
+
* @param watermark - The watermark configuration with position, size, and opacity
|
|
8
|
+
* @returns Mux overlay_settings object
|
|
9
|
+
* @see {@link https://www.mux.com/docs/guides/add-watermarks-to-your-videos}
|
|
10
|
+
*/
|
|
11
|
+
export function convertWatermarkToMuxOverlay(
|
|
12
|
+
watermark: WatermarkConfig,
|
|
13
|
+
options?: {
|
|
14
|
+
/**
|
|
15
|
+
* Video aspect ratio (width / height). Needed for correct vertical positioning,
|
|
16
|
+
* especially on vertical videos.
|
|
17
|
+
*/
|
|
18
|
+
videoAspectRatio?: number
|
|
19
|
+
/**
|
|
20
|
+
* Unit to emit for margins/width when generating overlay_settings from Canvas mode.
|
|
21
|
+
* - 'px' will generate pixel strings according to Mux's scaling rules:
|
|
22
|
+
* values are applied as if the video were scaled to 1920x1080 (horizontal)
|
|
23
|
+
* or 1080x1920 (vertical).
|
|
24
|
+
* - '%' preserves existing behavior.
|
|
25
|
+
*/
|
|
26
|
+
units?: '%' | 'px'
|
|
27
|
+
}
|
|
28
|
+
): MuxOverlaySettings | null {
|
|
29
|
+
if (!watermark.enabled || !watermark.imageUrl) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const size = watermark.size || 20
|
|
34
|
+
const opacity = watermark.opacity ?? 0.7
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert a percentage to whole-pixel string, using Mux's base dimensions:
|
|
38
|
+
* - Horizontal video: 1920x1080
|
|
39
|
+
* - Vertical video: 1080x1920
|
|
40
|
+
*/
|
|
41
|
+
const toPxString = (valuePercent: number, axis: 'x' | 'y') => {
|
|
42
|
+
const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
|
|
43
|
+
const isVertical = videoAspectRatio > 0 && videoAspectRatio < 1
|
|
44
|
+
const baseW = isVertical ? 1080 : 1920
|
|
45
|
+
const baseH = isVertical ? 1920 : 1080
|
|
46
|
+
const base = axis === 'x' ? baseW : baseH
|
|
47
|
+
const px = (valuePercent / 100) * base
|
|
48
|
+
let rounded = Math.round(px)
|
|
49
|
+
// Avoid sending 0px (and JS -0); keep sign for negative margins.
|
|
50
|
+
if (rounded === 0) rounded = px < 0 ? -1 : 1
|
|
51
|
+
return `${rounded}px`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalizeToPixels = (value: string | undefined, axis: 'x' | 'y'): string | undefined => {
|
|
55
|
+
if (!value) return value
|
|
56
|
+
const trimmed = value.trim()
|
|
57
|
+
if (trimmed.endsWith('px')) {
|
|
58
|
+
return roundPxString(trimmed)
|
|
59
|
+
}
|
|
60
|
+
if (trimmed.endsWith('%')) {
|
|
61
|
+
const n = Number(trimmed.slice(0, -1))
|
|
62
|
+
if (!Number.isFinite(n)) return value
|
|
63
|
+
return toPxString(n, axis)
|
|
64
|
+
}
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// If user provided explicit overlay settings, use them (Mux-documented format).
|
|
69
|
+
// When `options.units === 'px'`, we normalize both % and px to whole-pixel strings,
|
|
70
|
+
// honoring vertical vs horizontal video bases.
|
|
71
|
+
if (watermark.overlay_settings) {
|
|
72
|
+
const widthValue = watermark.overlay_settings.width
|
|
73
|
+
const widthNormalized =
|
|
74
|
+
options?.units === 'px' ? normalizeToPixels(widthValue, 'x') : widthValue
|
|
75
|
+
return {
|
|
76
|
+
...watermark.overlay_settings,
|
|
77
|
+
horizontal_margin:
|
|
78
|
+
options?.units === 'px'
|
|
79
|
+
? (normalizeToPixels(watermark.overlay_settings.horizontal_margin, 'x') ??
|
|
80
|
+
watermark.overlay_settings.horizontal_margin)
|
|
81
|
+
: watermark.overlay_settings.horizontal_margin,
|
|
82
|
+
vertical_margin:
|
|
83
|
+
options?.units === 'px'
|
|
84
|
+
? (normalizeToPixels(watermark.overlay_settings.vertical_margin, 'y') ??
|
|
85
|
+
watermark.overlay_settings.vertical_margin)
|
|
86
|
+
: watermark.overlay_settings.vertical_margin,
|
|
87
|
+
width: widthNormalized ?? `${size}%`,
|
|
88
|
+
opacity: watermark.overlay_settings.opacity ?? `${Math.round(opacity * 100)}%`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const position = watermark.position || {x: 50, y: 50}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Our UI stores watermark position as the *center point* in percentages.
|
|
96
|
+
* Mux margins are interpreted relative to an *edge* (based on align).
|
|
97
|
+
*
|
|
98
|
+
* To make "corner" placements match what the user dragged, we convert from
|
|
99
|
+
* center-position to top-left margins by subtracting half the watermark size.
|
|
100
|
+
*
|
|
101
|
+
* Note: `size` is a percentage of video width. Mux `width` is also expressed
|
|
102
|
+
* as a percentage of the video width, so we can reuse it for horizontal math.
|
|
103
|
+
* For vertical math, we approximate using the same percentage to keep behavior
|
|
104
|
+
* consistent with the current draggable UI (which also uses `size` in both axes
|
|
105
|
+
* for bounds).
|
|
106
|
+
*/
|
|
107
|
+
// Allow negative margins to compensate for rounding / letterboxing edge-cases.
|
|
108
|
+
// We still clamp to a sane range so values don't explode.
|
|
109
|
+
const clampPercent = (value: number) => Math.max(-100, Math.min(100, value))
|
|
110
|
+
|
|
111
|
+
// Mux accepts percentage strings; avoid sending an exact "0%" by nudging to 0.01%.
|
|
112
|
+
// This also handles the JS -0 edge case and tiny floating point remnants.
|
|
113
|
+
const toPercentString = (value: number) => {
|
|
114
|
+
const epsilon = 1e-9
|
|
115
|
+
const isZeroish = value === 0 || Object.is(value, -0) || Math.abs(value) < epsilon
|
|
116
|
+
return `${isZeroish ? 0.01 : value}%`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const watermarkWidthPercentOfVideoWidth = size
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert watermark height into % of video height.
|
|
123
|
+
* height% = (watermarkWidthPx / imageAspectRatio) / videoHeightPx
|
|
124
|
+
* = (size% * videoWidthPx / imageAspectRatio) / videoHeightPx
|
|
125
|
+
* = size% * (videoWidthPx/videoHeightPx) / imageAspectRatio
|
|
126
|
+
* = size% * videoAspectRatio / imageAspectRatio
|
|
127
|
+
*/
|
|
128
|
+
const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
|
|
129
|
+
const imageAspectRatio = watermark.imageAspectRatio ?? 1
|
|
130
|
+
const watermarkHeightPercentOfVideoHeight = Math.max(
|
|
131
|
+
0,
|
|
132
|
+
Math.min(100, (size * videoAspectRatio) / imageAspectRatio)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const halfWidth = watermarkWidthPercentOfVideoWidth / 2
|
|
136
|
+
const halfHeight = watermarkHeightPercentOfVideoHeight / 2
|
|
137
|
+
|
|
138
|
+
const leftMargin = clampPercent(
|
|
139
|
+
Math.min(position.x - halfWidth, 100 - watermarkWidthPercentOfVideoWidth)
|
|
140
|
+
)
|
|
141
|
+
const topMargin = clampPercent(
|
|
142
|
+
Math.min(position.y - halfHeight, 100 - watermarkHeightPercentOfVideoHeight)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const units = options?.units ?? '%'
|
|
146
|
+
const marginX = units === 'px' ? toPxString(leftMargin, 'x') : toPercentString(leftMargin)
|
|
147
|
+
const marginY = units === 'px' ? toPxString(topMargin, 'y') : toPercentString(topMargin)
|
|
148
|
+
const width = units === 'px' ? toPxString(size, 'x') : `${size}%`
|
|
149
|
+
|
|
150
|
+
const overlaySettings: MuxOverlaySettings = {
|
|
151
|
+
vertical_align: 'top',
|
|
152
|
+
vertical_margin: marginY,
|
|
153
|
+
horizontal_align: 'left',
|
|
154
|
+
horizontal_margin: marginX,
|
|
155
|
+
width,
|
|
156
|
+
opacity: `${Math.round(opacity * 100)}%`,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return overlaySettings
|
|
160
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rounds the numeric part of a px string to the nearest integer.
|
|
3
|
+
* Returns undefined if the value is not a valid px string or not a finite number.
|
|
4
|
+
* Avoids sending 0px (and JS -0); snaps to ±1 instead.
|
|
5
|
+
*/
|
|
6
|
+
export function roundPxString(value: unknown): string | undefined {
|
|
7
|
+
if (typeof value !== 'string') return undefined
|
|
8
|
+
const trimmed = value.trim()
|
|
9
|
+
if (!trimmed.endsWith('px')) return undefined
|
|
10
|
+
const n = Number(trimmed.slice(0, -2))
|
|
11
|
+
if (!Number.isFinite(n)) return undefined
|
|
12
|
+
let rounded = Math.round(n)
|
|
13
|
+
// Avoid sending 0px (and JS -0); keep sign when negative.
|
|
14
|
+
if (rounded === 0) rounded = n < 0 ? -1 : 1
|
|
15
|
+
return `${rounded}px`
|
|
16
|
+
}
|