sanity-plugin-r2-video 0.1.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,395 @@
1
+ export { resolveRenditionPath } from '../storage.js';
2
+ import { TranscodeOptions } from './transcode.worker.js';
3
+ import { VideoCodec, AudioCodec } from 'mediabunny';
4
+ import * as sanity from 'sanity';
5
+ import { SanityClient } from 'sanity';
6
+ import * as react from 'react';
7
+
8
+ /** A single encoded MP4 rendition, stored as one object in R2. */
9
+ type R2VideoRendition = {
10
+ width: number;
11
+ height: number;
12
+ key: string;
13
+ size: number;
14
+ };
15
+ /** A reference to another document, as Sanity stores it. */
16
+ type R2VideoReference = {
17
+ _type: "reference";
18
+ _ref: string;
19
+ };
20
+ /** A reference to a Sanity image asset, as stored on a document. */
21
+ type R2VideoPoster = {
22
+ _type: "image";
23
+ asset: R2VideoReference;
24
+ };
25
+ /**
26
+ * An `r2Video.asset` document. Metadata only — every MP4 lives in R2, and the
27
+ * poster lives in Sanity's own image pipeline so it inherits the CDN, the
28
+ * `srcset` helpers and the native preview branch.
29
+ */
30
+ type R2VideoAsset = {
31
+ _id: string;
32
+ _type: "r2Video.asset";
33
+ /** Display name. Editable — nothing in storage depends on it. */
34
+ filename: string;
35
+ /**
36
+ * The media library folder this video belongs to — the same documents the
37
+ * image browser uses, so folders are shared rather than mirrored. Object
38
+ * keys keep the prefix they were uploaded under, so renaming a folder moves
39
+ * the video in the Studio without invalidating anything already in R2.
40
+ */
41
+ folder?: R2VideoReference;
42
+ poster: R2VideoPoster;
43
+ duration: number;
44
+ hasAudio: boolean;
45
+ renditions: R2VideoRendition[];
46
+ uploadedAt: string;
47
+ };
48
+ /** The value an `r2Video` field holds — a reference to an `r2Video.asset`. */
49
+ type R2VideoValue = {
50
+ _type?: "r2Video";
51
+ asset?: R2VideoReference;
52
+ };
53
+ /** Field-level options for an `r2Video` field. */
54
+ type R2VideoFieldOptions = {
55
+ /** Id of the folder new uploads from this field are filed under. */
56
+ folder?: string;
57
+ };
58
+ /**
59
+ * Encoding options, applied to every rendition.
60
+ *
61
+ * Every field is optional; what each falls back to is in `defaults.ts`.
62
+ */
63
+ type R2VideoEncodingConfig = {
64
+ /**
65
+ * Rendition heights to produce, in any order. A source shorter than a tier
66
+ * skips it — nothing is ever upscaled.
67
+ */
68
+ heights?: number[];
69
+ /**
70
+ * Video codec. `avc` (h264) is the only one every browser plays from a plain
71
+ * `<video src>`, so change it only if you know the audience.
72
+ */
73
+ videoCodec?: VideoCodec;
74
+ /** Audio codec, used only when an upload opts into keeping audio. */
75
+ audioCodec?: AudioCodec;
76
+ /**
77
+ * Compression level passed to both encoders, from 0 (worst) to 1 (best).
78
+ *
79
+ * For codecs that support it — h264 included — this maps to a **quantizer**,
80
+ * not a bitrate. That means constant quality and *variable file size*: the
81
+ * output is as large as the footage needs to hit that quality, so detailed
82
+ * or grainy material produces much bigger files than flat material.
83
+ *
84
+ * ```
85
+ * 0.5 QP 28 good 0.75 QP 22 transparent
86
+ * 0.85 QP 20 transparent 1 QP 16 near-lossless, size unbounded
87
+ * ```
88
+ *
89
+ * QP 22 is h264's practical transparency threshold, which is why 0.75 is the
90
+ * default. Going to 1 can produce a file **larger than the source** when the
91
+ * source was exported at a normal quantizer — you are asking for a
92
+ * higher-fidelity encode than the original, and encoding can't add back
93
+ * detail that was never captured.
94
+ */
95
+ quality?: number;
96
+ /**
97
+ * Encode to a target **bitrate** instead of a quantizer.
98
+ *
99
+ * Flips the trade-off `quality` makes. The quantizer default is constant
100
+ * quality with variable size — grainy footage produces far bigger files than
101
+ * flat footage. With this on, size becomes predictable and quality varies
102
+ * instead: a tier lands at roughly the same weight whatever you feed it.
103
+ *
104
+ * The target is derived from frame size and `quality`, using 3 Mbps at
105
+ * 1920×1080 as the reference and a multiplier from the quality curve — so
106
+ * `0.75` is about 6.1 Mbps at 1080p, and `0.5` about 3.2 Mbps.
107
+ *
108
+ * Worth turning on when knowing what lands in the bucket matters more than
109
+ * every clip hitting the same visual bar.
110
+ */
111
+ preferBitrate?: boolean;
112
+ /**
113
+ * Copy the top rendition straight from the source instead of re-encoding it,
114
+ * when its height and codec already match. Mediabunny then copies rather
115
+ * than transcodes: instant, and bit-identical to the upload.
116
+ *
117
+ * Off by default because it hands size control to whoever exported the file
118
+ * — a 60 Mbps master would be stored at 60 Mbps. That tier is rarely the one
119
+ * served, since playback picks by element size, but the bytes are real.
120
+ */
121
+ nativeTopTier?: boolean;
122
+ };
123
+ /**
124
+ * Options for `r2Video`.
125
+ *
126
+ * Only `endpointUrl`, `token` and `bucketUrl` are required; what every other field
127
+ * falls back to is in `defaults.ts`.
128
+ */
129
+ type R2VideoPluginConfig = {
130
+ /**
131
+ * Origin of the deployed Worker that owns uploads and deletes. The Worker
132
+ * holds the R2 binding; the Studio never touches the bucket directly.
133
+ */
134
+ endpointUrl: string;
135
+ /**
136
+ * Shared secret the Worker checks. This ships inside the Studio bundle, so
137
+ * it gates casual access rather than providing real authentication — pair it
138
+ * with the Worker's origin allowlist.
139
+ */
140
+ token: string;
141
+ /**
142
+ * Origin the renditions are served from - the bucket's public URL, or the
143
+ * custom domain attached to it. Source URLs are built from this plus each
144
+ * rendition's key, so no document stores an origin.
145
+ */
146
+ bucketUrl: string;
147
+ /** Sanity API version the plugin's own queries and mutations run against. */
148
+ apiVersion?: string;
149
+ /** Optional tool configuration for the Studio plugin. */
150
+ tool?: {
151
+ name?: string;
152
+ title?: string;
153
+ };
154
+ /** Where videos and their posters are filed in the media library. */
155
+ folders?: {
156
+ /**
157
+ * Document type folders are read from. Defaults to
158
+ * `sanity-plugin-media`'s own, so the video library and the image browser
159
+ * share one set. Point it elsewhere to decouple them.
160
+ */
161
+ type?: string;
162
+ /** Folder generated posters are filed under, created on first upload. */
163
+ poster?: string;
164
+ };
165
+ /** Encoding options. See `R2VideoEncodingConfig`. */
166
+ encoding?: R2VideoEncodingConfig;
167
+ };
168
+
169
+ type WithNested = {
170
+ tool: Required<NonNullable<R2VideoPluginConfig["tool"]>>;
171
+ folders: Required<NonNullable<R2VideoPluginConfig["folders"]>>;
172
+ encoding: TranscodeOptions;
173
+ };
174
+ type ResolvedR2VideoConfig = R2VideoPluginConfig & Required<Pick<R2VideoPluginConfig, "apiVersion">> & WithNested;
175
+
176
+ /** A document still pointing at an asset, and therefore blocking its delete. */
177
+ type ReferencingDocument = {
178
+ _id: string;
179
+ _type: string;
180
+ title?: string;
181
+ };
182
+ /** Documents that would break if the asset went away. */
183
+ declare const findReferencingDocuments: (client: SanityClient, id: string) => Promise<ReferencingDocument[]>;
184
+ /**
185
+ * Removes an asset from Sanity and R2. The order is forced twice over.
186
+ *
187
+ * The document goes before the poster, because it holds the strong reference
188
+ * that would otherwise 409. And Sanity goes before R2, because the two failure
189
+ * modes are not symmetric — an orphaned object is invisible and costs pennies,
190
+ * whereas a document pointing at deleted media breaks the site.
191
+ */
192
+ declare const deleteVideoAsset: (client: SanityClient, config: R2VideoPluginConfig, asset: R2VideoAsset) => Promise<void>;
193
+
194
+ /**
195
+ * A folder from the media library. These are the *same* documents the image
196
+ * browser uses, not a parallel set — a folder made in either place shows up in
197
+ * both, so there is one place folders are defined.
198
+ */
199
+ type MediaFolder = {
200
+ _id: string;
201
+ name: string;
202
+ parentId: string | null;
203
+ };
204
+ declare const fetchFolders: (client: SanityClient, folderType: string) => Promise<MediaFolder[]>;
205
+ /**
206
+ * Slash-joined path for a folder, walking up the parent chain. Guards against
207
+ * a cycle, which a hand-edited `parent` reference could otherwise create.
208
+ */
209
+ declare const resolveFolderPath: (folders: MediaFolder[], id: string) => string;
210
+ /** A folder as a picker lists it: its id and its full slash-joined path. */
211
+ type FolderPath = {
212
+ id: string;
213
+ path: string;
214
+ };
215
+ /** Folder paths, sorted, for listing in a picker. */
216
+ declare const resolveFolderPaths: (folders: MediaFolder[]) => FolderPath[];
217
+
218
+ /**
219
+ * The Sanity plugin for R2 Video. Provides a video asset type, a reference
220
+ * field to it, and a tool for managing videos.
221
+ */
222
+ declare const r2Video: sanity.Plugin<R2VideoPluginConfig>;
223
+
224
+ /**
225
+ * The library document. Everything but `filename` and `folder` is written by the
226
+ * upload pipeline and read-only — there is nothing an editor can usefully
227
+ * correct by hand, and a stale `renditions` entry would point at an object that
228
+ * isn't in the bucket.
229
+ */
230
+ declare const createVideoAssetSchema: (config: ResolvedR2VideoConfig) => {
231
+ title: string;
232
+ name: "r2Video.asset";
233
+ type: "document";
234
+ components: {
235
+ input: (props: sanity.ObjectInputProps) => react.JSX.Element;
236
+ };
237
+ fields: (({
238
+ title: string;
239
+ name: "filename";
240
+ type: "string";
241
+ description: string;
242
+ validation: (Rule: sanity.StringRule) => sanity.StringRule;
243
+ } & sanity.WidenValidation) | {
244
+ title: string;
245
+ name: "folder";
246
+ type: "reference";
247
+ to: {
248
+ type: string;
249
+ }[];
250
+ description: string;
251
+ } | ({
252
+ title: string;
253
+ name: "poster";
254
+ type: "image";
255
+ readOnly: true;
256
+ description: string;
257
+ validation: (Rule: sanity.ImageRule) => sanity.ImageRule;
258
+ } & sanity.WidenValidation) | {
259
+ title: string;
260
+ name: "duration";
261
+ type: "number";
262
+ readOnly: true;
263
+ } | ({
264
+ title: string;
265
+ name: "hasAudio";
266
+ type: "boolean";
267
+ readOnly: true;
268
+ initialValue: false;
269
+ } & sanity.WidenInitialValue) | {
270
+ title: string;
271
+ name: "renditions";
272
+ type: "array";
273
+ readOnly: true;
274
+ of: {
275
+ type: "object";
276
+ name: string;
277
+ fields: ({
278
+ name: string;
279
+ type: "number";
280
+ } | {
281
+ name: string;
282
+ type: "string";
283
+ })[];
284
+ preview: {
285
+ select: {
286
+ width: string;
287
+ height: string;
288
+ size: string;
289
+ };
290
+ prepare({ width, height, size }: Record<string, any>): {
291
+ title: string;
292
+ subtitle: string;
293
+ };
294
+ };
295
+ }[];
296
+ } | {
297
+ title: string;
298
+ name: "uploadedAt";
299
+ type: "datetime";
300
+ readOnly: true;
301
+ })[];
302
+ preview: {
303
+ select: {
304
+ filename: string;
305
+ folder: string;
306
+ media: string;
307
+ };
308
+ prepare({ filename, folder, media }: Record<"filename" | "media" | "folder", any>): {
309
+ title: any;
310
+ subtitle: any;
311
+ media: any;
312
+ };
313
+ };
314
+ };
315
+ /**
316
+ * The field an `asset` object composes. Stores nothing but a reference, so the
317
+ * same video can be reused across documents without re-encoding it.
318
+ */
319
+ declare const SCHEMA_R2_VIDEO: {
320
+ title: string;
321
+ name: "r2Video";
322
+ type: "object";
323
+ options: {
324
+ collapsible: false;
325
+ };
326
+ components: {
327
+ input: (props: sanity.ObjectInputProps<R2VideoValue>) => react.JSX.Element;
328
+ };
329
+ fields: {
330
+ title: string;
331
+ name: "asset";
332
+ type: "reference";
333
+ to: {
334
+ type: string;
335
+ }[];
336
+ }[];
337
+ preview: {
338
+ select: {
339
+ filename: string;
340
+ media: string;
341
+ };
342
+ prepare({ filename, media }: Record<"filename" | "media", any>): {
343
+ title: any;
344
+ media: any;
345
+ };
346
+ };
347
+ };
348
+
349
+ type UploadStage = "encoding" | "storing" | "saving";
350
+ type UploadProgress = {
351
+ stage: UploadStage;
352
+ progress: number;
353
+ label: string;
354
+ };
355
+ type UploadRequest = {
356
+ client: SanityClient;
357
+ config: ResolvedR2VideoConfig;
358
+ file: File;
359
+ /** Media library folder id, or empty for none. */
360
+ folderId: string;
361
+ keepAudio: boolean;
362
+ /** Encoding settings for this upload, defaulting to the plugin's config. */
363
+ encoding: TranscodeOptions;
364
+ progressed: (progress: UploadProgress) => void;
365
+ };
366
+ /**
367
+ * Encodes a source into the full ladder, stores every rendition in R2, puts the
368
+ * poster through Sanity's own image pipeline, and writes the document that ties
369
+ * them together. Resolves with the created asset.
370
+ *
371
+ * The document is written last on purpose, so a failure can never leave a video
372
+ * in the library pointing at files that aren't there. Everything created before
373
+ * that point is rolled back if any step throws.
374
+ */
375
+ declare const uploadVideo: ({ client, config, file, folderId, keepAudio, encoding, progressed, }: UploadRequest) => Promise<R2VideoAsset>;
376
+
377
+ /**
378
+ * The rendition to play in the Studio: the second-tallest available. The top
379
+ * tier is several times the bytes for a player a few hundred pixels wide, and
380
+ * taking one step down works whatever ladder a project configures rather than
381
+ * pinning a tier this file has no business knowing about.
382
+ */
383
+ declare const resolvePreviewRendition: (renditions: R2VideoRendition[]) => R2VideoRendition | undefined;
384
+ type Props = {
385
+ renditions: R2VideoRendition[];
386
+ posterUrl?: string;
387
+ };
388
+ /**
389
+ * Plays the video rather than showing its first frame. Muted and looping to
390
+ * match how these are used on the site, but with controls — in the Studio the
391
+ * point is to check the footage, so scrubbing has to be possible.
392
+ */
393
+ declare const VideoPreview: ({ renditions, posterUrl }: Props) => react.JSX.Element;
394
+
395
+ export { type MediaFolder, type R2VideoAsset, type R2VideoEncodingConfig, type R2VideoFieldOptions, type R2VideoPluginConfig, type R2VideoPoster, type R2VideoReference, type R2VideoRendition, type R2VideoValue, type ReferencingDocument, type ResolvedR2VideoConfig, SCHEMA_R2_VIDEO, TranscodeOptions, VideoPreview, createVideoAssetSchema, deleteVideoAsset, fetchFolders, findReferencingDocuments, r2Video, resolveFolderPath, resolveFolderPaths, resolvePreviewRendition, uploadVideo };