sanity-plugin-mux-input 2.12.1 → 2.14.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.
Files changed (35) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +133 -0
  3. package/dist/index.d.mts +63 -1
  4. package/dist/index.d.ts +63 -1
  5. package/dist/index.js +1564 -153
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1565 -154
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +1 -1
  10. package/src/_exports/index.ts +1 -0
  11. package/src/actions/assets.ts +75 -0
  12. package/src/components/AddCaptionDialog.tsx +421 -0
  13. package/src/components/CaptionsDialog.tsx +23 -0
  14. package/src/components/EditCaptionDialog.tsx +508 -0
  15. package/src/components/FileInputButton.tsx +2 -2
  16. package/src/components/Onboard.tsx +2 -2
  17. package/src/components/PageSelector.tsx +57 -0
  18. package/src/components/Player.tsx +4 -3
  19. package/src/components/PlayerActionsMenu.tsx +17 -8
  20. package/src/components/TextTracksManager.tsx +781 -0
  21. package/src/components/UploadConfiguration.tsx +181 -4
  22. package/src/components/UploadPlaceholder.tsx +14 -6
  23. package/src/components/Uploader.styled.tsx +8 -15
  24. package/src/components/Uploader.tsx +61 -6
  25. package/src/components/VideoDetails/VideoDetails.tsx +16 -0
  26. package/src/components/VideoInBrowser.tsx +2 -7
  27. package/src/components/VideoPlayer.tsx +35 -6
  28. package/src/components/VideosBrowser.tsx +9 -1
  29. package/src/components/icons/Audio.tsx +13 -0
  30. package/src/hooks/useAccessControl.ts +1 -0
  31. package/src/hooks/useDialogState.ts +1 -1
  32. package/src/util/getVideoMetadata.ts +3 -1
  33. package/src/util/textTracks.ts +219 -0
  34. package/src/util/types.ts +56 -3
  35. package/src/components/FileInputArea.tsx +0 -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)
@@ -1,5 +1,5 @@
1
1
  import {formatSeconds} from './formatSeconds'
2
- import {VideoAssetDocument} from './types'
2
+ import type {MuxTextTrack, VideoAssetDocument} from './types'
3
3
 
4
4
  export default function getVideoMetadata(doc: VideoAssetDocument) {
5
5
  const id = doc.assetId || doc._id || ''
@@ -16,5 +16,7 @@ export default function getVideoMetadata(doc: VideoAssetDocument) {
16
16
  aspect_ratio: doc.data?.aspect_ratio,
17
17
  max_stored_resolution: doc.data?.max_stored_resolution,
18
18
  max_stored_frame_rate: doc.data?.max_stored_frame_rate,
19
+ text_tracks:
20
+ doc.data?.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') || [],
19
21
  }
20
22
  }
@@ -0,0 +1,219 @@
1
+ import type {SanityClient} from '@sanity/client'
2
+
3
+ import {getAsset} from '../actions/assets'
4
+ import {generateJwt} from './generateJwt'
5
+ import {getPlaybackId} from './getPlaybackId'
6
+ import {getPlaybackPolicy} from './getPlaybackPolicy'
7
+ import type {MuxTextTrack, VideoAssetDocument} from './types'
8
+
9
+ export function extractErrorMessage(
10
+ error: unknown,
11
+ defaultMessage = 'Failed to process request'
12
+ ): string {
13
+ let message = ''
14
+
15
+ if (error && typeof error === 'object') {
16
+ const err = error as {response?: {body?: {message?: string}}; message?: string}
17
+ message = err.response?.body?.message || err.message || ''
18
+ } else if (typeof error === 'string') {
19
+ message = error
20
+ }
21
+
22
+ if (!message) {
23
+ return defaultMessage
24
+ }
25
+
26
+ const match = message.match(/\(([^)]+)\)/)
27
+ if (match && match[1]) {
28
+ return match[1]
29
+ }
30
+
31
+ if (message.includes('responded with')) {
32
+ const parts = message.split('(')
33
+ if (parts.length > 1) {
34
+ return parts[parts.length - 1].replace(')', '').trim()
35
+ }
36
+ }
37
+
38
+ return message
39
+ }
40
+
41
+ export interface PollTrackStatusOptions {
42
+ client: SanityClient
43
+ assetId: string
44
+ trackName: string
45
+ trackLanguageCode: string
46
+ maxAttempts?: number
47
+ onTrackFound?: (track: MuxTextTrack) => void
48
+ onTrackErrored?: (track: MuxTextTrack) => void
49
+ onTrackReady?: (track: MuxTextTrack) => void
50
+ }
51
+
52
+ export interface PollTrackStatusResult {
53
+ track: MuxTextTrack | undefined
54
+ found: boolean
55
+ status: 'ready' | 'preparing' | 'errored' | 'not-found'
56
+ }
57
+
58
+ /**
59
+ * Polls Mux API to find and track the status of a newly added text track.
60
+ * The track may be in "preparing" state initially, then become "ready" or "errored".
61
+ *
62
+ * @param options - Configuration options for polling
63
+ * @returns Promise resolving to the poll result
64
+ */
65
+ export async function pollTrackStatus(
66
+ options: PollTrackStatusOptions
67
+ ): Promise<PollTrackStatusResult> {
68
+ const {
69
+ client,
70
+ assetId,
71
+ trackName,
72
+ trackLanguageCode,
73
+ maxAttempts = 10,
74
+ onTrackFound,
75
+ onTrackErrored,
76
+ onTrackReady,
77
+ } = options
78
+
79
+ const trimmedName = trackName.trim()
80
+ const trimmedLanguageCode = trackLanguageCode.trim()
81
+ let newTrack: MuxTextTrack | undefined
82
+ let attempts = 0
83
+ let trackFound = false
84
+
85
+ const findTrack = (textTracks: MuxTextTrack[]): MuxTextTrack | undefined => {
86
+ let foundTrack = textTracks.find(
87
+ (track) => track.name === trimmedName && track.language_code === trimmedLanguageCode
88
+ )
89
+
90
+ if (!foundTrack) {
91
+ foundTrack = textTracks.find((track) => track.language_code === trimmedLanguageCode)
92
+ }
93
+
94
+ if (!foundTrack && textTracks.length > 0) {
95
+ foundTrack = textTracks[textTracks.length - 1]
96
+ }
97
+
98
+ return foundTrack
99
+ }
100
+
101
+ while (attempts < maxAttempts) {
102
+ try {
103
+ if (attempts > 0) {
104
+ await new Promise((resolve) => setTimeout(resolve, 1000))
105
+ }
106
+
107
+ const assetData = await getAsset(client, assetId)
108
+ const textTracks =
109
+ assetData.data.tracks?.filter((track): track is MuxTextTrack => track.type === 'text') || []
110
+
111
+ const foundTrack = findTrack(textTracks)
112
+
113
+ if (!foundTrack) {
114
+ attempts++
115
+ continue
116
+ }
117
+
118
+ trackFound = true
119
+ newTrack = foundTrack
120
+
121
+ if (onTrackFound) {
122
+ onTrackFound(foundTrack)
123
+ }
124
+
125
+ if (foundTrack.status === 'ready') {
126
+ if (onTrackReady) {
127
+ onTrackReady(foundTrack)
128
+ }
129
+ break
130
+ }
131
+
132
+ if (foundTrack.status === 'errored') {
133
+ if (onTrackErrored) {
134
+ onTrackErrored(foundTrack)
135
+ }
136
+ return {
137
+ track: foundTrack,
138
+ found: true,
139
+ status: 'errored',
140
+ }
141
+ }
142
+ } catch (error) {
143
+ console.error('Failed to fetch updated asset:', error)
144
+ }
145
+
146
+ attempts++
147
+ }
148
+
149
+ if (!newTrack || !trackFound) {
150
+ return {
151
+ track: undefined,
152
+ found: false,
153
+ status: 'not-found',
154
+ }
155
+ }
156
+
157
+ if (newTrack.status === 'preparing') {
158
+ return {
159
+ track: newTrack,
160
+ found: true,
161
+ status: 'preparing',
162
+ }
163
+ }
164
+
165
+ return {
166
+ track: newTrack,
167
+ found: true,
168
+ status: 'ready',
169
+ }
170
+ }
171
+
172
+ export async function downloadVttFile(
173
+ client: SanityClient,
174
+ asset: VideoAssetDocument,
175
+ track: MuxTextTrack
176
+ ): Promise<void> {
177
+ if (!track.id) {
178
+ throw new Error('Track ID is missing')
179
+ }
180
+
181
+ if (track.status !== 'ready') {
182
+ throw new Error(`Track is not ready yet. Status: ${track.status}`)
183
+ }
184
+
185
+ if (!asset.assetId) {
186
+ throw new Error('Asset ID is required')
187
+ }
188
+
189
+ const playbackId = getPlaybackId(asset)
190
+ if (!playbackId) {
191
+ throw new Error('Playback ID is required')
192
+ }
193
+
194
+ const playbackPolicy = getPlaybackPolicy(asset)
195
+
196
+ let downloadUrl = `https://stream.mux.com/${playbackId}/text/${track.id}.vtt`
197
+
198
+ if (playbackPolicy === 'signed') {
199
+ const token = generateJwt(client, playbackId, 'v')
200
+ downloadUrl += `?token=${token}`
201
+ }
202
+
203
+ const response = await fetch(downloadUrl)
204
+ if (!response.ok) {
205
+ throw new Error(`Failed to download file: ${response.statusText}`)
206
+ }
207
+
208
+ const blob = await response.blob()
209
+ const blobUrl = URL.createObjectURL(blob)
210
+
211
+ const link = document.createElement('a')
212
+ link.href = blobUrl
213
+ link.download = `${asset.filename || 'captions'}-${track.language_code || 'en'}.vtt`
214
+ document.body.appendChild(link)
215
+ link.click()
216
+ document.body.removeChild(link)
217
+
218
+ URL.revokeObjectURL(blobUrl)
219
+ }
package/src/util/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type MuxPlayerElement from '@mux/mux-player'
1
2
  import type {ObjectInputProps, PreviewLayoutKey, PreviewProps, SchemaType} from 'sanity'
2
3
  import type {PartialDeep} from 'type-fest'
3
4
 
@@ -122,6 +123,50 @@ export interface MuxInputConfig {
122
123
  * @defaultValue false
123
124
  */
124
125
  disableTextTrackConfig?: boolean
126
+
127
+ /**
128
+ * The mime types that are accepted by the input.
129
+ *
130
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept}
131
+ * @defaultValue ['video/*','audio/*']
132
+
133
+ */
134
+ acceptedMimeTypes?: ('audio/*' | 'video/*')[]
135
+
136
+ /**
137
+ * Maximum file size allowed for video uploads in bytes.
138
+ * If not specified, no file size validation will be performed.
139
+ *
140
+ * @example 1024 * 1024 * 1024 // 1 GB
141
+ * @defaultValue undefined
142
+ */
143
+ maxAssetFileSize?: number
144
+
145
+ /**
146
+ * Maximum video duration allowed in seconds.
147
+ * If not specified, no duration validation will be performed.
148
+ *
149
+ * @example 2 * 60 * 60 // 2 hours
150
+ * @defaultValue undefined
151
+ */
152
+ maxAssetDuration?: number
153
+
154
+ /**
155
+ * HLS.js configuration options to be passed to the Mux Player.
156
+ * These options allow you to customize the underlying HLS.js playback engine behavior.
157
+ *
158
+ * @see {@link https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning}
159
+ * @defaultValue undefined
160
+ * @example
161
+ * ```ts
162
+ * {
163
+ * maxBufferLength: 30,
164
+ * lowLatencyMode: true,
165
+ * capLevelToPlayerSize: true
166
+ * }
167
+ * ```
168
+ */
169
+ hlsConfig?: MuxPlayerElement['_hlsConfig']
125
170
  }
126
171
 
127
172
  export interface PluginConfig extends MuxInputConfig {
@@ -258,7 +303,6 @@ export interface MuxNewAssetSettings
258
303
  name?: string
259
304
  /** Indicates the track provides Subtitles for the Deaf or Hard-of-hearing (SDH). */
260
305
  closed_captions?: boolean
261
- /// @TODO Huhh?>?? Below
262
306
  /** This optional parameter should be used tracks with type of text and text_type set to subtitles. */
263
307
  passthrough?: string
264
308
  }[]
@@ -356,7 +400,12 @@ export interface MuxTextTrack {
356
400
  id: string
357
401
  text_type?: 'subtitles'
358
402
  // https://docs.mux.com/api-reference/video#operation/list-assets:~:text=text%20type%20tracks.-,tracks%5B%5D.,text_source,-string
359
- text_source?: 'uploaded' | 'embedded' | 'generated_live' | 'generated_live_final'
403
+ text_source?:
404
+ | 'uploaded'
405
+ | 'embedded'
406
+ | 'generated_live'
407
+ | 'generated_live_final'
408
+ | 'generated_vod'
360
409
  // BCP 47 language code
361
410
  language_code?: 'en' | 'en-US' | string
362
411
  // The name of the track containing a human-readable description. The hls manifest will associate a subtitle text track with this value
@@ -365,8 +414,12 @@ export interface MuxTextTrack {
365
414
  // Max 255 characters
366
415
  passthrough?: string
367
416
  status: 'preparing' | 'ready' | 'errored'
417
+ error?: {
418
+ type: string
419
+ messages?: string[]
420
+ }
368
421
  }
369
- export type MuxTrack = MuxVideoTrack | MuxAudioTrack
422
+ export type MuxTrack = MuxVideoTrack | MuxAudioTrack | MuxTextTrack
370
423
  // Typings lifted from https://docs.mux.com/api-reference/video#tag/assets
371
424
  export interface MuxAsset {
372
425
  id: string
@@ -1,93 +0,0 @@
1
- import {UploadIcon} from '@sanity/icons'
2
- import {Card, CardTone, Flex, Inline} from '@sanity/ui'
3
- import {PropsWithChildren, useRef, useState} from 'react'
4
-
5
- import {extractDroppedFiles} from '../util/extractFiles'
6
- import {FileInputButton} from './FileInputButton'
7
-
8
- interface FileInputAreaProps extends PropsWithChildren {
9
- accept?: string
10
- acceptMIMETypes?: string[]
11
- label: React.ReactNode
12
- onSelect: (files: FileList | File[]) => void
13
- }
14
-
15
- export default function FileInputArea({
16
- label,
17
- accept,
18
- acceptMIMETypes,
19
- onSelect,
20
- }: FileInputAreaProps) {
21
- const dragEnteredEls = useRef<EventTarget[]>([])
22
- const [dragState, setDragState] = useState<'valid' | 'invalid' | null>(null)
23
-
24
- // Stages and validates an upload from dragging+dropping files or folders
25
- const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
26
- setDragState(null)
27
- event.preventDefault()
28
- event.stopPropagation()
29
- extractDroppedFiles(event.nativeEvent.dataTransfer!).then(onSelect)
30
- }
31
-
32
- /* ------------------------------- Drag State ------------------------------- */
33
-
34
- const handleDragOver: React.DragEventHandler<HTMLDivElement> = (event) => {
35
- event.preventDefault()
36
- event.stopPropagation()
37
- }
38
-
39
- const handleDragEnter: React.DragEventHandler<HTMLDivElement> = (event) => {
40
- event.stopPropagation()
41
- dragEnteredEls.current.push(event.target)
42
- const type = event.dataTransfer.items?.[0]?.type
43
- setDragState(
44
- !acceptMIMETypes || acceptMIMETypes.some((mimeType) => type?.match(mimeType))
45
- ? 'valid'
46
- : 'invalid'
47
- )
48
- }
49
-
50
- const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
51
- event.stopPropagation()
52
- const idx = dragEnteredEls.current.indexOf(event.target)
53
- if (idx > -1) {
54
- dragEnteredEls.current.splice(idx, 1)
55
- }
56
- if (dragEnteredEls.current.length === 0) {
57
- setDragState(null)
58
- }
59
- }
60
-
61
- let tone: CardTone = 'inherit'
62
- if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical'
63
- return (
64
- <Card border sizing="border" tone={tone} style={{borderStyle: 'dashed'}} padding={3}>
65
- <Flex
66
- align="center"
67
- justify="space-between"
68
- gap={4}
69
- direction={['column', 'column', 'row']}
70
- paddingY={[2, 2, 0]}
71
- sizing="border"
72
- onDrop={handleDrop}
73
- onDragOver={handleDragOver}
74
- onDragLeave={handleDragLeave}
75
- onDragEnter={handleDragEnter}
76
- >
77
- <Flex align="center" justify="center" gap={2} flex={1}>
78
- {label}
79
- </Flex>
80
- <Inline space={2}>
81
- <FileInputButton
82
- mode="ghost"
83
- tone="default"
84
- icon={UploadIcon}
85
- text="Upload"
86
- onSelect={onSelect}
87
- accept={accept}
88
- />
89
- </Inline>
90
- </Flex>
91
- </Card>
92
- )
93
- }