sanity-plugin-r2-video 0.1.0 → 0.1.2
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 +184 -169
- package/dist/studio/index.d.ts +6 -1
- package/dist/studio/index.js +104 -56
- package/package.json +14 -13
package/README.md
CHANGED
|
@@ -1,252 +1,263 @@
|
|
|
1
1
|
# sanity-plugin-r2-video
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[![NPM version][npm-image]][npm-url]
|
|
4
|
+
[![NPM downloads][npm-downloads-image]][npm-downloads-url]
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
WebCodecs, in a Web Worker. Each source becomes one MP4 per rendition tier, and
|
|
7
|
-
the first frame is uploaded to Sanity as a native image, so posters get the
|
|
8
|
-
Sanity CDN, `srcset`, `auto=format` and LQIP for free. A rendition is picked
|
|
9
|
-
once at load from the element's size, and never swapped.
|
|
6
|
+
A video plugin for Sanity Studio that encodes in the browser and stores plain MP4s in Cloudflare R2.
|
|
10
7
|
|
|
11
|
-
##
|
|
8
|
+
## Introduction
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
pnpm add sanity-plugin-r2-video
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
`sanity`, `react`, `react-dom`, `@sanity/ui`, `@sanity/icons` and
|
|
18
|
-
`styled-components` are peer dependencies. `mediabunny` is the only real
|
|
19
|
-
dependency.
|
|
20
|
-
|
|
21
|
-
Then add the plugin, and a Worker for it to talk to:
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
// sanity.config.ts
|
|
25
|
-
import { r2Video } from "sanity-plugin-r2-video/studio";
|
|
10
|
+
An editor drops in one file. Out come **renditions** — one MP4 per height you configured, plus the first frame as a poster:
|
|
26
11
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}),
|
|
34
|
-
],
|
|
35
|
-
});
|
|
12
|
+
```
|
|
13
|
+
j6w3wy2bd0jq/270.mp4 840 KB
|
|
14
|
+
j6w3wy2bd0jq/360.mp4 1.4 MB
|
|
15
|
+
j6w3wy2bd0jq/480.mp4 2.3 MB
|
|
16
|
+
j6w3wy2bd0jq/720.mp4 4.9 MB
|
|
17
|
+
j6w3wy2bd0jq/1080.mp4 9.7 MB
|
|
36
18
|
```
|
|
37
19
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Everything happens in the **R2 Video** tool in the Studio.
|
|
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.
|
|
41
21
|
|
|
42
|
-
|
|
43
|
-
list first; nothing encodes until you confirm.
|
|
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.
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
or adjust quality for this batch only. **Preview** encodes just the tallest tier,
|
|
47
|
-
so you can see the size and quality those settings produce before committing.
|
|
24
|
+
That means no encoding service, no per-minute bill, and no R2 credentials outside Cloudflare.
|
|
48
25
|
|
|
49
|
-
**
|
|
50
|
-
size, and lets you rename it, move it to another folder, or delete it.
|
|
26
|
+
**Note**: These are plain MP4s, not adaptive streams. A player picks one rendition when it loads and keeps it.
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
remove anything no document points at.
|
|
28
|
+
## Requirements
|
|
54
29
|
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
- Sanity Studio v6, React 19, `@sanity/ui` v4 or v5, `@sanity/icons` v5 and `styled-components` v6
|
|
31
|
+
- Node 20 or later
|
|
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.
|
|
57
34
|
|
|
58
|
-
##
|
|
35
|
+
## Installation
|
|
59
36
|
|
|
60
|
-
|
|
61
|
-
back to [`studio/defaults.ts`](studio/defaults.ts), shown here with its defaults.
|
|
37
|
+
Install this package with `npm`.
|
|
62
38
|
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
endpointUrl: "https://….workers.dev",
|
|
66
|
-
token: "…",
|
|
67
|
-
bucketUrl: "https://….r2.dev",
|
|
68
|
-
apiVersion: "2024-01-01",
|
|
69
|
-
tool: { name: "r2-video", title: "R2 Video" },
|
|
70
|
-
folders: {
|
|
71
|
-
type: "media.folder", // document type folders are read from
|
|
72
|
-
poster: "_R2 Video Posters", // where generated posters are filed
|
|
73
|
-
},
|
|
74
|
-
encoding: {
|
|
75
|
-
heights: [270, 360, 480, 720, 1080],
|
|
76
|
-
videoCodec: "avc",
|
|
77
|
-
audioCodec: "aac",
|
|
78
|
-
quality: 0.75,
|
|
79
|
-
preferBitrate: false,
|
|
80
|
-
nativeTopTier: false,
|
|
81
|
-
},
|
|
82
|
-
});
|
|
39
|
+
```bash
|
|
40
|
+
npm i sanity-plugin-r2-video
|
|
83
41
|
```
|
|
84
42
|
|
|
85
|
-
|
|
43
|
+
## Setup
|
|
44
|
+
|
|
45
|
+
Five steps, in order. The plugin needs a deployed Worker before it can be configured.
|
|
86
46
|
|
|
87
|
-
|
|
88
|
-
tier it does reach is another full encode.
|
|
47
|
+
### 1. Create the bucket
|
|
89
48
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
than the source**.
|
|
49
|
+
```bash
|
|
50
|
+
wrangler r2 bucket create my-bucket
|
|
51
|
+
```
|
|
94
52
|
|
|
95
|
-
|
|
96
|
-
1080p tier lands near 6.1 Mbps at `0.75` whatever the footage.
|
|
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.
|
|
97
54
|
|
|
98
|
-
|
|
99
|
-
height and codec already match the source — instant and bit-identical. Its size
|
|
100
|
-
is then whatever the source was exported at.
|
|
55
|
+
**Note**: Uploads go through the Worker rather than the browser, so the bucket needs no CORS policy.
|
|
101
56
|
|
|
102
|
-
|
|
57
|
+
### 2. Scaffold the Worker
|
|
103
58
|
|
|
104
|
-
The Worker
|
|
105
|
-
and compatibility date are fixed, and the CLI fills in the rest:
|
|
59
|
+
The Worker owns the R2 binding. To generate one:
|
|
106
60
|
|
|
107
61
|
```bash
|
|
108
|
-
|
|
62
|
+
npx sanity-plugin-r2-video setup worker
|
|
109
63
|
```
|
|
110
64
|
|
|
111
|
-
Pass `--
|
|
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
|
+
|
|
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.
|
|
68
|
+
|
|
69
|
+
### 3. Deploy the Worker
|
|
112
70
|
|
|
113
|
-
|
|
114
|
-
deploy:
|
|
71
|
+
Install its dependencies, generate a shared secret, then deploy:
|
|
115
72
|
|
|
116
73
|
```bash
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
wrangler r2 bucket create my-bucket
|
|
74
|
+
npm i sanity-plugin-r2-video
|
|
75
|
+
npm i -D wrangler @cloudflare/workers-types
|
|
120
76
|
openssl rand -base64 32
|
|
121
77
|
wrangler secret put UPLOAD_TOKEN --config r2-video-worker/wrangler.jsonc
|
|
122
78
|
wrangler deploy --config r2-video-worker/wrangler.jsonc
|
|
123
79
|
```
|
|
124
80
|
|
|
125
|
-
|
|
126
|
-
and the bucket's public URL as `bucketUrl`.
|
|
81
|
+
Keep the generated secret, as the Studio needs the same value in the next step.
|
|
127
82
|
|
|
128
|
-
|
|
129
|
-
package upgrades the deployed Worker. Only deployment identity — name, account,
|
|
130
|
-
bucket, origins — is written out.
|
|
83
|
+
**Note**: `--origins` defaults to `http://localhost:3333`. When you deploy the Studio, add its production origin to `ALLOWED_ORIGINS` in `wrangler.jsonc` and deploy the Worker again, or requests from it get a `403`.
|
|
131
84
|
|
|
132
|
-
|
|
85
|
+
### 4. Add the plugin
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// sanity.config.ts
|
|
89
|
+
import { r2Video } from "sanity-plugin-r2-video/studio";
|
|
133
90
|
|
|
91
|
+
export default defineConfig({
|
|
92
|
+
plugins: [
|
|
93
|
+
r2Video({
|
|
94
|
+
endpointUrl: "https://….workers.dev", // Worker you deployed
|
|
95
|
+
token: "…", // UPLOAD_TOKEN you generated
|
|
96
|
+
bucketUrl: "https://….r2.dev", // bucket's public origin
|
|
97
|
+
}),
|
|
98
|
+
],
|
|
99
|
+
});
|
|
134
100
|
```
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
101
|
+
|
|
102
|
+
This registers an `r2Video.asset` document type, an `r2Video` field type, and an **R2 Video** tool.
|
|
103
|
+
|
|
104
|
+
### 5. Add a field
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
defineField({ name: "video", title: "Video", type: "r2Video" })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
To file uploads made from this field under a fixed media library folder, pass its document id:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
defineField({
|
|
114
|
+
name: "video",
|
|
115
|
+
type: "r2Video",
|
|
116
|
+
options: { folder: "<folder document id>" },
|
|
117
|
+
})
|
|
138
118
|
```
|
|
139
119
|
|
|
140
|
-
|
|
141
|
-
Cloudflare**. Uploads go through it as plain request bodies — nothing is signed,
|
|
142
|
-
and the bucket needs no CORS policy. A rendition has to fit in one request body,
|
|
143
|
-
which caps an upload at 100 MB.
|
|
120
|
+
## Usage
|
|
144
121
|
|
|
145
|
-
|
|
146
|
-
the upload — **anyone who can load the Studio can read it**. The Worker's origin
|
|
147
|
-
allowlist is what restricts access. Put Cloudflare Access in front of the Worker
|
|
148
|
-
if you need authentication.
|
|
122
|
+
Videos live in the **R2 Video** tool.
|
|
149
123
|
|
|
150
|
-
|
|
124
|
+
| Action | Notes |
|
|
125
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| **Upload** | Drop files on the library, or press Upload. Files stage in a list first and nothing encodes until you confirm. |
|
|
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. |
|
|
151
130
|
|
|
152
|
-
|
|
131
|
+
Inside a document, an `r2Video` field picks from the library or uploads without leaving the page.
|
|
153
132
|
|
|
133
|
+
### Playing video on your site
|
|
134
|
+
|
|
135
|
+
A field stores a reference and nothing else, so the same video can be used on many documents without encoding it twice:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{ "_type": "r2Video", "asset": { "_type": "reference", "_ref": "hK3n…" } }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Follow that reference for an `r2Video.asset` document. It holds metadata only, as the MP4s are in R2 and the poster is a normal Sanity image asset:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
type R2VideoAsset = {
|
|
145
|
+
_id: string;
|
|
146
|
+
_type: "r2Video.asset";
|
|
147
|
+
filename: string; // display name, safe to change
|
|
148
|
+
folder?: { _type: "reference"; _ref: string };
|
|
149
|
+
poster: { _type: "image"; asset: { _type: "reference"; _ref: string } };
|
|
150
|
+
duration: number; // seconds
|
|
151
|
+
hasAudio: boolean;
|
|
152
|
+
uploadedAt: string; // ISO 8601
|
|
153
|
+
renditions: {
|
|
154
|
+
width: number;
|
|
155
|
+
height: number;
|
|
156
|
+
key: string; // R2 object key, "j6w3wy2bd0jq/720.mp4"
|
|
157
|
+
size: number; // bytes
|
|
158
|
+
}[];
|
|
159
|
+
};
|
|
154
160
|
```
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
|
|
162
|
+
A rendition's `key` joined to your `bucketUrl` is a playable URL:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const src = `${bucketUrl}/${rendition.key}`;
|
|
158
166
|
```
|
|
159
167
|
|
|
160
|
-
|
|
161
|
-
without pulling the Studio UI, or `sanity` and `react`, into its bundle.
|
|
168
|
+
To store the video's id instead of a list of keys, take it off any key and rebuild the rest. Keys are `<id>/<height>.mp4`, so the id is the part before the slash:
|
|
162
169
|
|
|
163
170
|
```ts
|
|
164
171
|
import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
|
|
165
172
|
|
|
166
|
-
const src = `${bucketUrl}/${resolveRenditionPath(
|
|
173
|
+
const src = `${bucketUrl}/${resolveRenditionPath(id, 720)}`;
|
|
167
174
|
```
|
|
168
175
|
|
|
169
|
-
|
|
176
|
+
**Note**: That id isn't the document's `_id`. It's generated at upload time for the R2 key, so a video keeps its objects even if the document is recreated.
|
|
170
177
|
|
|
171
|
-
|
|
178
|
+
`sanity-plugin-r2-video/storage` has no dependencies, so a web app can import it to build URLs without pulling the Studio, `sanity` or `react` into its bundle. Every type above is exported from `sanity-plugin-r2-video/studio`.
|
|
179
|
+
|
|
180
|
+
## API
|
|
181
|
+
|
|
182
|
+
### r2Video()
|
|
183
|
+
|
|
184
|
+
| Property | Type | Default | Notes |
|
|
185
|
+
| ------------------ | --------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
|
186
|
+
| **endpointUrl** | `string` | undefined | **Required**. Origin of the deployed Worker. The Studio never touches the bucket directly. |
|
|
187
|
+
| **token** | `string` | undefined | **Required**. Shared secret, matching the Worker's `UPLOAD_TOKEN`. |
|
|
188
|
+
| **bucketUrl** | `string` | undefined | **Required**. Public origin renditions are served from. No document stores an origin. |
|
|
189
|
+
| **apiVersion** | `string` | `"2024-01-01"` | Sanity API version the plugin's own queries and mutations run against. |
|
|
190
|
+
| **tool** | `object` | `{ name: "r2-video", title: "R2 Video" }` | Name and title of the Studio tool. |
|
|
191
|
+
| **folders.type** | `string` | `"media.folder"` | Document type folders are read from, defaulting to `sanity-plugin-media`'s. The plugin never imports that package, it reads a type by name, so this can point anywhere. |
|
|
192
|
+
| **folders.poster** | `string` | `"_R2 Video Posters"` | Folder generated posters are filed under, created on first upload. |
|
|
193
|
+
| **encoding** | `object` | See below | Encoding options, applied to every rendition. |
|
|
194
|
+
|
|
195
|
+
### encoding
|
|
196
|
+
|
|
197
|
+
| Property | Type | Default | Notes |
|
|
198
|
+
| ----------------- | ---------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
|
|
199
|
+
| **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
|
+
| **videoCodec** | `string` | `"avc"` | `avc` (h264) is the only codec every browser plays from a plain `<video src>`. |
|
|
201
|
+
| **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. |
|
|
205
|
+
|
|
206
|
+
## How it works
|
|
172
207
|
|
|
173
208
|
```
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
j6w3wy2bd0jq/720.mp4
|
|
178
|
-
j6w3wy2bd0jq/1080.mp4
|
|
209
|
+
Studio ──encoded renditions──▶ Worker ──binding──▶ R2 bucket
|
|
210
|
+
│ │
|
|
211
|
+
└──poster──▶ Sanity image assets bucket URL ─┘
|
|
179
212
|
```
|
|
180
213
|
|
|
181
|
-
|
|
182
|
-
build sources from `bucketUrl` plus the key. Moving the bucket behind a custom
|
|
183
|
-
domain is a config change, not a migration.
|
|
214
|
+
### Security
|
|
184
215
|
|
|
185
|
-
|
|
216
|
+
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.
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
parallel set. Create a folder in the Media tool and it appears in the video
|
|
189
|
-
picker; rename it there and every video moves with it.
|
|
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.
|
|
190
219
|
|
|
191
|
-
|
|
192
|
-
`media.folder`. The plugin never imports that package — it reads a document type
|
|
193
|
-
by name, so `folders.type` can point anywhere.
|
|
220
|
+
### Keys
|
|
194
221
|
|
|
195
|
-
|
|
196
|
-
The tool's filter lists only folders holding video; the upload dialog offers all
|
|
197
|
-
of them.
|
|
222
|
+
One directory per video, one object per tier, and no folder segment, as in `<id>/<height>.mp4` above.
|
|
198
223
|
|
|
199
|
-
|
|
200
|
-
upload, so they stay out of the folders holding real images.
|
|
224
|
+
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.
|
|
201
225
|
|
|
202
226
|
### Deleting
|
|
203
227
|
|
|
204
|
-
Order matters, and `delete-video.ts` documents it:
|
|
228
|
+
Order matters here, and `delete-video.ts` documents it:
|
|
205
229
|
|
|
206
|
-
1. Preflight `*[references($id)]
|
|
207
|
-
2. Delete the document
|
|
208
|
-
3. Delete the poster asset
|
|
230
|
+
1. Preflight `*[references($id)]`. Anything found blocks the delete.
|
|
231
|
+
2. Delete the document, which releases the strong reference to the poster.
|
|
232
|
+
3. Delete the poster asset, now unreferenced, so no `409`.
|
|
209
233
|
4. Delete the R2 objects, batched.
|
|
210
234
|
|
|
211
|
-
Sanity goes before R2
|
|
212
|
-
document pointing at deleted media breaks the site.
|
|
235
|
+
Sanity goes before R2. An orphaned object is invisible and costs pennies, while a document pointing at deleted media breaks the site.
|
|
213
236
|
|
|
214
237
|
### When an upload fails
|
|
215
238
|
|
|
216
|
-
The document is written
|
|
217
|
-
library pointing at files that aren't there.
|
|
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.
|
|
218
240
|
|
|
219
|
-
|
|
220
|
-
each upload, since a request that times out may still have stored the object.
|
|
221
|
-
Rollback never throws — the failure that started it is what surfaces.
|
|
241
|
+
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.
|
|
222
242
|
|
|
223
|
-
|
|
224
|
-
only in memory, so there's no resume. Whatever either case leaves behind is
|
|
225
|
-
unreferenced — **Sync** in the tool finds and removes it.
|
|
243
|
+
### Entry points
|
|
226
244
|
|
|
227
|
-
|
|
245
|
+
Three, each with its own tsconfig and its own type universe:
|
|
228
246
|
|
|
229
|
-
|
|
247
|
+
| Import | Contains | Depends on |
|
|
248
|
+
| ------------------------------------- | ------------------ | --------------------------------- |
|
|
249
|
+
| `sanity-plugin-r2-video/studio` | The Sanity plugin | `react`, `react-dom`, `@sanity/ui` |
|
|
250
|
+
| `sanity-plugin-r2-video/worker` | The endpoint | `@cloudflare/workers-types` |
|
|
251
|
+
| `sanity-plugin-r2-video/storage` | Where files live | Nothing |
|
|
230
252
|
|
|
231
|
-
|
|
232
|
-
The gate is a capability check — mediabunny's own `canEncodeVideo('avc')`, not
|
|
233
|
-
user-agent sniffing — and unsupported browsers get told so before they pick a
|
|
234
|
-
file. **Playback is unaffected everywhere.**
|
|
253
|
+
## Limitations
|
|
235
254
|
|
|
236
|
-
|
|
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.
|
|
256
|
+
- **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.
|
|
237
257
|
|
|
238
|
-
|
|
239
|
-
that straight from the package, but if it ever pre-bundles the plugin the URL
|
|
240
|
-
goes stale — if uploads fail to start, exclude the package from dependency
|
|
241
|
-
optimisation:
|
|
258
|
+
## Contributing
|
|
242
259
|
|
|
243
|
-
|
|
244
|
-
// sanity.cli.ts
|
|
245
|
-
vite: (config) => ({
|
|
246
|
-
...config,
|
|
247
|
-
optimizeDeps: { exclude: ["sanity-plugin-r2-video"] },
|
|
248
|
-
});
|
|
249
|
-
```
|
|
260
|
+
Want to get involved, or found an issue? Please contribute using the GitHub Flow. Create a branch, add commits, and open a Pull Request or submit a new issue.
|
|
250
261
|
|
|
251
262
|
## Developing
|
|
252
263
|
|
|
@@ -256,9 +267,13 @@ pnpm run type-check
|
|
|
256
267
|
pnpm run build # tsup: ESM + types into dist
|
|
257
268
|
```
|
|
258
269
|
|
|
259
|
-
`./worker` ships as TypeScript
|
|
260
|
-
extends `worker/tsconfig.json` for the compiler options it was written against.
|
|
270
|
+
`./worker` ships as TypeScript. Wrangler compiles it, and the generated Worker extends `worker/tsconfig.json` for the compiler options it was written against.
|
|
261
271
|
|
|
262
272
|
## License
|
|
263
273
|
|
|
264
274
|
MIT
|
|
275
|
+
|
|
276
|
+
[npm-image]: https://img.shields.io/npm/v/sanity-plugin-r2-video.svg?style=flat-square
|
|
277
|
+
[npm-url]: https://npmjs.org/package/sanity-plugin-r2-video
|
|
278
|
+
[npm-downloads-image]: https://img.shields.io/npm/dm/sanity-plugin-r2-video.svg
|
|
279
|
+
[npm-downloads-url]: https://npmcharts.com/compare/sanity-plugin-r2-video?minimal=true
|
package/dist/studio/index.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ import * as react from 'react';
|
|
|
7
7
|
|
|
8
8
|
/** A single encoded MP4 rendition, stored as one object in R2. */
|
|
9
9
|
type R2VideoRendition = {
|
|
10
|
+
/**
|
|
11
|
+
* Sanity's array item key, set to the height. Optional because documents
|
|
12
|
+
* written before it was added don't carry one.
|
|
13
|
+
*/
|
|
14
|
+
_key?: string;
|
|
10
15
|
width: number;
|
|
11
16
|
height: number;
|
|
12
17
|
key: string;
|
|
@@ -223,7 +228,7 @@ declare const r2Video: sanity.Plugin<R2VideoPluginConfig>;
|
|
|
223
228
|
|
|
224
229
|
/**
|
|
225
230
|
* The library document. Everything but `filename` and `folder` is written by the
|
|
226
|
-
* upload pipeline and read-only
|
|
231
|
+
* upload pipeline and read-only - there is nothing an editor can usefully
|
|
227
232
|
* correct by hand, and a stale `renditions` entry would point at an object that
|
|
228
233
|
* isn't in the bucket.
|
|
229
234
|
*/
|
package/dist/studio/index.js
CHANGED
|
@@ -615,6 +615,9 @@ var uploadVideo = async ({
|
|
|
615
615
|
written.keys.push(key);
|
|
616
616
|
const stored = await uploadObject(config, key, rendition.data);
|
|
617
617
|
renditions.push({
|
|
618
|
+
// One rendition per height, so the height is already the unique key
|
|
619
|
+
// Sanity needs on an array item
|
|
620
|
+
_key: String(rendition.height),
|
|
618
621
|
width: rendition.width,
|
|
619
622
|
height: rendition.height,
|
|
620
623
|
key: stored.key,
|
|
@@ -991,6 +994,63 @@ var VideoPreview = ({ renditions, posterUrl }) => {
|
|
|
991
994
|
}
|
|
992
995
|
);
|
|
993
996
|
};
|
|
997
|
+
var Fact = ({ label, value }) => {
|
|
998
|
+
return /* @__PURE__ */ jsxs(Stack, { gap: 2, children: [
|
|
999
|
+
/* @__PURE__ */ jsx(Text, { muted: true, size: 0, children: label }),
|
|
1000
|
+
/* @__PURE__ */ jsx(Text, { size: 1, children: value })
|
|
1001
|
+
] });
|
|
1002
|
+
};
|
|
1003
|
+
var VideoSummary = ({
|
|
1004
|
+
renditions,
|
|
1005
|
+
duration,
|
|
1006
|
+
hasAudio,
|
|
1007
|
+
uploadedAt
|
|
1008
|
+
}) => {
|
|
1009
|
+
const totalBytes = renditions.reduce((total, rendition) => {
|
|
1010
|
+
return total + rendition.size;
|
|
1011
|
+
}, 0);
|
|
1012
|
+
const ordered = [...renditions].sort((a, b) => b.height - a.height);
|
|
1013
|
+
const largest = ordered[0];
|
|
1014
|
+
return /* @__PURE__ */ jsxs(Stack, { gap: 5, children: [
|
|
1015
|
+
/* @__PURE__ */ jsx(Card, { border: true, padding: 4, radius: 2, tone: "transparent", children: /* @__PURE__ */ jsxs(Grid, { gap: 4, gridTemplateColumns: [2, 4], children: [
|
|
1016
|
+
/* @__PURE__ */ jsx(
|
|
1017
|
+
Fact,
|
|
1018
|
+
{
|
|
1019
|
+
label: "Source",
|
|
1020
|
+
value: largest ? `${largest.width} \xD7 ${largest.height}` : "Unknown"
|
|
1021
|
+
}
|
|
1022
|
+
),
|
|
1023
|
+
/* @__PURE__ */ jsx(Fact, { label: "Duration", value: formatDuration(duration) }),
|
|
1024
|
+
/* @__PURE__ */ jsx(Fact, { label: "Audio", value: hasAudio ? "Kept" : "Stripped" }),
|
|
1025
|
+
/* @__PURE__ */ jsx(Fact, { label: "Total", value: formatSize(totalBytes) })
|
|
1026
|
+
] }) }),
|
|
1027
|
+
/* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
|
|
1028
|
+
/* @__PURE__ */ jsx(Text, { muted: true, size: 1, weight: "medium", children: "Renditions" }),
|
|
1029
|
+
/* @__PURE__ */ jsx(Card, { border: true, radius: 2, children: ordered.map((rendition, index) => /* @__PURE__ */ jsx(
|
|
1030
|
+
Card,
|
|
1031
|
+
{
|
|
1032
|
+
borderTop: index > 0,
|
|
1033
|
+
padding: 3,
|
|
1034
|
+
radius: 0,
|
|
1035
|
+
children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
|
|
1036
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
1037
|
+
rendition.width,
|
|
1038
|
+
" \xD7 ",
|
|
1039
|
+
rendition.height
|
|
1040
|
+
] }) }),
|
|
1041
|
+
/* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: formatSize(rendition.size) })
|
|
1042
|
+
] })
|
|
1043
|
+
},
|
|
1044
|
+
rendition.key
|
|
1045
|
+
)) })
|
|
1046
|
+
] }),
|
|
1047
|
+
uploadedAt && /* @__PURE__ */ jsxs(Text, { muted: true, size: 0, children: [
|
|
1048
|
+
"Uploaded ",
|
|
1049
|
+
new Date(uploadedAt).toLocaleString()
|
|
1050
|
+
] })
|
|
1051
|
+
] });
|
|
1052
|
+
};
|
|
1053
|
+
var EDITABLE_FIELDS = /* @__PURE__ */ new Set(["filename", "folder"]);
|
|
994
1054
|
var isRendition = (value) => {
|
|
995
1055
|
return typeof value === "object" && value !== null && "key" in value && "width" in value && "height" in value;
|
|
996
1056
|
};
|
|
@@ -1004,11 +1064,43 @@ var readRenditions = (value) => {
|
|
|
1004
1064
|
}
|
|
1005
1065
|
return renditions.filter(isRendition);
|
|
1006
1066
|
};
|
|
1067
|
+
var read = (value, key, is) => {
|
|
1068
|
+
if (typeof value !== "object" || value === null) {
|
|
1069
|
+
return void 0;
|
|
1070
|
+
}
|
|
1071
|
+
const raw = Reflect.get(value, key);
|
|
1072
|
+
return is(raw) ? raw : void 0;
|
|
1073
|
+
};
|
|
1074
|
+
var memberField = (member) => {
|
|
1075
|
+
if (member.kind === "field") {
|
|
1076
|
+
return member.name;
|
|
1077
|
+
}
|
|
1078
|
+
if (member.kind === "error") {
|
|
1079
|
+
return member.fieldName;
|
|
1080
|
+
}
|
|
1081
|
+
return void 0;
|
|
1082
|
+
};
|
|
1083
|
+
var isNumber = (value) => typeof value === "number";
|
|
1084
|
+
var isBoolean = (value) => typeof value === "boolean";
|
|
1085
|
+
var isString = (value) => typeof value === "string";
|
|
1007
1086
|
var InputVideoAsset = (props) => {
|
|
1008
1087
|
const renditions = readRenditions(props.value);
|
|
1009
|
-
|
|
1088
|
+
const members = props.members.filter((member) => {
|
|
1089
|
+
const field = memberField(member);
|
|
1090
|
+
return field === void 0 || EDITABLE_FIELDS.has(field);
|
|
1091
|
+
});
|
|
1092
|
+
return /* @__PURE__ */ jsxs(Stack, { gap: 5, children: [
|
|
1010
1093
|
renditions.length > 0 && /* @__PURE__ */ jsx(VideoPreview, { renditions }),
|
|
1011
|
-
props.renderDefault(props)
|
|
1094
|
+
props.renderDefault({ ...props, members }),
|
|
1095
|
+
renditions.length > 0 && /* @__PURE__ */ jsx(
|
|
1096
|
+
VideoSummary,
|
|
1097
|
+
{
|
|
1098
|
+
duration: read(props.value, "duration", isNumber) ?? 0,
|
|
1099
|
+
hasAudio: read(props.value, "hasAudio", isBoolean) ?? false,
|
|
1100
|
+
renditions,
|
|
1101
|
+
uploadedAt: read(props.value, "uploadedAt", isString)
|
|
1102
|
+
}
|
|
1103
|
+
)
|
|
1012
1104
|
] });
|
|
1013
1105
|
};
|
|
1014
1106
|
|
|
@@ -1024,7 +1116,7 @@ var createVideoAssetSchema = (config) => {
|
|
|
1024
1116
|
title: "Filename",
|
|
1025
1117
|
name: "filename",
|
|
1026
1118
|
type: "string",
|
|
1027
|
-
description: "The name shown everywhere in the Studio. Renaming is safe
|
|
1119
|
+
description: "The name shown everywhere in the Studio. Renaming is safe - object keys carry no name, so nothing in storage depends on it.",
|
|
1028
1120
|
validation: (Rule) => Rule.required()
|
|
1029
1121
|
}),
|
|
1030
1122
|
defineField({
|
|
@@ -1223,12 +1315,6 @@ var Field2 = ({ label, children }) => {
|
|
|
1223
1315
|
children
|
|
1224
1316
|
] });
|
|
1225
1317
|
};
|
|
1226
|
-
var Fact = ({ label, value }) => {
|
|
1227
|
-
return /* @__PURE__ */ jsxs(Stack, { gap: 2, children: [
|
|
1228
|
-
/* @__PURE__ */ jsx(Text, { muted: true, size: 0, children: label }),
|
|
1229
|
-
/* @__PURE__ */ jsx(Text, { size: 1, children: value })
|
|
1230
|
-
] });
|
|
1231
|
-
};
|
|
1232
1318
|
var DialogDetails = ({
|
|
1233
1319
|
asset,
|
|
1234
1320
|
onChanged,
|
|
@@ -1247,11 +1333,6 @@ var DialogDetails = ({
|
|
|
1247
1333
|
useEffect(() => {
|
|
1248
1334
|
fetchFolders(client, config.folders.type).then(setFolders);
|
|
1249
1335
|
}, [client, config.folders.type]);
|
|
1250
|
-
const totalBytes = asset.renditions.reduce((total, rendition) => {
|
|
1251
|
-
return total + rendition.size;
|
|
1252
|
-
}, 0);
|
|
1253
|
-
const ordered = [...asset.renditions].sort((a, b) => b.height - a.height);
|
|
1254
|
-
const largest = ordered[0];
|
|
1255
1336
|
const paths = resolveFolderPaths(folders);
|
|
1256
1337
|
const trimmed = filename.trim();
|
|
1257
1338
|
const savedFolderId = asset.folder ? asset.folder._ref : "";
|
|
@@ -1374,48 +1455,15 @@ var DialogDetails = ({
|
|
|
1374
1455
|
) }) })
|
|
1375
1456
|
] }),
|
|
1376
1457
|
error && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
|
|
1377
|
-
/* @__PURE__ */ jsx(
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
/* @__PURE__ */ jsx(
|
|
1387
|
-
Fact,
|
|
1388
|
-
{
|
|
1389
|
-
label: "Audio",
|
|
1390
|
-
value: asset.hasAudio ? "Kept" : "Stripped"
|
|
1391
|
-
}
|
|
1392
|
-
),
|
|
1393
|
-
/* @__PURE__ */ jsx(Fact, { label: "Total", value: formatSize(totalBytes) })
|
|
1394
|
-
] }) }),
|
|
1395
|
-
/* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
|
|
1396
|
-
/* @__PURE__ */ jsx(Text, { muted: true, size: 1, weight: "medium", children: "Renditions" }),
|
|
1397
|
-
/* @__PURE__ */ jsx(Card, { border: true, radius: 2, children: ordered.map((rendition, index) => /* @__PURE__ */ jsx(
|
|
1398
|
-
Card,
|
|
1399
|
-
{
|
|
1400
|
-
borderTop: index > 0,
|
|
1401
|
-
padding: 3,
|
|
1402
|
-
radius: 0,
|
|
1403
|
-
children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
|
|
1404
|
-
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
1405
|
-
rendition.width,
|
|
1406
|
-
" \xD7 ",
|
|
1407
|
-
rendition.height
|
|
1408
|
-
] }) }),
|
|
1409
|
-
/* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: formatSize(rendition.size) })
|
|
1410
|
-
] })
|
|
1411
|
-
},
|
|
1412
|
-
rendition.key
|
|
1413
|
-
)) })
|
|
1414
|
-
] }),
|
|
1415
|
-
/* @__PURE__ */ jsxs(Text, { muted: true, size: 0, children: [
|
|
1416
|
-
"Uploaded ",
|
|
1417
|
-
new Date(asset.uploadedAt).toLocaleString()
|
|
1418
|
-
] })
|
|
1458
|
+
/* @__PURE__ */ jsx(
|
|
1459
|
+
VideoSummary,
|
|
1460
|
+
{
|
|
1461
|
+
duration: asset.duration,
|
|
1462
|
+
hasAudio: asset.hasAudio,
|
|
1463
|
+
renditions: asset.renditions,
|
|
1464
|
+
uploadedAt: asset.uploadedAt
|
|
1465
|
+
}
|
|
1466
|
+
)
|
|
1419
1467
|
] }) })
|
|
1420
1468
|
}
|
|
1421
1469
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanity-plugin-r2-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Video for Sanity Studio, encoded in the browser and stored in Cloudflare R2 as a ladder of plain MP4s.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "tsup",
|
|
54
|
+
"dev": "tsup --watch",
|
|
54
55
|
"type-check": "tsc --noEmit -p studio/tsconfig.json && tsc --noEmit -p worker/tsconfig.check.json",
|
|
55
56
|
"prepublishOnly": "pnpm run type-check && pnpm run build",
|
|
56
57
|
"setup:worker": "node bin/cli.js setup worker"
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"peerDependencies": {
|
|
62
63
|
"@cloudflare/workers-types": "^5",
|
|
63
64
|
"@sanity/icons": "^5",
|
|
64
|
-
"@sanity/ui": "^4",
|
|
65
|
+
"@sanity/ui": "^4 || ^5",
|
|
65
66
|
"react": "^19",
|
|
66
67
|
"react-dom": "^19",
|
|
67
68
|
"sanity": "^6",
|
|
@@ -73,16 +74,16 @@
|
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
|
-
"@cloudflare/workers-types": "^5
|
|
77
|
-
"@sanity/icons": "^5
|
|
78
|
-
"@sanity/ui": "^4
|
|
79
|
-
"@types/react": "^19
|
|
80
|
-
"@types/react-dom": "^19
|
|
81
|
-
"react": "^19
|
|
82
|
-
"react-dom": "^19
|
|
83
|
-
"sanity": "^6
|
|
84
|
-
"styled-components": "^6
|
|
85
|
-
"tsup": "^8
|
|
86
|
-
"typescript": "^5
|
|
77
|
+
"@cloudflare/workers-types": "^5",
|
|
78
|
+
"@sanity/icons": "^5",
|
|
79
|
+
"@sanity/ui": "^4",
|
|
80
|
+
"@types/react": "^19",
|
|
81
|
+
"@types/react-dom": "^19",
|
|
82
|
+
"react": "^19",
|
|
83
|
+
"react-dom": "^19",
|
|
84
|
+
"sanity": "^6",
|
|
85
|
+
"styled-components": "^6",
|
|
86
|
+
"tsup": "^8",
|
|
87
|
+
"typescript": "^5"
|
|
87
88
|
}
|
|
88
89
|
}
|