sanity-plugin-r2-video 0.1.0 → 0.1.1
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 +144 -96
- 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,12 +1,13 @@
|
|
|
1
1
|
# sanity-plugin-r2-video
|
|
2
2
|
|
|
3
|
-
Video for Sanity Studio
|
|
3
|
+
Video for Sanity Studio. Encodes in the browser, stores plain MP4s in Cloudflare
|
|
4
|
+
R2.
|
|
4
5
|
|
|
5
|
-
Encoding
|
|
6
|
-
WebCodecs
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
Encoding runs on the editor's machine through [mediabunny](https://mediabunny.dev),
|
|
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.
|
|
10
11
|
|
|
11
12
|
## Installing
|
|
12
13
|
|
|
@@ -18,7 +19,8 @@ pnpm add sanity-plugin-r2-video
|
|
|
18
19
|
`styled-components` are peer dependencies. `mediabunny` is the only real
|
|
19
20
|
dependency.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
Add the plugin, pointing it at a Worker you've deployed (see [Setting up a
|
|
23
|
+
Worker](#setting-up-a-worker)):
|
|
22
24
|
|
|
23
25
|
```ts
|
|
24
26
|
// sanity.config.ts
|
|
@@ -29,31 +31,99 @@ export default defineConfig({
|
|
|
29
31
|
r2Video({
|
|
30
32
|
endpointUrl: "https://….workers.dev", // deployed Worker
|
|
31
33
|
token: "…", // matches the Worker's UPLOAD_TOKEN
|
|
32
|
-
bucketUrl: "https://….r2.dev", //
|
|
34
|
+
bucketUrl: "https://….r2.dev", // where renditions are served from
|
|
33
35
|
}),
|
|
34
36
|
],
|
|
35
37
|
});
|
|
36
38
|
```
|
|
37
39
|
|
|
40
|
+
That registers two schema types and a tool. Add video to a document with the
|
|
41
|
+
`r2Video` type:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
defineField({ name: "video", title: "Video", type: "r2Video" })
|
|
45
|
+
```
|
|
46
|
+
|
|
38
47
|
## Using it
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
Videos live in the **R2 Video** tool.
|
|
41
50
|
|
|
42
|
-
**Upload** — drop files onto the library, or press Upload. Files stage in a
|
|
43
|
-
|
|
51
|
+
**Upload** — drop files onto the library, or press Upload. Files stage in a list
|
|
52
|
+
first and nothing encodes until you confirm.
|
|
44
53
|
|
|
45
|
-
**Settings** — collapsed by default. Open it to change the folder, keep audio,
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
**Settings** — collapsed by default. Open it to change the folder, keep audio, or
|
|
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.
|
|
48
57
|
|
|
49
|
-
**Details** — click a card
|
|
50
|
-
size
|
|
58
|
+
**Details** — click a card to play the video and see every rendition with its
|
|
59
|
+
size. Rename it, move it to another folder, or delete it from here.
|
|
51
60
|
|
|
52
|
-
**Sync** —
|
|
61
|
+
**Sync** — next to Upload. Compares the bucket against the library and offers to
|
|
53
62
|
remove anything no document points at.
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
Inside a document, an `r2Video` field lets you pick from the library or upload
|
|
65
|
+
without leaving the page.
|
|
66
|
+
|
|
67
|
+
## What you get back
|
|
68
|
+
|
|
69
|
+
An `r2Video` field stores a reference and nothing else, so the same video can be
|
|
70
|
+
used on many documents without encoding it twice:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "_type": "r2Video", "asset": { "_type": "reference", "_ref": "hK3n…" } }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Follow the reference and you get an `r2Video.asset` document. It holds metadata
|
|
77
|
+
only. The MP4s are in R2 and the poster is a normal Sanity image asset:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
type R2VideoAsset = {
|
|
81
|
+
_id: string;
|
|
82
|
+
_type: "r2Video.asset";
|
|
83
|
+
filename: string; // display name, safe to change
|
|
84
|
+
folder?: { _type: "reference"; _ref: string };
|
|
85
|
+
poster: { _type: "image"; asset: { _type: "reference"; _ref: string } };
|
|
86
|
+
duration: number; // seconds
|
|
87
|
+
hasAudio: boolean;
|
|
88
|
+
uploadedAt: string; // ISO 8601
|
|
89
|
+
renditions: {
|
|
90
|
+
width: number;
|
|
91
|
+
height: number;
|
|
92
|
+
key: string; // R2 object key, e.g. "j6w3wy2bd0jq/720.mp4"
|
|
93
|
+
size: number; // bytes
|
|
94
|
+
}[];
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every type here is exported from `sanity-plugin-r2-video/studio`.
|
|
99
|
+
|
|
100
|
+
A rendition's `key` is the whole story for playback. Join it to your `bucketUrl`
|
|
101
|
+
and you have a source URL:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const src = `${bucketUrl}/${rendition.key}`;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`renditions` only ever contains heights that were actually encoded. A 480p source
|
|
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:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
|
|
117
|
+
|
|
118
|
+
const src = `${bucketUrl}/${resolveRenditionPath(id, 720)}`;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```groq
|
|
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.
|
|
57
127
|
|
|
58
128
|
## Configuring
|
|
59
129
|
|
|
@@ -84,25 +154,25 @@ r2Video({
|
|
|
84
154
|
|
|
85
155
|
### Encoding
|
|
86
156
|
|
|
87
|
-
**`heights`** is the tier ladder. A source shorter than a tier skips it
|
|
88
|
-
tier it does reach is another full encode.
|
|
157
|
+
**`heights`** is the tier ladder. A source shorter than a tier skips it. Every
|
|
158
|
+
tier it does reach is another full encode, so the list is what upload time costs.
|
|
89
159
|
|
|
90
|
-
**`quality`**
|
|
91
|
-
|
|
92
|
-
the source. `1` is QP 16, near-lossless, and routinely
|
|
93
|
-
than the source**.
|
|
160
|
+
**`quality`** sets a quantizer for h264 rather than a bitrate, so quality stays
|
|
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**.
|
|
94
164
|
|
|
95
|
-
**`preferBitrate`** flips that trade
|
|
165
|
+
**`preferBitrate`** flips that trade for predictable size and variable quality. A
|
|
96
166
|
1080p tier lands near 6.1 Mbps at `0.75` whatever the footage.
|
|
97
167
|
|
|
98
168
|
**`nativeTopTier`** copies the top rendition instead of re-encoding it, when its
|
|
99
|
-
height and codec already match the source
|
|
100
|
-
is
|
|
169
|
+
height and codec already match the source. That's instant and bit-identical, and
|
|
170
|
+
its size is whatever the source was exported at.
|
|
101
171
|
|
|
102
172
|
## Setting up a Worker
|
|
103
173
|
|
|
104
|
-
The Worker holds the R2 binding. Scaffold one
|
|
105
|
-
and compatibility date
|
|
174
|
+
The Worker holds the R2 binding. Scaffold one and the binding name, entry point
|
|
175
|
+
and compatibility date come out right:
|
|
106
176
|
|
|
107
177
|
```bash
|
|
108
178
|
pnpx sanity-plugin-r2-video setup worker
|
|
@@ -126,8 +196,8 @@ Give `r2Video()` the deployed URL as `endpointUrl`, the same secret as `token`,
|
|
|
126
196
|
and the bucket's public URL as `bucketUrl`.
|
|
127
197
|
|
|
128
198
|
The generated entry point re-exports this package's handler, so upgrading the
|
|
129
|
-
package upgrades the deployed Worker. Only deployment identity
|
|
130
|
-
|
|
199
|
+
package upgrades the deployed Worker. Only deployment identity gets written out:
|
|
200
|
+
name, account, bucket and origins.
|
|
131
201
|
|
|
132
202
|
## How it works
|
|
133
203
|
|
|
@@ -138,14 +208,14 @@ Studio ──encoded renditions──▶ Worker ──binding──▶ R2 bucket
|
|
|
138
208
|
```
|
|
139
209
|
|
|
140
210
|
The Worker holds the only R2 binding, so **no R2 credentials exist outside
|
|
141
|
-
Cloudflare**. Uploads go through it as plain request bodies
|
|
142
|
-
and the bucket needs no CORS policy. A rendition has to fit in one
|
|
143
|
-
|
|
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.
|
|
144
214
|
|
|
145
|
-
`UPLOAD_TOKEN` ships inside the Studio bundle,
|
|
146
|
-
the upload
|
|
147
|
-
|
|
148
|
-
if you need authentication.
|
|
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.
|
|
149
219
|
|
|
150
220
|
### Entry points
|
|
151
221
|
|
|
@@ -157,18 +227,12 @@ Three, each with its own tsconfig and its own type universe:
|
|
|
157
227
|
./storage where files live nothing
|
|
158
228
|
```
|
|
159
229
|
|
|
160
|
-
`./storage` has no dependencies. A web app
|
|
161
|
-
without pulling the Studio UI,
|
|
162
|
-
|
|
163
|
-
```ts
|
|
164
|
-
import { resolveRenditionPath } from "sanity-plugin-r2-video/storage";
|
|
165
|
-
|
|
166
|
-
const src = `${bucketUrl}/${resolveRenditionPath(asset._id, 720)}`;
|
|
167
|
-
```
|
|
230
|
+
`./storage` has no dependencies. A web app can import it to build source URLs
|
|
231
|
+
without pulling the Studio UI, `sanity` or `react` into its bundle.
|
|
168
232
|
|
|
169
233
|
### Keys
|
|
170
234
|
|
|
171
|
-
One directory per video, one object per tier, no folder segment:
|
|
235
|
+
One directory per video, one object per tier, and no folder segment:
|
|
172
236
|
|
|
173
237
|
```
|
|
174
238
|
j6w3wy2bd0jq/270.mp4
|
|
@@ -178,75 +242,59 @@ j6w3wy2bd0jq/720.mp4
|
|
|
178
242
|
j6w3wy2bd0jq/1080.mp4
|
|
179
243
|
```
|
|
180
244
|
|
|
181
|
-
Documents store
|
|
182
|
-
build sources from `bucketUrl
|
|
183
|
-
domain is a config change
|
|
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.
|
|
184
248
|
|
|
185
249
|
### Folders
|
|
186
250
|
|
|
187
|
-
Videos are filed in the
|
|
188
|
-
parallel set. Create a folder in the Media tool and it
|
|
189
|
-
picker
|
|
251
|
+
Videos are filed in the same folder documents your image library uses, rather
|
|
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.
|
|
190
254
|
|
|
191
|
-
The type is `folders.type`,
|
|
192
|
-
`media.folder`. The plugin never imports that package
|
|
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
|
|
193
257
|
by name, so `folders.type` can point anywhere.
|
|
194
258
|
|
|
195
|
-
Keys carry no folder, so renaming or moving a video never touches the bucket.
|
|
196
|
-
|
|
197
|
-
of them.
|
|
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.
|
|
198
262
|
|
|
199
|
-
Generated posters go to their own folder
|
|
200
|
-
upload, so they stay out of the folders holding real images.
|
|
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.
|
|
201
265
|
|
|
202
266
|
### Deleting
|
|
203
267
|
|
|
204
|
-
Order matters, and `delete-video.ts` documents it:
|
|
268
|
+
Order matters here, and `delete-video.ts` documents it:
|
|
205
269
|
|
|
206
|
-
1. Preflight `*[references($id)]
|
|
207
|
-
2. Delete the document
|
|
208
|
-
3. Delete the poster asset
|
|
270
|
+
1. Preflight `*[references($id)]`. Anything found blocks the delete.
|
|
271
|
+
2. Delete the document, which releases the strong reference to the poster.
|
|
272
|
+
3. Delete the poster asset, now unreferenced, so no `409`.
|
|
209
273
|
4. Delete the R2 objects, batched.
|
|
210
274
|
|
|
211
|
-
Sanity goes before R2
|
|
275
|
+
Sanity goes before R2. An orphaned object is invisible and costs pennies, while a
|
|
212
276
|
document pointing at deleted media breaks the site.
|
|
213
277
|
|
|
214
278
|
### When an upload fails
|
|
215
279
|
|
|
216
|
-
The document is written
|
|
217
|
-
|
|
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.
|
|
218
282
|
|
|
219
|
-
Anything created before that point is rolled back. Keys are recorded
|
|
220
|
-
|
|
221
|
-
Rollback never throws
|
|
283
|
+
Anything created before that point is rolled back. Keys are recorded before each
|
|
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.
|
|
222
287
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
288
|
+
A closed tab or a crash skips rollback, and encoded renditions live only in
|
|
289
|
+
memory, so there's no resume. Whatever either case leaves behind is unreferenced,
|
|
290
|
+
and **Sync** in the tool finds and removes it.
|
|
226
291
|
|
|
227
292
|
## Limitations
|
|
228
293
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
```
|
|
294
|
+
Uploading needs WebCodecs h264 encoding, which Safari doesn't reliably provide,
|
|
295
|
+
so uploads are effectively Chrome-only today. The gate is a capability check
|
|
296
|
+
using mediabunny's `canEncodeVideo('avc')` rather than user-agent sniffing, and
|
|
297
|
+
unsupported browsers are told before they pick a file. Playback works everywhere.
|
|
250
298
|
|
|
251
299
|
## Developing
|
|
252
300
|
|
|
@@ -256,7 +304,7 @@ pnpm run type-check
|
|
|
256
304
|
pnpm run build # tsup: ESM + types into dist
|
|
257
305
|
```
|
|
258
306
|
|
|
259
|
-
`./worker` ships as TypeScript
|
|
307
|
+
`./worker` ships as TypeScript. Wrangler compiles it, and the generated Worker
|
|
260
308
|
extends `worker/tsconfig.json` for the compiler options it was written against.
|
|
261
309
|
|
|
262
310
|
## License
|
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.1",
|
|
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
|
}
|