sanity-plugin-mux-input 2.2.4 → 2.3.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 (49) hide show
  1. package/README.md +148 -16
  2. package/lib/index.cjs +3996 -3677
  3. package/lib/index.cjs.map +1 -1
  4. package/lib/index.d.cts +210 -0
  5. package/lib/index.d.ts +109 -25
  6. package/lib/index.esm.js +4390 -0
  7. package/lib/index.esm.js.map +1 -0
  8. package/lib/index.js +3964 -3626
  9. package/lib/index.js.map +1 -1
  10. package/package.json +48 -52
  11. package/src/_exports/index.ts +32 -0
  12. package/src/actions/upload.ts +35 -40
  13. package/src/clients/upChunkObservable.ts +5 -1
  14. package/src/components/ConfigureApi.tsx +0 -1
  15. package/src/components/FileInputArea.tsx +92 -0
  16. package/src/components/FileInputButton.tsx +3 -2
  17. package/src/components/FileInputMenuItem.styled.tsx +2 -2
  18. package/src/components/FileInputMenuItem.tsx +2 -10
  19. package/src/components/ImportVideosFromMux.tsx +317 -0
  20. package/src/components/Input.tsx +3 -3
  21. package/src/components/PlayerActionsMenu.tsx +14 -12
  22. package/src/components/SelectAsset.tsx +1 -1
  23. package/src/components/StudioTool.tsx +11 -6
  24. package/src/components/TextTracksEditor.tsx +214 -0
  25. package/src/components/UploadConfiguration.tsx +390 -0
  26. package/src/components/UploadPlaceholder.tsx +41 -55
  27. package/src/components/Uploader.styled.tsx +0 -1
  28. package/src/components/Uploader.tsx +384 -0
  29. package/src/components/VideoDetails/DeleteDialog.tsx +20 -24
  30. package/src/components/VideoPlayer.tsx +33 -5
  31. package/src/components/VideoThumbnail.tsx +21 -7
  32. package/src/components/VideosBrowser.tsx +6 -3
  33. package/src/components/withFocusRing/withFocusRing.ts +20 -22
  34. package/src/hooks/useClient.ts +1 -1
  35. package/src/hooks/useImportMuxAssets.ts +127 -0
  36. package/src/hooks/useMuxAssets.ts +168 -0
  37. package/src/plugin.tsx +5 -5
  38. package/src/util/asserters.ts +9 -0
  39. package/src/util/createSearchFilter.ts +1 -1
  40. package/src/util/formatBytes.ts +32 -0
  41. package/src/util/generateJwt.ts +1 -0
  42. package/src/util/getAnimatedPosterSrc.ts +1 -1
  43. package/src/util/getPlaybackId.ts +1 -1
  44. package/src/util/getPlaybackPolicy.ts +1 -1
  45. package/src/util/parsers.ts +5 -0
  46. package/src/util/types.ts +195 -12
  47. package/lib/index.cjs.js +0 -5
  48. package/src/components/__legacy__Uploader.tsx +0 -280
  49. package/src/index.ts +0 -29
@@ -0,0 +1,390 @@
1
+ import {DocumentVideoIcon, UploadIcon} from '@sanity/icons'
2
+ import {Button, Card, Checkbox, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui'
3
+ import {uuid} from '@sanity/uuid'
4
+ import LanguagesList from 'iso-639-1'
5
+ import {useEffect, useId, useReducer, useRef} from 'react'
6
+ import {FormField} from 'sanity'
7
+
8
+ import formatBytes from '../util/formatBytes'
9
+ import {
10
+ type AutogeneratedTextTrack,
11
+ type CustomTextTrack,
12
+ isAutogeneratedTrack,
13
+ isCustomTextTrack,
14
+ type MuxNewAssetSettings,
15
+ type PluginConfig,
16
+ type Secrets,
17
+ type SupportedMuxLanguage,
18
+ type UploadConfig,
19
+ type UploadTextTrack,
20
+ } from '../util/types'
21
+ import TextTracksEditor, {type TrackAction} from './TextTracksEditor'
22
+ import type {StagedUpload} from './Uploader'
23
+
24
+ export type UploadConfigurationStateAction =
25
+ | {action: 'encoding_tier'; value: UploadConfig['encoding_tier']}
26
+ | {action: 'max_resolution_tier'; value: UploadConfig['max_resolution_tier']}
27
+ | {action: 'mp4_support'; value: UploadConfig['mp4_support']}
28
+ | {action: 'normalize_audio'; value: UploadConfig['normalize_audio']}
29
+ | {action: 'signed'; value: UploadConfig['signed']}
30
+ | TrackAction
31
+
32
+ const ENCODING_OPTIONS = [
33
+ {value: 'smart', label: 'Smart'},
34
+ {value: 'baseline', label: 'Baseline'},
35
+ ] as const satisfies {value: UploadConfig['encoding_tier']; label: string}[]
36
+
37
+ const RESOLUTION_TIERS = [
38
+ {value: '1080p', label: '1080p'},
39
+ {value: '1440p', label: '1440p (2k)'},
40
+ {value: '2160p', label: '2160p (4k)'},
41
+ ] as const satisfies {value: UploadConfig['max_resolution_tier']; label: string}[]
42
+
43
+ /**
44
+ * The modal for configuring a staged upload. Handles triggering of the asset
45
+ * upload, even if no modal needs to be shown.
46
+ *
47
+ * @returns
48
+ */
49
+ export default function UploadConfiguration({
50
+ stagedUpload,
51
+ secrets,
52
+ pluginConfig,
53
+ startUpload,
54
+ onClose,
55
+ }: {
56
+ stagedUpload: StagedUpload
57
+ secrets: Secrets
58
+ pluginConfig: PluginConfig
59
+ startUpload: (settings: MuxNewAssetSettings) => void
60
+ onClose: () => void
61
+ }) {
62
+ const id = useId()
63
+ const autoTextTracks = useRef<NonNullable<UploadConfig['text_tracks']>>(
64
+ (pluginConfig.encoding_tier === 'smart' &&
65
+ pluginConfig.defaultAutogeneratedSubtitleLangs?.map(
66
+ (language_code) =>
67
+ ({
68
+ _id: uuid(),
69
+ type: 'autogenerated',
70
+ language_code,
71
+ name: LanguagesList.getNativeName(language_code),
72
+ }) satisfies AutogeneratedTextTrack
73
+ )) ||
74
+ []
75
+ ).current
76
+
77
+ const [config, dispatch] = useReducer(
78
+ (prev: UploadConfig, action: UploadConfigurationStateAction) => {
79
+ switch (action.action) {
80
+ case 'encoding_tier':
81
+ // If encoding tier switches to baseline, remove smart-only features
82
+ if (action.value === 'baseline') {
83
+ return Object.assign({}, prev, {
84
+ encoding_tier: action.value,
85
+ mp4_support: 'none',
86
+ max_resolution_tier: '1080p',
87
+ text_tracks: prev.text_tracks?.filter(({type}) => type !== 'autogenerated'),
88
+ })
89
+ // If encoding tier switches to smart, add back in default smart features
90
+ }
91
+ return Object.assign({}, prev, {
92
+ encoding_tier: action.value,
93
+ mp4_support: pluginConfig.mp4_support,
94
+ max_resolution_tier: pluginConfig.max_resolution_tier,
95
+ text_tracks: [...autoTextTracks, ...(prev.text_tracks || [])],
96
+ })
97
+
98
+ case 'mp4_support':
99
+ case 'max_resolution_tier':
100
+ case 'normalize_audio':
101
+ case 'signed':
102
+ return Object.assign({}, prev, {[action.action]: action.value})
103
+ // Updating individual tracks
104
+ case 'track': {
105
+ const text_tracks = [...prev.text_tracks]
106
+ const target_track_i = text_tracks.findIndex(({_id}) => _id === action.id)
107
+ // eslint-disable-next-line default-case
108
+ switch (action.subAction) {
109
+ case 'add':
110
+ // Exit early if track already exists
111
+ if (target_track_i !== -1) break
112
+ text_tracks.push(
113
+ (prev.encoding_tier === 'smart'
114
+ ? {_id: action.id, type: 'autogenerated'}
115
+ : {_id: action.id, type: 'subtitles'}) as UploadTextTrack
116
+ )
117
+ break
118
+ case 'update':
119
+ if (target_track_i === -1) break
120
+ text_tracks[target_track_i] = {
121
+ ...text_tracks[target_track_i],
122
+ ...action.value,
123
+ } as UploadTextTrack
124
+ break
125
+ case 'delete':
126
+ if (target_track_i === -1) break
127
+ text_tracks.splice(target_track_i, 1)
128
+ break
129
+ }
130
+ return Object.assign({}, prev, {text_tracks})
131
+ }
132
+ default:
133
+ return prev
134
+ }
135
+ },
136
+ {
137
+ encoding_tier: pluginConfig.encoding_tier,
138
+ max_resolution_tier: pluginConfig.max_resolution_tier,
139
+ mp4_support: pluginConfig.mp4_support,
140
+ signed: secrets.enableSignedUrls && pluginConfig.defaultSigned,
141
+ normalize_audio: pluginConfig.normalize_audio,
142
+ text_tracks: autoTextTracks,
143
+ } as UploadConfig
144
+ )
145
+
146
+ // If user-provided config is disabled, begin the upload immediately with
147
+ // the developer-specified values from the schema or config or defaults.
148
+ // This can include auto-generated subtitles!
149
+ const {disableTextTrackConfig, disableUploadConfig} = pluginConfig
150
+ const skipConfig = disableTextTrackConfig && disableUploadConfig
151
+ useEffect(() => {
152
+ if (skipConfig) startUpload(formatUploadConfig(config))
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [])
155
+ if (skipConfig) return null
156
+
157
+ const maxSupportedResolution = RESOLUTION_TIERS.findIndex(
158
+ (rt) => rt.value === pluginConfig.max_resolution_tier
159
+ )
160
+ return (
161
+ <Dialog
162
+ open
163
+ id="upload-configuration"
164
+ zOffset={1000}
165
+ width={1}
166
+ header="Configure Mux Upload"
167
+ onClose={onClose}
168
+ >
169
+ <Stack padding={4} space={2}>
170
+ <Label size={3}>FILE TO UPLOAD</Label>
171
+ <Card
172
+ tone="transparent"
173
+ border
174
+ padding={3}
175
+ paddingY={4}
176
+ style={{borderRadius: '0.1865rem'}}
177
+ >
178
+ <Flex gap={2}>
179
+ <DocumentVideoIcon fontSize="2em" />
180
+ <Stack space={2}>
181
+ <Text textOverflow="ellipsis" as="h2" size={3}>
182
+ {stagedUpload.type === 'file' ? stagedUpload.files[0].name : stagedUpload.url}
183
+ </Text>
184
+ <Text as="p" size={1} muted>
185
+ {stagedUpload.type === 'file'
186
+ ? `Direct File Upload (${formatBytes(stagedUpload.files[0].size)})`
187
+ : 'File From URL (Unknown size)'}
188
+ </Text>
189
+ </Stack>
190
+ </Flex>
191
+ </Card>
192
+ {!disableUploadConfig && (
193
+ <Stack space={3} paddingBottom={2}>
194
+ <FormField
195
+ title="Encoding Tier"
196
+ description={
197
+ <>
198
+ The encoding tier informs the cost, quality, and available platform features for
199
+ the asset.{' '}
200
+ <a
201
+ href="https://docs.mux.com/guides/use-encoding-tiers"
202
+ target="_blank"
203
+ rel="noopener noreferrer"
204
+ >
205
+ See the Mux guide for more details.
206
+ </a>
207
+ </>
208
+ }
209
+ >
210
+ <Flex gap={3}>
211
+ {ENCODING_OPTIONS.map(({value, label}) => {
212
+ const inputId = `${id}--encodingtier-${value}`
213
+ return (
214
+ <Flex key={value} align="center" gap={2}>
215
+ <Radio
216
+ checked={config.encoding_tier === value}
217
+ name="asset-encodingtier"
218
+ onChange={(e) =>
219
+ dispatch({
220
+ action: 'encoding_tier' as const,
221
+ value: e.currentTarget.value as UploadConfig['encoding_tier'],
222
+ })
223
+ }
224
+ value={value}
225
+ id={inputId}
226
+ />
227
+ <Text as="label" htmlFor={inputId}>
228
+ {label}
229
+ </Text>
230
+ </Flex>
231
+ )
232
+ })}
233
+ </Flex>
234
+ </FormField>
235
+
236
+ {config.encoding_tier === 'smart' && maxSupportedResolution > 0 && (
237
+ <FormField
238
+ title="Resolution Tier"
239
+ description={
240
+ <>
241
+ The maximum{' '}
242
+ <a
243
+ href="https://docs.mux.com/api-reference#video/operation/create-direct-upload"
244
+ target="_blank"
245
+ rel="noopener noreferrer"
246
+ >
247
+ resolution_tier
248
+ </a>{' '}
249
+ your asset is encoded, stored, and streamed at.
250
+ </>
251
+ }
252
+ >
253
+ <Flex gap={3} wrap={'wrap'}>
254
+ {RESOLUTION_TIERS.map(({value, label}, index) => {
255
+ const inputId = `${id}--type-${value}`
256
+
257
+ if (index > maxSupportedResolution) return null
258
+
259
+ return (
260
+ <Flex key={value} align="center" gap={2}>
261
+ <Radio
262
+ checked={config.max_resolution_tier === value}
263
+ name="asset-resolutiontier"
264
+ onChange={(e) =>
265
+ dispatch({
266
+ action: 'max_resolution_tier',
267
+ value: e.currentTarget.value as UploadConfig['max_resolution_tier'],
268
+ })
269
+ }
270
+ value={value}
271
+ id={inputId}
272
+ />
273
+ <Text as="label" htmlFor={inputId}>
274
+ {label}
275
+ </Text>
276
+ </Flex>
277
+ )
278
+ })}
279
+ </Flex>
280
+ </FormField>
281
+ )}
282
+
283
+ {(secrets.enableSignedUrls || config.encoding_tier === 'smart') && (
284
+ <FormField title="Additional Configuration">
285
+ <Stack space={2}>
286
+ {secrets.enableSignedUrls && (
287
+ <Flex align="center" gap={2}>
288
+ <Checkbox
289
+ id={`${id}--signed`}
290
+ style={{display: 'block'}}
291
+ name="signed"
292
+ required
293
+ checked={config.signed}
294
+ onChange={(e) =>
295
+ dispatch({
296
+ action: 'signed',
297
+ value: e.currentTarget.checked,
298
+ })
299
+ }
300
+ />
301
+ <Text>
302
+ <label htmlFor={`${id}--signed`}>Signed playback URL</label>
303
+ </Text>
304
+ </Flex>
305
+ )}
306
+ {config.encoding_tier === 'smart' && (
307
+ <Flex align="center" gap={2}>
308
+ <Checkbox
309
+ id={`${id}--mp4_support`}
310
+ style={{display: 'block'}}
311
+ name="mp4_support"
312
+ required
313
+ checked={config.mp4_support === 'standard'}
314
+ onChange={(e) =>
315
+ dispatch({
316
+ action: 'mp4_support',
317
+ value: e.currentTarget.checked ? 'standard' : 'none',
318
+ })
319
+ }
320
+ />
321
+ <Text>
322
+ <label htmlFor={`${id}--mp4_support`}>
323
+ MP4 support (allow downloading)
324
+ </label>
325
+ </Text>
326
+ </Flex>
327
+ )}
328
+ </Stack>
329
+ </FormField>
330
+ )}
331
+ </Stack>
332
+ )}
333
+
334
+ {!disableTextTrackConfig && (
335
+ <TextTracksEditor
336
+ canAutoGenerate={config.encoding_tier === 'smart'}
337
+ tracks={config.text_tracks}
338
+ dispatch={dispatch}
339
+ />
340
+ )}
341
+
342
+ <Button
343
+ icon={UploadIcon}
344
+ text="Upload"
345
+ tone="positive"
346
+ onClick={() => startUpload(formatUploadConfig(config))}
347
+ />
348
+ </Stack>
349
+ </Dialog>
350
+ )
351
+ }
352
+
353
+ function formatUploadConfig(config: UploadConfig): MuxNewAssetSettings {
354
+ const generated_subtitles = config.text_tracks
355
+ .filter<AutogeneratedTextTrack>(isAutogeneratedTrack)
356
+ .map<{name: string; language_code: SupportedMuxLanguage}>((track) => ({
357
+ name: track.name,
358
+ language_code: track.language_code,
359
+ }))
360
+
361
+ return {
362
+ input: [
363
+ {
364
+ type: 'video',
365
+ generated_subtitles: generated_subtitles.length > 0 ? generated_subtitles : undefined,
366
+ },
367
+ ...config.text_tracks.filter<CustomTextTrack>(isCustomTextTrack).reduce(
368
+ (acc, track) => {
369
+ if (track.language_code && track.file && track.name) {
370
+ acc.push({
371
+ url: track.file.contents,
372
+ type: 'text',
373
+ text_type: track.type === 'subtitles' ? 'subtitles' : undefined,
374
+ language_code: track.language_code,
375
+ name: track.name,
376
+ closed_captions: track.type === 'captions',
377
+ })
378
+ }
379
+ return acc
380
+ },
381
+ [] as NonNullable<MuxNewAssetSettings['input']>
382
+ ),
383
+ ],
384
+ mp4_support: config.mp4_support,
385
+ playback_policy: config.signed ? ['public', 'signed'] : ['public'],
386
+ max_resolution_tier: config.max_resolution_tier,
387
+ encoding_tier: config.encoding_tier,
388
+ normalize_audio: config.normalize_audio,
389
+ }
390
+ }
@@ -7,18 +7,6 @@ import styled from 'styled-components'
7
7
  import type {SetDialogState} from '../hooks/useDialogState'
8
8
  import {FileInputButton, type FileInputButtonProps} from './FileInputButton'
9
9
 
10
- const UploadCard = styled(Card)`
11
- && {
12
- border-style: dashed;
13
- }
14
- `
15
-
16
- const ConfigureApiBox = styled(Box)`
17
- position: absolute;
18
- top: 0;
19
- right: 0;
20
- `
21
-
22
10
  interface UploadPlaceholderProps {
23
11
  setDialogState: SetDialogState
24
12
  readOnly: boolean
@@ -32,16 +20,45 @@ export default function UploadPlaceholder(props: UploadPlaceholderProps) {
32
20
  const handleConfigureApi = useCallback(() => setDialogState('secrets'), [setDialogState])
33
21
 
34
22
  return (
35
- <Box style={{padding: 1, position: 'relative'}} height="stretch">
36
- <UploadCard
23
+ <Card
24
+ sizing="border"
25
+ tone={readOnly ? 'transparent' : 'inherit'}
26
+ border
27
+ radius={2}
28
+ paddingX={3}
29
+ paddingY={1}
30
+ style={hovering ? {borderColor: 'transparent'} : undefined}
31
+ >
32
+ <Flex
33
+ align="center"
34
+ justify="space-between"
35
+ gap={4}
36
+ direction={['column', 'column', 'row']}
37
+ paddingY={2}
37
38
  sizing="border"
38
- height="fill"
39
- tone={readOnly ? 'transparent' : 'inherit'}
40
- border
41
- padding={3}
42
- style={hovering ? {borderColor: 'transparent'} : undefined}
43
39
  >
44
- <ConfigureApiBox padding={3}>
40
+ <Flex align="center" justify="flex-start" gap={2} flex={1}>
41
+ <Flex justify="center">
42
+ <Text muted>
43
+ <DocumentVideoIcon />
44
+ </Text>
45
+ </Flex>
46
+ <Flex justify="center">
47
+ <Text size={1} muted>
48
+ Drag video or paste URL here
49
+ </Text>
50
+ </Flex>
51
+ </Flex>
52
+ <Inline space={2}>
53
+ <FileInputButton
54
+ mode="bleed"
55
+ tone="default"
56
+ icon={UploadIcon}
57
+ text="Upload"
58
+ onSelect={onSelect}
59
+ />
60
+ <Button mode="bleed" icon={SearchIcon} text="Select" onClick={handleBrowse} />
61
+
45
62
  <Button
46
63
  padding={3}
47
64
  radius={3}
@@ -49,41 +66,10 @@ export default function UploadPlaceholder(props: UploadPlaceholderProps) {
49
66
  onClick={handleConfigureApi}
50
67
  icon={PlugIcon}
51
68
  mode="bleed"
69
+ title="Configure plugin credentials"
52
70
  />
53
- </ConfigureApiBox>
54
- <Flex
55
- align="center"
56
- justify="space-between"
57
- gap={4}
58
- direction={['column', 'column', 'row']}
59
- paddingY={[2, 2, 0]}
60
- sizing="border"
61
- height="fill"
62
- >
63
- <Flex align="center" justify="center" gap={2} flex={1}>
64
- <Flex justify="center">
65
- <Text muted>
66
- <DocumentVideoIcon />
67
- </Text>
68
- </Flex>
69
- <Flex justify="center">
70
- <Text size={1} muted>
71
- Drag video or paste URL here
72
- </Text>
73
- </Flex>
74
- </Flex>
75
- <Inline space={2}>
76
- <FileInputButton
77
- mode="ghost"
78
- tone="default"
79
- icon={UploadIcon}
80
- text="Upload"
81
- onSelect={onSelect}
82
- />
83
- <Button mode="ghost" icon={SearchIcon} text="Select" onClick={handleBrowse} />
84
- </Inline>
85
- </Flex>
86
- </UploadCard>
87
- </Box>
71
+ </Inline>
72
+ </Flex>
73
+ </Card>
88
74
  )
89
75
  }
@@ -41,7 +41,6 @@ export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps>(
41
41
  return (
42
42
  <UploadCardWithFocusRing
43
43
  tone={tone}
44
- height="fill"
45
44
  ref={forwardedRef}
46
45
  padding={0}
47
46
  radius={2}