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.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/cli.js +172 -0
- package/dist/chunk-D5VS4ED7.js +6 -0
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +1 -0
- package/dist/studio/index.d.ts +395 -0
- package/dist/studio/index.js +1836 -0
- package/dist/studio/transcode.worker.d.ts +48 -0
- package/dist/studio/transcode.worker.js +132 -0
- package/package.json +88 -0
- package/worker/index.ts +181 -0
- package/worker/tsconfig.json +11 -0
|
@@ -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 };
|