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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # sanity-plugin-r2-video
2
2
 
3
- Video for Sanity Studio, stored in Cloudflare R2 as a ladder of plain MP4s.
3
+ Video for Sanity Studio. Encodes in the browser, stores plain MP4s in Cloudflare
4
+ R2.
4
5
 
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.
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
- Then add the plugin, and a Worker for it to talk to:
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", // origin renditions are served from
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
- Everything happens in the **R2 Video** tool in the Studio.
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
- list first; nothing encodes until you confirm.
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
- 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.
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. Plays the video, lists every rendition with its
50
- size, and lets you rename it, move it to another folder, or delete it.
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** — beside Upload. Compares the bucket against the library and offers to
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
- In a document, add a video through any `asset` field: pick one from the library,
56
- or upload without leaving the page.
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; every
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`** 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**.
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: predictable size, variable quality. A
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 — instant and bit-identical. Its size
100
- is then whatever the source was exported at.
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 — the binding name, entry point
105
- and compatibility date are fixed, and the CLI fills in the rest:
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 — name, account,
130
- bucket, origins — is written out.
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 — 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.
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, 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.
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 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
- ```
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 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.
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 **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.
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`, defaulting to `sanity-plugin-media`'s
192
- `media.folder`. The plugin never imports that package — it reads a document type
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
- The tool's filter lists only folders holding video; the upload dialog offers all
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 (`folders.poster`), created on first
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)]` — 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`.
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: an orphaned object is invisible and costs pennies, a
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 **last**, so a failure can never leave a video in the
217
- library pointing at files that aren't there.
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 *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.
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
- **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.
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
- ### 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
- ```
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: wrangler compiles it, and the generated Worker
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
@@ -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 — there is nothing an editor can usefully
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
  */
@@ -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
- return /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
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 \u2014 object keys carry no name, so nothing in storage depends on it.",
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(Card, { border: true, padding: 4, radius: 2, tone: "transparent", children: /* @__PURE__ */ jsxs(Grid, { gap: 4, gridTemplateColumns: [2, 4], children: [
1378
- /* @__PURE__ */ jsx(
1379
- Fact,
1380
- {
1381
- label: "Source",
1382
- value: largest ? `${largest.width} \xD7 ${largest.height}` : "Unknown"
1383
- }
1384
- ),
1385
- /* @__PURE__ */ jsx(Fact, { label: "Duration", value: formatDuration(asset.duration) }),
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.0",
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.0.0",
77
- "@sanity/icons": "^5.0.0",
78
- "@sanity/ui": "^4.0.0",
79
- "@types/react": "^19.0.0",
80
- "@types/react-dom": "^19.0.0",
81
- "react": "^19.0.0",
82
- "react-dom": "^19.0.0",
83
- "sanity": "^6.0.0",
84
- "styled-components": "^6.0.0",
85
- "tsup": "^8.5.0",
86
- "typescript": "^5.9.0"
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
  }