sanity-plugin-r2-video 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paulo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # sanity-plugin-r2-video
2
+
3
+ Video for Sanity Studio, stored in Cloudflare R2 as a ladder of plain MP4s.
4
+
5
+ Encoding happens **in the browser** with [mediabunny](https://mediabunny.dev) —
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.
10
+
11
+ ## Installing
12
+
13
+ ```bash
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";
26
+
27
+ export default defineConfig({
28
+ plugins: [
29
+ r2Video({
30
+ endpointUrl: "https://….workers.dev", // deployed Worker
31
+ token: "…", // matches the Worker's UPLOAD_TOKEN
32
+ bucketUrl: "https://….r2.dev", // origin renditions are served from
33
+ }),
34
+ ],
35
+ });
36
+ ```
37
+
38
+ ## Using it
39
+
40
+ Everything happens in the **R2 Video** tool in the Studio.
41
+
42
+ **Upload** — drop files onto the library, or press Upload. Files stage in a
43
+ list first; nothing encodes until you confirm.
44
+
45
+ **Settings** — collapsed by default. Open it to change the folder, keep audio,
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.
48
+
49
+ **Details** — click a card. Plays the video, lists every rendition with its
50
+ size, and lets you rename it, move it to another folder, or delete it.
51
+
52
+ **Sync** — beside Upload. Compares the bucket against the library and offers to
53
+ remove anything no document points at.
54
+
55
+ In a document, add a video through any `asset` field: pick one from the library,
56
+ or upload without leaving the page.
57
+
58
+ ## Configuring
59
+
60
+ Only `endpointUrl`, `token` and `bucketUrl` are required. Everything else falls
61
+ back to [`studio/defaults.ts`](studio/defaults.ts), shown here with its defaults.
62
+
63
+ ```ts
64
+ r2Video({
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
+ });
83
+ ```
84
+
85
+ ### Encoding
86
+
87
+ **`heights`** is the tier ladder. A source shorter than a tier skips it; every
88
+ tier it does reach is another full encode.
89
+
90
+ **`quality`** maps to a **quantizer** for h264, not a bitrate: constant quality,
91
+ variable file size. `0.75` is QP 22, where h264 stops being distinguishable from
92
+ the source. `1` is QP 16, near-lossless, and routinely produces files **larger
93
+ than the source**.
94
+
95
+ **`preferBitrate`** flips that trade: predictable size, variable quality. A
96
+ 1080p tier lands near 6.1 Mbps at `0.75` whatever the footage.
97
+
98
+ **`nativeTopTier`** copies the top rendition instead of re-encoding it, when its
99
+ height and codec already match the source — instant and bit-identical. Its size
100
+ is then whatever the source was exported at.
101
+
102
+ ## Setting up a Worker
103
+
104
+ The Worker holds the R2 binding. Scaffold one — the binding name, entry point
105
+ and compatibility date are fixed, and the CLI fills in the rest:
106
+
107
+ ```bash
108
+ pnpx sanity-plugin-r2-video setup worker
109
+ ```
110
+
111
+ Pass `--account`, `--bucket`, `--name` and `--origins` to skip the prompts.
112
+
113
+ Then install its dependencies, create the bucket, set the shared secret, and
114
+ deploy:
115
+
116
+ ```bash
117
+ pnpm add sanity-plugin-r2-video
118
+ pnpm add -D wrangler @cloudflare/workers-types
119
+ wrangler r2 bucket create my-bucket
120
+ openssl rand -base64 32
121
+ wrangler secret put UPLOAD_TOKEN --config r2-video-worker/wrangler.jsonc
122
+ wrangler deploy --config r2-video-worker/wrangler.jsonc
123
+ ```
124
+
125
+ Give `r2Video()` the deployed URL as `endpointUrl`, the same secret as `token`,
126
+ and the bucket's public URL as `bucketUrl`.
127
+
128
+ The generated entry point re-exports this package's handler, so upgrading the
129
+ package upgrades the deployed Worker. Only deployment identity — name, account,
130
+ bucket, origins — is written out.
131
+
132
+ ## How it works
133
+
134
+ ```
135
+ Studio ──encoded renditions──▶ Worker ──binding──▶ R2 bucket
136
+ │ │
137
+ └──poster──▶ Sanity image assets bucket URL ─┘
138
+ ```
139
+
140
+ The Worker holds the only R2 binding, so **no R2 credentials exist outside
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.
144
+
145
+ `UPLOAD_TOKEN` ships inside the Studio bundle, since the browser is what sends
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.
149
+
150
+ ### Entry points
151
+
152
+ Three, each with its own tsconfig and its own type universe:
153
+
154
+ ```
155
+ ./studio the Sanity plugin react, react-dom, @sanity/ui
156
+ ./worker the endpoint @cloudflare/workers-types
157
+ ./storage where files live nothing
158
+ ```
159
+
160
+ `./storage` has no dependencies. A web app imports it to build source URLs
161
+ without pulling the Studio UI, or `sanity` and `react`, into its bundle.
162
+
163
+ ```ts
164
+ import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
165
+
166
+ const src = `${bucketUrl}/${resolveRenditionPath(asset._id, 720)}`;
167
+ ```
168
+
169
+ ### Keys
170
+
171
+ One directory per video, one object per tier, no folder segment:
172
+
173
+ ```
174
+ j6w3wy2bd0jq/270.mp4
175
+ j6w3wy2bd0jq/360.mp4
176
+ j6w3wy2bd0jq/480.mp4
177
+ j6w3wy2bd0jq/720.mp4
178
+ j6w3wy2bd0jq/1080.mp4
179
+ ```
180
+
181
+ Documents store the id and the heights, not URLs — both the Studio and the web
182
+ build sources from `bucketUrl` plus the key. Moving the bucket behind a custom
183
+ domain is a config change, not a migration.
184
+
185
+ ### Folders
186
+
187
+ Videos are filed in the **same folder documents the image library uses**, not a
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.
190
+
191
+ The type is `folders.type`, defaulting to `sanity-plugin-media`'s
192
+ `media.folder`. The plugin never imports that package — it reads a document type
193
+ by name, so `folders.type` can point anywhere.
194
+
195
+ Keys carry no folder, so renaming or moving a video never touches the bucket.
196
+ The tool's filter lists only folders holding video; the upload dialog offers all
197
+ of them.
198
+
199
+ Generated posters go to their own folder (`folders.poster`), created on first
200
+ upload, so they stay out of the folders holding real images.
201
+
202
+ ### Deleting
203
+
204
+ Order matters, and `delete-video.ts` documents it:
205
+
206
+ 1. Preflight `*[references($id)]` — anything found blocks the delete.
207
+ 2. Delete the document. This releases the strong reference to the poster.
208
+ 3. Delete the poster asset. Now unreferenced, so no `409`.
209
+ 4. Delete the R2 objects, batched.
210
+
211
+ Sanity goes before R2: an orphaned object is invisible and costs pennies, a
212
+ document pointing at deleted media breaks the site.
213
+
214
+ ### When an upload fails
215
+
216
+ The document is written **last**, so a failure can never leave a video in the
217
+ library pointing at files that aren't there.
218
+
219
+ Anything created before that point is rolled back. Keys are recorded *before*
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.
222
+
223
+ **A closed tab or a crash skips rollback entirely**, and encoded renditions live
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.
226
+
227
+ ## Limitations
228
+
229
+ ### Chrome only, for uploading
230
+
231
+ Uploading needs WebCodecs h264 encoding, which Safari doesn't reliably provide.
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.**
235
+
236
+ ### The encoder runs in a Web Worker
237
+
238
+ `transcode.worker.js` is loaded with `new URL(…, import.meta.url)`. Vite serves
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:
242
+
243
+ ```ts
244
+ // sanity.cli.ts
245
+ vite: (config) => ({
246
+ ...config,
247
+ optimizeDeps: { exclude: ["sanity-plugin-r2-video"] },
248
+ });
249
+ ```
250
+
251
+ ## Developing
252
+
253
+ ```bash
254
+ pnpm install
255
+ pnpm run type-check
256
+ pnpm run build # tsup: ESM + types into dist
257
+ ```
258
+
259
+ `./worker` ships as TypeScript: wrangler compiles it, and the generated Worker
260
+ extends `worker/tsconfig.json` for the compiler options it was written against.
261
+
262
+ ## License
263
+
264
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ const USAGE = `
9
+ sanity-plugin-r2-video setup worker [directory]
10
+
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 — so
13
+ upgrading the package upgrades the Worker.
14
+
15
+ directory Where to write it. Defaults to ./r2-video-worker
16
+
17
+ Options (prompted for when omitted, required when not a terminal):
18
+
19
+ --name Worker name default r2-video
20
+ --account Cloudflare account id required
21
+ --bucket R2 bucket name required
22
+ --origins Allowed Studio origins default http://localhost:3333
23
+ `;
24
+
25
+ /** Flags parsed as `--key value`, so the command works unattended in CI. */
26
+ const parseFlags = (argv) => {
27
+ const flags = {};
28
+
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const arg = argv[index];
31
+
32
+ if (arg.startsWith("--")) {
33
+ flags[arg.slice(2)] = argv[index + 1];
34
+ index += 1;
35
+ }
36
+ }
37
+
38
+ return flags;
39
+ };
40
+
41
+ /**
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 — is generated.
45
+ */
46
+ const ENTRY = `// The endpoint itself lives in the plugin, so upgrading the package upgrades
47
+ // this Worker. Only deployment identity belongs here, in \`wrangler.jsonc\`.
48
+ export { default } from "sanity-plugin-r2-video/worker";
49
+ `;
50
+
51
+ const TSCONFIG = `{
52
+ "$schema": "https://json.schemastore.org/tsconfig.json",
53
+ "extends": "sanity-plugin-r2-video/worker/tsconfig.json"
54
+ }
55
+ `;
56
+
57
+ const ask = async (rl, question, fallback) => {
58
+ if (!rl) {
59
+ return fallback ?? "";
60
+ }
61
+
62
+ const suffix = fallback ? ` (${fallback})` : "";
63
+ const answer = (await rl.question(` ${question}${suffix}: `)).trim();
64
+
65
+ return answer || fallback || "";
66
+ };
67
+
68
+ /**
69
+ * Only the values that differ per deployment. The binding name, the entry point
70
+ * and the compatibility date are fixed — the Worker source expects them, so
71
+ * asking would only create a way to get them wrong.
72
+ */
73
+ const createConfig = ({ name, accountId, bucket, origins }) => {
74
+ return `{
75
+ "$schema": "./node_modules/wrangler/config-schema.json",
76
+ "name": ${JSON.stringify(name)},
77
+ "account_id": ${JSON.stringify(accountId)},
78
+ "main": "src/index.ts",
79
+ "compatibility_date": "2026-08-01",
80
+
81
+ // The binding is what keeps R2 credentials out of your repo entirely — the
82
+ // Worker reaches the bucket directly, so nothing has to be signed or stored
83
+ "r2_buckets": [
84
+ {
85
+ "binding": "BUCKET",
86
+ "bucket_name": ${JSON.stringify(bucket)}
87
+ }
88
+ ],
89
+
90
+ "vars": {
91
+ // Studio origins allowed to call this Worker, comma separated
92
+ "ALLOWED_ORIGINS": ${JSON.stringify(origins)}
93
+ }
94
+
95
+ // UPLOAD_TOKEN is set separately, with: wrangler secret put UPLOAD_TOKEN
96
+ // Not because it's secret — the Studio ships the same value to browsers —
97
+ // but so it stays out of this file, out of git, and out of deploy logs
98
+ }
99
+ `;
100
+ };
101
+
102
+ const setupWorker = async (target, flags) => {
103
+ const directory = resolve(process.cwd(), target ?? "r2-video-worker");
104
+
105
+ if (existsSync(directory)) {
106
+ console.error(
107
+ `\n ${directory} already exists. Move it or pick another.\n`,
108
+ );
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+
113
+ // Prompt only for what wasn't passed, and only when there's someone to ask
114
+ const rl =
115
+ (!flags.account || !flags.bucket) && process.stdin.isTTY
116
+ ? createInterface({ input: process.stdin, output: process.stdout })
117
+ : null;
118
+
119
+ const answers = {
120
+ name: flags.name ?? (await ask(rl, "Worker name", "r2-video")),
121
+ accountId: flags.account ?? (await ask(rl, "Cloudflare account id")),
122
+ bucket: flags.bucket ?? (await ask(rl, "R2 bucket name")),
123
+ origins:
124
+ flags.origins ??
125
+ (await ask(rl, "Allowed Studio origins", "http://localhost:3333")),
126
+ };
127
+
128
+ if (rl) {
129
+ rl.close();
130
+ }
131
+
132
+ if (!answers.accountId || !answers.bucket) {
133
+ console.error(
134
+ "\n An account id and a bucket name are both required." +
135
+ "\n Pass --account and --bucket, or run this in a terminal.\n",
136
+ );
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+
141
+ await mkdir(join(directory, "src"), { recursive: true });
142
+ await writeFile(join(directory, "src", "index.ts"), ENTRY);
143
+ await writeFile(join(directory, "tsconfig.json"), TSCONFIG);
144
+ await writeFile(join(directory, "wrangler.jsonc"), createConfig(answers));
145
+
146
+ const config = `${target ?? "r2-video-worker"}/wrangler.jsonc`;
147
+
148
+ console.info(`
149
+ Written to ${directory}
150
+
151
+ Next:
152
+
153
+ 1. Install its dependencies there:
154
+ pnpm add sanity-plugin-r2-video
155
+ pnpm add -D wrangler @cloudflare/workers-types
156
+ 2. wrangler secret put UPLOAD_TOKEN --config ${config}
157
+ 3. wrangler deploy --config ${config}
158
+ 4. Pass the deployed URL, the same token, and the bucket's public origin
159
+ to r2Video() in sanity.config.ts.
160
+ `);
161
+ };
162
+
163
+ const argv = process.argv.slice(2);
164
+ const [command, subcommand] = argv;
165
+ const target = argv[2] && !argv[2].startsWith("--") ? argv[2] : undefined;
166
+
167
+ if (command === "setup" && subcommand === "worker") {
168
+ await setupWorker(target, parseFlags(argv));
169
+ } else {
170
+ console.info(USAGE);
171
+ process.exitCode = command ? 1 : 0;
172
+ }
@@ -0,0 +1,6 @@
1
+ // storage.ts
2
+ var resolveRenditionPath = (id, height) => {
3
+ return `${id}/${height}.mp4`;
4
+ };
5
+
6
+ export { resolveRenditionPath };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Get the R2 object key for one rendition:
3
+ *
4
+ * ```
5
+ * k3m9p2xq7wvt/1080.mp4
6
+ * └─── id ───┘ └height┘
7
+ * ```
8
+ *
9
+ * One folder per video, one object per tier inside it. Deliberately flat - no
10
+ * folder segment, because folders are a Sanity concern only, so moving or
11
+ * renaming one never has to match anything stored.
12
+ */
13
+ declare const resolveRenditionPath: (id: string, height: number) => string;
14
+
15
+ export { resolveRenditionPath };
@@ -0,0 +1 @@
1
+ export { resolveRenditionPath } from './chunk-D5VS4ED7.js';