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.
- package/LICENSE +1 -1
- package/README.md +133 -0
- package/dist/index.d.mts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +1564 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1565 -154
- 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/components/AddCaptionDialog.tsx +421 -0
- package/src/components/CaptionsDialog.tsx +23 -0
- package/src/components/EditCaptionDialog.tsx +508 -0
- package/src/components/FileInputButton.tsx +2 -2
- package/src/components/Onboard.tsx +2 -2
- package/src/components/PageSelector.tsx +57 -0
- package/src/components/Player.tsx +4 -3
- package/src/components/PlayerActionsMenu.tsx +17 -8
- package/src/components/TextTracksManager.tsx +781 -0
- package/src/components/UploadConfiguration.tsx +181 -4
- package/src/components/UploadPlaceholder.tsx +14 -6
- package/src/components/Uploader.styled.tsx +8 -15
- package/src/components/Uploader.tsx +61 -6
- package/src/components/VideoDetails/VideoDetails.tsx +16 -0
- package/src/components/VideoInBrowser.tsx +2 -7
- package/src/components/VideoPlayer.tsx +35 -6
- package/src/components/VideosBrowser.tsx +9 -1
- package/src/components/icons/Audio.tsx +13 -0
- package/src/hooks/useAccessControl.ts +1 -0
- package/src/hooks/useDialogState.ts +1 -1
- package/src/util/getVideoMetadata.ts +3 -1
- package/src/util/textTracks.ts +219 -0
- package/src/util/types.ts +56 -3
- 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?:
|
|
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
|
-
}
|