diff --git a/README.md b/README.md index ffcf8e74..521a033b 100644 --- a/README.md +++ b/README.md @@ -198,21 +198,65 @@ sanity documents delete secrets.mux More information on signed URLs is available on Mux's [docs](https://docs.mux.com/docs/headless-cms-sanity#advanced-signed-urls) -### MP4 support (downloadable videos or offline viewing) +### Static Renditions (downloadable videos or offline viewing) -To enable [static MP4 renditions](https://docs.mux.com/guides/video/enable-static-mp4-renditions), add `mp4_support: 'standard'` to the `options` of your `mux.video` schema type. +To enable [static MP4 renditions](https://docs.mux.com/guides/video/enable-static-mp4-renditions), add `static_renditions` to your plugin configuration. This allows users to download videos for offline viewing. + +#### Standard Mode (Recommended) ```js import {muxInput} from 'sanity-plugin-mux-input' export default defineConfig({ - plugins: [muxInput({mp4_support: 'standard'})], + plugins: [ + muxInput({ + static_renditions: ['highest'], // Enables MP4 downloads at the highest quality (up to 4K) + // or + static_renditions: ['highest', 'audio-only'], // Also includes audio-only (M4A) downloads + }), + ], }) ``` -If MP4 support is enabled in the plugin's configuration, editors can still choose to enable MP4 renditions on a per-video basis when uploading new assets. +**Standard mode options:** +- `'highest'`: Produces an MP4 file with video resolution up to 4K (2160p) +- `'audio-only'`: Produces an M4A (audio-only MP4) file + +#### Advanced Mode (Specific Resolutions) + +For more control, you can specify exact resolutions: + +```js +import {muxInput} from 'sanity-plugin-mux-input' + +export default defineConfig({ + plugins: [ + muxInput({ + static_renditions: ['1080p', '720p', 'audio-only'], + }), + ], +}) +``` + +**Advanced mode options:** +- Specific resolutions: `'270p'`, `'360p'`, `'480p'`, `'540p'`, `'720p'`, `'1080p'`, `'1440p'`, `'2160p'` +- `'audio-only'`: M4A file + +**Important notes:** +- You cannot mix `'highest'` with specific resolutions (e.g., `['highest', '1080p']` is invalid) +- Mux will not upscale videos - renditions requiring upscaling are automatically skipped +- When uploading new assets, editors can choose different rendition settings on a per-video basis + +#### Backward Compatibility + +The deprecated `mp4_support` field is still supported for backward compatibility: + +```js +// ⚠️ Deprecated - use static_renditions instead +muxInput({mp4_support: 'standard'}) // Equivalent to static_renditions: ['highest'] +``` -MP4 allows users to download videos for later or offline viewing. More information can be found on Mux's [documentation](https://docs.mux.com/guides/enable-static-mp4-renditions). +More information can be found on Mux's [documentation](https://docs.mux.com/guides/enable-static-mp4-renditions). ### Video resolution (max_resolution_tier) diff --git a/sanity.config.ts b/sanity.config.ts index 374c2f8a..bd4b90a5 100644 --- a/sanity.config.ts +++ b/sanity.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ muxInput({ video_quality: 'plus', max_resolution_tier: '2160p', - mp4_support: 'standard', + static_renditions: ['highest'], defaultAutogeneratedSubtitleLang: 'en', }), visionTool(), diff --git a/src/_exports/index.ts b/src/_exports/index.ts index 3e2eed38..2be6c99d 100644 --- a/src/_exports/index.ts +++ b/src/_exports/index.ts @@ -3,10 +3,11 @@ import {definePlugin} from 'sanity' import createStudioTool, {DEFAULT_TOOL_CONFIG} from '../components/StudioTool' import {muxVideoCustomRendering} from '../plugin' import {muxVideoSchema, schemaTypes} from '../schema' -import type {PluginConfig} from '../util/types' +import type {PluginConfig, StaticRenditionResolution} from '../util/types' export type {VideoAssetDocument} from '../util/types' export const defaultConfig: PluginConfig = { + static_renditions: [], mp4_support: 'none', video_quality: 'plus', max_resolution_tier: '1080p', @@ -17,6 +18,25 @@ export const defaultConfig: PluginConfig = { allowedRolesForConfiguration: [], } +/** + * Converts legacy mp4_support configuration to static_renditions format + */ +function convertLegacyConfig(config: Partial): { + static_renditions: StaticRenditionResolution[] +} { + // If static_renditions is already provided, use it + if (config.static_renditions && config.static_renditions.length > 0) { + return {static_renditions: config.static_renditions} + } + + // Convert legacy mp4_support to static_renditions + if (config.mp4_support === 'standard') { + return {static_renditions: ['highest']} + } + + return {static_renditions: []} +} + export const muxInput = definePlugin | void>((userConfig) => { // TODO: Remove this on next major version when we end support for encoding_tier if (typeof userConfig === 'object' && 'encoding_tier' in userConfig) { @@ -30,7 +50,11 @@ export const muxInput = definePlugin | void>((userConfig) } } } - const config: PluginConfig = {...defaultConfig, ...(userConfig || {})} + const config: PluginConfig = { + ...defaultConfig, + ...(userConfig || {}), + ...convertLegacyConfig(userConfig || {}), + } return { name: 'mux-input', schema: { diff --git a/src/actions/upload.ts b/src/actions/upload.ts index c3a49de1..c7f1007c 100644 --- a/src/actions/upload.ts +++ b/src/actions/upload.ts @@ -156,7 +156,7 @@ type UploadResponse = { cors_origin: string id: string new_asset_settings: { - mp4_support: 'standard' | 'none' + static_renditions?: {resolution: string}[] passthrough: string playback_policies: ['public' | 'signed'] } diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 5bf2ce34..ad5e137f 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -33,14 +33,23 @@ const Player = ({asset, buttons, readOnly, onChange}: Props) => { return true }, [asset]) const isPreparingStaticRenditions = useMemo(() => { - if (asset?.data?.static_renditions?.status === 'preparing') { - return true + // Legacy: If static_renditions has a status field, it was created with mp4_support (deprecated) + // We don't process this old format, just return false + // Note: 'disabled' status is valid in the new format when no renditions were requested + if ( + asset?.data?.static_renditions?.status && + asset?.data?.static_renditions?.status !== 'disabled' + ) { + return false } - if (asset?.data?.static_renditions?.status === 'ready') { + + // Check if any file in static_renditions is still preparing + const files = asset?.data?.static_renditions?.files + if (!files || files.length === 0) { return false } - return false - }, [asset?.data?.static_renditions?.status]) + return files.some((file) => file.status === 'preparing') + }, [asset?.data?.static_renditions?.status, asset?.data?.static_renditions?.files]) const playRef = useRef(null) const muteRef = useRef(null) const handleCancelUpload = useCancelUpload(asset, onChange) diff --git a/src/components/UploadConfiguration.tsx b/src/components/UploadConfiguration.tsx index 32259f38..e09e5e45 100644 --- a/src/components/UploadConfiguration.tsx +++ b/src/components/UploadConfiguration.tsx @@ -2,7 +2,7 @@ import {DocumentVideoIcon, UploadIcon} from '@sanity/icons' import {Box, Button, Card, Checkbox, Dialog, Flex, Label, Radio, Stack, Text} from '@sanity/ui' import {uuid} from '@sanity/uuid' import LanguagesList from 'iso-639-1' -import {useEffect, useId, useReducer, useRef} from 'react' +import {useEffect, useId, useMemo, useReducer, useRef, useState} from 'react' import {FormField} from 'sanity' import formatBytes from '../util/formatBytes' @@ -14,6 +14,7 @@ import { type MuxNewAssetSettings, type PluginConfig, type Secrets, + type StaticRenditionResolution, type SupportedMuxLanguage, type UploadConfig, type UploadTextTrack, @@ -25,7 +26,7 @@ import type {StagedUpload} from './Uploader' export type UploadConfigurationStateAction = | {action: 'video_quality'; value: UploadConfig['video_quality']} | {action: 'max_resolution_tier'; value: UploadConfig['max_resolution_tier']} - | {action: 'mp4_support'; value: UploadConfig['mp4_support']} + | {action: 'static_renditions'; value: UploadConfig['static_renditions']} | {action: 'normalize_audio'; value: UploadConfig['normalize_audio']} | {action: 'signed_policy'; value: UploadConfig['signed_policy']} | {action: 'public_policy'; value: UploadConfig['public_policy']} @@ -43,6 +44,35 @@ const RESOLUTION_TIERS = [ {value: '2160p', label: '2160p (4k)'}, ] as const satisfies {value: UploadConfig['max_resolution_tier']; label: string}[] +const ADVANCED_RESOLUTIONS: {value: StaticRenditionResolution; label: string}[] = [ + {value: '270p', label: '270p'}, + {value: '360p', label: '360p'}, + {value: '480p', label: '480p'}, + {value: '540p', label: '540p'}, + {value: '720p', label: '720p'}, + {value: '1080p', label: '1080p'}, + {value: '1440p', label: '1440p'}, + {value: '2160p', label: '2160p'}, +] + +/** + * Sanitizes static renditions configuration to ensure 'highest' is not mixed with specific resolutions. + * If both are present, only 'highest' (and 'audio-only' if present) will be kept. + */ +function sanitizeStaticRenditions( + renditions: StaticRenditionResolution[] +): StaticRenditionResolution[] { + const hasHighest = renditions.includes('highest') + const hasSpecificResolutions = renditions.some((r) => r !== 'highest' && r !== 'audio-only') + + // If both highest and specific resolutions are present, keep only highest and audio-only + if (hasHighest && hasSpecificResolutions) { + return renditions.filter((r) => r === 'highest' || r === 'audio-only') + } + + return renditions +} + /** * The modal for configuring a staged upload. Handles triggering of the asset * upload, even if no modal needs to be shown. @@ -84,7 +114,7 @@ export default function UploadConfiguration({ if (action.value === 'basic') { return Object.assign({}, prev, { video_quality: action.value, - mp4_support: 'none', + static_renditions: [], max_resolution_tier: '1080p', text_tracks: prev.text_tracks?.filter(({type}) => type !== 'autogenerated'), public_policy: true, @@ -94,12 +124,12 @@ export default function UploadConfiguration({ } return Object.assign({}, prev, { video_quality: action.value, - mp4_support: pluginConfig.mp4_support, + static_renditions: sanitizeStaticRenditions(pluginConfig.static_renditions || []), max_resolution_tier: pluginConfig.max_resolution_tier, text_tracks: [...autoTextTracks, ...(prev.text_tracks || [])], }) - case 'mp4_support': + case 'static_renditions': case 'max_resolution_tier': case 'normalize_audio': case 'signed_policy': @@ -141,7 +171,7 @@ export default function UploadConfiguration({ { video_quality: pluginConfig.video_quality, max_resolution_tier: pluginConfig.max_resolution_tier, - mp4_support: pluginConfig.mp4_support, + static_renditions: sanitizeStaticRenditions(pluginConfig.static_renditions || []), signed_policy: secrets.enableSignedUrls && pluginConfig.defaultSigned, public_policy: pluginConfig.defaultPublic, normalize_audio: pluginConfig.normalize_audio, @@ -149,6 +179,54 @@ export default function UploadConfiguration({ } as UploadConfig ) + // Determine if user is in advanced mode based on selected renditions + const isAdvancedMode = useMemo(() => { + const specificResolutions = config.static_renditions.filter( + (r) => r !== 'highest' && r !== 'audio-only' + ) + return specificResolutions.length > 0 + }, [config.static_renditions]) + + const [renditionMode, setRenditionMode] = useState<'standard' | 'advanced'>( + isAdvancedMode ? 'advanced' : 'standard' + ) + + // Helper to toggle a rendition + const toggleRendition = (rendition: StaticRenditionResolution) => { + const current = config.static_renditions + const hasRendition = current.includes(rendition) + + if (hasRendition) { + dispatch({ + action: 'static_renditions', + value: current.filter((r) => r !== rendition), + }) + } else { + dispatch({ + action: 'static_renditions', + value: [...current, rendition], + }) + } + } + + // When switching modes, clear renditions that don't apply + const handleModeChange = (mode: 'standard' | 'advanced') => { + setRenditionMode(mode) + if (mode === 'standard') { + // Remove specific resolutions, keep only highest and audio-only + dispatch({ + action: 'static_renditions', + value: config.static_renditions.filter((r) => r === 'highest' || r === 'audio-only'), + }) + } else { + // Remove highest, keep specific resolutions and audio-only + dispatch({ + action: 'static_renditions', + value: config.static_renditions.filter((r) => r !== 'highest'), + }) + } + } + // If user-provided config is disabled, begin the upload immediately with // the developer-specified values from the schema or config or defaults. // This can include auto-generated subtitles! @@ -290,31 +368,111 @@ export default function UploadConfiguration({ {!basicConfig && ( - + - {!basicConfig && ( - - - dispatch({ - action: 'mp4_support', - value: e.currentTarget.checked ? 'standard' : 'none', - }) - } - /> - - - - - )} + + + + {/* Mode Selector */} + + + handleModeChange('standard')} + value="standard" + id={`${id}--mode-standard`} + /> + + Standard + + + + handleModeChange('advanced')} + value="advanced" + id={`${id}--mode-advanced`} + /> + + Advanced + + + + + {/* Standard Mode Options */} + {renditionMode === 'standard' && ( + + + toggleRendition('highest')} + /> + + Highest Resolution (up to 4K) + + + + toggleRendition('audio-only')} + /> + + Audio Only (M4A) + + + + )} + + {/* Advanced Mode Options */} + {renditionMode === 'advanced' && ( + + + + {ADVANCED_RESOLUTIONS.map(({value, label}) => { + const inputId = `${id}--resolution-${value}` + return ( + + toggleRendition(value)} + /> + + {label} + + + ) + })} + + + toggleRendition('audio-only')} + /> + + Audio Only (M4A) + + + + )} + + + )} @@ -385,7 +543,10 @@ function formatUploadConfig(config: UploadConfig): MuxNewAssetSettings { [] as NonNullable ), ], - mp4_support: config.mp4_support, + static_renditions: + config.static_renditions.length > 0 + ? config.static_renditions.map((resolution) => ({resolution})) + : undefined, playback_policy: setPlaybackPolicy(config), max_resolution_tier: config.max_resolution_tier, video_quality: config.video_quality, diff --git a/src/hooks/useMuxPolling.ts b/src/hooks/useMuxPolling.ts index 32bdfe81..411e7545 100644 --- a/src/hooks/useMuxPolling.ts +++ b/src/hooks/useMuxPolling.ts @@ -10,11 +10,27 @@ export const useMuxPolling = (asset?: VideoAssetDocument) => { const client = useClient() const projectId = useProjectId() const dataset = useDataset() + const isPreparingStaticRenditions = useMemo(() => { + // Legacy: If static_renditions has a status field, it was created with mp4_support (deprecated) + // We don't process this old format, just return false + // Note: 'disabled' status is valid in the new format when no renditions were requested + if ( + asset?.data?.static_renditions?.status && + asset?.data?.static_renditions?.status !== 'disabled' + ) { + return false + } + + const files = asset?.data?.static_renditions?.files + if (!files || files.length === 0) { + return false + } + return files.some((file) => file.status === 'preparing') + }, [asset?.data?.static_renditions?.status, asset?.data?.static_renditions?.files]) + const shouldFetch = useMemo( - () => - !!asset?.assetId && - (asset?.status === 'preparing' || asset?.data?.static_renditions?.status === 'preparing'), - [asset?.assetId, asset?.data?.static_renditions?.status, asset?.status] + () => !!asset?.assetId && (asset?.status === 'preparing' || isPreparingStaticRenditions), + [asset?.assetId, asset?.status, isPreparingStaticRenditions] ) return useSWR( shouldFetch ? `/${projectId}/addons/mux/assets/${dataset}/data/${asset?.assetId}` : null, diff --git a/src/util/types.ts b/src/util/types.ts index aae90a39..424f5ef4 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -1,8 +1,42 @@ import type {ObjectInputProps, PreviewLayoutKey, PreviewProps, SchemaType} from 'sanity' import type {PartialDeep} from 'type-fest' +/** + * Standard static rendition options available for plugin configuration defaults + */ +export type StandardRendition = 'highest' | 'audio-only' + +/** + * All static rendition resolution options supported by Mux + */ +export type StaticRenditionResolution = + | 'highest' + | 'audio-only' + | '270p' + | '360p' + | '480p' + | '540p' + | '720p' + | '1080p' + | '1440p' + | '2160p' + export interface MuxInputConfig { /** + * Enable static renditions by default. Can be overwritten on a per-asset basis. + * Supports: + * - Standard mode: 'highest' (up to 4K MP4) and/or 'audio-only' (M4A) + * - Advanced mode: Specific resolutions ('270p', '360p', '480p', '540p', '720p', '1080p', '1440p', '2160p') and/or 'audio-only' + * + * **Important**: 'highest' cannot be mixed with specific resolutions. If both are provided, only 'highest' will be used. + * + * @see {@link https://docs.mux.com/guides/video/enable-static-mp4-renditions} + * @defaultValue [] + */ + static_renditions: StaticRenditionResolution[] + + /** + * @deprecated Use `static_renditions` instead. This field is kept for backward compatibility. * Enable static renditions by setting this to 'standard'. Can be overwritten on a per-asset basis. * Requires `"video_quality": "plus"` * @see {@link https://docs.mux.com/guides/video/enable-static-mp4-renditions#why-enable-mp4-support} @@ -182,10 +216,8 @@ export function isAutogeneratedTrack( export type UploadTextTrack = AutogeneratedTextTrack | CustomTextTrack export interface UploadConfig - extends Pick< - MuxInputConfig, - 'max_resolution_tier' | 'mp4_support' | 'normalize_audio' | 'video_quality' - > { + extends Pick { + static_renditions: StaticRenditionResolution[] text_tracks: UploadTextTrack[] signed_policy: boolean public_policy: boolean @@ -196,10 +228,9 @@ export interface UploadConfig * @docs {@link https://docs.mux.com/api-reference#video/operation/create-direct-upload} */ export interface MuxNewAssetSettings - extends Pick< - MuxInputConfig, - 'max_resolution_tier' | 'mp4_support' | 'normalize_audio' | 'video_quality' - > { + extends Pick { + /** Static renditions configuration for downloadable files */ + static_renditions?: {resolution: StaticRenditionResolution}[] /** An array of objects that each describe an input file to be used to create the asset.*/ input?: { /** The URL of the file that Mux should download and use. */ @@ -371,12 +402,18 @@ export interface MuxAsset { static_renditions?: { status: 'ready' | 'preparing' | 'disabled' | 'errored' files: { - name: 'low.mp4' | 'medium.mp4' | 'high.mp4' | 'audio.m4a' + name: string ext: 'mp4' | 'm4a' height: number width: number bitrate: number - filesize: number + filesize: number | string + type: 'standard' | 'advanced' + status: 'ready' | 'preparing' | 'skipped' | 'errored' + resolution_tier?: string + resolution?: string + id: string + passthrough?: string }[] } recording_times?: {