sanity-plugin-mux-input 2.13.0 → 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.
@@ -5,9 +5,6 @@ import {styled} from 'styled-components'
5
5
 
6
6
  import {withFocusRing} from './withFocusRing'
7
7
 
8
- const ctrlKey = 17
9
- const cmdKey = 91
10
-
11
8
  const UploadCardWithFocusRing = withFocusRing(Card)
12
9
 
13
10
  interface UploadCardProps {
@@ -21,22 +18,19 @@ interface UploadCardProps {
21
18
  }
22
19
  export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps>(
23
20
  ({children, tone, onPaste, onDrop, onDragEnter, onDragLeave, onDragOver}, forwardedRef) => {
24
- const ctrlDown = useRef(false)
25
21
  const inputRef = useRef<HTMLInputElement>(null)
26
22
  const handleKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>((event) => {
27
- if (event.keyCode == ctrlKey || event.keyCode == cmdKey) {
28
- ctrlDown.current = true
23
+ const target = event.target as HTMLElement
24
+
25
+ // Don't steal focus when pasting into the VTT input
26
+ if (target.closest('#vtt-url')) {
27
+ return
29
28
  }
30
- const vKey = 86
31
- if (ctrlDown.current && event.keyCode == vKey) {
29
+
30
+ if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
32
31
  inputRef.current!.focus()
33
32
  }
34
33
  }, [])
35
- const handleKeyUp = useCallback<React.KeyboardEventHandler<HTMLDivElement>>((event) => {
36
- if (event.keyCode == ctrlKey || event.keyCode == cmdKey) {
37
- ctrlDown.current = false
38
- }
39
- }, [])
40
34
 
41
35
  return (
42
36
  <UploadCardWithFocusRing
@@ -47,14 +41,13 @@ export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps>(
47
41
  shadow={0}
48
42
  tabIndex={0}
49
43
  onKeyDown={handleKeyDown}
50
- onKeyUp={handleKeyUp}
51
44
  onPaste={onPaste}
52
45
  onDrop={onDrop}
53
46
  onDragEnter={onDragEnter}
54
47
  onDragLeave={onDragLeave}
55
48
  onDragOver={onDragOver}
56
49
  >
57
- <HiddenInput ref={inputRef} onPaste={onPaste} />
50
+ <HiddenInput ref={inputRef} />
58
51
  {children}
59
52
  </UploadCardWithFocusRing>
60
53
  )
@@ -297,6 +297,13 @@ export default function Uploader(props: Props) {
297
297
 
298
298
  // Stages and validates an upload from pasting an asset URL
299
299
  const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
300
+ const target = event.target as HTMLElement
301
+
302
+ // Ignore paste coming from the VTT URL input
303
+ if (target.closest('#vtt-url')) {
304
+ return
305
+ }
306
+
300
307
  event.preventDefault()
301
308
  event.stopPropagation()
302
309
  const clipboardData =
@@ -27,10 +27,12 @@ import {
27
27
  import React, {useEffect, useState} from 'react'
28
28
 
29
29
  import {DIALOGS_Z_INDEX} from '../../util/constants'
30
+ import type {MuxTextTrack} from '../../util/types'
30
31
  import FormField from '../FormField'
31
32
  import IconInfo from '../IconInfo'
32
33
  import {ResolutionIcon} from '../icons/Resolution'
33
34
  import {StopWatchIcon} from '../icons/StopWatch'
35
+ import TextTracksManager from '../TextTracksManager'
34
36
  import VideoPlayer from '../VideoPlayer'
35
37
  import DeleteDialog from './DeleteDialog'
36
38
  import useVideoDetails, {VideoDetailsProps} from './useVideoDetails'
@@ -203,6 +205,20 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
203
205
  >
204
206
  <Stack space={4} flex={1} sizing="border">
205
207
  <VideoPlayer asset={props.asset} autoPlay={props.asset.autoPlay || false} />
208
+ {tab === 'details' && (
209
+ <TextTracksManager
210
+ asset={props.asset}
211
+ iconOnly
212
+ collapseTracks
213
+ tracks={
214
+ displayInfo?.text_tracks ||
215
+ props.asset.data?.tracks?.filter(
216
+ (track): track is MuxTextTrack => track.type === 'text'
217
+ ) ||
218
+ []
219
+ }
220
+ />
221
+ )}
206
222
  </Stack>
207
223
  <Stack space={4} flex={1} sizing="border">
208
224
  <TabList space={2}>
@@ -10,6 +10,7 @@ import {AUDIO_ASPECT_RATIO, MIN_ASPECT_RATIO} from '../util/constants'
10
10
  import {getPosterSrc} from '../util/getPosterSrc'
11
11
  import {getVideoSrc} from '../util/getVideoSrc'
12
12
  import type {VideoAssetDocument} from '../util/types'
13
+ import CaptionsDialog from './CaptionsDialog'
13
14
  import EditThumbnailDialog from './EditThumbnailDialog'
14
15
  import {AudioIcon} from './icons/Audio'
15
16
 
@@ -149,6 +150,7 @@ export default function VideoPlayer({
149
150
  {dialogState === 'edit-thumbnail' && (
150
151
  <EditThumbnailDialog asset={asset} currentTime={muxPlayer?.current?.currentTime} />
151
152
  )}
153
+ {dialogState === 'edit-captions' && <CaptionsDialog asset={asset} />}
152
154
  </>
153
155
  )
154
156
  }
@@ -6,6 +6,7 @@ import useAssets from '../hooks/useAssets'
6
6
  import type {VideoAssetDocument} from '../util/types'
7
7
  import ConfigureApi from './ConfigureApi'
8
8
  import ImportVideosFromMux from './ImportVideosFromMux'
9
+ import PageSelector from './PageSelector'
9
10
  import ResyncMetadata from './ResyncMetadata'
10
11
  import {SelectSortOptions} from './SelectSortOptions'
11
12
  import SpinnerBox from './SpinnerBox'
@@ -19,12 +20,18 @@ export interface VideosBrowserProps {
19
20
 
20
21
  export default function VideosBrowser({onSelect}: VideosBrowserProps) {
21
22
  const {assets, isLoading, searchQuery, setSearchQuery, setSort, sort} = useAssets()
23
+ const [page, setPage] = useState<number>(0)
24
+ const pageLimit = 20
25
+ const pageTotal = Math.floor(assets.length / pageLimit) + 1
22
26
  const [editedAsset, setEditedAsset] = useState<VideoDetailsProps['asset'] | null>(null)
23
27
  const freshEditedAsset = useMemo(
24
28
  () => assets.find((a) => a._id === editedAsset?._id) || editedAsset,
25
29
  [editedAsset, assets]
26
30
  )
27
31
 
32
+ const pageStart = page * pageLimit
33
+ const pageEnd = pageStart + pageLimit
34
+
28
35
  const placement = onSelect ? 'input' : 'tool'
29
36
  return (
30
37
  <>
@@ -40,6 +47,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
40
47
  placeholder="Search videos"
41
48
  />
42
49
  <SelectSortOptions setSort={setSort} sort={sort} />
50
+ <PageSelector page={page} setPage={setPage} total={pageTotal} limit={pageLimit} />
43
51
  </Flex>
44
52
  {placement === 'tool' && (
45
53
  <Inline space={2}>
@@ -62,7 +70,7 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
62
70
  gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
63
71
  }}
64
72
  >
65
- {assets.map((asset) => (
73
+ {assets.slice(pageStart, pageEnd).map((asset) => (
66
74
  <VideoInBrowser
67
75
  key={asset._id}
68
76
  asset={asset}
@@ -1,4 +1,5 @@
1
1
  import {useCurrentUser} from 'sanity'
2
+
2
3
  import {PluginConfig} from '../util/types'
3
4
 
4
5
  export const useAccessControl = (config: PluginConfig) => {
@@ -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,6 +1,6 @@
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
- import type MuxPlayerElement from '@mux/mux-player'
4
4
 
5
5
  /**
6
6
  * Standard static rendition options available for plugin configuration defaults
@@ -303,7 +303,6 @@ export interface MuxNewAssetSettings
303
303
  name?: string
304
304
  /** Indicates the track provides Subtitles for the Deaf or Hard-of-hearing (SDH). */
305
305
  closed_captions?: boolean
306
- /// @TODO Huhh?>?? Below
307
306
  /** This optional parameter should be used tracks with type of text and text_type set to subtitles. */
308
307
  passthrough?: string
309
308
  }[]
@@ -401,7 +400,12 @@ export interface MuxTextTrack {
401
400
  id: string
402
401
  text_type?: 'subtitles'
403
402
  // https://docs.mux.com/api-reference/video#operation/list-assets:~:text=text%20type%20tracks.-,tracks%5B%5D.,text_source,-string
404
- 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'
405
409
  // BCP 47 language code
406
410
  language_code?: 'en' | 'en-US' | string
407
411
  // The name of the track containing a human-readable description. The hls manifest will associate a subtitle text track with this value
@@ -410,8 +414,12 @@ export interface MuxTextTrack {
410
414
  // Max 255 characters
411
415
  passthrough?: string
412
416
  status: 'preparing' | 'ready' | 'errored'
417
+ error?: {
418
+ type: string
419
+ messages?: string[]
420
+ }
413
421
  }
414
- export type MuxTrack = MuxVideoTrack | MuxAudioTrack
422
+ export type MuxTrack = MuxVideoTrack | MuxAudioTrack | MuxTextTrack
415
423
  // Typings lifted from https://docs.mux.com/api-reference/video#tag/assets
416
424
  export interface MuxAsset {
417
425
  id: string