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.
@@ -0,0 +1,1836 @@
1
+ import { resolveRenditionPath } from '../chunk-D5VS4ED7.js';
2
+ export { resolveRenditionPath } from '../chunk-D5VS4ED7.js';
3
+ import { PlayIcon } from '@sanity/icons/Play';
4
+ import { defineType, defineField, definePlugin, useClient, set } from 'sanity';
5
+ import { createContext, useState, useCallback, useEffect, useRef, useMemo, useContext, useId } from 'react';
6
+ import { jsx, jsxs } from 'react/jsx-runtime';
7
+ import { UploadIcon } from '@sanity/icons/Upload';
8
+ import { Stack, Button, Flex, Box, TextInput, Spinner, Text, Card, Grid, Dialog, Select, Switch } from '@sanity/ui';
9
+ import { canEncodeVideo } from 'mediabunny';
10
+ import { WarningOutlineIcon } from '@sanity/icons/WarningOutline';
11
+ import { ChevronDownIcon } from '@sanity/icons/ChevronDown';
12
+ import { ChevronRightIcon } from '@sanity/icons/ChevronRight';
13
+ import { SearchIcon } from '@sanity/icons/Search';
14
+ import { SyncIcon } from '@sanity/icons/Sync';
15
+ import { TrashIcon } from '@sanity/icons/Trash';
16
+ import { FolderIcon } from '@sanity/icons/Folder';
17
+
18
+ // studio/client.ts
19
+ var isUploadedObject = (value) => {
20
+ return typeof value === "object" && value !== null && "key" in value && "size" in value;
21
+ };
22
+ var authorize = (config) => {
23
+ return { authorization: `Bearer ${config.token}` };
24
+ };
25
+ var failed = async (response, action) => {
26
+ const detail = await response.text();
27
+ return new Error(`${action} failed (${response.status}). ${detail}`);
28
+ };
29
+ var uploadObject = async (config, key, body) => {
30
+ const response = await fetch(`${config.endpointUrl}/upload`, {
31
+ method: "POST",
32
+ headers: {
33
+ ...authorize(config),
34
+ "content-type": "video/mp4",
35
+ "x-object-key": key
36
+ },
37
+ body
38
+ });
39
+ if (!response.ok) {
40
+ throw await failed(response, `Uploading '${key}'`);
41
+ }
42
+ const payload = await response.json();
43
+ if (!isUploadedObject(payload)) {
44
+ throw new Error(`The Worker returned an unexpected response for '${key}'.`);
45
+ }
46
+ return payload;
47
+ };
48
+ var isKeyPage = (value) => {
49
+ return typeof value === "object" && value !== null && "keys" in value && Array.isArray(value.keys);
50
+ };
51
+ var listObjects = async (config) => {
52
+ const keys = [];
53
+ let cursor = null;
54
+ do {
55
+ const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
56
+ const response = await fetch(`${config.endpointUrl}/files${query}`, {
57
+ headers: authorize(config)
58
+ });
59
+ if (!response.ok) {
60
+ throw await failed(response, "Listing objects");
61
+ }
62
+ const payload = await response.json();
63
+ if (!isKeyPage(payload)) {
64
+ throw new Error("The Worker returned an unexpected listing response.");
65
+ }
66
+ keys.push(...payload.keys);
67
+ cursor = payload.cursor;
68
+ } while (cursor);
69
+ return keys;
70
+ };
71
+ var deleteObjects = async (config, keys) => {
72
+ if (keys.length === 0) {
73
+ return;
74
+ }
75
+ const response = await fetch(`${config.endpointUrl}/files`, {
76
+ method: "DELETE",
77
+ headers: { ...authorize(config), "content-type": "application/json" },
78
+ body: JSON.stringify({ keys })
79
+ });
80
+ if (!response.ok) {
81
+ throw await failed(response, "Deleting objects");
82
+ }
83
+ };
84
+
85
+ // studio/delete-video.ts
86
+ var QUERY_REFERENCES = `*[references($id)]{ _id, _type, title }`;
87
+ var findReferencingDocuments = (client, id) => {
88
+ return client.fetch(QUERY_REFERENCES, { id });
89
+ };
90
+ var deleteVideoAsset = async (client, config, asset) => {
91
+ await client.delete(asset._id);
92
+ try {
93
+ await client.delete(asset.poster.asset._ref);
94
+ } catch (error) {
95
+ console.info(`Kept poster for '${asset.filename}' \u2014 still in use.`, error);
96
+ }
97
+ await deleteObjects(
98
+ config,
99
+ asset.renditions.map((rendition) => rendition.key)
100
+ );
101
+ };
102
+
103
+ // studio/folders.ts
104
+ var QUERY_FOLDERS = `*[_type == $folderType]{
105
+ _id,
106
+ name,
107
+ "parentId": parent._ref
108
+ }`;
109
+ var fetchFolders = (client, folderType) => {
110
+ return client.fetch(QUERY_FOLDERS, { folderType });
111
+ };
112
+ var resolveFolderPath = (folders, id) => {
113
+ const byId = new Map(folders.map((folder) => [folder._id, folder]));
114
+ const segments = [];
115
+ const seen = /* @__PURE__ */ new Set();
116
+ let current = byId.get(id);
117
+ while (current && !seen.has(current._id)) {
118
+ seen.add(current._id);
119
+ segments.unshift(current.name);
120
+ current = current.parentId ? byId.get(current.parentId) : void 0;
121
+ }
122
+ return segments.join("/");
123
+ };
124
+ var resolveFolderPaths = (folders) => {
125
+ return folders.map((folder) => ({
126
+ id: folder._id,
127
+ path: resolveFolderPath(folders, folder._id)
128
+ })).sort((a, b) => a.path.localeCompare(b.path));
129
+ };
130
+ var QUERY_FOLDER_BY_NAME = `*[_type == $folderType && name == $name][0]._id`;
131
+ var findFolderByName = (client, folderType, name) => {
132
+ return client.fetch(QUERY_FOLDER_BY_NAME, {
133
+ folderType,
134
+ name
135
+ });
136
+ };
137
+ var resolvePosterFolder = async (client, folderType, name) => {
138
+ const existing = await findFolderByName(client, folderType, name);
139
+ if (existing) {
140
+ return existing;
141
+ }
142
+ const created = await client.create({ _type: folderType, name });
143
+ return created._id;
144
+ };
145
+ var R2VideoConfigContext = createContext(null);
146
+ var R2VideoConfigProvider = ({ config, children }) => {
147
+ return /* @__PURE__ */ jsx(R2VideoConfigContext.Provider, { value: config, children });
148
+ };
149
+ var useR2VideoConfig = () => {
150
+ const config = useContext(R2VideoConfigContext);
151
+ if (!config) {
152
+ throw new Error(
153
+ "Missing R2 video config. Add `r2Video` to the Studio's plugin list."
154
+ );
155
+ }
156
+ return config;
157
+ };
158
+
159
+ // studio/defaults.ts
160
+ var DEFAULTS = {
161
+ apiVersion: "2024-01-01",
162
+ tool: {
163
+ name: "r2-video",
164
+ title: "R2 Video"
165
+ },
166
+ folders: {
167
+ type: "media.folder",
168
+ poster: "_R2 Video Posters"
169
+ },
170
+ encoding: {
171
+ heights: [270, 360, 480, 720, 1080],
172
+ videoCodec: "avc",
173
+ audioCodec: "aac",
174
+ quality: 0.75,
175
+ preferBitrate: false,
176
+ nativeTopTier: false
177
+ }
178
+ };
179
+ var resolveConfig = (config) => {
180
+ return {
181
+ ...DEFAULTS,
182
+ ...config,
183
+ tool: { ...DEFAULTS.tool, ...config.tool },
184
+ folders: { ...DEFAULTS.folders, ...config.folders },
185
+ encoding: { ...DEFAULTS.encoding, ...config.encoding }
186
+ };
187
+ };
188
+
189
+ // studio/format.ts
190
+ var formatSize = (bytes) => {
191
+ if (bytes < 1e6) {
192
+ return `${Math.round(bytes / 1e3)} KB`;
193
+ }
194
+ return `${(bytes / 1e6).toFixed(1)} MB`;
195
+ };
196
+ var formatDuration = (seconds) => {
197
+ return `${seconds.toFixed(1)}s`;
198
+ };
199
+ var formatBitrate = (bytes, seconds) => {
200
+ if (!seconds) {
201
+ return "\u2014";
202
+ }
203
+ return `${(bytes * 8 / seconds / 1e6).toFixed(2)} Mbps`;
204
+ };
205
+ var OVERLAY_STYLE = {
206
+ alignItems: "center",
207
+ background: "rgba(0, 0, 0, 0.75)",
208
+ color: "#fff",
209
+ display: "flex",
210
+ height: "100%",
211
+ justifyContent: "center",
212
+ position: "absolute",
213
+ right: 0,
214
+ top: 0,
215
+ width: "100%",
216
+ zIndex: 3
217
+ };
218
+ var DragOverlay = () => {
219
+ return /* @__PURE__ */ jsx(Flex, { style: OVERLAY_STYLE, children: /* @__PURE__ */ jsx(Text, { size: 3, style: { color: "inherit" }, children: "Drop files to upload" }) });
220
+ };
221
+ var useFileDrop = ({ onDrop, isEnabled }) => {
222
+ const [isDragging, setIsDragging] = useState(false);
223
+ const depth = useRef(0);
224
+ const reset = () => {
225
+ depth.current = 0;
226
+ setIsDragging(false);
227
+ };
228
+ useEffect(() => {
229
+ if (!isEnabled) {
230
+ depth.current = 0;
231
+ setIsDragging(false);
232
+ }
233
+ }, [isEnabled]);
234
+ const dragEntered = (event) => {
235
+ if (!isEnabled) {
236
+ return;
237
+ }
238
+ event.preventDefault();
239
+ depth.current += 1;
240
+ setIsDragging(true);
241
+ };
242
+ const dragLeft = (event) => {
243
+ if (!isEnabled) {
244
+ return;
245
+ }
246
+ event.preventDefault();
247
+ depth.current -= 1;
248
+ if (depth.current <= 0) {
249
+ reset();
250
+ }
251
+ };
252
+ const dragOver = (event) => {
253
+ if (isEnabled) {
254
+ event.preventDefault();
255
+ }
256
+ };
257
+ const dropped = (event) => {
258
+ if (!isEnabled) {
259
+ return;
260
+ }
261
+ event.preventDefault();
262
+ reset();
263
+ const files = Array.from(event.dataTransfer.files).filter((file) => {
264
+ return file.type.startsWith("video/");
265
+ });
266
+ if (files.length > 0) {
267
+ onDrop(files);
268
+ }
269
+ };
270
+ return {
271
+ isDragging: isDragging && isEnabled,
272
+ dropProps: {
273
+ onDragEnter: dragEntered,
274
+ onDragLeave: dragLeft,
275
+ onDragOver: dragOver,
276
+ onDrop: dropped
277
+ }
278
+ };
279
+ };
280
+ var canEncodeLadder = (codec) => {
281
+ return canEncodeVideo(codec);
282
+ };
283
+ var transcodeVideo = (request, progressed) => {
284
+ return new Promise((resolve, reject) => {
285
+ const worker = new Worker(
286
+ new URL("./transcode.worker.js", import.meta.url),
287
+ { type: "module" }
288
+ );
289
+ worker.addEventListener(
290
+ "message",
291
+ (event) => {
292
+ const message = event.data;
293
+ if (message.type === "progress") {
294
+ progressed(message.progress, message.label);
295
+ return;
296
+ }
297
+ worker.terminate();
298
+ if (message.type === "error") {
299
+ reject(new Error(message.message));
300
+ return;
301
+ }
302
+ resolve(message);
303
+ }
304
+ );
305
+ worker.addEventListener("error", (event) => {
306
+ worker.terminate();
307
+ reject(new Error(event.message));
308
+ });
309
+ worker.postMessage(request);
310
+ });
311
+ };
312
+ var PreviewEncode = ({ file, keepAudio, encoding }) => {
313
+ const [preview, setPreview] = useState(null);
314
+ const [isEncoding, setIsEncoding] = useState(false);
315
+ const [progress, setProgress] = useState(0);
316
+ const [error, setError] = useState(null);
317
+ useEffect(() => {
318
+ setPreview(null);
319
+ setError(null);
320
+ }, []);
321
+ useEffect(() => {
322
+ return () => {
323
+ if (preview) {
324
+ URL.revokeObjectURL(preview.url);
325
+ }
326
+ };
327
+ }, [preview]);
328
+ if (!file) {
329
+ return null;
330
+ }
331
+ const encode = async () => {
332
+ setIsEncoding(true);
333
+ setProgress(0);
334
+ setError(null);
335
+ try {
336
+ const result = await transcodeVideo(
337
+ { file, keepAudio, options: encoding, topTierOnly: true },
338
+ (value) => setProgress(value)
339
+ );
340
+ const [rendition] = result.renditions;
341
+ if (!rendition) {
342
+ throw new Error("Nothing was encoded.");
343
+ }
344
+ const blob = new Blob([rendition.data], { type: "video/mp4" });
345
+ setPreview({
346
+ url: URL.createObjectURL(blob),
347
+ width: rendition.width,
348
+ height: rendition.height,
349
+ bytes: blob.size,
350
+ duration: result.duration
351
+ });
352
+ } catch (caught) {
353
+ setError(caught instanceof Error ? caught.message : String(caught));
354
+ }
355
+ setIsEncoding(false);
356
+ };
357
+ return /* @__PURE__ */ jsx(Card, { border: true, padding: 3, radius: 2, tone: "transparent", children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
358
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
359
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
360
+ "Test these settings on ",
361
+ file.name
362
+ ] }) }),
363
+ /* @__PURE__ */ jsx(
364
+ Button,
365
+ {
366
+ disabled: isEncoding,
367
+ mode: "ghost",
368
+ text: preview ? "Re-encode" : "Preview",
369
+ onClick: encode
370
+ }
371
+ )
372
+ ] }),
373
+ isEncoding && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
374
+ /* @__PURE__ */ jsx(Spinner, { muted: true }),
375
+ /* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
376
+ "Encoding top tier \u2014 ",
377
+ Math.round(progress * 100),
378
+ "%"
379
+ ] })
380
+ ] }),
381
+ error && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
382
+ preview && !isEncoding && /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
383
+ /* @__PURE__ */ jsx(
384
+ "video",
385
+ {
386
+ controls: true,
387
+ loop: true,
388
+ muted: true,
389
+ playsInline: true,
390
+ src: preview.url,
391
+ style: {
392
+ display: "block",
393
+ // Same reasoning as the details player: capped by height and
394
+ // centred, so nothing is letterboxed
395
+ maxHeight: 320,
396
+ maxWidth: "100%",
397
+ margin: "0 auto",
398
+ aspectRatio: `${preview.width} / ${preview.height}`,
399
+ borderRadius: 3
400
+ }
401
+ }
402
+ ),
403
+ /* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
404
+ preview.width,
405
+ " \xD7 ",
406
+ preview.height,
407
+ " \xB7 ",
408
+ formatSize(preview.bytes),
409
+ " \xB7",
410
+ " ",
411
+ formatBitrate(preview.bytes, preview.duration)
412
+ ] }),
413
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 0, children: "This is the largest rendition. Smaller tiers are a fraction of it." })
414
+ ] })
415
+ ] }) });
416
+ };
417
+ var UnsupportedBrowser = () => {
418
+ return /* @__PURE__ */ jsx(Card, { padding: 4, radius: 2, tone: "caution", children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
419
+ /* @__PURE__ */ jsx(Text, { size: 2, children: /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }),
420
+ /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
421
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "This browser can't encode video" }),
422
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Uploading needs WebCodecs h264 encoding, which this browser doesn't provide. Open the Studio in Chrome to add videos. Everything already in the library still works here." })
423
+ ] })
424
+ ] }) });
425
+ };
426
+ var Field = ({ id, label, description, children }) => {
427
+ return /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
428
+ /* @__PURE__ */ jsx(Text, { as: "label", htmlFor: id, size: 1, weight: "medium", children: label }),
429
+ children,
430
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 0, children: description })
431
+ ] });
432
+ };
433
+ var ToggleRow = ({ id, label, description, children }) => {
434
+ return /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
435
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsxs(Stack, { gap: 2, children: [
436
+ /* @__PURE__ */ jsx(Text, { as: "label", htmlFor: id, size: 1, children: label }),
437
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 0, children: description })
438
+ ] }) }),
439
+ children
440
+ ] });
441
+ };
442
+ var UploadSettings = ({
443
+ folderId,
444
+ folderPaths,
445
+ keepAudio,
446
+ quality,
447
+ preferBitrate,
448
+ isDisabled,
449
+ onFolderChange,
450
+ onKeepAudioChange,
451
+ onQualityChange,
452
+ onPreferBitrateChange,
453
+ children
454
+ }) => {
455
+ const [isOpen, setIsOpen] = useState(false);
456
+ const panelId = useId();
457
+ const folderInputId = `${panelId}-folder`;
458
+ const audioInputId = `${panelId}-keep-audio`;
459
+ const qualityInputId = `${panelId}-quality`;
460
+ const bitrateInputId = `${panelId}-prefer-bitrate`;
461
+ return /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
462
+ /* @__PURE__ */ jsx(Flex, { children: /* @__PURE__ */ jsx(
463
+ Button,
464
+ {
465
+ "aria-controls": panelId,
466
+ "aria-expanded": isOpen,
467
+ icon: isOpen ? ChevronDownIcon : ChevronRightIcon,
468
+ mode: "ghost",
469
+ text: "Settings",
470
+ onClick: () => setIsOpen(!isOpen)
471
+ }
472
+ ) }),
473
+ isOpen && /* @__PURE__ */ jsxs(Stack, { gap: 4, id: panelId, children: [
474
+ /* @__PURE__ */ jsx(
475
+ Field,
476
+ {
477
+ description: "Shared with the image library \u2014 create folders there and they appear here.",
478
+ id: folderInputId,
479
+ label: "Folder",
480
+ children: /* @__PURE__ */ jsxs(
481
+ Select,
482
+ {
483
+ disabled: isDisabled,
484
+ id: folderInputId,
485
+ value: folderId,
486
+ onChange: (event) => onFolderChange(event.currentTarget.value),
487
+ children: [
488
+ /* @__PURE__ */ jsx("option", { value: "", children: "No folder" }),
489
+ folderPaths.map((entry) => /* @__PURE__ */ jsx("option", { value: entry.id, children: entry.path }, entry.id))
490
+ ]
491
+ }
492
+ )
493
+ }
494
+ ),
495
+ /* @__PURE__ */ jsx(
496
+ ToggleRow,
497
+ {
498
+ id: audioInputId,
499
+ label: "Keep audio",
500
+ description: "Loops still play muted - autoplay requires it.",
501
+ children: /* @__PURE__ */ jsx(
502
+ Switch,
503
+ {
504
+ checked: keepAudio,
505
+ disabled: isDisabled,
506
+ id: audioInputId,
507
+ onChange: (event) => onKeepAudioChange(event.currentTarget.checked)
508
+ }
509
+ )
510
+ }
511
+ ),
512
+ /* @__PURE__ */ jsx(
513
+ Field,
514
+ {
515
+ description: "0 to 1. Around 0.75 is where h264 stops being distinguishable from the source; 1 is near-lossless and can exceed it.",
516
+ id: qualityInputId,
517
+ label: "Quality",
518
+ children: /* @__PURE__ */ jsx(
519
+ TextInput,
520
+ {
521
+ disabled: isDisabled,
522
+ id: qualityInputId,
523
+ max: 1,
524
+ min: 0,
525
+ step: 0.05,
526
+ type: "number",
527
+ value: quality,
528
+ onChange: (event) => onQualityChange(Number(event.currentTarget.value))
529
+ }
530
+ )
531
+ }
532
+ ),
533
+ /* @__PURE__ */ jsx(
534
+ ToggleRow,
535
+ {
536
+ id: bitrateInputId,
537
+ label: "Predictable file size",
538
+ description: "Targets a bitrate instead of a quality level, so size stops depending on how detailed the footage is.",
539
+ children: /* @__PURE__ */ jsx(
540
+ Switch,
541
+ {
542
+ checked: preferBitrate,
543
+ disabled: isDisabled,
544
+ id: bitrateInputId,
545
+ onChange: (event) => onPreferBitrateChange(event.currentTarget.checked)
546
+ }
547
+ )
548
+ }
549
+ ),
550
+ children
551
+ ] })
552
+ ] });
553
+ };
554
+
555
+ // studio/upload-video.ts
556
+ var dropExtension = (name) => name.replace(/\.[^./\\]+$/, "");
557
+ var ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
558
+ var ID_LENGTH = 12;
559
+ var createId = () => {
560
+ const bytes = crypto.getRandomValues(new Uint8Array(ID_LENGTH));
561
+ return Array.from(bytes, (byte) => ID_ALPHABET[byte & 31]).join("");
562
+ };
563
+ var rollback = async (client, config, written) => {
564
+ try {
565
+ await deleteObjects(config, written.keys);
566
+ } catch (error) {
567
+ console.warn("Could not remove stored renditions after a failure.", error);
568
+ }
569
+ if (!written.posterId) {
570
+ return;
571
+ }
572
+ try {
573
+ await client.delete(written.posterId);
574
+ } catch (error) {
575
+ console.warn("Could not remove the poster after a failure.", error);
576
+ }
577
+ };
578
+ var uploadVideo = async ({
579
+ client,
580
+ config,
581
+ file,
582
+ folderId,
583
+ keepAudio,
584
+ encoding,
585
+ progressed
586
+ }) => {
587
+ const name = dropExtension(file.name);
588
+ const encoded = await transcodeVideo(
589
+ { file, keepAudio, options: encoding },
590
+ (progress, label) => progressed({ stage: "encoding", progress, label })
591
+ );
592
+ const written = { posterId: null, keys: [] };
593
+ try {
594
+ progressed({ stage: "storing", progress: 0, label: "poster" });
595
+ const poster = await client.assets.upload("image", encoded.poster, {
596
+ filename: `${name}.jpg`
597
+ });
598
+ written.posterId = poster._id;
599
+ const posterFolderId = await resolvePosterFolder(
600
+ client,
601
+ config.folders.type,
602
+ config.folders.poster
603
+ );
604
+ await client.patch(poster._id).set({
605
+ "opt.media.folder": {
606
+ _type: "reference",
607
+ _ref: posterFolderId,
608
+ _weak: true
609
+ }
610
+ }).commit();
611
+ const id = createId();
612
+ const renditions = [];
613
+ for (const [index, rendition] of encoded.renditions.entries()) {
614
+ const key = resolveRenditionPath(id, rendition.height);
615
+ written.keys.push(key);
616
+ const stored = await uploadObject(config, key, rendition.data);
617
+ renditions.push({
618
+ width: rendition.width,
619
+ height: rendition.height,
620
+ key: stored.key,
621
+ size: stored.size
622
+ });
623
+ progressed({
624
+ stage: "storing",
625
+ progress: (index + 1) / encoded.renditions.length,
626
+ label: `${rendition.height}p`
627
+ });
628
+ }
629
+ progressed({ stage: "saving", progress: 1, label: name });
630
+ return await client.create({
631
+ _type: "r2Video.asset",
632
+ filename: name,
633
+ ...folderId && {
634
+ folder: { _type: "reference", _ref: folderId }
635
+ },
636
+ poster: {
637
+ _type: "image",
638
+ asset: { _type: "reference", _ref: poster._id }
639
+ },
640
+ duration: encoded.duration,
641
+ hasAudio: encoded.hasAudio,
642
+ renditions,
643
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
644
+ });
645
+ } catch (error) {
646
+ await rollback(client, config, written);
647
+ throw error;
648
+ }
649
+ };
650
+ var STAGE_LABELS = {
651
+ encoding: "Encoding",
652
+ storing: "Storing",
653
+ saving: "Saving"
654
+ };
655
+ var STATUS_TONES = {
656
+ pending: "transparent",
657
+ waiting: "transparent",
658
+ working: "transparent",
659
+ done: "positive",
660
+ failed: "critical"
661
+ };
662
+ var describe = (item) => {
663
+ if (item.status === "failed") {
664
+ return item.error ?? "Failed.";
665
+ }
666
+ if (item.status === "done") {
667
+ return "Done";
668
+ }
669
+ if (item.status === "pending") {
670
+ return formatSize(item.file.size);
671
+ }
672
+ if (item.status === "waiting" || !item.progress) {
673
+ return "Waiting";
674
+ }
675
+ const { stage, progress, label } = item.progress;
676
+ return `${STAGE_LABELS[stage]} ${label} \xB7 ${Math.round(progress * 100)}%`;
677
+ };
678
+ var countLabel = (count) => {
679
+ return count === 1 ? "1 video" : `${count} video(s)`;
680
+ };
681
+ var toItem = (file) => ({
682
+ id: crypto.randomUUID(),
683
+ file,
684
+ status: "pending",
685
+ progress: null,
686
+ error: null
687
+ });
688
+ var DialogUpload = ({
689
+ folderId,
690
+ initialFiles,
691
+ onUploaded,
692
+ onClose
693
+ }) => {
694
+ const config = useR2VideoConfig();
695
+ const client = useClient({ apiVersion: config.apiVersion });
696
+ const [canEncode, setCanEncode] = useState(null);
697
+ const [folders, setFolders] = useState([]);
698
+ const [targetFolder, setTargetFolder] = useState(folderId);
699
+ const [keepAudio, setKeepAudio] = useState(false);
700
+ const [quality, setQuality] = useState(config.encoding.quality);
701
+ const [preferBitrate, setPreferBitrate] = useState(
702
+ config.encoding.preferBitrate
703
+ );
704
+ const [items, setItems] = useState(
705
+ (initialFiles ?? []).map(toItem)
706
+ );
707
+ const isRunning = useRef(false);
708
+ const fileInputRef = useRef(null);
709
+ useEffect(() => {
710
+ canEncodeLadder(config.encoding.videoCodec).then(setCanEncode);
711
+ }, [config.encoding.videoCodec]);
712
+ useEffect(() => {
713
+ fetchFolders(client, config.folders.type).then(setFolders);
714
+ }, [client, config.folders.type]);
715
+ const encoding = useMemo(() => {
716
+ return { ...config.encoding, quality, preferBitrate };
717
+ }, [config.encoding, quality, preferBitrate]);
718
+ const paths = resolveFolderPaths(folders);
719
+ const pending = items.filter((item) => item.status === "pending");
720
+ const isBusy = items.some((item) => item.status === "working");
721
+ const canStart = pending.length > 0 && !isBusy && canEncode !== false;
722
+ const update = (id, changes) => {
723
+ setItems((existing) => {
724
+ return existing.map((item) => {
725
+ return item.id === id ? { ...item, ...changes } : item;
726
+ });
727
+ });
728
+ };
729
+ const run = async (queued) => {
730
+ if (isRunning.current) {
731
+ return;
732
+ }
733
+ isRunning.current = true;
734
+ for (const item of queued) {
735
+ update(item.id, { status: "working", progress: null });
736
+ try {
737
+ const asset = await uploadVideo({
738
+ client,
739
+ config,
740
+ file: item.file,
741
+ folderId: targetFolder,
742
+ keepAudio,
743
+ encoding,
744
+ progressed: (progress) => update(item.id, { progress })
745
+ });
746
+ update(item.id, { status: "done", progress: null });
747
+ onUploaded(asset);
748
+ } catch (error) {
749
+ update(item.id, {
750
+ status: "failed",
751
+ progress: null,
752
+ error: error instanceof Error ? error.message : String(error)
753
+ });
754
+ }
755
+ }
756
+ isRunning.current = false;
757
+ };
758
+ const stage = (files) => {
759
+ setItems((existing) => [...existing, ...files.map(toItem)]);
760
+ };
761
+ const { isDragging, dropProps } = useFileDrop({
762
+ isEnabled: !isBusy && canEncode !== false,
763
+ onDrop: stage
764
+ });
765
+ const browse = () => {
766
+ const input = fileInputRef.current;
767
+ if (input) {
768
+ input.click();
769
+ }
770
+ };
771
+ const chosen = (event) => {
772
+ stage(Array.from(event.target.files ?? []));
773
+ event.target.value = "";
774
+ };
775
+ const removed = (id) => {
776
+ setItems((existing) => existing.filter((item) => item.id !== id));
777
+ };
778
+ const confirmed = () => {
779
+ const queued = pending.map((item) => {
780
+ return { ...item, status: "waiting" };
781
+ });
782
+ const byId = new Map(queued.map((item) => [item.id, item]));
783
+ setItems((existing) => existing.map((item) => byId.get(item.id) ?? item));
784
+ run(queued);
785
+ };
786
+ return /* @__PURE__ */ jsx(
787
+ Dialog,
788
+ {
789
+ header: "Upload videos",
790
+ id: "r2-video-upload",
791
+ width: 1,
792
+ onClose: isBusy ? void 0 : onClose,
793
+ footer: /* @__PURE__ */ jsx(Card, { borderTop: true, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
794
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
795
+ Button,
796
+ {
797
+ disabled: isBusy,
798
+ mode: "ghost",
799
+ text: items.length > 0 && !canStart ? "Done" : "Cancel",
800
+ width: "fill",
801
+ onClick: onClose
802
+ }
803
+ ) }),
804
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
805
+ Button,
806
+ {
807
+ disabled: !canStart,
808
+ text: isBusy ? "Encoding\u2026" : `Upload ${countLabel(pending.length)}`,
809
+ tone: "primary",
810
+ width: "fill",
811
+ onClick: confirmed
812
+ }
813
+ ) })
814
+ ] }) }),
815
+ children: /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 5, children: [
816
+ canEncode === false && /* @__PURE__ */ jsx(UnsupportedBrowser, {}),
817
+ /* @__PURE__ */ jsx(
818
+ "input",
819
+ {
820
+ accept: "video/*",
821
+ multiple: true,
822
+ ref: fileInputRef,
823
+ style: { display: "none" },
824
+ type: "file",
825
+ onChange: chosen
826
+ }
827
+ ),
828
+ canEncode !== false && /* @__PURE__ */ jsxs(
829
+ Card,
830
+ {
831
+ border: true,
832
+ padding: 5,
833
+ radius: 2,
834
+ style: { borderStyle: "dashed", position: "relative" },
835
+ tone: "transparent",
836
+ ...dropProps,
837
+ children: [
838
+ isDragging && /* @__PURE__ */ jsx(DragOverlay, {}),
839
+ /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
840
+ /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
841
+ /* @__PURE__ */ jsx(Flex, { justify: "center", children: /* @__PURE__ */ jsx(Text, { muted: true, size: 3, children: /* @__PURE__ */ jsx(UploadIcon, {}) }) }),
842
+ /* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 1, children: "Drop videos here" }),
843
+ /* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 0, children: "MP4, MOV or WebM \u2014 encoded to every size the site needs" })
844
+ ] }),
845
+ /* @__PURE__ */ jsx(Flex, { justify: "center", children: /* @__PURE__ */ jsx(
846
+ Button,
847
+ {
848
+ disabled: isBusy || canEncode === null,
849
+ mode: "ghost",
850
+ text: "Choose videos",
851
+ onClick: browse
852
+ }
853
+ ) })
854
+ ] })
855
+ ]
856
+ }
857
+ ),
858
+ items.length > 0 && /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
859
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, weight: "medium", children: countLabel(items.length) }),
860
+ /* @__PURE__ */ jsx(Stack, { gap: 2, children: items.map((item) => /* @__PURE__ */ jsx(
861
+ Card,
862
+ {
863
+ border: true,
864
+ padding: 3,
865
+ radius: 2,
866
+ tone: STATUS_TONES[item.status],
867
+ children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
868
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, textOverflow: "ellipsis", children: item.file.name }) }),
869
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: describe(item) }),
870
+ item.status === "pending" && /* @__PURE__ */ jsx(
871
+ Button,
872
+ {
873
+ "aria-label": `Remove ${item.file.name}`,
874
+ disabled: isBusy,
875
+ mode: "ghost",
876
+ padding: 2,
877
+ text: "\u2715",
878
+ onClick: () => removed(item.id)
879
+ }
880
+ )
881
+ ] })
882
+ },
883
+ item.id
884
+ )) })
885
+ ] }),
886
+ /* @__PURE__ */ jsx(
887
+ UploadSettings,
888
+ {
889
+ folderId: targetFolder,
890
+ folderPaths: paths,
891
+ isDisabled: isBusy,
892
+ keepAudio,
893
+ preferBitrate,
894
+ quality,
895
+ onFolderChange: setTargetFolder,
896
+ onKeepAudioChange: setKeepAudio,
897
+ onPreferBitrateChange: setPreferBitrate,
898
+ onQualityChange: setQuality,
899
+ children: /* @__PURE__ */ jsx(
900
+ PreviewEncode,
901
+ {
902
+ encoding,
903
+ file: pending.length > 0 ? pending[0].file : null,
904
+ keepAudio
905
+ }
906
+ )
907
+ }
908
+ )
909
+ ] }) })
910
+ }
911
+ );
912
+ };
913
+ var readFolder = (options) => {
914
+ if (typeof options !== "object" || options === null) {
915
+ return "";
916
+ }
917
+ const folder = Reflect.get(options, "folder");
918
+ return typeof folder === "string" ? folder : "";
919
+ };
920
+ var InputVideo = (props) => {
921
+ const { onChange, readOnly, renderDefault, schemaType } = props;
922
+ const [isUploadOpen, setIsUploadOpen] = useState(false);
923
+ const closeUpload = () => setIsUploadOpen(false);
924
+ const uploaded = (asset) => {
925
+ onChange(
926
+ set({
927
+ _type: "r2Video",
928
+ asset: { _type: "reference", _ref: asset._id }
929
+ })
930
+ );
931
+ closeUpload();
932
+ };
933
+ return /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
934
+ renderDefault(props),
935
+ /* @__PURE__ */ jsx(
936
+ Button,
937
+ {
938
+ disabled: readOnly === true,
939
+ icon: UploadIcon,
940
+ mode: "ghost",
941
+ text: "Upload video",
942
+ onClick: () => setIsUploadOpen(true)
943
+ }
944
+ ),
945
+ isUploadOpen && /* @__PURE__ */ jsx(
946
+ DialogUpload,
947
+ {
948
+ folderId: readFolder(schemaType.options),
949
+ onClose: closeUpload,
950
+ onUploaded: uploaded
951
+ }
952
+ )
953
+ ] });
954
+ };
955
+ var MAX_PLAYER_HEIGHT = 420;
956
+ var resolvePreviewRendition = (renditions) => {
957
+ const ordered = [...renditions].sort((a, b) => a.height - b.height);
958
+ if (ordered.length === 0) {
959
+ return void 0;
960
+ }
961
+ return ordered[Math.max(0, ordered.length - 2)];
962
+ };
963
+ var VideoPreview = ({ renditions, posterUrl }) => {
964
+ const config = useR2VideoConfig();
965
+ const rendition = resolvePreviewRendition(renditions);
966
+ if (!rendition) {
967
+ return /* @__PURE__ */ jsx(Card, { padding: 4, radius: 2, tone: "caution", children: /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "This video has no renditions to play." }) });
968
+ }
969
+ return /* @__PURE__ */ jsx(
970
+ "video",
971
+ {
972
+ autoPlay: true,
973
+ controls: true,
974
+ loop: true,
975
+ muted: true,
976
+ playsInline: true,
977
+ poster: posterUrl,
978
+ preload: "metadata",
979
+ src: `${config.bucketUrl}/${rendition.key}`,
980
+ style: {
981
+ display: "block",
982
+ // Sized by height, not width, so the element is exactly the shape
983
+ // of the video — a width-driven box capped by max-height would
984
+ // letterbox instead, painting bars around anything tall
985
+ maxHeight: MAX_PLAYER_HEIGHT,
986
+ maxWidth: "100%",
987
+ margin: "0 auto",
988
+ aspectRatio: `${rendition.width} / ${rendition.height}`,
989
+ borderRadius: 3
990
+ }
991
+ }
992
+ );
993
+ };
994
+ var isRendition = (value) => {
995
+ return typeof value === "object" && value !== null && "key" in value && "width" in value && "height" in value;
996
+ };
997
+ var readRenditions = (value) => {
998
+ if (typeof value !== "object" || value === null) {
999
+ return [];
1000
+ }
1001
+ const renditions = Reflect.get(value, "renditions");
1002
+ if (!Array.isArray(renditions)) {
1003
+ return [];
1004
+ }
1005
+ return renditions.filter(isRendition);
1006
+ };
1007
+ var InputVideoAsset = (props) => {
1008
+ const renditions = readRenditions(props.value);
1009
+ return /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
1010
+ renditions.length > 0 && /* @__PURE__ */ jsx(VideoPreview, { renditions }),
1011
+ props.renderDefault(props)
1012
+ ] });
1013
+ };
1014
+
1015
+ // studio/schema-video.ts
1016
+ var createVideoAssetSchema = (config) => {
1017
+ return defineType({
1018
+ title: "Video",
1019
+ name: "r2Video.asset",
1020
+ type: "document",
1021
+ components: { input: InputVideoAsset },
1022
+ fields: [
1023
+ defineField({
1024
+ title: "Filename",
1025
+ name: "filename",
1026
+ 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.",
1028
+ validation: (Rule) => Rule.required()
1029
+ }),
1030
+ defineField({
1031
+ title: "Folder",
1032
+ name: "folder",
1033
+ type: "reference",
1034
+ to: [{ type: config.folders.type }],
1035
+ description: "The same folders the image library uses. Renaming one moves this video without touching anything already in the bucket."
1036
+ }),
1037
+ defineField({
1038
+ title: "Poster",
1039
+ name: "poster",
1040
+ type: "image",
1041
+ readOnly: true,
1042
+ description: "First frame, stored as a normal Sanity image.",
1043
+ validation: (Rule) => Rule.required()
1044
+ }),
1045
+ defineField({
1046
+ title: "Duration",
1047
+ name: "duration",
1048
+ type: "number",
1049
+ readOnly: true
1050
+ }),
1051
+ defineField({
1052
+ title: "Has audio",
1053
+ name: "hasAudio",
1054
+ type: "boolean",
1055
+ readOnly: true,
1056
+ initialValue: false
1057
+ }),
1058
+ defineField({
1059
+ title: "Renditions",
1060
+ name: "renditions",
1061
+ type: "array",
1062
+ readOnly: true,
1063
+ of: [
1064
+ {
1065
+ type: "object",
1066
+ name: "rendition",
1067
+ fields: [
1068
+ { name: "width", type: "number" },
1069
+ { name: "height", type: "number" },
1070
+ { name: "key", type: "string" },
1071
+ { name: "size", type: "number" }
1072
+ ],
1073
+ preview: {
1074
+ select: { width: "width", height: "height", size: "size" },
1075
+ prepare({ width, height, size }) {
1076
+ return {
1077
+ title: `${width} \xD7 ${height}`,
1078
+ subtitle: formatSize(size ?? 0)
1079
+ };
1080
+ }
1081
+ }
1082
+ }
1083
+ ]
1084
+ }),
1085
+ defineField({
1086
+ title: "Uploaded at",
1087
+ name: "uploadedAt",
1088
+ type: "datetime",
1089
+ readOnly: true
1090
+ })
1091
+ ],
1092
+ preview: {
1093
+ select: {
1094
+ filename: "filename",
1095
+ folder: "folder.name",
1096
+ media: "poster"
1097
+ },
1098
+ prepare({ filename, folder, media }) {
1099
+ return {
1100
+ title: filename || "Untitled video",
1101
+ subtitle: folder || "No folder",
1102
+ media: media || (() => "\u25B6")
1103
+ };
1104
+ }
1105
+ }
1106
+ });
1107
+ };
1108
+ var SCHEMA_R2_VIDEO = defineType({
1109
+ title: "Video",
1110
+ name: "r2Video",
1111
+ type: "object",
1112
+ options: { collapsible: false },
1113
+ components: { input: InputVideo },
1114
+ fields: [
1115
+ defineField({
1116
+ title: "Asset",
1117
+ name: "asset",
1118
+ type: "reference",
1119
+ to: [{ type: "r2Video.asset" }]
1120
+ })
1121
+ ],
1122
+ preview: {
1123
+ select: {
1124
+ filename: "asset.filename",
1125
+ media: "asset.poster"
1126
+ },
1127
+ prepare({ filename, media }) {
1128
+ return {
1129
+ title: filename || "No video selected.",
1130
+ media: media || (() => "\u25B6")
1131
+ };
1132
+ }
1133
+ }
1134
+ });
1135
+ var DialogDelete = ({ asset, onDeleted, onClose }) => {
1136
+ const config = useR2VideoConfig();
1137
+ const client = useClient({ apiVersion: config.apiVersion });
1138
+ const [blockers, setBlockers] = useState(null);
1139
+ const [isDeleting, setIsDeleting] = useState(false);
1140
+ const [error, setError] = useState(null);
1141
+ useEffect(() => {
1142
+ findReferencingDocuments(client, asset._id).then(setBlockers).catch(() => {
1143
+ setError("Could not check which documents use this video.");
1144
+ });
1145
+ }, [client, asset._id]);
1146
+ const confirmed = async () => {
1147
+ setIsDeleting(true);
1148
+ setError(null);
1149
+ try {
1150
+ await deleteVideoAsset(client, config, asset);
1151
+ onDeleted();
1152
+ } catch (caught) {
1153
+ setError(caught instanceof Error ? caught.message : String(caught));
1154
+ setIsDeleting(false);
1155
+ }
1156
+ };
1157
+ const totalBytes = asset.renditions.reduce((total, rendition) => {
1158
+ return total + rendition.size;
1159
+ }, 0);
1160
+ const isBlocked = blockers !== null && blockers.length > 0;
1161
+ return /* @__PURE__ */ jsx(
1162
+ Dialog,
1163
+ {
1164
+ header: "Delete video",
1165
+ id: "r2-video-delete",
1166
+ width: 1,
1167
+ onClose: isDeleting ? void 0 : onClose,
1168
+ footer: /* @__PURE__ */ jsx(Card, { borderTop: true, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
1169
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1170
+ Button,
1171
+ {
1172
+ disabled: isDeleting,
1173
+ mode: "ghost",
1174
+ text: "Cancel",
1175
+ width: "fill",
1176
+ onClick: onClose
1177
+ }
1178
+ ) }),
1179
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1180
+ Button,
1181
+ {
1182
+ disabled: isDeleting || isBlocked || blockers === null,
1183
+ text: isDeleting ? "Deleting\u2026" : "Delete",
1184
+ tone: "critical",
1185
+ width: "fill",
1186
+ onClick: confirmed
1187
+ }
1188
+ ) })
1189
+ ] }) }),
1190
+ children: /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
1191
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
1192
+ asset.filename,
1193
+ " \u2014 ",
1194
+ asset.renditions.length,
1195
+ " renditions,",
1196
+ " ",
1197
+ formatSize(totalBytes),
1198
+ ", plus its poster."
1199
+ ] }),
1200
+ blockers === null && !error && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
1201
+ /* @__PURE__ */ jsx(Spinner, { muted: true }),
1202
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Checking where it's used\u2026" })
1203
+ ] }),
1204
+ isBlocked && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "caution", children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
1205
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "Still in use" }),
1206
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Remove it from these documents first:" }),
1207
+ blockers.map((blocker) => /* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
1208
+ blocker.title || blocker._id,
1209
+ " (",
1210
+ blocker._type,
1211
+ ")"
1212
+ ] }, blocker._id))
1213
+ ] }) }),
1214
+ blockers !== null && blockers.length === 0 && /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Nothing references it. This can't be undone." }),
1215
+ error && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) })
1216
+ ] }) })
1217
+ }
1218
+ );
1219
+ };
1220
+ var Field2 = ({ label, children }) => {
1221
+ return /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
1222
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, weight: "medium", children: label }),
1223
+ children
1224
+ ] });
1225
+ };
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
+ var DialogDetails = ({
1233
+ asset,
1234
+ onChanged,
1235
+ onDelete,
1236
+ onClose
1237
+ }) => {
1238
+ const config = useR2VideoConfig();
1239
+ const client = useClient({ apiVersion: config.apiVersion });
1240
+ const [folders, setFolders] = useState([]);
1241
+ const [folderId, setFolderId] = useState(
1242
+ asset.folder ? asset.folder._ref : ""
1243
+ );
1244
+ const [filename, setFilename] = useState(asset.filename);
1245
+ const [isSaving, setIsSaving] = useState(false);
1246
+ const [error, setError] = useState(null);
1247
+ useEffect(() => {
1248
+ fetchFolders(client, config.folders.type).then(setFolders);
1249
+ }, [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
+ const paths = resolveFolderPaths(folders);
1256
+ const trimmed = filename.trim();
1257
+ const savedFolderId = asset.folder ? asset.folder._ref : "";
1258
+ const isRenamed = trimmed.length > 0 && trimmed !== asset.filename;
1259
+ const isMoved = folderId !== savedFolderId;
1260
+ const isDirty = isRenamed || isMoved;
1261
+ const reset = () => {
1262
+ setFilename(asset.filename);
1263
+ setFolderId(savedFolderId);
1264
+ };
1265
+ const save = async () => {
1266
+ if (!isDirty) {
1267
+ return;
1268
+ }
1269
+ setIsSaving(true);
1270
+ setError(null);
1271
+ const changes = {};
1272
+ const removals = [];
1273
+ if (isRenamed) {
1274
+ changes.filename = trimmed;
1275
+ }
1276
+ if (isMoved) {
1277
+ if (folderId) {
1278
+ changes.folder = { _type: "reference", _ref: folderId };
1279
+ } else {
1280
+ removals.push("folder");
1281
+ }
1282
+ }
1283
+ try {
1284
+ let mutation = client.patch(asset._id);
1285
+ if (Object.keys(changes).length > 0) {
1286
+ mutation = mutation.set(changes);
1287
+ }
1288
+ if (removals.length > 0) {
1289
+ mutation = mutation.unset(removals);
1290
+ }
1291
+ await mutation.commit();
1292
+ onChanged();
1293
+ } catch (caught) {
1294
+ reset();
1295
+ setError(caught instanceof Error ? caught.message : String(caught));
1296
+ }
1297
+ setIsSaving(false);
1298
+ };
1299
+ return /* @__PURE__ */ jsx(
1300
+ Dialog,
1301
+ {
1302
+ header: asset.filename,
1303
+ id: "r2-video-details",
1304
+ width: 1,
1305
+ onClose: isSaving ? void 0 : onClose,
1306
+ footer: /* @__PURE__ */ jsx(Card, { borderTop: true, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
1307
+ /* @__PURE__ */ jsx(
1308
+ Button,
1309
+ {
1310
+ "aria-label": "Delete video",
1311
+ disabled: isSaving,
1312
+ icon: TrashIcon,
1313
+ mode: "ghost",
1314
+ tone: "critical",
1315
+ onClick: onDelete
1316
+ }
1317
+ ),
1318
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1319
+ Button,
1320
+ {
1321
+ disabled: isSaving,
1322
+ mode: "ghost",
1323
+ text: isDirty ? "Discard" : "Close",
1324
+ width: "fill",
1325
+ onClick: isDirty ? reset : onClose
1326
+ }
1327
+ ) }),
1328
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1329
+ Button,
1330
+ {
1331
+ disabled: isSaving || !isDirty,
1332
+ text: isSaving ? "Saving\u2026" : "Save changes",
1333
+ tone: "primary",
1334
+ width: "fill",
1335
+ onClick: save
1336
+ }
1337
+ ) })
1338
+ ] }) }),
1339
+ children: /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 5, children: [
1340
+ /* @__PURE__ */ jsx(
1341
+ VideoPreview,
1342
+ {
1343
+ renditions: asset.renditions,
1344
+ posterUrl: asset.posterUrl
1345
+ }
1346
+ ),
1347
+ /* @__PURE__ */ jsxs(Flex, { gap: 3, children: [
1348
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Field2, { label: "Filename", children: /* @__PURE__ */ jsx(
1349
+ TextInput,
1350
+ {
1351
+ "aria-label": "Filename",
1352
+ disabled: isSaving,
1353
+ value: filename,
1354
+ onChange: (event) => setFilename(event.currentTarget.value),
1355
+ onKeyDown: (event) => {
1356
+ if (event.key === "Enter") {
1357
+ save();
1358
+ }
1359
+ }
1360
+ }
1361
+ ) }) }),
1362
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Field2, { label: "Folder", children: /* @__PURE__ */ jsxs(
1363
+ Select,
1364
+ {
1365
+ "aria-label": "Folder",
1366
+ disabled: isSaving,
1367
+ value: folderId,
1368
+ onChange: (event) => setFolderId(event.currentTarget.value),
1369
+ children: [
1370
+ /* @__PURE__ */ jsx("option", { value: "", children: "No folder" }),
1371
+ paths.map((entry) => /* @__PURE__ */ jsx("option", { value: entry.id, children: entry.path }, entry.id))
1372
+ ]
1373
+ }
1374
+ ) }) })
1375
+ ] }),
1376
+ 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
+ ] })
1419
+ ] }) })
1420
+ }
1421
+ );
1422
+ };
1423
+
1424
+ // studio/orphans.ts
1425
+ var QUERY_LIVE_KEYS = `array::unique(*[_type == "r2Video.asset"].renditions[].key)`;
1426
+ var QUERY_ORPHAN_POSTERS = `*[
1427
+ _type == "sanity.imageAsset"
1428
+ && opt.media.folder._ref == $folderId
1429
+ && count(*[references(^._id)]) == 0
1430
+ ]._id`;
1431
+ var findOrphans = async (client, config) => {
1432
+ const folderId = await findFolderByName(
1433
+ client,
1434
+ config.folders.type,
1435
+ config.folders.poster
1436
+ );
1437
+ const [stored, liveKeys, posterIds] = await Promise.all([
1438
+ listObjects(config),
1439
+ client.fetch(QUERY_LIVE_KEYS),
1440
+ folderId ? client.fetch(QUERY_ORPHAN_POSTERS, { folderId }) : Promise.resolve([])
1441
+ ]);
1442
+ const live = new Set(liveKeys);
1443
+ return {
1444
+ keys: stored.filter((key) => !live.has(key)),
1445
+ posterIds
1446
+ };
1447
+ };
1448
+ var removeOrphans = async (client, config, orphans) => {
1449
+ await deleteObjects(config, orphans.keys);
1450
+ for (const posterId of orphans.posterIds) {
1451
+ try {
1452
+ await client.delete(posterId);
1453
+ } catch (error) {
1454
+ console.warn(`Kept poster '${posterId}' \u2014 still referenced.`, error);
1455
+ }
1456
+ }
1457
+ };
1458
+ var countLabel2 = (count, noun) => {
1459
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
1460
+ };
1461
+ var DialogOrphans = ({ onCleaned, onClose }) => {
1462
+ const config = useR2VideoConfig();
1463
+ const client = useClient({ apiVersion: config.apiVersion });
1464
+ const [orphans, setOrphans] = useState(null);
1465
+ const [isRemoving, setIsRemoving] = useState(false);
1466
+ const [isDone, setIsDone] = useState(false);
1467
+ const [error, setError] = useState(null);
1468
+ useEffect(() => {
1469
+ findOrphans(client, config).then(setOrphans).catch((caught) => {
1470
+ setError(caught instanceof Error ? caught.message : String(caught));
1471
+ });
1472
+ }, [client, config]);
1473
+ const confirmed = async () => {
1474
+ if (!orphans) {
1475
+ return;
1476
+ }
1477
+ setIsRemoving(true);
1478
+ setError(null);
1479
+ try {
1480
+ await removeOrphans(client, config, orphans);
1481
+ setIsDone(true);
1482
+ onCleaned();
1483
+ } catch (caught) {
1484
+ setError(caught instanceof Error ? caught.message : String(caught));
1485
+ }
1486
+ setIsRemoving(false);
1487
+ };
1488
+ const total = orphans ? orphans.keys.length + orphans.posterIds.length : 0;
1489
+ return /* @__PURE__ */ jsx(
1490
+ Dialog,
1491
+ {
1492
+ header: "Sync storage",
1493
+ id: "r2-video-orphans",
1494
+ width: 1,
1495
+ onClose: isRemoving ? void 0 : onClose,
1496
+ footer: /* @__PURE__ */ jsx(Card, { borderTop: true, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
1497
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1498
+ Button,
1499
+ {
1500
+ disabled: isRemoving,
1501
+ mode: "ghost",
1502
+ text: isDone ? "Done" : "Cancel",
1503
+ width: "fill",
1504
+ onClick: onClose
1505
+ }
1506
+ ) }),
1507
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1508
+ Button,
1509
+ {
1510
+ disabled: isRemoving || isDone || total === 0,
1511
+ text: isRemoving ? "Removing\u2026" : "Remove",
1512
+ tone: "critical",
1513
+ width: "fill",
1514
+ onClick: confirmed
1515
+ }
1516
+ ) })
1517
+ ] }) }),
1518
+ children: /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
1519
+ !orphans && !error && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
1520
+ /* @__PURE__ */ jsx(Spinner, { muted: true }),
1521
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Comparing storage against the library\u2026" })
1522
+ ] }),
1523
+ isDone && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "positive", children: /* @__PURE__ */ jsx(Text, { size: 1, children: "Removed." }) }),
1524
+ orphans && !isDone && total === 0 && /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Nothing to clean up \u2014 every stored file belongs to a video." }),
1525
+ orphans && !isDone && total > 0 && /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
1526
+ /* @__PURE__ */ jsxs(Text, { size: 1, children: [
1527
+ "Found ",
1528
+ countLabel2(orphans.keys.length, "orphaned file"),
1529
+ " in R2 and ",
1530
+ countLabel2(orphans.posterIds.length, "unused poster"),
1531
+ " in Sanity."
1532
+ ] }),
1533
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Nothing references any of them, so removing them can't affect the site. This can't be undone." })
1534
+ ] }),
1535
+ error && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) })
1536
+ ] }) })
1537
+ }
1538
+ );
1539
+ };
1540
+ var SIDEBAR_WIDTH = 260;
1541
+ var Row = ({ label, count, isSelected, hasIcon, onSelect }) => {
1542
+ return /* @__PURE__ */ jsx(
1543
+ Card,
1544
+ {
1545
+ as: "button",
1546
+ padding: 3,
1547
+ radius: 2,
1548
+ tone: isSelected ? "primary" : "default",
1549
+ onClick: onSelect,
1550
+ style: { cursor: "pointer", textAlign: "left", width: "100%" },
1551
+ children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
1552
+ hasIcon && /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: /* @__PURE__ */ jsx(FolderIcon, {}) }),
1553
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1554
+ Text,
1555
+ {
1556
+ size: 1,
1557
+ textOverflow: "ellipsis",
1558
+ weight: isSelected ? "medium" : void 0,
1559
+ children: label
1560
+ }
1561
+ ) }),
1562
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: count })
1563
+ ] })
1564
+ }
1565
+ );
1566
+ };
1567
+ var FolderSidebar = ({
1568
+ folders,
1569
+ counts,
1570
+ total,
1571
+ selectedId,
1572
+ onSelect
1573
+ }) => {
1574
+ return /* @__PURE__ */ jsxs(
1575
+ Card,
1576
+ {
1577
+ borderRight: true,
1578
+ height: "fill",
1579
+ style: { width: SIDEBAR_WIDTH, flex: "none", overflowY: "auto" },
1580
+ children: [
1581
+ /* @__PURE__ */ jsx(Card, { borderBottom: true, padding: 4, children: /* @__PURE__ */ jsx(Text, { muted: true, size: 0, weight: "semibold", children: "FOLDERS" }) }),
1582
+ /* @__PURE__ */ jsx(Box, { padding: 2, children: /* @__PURE__ */ jsxs(Stack, { gap: 1, children: [
1583
+ /* @__PURE__ */ jsx(
1584
+ Row,
1585
+ {
1586
+ count: total,
1587
+ isSelected: !selectedId,
1588
+ label: "All assets",
1589
+ onSelect: () => onSelect("")
1590
+ }
1591
+ ),
1592
+ folders.map((folder) => /* @__PURE__ */ jsx(
1593
+ Row,
1594
+ {
1595
+ count: counts.get(folder.id) ?? 0,
1596
+ hasIcon: true,
1597
+ isSelected: selectedId === folder.id,
1598
+ label: folder.path,
1599
+ onSelect: () => onSelect(folder.id)
1600
+ },
1601
+ folder.id
1602
+ ))
1603
+ ] }) })
1604
+ ]
1605
+ }
1606
+ );
1607
+ };
1608
+ var QUERY_ASSETS = `*[_type == "r2Video.asset"] | order(uploadedAt desc){
1609
+ _id,
1610
+ _type,
1611
+ filename,
1612
+ folder,
1613
+ poster,
1614
+ duration,
1615
+ hasAudio,
1616
+ renditions,
1617
+ uploadedAt,
1618
+ "posterUrl": poster.asset->url,
1619
+ "folderName": folder->name
1620
+ }`;
1621
+ var slugify = (name) => {
1622
+ return name.toLowerCase().replace(/['\u2019]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1623
+ };
1624
+ var toTitle = (asset, isFiltered) => {
1625
+ if (isFiltered || !asset.folderName) {
1626
+ return asset.filename;
1627
+ }
1628
+ return `${slugify(asset.folderName)}/${asset.filename}`;
1629
+ };
1630
+ var matches = (asset, search) => {
1631
+ if (!search) {
1632
+ return true;
1633
+ }
1634
+ const term = search.toLowerCase();
1635
+ const folderName = asset.folderName ?? "";
1636
+ return asset.filename.toLowerCase().includes(term) || folderName.toLowerCase().includes(term);
1637
+ };
1638
+ var ToolVideoLibrary = () => {
1639
+ const config = useR2VideoConfig();
1640
+ const client = useClient({ apiVersion: config.apiVersion });
1641
+ const [assets, setAssets] = useState(null);
1642
+ const [folders, setFolders] = useState([]);
1643
+ const [search, setSearch] = useState("");
1644
+ const [folderId, setFolderId] = useState("");
1645
+ const [isUploadOpen, setIsUploadOpen] = useState(false);
1646
+ const [isOrphansOpen, setIsOrphansOpen] = useState(false);
1647
+ const [droppedFiles, setDroppedFiles] = useState([]);
1648
+ const [detailing, setDetailing] = useState(null);
1649
+ const [deleting, setDeleting] = useState(null);
1650
+ const load = useCallback(() => {
1651
+ client.fetch(QUERY_ASSETS).then(setAssets);
1652
+ fetchFolders(client, config.folders.type).then(setFolders);
1653
+ }, [client, config.folders.type]);
1654
+ useEffect(load, [load]);
1655
+ const counts = /* @__PURE__ */ new Map();
1656
+ for (const asset of assets ?? []) {
1657
+ if (asset.folder) {
1658
+ const id = asset.folder._ref;
1659
+ counts.set(id, (counts.get(id) ?? 0) + 1);
1660
+ }
1661
+ }
1662
+ const paths = resolveFolderPaths(folders).filter((entry) => {
1663
+ return counts.has(entry.id);
1664
+ });
1665
+ const { isDragging, dropProps } = useFileDrop({
1666
+ isEnabled: !isUploadOpen,
1667
+ onDrop: (files) => {
1668
+ setDroppedFiles(files);
1669
+ setIsUploadOpen(true);
1670
+ }
1671
+ });
1672
+ const closeUpload = () => {
1673
+ setIsUploadOpen(false);
1674
+ setDroppedFiles([]);
1675
+ load();
1676
+ };
1677
+ const visible = (assets ?? []).filter((asset) => {
1678
+ const inFolder = !folderId || asset.folder && asset.folder._ref === folderId;
1679
+ return matches(asset, search) && inFolder;
1680
+ });
1681
+ return /* @__PURE__ */ jsxs(
1682
+ Flex,
1683
+ {
1684
+ style: { position: "relative", height: "100%", minHeight: "100%" },
1685
+ ...dropProps,
1686
+ children: [
1687
+ /* @__PURE__ */ jsx(
1688
+ FolderSidebar,
1689
+ {
1690
+ counts,
1691
+ folders: paths,
1692
+ selectedId: folderId,
1693
+ total: (assets ?? []).length,
1694
+ onSelect: setFolderId
1695
+ }
1696
+ ),
1697
+ /* @__PURE__ */ jsx(Box, { flex: 1, style: { overflowY: "auto" }, children: /* @__PURE__ */ jsx(Box, { padding: 4, children: /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
1698
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, children: [
1699
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
1700
+ TextInput,
1701
+ {
1702
+ icon: SearchIcon,
1703
+ placeholder: "Search videos",
1704
+ value: search,
1705
+ onChange: (event) => setSearch(event.currentTarget.value)
1706
+ }
1707
+ ) }),
1708
+ /* @__PURE__ */ jsx(
1709
+ Button,
1710
+ {
1711
+ icon: SyncIcon,
1712
+ mode: "ghost",
1713
+ text: "Sync",
1714
+ onClick: () => setIsOrphansOpen(true)
1715
+ }
1716
+ ),
1717
+ /* @__PURE__ */ jsx(
1718
+ Button,
1719
+ {
1720
+ icon: UploadIcon,
1721
+ text: "Upload",
1722
+ tone: "primary",
1723
+ onClick: () => setIsUploadOpen(true)
1724
+ }
1725
+ )
1726
+ ] }),
1727
+ assets === null && /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 3, padding: 4, children: [
1728
+ /* @__PURE__ */ jsx(Spinner, { muted: true }),
1729
+ /* @__PURE__ */ jsx(Text, { muted: true, size: 1, children: "Loading library\u2026" })
1730
+ ] }),
1731
+ assets !== null && visible.length === 0 && /* @__PURE__ */ jsx(Card, { padding: 5, radius: 2, tone: "transparent", children: /* @__PURE__ */ jsx(Text, { align: "center", muted: true, size: 1, children: "No videos here yet." }) }),
1732
+ /* @__PURE__ */ jsx(Grid, { gridTemplateColumns: [1, 2, 3, 4], gap: 3, children: visible.map((asset) => /* @__PURE__ */ jsx(
1733
+ Card,
1734
+ {
1735
+ as: "button",
1736
+ border: true,
1737
+ padding: 2,
1738
+ radius: 2,
1739
+ style: { cursor: "pointer", textAlign: "left" },
1740
+ onClick: () => setDetailing(asset),
1741
+ children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
1742
+ /* @__PURE__ */ jsx(
1743
+ Box,
1744
+ {
1745
+ style: {
1746
+ aspectRatio: "16 / 9",
1747
+ backgroundImage: asset.posterUrl ? `url(${asset.posterUrl}?w=480&fit=crop&auto=format)` : void 0,
1748
+ backgroundPosition: "center",
1749
+ backgroundSize: "cover",
1750
+ borderRadius: 2
1751
+ }
1752
+ }
1753
+ ),
1754
+ /* @__PURE__ */ jsxs(Stack, { gap: 2, children: [
1755
+ /* @__PURE__ */ jsx(Text, { size: 1, textOverflow: "ellipsis", weight: "medium", children: toTitle(asset, Boolean(folderId)) }),
1756
+ /* @__PURE__ */ jsxs(Text, { muted: true, size: 1, children: [
1757
+ asset.renditions.length,
1758
+ " sizes",
1759
+ asset.hasAudio ? " \xB7 audio" : ""
1760
+ ] })
1761
+ ] })
1762
+ ] })
1763
+ },
1764
+ asset._id
1765
+ )) })
1766
+ ] }) }) }),
1767
+ isDragging && /* @__PURE__ */ jsx(DragOverlay, {}),
1768
+ isUploadOpen && /* @__PURE__ */ jsx(
1769
+ DialogUpload,
1770
+ {
1771
+ folderId,
1772
+ initialFiles: droppedFiles,
1773
+ onClose: closeUpload,
1774
+ onUploaded: load
1775
+ }
1776
+ ),
1777
+ isOrphansOpen && /* @__PURE__ */ jsx(
1778
+ DialogOrphans,
1779
+ {
1780
+ onCleaned: load,
1781
+ onClose: () => setIsOrphansOpen(false)
1782
+ }
1783
+ ),
1784
+ detailing && /* @__PURE__ */ jsx(
1785
+ DialogDetails,
1786
+ {
1787
+ asset: detailing,
1788
+ onChanged: load,
1789
+ onDelete: () => {
1790
+ setDeleting(detailing);
1791
+ setDetailing(null);
1792
+ },
1793
+ onClose: () => setDetailing(null)
1794
+ }
1795
+ ),
1796
+ deleting && /* @__PURE__ */ jsx(
1797
+ DialogDelete,
1798
+ {
1799
+ asset: deleting,
1800
+ onClose: () => setDeleting(null),
1801
+ onDeleted: () => {
1802
+ setDeleting(null);
1803
+ load();
1804
+ }
1805
+ }
1806
+ )
1807
+ ]
1808
+ }
1809
+ );
1810
+ };
1811
+ var r2Video = definePlugin((options) => {
1812
+ const resolvedConfig = resolveConfig(options);
1813
+ return {
1814
+ // The plugin's own identity, not the tool's route — 'tool.name' below is
1815
+ // configurable and moves the tool's URL, this never changes
1816
+ name: "r2-video",
1817
+ schema: {
1818
+ types: [createVideoAssetSchema(resolvedConfig), SCHEMA_R2_VIDEO]
1819
+ },
1820
+ studio: {
1821
+ components: {
1822
+ layout: (props) => /* @__PURE__ */ jsx(R2VideoConfigProvider, { config: resolvedConfig, children: props.renderDefault(props) })
1823
+ }
1824
+ },
1825
+ tools: [
1826
+ {
1827
+ name: resolvedConfig.tool.name,
1828
+ title: resolvedConfig.tool.title,
1829
+ icon: PlayIcon,
1830
+ component: ToolVideoLibrary
1831
+ }
1832
+ ]
1833
+ };
1834
+ });
1835
+
1836
+ export { SCHEMA_R2_VIDEO, VideoPreview, createVideoAssetSchema, deleteVideoAsset, fetchFolders, findReferencingDocuments, r2Video, resolveFolderPath, resolveFolderPaths, resolvePreviewRendition, uploadVideo };