sanity-plugin-r2-video 0.1.1 → 0.1.3
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 +178 -191
- package/bin/cli.js +6 -6
- package/dist/studio/index.d.ts +19 -16
- package/dist/studio/index.js +86 -25
- package/dist/studio/transcode.worker.d.ts +1 -1
- package/package.json +1 -1
- package/worker/index.ts +1 -1
package/README.md
CHANGED
|
@@ -1,26 +1,88 @@
|
|
|
1
1
|
# sanity-plugin-r2-video
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[![NPM version][npm-image]][npm-url]
|
|
4
|
+
[![NPM downloads][npm-downloads-image]][npm-downloads-url]
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
using WebCodecs in a Web Worker. One MP4 comes out per height you ask for. The
|
|
8
|
-
first frame is uploaded to Sanity as an ordinary image, so posters get the Sanity
|
|
9
|
-
CDN, `srcset`, `auto=format` and LQIP without any extra work. Players pick one
|
|
10
|
-
rendition when they load and keep it.
|
|
6
|
+
A video plugin for Sanity Studio that encodes in the browser and stores plain MP4s in Cloudflare R2.
|
|
11
7
|
|
|
12
|
-
##
|
|
8
|
+
## Introduction
|
|
9
|
+
|
|
10
|
+
An editor drops in one file. Out come **renditions** - one MP4 per height you configured, plus the first frame as a poster:
|
|
11
|
+
|
|
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
|
|
18
|
+
```
|
|
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.
|
|
21
|
+
|
|
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
|
+
|
|
24
|
+
That means no encoding service, no per-minute bill, and no R2 credentials outside Cloudflare.
|
|
25
|
+
|
|
26
|
+
**Note**: These are plain MP4s, not adaptive streams. A player picks one rendition when it loads and keeps it.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
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.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Install this package with `npm`.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm i sanity-plugin-r2-video
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Setup
|
|
44
|
+
|
|
45
|
+
Five steps, in order. The plugin needs a deployed Worker before it can be configured.
|
|
46
|
+
|
|
47
|
+
### 1. Create the bucket
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
wrangler r2 bucket create my-bucket
|
|
51
|
+
```
|
|
52
|
+
|
|
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
|
+
|
|
55
|
+
**Note**: Uploads go through the Worker rather than the browser, so the bucket needs no CORS policy.
|
|
56
|
+
|
|
57
|
+
### 2. Scaffold the Worker
|
|
58
|
+
|
|
59
|
+
The Worker owns the R2 binding. To generate one:
|
|
13
60
|
|
|
14
61
|
```bash
|
|
15
|
-
|
|
62
|
+
npx sanity-plugin-r2-video setup worker
|
|
16
63
|
```
|
|
17
64
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
21
68
|
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
### 3. Deploy the Worker
|
|
70
|
+
|
|
71
|
+
Install its dependencies, generate a shared secret, then deploy:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm i sanity-plugin-r2-video
|
|
75
|
+
npm i -D wrangler @cloudflare/workers-types
|
|
76
|
+
openssl rand -base64 32
|
|
77
|
+
wrangler secret put UPLOAD_TOKEN --config r2-video-worker/wrangler.jsonc
|
|
78
|
+
wrangler deploy --config r2-video-worker/wrangler.jsonc
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Keep the generated secret, as the Studio needs the same value in the next step.
|
|
82
|
+
|
|
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`.
|
|
84
|
+
|
|
85
|
+
### 4. Add the plugin
|
|
24
86
|
|
|
25
87
|
```ts
|
|
26
88
|
// sanity.config.ts
|
|
@@ -29,58 +91,60 @@ import { r2Video } from "sanity-plugin-r2-video/studio";
|
|
|
29
91
|
export default defineConfig({
|
|
30
92
|
plugins: [
|
|
31
93
|
r2Video({
|
|
32
|
-
endpointUrl: "https://….workers.dev",
|
|
33
|
-
token: "…",
|
|
34
|
-
bucketUrl: "https://….r2.dev",
|
|
94
|
+
endpointUrl: "https://….workers.dev", // Worker you deployed
|
|
95
|
+
token: "…", // UPLOAD_TOKEN you generated
|
|
96
|
+
bucketUrl: "https://….r2.dev", // bucket's public origin
|
|
35
97
|
}),
|
|
36
98
|
],
|
|
37
99
|
});
|
|
38
100
|
```
|
|
39
101
|
|
|
40
|
-
|
|
41
|
-
|
|
102
|
+
This registers an `r2Video.asset` document type, an `r2Video` field type, and an **R2 Video** tool.
|
|
103
|
+
|
|
104
|
+
### 5. Add a field
|
|
42
105
|
|
|
43
106
|
```ts
|
|
44
107
|
defineField({ name: "video", title: "Video", type: "r2Video" })
|
|
45
108
|
```
|
|
46
109
|
|
|
47
|
-
|
|
110
|
+
To file uploads made from this field under a fixed media library folder, pass its document id:
|
|
48
111
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
112
|
+
```ts
|
|
113
|
+
defineField({
|
|
114
|
+
name: "video",
|
|
115
|
+
type: "r2Video",
|
|
116
|
+
options: { folder: "<folder document id>" },
|
|
117
|
+
})
|
|
118
|
+
```
|
|
53
119
|
|
|
54
|
-
|
|
55
|
-
adjust quality for one batch. **Preview** encodes only the tallest tier, so you
|
|
56
|
-
can check size and quality before running the whole ladder.
|
|
120
|
+
## Usage
|
|
57
121
|
|
|
58
|
-
|
|
59
|
-
size. Rename it, move it to another folder, or delete it from here.
|
|
122
|
+
Videos live in the **R2 Video** tool.
|
|
60
123
|
|
|
61
|
-
|
|
62
|
-
|
|
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. |
|
|
63
130
|
|
|
64
|
-
Inside a document, an `r2Video` field
|
|
65
|
-
without leaving the page.
|
|
131
|
+
Inside a document, an `r2Video` field picks from the library or uploads without leaving the page.
|
|
66
132
|
|
|
67
|
-
|
|
133
|
+
### Playing video on your site
|
|
68
134
|
|
|
69
|
-
|
|
70
|
-
used on many documents without encoding it twice:
|
|
135
|
+
A field stores a reference and nothing else, so the same video can be used on many documents without encoding it twice:
|
|
71
136
|
|
|
72
137
|
```json
|
|
73
138
|
{ "_type": "r2Video", "asset": { "_type": "reference", "_ref": "hK3n…" } }
|
|
74
139
|
```
|
|
75
140
|
|
|
76
|
-
Follow
|
|
77
|
-
only. The MP4s are in R2 and the poster is a normal Sanity image asset:
|
|
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:
|
|
78
142
|
|
|
79
143
|
```ts
|
|
80
144
|
type R2VideoAsset = {
|
|
81
145
|
_id: string;
|
|
82
146
|
_type: "r2Video.asset";
|
|
83
|
-
filename: string;
|
|
147
|
+
filename: string; // display name, safe to change
|
|
84
148
|
folder?: { _type: "reference"; _ref: string };
|
|
85
149
|
poster: { _type: "image"; asset: { _type: "reference"; _ref: string } };
|
|
86
150
|
duration: number; // seconds
|
|
@@ -89,28 +153,19 @@ type R2VideoAsset = {
|
|
|
89
153
|
renditions: {
|
|
90
154
|
width: number;
|
|
91
155
|
height: number;
|
|
92
|
-
key: string; // R2 object key,
|
|
156
|
+
key: string; // R2 object key, "j6w3wy2bd0jq/720.mp4"
|
|
93
157
|
size: number; // bytes
|
|
94
158
|
}[];
|
|
95
159
|
};
|
|
96
160
|
```
|
|
97
161
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
A rendition's `key` is the whole story for playback. Join it to your `bucketUrl`
|
|
101
|
-
and you have a source URL:
|
|
162
|
+
A rendition's `key` joined to your `bucketUrl` is a playable URL:
|
|
102
163
|
|
|
103
164
|
```ts
|
|
104
165
|
const src = `${bucketUrl}/${rendition.key}`;
|
|
105
166
|
```
|
|
106
167
|
|
|
107
|
-
|
|
108
|
-
never produces a 1080p entry, so you can pick from this array without checking
|
|
109
|
-
whether a tier exists.
|
|
110
|
-
|
|
111
|
-
If you'd rather store the video's id than a list of keys, take it off any key and
|
|
112
|
-
rebuild the rest with `resolveRenditionPath`. Keys are `<id>/<height>.mp4`, so the
|
|
113
|
-
id is the part before the slash:
|
|
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:
|
|
114
169
|
|
|
115
170
|
```ts
|
|
116
171
|
import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
|
|
@@ -118,86 +173,35 @@ import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
|
|
|
118
173
|
const src = `${bucketUrl}/${resolveRenditionPath(id, 720)}`;
|
|
119
174
|
```
|
|
120
175
|
|
|
121
|
-
|
|
122
|
-
"id": string::split(renditions[0].key, "/")[0]
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Note that the id is not the document's `_id`. It's generated at upload time for
|
|
126
|
-
the R2 key, so a video keeps its objects even if the document is recreated.
|
|
127
|
-
|
|
128
|
-
## Configuring
|
|
129
|
-
|
|
130
|
-
Only `endpointUrl`, `token` and `bucketUrl` are required. Everything else falls
|
|
131
|
-
back to [`studio/defaults.ts`](studio/defaults.ts), shown here with its defaults.
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
r2Video({
|
|
135
|
-
endpointUrl: "https://….workers.dev",
|
|
136
|
-
token: "…",
|
|
137
|
-
bucketUrl: "https://….r2.dev",
|
|
138
|
-
apiVersion: "2024-01-01",
|
|
139
|
-
tool: { name: "r2-video", title: "R2 Video" },
|
|
140
|
-
folders: {
|
|
141
|
-
type: "media.folder", // document type folders are read from
|
|
142
|
-
poster: "_R2 Video Posters", // where generated posters are filed
|
|
143
|
-
},
|
|
144
|
-
encoding: {
|
|
145
|
-
heights: [270, 360, 480, 720, 1080],
|
|
146
|
-
videoCodec: "avc",
|
|
147
|
-
audioCodec: "aac",
|
|
148
|
-
quality: 0.75,
|
|
149
|
-
preferBitrate: false,
|
|
150
|
-
nativeTopTier: false,
|
|
151
|
-
},
|
|
152
|
-
});
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### Encoding
|
|
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.
|
|
156
177
|
|
|
157
|
-
|
|
158
|
-
tier it does reach is another full encode, so the list is what upload time costs.
|
|
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`.
|
|
159
179
|
|
|
160
|
-
|
|
161
|
-
constant and file size varies. `0.75` is QP 22, where h264 stops being
|
|
162
|
-
distinguishable from the source. `1` is QP 16, near-lossless, and routinely
|
|
163
|
-
produces files **larger than the source**.
|
|
180
|
+
## API
|
|
164
181
|
|
|
165
|
-
|
|
166
|
-
1080p tier lands near 6.1 Mbps at `0.75` whatever the footage.
|
|
182
|
+
### r2Video()
|
|
167
183
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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. |
|
|
171
194
|
|
|
172
|
-
|
|
195
|
+
### encoding
|
|
173
196
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
Then install its dependencies, create the bucket, set the shared secret, and
|
|
184
|
-
deploy:
|
|
185
|
-
|
|
186
|
-
```bash
|
|
187
|
-
pnpm add sanity-plugin-r2-video
|
|
188
|
-
pnpm add -D wrangler @cloudflare/workers-types
|
|
189
|
-
wrangler r2 bucket create my-bucket
|
|
190
|
-
openssl rand -base64 32
|
|
191
|
-
wrangler secret put UPLOAD_TOKEN --config r2-video-worker/wrangler.jsonc
|
|
192
|
-
wrangler deploy --config r2-video-worker/wrangler.jsonc
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Give `r2Video()` the deployed URL as `endpointUrl`, the same secret as `token`,
|
|
196
|
-
and the bucket's public URL as `bucketUrl`.
|
|
197
|
-
|
|
198
|
-
The generated entry point re-exports this package's handler, so upgrading the
|
|
199
|
-
package upgrades the deployed Worker. Only deployment identity gets written out:
|
|
200
|
-
name, account, bucket and origins.
|
|
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. |
|
|
201
205
|
|
|
202
206
|
## How it works
|
|
203
207
|
|
|
@@ -207,61 +211,17 @@ Studio ──encoded renditions──▶ Worker ──binding──▶ R2 bucket
|
|
|
207
211
|
└──poster──▶ Sanity image assets bucket URL ─┘
|
|
208
212
|
```
|
|
209
213
|
|
|
210
|
-
|
|
211
|
-
Cloudflare**. Uploads go through it as plain request bodies, which means nothing
|
|
212
|
-
is signed and the bucket needs no CORS policy. A rendition has to fit in one
|
|
213
|
-
request body, capping an upload at 100 MB.
|
|
214
|
-
|
|
215
|
-
`UPLOAD_TOKEN` ships inside the Studio bundle, because the browser is what sends
|
|
216
|
-
the upload. **Anyone who can load the Studio can read it.** What actually
|
|
217
|
-
restricts access is the Worker's origin allowlist. Put Cloudflare Access in front
|
|
218
|
-
of the Worker if you need real authentication.
|
|
219
|
-
|
|
220
|
-
### Entry points
|
|
221
|
-
|
|
222
|
-
Three, each with its own tsconfig and its own type universe:
|
|
214
|
+
### Security
|
|
223
215
|
|
|
224
|
-
|
|
225
|
-
./studio the Sanity plugin react, react-dom, @sanity/ui
|
|
226
|
-
./worker the endpoint @cloudflare/workers-types
|
|
227
|
-
./storage where files live nothing
|
|
228
|
-
```
|
|
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.
|
|
229
217
|
|
|
230
|
-
|
|
231
|
-
without pulling the Studio UI, `sanity` or `react` into its bundle.
|
|
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.
|
|
232
219
|
|
|
233
220
|
### Keys
|
|
234
221
|
|
|
235
|
-
One directory per video, one object per tier, and no folder segment
|
|
236
|
-
|
|
237
|
-
```
|
|
238
|
-
j6w3wy2bd0jq/270.mp4
|
|
239
|
-
j6w3wy2bd0jq/360.mp4
|
|
240
|
-
j6w3wy2bd0jq/480.mp4
|
|
241
|
-
j6w3wy2bd0jq/720.mp4
|
|
242
|
-
j6w3wy2bd0jq/1080.mp4
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
Documents store keys and heights rather than URLs, and both the Studio and your
|
|
246
|
-
front end build sources from `bucketUrl`. Moving the bucket behind a custom
|
|
247
|
-
domain is a config change rather than a migration.
|
|
248
|
-
|
|
249
|
-
### Folders
|
|
222
|
+
One directory per video, one object per tier, and no folder segment, as in `<id>/<height>.mp4` above.
|
|
250
223
|
|
|
251
|
-
|
|
252
|
-
than a parallel set. Create a folder in the Media tool and it shows up in the
|
|
253
|
-
video picker. Rename it there and every video moves with it.
|
|
254
|
-
|
|
255
|
-
The type is `folders.type`, which defaults to `sanity-plugin-media`'s
|
|
256
|
-
`media.folder`. The plugin never imports that package. It reads a document type
|
|
257
|
-
by name, so `folders.type` can point anywhere.
|
|
258
|
-
|
|
259
|
-
Keys carry no folder, so renaming or moving a video never touches the bucket. The
|
|
260
|
-
tool's filter lists only folders holding video, while the upload dialog offers
|
|
261
|
-
all of them.
|
|
262
|
-
|
|
263
|
-
Generated posters go to their own folder, set by `folders.poster` and created on
|
|
264
|
-
first 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.
|
|
265
225
|
|
|
266
226
|
### Deleting
|
|
267
227
|
|
|
@@ -272,29 +232,32 @@ Order matters here, and `delete-video.ts` documents it:
|
|
|
272
232
|
3. Delete the poster asset, now unreferenced, so no `409`.
|
|
273
233
|
4. Delete the R2 objects, batched.
|
|
274
234
|
|
|
275
|
-
Sanity goes before R2. An orphaned object is invisible and costs pennies, while a
|
|
276
|
-
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.
|
|
277
236
|
|
|
278
237
|
### When an upload fails
|
|
279
238
|
|
|
280
|
-
The document is written last, so a failure can't leave a video in the library
|
|
281
|
-
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.
|
|
282
240
|
|
|
283
|
-
|
|
284
|
-
upload rather than after, since a request that times out may still have stored
|
|
285
|
-
the object. Rollback never throws, so the failure that started it is what you
|
|
286
|
-
see.
|
|
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.
|
|
287
242
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
243
|
+
### Entry points
|
|
244
|
+
|
|
245
|
+
Three, each with its own tsconfig and its own type universe:
|
|
246
|
+
|
|
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 |
|
|
291
252
|
|
|
292
253
|
## Limitations
|
|
293
254
|
|
|
294
|
-
Uploading
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
257
|
+
|
|
258
|
+
## Contributing
|
|
259
|
+
|
|
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.
|
|
298
261
|
|
|
299
262
|
## Developing
|
|
300
263
|
|
|
@@ -304,9 +267,33 @@ pnpm run type-check
|
|
|
304
267
|
pnpm run build # tsup: ESM + types into dist
|
|
305
268
|
```
|
|
306
269
|
|
|
307
|
-
`./worker` ships as TypeScript. Wrangler compiles it, and the generated Worker
|
|
308
|
-
|
|
270
|
+
`./worker` ships as TypeScript. Wrangler compiles it, and the generated Worker extends `worker/tsconfig.json` for the compiler options it was written against.
|
|
271
|
+
|
|
272
|
+
### Linking it into a Studio
|
|
273
|
+
|
|
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:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// sanity.cli.ts
|
|
278
|
+
vite: (config) => ({
|
|
279
|
+
...config,
|
|
280
|
+
server: {
|
|
281
|
+
...config.server,
|
|
282
|
+
fs: {
|
|
283
|
+
...config.server?.fs,
|
|
284
|
+
allow: [...(config.server?.fs?.allow ?? []), "/path/to/sanity-plugin-r2-video"],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Vite doesn't watch a linked package, so a rebuild needs a hard reload rather than arriving over HMR.
|
|
309
291
|
|
|
310
292
|
## License
|
|
311
293
|
|
|
312
294
|
MIT
|
|
295
|
+
|
|
296
|
+
[npm-image]: https://img.shields.io/npm/v/sanity-plugin-r2-video.svg?style=flat-square
|
|
297
|
+
[npm-url]: https://npmjs.org/package/sanity-plugin-r2-video
|
|
298
|
+
[npm-downloads-image]: https://img.shields.io/npm/dm/sanity-plugin-r2-video.svg
|
|
299
|
+
[npm-downloads-url]: https://npmcharts.com/compare/sanity-plugin-r2-video?minimal=true
|
package/bin/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ const USAGE = `
|
|
|
9
9
|
sanity-plugin-r2-video setup worker [directory]
|
|
10
10
|
|
|
11
11
|
Writes a deployable Cloudflare Worker: A wrangler config filled in from your
|
|
12
|
-
answers, and an entry point that re-exports this package's endpoint
|
|
12
|
+
answers, and an entry point that re-exports this package's endpoint - so
|
|
13
13
|
upgrading the package upgrades the Worker.
|
|
14
14
|
|
|
15
15
|
directory Where to write it. Defaults to ./r2-video-worker
|
|
@@ -40,8 +40,8 @@ const parseFlags = (argv) => {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* The Worker re-exports the package's handler rather than copying it, so
|
|
43
|
-
* upgrading the package upgrades the deployed endpoint. Only identity
|
|
44
|
-
* name, account, bucket, origins
|
|
43
|
+
* upgrading the package upgrades the deployed endpoint. Only identity -
|
|
44
|
+
* name, account, bucket, origins - is generated.
|
|
45
45
|
*/
|
|
46
46
|
const ENTRY = `// The endpoint itself lives in the plugin, so upgrading the package upgrades
|
|
47
47
|
// this Worker. Only deployment identity belongs here, in \`wrangler.jsonc\`.
|
|
@@ -67,7 +67,7 @@ const ask = async (rl, question, fallback) => {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Only the values that differ per deployment. The binding name, the entry point
|
|
70
|
-
* and the compatibility date are fixed
|
|
70
|
+
* and the compatibility date are fixed - the Worker source expects them, so
|
|
71
71
|
* asking would only create a way to get them wrong.
|
|
72
72
|
*/
|
|
73
73
|
const createConfig = ({ name, accountId, bucket, origins }) => {
|
|
@@ -78,7 +78,7 @@ const createConfig = ({ name, accountId, bucket, origins }) => {
|
|
|
78
78
|
"main": "src/index.ts",
|
|
79
79
|
"compatibility_date": "2026-08-01",
|
|
80
80
|
|
|
81
|
-
// The binding is what keeps R2 credentials out of your repo entirely
|
|
81
|
+
// The binding is what keeps R2 credentials out of your repo entirely - the
|
|
82
82
|
// Worker reaches the bucket directly, so nothing has to be signed or stored
|
|
83
83
|
"r2_buckets": [
|
|
84
84
|
{
|
|
@@ -93,7 +93,7 @@ const createConfig = ({ name, accountId, bucket, origins }) => {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// UPLOAD_TOKEN is set separately, with: wrangler secret put UPLOAD_TOKEN
|
|
96
|
-
// Not because it's secret
|
|
96
|
+
// Not because it's secret - the Studio ships the same value to browsers -
|
|
97
97
|
// but so it stays out of this file, out of git, and out of deploy logs
|
|
98
98
|
}
|
|
99
99
|
`;
|
package/dist/studio/index.d.ts
CHANGED
|
@@ -28,17 +28,17 @@ type R2VideoPoster = {
|
|
|
28
28
|
asset: R2VideoReference;
|
|
29
29
|
};
|
|
30
30
|
/**
|
|
31
|
-
* An `r2Video.asset` document. Metadata only
|
|
31
|
+
* An `r2Video.asset` document. Metadata only - every MP4 lives in R2, and the
|
|
32
32
|
* poster lives in Sanity's own image pipeline so it inherits the CDN, the
|
|
33
33
|
* `srcset` helpers and the native preview branch.
|
|
34
34
|
*/
|
|
35
35
|
type R2VideoAsset = {
|
|
36
36
|
_id: string;
|
|
37
37
|
_type: "r2Video.asset";
|
|
38
|
-
/** Display name. Editable
|
|
38
|
+
/** Display name. Editable - nothing in storage depends on it. */
|
|
39
39
|
filename: string;
|
|
40
40
|
/**
|
|
41
|
-
* The media library folder this video belongs to
|
|
41
|
+
* The media library folder this video belongs to - the same documents the
|
|
42
42
|
* image browser uses, so folders are shared rather than mirrored. Object
|
|
43
43
|
* keys keep the prefix they were uploaded under, so renaming a folder moves
|
|
44
44
|
* the video in the Studio without invalidating anything already in R2.
|
|
@@ -50,7 +50,7 @@ type R2VideoAsset = {
|
|
|
50
50
|
renditions: R2VideoRendition[];
|
|
51
51
|
uploadedAt: string;
|
|
52
52
|
};
|
|
53
|
-
/** The value an `r2Video` field holds
|
|
53
|
+
/** The value an `r2Video` field holds - a reference to an `r2Video.asset`. */
|
|
54
54
|
type R2VideoValue = {
|
|
55
55
|
_type?: "r2Video";
|
|
56
56
|
asset?: R2VideoReference;
|
|
@@ -68,7 +68,7 @@ type R2VideoFieldOptions = {
|
|
|
68
68
|
type R2VideoEncodingConfig = {
|
|
69
69
|
/**
|
|
70
70
|
* Rendition heights to produce, in any order. A source shorter than a tier
|
|
71
|
-
* skips it
|
|
71
|
+
* skips it - nothing is ever upscaled.
|
|
72
72
|
*/
|
|
73
73
|
heights?: number[];
|
|
74
74
|
/**
|
|
@@ -81,7 +81,7 @@ type R2VideoEncodingConfig = {
|
|
|
81
81
|
/**
|
|
82
82
|
* Compression level passed to both encoders, from 0 (worst) to 1 (best).
|
|
83
83
|
*
|
|
84
|
-
* For codecs that support it
|
|
84
|
+
* For codecs that support it - h264 included - this maps to a **quantizer**,
|
|
85
85
|
* not a bitrate. That means constant quality and *variable file size*: the
|
|
86
86
|
* output is as large as the footage needs to hit that quality, so detailed
|
|
87
87
|
* or grainy material produces much bigger files than flat material.
|
|
@@ -93,7 +93,7 @@ type R2VideoEncodingConfig = {
|
|
|
93
93
|
*
|
|
94
94
|
* QP 22 is h264's practical transparency threshold, which is why 0.75 is the
|
|
95
95
|
* default. Going to 1 can produce a file **larger than the source** when the
|
|
96
|
-
* source was exported at a normal quantizer
|
|
96
|
+
* source was exported at a normal quantizer - you are asking for a
|
|
97
97
|
* higher-fidelity encode than the original, and encoding can't add back
|
|
98
98
|
* detail that was never captured.
|
|
99
99
|
*/
|
|
@@ -102,12 +102,12 @@ type R2VideoEncodingConfig = {
|
|
|
102
102
|
* Encode to a target **bitrate** instead of a quantizer.
|
|
103
103
|
*
|
|
104
104
|
* Flips the trade-off `quality` makes. The quantizer default is constant
|
|
105
|
-
* quality with variable size
|
|
105
|
+
* quality with variable size - grainy footage produces far bigger files than
|
|
106
106
|
* flat footage. With this on, size becomes predictable and quality varies
|
|
107
107
|
* instead: a tier lands at roughly the same weight whatever you feed it.
|
|
108
108
|
*
|
|
109
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
|
|
110
|
+
* 1920×1080 as the reference and a multiplier from the quality curve - so
|
|
111
111
|
* `0.75` is about 6.1 Mbps at 1080p, and `0.5` about 3.2 Mbps.
|
|
112
112
|
*
|
|
113
113
|
* Worth turning on when knowing what lands in the bucket matters more than
|
|
@@ -120,7 +120,7 @@ type R2VideoEncodingConfig = {
|
|
|
120
120
|
* than transcodes: instant, and bit-identical to the upload.
|
|
121
121
|
*
|
|
122
122
|
* Off by default because it hands size control to whoever exported the file
|
|
123
|
-
*
|
|
123
|
+
* - a 60 Mbps master would be stored at 60 Mbps. That tier is rarely the one
|
|
124
124
|
* served, since playback picks by element size, but the bytes are real.
|
|
125
125
|
*/
|
|
126
126
|
nativeTopTier?: boolean;
|
|
@@ -139,7 +139,7 @@ type R2VideoPluginConfig = {
|
|
|
139
139
|
endpointUrl: string;
|
|
140
140
|
/**
|
|
141
141
|
* Shared secret the Worker checks. This ships inside the Studio bundle, so
|
|
142
|
-
* it gates casual access rather than providing real authentication
|
|
142
|
+
* it gates casual access rather than providing real authentication - pair it
|
|
143
143
|
* with the Worker's origin allowlist.
|
|
144
144
|
*/
|
|
145
145
|
token: string;
|
|
@@ -191,14 +191,14 @@ declare const findReferencingDocuments: (client: SanityClient, id: string) => Pr
|
|
|
191
191
|
*
|
|
192
192
|
* The document goes before the poster, because it holds the strong reference
|
|
193
193
|
* that would otherwise 409. And Sanity goes before R2, because the two failure
|
|
194
|
-
* modes are not symmetric
|
|
194
|
+
* modes are not symmetric - an orphaned object is invisible and costs pennies,
|
|
195
195
|
* whereas a document pointing at deleted media breaks the site.
|
|
196
196
|
*/
|
|
197
197
|
declare const deleteVideoAsset: (client: SanityClient, config: R2VideoPluginConfig, asset: R2VideoAsset) => Promise<void>;
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
200
|
* A folder from the media library. These are the *same* documents the image
|
|
201
|
-
* browser uses, not a parallel set
|
|
201
|
+
* browser uses, not a parallel set - a folder made in either place shows up in
|
|
202
202
|
* both, so there is one place folders are defined.
|
|
203
203
|
*/
|
|
204
204
|
type MediaFolder = {
|
|
@@ -310,7 +310,7 @@ declare const createVideoAssetSchema: (config: ResolvedR2VideoConfig) => {
|
|
|
310
310
|
folder: string;
|
|
311
311
|
media: string;
|
|
312
312
|
};
|
|
313
|
-
prepare({ filename, folder, media }: Record<"
|
|
313
|
+
prepare({ filename, folder, media }: Record<"media" | "filename" | "folder", any>): {
|
|
314
314
|
title: any;
|
|
315
315
|
subtitle: any;
|
|
316
316
|
media: any;
|
|
@@ -338,13 +338,16 @@ declare const SCHEMA_R2_VIDEO: {
|
|
|
338
338
|
to: {
|
|
339
339
|
type: string;
|
|
340
340
|
}[];
|
|
341
|
+
options: {
|
|
342
|
+
disableNew: true;
|
|
343
|
+
};
|
|
341
344
|
}[];
|
|
342
345
|
preview: {
|
|
343
346
|
select: {
|
|
344
347
|
filename: string;
|
|
345
348
|
media: string;
|
|
346
349
|
};
|
|
347
|
-
prepare({ filename, media }: Record<"
|
|
350
|
+
prepare({ filename, media }: Record<"media" | "filename", any>): {
|
|
348
351
|
title: any;
|
|
349
352
|
media: any;
|
|
350
353
|
};
|
|
@@ -392,7 +395,7 @@ type Props = {
|
|
|
392
395
|
};
|
|
393
396
|
/**
|
|
394
397
|
* Plays the video rather than showing its first frame. Muted and looping to
|
|
395
|
-
* match how these are used on the site, but with controls
|
|
398
|
+
* match how these are used on the site, but with controls - in the Studio the
|
|
396
399
|
* point is to check the footage, so scrubbing has to be possible.
|
|
397
400
|
*/
|
|
398
401
|
declare const VideoPreview: ({ renditions, posterUrl }: Props) => react.JSX.Element;
|
package/dist/studio/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import { defineType, defineField, definePlugin, useClient, set } from 'sanity';
|
|
|
5
5
|
import { createContext, useState, useCallback, useEffect, useRef, useMemo, useContext, useId } from 'react';
|
|
6
6
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
7
7
|
import { UploadIcon } from '@sanity/icons/Upload';
|
|
8
|
-
import { Stack,
|
|
8
|
+
import { Stack, Flex, Box, Button, TextInput, Spinner, Text, Card, Grid, Dialog, Select, Switch } from '@sanity/ui';
|
|
9
|
+
import { AccessDeniedIcon } from '@sanity/icons/AccessDenied';
|
|
9
10
|
import { canEncodeVideo } from 'mediabunny';
|
|
10
11
|
import { WarningOutlineIcon } from '@sanity/icons/WarningOutline';
|
|
11
12
|
import { ChevronDownIcon } from '@sanity/icons/ChevronDown';
|
|
@@ -92,7 +93,7 @@ var deleteVideoAsset = async (client, config, asset) => {
|
|
|
92
93
|
try {
|
|
93
94
|
await client.delete(asset.poster.asset._ref);
|
|
94
95
|
} catch (error) {
|
|
95
|
-
console.info(`Kept poster for '${asset.filename}'
|
|
96
|
+
console.info(`Kept poster for '${asset.filename}' - still in use.`, error);
|
|
96
97
|
}
|
|
97
98
|
await deleteObjects(
|
|
98
99
|
config,
|
|
@@ -198,7 +199,7 @@ var formatDuration = (seconds) => {
|
|
|
198
199
|
};
|
|
199
200
|
var formatBitrate = (bytes, seconds) => {
|
|
200
201
|
if (!seconds) {
|
|
201
|
-
return "
|
|
202
|
+
return "-";
|
|
202
203
|
}
|
|
203
204
|
return `${(bytes * 8 / seconds / 1e6).toFixed(2)} Mbps`;
|
|
204
205
|
};
|
|
@@ -218,12 +219,42 @@ var OVERLAY_STYLE = {
|
|
|
218
219
|
var DragOverlay = () => {
|
|
219
220
|
return /* @__PURE__ */ jsx(Flex, { style: OVERLAY_STYLE, children: /* @__PURE__ */ jsx(Text, { size: 3, style: { color: "inherit" }, children: "Drop files to upload" }) });
|
|
220
221
|
};
|
|
222
|
+
var hasVideoFile = (transfer) => {
|
|
223
|
+
return Array.from(transfer.items).some((item) => {
|
|
224
|
+
if (item.kind !== "file") {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return item.type === "" || item.type.startsWith("video/");
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
var DropToUpload = ({ isRejected }) => {
|
|
231
|
+
return /* @__PURE__ */ jsx(
|
|
232
|
+
Card,
|
|
233
|
+
{
|
|
234
|
+
radius: 2,
|
|
235
|
+
tone: isRejected ? "critical" : "primary",
|
|
236
|
+
style: {
|
|
237
|
+
position: "absolute",
|
|
238
|
+
inset: -4,
|
|
239
|
+
zIndex: 3,
|
|
240
|
+
opacity: 0.9,
|
|
241
|
+
pointerEvents: "none"
|
|
242
|
+
},
|
|
243
|
+
children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, justify: "center", style: { height: "100%" }, children: [
|
|
244
|
+
/* @__PURE__ */ jsx(Text, { size: 2, children: isRejected ? /* @__PURE__ */ jsx(AccessDeniedIcon, {}) : /* @__PURE__ */ jsx(UploadIcon, {}) }),
|
|
245
|
+
/* @__PURE__ */ jsx(Text, { size: 2, children: isRejected ? "Can't upload this file here" : "Drop to upload" })
|
|
246
|
+
] })
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
};
|
|
221
250
|
var useFileDrop = ({ onDrop, isEnabled }) => {
|
|
222
251
|
const [isDragging, setIsDragging] = useState(false);
|
|
252
|
+
const [isRejected, setIsRejected] = useState(false);
|
|
223
253
|
const depth = useRef(0);
|
|
224
254
|
const reset = () => {
|
|
225
255
|
depth.current = 0;
|
|
226
256
|
setIsDragging(false);
|
|
257
|
+
setIsRejected(false);
|
|
227
258
|
};
|
|
228
259
|
useEffect(() => {
|
|
229
260
|
if (!isEnabled) {
|
|
@@ -238,6 +269,7 @@ var useFileDrop = ({ onDrop, isEnabled }) => {
|
|
|
238
269
|
event.preventDefault();
|
|
239
270
|
depth.current += 1;
|
|
240
271
|
setIsDragging(true);
|
|
272
|
+
setIsRejected(!hasVideoFile(event.dataTransfer));
|
|
241
273
|
};
|
|
242
274
|
const dragLeft = (event) => {
|
|
243
275
|
if (!isEnabled) {
|
|
@@ -269,6 +301,7 @@ var useFileDrop = ({ onDrop, isEnabled }) => {
|
|
|
269
301
|
};
|
|
270
302
|
return {
|
|
271
303
|
isDragging: isDragging && isEnabled,
|
|
304
|
+
isRejected,
|
|
272
305
|
dropProps: {
|
|
273
306
|
onDragEnter: dragEntered,
|
|
274
307
|
onDragLeave: dragLeft,
|
|
@@ -373,7 +406,7 @@ var PreviewEncode = ({ file, keepAudio, encoding }) => {
|
|
|
373
406
|
isEncoding && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
|
|
374
407
|
/* @__PURE__ */ jsx(Spinner, { muted: true }),
|
|
375
408
|
/* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
|
|
376
|
-
"Encoding top tier
|
|
409
|
+
"Encoding top tier - ",
|
|
377
410
|
Math.round(progress * 100),
|
|
378
411
|
"%"
|
|
379
412
|
] })
|
|
@@ -474,7 +507,7 @@ var UploadSettings = ({
|
|
|
474
507
|
/* @__PURE__ */ jsx(
|
|
475
508
|
Field,
|
|
476
509
|
{
|
|
477
|
-
description: "Shared with the image library
|
|
510
|
+
description: "Shared with the image library - create folders there and they appear here.",
|
|
478
511
|
id: folderInputId,
|
|
479
512
|
label: "Folder",
|
|
480
513
|
children: /* @__PURE__ */ jsxs(
|
|
@@ -843,7 +876,7 @@ var DialogUpload = ({
|
|
|
843
876
|
/* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
|
|
844
877
|
/* @__PURE__ */ jsx(Flex, { justify: "center", children: /* @__PURE__ */ jsx(Text, { muted: true, size: 3, children: /* @__PURE__ */ jsx(UploadIcon, {}) }) }),
|
|
845
878
|
/* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 1, children: "Drop videos here" }),
|
|
846
|
-
/* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 0, children: "MP4, MOV or WebM
|
|
879
|
+
/* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 0, children: "MP4, MOV or WebM - encoded to every size the site needs" })
|
|
847
880
|
] }),
|
|
848
881
|
/* @__PURE__ */ jsx(Flex, { justify: "center", children: /* @__PURE__ */ jsx(
|
|
849
882
|
Button,
|
|
@@ -923,7 +956,24 @@ var readFolder = (options) => {
|
|
|
923
956
|
var InputVideo = (props) => {
|
|
924
957
|
const { onChange, readOnly, renderDefault, schemaType } = props;
|
|
925
958
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
926
|
-
const
|
|
959
|
+
const [droppedFiles, setDroppedFiles] = useState([]);
|
|
960
|
+
const isDisabled = readOnly === true;
|
|
961
|
+
const closeUpload = () => {
|
|
962
|
+
setIsUploadOpen(false);
|
|
963
|
+
setDroppedFiles([]);
|
|
964
|
+
};
|
|
965
|
+
const openUpload = () => {
|
|
966
|
+
setDroppedFiles([]);
|
|
967
|
+
setIsUploadOpen(true);
|
|
968
|
+
};
|
|
969
|
+
const dropped = (files) => {
|
|
970
|
+
setDroppedFiles(files);
|
|
971
|
+
setIsUploadOpen(true);
|
|
972
|
+
};
|
|
973
|
+
const { isDragging, isRejected, dropProps } = useFileDrop({
|
|
974
|
+
onDrop: dropped,
|
|
975
|
+
isEnabled: !isDisabled && !isUploadOpen
|
|
976
|
+
});
|
|
927
977
|
const uploaded = (asset) => {
|
|
928
978
|
onChange(
|
|
929
979
|
set({
|
|
@@ -933,22 +983,29 @@ var InputVideo = (props) => {
|
|
|
933
983
|
);
|
|
934
984
|
closeUpload();
|
|
935
985
|
};
|
|
936
|
-
return /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
|
|
937
|
-
|
|
938
|
-
/* @__PURE__ */
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
986
|
+
return /* @__PURE__ */ jsxs(Stack, { gap: 3, style: { position: "relative" }, ...dropProps, children: [
|
|
987
|
+
isDragging && /* @__PURE__ */ jsx(DropToUpload, { isRejected }),
|
|
988
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 0, children: [
|
|
989
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: renderDefault({
|
|
990
|
+
...props,
|
|
991
|
+
renderField: (field) => field.children
|
|
992
|
+
}) }),
|
|
993
|
+
/* @__PURE__ */ jsx(
|
|
994
|
+
Button,
|
|
995
|
+
{
|
|
996
|
+
disabled: isDisabled,
|
|
997
|
+
icon: UploadIcon,
|
|
998
|
+
mode: "ghost",
|
|
999
|
+
text: "Upload",
|
|
1000
|
+
onClick: openUpload
|
|
1001
|
+
}
|
|
1002
|
+
)
|
|
1003
|
+
] }),
|
|
948
1004
|
isUploadOpen && /* @__PURE__ */ jsx(
|
|
949
1005
|
DialogUpload,
|
|
950
1006
|
{
|
|
951
1007
|
folderId: readFolder(schemaType.options),
|
|
1008
|
+
initialFiles: droppedFiles,
|
|
952
1009
|
onClose: closeUpload,
|
|
953
1010
|
onUploaded: uploaded
|
|
954
1011
|
}
|
|
@@ -983,7 +1040,7 @@ var VideoPreview = ({ renditions, posterUrl }) => {
|
|
|
983
1040
|
style: {
|
|
984
1041
|
display: "block",
|
|
985
1042
|
// Sized by height, not width, so the element is exactly the shape
|
|
986
|
-
// of the video
|
|
1043
|
+
// of the video - a width-driven box capped by max-height would
|
|
987
1044
|
// letterbox instead, painting bars around anything tall
|
|
988
1045
|
maxHeight: MAX_PLAYER_HEIGHT,
|
|
989
1046
|
maxWidth: "100%",
|
|
@@ -1208,7 +1265,11 @@ var SCHEMA_R2_VIDEO = defineType({
|
|
|
1208
1265
|
title: "Asset",
|
|
1209
1266
|
name: "asset",
|
|
1210
1267
|
type: "reference",
|
|
1211
|
-
to: [{ type: "r2Video.asset" }]
|
|
1268
|
+
to: [{ type: "r2Video.asset" }],
|
|
1269
|
+
// New videos come from the upload button beside this input, which
|
|
1270
|
+
// encodes and stores before writing the document. Sanity's own create
|
|
1271
|
+
// would make an empty one the pipeline never filled in
|
|
1272
|
+
options: { disableNew: true }
|
|
1212
1273
|
})
|
|
1213
1274
|
],
|
|
1214
1275
|
preview: {
|
|
@@ -1282,7 +1343,7 @@ var DialogDelete = ({ asset, onDeleted, onClose }) => {
|
|
|
1282
1343
|
children: /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
|
|
1283
1344
|
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
1284
1345
|
asset.filename,
|
|
1285
|
-
"
|
|
1346
|
+
" - ",
|
|
1286
1347
|
asset.renditions.length,
|
|
1287
1348
|
" renditions,",
|
|
1288
1349
|
" ",
|
|
@@ -1499,7 +1560,7 @@ var removeOrphans = async (client, config, orphans) => {
|
|
|
1499
1560
|
try {
|
|
1500
1561
|
await client.delete(posterId);
|
|
1501
1562
|
} catch (error) {
|
|
1502
|
-
console.warn(`Kept poster '${posterId}'
|
|
1563
|
+
console.warn(`Kept poster '${posterId}' - still referenced.`, error);
|
|
1503
1564
|
}
|
|
1504
1565
|
}
|
|
1505
1566
|
};
|
|
@@ -1569,7 +1630,7 @@ var DialogOrphans = ({ onCleaned, onClose }) => {
|
|
|
1569
1630
|
/* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Comparing storage against the library\u2026" })
|
|
1570
1631
|
] }),
|
|
1571
1632
|
isDone && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "positive", children: /* @__PURE__ */ jsx(Text, { size: 1, children: "Removed." }) }),
|
|
1572
|
-
orphans && !isDone && total === 0 && /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Nothing to clean up
|
|
1633
|
+
orphans && !isDone && total === 0 && /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Nothing to clean up - every stored file belongs to a video." }),
|
|
1573
1634
|
orphans && !isDone && total > 0 && /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
|
|
1574
1635
|
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
1575
1636
|
"Found ",
|
|
@@ -1859,7 +1920,7 @@ var ToolVideoLibrary = () => {
|
|
|
1859
1920
|
var r2Video = definePlugin((options) => {
|
|
1860
1921
|
const resolvedConfig = resolveConfig(options);
|
|
1861
1922
|
return {
|
|
1862
|
-
// The plugin's own identity, not the tool's route
|
|
1923
|
+
// The plugin's own identity, not the tool's route - 'tool.name' below is
|
|
1863
1924
|
// configurable and moves the tool's URL, this never changes
|
|
1864
1925
|
name: "r2-video",
|
|
1865
1926
|
schema: {
|
|
@@ -16,7 +16,7 @@ type TranscodeRequest = {
|
|
|
16
16
|
options: TranscodeOptions;
|
|
17
17
|
/**
|
|
18
18
|
* Encode only the tallest tier and stop. Used to show what the current
|
|
19
|
-
* settings actually produce before committing to the whole ladder
|
|
19
|
+
* settings actually produce before committing to the whole ladder - the top
|
|
20
20
|
* tier is the one that varies most, and the one most likely to surprise.
|
|
21
21
|
*/
|
|
22
22
|
topTierOnly?: boolean;
|
package/package.json
CHANGED
package/worker/index.ts
CHANGED
|
@@ -87,7 +87,7 @@ const storeObject = async (
|
|
|
87
87
|
},
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
// Only the key and size
|
|
90
|
+
// Only the key and size - the Studio builds URLs from its own configured
|
|
91
91
|
// origin, so this Worker never needs to know one
|
|
92
92
|
return jsonResponse({ key, size: body.byteLength }, 200, headers);
|
|
93
93
|
};
|