Skip to content

Commit 36b4d13

Browse files
stipsany-dpicursoragentR-Delfino95Copilot
authored
feat(mux-input): add mezzanine (master access) enable & download (#1107)
* feat(mux-input): add source file download action Port sanity-io/sanity-plugin-mux-input#470 into the monorepo plugin. Adds a "Download" action (player actions menu and asset details dialog) that uses Mux's master_access feature to prepare and download the original source video/audio file, named after the asset with its original extension. New modules: actions/download.ts, components/DownloadAssetDialog.tsx, util/downloadFile.ts, util/mimeExtGraph.ts, plus the updateMasterAccess action. Note: preparing the source requires the Mux addon master-access endpoint, which is not yet exposed; until then updateMasterAccess must be overridden to point at a custom Mux API proxy. Co-authored-by: Yohan DPI <yohan.desprairies@gmail.com> * fix(mux-input): conform ported download feature to monorepo lint/knip standards main now lints, formats and knip-checks sanity-plugin-mux-input (PRs #1347/#1342), which the feature was originally ported under the old exemptions. Adapt the ported code: - Remove unused exports flagged by knip: disableMasterAccess and extToMime; make pollMasterAccess a file-local helper. - Refactor DownloadAssetDialog to start in the 'preparing' state so the mount effect no longer calls setState synchronously (react-compiler EffectSetState). - Apply oxfmt formatting (trailing commas, changeset frontmatter quotes). Co-authored-by: Yohan DPI <yohan.desprairies@gmail.com> * feat(mux-input): add mezzanine (master access) enable & download Replaces the source-file download dialog with a "Mezzanine" flow: editors can enable Mux master access, watch it prepare, and download the high-quality source copy. - Enabling stores the returned master data on the Sanity document and polls the asset until it's ready, so status survives reloads and is present for imported assets. - Downloading re-fetches the asset and redirects to the fresh, short-lived Mux URL, prompting to re-enable once the 24h window elapses. - Exposes it inline in the Videos tool details and via a single dialog in the player actions menu, each explaining the mezzanine vs. the MP4 renditions. - Hardens useMuxPolling so a background polling failure no longer crashes the field input. * fix(mux-input): simplify status retrieval by removing useMemo. Add optional max_resolution_tier to MuxAsset interface * fix(mux-input): stabilize asset _keys and rate-limit polling log * chore(changeset): add R-Delfino95 author credit * fix(mux-input): guard overlapping polls and set request content-type * fix(mux-input): persist enable response and gate ready on expired * fix(mux-input): reset expired flag when asset changes * fix(mux-input): reset expired flag during render instead of in effect --------- Co-authored-by: Yohan DPI <yohan.desprairies@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Renzo Delfino <75499398+R-Delfino95@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 7e4a47e commit 36b4d13

15 files changed

Lines changed: 518 additions & 36 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"sanity-plugin-mux-input": minor
3+
---
4+
5+
author: @y-dpi
6+
author: @R-Delfino95
7+
8+
Add a "Mezzanine" action to enable and download a Mux asset's master-access file
9+
10+
A new "Mezzanine" section is available in the asset details dialog (next to Captions) and in the player actions menu. It lets editors enable Mux's master access — the highest-quality, near-lossless source copy of the asset, meant for offline editing and archival rather than streaming (unlike the streamable MP4 static renditions). The same surface explains what it is (free, available for 24 hours) and lets the user enable, watch it prepare, and download it, and links to Mux's guide — there's no separate confirmation dialog.
11+
12+
Enabling and status checks go through the Mux addon proxy (the same authentication as every other addon call), and the resulting `master` data is stored on the Sanity document — so the status is polled until ready (like captions) and is already present for assets imported from Mux. Because the Mux download URL is short-lived, the Download action re-fetches the asset first: if the file is still available it redirects to it, otherwise it prompts to enable it again.

plugins/sanity-plugin-mux-input/src/actions/assets.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,25 @@ export function deleteTextTrack(client: SanityClient, assetId: string, trackId:
150150
query: PLUGIN_VERSION_QUERY,
151151
})
152152
}
153+
154+
/**
155+
* Updates master access (the "mezzanine" file in Mux's docs) on a Mux asset, via the addon proxy.
156+
* @see {@link https://docs.mux.com/api-reference/video/assets/update-asset-master-access}
157+
*/
158+
export function updateMasterAccess(
159+
client: SanityClient,
160+
assetId: string,
161+
masterAccess: 'temporary' | 'none',
162+
) {
163+
const {dataset} = client.config()
164+
return client.request<{data: MuxAsset}>({
165+
url: `/addons/mux/assets/${dataset}/${assetId}/master-access`,
166+
withCredentials: true,
167+
method: 'PUT',
168+
body: {master_access: masterAccess},
169+
headers: {
170+
'Content-Type': 'application/json',
171+
},
172+
query: PLUGIN_VERSION_QUERY,
173+
})
174+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {DownloadIcon} from '@sanity/icons'
2+
import {Button, Card, Flex, Label, Spinner, Stack, Text} from '@sanity/ui'
3+
4+
import {useMezzanine} from '../hooks/useMezzanine'
5+
import {MEZZANINE_LEARN_MORE_URL} from '../util/constants'
6+
import type {VideoAssetDocument} from '../util/types'
7+
import MezzanineExplanation from './MezzanineExplanation'
8+
9+
export interface Props {
10+
asset: VideoAssetDocument
11+
/**
12+
* Render the mezzanine explanation copy inside the card.
13+
* @defaultValue true
14+
*/
15+
withExplanation?: boolean
16+
}
17+
18+
/**
19+
* "Mezzanine" panel, modeled after Mux's dashboard card: enable the
20+
* master-access copy, watch it prepare, then download it.
21+
*/
22+
export default function Mezzanine({asset, withExplanation = true}: Props) {
23+
const {status, busy, expired, resolution, enable, download} = useMezzanine(asset)
24+
25+
const assetReady = asset.status === 'ready' || asset.data?.status === 'ready'
26+
// Once a download finds the file gone, treat it as not-ready even if the
27+
// (stale) asset prop still reports `ready`, so we show the re-enable path
28+
// instead of a dead Download button.
29+
const isReady = status === 'ready' && !expired
30+
const isPreparing = status === 'preparing'
31+
const showExplanation = !isReady && !isPreparing
32+
33+
return (
34+
<Card padding={3} radius={2} tone="transparent" border>
35+
<Stack space={4}>
36+
<Flex align="center" justify="space-between" gap={3}>
37+
<Label muted size={1}>
38+
Mezzanine
39+
</Label>
40+
<Text size={1}>
41+
<a href={MEZZANINE_LEARN_MORE_URL} target="_blank" rel="noopener noreferrer">
42+
Learn more
43+
</a>
44+
</Text>
45+
</Flex>
46+
47+
<Stack space={2}>
48+
<Text size={1} muted={!isReady}>
49+
mezzanine.mp4
50+
</Text>
51+
{resolution && (
52+
<Text size={1} muted>
53+
{resolution}
54+
</Text>
55+
)}
56+
</Stack>
57+
58+
{withExplanation && showExplanation && <MezzanineExplanation />}
59+
60+
{showExplanation && (expired || status === 'errored') && (
61+
<Stack space={2}>
62+
{expired && (
63+
<Text size={1} muted>
64+
The previous mezzanine file expired. Enable it again to download.
65+
</Text>
66+
)}
67+
{status === 'errored' && (
68+
<Text size={1} style={{color: 'var(--card-critical-color)'}}>
69+
Mux could not prepare the mezzanine file. Please try again.
70+
</Text>
71+
)}
72+
</Stack>
73+
)}
74+
75+
{isPreparing && (
76+
<Text size={1} muted>
77+
Preparing the mezzanine file… this can take a few minutes. You can close this and come
78+
back later.
79+
</Text>
80+
)}
81+
82+
{isReady && (
83+
<Text size={1} muted>
84+
Ready to download. The link stays available for about 24 hours.
85+
</Text>
86+
)}
87+
88+
<Flex justify="flex-end" gap={2}>
89+
{isPreparing ? (
90+
<Flex align="center" gap={2}>
91+
<Spinner muted size={1} />
92+
<Text size={1} muted>
93+
Preparing…
94+
</Text>
95+
</Flex>
96+
) : isReady ? (
97+
<Button
98+
icon={DownloadIcon}
99+
text="Download"
100+
tone="positive"
101+
fontSize={1}
102+
padding={3}
103+
loading={busy}
104+
disabled={busy}
105+
onClick={() => void download()}
106+
/>
107+
) : (
108+
<Button
109+
text={status === 'errored' ? 'Try again' : 'Enable'}
110+
tone="primary"
111+
fontSize={1}
112+
padding={3}
113+
loading={busy}
114+
disabled={busy || !assetReady}
115+
onClick={() => void enable()}
116+
/>
117+
)}
118+
</Flex>
119+
</Stack>
120+
</Card>
121+
)
122+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {Box, Dialog, Stack} from '@sanity/ui'
2+
import {useId} from 'react'
3+
4+
import {useDialogStateContext} from '../context/DialogStateContext'
5+
import {getMezzanineStatus} from '../hooks/useMezzanine'
6+
import {DIALOGS_Z_INDEX} from '../util/constants'
7+
import type {VideoAssetDocument} from '../util/types'
8+
import Mezzanine from './Mezzanine'
9+
import MezzanineExplanation from './MezzanineExplanation'
10+
11+
export interface Props {
12+
asset: VideoAssetDocument
13+
onClose?: () => void
14+
}
15+
16+
/**
17+
* Player actions menu entry point: the explanation in the dialog body (until the
18+
* file is enabled) with the {@link Mezzanine} card below for the action/status.
19+
*/
20+
export default function MezzanineDialog({asset, onClose}: Props) {
21+
const {setDialogState} = useDialogStateContext()
22+
const dialogId = `MezzanineDialog${useId()}`
23+
24+
const closing = () => {
25+
onClose?.()
26+
setDialogState(false)
27+
}
28+
29+
const status = getMezzanineStatus(asset)
30+
const showExplanation = status !== 'preparing' && status !== 'ready'
31+
32+
return (
33+
<Dialog
34+
id={dialogId}
35+
header="Mezzanine file"
36+
onClose={closing}
37+
zOffset={DIALOGS_Z_INDEX}
38+
width={1}
39+
>
40+
<Box padding={4}>
41+
<Stack space={4}>
42+
{showExplanation && <MezzanineExplanation />}
43+
<Mezzanine asset={asset} withExplanation={false} />
44+
</Stack>
45+
</Box>
46+
</Dialog>
47+
)
48+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Stack, Text} from '@sanity/ui'
2+
3+
/** Explains what the mezzanine file is and how it differs from the streamable MP4 renditions. */
4+
export default function MezzanineExplanation() {
5+
return (
6+
<Stack space={3}>
7+
<Text size={1} muted>
8+
When you enable the mezzanine file, Mux creates a copy equivalent in quality to your
9+
original video — ideal for offline editing or archiving. Unlike the streamable MP4 (static)
10+
renditions, it is not meant for streaming.
11+
</Text>
12+
<Text size={1} muted>
13+
The file is prepared in the background and may take a few minutes depending on the
14+
asset&apos;s duration and resolution. Enabling is free and the download stays available for
15+
24 hours.
16+
</Text>
17+
</Stack>
18+
)
19+
}

plugins/sanity-plugin-mux-input/src/components/PlayerActionsMenu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
DownloadIcon,
23
EllipsisHorizontalIcon,
34
ImageIcon,
45
LockIcon,
@@ -152,6 +153,12 @@ function PlayerActionsMenu(
152153
</>
153154
)}
154155
<MenuDivider />
156+
<MenuItem
157+
icon={DownloadIcon}
158+
text="Mezzanine"
159+
onClick={() => setDialogState('mezzanine')}
160+
/>
161+
<MenuDivider />
155162
{hasConfigAccess && (
156163
<>
157164
<MenuItem

plugins/sanity-plugin-mux-input/src/components/VideoDetails/VideoDetails.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import FormField from '../FormField'
3333
import IconInfo from '../IconInfo'
3434
import {ResolutionIcon} from '../icons/Resolution'
3535
import {StopWatchIcon} from '../icons/StopWatch'
36+
import Mezzanine from '../Mezzanine'
3637
import TextTracksManager from '../TextTracksManager'
3738
import VideoPlayer from '../VideoPlayer'
3839
import DeleteDialog from './DeleteDialog'
@@ -222,18 +223,21 @@ const VideoDetails: React.FC<VideoDetailsProps> = (props) => {
222223
<Stack space={4} flex={1} sizing="border">
223224
<VideoPlayer asset={props.asset} autoPlay={props.asset.autoPlay || false} />
224225
{tab === 'details' && (
225-
<TextTracksManager
226-
asset={props.asset}
227-
iconOnly
228-
collapseTracks
229-
tracks={
230-
displayInfo?.text_tracks ||
231-
props.asset.data?.tracks?.filter(
232-
(track): track is MuxTextTrack => track.type === 'text',
233-
) ||
234-
[]
235-
}
236-
/>
226+
<>
227+
<TextTracksManager
228+
asset={props.asset}
229+
iconOnly
230+
collapseTracks
231+
tracks={
232+
displayInfo?.text_tracks ||
233+
props.asset.data?.tracks?.filter(
234+
(track): track is MuxTextTrack => track.type === 'text',
235+
) ||
236+
[]
237+
}
238+
/>
239+
<Mezzanine asset={props.asset} />
240+
</>
237241
)}
238242
</Stack>
239243
<Stack space={4} flex={1} sizing="border">

plugins/sanity-plugin-mux-input/src/components/VideoPlayer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {VideoAssetDocument} from '../util/types'
1717
import CaptionsDialog from './CaptionsDialog'
1818
import EditThumbnailDialog from './EditThumbnailDialog'
1919
import {AudioIcon} from './icons/Audio'
20+
import MezzanineDialog from './MezzanineDialog'
2021

2122
export default function VideoPlayer({
2223
asset,
@@ -232,6 +233,7 @@ export default function VideoPlayer({
232233
/>
233234
)}
234235
{dialogState === 'edit-captions' && <CaptionsDialog asset={asset} />}
236+
{dialogState === 'mezzanine' && <MezzanineDialog asset={asset} />}
235237
</>
236238
)
237239
}

plugins/sanity-plugin-mux-input/src/hooks/useDialogState.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import {useState} from 'react'
44

5-
export type DialogState = 'secrets' | 'select-video' | 'edit-thumbnail' | 'edit-captions' | false
5+
export type DialogState =
6+
| 'secrets'
7+
| 'mezzanine'
8+
| 'select-video'
9+
| 'edit-thumbnail'
10+
| 'edit-captions'
11+
| false
612

713
export function useDialogState() {
814
return useState<DialogState>(false)

0 commit comments

Comments
 (0)