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 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 nothing is ever upscaled.
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 an editor picks a file. Playback works everywhere.
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 browser, so the bucket needs no CORS policy.
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 file uploads made from this field under a fixed media library folder, pass its document id:
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
- | **Details** | Click a card to play the video and see every rendition with its size. Rename it, move it to another folder, or delete it from here. |
129
- | **Sync** | Lists objects in the bucket that no video document claims, and posters nothing references, then offers to delete them. |
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` | A quantizer for h264 rather than a bitrate, so quality stays constant and file size varies. `0.75` is QP 22, where h264 stops being distinguishable from the source. `1` is QP 16, near-lossless, and routinely produces files **larger than the source**. |
203
- | **preferBitrate** | `boolean` | `false` | Flips that trade for predictable size and variable quality. A 1080p tier lands near 6.1 Mbps at `0.75` whatever the footage. |
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.** What actually restricts access is the Worker's origin allowlist. Put Cloudflare Access in front of the Worker if you need real authentication.
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. Anything created before that point is 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.
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, each with its own tsconfig and its own type universe:
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 | `react`, `react-dom`, `@sanity/ui` |
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 is Chrome-only today.** WebCodecs h264 encoding is the requirement, and the gate is a `canEncodeVideo('avc')` capability check rather than user-agent sniffing.
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: [...(config.server?.fs?.allow ?? []), "/path/to/sanity-plugin-r2-video"],
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
  });
@@ -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