sanity-plugin-mux-input 2.13.0 → 2.15.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/README.md +25 -24
- package/dist/index.d.mts +35 -2
- package/dist/index.d.ts +35 -2
- package/dist/index.js +2176 -461
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2178 -463
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/_exports/index.ts +1 -0
- package/src/actions/assets.ts +75 -0
- package/src/actions/secrets.ts +6 -1
- package/src/actions/upload.ts +1 -1
- package/src/components/AddCaptionDialog.tsx +421 -0
- package/src/components/CaptionsDialog.tsx +23 -0
- package/src/components/ConfigureApi.tsx +51 -5
- package/src/components/EditCaptionDialog.tsx +508 -0
- package/src/components/InputBrowser.tsx +8 -2
- package/src/components/Onboard.tsx +2 -2
- package/src/components/PageSelector.tsx +54 -0
- package/src/components/Player.styled.tsx +7 -2
- package/src/components/PlayerActionsMenu.tsx +14 -6
- package/src/components/SelectAsset.tsx +9 -3
- package/src/components/StudioTool.tsx +2 -2
- package/src/components/TextTracksManager.tsx +781 -0
- package/src/components/UploadConfiguration.tsx +104 -343
- package/src/components/Uploader.styled.tsx +8 -15
- package/src/components/Uploader.tsx +25 -7
- package/src/components/VideoDetails/VideoDetails.tsx +43 -7
- package/src/components/VideoInBrowser.tsx +53 -6
- package/src/components/VideoPlayer.tsx +122 -47
- package/src/components/VideoThumbnail.tsx +84 -72
- package/src/components/VideosBrowser.tsx +15 -5
- package/src/components/uploadConfiguration/PlaybackPolicy.tsx +95 -6
- package/src/components/uploadConfiguration/PlaybackPolicyOption.tsx +26 -10
- package/src/components/uploadConfiguration/ResolutionTierSelector.tsx +71 -0
- package/src/components/uploadConfiguration/StaticRenditionSelector.tsx +179 -0
- package/src/context/DrmPlaybackWarningContext.tsx +93 -0
- package/src/hooks/useAccessControl.ts +1 -0
- package/src/hooks/useDialogState.ts +1 -1
- package/src/hooks/useFetchFileSize.ts +54 -0
- package/src/hooks/useMediaMetadata.ts +100 -0
- package/src/hooks/useSaveSecrets.ts +10 -3
- package/src/hooks/useSecretsDocumentValues.ts +9 -1
- package/src/hooks/useSecretsFormState.ts +6 -3
- package/src/util/asserters.ts +14 -0
- package/src/util/createUrlParamsObject.ts +7 -3
- package/src/util/generateJwt.ts +11 -2
- package/src/util/getPlaybackPolicy.ts +63 -4
- package/src/util/getStoryboardSrc.ts +7 -3
- package/src/util/getVideoMetadata.ts +4 -1
- package/src/util/getVideoSrc.ts +9 -9
- package/src/util/readSecrets.ts +3 -1
- package/src/util/textTracks.ts +222 -0
- package/src/util/tryWithSuspend.ts +22 -0
- package/src/util/types.ts +39 -6
- package/src/util/getPlaybackId.ts +0 -9
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {Checkbox, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
|
|
2
|
+
import {ActionDispatch, useMemo, useState} from 'react'
|
|
3
|
+
import {FormField} from 'sanity'
|
|
4
|
+
|
|
5
|
+
import {type StaticRenditionResolution, type UploadConfig} from '../../util/types'
|
|
6
|
+
import {UploadConfigurationStateAction} from '../UploadConfiguration'
|
|
7
|
+
|
|
8
|
+
const ADVANCED_RESOLUTIONS: {value: StaticRenditionResolution; label: string}[] = [
|
|
9
|
+
{value: '270p', label: '270p'},
|
|
10
|
+
{value: '360p', label: '360p'},
|
|
11
|
+
{value: '480p', label: '480p'},
|
|
12
|
+
{value: '540p', label: '540p'},
|
|
13
|
+
{value: '720p', label: '720p'},
|
|
14
|
+
{value: '1080p', label: '1080p'},
|
|
15
|
+
{value: '1440p', label: '1440p'},
|
|
16
|
+
{value: '2160p', label: '2160p'},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
export const StaticRenditionSelector = ({
|
|
20
|
+
id,
|
|
21
|
+
config,
|
|
22
|
+
dispatch,
|
|
23
|
+
}: {
|
|
24
|
+
id: string
|
|
25
|
+
config: UploadConfig
|
|
26
|
+
dispatch: ActionDispatch<[action: UploadConfigurationStateAction]>
|
|
27
|
+
}) => {
|
|
28
|
+
// Determine if user is in advanced mode based on selected renditions
|
|
29
|
+
const isAdvancedMode = useMemo(() => {
|
|
30
|
+
const specificResolutions = config.static_renditions.filter(
|
|
31
|
+
(r) => r !== 'highest' && r !== 'audio-only'
|
|
32
|
+
)
|
|
33
|
+
return specificResolutions.length > 0
|
|
34
|
+
}, [config.static_renditions])
|
|
35
|
+
|
|
36
|
+
const [renditionMode, setRenditionMode] = useState<'standard' | 'advanced'>(
|
|
37
|
+
isAdvancedMode ? 'advanced' : 'standard'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Helper to toggle a rendition
|
|
41
|
+
const toggleRendition = (rendition: StaticRenditionResolution) => {
|
|
42
|
+
const current = config.static_renditions
|
|
43
|
+
const hasRendition = current.includes(rendition)
|
|
44
|
+
|
|
45
|
+
if (hasRendition) {
|
|
46
|
+
dispatch({
|
|
47
|
+
action: 'static_renditions',
|
|
48
|
+
value: current.filter((r) => r !== rendition),
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
dispatch({
|
|
52
|
+
action: 'static_renditions',
|
|
53
|
+
value: [...current, rendition],
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// When switching modes, clear renditions that don't apply
|
|
59
|
+
const handleModeChange = (mode: 'standard' | 'advanced') => {
|
|
60
|
+
setRenditionMode(mode)
|
|
61
|
+
if (mode === 'standard') {
|
|
62
|
+
// Remove specific resolutions, keep only highest and audio-only
|
|
63
|
+
dispatch({
|
|
64
|
+
action: 'static_renditions',
|
|
65
|
+
value: config.static_renditions.filter((r) => r === 'highest' || r === 'audio-only'),
|
|
66
|
+
})
|
|
67
|
+
} else {
|
|
68
|
+
// Remove highest, keep specific resolutions and audio-only
|
|
69
|
+
dispatch({
|
|
70
|
+
action: 'static_renditions',
|
|
71
|
+
value: config.static_renditions.filter((r) => r !== 'highest'),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<Stack space={3}>
|
|
77
|
+
<FormField
|
|
78
|
+
title="Static Renditions"
|
|
79
|
+
description="Generate downloadable MP4 or M4A files. Note: Mux will not upscale to produce MP4 renditions - renditions that would cause upscaling are skipped."
|
|
80
|
+
>
|
|
81
|
+
<Stack space={3}>
|
|
82
|
+
{/* Mode Selector */}
|
|
83
|
+
<Flex gap={3}>
|
|
84
|
+
<Flex align="center" gap={2}>
|
|
85
|
+
<Radio
|
|
86
|
+
checked={renditionMode === 'standard'}
|
|
87
|
+
name="rendition-mode"
|
|
88
|
+
onChange={() => handleModeChange('standard')}
|
|
89
|
+
value="standard"
|
|
90
|
+
id={`${id}--mode-standard`}
|
|
91
|
+
/>
|
|
92
|
+
<Text as="label" htmlFor={`${id}--mode-standard`}>
|
|
93
|
+
Standard
|
|
94
|
+
</Text>
|
|
95
|
+
</Flex>
|
|
96
|
+
<Flex align="center" gap={2}>
|
|
97
|
+
<Radio
|
|
98
|
+
checked={renditionMode === 'advanced'}
|
|
99
|
+
name="rendition-mode"
|
|
100
|
+
onChange={() => handleModeChange('advanced')}
|
|
101
|
+
value="advanced"
|
|
102
|
+
id={`${id}--mode-advanced`}
|
|
103
|
+
/>
|
|
104
|
+
<Text as="label" htmlFor={`${id}--mode-advanced`}>
|
|
105
|
+
Advanced
|
|
106
|
+
</Text>
|
|
107
|
+
</Flex>
|
|
108
|
+
</Flex>
|
|
109
|
+
|
|
110
|
+
{/* Standard Mode Options */}
|
|
111
|
+
{renditionMode === 'standard' && (
|
|
112
|
+
<Stack space={2}>
|
|
113
|
+
<Flex align="center" gap={2} padding={[0, 2]}>
|
|
114
|
+
<Checkbox
|
|
115
|
+
id={`${id}--highest`}
|
|
116
|
+
style={{display: 'block'}}
|
|
117
|
+
checked={config.static_renditions.includes('highest')}
|
|
118
|
+
onChange={() => toggleRendition('highest')}
|
|
119
|
+
/>
|
|
120
|
+
<Text as="label" htmlFor={`${id}--highest`}>
|
|
121
|
+
Highest Resolution (up to 4K)
|
|
122
|
+
</Text>
|
|
123
|
+
</Flex>
|
|
124
|
+
<Flex align="center" gap={2} padding={[0, 2]}>
|
|
125
|
+
<Checkbox
|
|
126
|
+
id={`${id}--audio-only-standard`}
|
|
127
|
+
style={{display: 'block'}}
|
|
128
|
+
checked={config.static_renditions.includes('audio-only')}
|
|
129
|
+
onChange={() => toggleRendition('audio-only')}
|
|
130
|
+
/>
|
|
131
|
+
<Text as="label" htmlFor={`${id}--audio-only-standard`}>
|
|
132
|
+
Audio Only (M4A)
|
|
133
|
+
</Text>
|
|
134
|
+
</Flex>
|
|
135
|
+
</Stack>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* Advanced Mode Options */}
|
|
139
|
+
{renditionMode === 'advanced' && (
|
|
140
|
+
<Stack space={2}>
|
|
141
|
+
<Label size={1} muted>
|
|
142
|
+
Select specific resolutions:
|
|
143
|
+
</Label>
|
|
144
|
+
<Flex gap={2} wrap="wrap">
|
|
145
|
+
{ADVANCED_RESOLUTIONS.map(({value, label}) => {
|
|
146
|
+
const inputId = `${id}--resolution-${value}`
|
|
147
|
+
return (
|
|
148
|
+
<Flex key={value} align="center" gap={2}>
|
|
149
|
+
<Checkbox
|
|
150
|
+
id={inputId}
|
|
151
|
+
style={{display: 'block'}}
|
|
152
|
+
checked={config.static_renditions.includes(value)}
|
|
153
|
+
onChange={() => toggleRendition(value)}
|
|
154
|
+
/>
|
|
155
|
+
<Text as="label" htmlFor={inputId} size={1}>
|
|
156
|
+
{label}
|
|
157
|
+
</Text>
|
|
158
|
+
</Flex>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</Flex>
|
|
162
|
+
<Flex align="center" gap={2} padding={[2, 2, 0, 2]}>
|
|
163
|
+
<Checkbox
|
|
164
|
+
id={`${id}--audio-only-advanced`}
|
|
165
|
+
style={{display: 'block'}}
|
|
166
|
+
checked={config.static_renditions.includes('audio-only')}
|
|
167
|
+
onChange={() => toggleRendition('audio-only')}
|
|
168
|
+
/>
|
|
169
|
+
<Text as="label" htmlFor={`${id}--audio-only-advanced`}>
|
|
170
|
+
Audio Only (M4A)
|
|
171
|
+
</Text>
|
|
172
|
+
</Flex>
|
|
173
|
+
</Stack>
|
|
174
|
+
)}
|
|
175
|
+
</Stack>
|
|
176
|
+
</FormField>
|
|
177
|
+
</Stack>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {Button, Card, Dialog, Stack, Text} from '@sanity/ui'
|
|
2
|
+
import React, {createContext, useContext, useState} from 'react'
|
|
3
|
+
|
|
4
|
+
import {PluginConfig} from '../util/types'
|
|
5
|
+
|
|
6
|
+
const LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY = 'mux-plugin-has-shown-drm-playback-warning'
|
|
7
|
+
|
|
8
|
+
type DrmPlaybackWarningContextContextProps = {
|
|
9
|
+
hasShownWarning: boolean
|
|
10
|
+
setHasWarnedAboutDrmPlayback: (b: boolean) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DrmPlaybackWarningContext = createContext<DrmPlaybackWarningContextContextProps>({
|
|
14
|
+
hasShownWarning: false,
|
|
15
|
+
setHasWarnedAboutDrmPlayback: () => {
|
|
16
|
+
return null
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
interface DrmPlaybackWarningContextProviderProps {
|
|
21
|
+
config?: PluginConfig
|
|
22
|
+
children: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DrmPlaybackWarningContextProvider = ({
|
|
26
|
+
config,
|
|
27
|
+
children,
|
|
28
|
+
}: DrmPlaybackWarningContextProviderProps) => {
|
|
29
|
+
const warningDisabled = config?.disableDrmPlaybackWarning ?? false
|
|
30
|
+
const hasWarned: boolean =
|
|
31
|
+
warningDisabled || window.localStorage.getItem(LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY) === 'true'
|
|
32
|
+
const [hasWarnedAboutDrmPlayback, setHasWarnedAboutDrmPlayback] = useState(hasWarned)
|
|
33
|
+
|
|
34
|
+
const setHasShownWarning = (b: boolean) => {
|
|
35
|
+
window.localStorage.setItem(LOCAL_STORAGE_HAS_SHOWN_WARNING_KEY, b.toString())
|
|
36
|
+
setHasWarnedAboutDrmPlayback(b)
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<DrmPlaybackWarningContext.Provider
|
|
40
|
+
value={{
|
|
41
|
+
hasShownWarning: hasWarnedAboutDrmPlayback,
|
|
42
|
+
setHasWarnedAboutDrmPlayback: setHasShownWarning,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</DrmPlaybackWarningContext.Provider>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const useDrmPlaybackWarningContext = () => {
|
|
51
|
+
const context = useContext(DrmPlaybackWarningContext)
|
|
52
|
+
return context
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DRMWarningDialog = ({onClose}: {onClose: () => void}) => {
|
|
56
|
+
const {setHasWarnedAboutDrmPlayback} = useDrmPlaybackWarningContext()
|
|
57
|
+
const _onClose = () => {
|
|
58
|
+
setHasWarnedAboutDrmPlayback(true)
|
|
59
|
+
onClose()
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<Dialog
|
|
63
|
+
open
|
|
64
|
+
id="drm-playback-warn"
|
|
65
|
+
onClose={_onClose}
|
|
66
|
+
header="DRM Playback Warning"
|
|
67
|
+
footer={
|
|
68
|
+
<Stack padding={3}>
|
|
69
|
+
<Button mode="ghost" tone="primary" onClick={_onClose} text="Ok" />
|
|
70
|
+
</Stack>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<Stack space={3} padding={3}>
|
|
74
|
+
<Card padding={[3, 3, 3]} radius={2}>
|
|
75
|
+
<Stack space={3}>
|
|
76
|
+
<Text size={1} weight="semibold">
|
|
77
|
+
DRM-protected playback will generate a license with a small associated cost. The
|
|
78
|
+
plugin will attempt to play signed or public playback IDs instead whenever possible.
|
|
79
|
+
</Text>
|
|
80
|
+
</Stack>
|
|
81
|
+
</Card>
|
|
82
|
+
<Card padding={[3, 3, 3]} radius={2} tone="suggest">
|
|
83
|
+
<Stack space={3}>
|
|
84
|
+
<Text size={1} weight="semibold">
|
|
85
|
+
This is a one time warning. If it persists, you can disable it from your plugin
|
|
86
|
+
configuration.
|
|
87
|
+
</Text>
|
|
88
|
+
</Stack>
|
|
89
|
+
</Card>
|
|
90
|
+
</Stack>
|
|
91
|
+
</Dialog>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {useState} from 'react'
|
|
4
4
|
|
|
5
|
-
export type DialogState = 'secrets' | 'select-video' | 'edit-thumbnail' | false
|
|
5
|
+
export type DialogState = 'secrets' | 'select-video' | 'edit-thumbnail' | 'edit-captions' | false
|
|
6
6
|
|
|
7
7
|
export function useDialogState() {
|
|
8
8
|
return useState<DialogState>(false)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react'
|
|
2
|
+
|
|
3
|
+
import {StagedUpload} from '../components/Uploader'
|
|
4
|
+
|
|
5
|
+
export function useFetchFileSize(stagedUpload: StagedUpload, maxFileSize?: number) {
|
|
6
|
+
const [fileSize, setFileSize] = useState<number | null>(null)
|
|
7
|
+
const [isLoadingFileSize, setIsLoadingFileSize] = useState(false)
|
|
8
|
+
const [canSkipFileSizeValidation, setCanSkipFileSizeValidation] = useState(false)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
// Fetch URL Upload file size
|
|
12
|
+
if (stagedUpload.type === 'url') {
|
|
13
|
+
setIsLoadingFileSize(false)
|
|
14
|
+
setCanSkipFileSizeValidation(false)
|
|
15
|
+
setFileSize(null)
|
|
16
|
+
const url = stagedUpload.url
|
|
17
|
+
|
|
18
|
+
// Get file size from URL
|
|
19
|
+
const fetchFileSize = async () => {
|
|
20
|
+
setIsLoadingFileSize(true)
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(url, {method: 'HEAD'})
|
|
23
|
+
const contentLength = response.headers.get('content-length')
|
|
24
|
+
const newFileSize = contentLength ? parseInt(contentLength, 10) : null
|
|
25
|
+
|
|
26
|
+
setIsLoadingFileSize(false)
|
|
27
|
+
if (newFileSize) {
|
|
28
|
+
setFileSize(newFileSize)
|
|
29
|
+
}
|
|
30
|
+
if (newFileSize === null && maxFileSize !== undefined) {
|
|
31
|
+
// Size unknown but size limit is configured - skip file size validation
|
|
32
|
+
setCanSkipFileSizeValidation(true)
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
console.warn('Could not validate file size from URL')
|
|
36
|
+
// Skip validation of file size, but still validate duration
|
|
37
|
+
setCanSkipFileSizeValidation(true)
|
|
38
|
+
setIsLoadingFileSize(false)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fetchFileSize()
|
|
43
|
+
}
|
|
44
|
+
if (stagedUpload.type === 'file') {
|
|
45
|
+
setFileSize(stagedUpload.files[0].size)
|
|
46
|
+
}
|
|
47
|
+
}, [maxFileSize, stagedUpload, stagedUpload.type])
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
fileSize,
|
|
51
|
+
isLoadingFileSize,
|
|
52
|
+
canSkipFileSizeValidation,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react'
|
|
2
|
+
|
|
3
|
+
import {StagedUpload} from '../components/Uploader'
|
|
4
|
+
|
|
5
|
+
export interface VideoAssetMetadata {
|
|
6
|
+
width?: number
|
|
7
|
+
height?: number
|
|
8
|
+
isAudioOnly?: boolean
|
|
9
|
+
duration?: number
|
|
10
|
+
size?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useMediaMetadata(stagedUpload: StagedUpload) {
|
|
14
|
+
const [videoAssetMetadata, setVideoAssetMetadata] = useState<VideoAssetMetadata | null>(null)
|
|
15
|
+
const [isLoadingMetadata, setIsLoadingMetadata] = useState(false)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let videoSrc = null
|
|
18
|
+
// Validate file uploads
|
|
19
|
+
if (stagedUpload.type === 'file') {
|
|
20
|
+
const file = stagedUpload.files[0]
|
|
21
|
+
videoSrc = URL.createObjectURL(file)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validate URL uploads
|
|
25
|
+
if (stagedUpload.type === 'url') {
|
|
26
|
+
videoSrc = stagedUpload.url
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setVideoAssetMetadata((old) => ({
|
|
30
|
+
...old,
|
|
31
|
+
duration: undefined,
|
|
32
|
+
width: undefined,
|
|
33
|
+
height: undefined,
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
if (!videoSrc) return () => null
|
|
37
|
+
|
|
38
|
+
setIsLoadingMetadata(true)
|
|
39
|
+
const videoElement = document.createElement('video')
|
|
40
|
+
videoElement.preload = 'metadata'
|
|
41
|
+
|
|
42
|
+
const metadataListeners = [
|
|
43
|
+
() => {
|
|
44
|
+
setIsLoadingMetadata(false)
|
|
45
|
+
},
|
|
46
|
+
() => {
|
|
47
|
+
const duration = videoElement.duration
|
|
48
|
+
const width = videoElement.videoWidth
|
|
49
|
+
const height = videoElement.videoHeight
|
|
50
|
+
const isAudioOnly = width <= 0 && height <= 0
|
|
51
|
+
setVideoAssetMetadata((old) => {
|
|
52
|
+
return {
|
|
53
|
+
...old,
|
|
54
|
+
duration: duration,
|
|
55
|
+
width: width,
|
|
56
|
+
height: height,
|
|
57
|
+
isAudioOnly: isAudioOnly,
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const cleanupVideo = (videoEl: HTMLVideoElement) => {
|
|
64
|
+
const currentVideoSrc = videoEl?.src
|
|
65
|
+
if (videoEl) {
|
|
66
|
+
metadataListeners.forEach((listener) =>
|
|
67
|
+
videoEl.removeEventListener('loadedmetadata', listener)
|
|
68
|
+
)
|
|
69
|
+
videoEl.onerror = null
|
|
70
|
+
videoEl.src = ''
|
|
71
|
+
videoEl.load()
|
|
72
|
+
}
|
|
73
|
+
if (currentVideoSrc?.startsWith('blob:')) {
|
|
74
|
+
URL.revokeObjectURL(currentVideoSrc)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
metadataListeners.push(() => setTimeout(() => cleanupVideo(videoElement), 0))
|
|
78
|
+
|
|
79
|
+
videoElement.onerror = () => {
|
|
80
|
+
setIsLoadingMetadata(false)
|
|
81
|
+
console.warn('Could not read video metadata for validation')
|
|
82
|
+
cleanupVideo(videoElement)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
metadataListeners.forEach((listener) =>
|
|
86
|
+
videoElement.addEventListener('loadedmetadata', listener)
|
|
87
|
+
)
|
|
88
|
+
videoElement.src = videoSrc
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
cleanupVideo(videoElement)
|
|
92
|
+
}
|
|
93
|
+
}, [stagedUpload.type, stagedUpload])
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
videoAssetMetadata,
|
|
97
|
+
setVideoAssetMetadata,
|
|
98
|
+
isLoadingMetadata,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -10,7 +10,11 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
|
|
|
10
10
|
token,
|
|
11
11
|
secretKey,
|
|
12
12
|
enableSignedUrls,
|
|
13
|
-
|
|
13
|
+
drmConfigId,
|
|
14
|
+
}: Pick<
|
|
15
|
+
Secrets,
|
|
16
|
+
'token' | 'secretKey' | 'enableSignedUrls' | 'drmConfigId'
|
|
17
|
+
>): Promise<Secrets> => {
|
|
14
18
|
let {signingKeyId, signingKeyPrivate} = secrets
|
|
15
19
|
|
|
16
20
|
try {
|
|
@@ -20,7 +24,8 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
|
|
|
20
24
|
secretKey!,
|
|
21
25
|
enableSignedUrls,
|
|
22
26
|
signingKeyId!,
|
|
23
|
-
signingKeyPrivate
|
|
27
|
+
signingKeyPrivate!,
|
|
28
|
+
drmConfigId!
|
|
24
29
|
)
|
|
25
30
|
const valid = await testSecrets(client)
|
|
26
31
|
if (!valid?.status && token && secretKey) {
|
|
@@ -49,7 +54,8 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
|
|
|
49
54
|
secretKey!,
|
|
50
55
|
enableSignedUrls,
|
|
51
56
|
signingKeyId,
|
|
52
|
-
signingKeyPrivate
|
|
57
|
+
signingKeyPrivate,
|
|
58
|
+
drmConfigId ?? ''
|
|
53
59
|
)
|
|
54
60
|
} catch (err: any) {
|
|
55
61
|
// eslint-disable-next-line no-console
|
|
@@ -64,6 +70,7 @@ export const useSaveSecrets = (client: SanityClient, secrets: Secrets) => {
|
|
|
64
70
|
enableSignedUrls,
|
|
65
71
|
signingKeyId,
|
|
66
72
|
signingKeyPrivate,
|
|
73
|
+
drmConfigId,
|
|
67
74
|
}
|
|
68
75
|
},
|
|
69
76
|
[client, secrets]
|
|
@@ -4,7 +4,14 @@ import {useDocumentValues} from 'sanity'
|
|
|
4
4
|
import {muxSecretsDocumentId} from '../util/constants'
|
|
5
5
|
import type {Secrets} from '../util/types'
|
|
6
6
|
|
|
7
|
-
const path = [
|
|
7
|
+
const path = [
|
|
8
|
+
'token',
|
|
9
|
+
'secretKey',
|
|
10
|
+
'enableSignedUrls',
|
|
11
|
+
'signingKeyId',
|
|
12
|
+
'signingKeyPrivate',
|
|
13
|
+
'drmConfigId',
|
|
14
|
+
]
|
|
8
15
|
export const useSecretsDocumentValues = () => {
|
|
9
16
|
const {error, isLoading, value} = useDocumentValues<Partial<Secrets> | null | undefined>(
|
|
10
17
|
muxSecretsDocumentId,
|
|
@@ -18,6 +25,7 @@ export const useSecretsDocumentValues = () => {
|
|
|
18
25
|
enableSignedUrls: value?.enableSignedUrls || false,
|
|
19
26
|
signingKeyId: value?.signingKeyId || null,
|
|
20
27
|
signingKeyPrivate: value?.signingKeyPrivate || null,
|
|
28
|
+
drmConfigId: value?.drmConfigId || null,
|
|
21
29
|
}
|
|
22
30
|
return {
|
|
23
31
|
isInitialSetup: !exists,
|
|
@@ -2,7 +2,8 @@ import {useReducer} from 'react'
|
|
|
2
2
|
|
|
3
3
|
import type {Secrets} from '../util/types'
|
|
4
4
|
|
|
5
|
-
export interface State
|
|
5
|
+
export interface State
|
|
6
|
+
extends Pick<Secrets, 'token' | 'secretKey' | 'enableSignedUrls' | 'drmConfigId'> {
|
|
6
7
|
submitting: boolean
|
|
7
8
|
error: string | null
|
|
8
9
|
}
|
|
@@ -13,7 +14,8 @@ export type Action =
|
|
|
13
14
|
| {type: 'change'; payload: {name: 'token'; value: string}}
|
|
14
15
|
| {type: 'change'; payload: {name: 'secretKey'; value: string}}
|
|
15
16
|
| {type: 'change'; payload: {name: 'enableSignedUrls'; value: boolean}}
|
|
16
|
-
|
|
17
|
+
| {type: 'change'; payload: {name: 'drmConfigId'; value: string}}
|
|
18
|
+
function init({token, secretKey, enableSignedUrls, drmConfigId}: Secrets): State {
|
|
17
19
|
return {
|
|
18
20
|
submitting: false,
|
|
19
21
|
error: null,
|
|
@@ -22,6 +24,7 @@ function init({token, secretKey, enableSignedUrls}: Secrets): State {
|
|
|
22
24
|
token: token ?? '',
|
|
23
25
|
secretKey: secretKey ?? '',
|
|
24
26
|
enableSignedUrls: enableSignedUrls ?? false,
|
|
27
|
+
drmConfigId: drmConfigId ?? '',
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
function reducer(state: State, action: Action) {
|
|
@@ -35,7 +38,7 @@ function reducer(state: State, action: Action) {
|
|
|
35
38
|
case 'change':
|
|
36
39
|
return {...state, [action.payload.name]: action.payload.value}
|
|
37
40
|
default:
|
|
38
|
-
throw new Error(`Unknown action type: ${(action as
|
|
41
|
+
throw new Error(`Unknown action type: ${(action as unknown as Action)?.type}`)
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
package/src/util/asserters.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {ServerError} from '@sanity/client'
|
|
1
2
|
import {type InputProps, isObjectInputProps, type PreviewLayoutKey, type PreviewProps} from 'sanity'
|
|
2
3
|
|
|
3
4
|
import type {MuxInputPreviewProps, MuxInputProps} from './types'
|
|
@@ -20,3 +21,16 @@ export function isValidUrl(url: string): boolean {
|
|
|
20
21
|
return false
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* We consider a server error one with status code 5XX.
|
|
27
|
+
* Used mainly to handle unknown Proxy issues.
|
|
28
|
+
*/
|
|
29
|
+
export function isServerError(error: Error): error is ServerError {
|
|
30
|
+
return (
|
|
31
|
+
'statusCode' in error &&
|
|
32
|
+
typeof error.statusCode === 'number' &&
|
|
33
|
+
500 <= error.statusCode &&
|
|
34
|
+
error.statusCode <= 600
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type {SanityClient} from 'sanity'
|
|
2
2
|
|
|
3
|
+
import {getPlaybackId} from '../util/getPlaybackPolicy'
|
|
3
4
|
import {Audience, generateJwt} from './generateJwt'
|
|
4
|
-
import {
|
|
5
|
-
import {getPlaybackPolicy} from './getPlaybackPolicy'
|
|
5
|
+
import {getPlaybackPolicyById} from './getPlaybackPolicy'
|
|
6
6
|
import type {AssetThumbnailOptions} from './types'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* May throw a Promise. Call this with {@link tryWithSuspend} or rethrow the Promise
|
|
10
|
+
*/
|
|
8
11
|
export function createUrlParamsObject(
|
|
9
12
|
client: SanityClient,
|
|
10
13
|
asset: AssetThumbnailOptions['asset'],
|
|
@@ -16,7 +19,8 @@ export function createUrlParamsObject(
|
|
|
16
19
|
let searchParams = new URLSearchParams(
|
|
17
20
|
JSON.parse(JSON.stringify(params, (_, v) => v ?? undefined))
|
|
18
21
|
)
|
|
19
|
-
|
|
22
|
+
const playbackPolicy = getPlaybackPolicyById(asset, playbackId)?.policy
|
|
23
|
+
if (playbackPolicy === 'signed' || playbackPolicy === 'drm') {
|
|
20
24
|
const token = generateJwt(client, playbackId, audience, params)
|
|
21
25
|
searchParams = new URLSearchParams({token})
|
|
22
26
|
}
|
package/src/util/generateJwt.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {suspend} from 'suspend-react'
|
|
|
4
4
|
import {readSecrets} from './readSecrets'
|
|
5
5
|
import type {AnimatedThumbnailOptions, ThumbnailOptions} from './types'
|
|
6
6
|
|
|
7
|
-
export type Audience = 'g' | 's' | 't' | 'v'
|
|
7
|
+
export type Audience = 'g' | 's' | 't' | 'v' | 'd'
|
|
8
8
|
|
|
9
9
|
export type Payload<T extends Audience> = T extends 'g'
|
|
10
10
|
? AnimatedThumbnailOptions
|
|
@@ -14,8 +14,13 @@ export type Payload<T extends Audience> = T extends 'g'
|
|
|
14
14
|
? ThumbnailOptions
|
|
15
15
|
: T extends 'v'
|
|
16
16
|
? never
|
|
17
|
-
:
|
|
17
|
+
: T extends 'd'
|
|
18
|
+
? never
|
|
19
|
+
: never
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Uses suspend. Call this with {@link tryWithSuspend} or rethrow the Promise
|
|
23
|
+
*/
|
|
19
24
|
export function generateJwt<T extends Audience>(
|
|
20
25
|
client: SanityClient,
|
|
21
26
|
playbackId: string,
|
|
@@ -30,6 +35,10 @@ export function generateJwt<T extends Audience>(
|
|
|
30
35
|
throw new TypeError("Missing `signingKeyPrivate`.\n Check your plugin's configuration")
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/* Using suspend means we need to use Suspense on parent components.
|
|
39
|
+
Also, this will throw a Promise under the hood (apparently common in React),
|
|
40
|
+
so if we want to catch errors we have to take this into account in catch blocks
|
|
41
|
+
and rethrow promises. */
|
|
33
42
|
// @ts-expect-error - handle missing typings for this package
|
|
34
43
|
const {default: sign} = suspend(() => import('jsonwebtoken-esm/sign'), ['jsonwebtoken-esm/sign'])
|
|
35
44
|
|