sanity-plugin-mux-input 2.15.0 → 2.17.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.
@@ -0,0 +1,110 @@
1
+ import {useToast} from '@sanity/ui'
2
+ import {useCallback, useState} from 'react'
3
+
4
+ import {getAsset} from '../actions/assets'
5
+ import {addKeysToMuxData} from '../util/addKeysToMuxData'
6
+ import type {MuxAsset, VideoAssetDocument} from '../util/types'
7
+ import {useClient} from './useClient'
8
+
9
+ type ResyncAssetState = 'idle' | 'syncing' | 'success' | 'error'
10
+
11
+ interface UseResyncAssetOptions {
12
+ showToast?: boolean
13
+ onSuccess?: (updatedData: MuxAsset) => void
14
+ onError?: (error: unknown) => void
15
+ }
16
+
17
+ interface UseResyncAssetReturn {
18
+ resyncState: ResyncAssetState
19
+ resyncError: unknown
20
+ resyncAsset: (asset: VideoAssetDocument) => Promise<MuxAsset | undefined>
21
+ isResyncing: boolean
22
+ }
23
+
24
+ export function useResyncAsset(options?: UseResyncAssetOptions): UseResyncAssetReturn {
25
+ const client = useClient()
26
+ const toast = useToast()
27
+ const [resyncState, setResyncState] = useState<ResyncAssetState>('idle')
28
+ const [resyncError, setResyncError] = useState<unknown>(null)
29
+
30
+ const showToast = options?.showToast ?? false
31
+
32
+ const resyncAsset = useCallback(
33
+ async (asset: VideoAssetDocument) => {
34
+ if (!asset.assetId) {
35
+ if (showToast) {
36
+ toast.push({
37
+ title: 'Cannot resync',
38
+ description: 'Asset has no Mux ID',
39
+ status: 'error',
40
+ })
41
+ }
42
+ options?.onError?.(new Error('Asset has no Mux ID'))
43
+ return undefined
44
+ }
45
+
46
+ if (!asset._id) {
47
+ if (showToast) {
48
+ toast.push({
49
+ title: 'Cannot resync',
50
+ description: 'Asset has no document ID',
51
+ status: 'error',
52
+ })
53
+ }
54
+ options?.onError?.(new Error('Asset has no document ID'))
55
+ return undefined
56
+ }
57
+
58
+ setResyncState('syncing')
59
+ setResyncError(null)
60
+
61
+ try {
62
+ const response = await getAsset(client, asset.assetId)
63
+ const muxData = response.data
64
+ const dataWithKeys = addKeysToMuxData(muxData)
65
+
66
+ await client
67
+ .patch(asset._id)
68
+ .set({
69
+ status: muxData.status,
70
+ data: dataWithKeys,
71
+ ...(muxData.meta?.title && {filename: muxData.meta.title}),
72
+ })
73
+ .commit({returnDocuments: false})
74
+
75
+ setResyncState('success')
76
+ if (showToast) {
77
+ toast.push({
78
+ title: 'Asset synced',
79
+ description: 'Data has been updated from Mux',
80
+ status: 'success',
81
+ })
82
+ }
83
+
84
+ options?.onSuccess?.(muxData)
85
+ return muxData
86
+ } catch (error) {
87
+ setResyncState('error')
88
+ setResyncError(error)
89
+ console.error('Failed to refresh asset data:', error)
90
+ if (showToast) {
91
+ toast.push({
92
+ title: 'Sync failed',
93
+ description: 'Could not sync asset from Mux',
94
+ status: 'error',
95
+ })
96
+ }
97
+ options?.onError?.(error)
98
+ return undefined
99
+ }
100
+ },
101
+ [client, toast, options, showToast]
102
+ )
103
+
104
+ return {
105
+ resyncState,
106
+ resyncError,
107
+ resyncAsset,
108
+ isResyncing: resyncState === 'syncing',
109
+ }
110
+ }
@@ -6,6 +6,7 @@ import {
6
6
  useDocumentStore,
7
7
  } from 'sanity'
8
8
 
9
+ import {addKeysToMuxData} from '../util/addKeysToMuxData'
9
10
  import {isEmptyOrPlaceholderTitle} from '../util/assetTitlePlaceholder'
10
11
  import type {MuxAsset, VideoAssetDocument} from '../util/types'
11
12
  import {SANITY_API_VERSION} from './useClient'
@@ -115,6 +116,37 @@ export default function useResyncMuxMetadata() {
115
116
  }
116
117
  }
117
118
 
119
+ async function syncFullData() {
120
+ if (!matchedAssets) return
121
+
122
+ setResyncState('syncing')
123
+
124
+ try {
125
+ const tx = client.transaction()
126
+
127
+ matchedAssets.forEach((matched) => {
128
+ if (!matched.muxAsset) return
129
+
130
+ const dataWithKeys = addKeysToMuxData(matched.muxAsset)
131
+
132
+ // Update all fields: filename, status, and full data from Mux
133
+ tx.patch(matched.sanityDoc._id, {
134
+ set: {
135
+ filename: matched.muxTitle || matched.currentTitle || '',
136
+ status: matched.muxAsset.status,
137
+ data: dataWithKeys,
138
+ },
139
+ })
140
+ })
141
+
142
+ await tx.commit({returnDocuments: false})
143
+ setResyncState('done')
144
+ } catch (error) {
145
+ setResyncState('error')
146
+ setResyncError(error)
147
+ }
148
+ }
149
+
118
150
  return {
119
151
  sanityAssetsLoading,
120
152
  closeDialog,
@@ -124,6 +156,7 @@ export default function useResyncMuxMetadata() {
124
156
  hasSecrets,
125
157
  syncAllVideos,
126
158
  syncOnlyEmpty,
159
+ syncFullData,
127
160
  matchedAssets,
128
161
  muxAssets,
129
162
  openDialog,
package/src/schema.ts CHANGED
@@ -23,6 +23,11 @@ const muxTrack = {
23
23
  {type: 'number', name: 'max_frame_rate'},
24
24
  {type: 'number', name: 'duration'},
25
25
  {type: 'number', name: 'max_height'},
26
+ {type: 'string', name: 'language_code'},
27
+ {type: 'string', name: 'name'},
28
+ {type: 'string', name: 'status'},
29
+ {type: 'string', name: 'text_source'},
30
+ {type: 'string', name: 'text_type'},
26
31
  ],
27
32
  }
28
33
 
@@ -0,0 +1,30 @@
1
+ import {uuid} from '@sanity/uuid'
2
+
3
+ import type {MuxAsset} from './types'
4
+
5
+ /**
6
+ * Adds _key to array items in MuxAsset data for Sanity compatibility.
7
+ * Sanity requires _key on array items for proper editing support.
8
+ */
9
+ export function addKeysToMuxData(data: MuxAsset): MuxAsset {
10
+ return {
11
+ ...data,
12
+ tracks: data.tracks?.map((track) => ({
13
+ ...track,
14
+ _key: uuid(),
15
+ })),
16
+ playback_ids: data.playback_ids?.map((playbackId) => ({
17
+ ...playbackId,
18
+ _key: uuid(),
19
+ })),
20
+ static_renditions: data.static_renditions
21
+ ? {
22
+ ...data.static_renditions,
23
+ files: data.static_renditions.files?.map((file) => ({
24
+ ...file,
25
+ _key: uuid(),
26
+ })),
27
+ }
28
+ : undefined,
29
+ }
30
+ }
@@ -0,0 +1,160 @@
1
+ import {roundPxString} from './roundPxString'
2
+ import type {MuxOverlaySettings, WatermarkConfig} from './types'
3
+
4
+ /**
5
+ * Converts a draggable watermark position (x, y percentages) to Mux's overlay_settings format.
6
+ *
7
+ * @param watermark - The watermark configuration with position, size, and opacity
8
+ * @returns Mux overlay_settings object
9
+ * @see {@link https://www.mux.com/docs/guides/add-watermarks-to-your-videos}
10
+ */
11
+ export function convertWatermarkToMuxOverlay(
12
+ watermark: WatermarkConfig,
13
+ options?: {
14
+ /**
15
+ * Video aspect ratio (width / height). Needed for correct vertical positioning,
16
+ * especially on vertical videos.
17
+ */
18
+ videoAspectRatio?: number
19
+ /**
20
+ * Unit to emit for margins/width when generating overlay_settings from Canvas mode.
21
+ * - 'px' will generate pixel strings according to Mux's scaling rules:
22
+ * values are applied as if the video were scaled to 1920x1080 (horizontal)
23
+ * or 1080x1920 (vertical).
24
+ * - '%' preserves existing behavior.
25
+ */
26
+ units?: '%' | 'px'
27
+ }
28
+ ): MuxOverlaySettings | null {
29
+ if (!watermark.enabled || !watermark.imageUrl) {
30
+ return null
31
+ }
32
+
33
+ const size = watermark.size || 20
34
+ const opacity = watermark.opacity ?? 0.7
35
+
36
+ /**
37
+ * Convert a percentage to whole-pixel string, using Mux's base dimensions:
38
+ * - Horizontal video: 1920x1080
39
+ * - Vertical video: 1080x1920
40
+ */
41
+ const toPxString = (valuePercent: number, axis: 'x' | 'y') => {
42
+ const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
43
+ const isVertical = videoAspectRatio > 0 && videoAspectRatio < 1
44
+ const baseW = isVertical ? 1080 : 1920
45
+ const baseH = isVertical ? 1920 : 1080
46
+ const base = axis === 'x' ? baseW : baseH
47
+ const px = (valuePercent / 100) * base
48
+ let rounded = Math.round(px)
49
+ // Avoid sending 0px (and JS -0); keep sign for negative margins.
50
+ if (rounded === 0) rounded = px < 0 ? -1 : 1
51
+ return `${rounded}px`
52
+ }
53
+
54
+ const normalizeToPixels = (value: string | undefined, axis: 'x' | 'y'): string | undefined => {
55
+ if (!value) return value
56
+ const trimmed = value.trim()
57
+ if (trimmed.endsWith('px')) {
58
+ return roundPxString(trimmed)
59
+ }
60
+ if (trimmed.endsWith('%')) {
61
+ const n = Number(trimmed.slice(0, -1))
62
+ if (!Number.isFinite(n)) return value
63
+ return toPxString(n, axis)
64
+ }
65
+ return value
66
+ }
67
+
68
+ // If user provided explicit overlay settings, use them (Mux-documented format).
69
+ // When `options.units === 'px'`, we normalize both % and px to whole-pixel strings,
70
+ // honoring vertical vs horizontal video bases.
71
+ if (watermark.overlay_settings) {
72
+ const widthValue = watermark.overlay_settings.width
73
+ const widthNormalized =
74
+ options?.units === 'px' ? normalizeToPixels(widthValue, 'x') : widthValue
75
+ return {
76
+ ...watermark.overlay_settings,
77
+ horizontal_margin:
78
+ options?.units === 'px'
79
+ ? (normalizeToPixels(watermark.overlay_settings.horizontal_margin, 'x') ??
80
+ watermark.overlay_settings.horizontal_margin)
81
+ : watermark.overlay_settings.horizontal_margin,
82
+ vertical_margin:
83
+ options?.units === 'px'
84
+ ? (normalizeToPixels(watermark.overlay_settings.vertical_margin, 'y') ??
85
+ watermark.overlay_settings.vertical_margin)
86
+ : watermark.overlay_settings.vertical_margin,
87
+ width: widthNormalized ?? `${size}%`,
88
+ opacity: watermark.overlay_settings.opacity ?? `${Math.round(opacity * 100)}%`,
89
+ }
90
+ }
91
+
92
+ const position = watermark.position || {x: 50, y: 50}
93
+
94
+ /**
95
+ * Our UI stores watermark position as the *center point* in percentages.
96
+ * Mux margins are interpreted relative to an *edge* (based on align).
97
+ *
98
+ * To make "corner" placements match what the user dragged, we convert from
99
+ * center-position to top-left margins by subtracting half the watermark size.
100
+ *
101
+ * Note: `size` is a percentage of video width. Mux `width` is also expressed
102
+ * as a percentage of the video width, so we can reuse it for horizontal math.
103
+ * For vertical math, we approximate using the same percentage to keep behavior
104
+ * consistent with the current draggable UI (which also uses `size` in both axes
105
+ * for bounds).
106
+ */
107
+ // Allow negative margins to compensate for rounding / letterboxing edge-cases.
108
+ // We still clamp to a sane range so values don't explode.
109
+ const clampPercent = (value: number) => Math.max(-100, Math.min(100, value))
110
+
111
+ // Mux accepts percentage strings; avoid sending an exact "0%" by nudging to 0.01%.
112
+ // This also handles the JS -0 edge case and tiny floating point remnants.
113
+ const toPercentString = (value: number) => {
114
+ const epsilon = 1e-9
115
+ const isZeroish = value === 0 || Object.is(value, -0) || Math.abs(value) < epsilon
116
+ return `${isZeroish ? 0.01 : value}%`
117
+ }
118
+
119
+ const watermarkWidthPercentOfVideoWidth = size
120
+
121
+ /**
122
+ * Convert watermark height into % of video height.
123
+ * height% = (watermarkWidthPx / imageAspectRatio) / videoHeightPx
124
+ * = (size% * videoWidthPx / imageAspectRatio) / videoHeightPx
125
+ * = size% * (videoWidthPx/videoHeightPx) / imageAspectRatio
126
+ * = size% * videoAspectRatio / imageAspectRatio
127
+ */
128
+ const videoAspectRatio = options?.videoAspectRatio ?? 16 / 9
129
+ const imageAspectRatio = watermark.imageAspectRatio ?? 1
130
+ const watermarkHeightPercentOfVideoHeight = Math.max(
131
+ 0,
132
+ Math.min(100, (size * videoAspectRatio) / imageAspectRatio)
133
+ )
134
+
135
+ const halfWidth = watermarkWidthPercentOfVideoWidth / 2
136
+ const halfHeight = watermarkHeightPercentOfVideoHeight / 2
137
+
138
+ const leftMargin = clampPercent(
139
+ Math.min(position.x - halfWidth, 100 - watermarkWidthPercentOfVideoWidth)
140
+ )
141
+ const topMargin = clampPercent(
142
+ Math.min(position.y - halfHeight, 100 - watermarkHeightPercentOfVideoHeight)
143
+ )
144
+
145
+ const units = options?.units ?? '%'
146
+ const marginX = units === 'px' ? toPxString(leftMargin, 'x') : toPercentString(leftMargin)
147
+ const marginY = units === 'px' ? toPxString(topMargin, 'y') : toPercentString(topMargin)
148
+ const width = units === 'px' ? toPxString(size, 'x') : `${size}%`
149
+
150
+ const overlaySettings: MuxOverlaySettings = {
151
+ vertical_align: 'top',
152
+ vertical_margin: marginY,
153
+ horizontal_align: 'left',
154
+ horizontal_margin: marginX,
155
+ width,
156
+ opacity: `${Math.round(opacity * 100)}%`,
157
+ }
158
+
159
+ return overlaySettings
160
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Rounds the numeric part of a px string to the nearest integer.
3
+ * Returns undefined if the value is not a valid px string or not a finite number.
4
+ * Avoids sending 0px (and JS -0); snaps to ±1 instead.
5
+ */
6
+ export function roundPxString(value: unknown): string | undefined {
7
+ if (typeof value !== 'string') return undefined
8
+ const trimmed = value.trim()
9
+ if (!trimmed.endsWith('px')) return undefined
10
+ const n = Number(trimmed.slice(0, -2))
11
+ if (!Number.isFinite(n)) return undefined
12
+ let rounded = Math.round(n)
13
+ // Avoid sending 0px (and JS -0); keep sign when negative.
14
+ if (rounded === 0) rounded = n < 0 ? -1 : 1
15
+ return `${rounded}px`
16
+ }
package/src/util/types.ts CHANGED
@@ -274,12 +274,52 @@ export function isAutogeneratedTrack(
274
274
 
275
275
  export type UploadTextTrack = AutogeneratedTextTrack | CustomTextTrack
276
276
 
277
+ /**
278
+ * Watermark configuration for UI (draggable position)
279
+ */
280
+ export interface WatermarkConfig {
281
+ enabled: boolean
282
+ imageUrl?: string
283
+ /**
284
+ * Aspect ratio (width / height) of the watermark image.
285
+ * Used to convert between our center-based UI coords and Mux overlay margins.
286
+ */
287
+ imageAspectRatio?: number
288
+ /**
289
+ * Optional explicit Mux `overlay_settings`.
290
+ * When set, these values should be used as-is for the upload request and preview.
291
+ * @see {@link https://www.mux.com/docs/guides/add-watermarks-to-your-videos}
292
+ */
293
+ overlay_settings?: MuxOverlaySettings
294
+ position?: {
295
+ x: number // percentage (0-100)
296
+ y: number // percentage (0-100)
297
+ }
298
+ size?: number // percentage of video width (0-100)
299
+ opacity?: number // 0-1 (converted to percentage string for Mux API)
300
+ }
301
+
302
+ /**
303
+ * Mux overlay_settings format for watermark API
304
+ * @see {@link https://www.mux.com/docs/guides/add-watermarks-to-your-videos}
305
+ */
306
+ export interface MuxOverlaySettings {
307
+ vertical_align: 'top' | 'middle' | 'bottom'
308
+ vertical_margin: string // percentage (e.g., "10%") or pixels (e.g., "40px")
309
+ horizontal_align: 'left' | 'center' | 'right'
310
+ horizontal_margin: string // percentage (e.g., "10%") or pixels (e.g., "40px")
311
+ width?: string // percentage (e.g., "25%") or pixels (e.g., "80px")
312
+ height?: string // percentage or pixels
313
+ opacity?: string // percentage string (e.g., "90%")
314
+ }
315
+
277
316
  export interface UploadConfig
278
317
  extends Pick<MuxInputConfig, 'max_resolution_tier' | 'normalize_audio' | 'video_quality'> {
279
318
  static_renditions: StaticRenditionResolution[]
280
319
  text_tracks: UploadTextTrack[]
281
320
  signed_policy: boolean
282
321
  public_policy: boolean
322
+ watermark?: WatermarkConfig
283
323
  drm_policy: boolean
284
324
  }
285
325
 
@@ -309,7 +349,7 @@ export interface MuxNewAssetSettings
309
349
  /** The time offset in seconds from the beginning of the video indicating the clip's ending marker. */
310
350
  end_time?: number
311
351
  /** This parameter is required for text type tracks. */
312
- type: 'video' | 'audio' | 'text'
352
+ type?: 'video' | 'audio' | 'text'
313
353
  /** Type of text track. This parameter only supports subtitles value. */
314
354
  text_type?: 'subtitles'
315
355
  /** The language code value must be a valid BCP 47 specification compliant value. */
@@ -320,6 +360,8 @@ export interface MuxNewAssetSettings
320
360
  closed_captions?: boolean
321
361
  /** This optional parameter should be used tracks with type of text and text_type set to subtitles. */
322
362
  passthrough?: string
363
+ /** Overlay settings for watermarks. Used when adding a watermark image as a second input. */
364
+ overlay_settings?: MuxOverlaySettings
323
365
  }[]
324
366
 
325
367
  /** An array of playback policy names that you want applied to this asset and available through playback_ids. */