sanity-plugin-r2-video 0.1.10 → 0.2.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/README.md +58 -19
- package/dist/studio/index.d.ts +11 -17
- package/dist/studio/index.js +531 -278
- package/dist/studio/transcode.worker.d.ts +1 -1
- package/dist/studio/transcode.worker.js +43 -13
- package/package.json +1 -1
- package/worker/index.ts +7 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ j6w3wy2bd0jq/720.mp4 4.9 MB
|
|
|
17
17
|
j6w3wy2bd0jq/1080.mp4 9.7 MB
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Your site picks whichever one fits: the 360 behind a thumbnail, the 1080 in a hero. Each document keeps the whole list, with the height, key and size of every file, so choosing one takes a line of code. Upload a 480p clip and you get three files instead of five, as
|
|
20
|
+
Your site picks whichever one fits: the 360 behind a thumbnail, the 1080 in a hero. Each document keeps the whole list, with the height, key and size of every file, so choosing one takes a line of code. Upload a 480p clip and you get three files instead of five, as tiers taller than the source are skipped.
|
|
21
21
|
|
|
22
22
|
Encoding runs on the editor's machine with [mediabunny](https://mediabunny.dev), using WebCodecs in a Web Worker. A Cloudflare Worker you own writes the MP4s to your bucket. The poster is saved as an ordinary Sanity image, so it gets the Sanity CDN, `srcset`, `auto=format` and LQIP for free.
|
|
23
23
|
|
|
@@ -30,7 +30,7 @@ That means no encoding service, no per-minute bill, and no R2 credentials outsid
|
|
|
30
30
|
- Sanity Studio v6, React 19, `@sanity/ui` v4 or v5, `@sanity/icons` v5 and `styled-components` v6
|
|
31
31
|
- Node 20 or later
|
|
32
32
|
- A Cloudflare account with R2 enabled
|
|
33
|
-
- Chrome to upload. Encoding needs WebCodecs h264, which Safari doesn't reliably provide, so the Studio checks for it and says so before
|
|
33
|
+
- Chrome to upload. Encoding needs WebCodecs h264, which Safari doesn't reliably provide, so the Studio checks for it and says so before anything encodes. Playback works everywhere.
|
|
34
34
|
|
|
35
35
|
## Installation
|
|
36
36
|
|
|
@@ -52,7 +52,7 @@ wrangler r2 bucket create my-bucket
|
|
|
52
52
|
|
|
53
53
|
Then open the bucket's **Settings** in the Cloudflare dashboard and enable public access, either with the `r2.dev` development URL or a custom domain. That public origin is your `bucketUrl`, and without it nothing you upload is playable.
|
|
54
54
|
|
|
55
|
-
**Note**: Uploads go through the Worker rather than the
|
|
55
|
+
**Note**: Uploads go through the Worker rather than straight to the bucket, so the bucket needs no CORS policy. Use a bucket dedicated to this plugin, as **Sync** treats every object in it as a rendition.
|
|
56
56
|
|
|
57
57
|
### 2. Scaffold the Worker
|
|
58
58
|
|
|
@@ -64,7 +64,7 @@ npx sanity-plugin-r2-video setup worker
|
|
|
64
64
|
|
|
65
65
|
It asks for a Worker name, your Cloudflare account id, the bucket name, and the Studio origins allowed to call it. Pass `--name`, `--account`, `--bucket` and `--origins` to skip the prompts.
|
|
66
66
|
|
|
67
|
-
This writes `r2-video-worker/`, containing a `wrangler.jsonc` and an entry point that re-exports this package's handler. Upgrading the package upgrades the deployed Worker.
|
|
67
|
+
This writes `r2-video-worker/`, or a directory you pass, containing a `wrangler.jsonc`, a `tsconfig.json` and an entry point that re-exports this package's handler. Upgrading the package upgrades the deployed Worker.
|
|
68
68
|
|
|
69
69
|
### 3. Deploy the Worker
|
|
70
70
|
|
|
@@ -107,7 +107,7 @@ This registers an `r2Video.asset` document type, an `r2Video` field type, and an
|
|
|
107
107
|
defineField({ name: "video", title: "Video", type: "r2Video" })
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
To
|
|
110
|
+
To preselect a media library folder for uploads made from this field, pass its document id. Editors can still change it before uploading:
|
|
111
111
|
|
|
112
112
|
```ts
|
|
113
113
|
defineField({
|
|
@@ -125,10 +125,13 @@ Videos live in the **R2 Video** tool.
|
|
|
125
125
|
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
126
|
| **Upload** | Drop files on the library, or press Upload. Files stage in a list first and nothing encodes until you confirm. |
|
|
127
127
|
| **Settings** | Collapsed by default. Change the folder, keep audio, or adjust quality for one batch. **Preview** encodes only the tallest tier, so you can check size and quality before running the whole ladder. |
|
|
128
|
-
| **
|
|
129
|
-
| **
|
|
128
|
+
| **Search** | Matches filenames and folder names. |
|
|
129
|
+
| **Filters** | Narrow to videos **In a folder** or with **No folder**, and **In use** or **Unused**. Picking both of a pair shows both. |
|
|
130
|
+
| **Select** | Tick a card's checkbox, shown on hover, or shift-click the card. A selection can be moved to a folder or deleted together. |
|
|
131
|
+
| **Details** | Click a card to play the video and see every rendition with its size. Rename it, move it to another folder, replace it, or delete it from here. |
|
|
132
|
+
| **Sync** | Lists objects in the bucket that no video document claims, and posters in the poster folder that nothing references, then offers to delete them. |
|
|
130
133
|
|
|
131
|
-
Inside a document, an `r2Video` field picks from the library or uploads without leaving the page.
|
|
134
|
+
Inside a document, an `r2Video` field picks from the library, or uploads from its button or a file dropped onto it, without leaving the page.
|
|
132
135
|
|
|
133
136
|
### Playing video on your site
|
|
134
137
|
|
|
@@ -148,9 +151,11 @@ type R2VideoAsset = {
|
|
|
148
151
|
folder?: { _type: "reference"; _ref: string };
|
|
149
152
|
poster: { _type: "image"; asset: { _type: "reference"; _ref: string } };
|
|
150
153
|
duration: number; // seconds
|
|
154
|
+
frameRate?: number; // fps the renditions were encoded at, absent before 0.2.0
|
|
151
155
|
hasAudio: boolean;
|
|
152
156
|
uploadedAt: string; // ISO 8601
|
|
153
157
|
renditions: {
|
|
158
|
+
_key?: string; // Sanity array key
|
|
154
159
|
width: number;
|
|
155
160
|
height: number;
|
|
156
161
|
key: string; // R2 object key, "j6w3wy2bd0jq/720.mp4"
|
|
@@ -199,9 +204,8 @@ const src = `${bucketUrl}/${resolveRenditionPath(id, 720)}`;
|
|
|
199
204
|
| **heights** | `number[]` | `[270, 360, 480, 720, 1080]` | The tier ladder. A source shorter than a tier skips it, and every tier it does reach is another full encode, so this list is what upload time costs. |
|
|
200
205
|
| **videoCodec** | `string` | `"avc"` | `avc` (h264) is the only codec every browser plays from a plain `<video src>`. |
|
|
201
206
|
| **audioCodec** | `string` | `"aac"` | Used only when an upload opts into keeping audio. |
|
|
202
|
-
| **quality** | `number` | `0.75` |
|
|
203
|
-
| **
|
|
204
|
-
| **nativeTopTier** | `boolean` | `false` | Copies the top rendition instead of re-encoding it, when its height and codec already match the source. Instant and bit-identical, but its size is whatever the source was exported at. |
|
|
207
|
+
| **quality** | `number` | `0.75` | How much detail each frame keeps, from `0` (blocky) to `1` (near-lossless). Quality stays constant and file size follows the footage, so a static screen recording comes out small and busy camera footage large. `0.75` is hard to tell from the source. `1` routinely produces files **larger than the source**. |
|
|
208
|
+
| **nativeTopTier** | `boolean` | `false` | Copies the source's video into the top rendition instead of re-encoding it, when its height and codec already match. Instant and lossless, but that tier keeps the source's size and frame timing, and variable frame rates can stutter in browsers. |
|
|
205
209
|
|
|
206
210
|
## How it works
|
|
207
211
|
|
|
@@ -211,11 +215,28 @@ Studio ──encoded renditions──▶ Worker ──binding──▶ R2 bucket
|
|
|
211
215
|
└──poster──▶ Sanity image assets bucket URL ─┘
|
|
212
216
|
```
|
|
213
217
|
|
|
218
|
+
### Encoding
|
|
219
|
+
|
|
220
|
+
Encoding runs in a Web Worker in the editor's browser. [Mediabunny](https://mediabunny.dev) drives the browser's WebCodecs decoder and encoder.
|
|
221
|
+
|
|
222
|
+
1. **Read** the video's size, duration, codec, and whether it has audio.
|
|
223
|
+
2. **Measure the frame rate** from the most common gap between frames, so 29.97 stays 29.97. A screen recording reads as its real rate, such as 60, not its lower average.
|
|
224
|
+
3. **Save the first frame** as the poster, a full-size JPEG at 100% quality.
|
|
225
|
+
4. **Encode each tier**, tallest first, skipping any taller than the source. A source shorter than every tier gets the shortest one:
|
|
226
|
+
- resize, keeping the aspect ratio, with the width rounded to an even number
|
|
227
|
+
- retime to a constant frame rate, repeating held frames
|
|
228
|
+
- encode with `videoCodec`, h264 by default, at a fixed compression level set by `quality`, so file size follows the footage
|
|
229
|
+
- drop the audio, or re-encode it with `audioCodec`, AAC by default, if kept
|
|
230
|
+
- write an MP4 with its index first, so playback can start before it downloads
|
|
231
|
+
5. **Store** the poster in Sanity and the renditions in R2, then write the document.
|
|
232
|
+
|
|
233
|
+
Every tier encodes from the source, so none is a copy of another's compression. With `nativeTopTier`, a tallest tier matching the source's height and codec is copied without re-encoding, and keeps the source's frame timing.
|
|
234
|
+
|
|
214
235
|
### Security
|
|
215
236
|
|
|
216
237
|
The Worker holds the only R2 binding, so **no R2 credentials exist outside Cloudflare**. Renditions are sent as plain request bodies, which means nothing is signed and the bucket needs no CORS policy.
|
|
217
238
|
|
|
218
|
-
`UPLOAD_TOKEN` ships inside the Studio bundle, because the browser is what uploads. **Anyone who can load the Studio can read it.**
|
|
239
|
+
`UPLOAD_TOKEN` ships inside the Studio bundle, because the browser is what uploads. **Anyone who can load the Studio can read it.** The Worker's origin allowlist stops other websites uploading through a visitor's browser, but a script can send any `Origin` header, so anyone with the token can write to the bucket. Put Cloudflare Access in front of the Worker if you need real authentication.
|
|
219
240
|
|
|
220
241
|
### Keys
|
|
221
242
|
|
|
@@ -223,36 +244,49 @@ One directory per video, one object per tier, and no folder segment, as in `<id>
|
|
|
223
244
|
|
|
224
245
|
Documents store keys and heights rather than URLs, and both the Studio and your front end build sources from `bucketUrl`. Moving the bucket behind a custom domain is a config change rather than a migration. Keys carry no folder either, so renaming or moving a video in the Studio never touches the bucket.
|
|
225
246
|
|
|
247
|
+
### Replacing
|
|
248
|
+
|
|
249
|
+
**Replace** in a video's details uploads a new file into the same document, so everything referencing the video shows the new one without being relinked. The name and folder stay the same.
|
|
250
|
+
|
|
251
|
+
1. Encode and store the new renditions and poster, exactly as an upload does.
|
|
252
|
+
2. In one transaction, swap the document's renditions, poster and metadata, patch its unpublished draft if there is one, and repoint any other reference to the old poster at the new one.
|
|
253
|
+
3. Delete the old poster and renditions.
|
|
254
|
+
|
|
255
|
+
If anything fails before the transaction commits, the new files are rolled back and the video is unchanged. A failure deleting the old files leaves them unreferenced, for **Sync** to collect.
|
|
256
|
+
|
|
226
257
|
### Deleting
|
|
227
258
|
|
|
228
259
|
Order matters here, and `delete-video.ts` documents it:
|
|
229
260
|
|
|
230
261
|
1. Preflight `*[references($id)]`. Anything found blocks the delete.
|
|
231
|
-
2. Delete the document, which releases the strong reference to the poster.
|
|
262
|
+
2. Delete the document and any draft of it, which releases the strong reference to the poster.
|
|
232
263
|
3. Delete the poster asset, now unreferenced, so no `409`.
|
|
233
264
|
4. Delete the R2 objects, batched.
|
|
234
265
|
|
|
235
266
|
Sanity goes before R2. An orphaned object is invisible and costs pennies, while a document pointing at deleted media breaks the site.
|
|
236
267
|
|
|
268
|
+
Deleting a selection checks every video first, and deletes nothing if any is still in use.
|
|
269
|
+
|
|
237
270
|
### When an upload fails
|
|
238
271
|
|
|
239
|
-
The document is written last, so a failure can't leave a video in the library pointing at files that aren't there.
|
|
272
|
+
The document is written last, so a failure can't leave a video in the library pointing at files that aren't there. The poster and any stored renditions are rolled back, and keys are recorded before each upload rather than after, since a request that times out may still have stored the object. Rollback never throws, so the failure that started it is what you see.
|
|
240
273
|
|
|
241
274
|
A closed tab or a crash skips rollback, and encoded renditions live only in memory, so there's no resume. Whatever either case leaves behind is unreferenced, and **Sync** in the tool finds and removes it.
|
|
242
275
|
|
|
243
276
|
### Entry points
|
|
244
277
|
|
|
245
|
-
Three,
|
|
278
|
+
Three, so a consumer imports only what it needs:
|
|
246
279
|
|
|
247
280
|
| Import | Contains | Depends on |
|
|
248
281
|
| ------------------------------------- | ------------------ | --------------------------------- |
|
|
249
|
-
| `sanity-plugin-r2-video/studio` | The Sanity plugin | `
|
|
282
|
+
| `sanity-plugin-r2-video/studio` | The Sanity plugin | `sanity`, `react`, `@sanity/ui`, `mediabunny` |
|
|
250
283
|
| `sanity-plugin-r2-video/worker` | The endpoint | `@cloudflare/workers-types` |
|
|
251
284
|
| `sanity-plugin-r2-video/storage` | Where files live | Nothing |
|
|
252
285
|
|
|
253
286
|
## Limitations
|
|
254
287
|
|
|
255
|
-
- **Uploading
|
|
288
|
+
- **Uploading needs WebCodecs encoding.** Chrome can encode h264 and Safari doesn't reliably. The Studio checks `canEncodeVideo` for `videoCodec` rather than sniffing the browser.
|
|
289
|
+
- **Keeping audio needs WebCodecs encoding of `audioCodec`.** Where the browser can't, the Keep audio setting is disabled and audio is dropped.
|
|
256
290
|
- **100 MB per rendition.** Each MP4 is uploaded as a single request body. The source file can be much larger, but any one tier over 100 MB is rejected with a `413`, so lower `quality` or drop the tallest tier.
|
|
257
291
|
|
|
258
292
|
## Contributing
|
|
@@ -265,13 +299,14 @@ Want to get involved, or found an issue? Please contribute using the GitHub Flow
|
|
|
265
299
|
pnpm install
|
|
266
300
|
pnpm run type-check
|
|
267
301
|
pnpm run build # tsup: ESM + types into dist
|
|
302
|
+
pnpm run dev # tsup --watch
|
|
268
303
|
```
|
|
269
304
|
|
|
270
305
|
`./worker` ships as TypeScript. Wrangler compiles it, and the generated Worker extends `worker/tsconfig.json` for the compiler options it was written against.
|
|
271
306
|
|
|
272
307
|
### Linking it into a Studio
|
|
273
308
|
|
|
274
|
-
Point a Studio at a checkout with a `link:` override, then allow this directory in Vite. The encoder is loaded as a worker by URL rather than imported, so it never enters the module graph Vite serves by default, and every upload fails on a 403 without this:
|
|
309
|
+
Point a Studio at a checkout with a `link:` override, then allow this directory in Vite. The encoder is loaded as a worker by URL rather than imported, so it never enters the module graph Vite serves by default, and every upload fails on a 403 without this. Naming any path replaces Vite's default allow list, so include the Studio's workspace root too, or the Studio itself fails on a 403:
|
|
275
310
|
|
|
276
311
|
```ts
|
|
277
312
|
// sanity.cli.ts
|
|
@@ -281,7 +316,11 @@ vite: (config) => ({
|
|
|
281
316
|
...config.server,
|
|
282
317
|
fs: {
|
|
283
318
|
...config.server?.fs,
|
|
284
|
-
allow: [
|
|
319
|
+
allow: [
|
|
320
|
+
...(config.server?.fs?.allow ?? []),
|
|
321
|
+
"/path/to/studio-workspace-root",
|
|
322
|
+
"/path/to/sanity-plugin-r2-video",
|
|
323
|
+
],
|
|
285
324
|
},
|
|
286
325
|
},
|
|
287
326
|
});
|
package/dist/studio/index.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ type R2VideoAsset = {
|
|
|
46
46
|
folder?: R2VideoReference;
|
|
47
47
|
poster: R2VideoPoster;
|
|
48
48
|
duration: number;
|
|
49
|
+
/** Frame rate the renditions were encoded at. Absent on videos uploaded before 0.2.0. */
|
|
50
|
+
frameRate?: number;
|
|
49
51
|
hasAudio: boolean;
|
|
50
52
|
renditions: R2VideoRendition[];
|
|
51
53
|
uploadedAt: string;
|
|
@@ -98,22 +100,6 @@ type R2VideoEncodingConfig = {
|
|
|
98
100
|
* detail that was never captured.
|
|
99
101
|
*/
|
|
100
102
|
quality?: number;
|
|
101
|
-
/**
|
|
102
|
-
* Encode to a target **bitrate** instead of a quantizer.
|
|
103
|
-
*
|
|
104
|
-
* Flips the trade-off `quality` makes. The quantizer default is constant
|
|
105
|
-
* quality with variable size - grainy footage produces far bigger files than
|
|
106
|
-
* flat footage. With this on, size becomes predictable and quality varies
|
|
107
|
-
* instead: a tier lands at roughly the same weight whatever you feed it.
|
|
108
|
-
*
|
|
109
|
-
* The target is derived from frame size and `quality`, using 3 Mbps at
|
|
110
|
-
* 1920×1080 as the reference and a multiplier from the quality curve - so
|
|
111
|
-
* `0.75` is about 6.1 Mbps at 1080p, and `0.5` about 3.2 Mbps.
|
|
112
|
-
*
|
|
113
|
-
* Worth turning on when knowing what lands in the bucket matters more than
|
|
114
|
-
* every clip hitting the same visual bar.
|
|
115
|
-
*/
|
|
116
|
-
preferBitrate?: boolean;
|
|
117
103
|
/**
|
|
118
104
|
* Copy the top rendition straight from the source instead of re-encoding it,
|
|
119
105
|
* when its height and codec already match. Mediabunny then copies rather
|
|
@@ -236,6 +222,7 @@ declare const createVideoAssetSchema: (config: ResolvedR2VideoConfig) => {
|
|
|
236
222
|
title: string;
|
|
237
223
|
name: "r2Video.asset";
|
|
238
224
|
type: "document";
|
|
225
|
+
liveEdit: true;
|
|
239
226
|
components: {
|
|
240
227
|
input: (props: sanity.ObjectInputProps) => react.JSX.Element;
|
|
241
228
|
};
|
|
@@ -265,6 +252,11 @@ declare const createVideoAssetSchema: (config: ResolvedR2VideoConfig) => {
|
|
|
265
252
|
name: "duration";
|
|
266
253
|
type: "number";
|
|
267
254
|
readOnly: true;
|
|
255
|
+
} | {
|
|
256
|
+
title: string;
|
|
257
|
+
name: "frameRate";
|
|
258
|
+
type: "number";
|
|
259
|
+
readOnly: true;
|
|
268
260
|
} | ({
|
|
269
261
|
title: string;
|
|
270
262
|
name: "hasAudio";
|
|
@@ -369,6 +361,8 @@ type UploadRequest = {
|
|
|
369
361
|
keepAudio: boolean;
|
|
370
362
|
/** Encoding settings for this upload, defaulting to the plugin's config. */
|
|
371
363
|
encoding: TranscodeOptions;
|
|
364
|
+
/** An existing video to swap this upload into, instead of creating one. */
|
|
365
|
+
replacing?: R2VideoAsset;
|
|
372
366
|
progressed: (progress: UploadProgress) => void;
|
|
373
367
|
};
|
|
374
368
|
/**
|
|
@@ -380,7 +374,7 @@ type UploadRequest = {
|
|
|
380
374
|
* in the library pointing at files that aren't there. Everything created before
|
|
381
375
|
* that point is rolled back if any step throws.
|
|
382
376
|
*/
|
|
383
|
-
declare const uploadVideo: ({ client, config, file, folderId, keepAudio, encoding, progressed, }: UploadRequest) => Promise<R2VideoAsset>;
|
|
377
|
+
declare const uploadVideo: ({ client, config, file, folderId, keepAudio, encoding, replacing, progressed, }: UploadRequest) => Promise<R2VideoAsset>;
|
|
384
378
|
|
|
385
379
|
/**
|
|
386
380
|
* The rendition to play in the Studio: the second-tallest available. The top
|