@devultur/convex 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/dist/bin/init.d.ts +1 -0
- package/dist/bin/init.js +112 -0
- package/dist/chunk-FHWT3GD6.js +21 -0
- package/dist/component/_generated/api.d.ts +36 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +100 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +130 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +79 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +6 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +10 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +25 -0
- package/dist/component/http.js.map +1 -0
- package/dist/component/public.d.ts +221 -0
- package/dist/component/public.d.ts.map +1 -0
- package/dist/component/public.js +429 -0
- package/dist/component/public.js.map +1 -0
- package/dist/component/schema.d.ts +169 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +63 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +121 -0
- package/dist/react.d.ts +281 -0
- package/dist/react.js +756 -0
- package/dist/validators.d.ts +31 -0
- package/dist/validators.js +6 -0
- package/package.json +70 -3
package/dist/react.js
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/react.tsx
|
|
4
|
+
import React2 from "react";
|
|
5
|
+
import {
|
|
6
|
+
buildMediaUrl,
|
|
7
|
+
CaptionLocaleLabel,
|
|
8
|
+
DEFAULT_TOKEN_EXPIRY_SECONDS,
|
|
9
|
+
MediaStatus,
|
|
10
|
+
mediaCaptionPath,
|
|
11
|
+
mediaPath,
|
|
12
|
+
mediaUrl,
|
|
13
|
+
SESSION_HEADER,
|
|
14
|
+
visibilityFromKey
|
|
15
|
+
} from "@devultur/core";
|
|
16
|
+
import { UploadHttpError, uploadFileWithProgress } from "@devultur/react";
|
|
17
|
+
import { useAction, useConvex, useMutation, useQuery } from "convex/react";
|
|
18
|
+
|
|
19
|
+
// src/image-recovery.ts
|
|
20
|
+
async function isSessionMismatch(response) {
|
|
21
|
+
try {
|
|
22
|
+
const data = await response.clone().json();
|
|
23
|
+
return data.code === "session_mismatch";
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/media-upload-derive.ts
|
|
30
|
+
function deriveUploadState(local, videoState, retry) {
|
|
31
|
+
const { phase, percent, file, failure, media, processRequested } = local;
|
|
32
|
+
if (phase === "uploading") return { phase: "uploading", percent, file };
|
|
33
|
+
if (phase === "upload-failed" && failure) {
|
|
34
|
+
return {
|
|
35
|
+
phase: "failed",
|
|
36
|
+
stage: "upload",
|
|
37
|
+
reason: failure.reason,
|
|
38
|
+
retriable: failure.retriable,
|
|
39
|
+
message: failure.message,
|
|
40
|
+
retry
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (phase === "idle" || !media) return { phase: "idle" };
|
|
44
|
+
if (media.kind !== "video" || !processRequested) return { phase: "ready", media };
|
|
45
|
+
switch (videoState.phase) {
|
|
46
|
+
case "processing":
|
|
47
|
+
return { phase: "processing", media, step: videoState.step, percent: videoState.percent };
|
|
48
|
+
case "ready":
|
|
49
|
+
return {
|
|
50
|
+
phase: "ready",
|
|
51
|
+
media,
|
|
52
|
+
playlist: videoState.playlist,
|
|
53
|
+
duration: videoState.duration,
|
|
54
|
+
captions: videoState.captions,
|
|
55
|
+
playerProps: videoState.playerProps
|
|
56
|
+
};
|
|
57
|
+
case "failed":
|
|
58
|
+
return {
|
|
59
|
+
phase: "failed",
|
|
60
|
+
stage: "transcode",
|
|
61
|
+
reason: videoState.reason,
|
|
62
|
+
retriable: videoState.retriable,
|
|
63
|
+
message: videoState.message,
|
|
64
|
+
retry
|
|
65
|
+
};
|
|
66
|
+
default:
|
|
67
|
+
return { phase: "queued", media };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/crop-upload.tsx
|
|
72
|
+
import React from "react";
|
|
73
|
+
import { ImageCropper } from "@devultur/react";
|
|
74
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
75
|
+
var DEFAULT_LABELS = {
|
|
76
|
+
zoom: "Zoom",
|
|
77
|
+
confirm: "Crop and upload",
|
|
78
|
+
processing: "Processing\u2026",
|
|
79
|
+
cancel: "Cancel"
|
|
80
|
+
};
|
|
81
|
+
function CropUpload({
|
|
82
|
+
aspectRatio,
|
|
83
|
+
visibility,
|
|
84
|
+
outputFormat,
|
|
85
|
+
maxWidth,
|
|
86
|
+
minSourceWidth,
|
|
87
|
+
replaces,
|
|
88
|
+
onComplete,
|
|
89
|
+
onReject,
|
|
90
|
+
accept = ["image/*"],
|
|
91
|
+
cropperClassName,
|
|
92
|
+
labels,
|
|
93
|
+
children
|
|
94
|
+
}) {
|
|
95
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
96
|
+
const [pendingFile, setPendingFile] = React.useState(null);
|
|
97
|
+
const inputRef = React.useRef(null);
|
|
98
|
+
const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
|
|
99
|
+
const pickFile = React.useCallback(() => inputRef.current?.click(), []);
|
|
100
|
+
const handleCrop = React.useCallback(
|
|
101
|
+
(baked) => {
|
|
102
|
+
setPendingFile(null);
|
|
103
|
+
upload(baked);
|
|
104
|
+
},
|
|
105
|
+
[upload]
|
|
106
|
+
);
|
|
107
|
+
const resetAll = React.useCallback(() => {
|
|
108
|
+
setPendingFile(null);
|
|
109
|
+
reset();
|
|
110
|
+
}, [reset]);
|
|
111
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
112
|
+
/* @__PURE__ */ jsx(
|
|
113
|
+
"input",
|
|
114
|
+
{
|
|
115
|
+
ref: inputRef,
|
|
116
|
+
type: "file",
|
|
117
|
+
accept: accept.join(","),
|
|
118
|
+
hidden: true,
|
|
119
|
+
onChange: (event) => {
|
|
120
|
+
const file = event.target.files?.[0];
|
|
121
|
+
if (file) setPendingFile(file);
|
|
122
|
+
event.target.value = "";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
),
|
|
126
|
+
pendingFile && /* @__PURE__ */ jsx(
|
|
127
|
+
ImageCropper,
|
|
128
|
+
{
|
|
129
|
+
file: pendingFile,
|
|
130
|
+
aspectRatio,
|
|
131
|
+
outputFormat,
|
|
132
|
+
maxWidth,
|
|
133
|
+
minSourceWidth,
|
|
134
|
+
onCrop: handleCrop,
|
|
135
|
+
onReject,
|
|
136
|
+
className: cropperClassName,
|
|
137
|
+
children: ({ zoom, setZoom, confirm, isBaking }) => /* @__PURE__ */ jsxs("div", { className: "dv-crop-controls", children: [
|
|
138
|
+
/* @__PURE__ */ jsx(
|
|
139
|
+
"input",
|
|
140
|
+
{
|
|
141
|
+
type: "range",
|
|
142
|
+
min: 1,
|
|
143
|
+
max: 3,
|
|
144
|
+
step: 0.01,
|
|
145
|
+
value: zoom,
|
|
146
|
+
onChange: (event) => setZoom(Number(event.target.value)),
|
|
147
|
+
"aria-label": text.zoom
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
/* @__PURE__ */ jsx("button", { type: "button", onClick: confirm, disabled: isBaking, children: isBaking ? text.processing : text.confirm }),
|
|
151
|
+
/* @__PURE__ */ jsx("button", { type: "button", onClick: () => setPendingFile(null), children: text.cancel })
|
|
152
|
+
] })
|
|
153
|
+
}
|
|
154
|
+
),
|
|
155
|
+
children?.({ state, pickFile, pendingFile, cancel, reset: resetAll })
|
|
156
|
+
] });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/react.tsx
|
|
160
|
+
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
161
|
+
var DevulturContext = React2.createContext(null);
|
|
162
|
+
var DevulturApiContext = React2.createContext(null);
|
|
163
|
+
var ProgressContext = React2.createContext(null);
|
|
164
|
+
var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
|
|
165
|
+
var MIN_REFRESH_MS = 6e4;
|
|
166
|
+
var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
|
|
167
|
+
function extractTokenResult(result) {
|
|
168
|
+
const tokenValue = typeof result === "string" ? result : result.token;
|
|
169
|
+
const ttlMs = typeof result === "string" ? DEFAULT_TTL_MS : result.expiresIn * 1e3;
|
|
170
|
+
return { tokenValue, ttlMs };
|
|
171
|
+
}
|
|
172
|
+
function DevulturProvider({ api, baseUrl, children }) {
|
|
173
|
+
const [token, setToken] = React2.useState(null);
|
|
174
|
+
const [sessionSecret] = React2.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
|
|
175
|
+
const issueViewerToken = useAction(api.issueViewerToken);
|
|
176
|
+
const createUploadUrlAction = useAction(api.createUploadUrl);
|
|
177
|
+
const deleteMediaAction = useAction(api.deleteMedia);
|
|
178
|
+
const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
|
|
179
|
+
const refreshTimerRef = React2.useRef(null);
|
|
180
|
+
const reissueInFlightRef = React2.useRef(null);
|
|
181
|
+
const scheduleRefresh = React2.useCallback(
|
|
182
|
+
(ttlMs) => {
|
|
183
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
184
|
+
const refreshIn = Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS);
|
|
185
|
+
refreshTimerRef.current = setTimeout(() => {
|
|
186
|
+
issueViewerToken({ sessionSecret }).then((result) => {
|
|
187
|
+
const { tokenValue, ttlMs: nextTtl } = extractTokenResult(result);
|
|
188
|
+
setToken(tokenValue);
|
|
189
|
+
scheduleRefresh(nextTtl);
|
|
190
|
+
});
|
|
191
|
+
}, refreshIn);
|
|
192
|
+
},
|
|
193
|
+
[issueViewerToken, sessionSecret]
|
|
194
|
+
);
|
|
195
|
+
React2.useEffect(() => {
|
|
196
|
+
if (token) return;
|
|
197
|
+
issueViewerToken({ sessionSecret }).then((result) => {
|
|
198
|
+
const { tokenValue, ttlMs } = extractTokenResult(result);
|
|
199
|
+
setToken(tokenValue);
|
|
200
|
+
scheduleRefresh(ttlMs);
|
|
201
|
+
});
|
|
202
|
+
}, [token, issueViewerToken, scheduleRefresh, sessionSecret]);
|
|
203
|
+
const reissueToken = React2.useCallback(() => {
|
|
204
|
+
if (reissueInFlightRef.current) return reissueInFlightRef.current;
|
|
205
|
+
const pending = issueViewerToken({ sessionSecret }).then((result) => {
|
|
206
|
+
const { tokenValue, ttlMs } = extractTokenResult(result);
|
|
207
|
+
setToken(tokenValue);
|
|
208
|
+
scheduleRefresh(ttlMs);
|
|
209
|
+
reissueInFlightRef.current = null;
|
|
210
|
+
return tokenValue;
|
|
211
|
+
}).catch(() => {
|
|
212
|
+
reissueInFlightRef.current = null;
|
|
213
|
+
return null;
|
|
214
|
+
});
|
|
215
|
+
reissueInFlightRef.current = pending;
|
|
216
|
+
return pending;
|
|
217
|
+
}, [issueViewerToken, scheduleRefresh, sessionSecret]);
|
|
218
|
+
React2.useEffect(() => {
|
|
219
|
+
return () => {
|
|
220
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
221
|
+
};
|
|
222
|
+
}, []);
|
|
223
|
+
const uploadUrl = React2.useCallback(
|
|
224
|
+
async (file) => {
|
|
225
|
+
const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
|
|
226
|
+
return { url: result.url, key: result.key };
|
|
227
|
+
},
|
|
228
|
+
[createUploadUrlAction]
|
|
229
|
+
);
|
|
230
|
+
const upload = React2.useCallback(
|
|
231
|
+
async (file, options) => {
|
|
232
|
+
const dimensions = await readImageDimensions(file);
|
|
233
|
+
const result = await createUploadUrlAction({
|
|
234
|
+
filename: file.name,
|
|
235
|
+
contentType: file.type,
|
|
236
|
+
visibility: options?.visibility
|
|
237
|
+
});
|
|
238
|
+
const uploadResponse = await fetch(result.url, {
|
|
239
|
+
method: "PUT",
|
|
240
|
+
body: file,
|
|
241
|
+
headers: { "Content-Type": file.type }
|
|
242
|
+
});
|
|
243
|
+
if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.status}`);
|
|
244
|
+
return {
|
|
245
|
+
id: result.id,
|
|
246
|
+
kind: result.kind,
|
|
247
|
+
visibility: result.visibility,
|
|
248
|
+
filename: file.name,
|
|
249
|
+
...dimensions
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
[createUploadUrlAction]
|
|
253
|
+
);
|
|
254
|
+
const url = React2.useCallback(
|
|
255
|
+
(media, options) => {
|
|
256
|
+
if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
|
|
257
|
+
return mediaUrl(baseUrl, media, { ...options, token: token ?? void 0 });
|
|
258
|
+
},
|
|
259
|
+
[baseUrl, token]
|
|
260
|
+
);
|
|
261
|
+
const getCaptionsText = React2.useCallback(
|
|
262
|
+
async (media, locale) => {
|
|
263
|
+
if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
|
|
264
|
+
const path = mediaCaptionPath(media, locale);
|
|
265
|
+
if (!path) throw new Error("getCaptionsText: the reference is not a video");
|
|
266
|
+
const isPrivate = media.visibility === "private";
|
|
267
|
+
const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
|
|
268
|
+
headers: sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0
|
|
269
|
+
});
|
|
270
|
+
let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
|
|
271
|
+
if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
|
|
272
|
+
const fresh = await reissueToken();
|
|
273
|
+
if (fresh) response = await fetchWith(fresh);
|
|
274
|
+
}
|
|
275
|
+
if (!response.ok) throw new Error(`Captions fetch failed: ${response.status}`);
|
|
276
|
+
return response.text();
|
|
277
|
+
},
|
|
278
|
+
[baseUrl, token, reissueToken, sessionSecret]
|
|
279
|
+
);
|
|
280
|
+
const deleteMedia = React2.useCallback(
|
|
281
|
+
(media) => {
|
|
282
|
+
if (media.kind === "video" && api.deleteVideo) {
|
|
283
|
+
deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
deleteMediaAction({ key: mediaPath(media) }).catch(console.error);
|
|
287
|
+
},
|
|
288
|
+
[deleteMediaAction, deleteVideoAction, api.deleteVideo]
|
|
289
|
+
);
|
|
290
|
+
const devulturValue = React2.useMemo(
|
|
291
|
+
() => ({
|
|
292
|
+
token,
|
|
293
|
+
baseUrl: baseUrl ?? null,
|
|
294
|
+
sessionSecret,
|
|
295
|
+
uploadUrl,
|
|
296
|
+
upload,
|
|
297
|
+
url,
|
|
298
|
+
getCaptionsText,
|
|
299
|
+
deleteMedia,
|
|
300
|
+
reissueToken
|
|
301
|
+
}),
|
|
302
|
+
[token, baseUrl, sessionSecret, uploadUrl, upload, url, getCaptionsText, deleteMedia, reissueToken]
|
|
303
|
+
);
|
|
304
|
+
let content = /* @__PURE__ */ jsx2(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx2(DevulturContext, { value: devulturValue, children }) });
|
|
305
|
+
if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
|
|
306
|
+
content = /* @__PURE__ */ jsx2(
|
|
307
|
+
ProgressBridge,
|
|
308
|
+
{
|
|
309
|
+
save: api.saveProgress,
|
|
310
|
+
load: api.loadProgress,
|
|
311
|
+
markComplete: api.markComplete,
|
|
312
|
+
reset: api.resetProgress,
|
|
313
|
+
children: content
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return content;
|
|
318
|
+
}
|
|
319
|
+
function ProgressBridge({
|
|
320
|
+
save,
|
|
321
|
+
load,
|
|
322
|
+
markComplete,
|
|
323
|
+
reset,
|
|
324
|
+
children
|
|
325
|
+
}) {
|
|
326
|
+
const convex = useConvex();
|
|
327
|
+
const saveMutation = useMutation(save);
|
|
328
|
+
const markCompleteMutation = useMutation(markComplete);
|
|
329
|
+
const resetMutation = useMutation(reset);
|
|
330
|
+
const onSave = React2.useCallback(
|
|
331
|
+
async (videoId, position) => {
|
|
332
|
+
await saveMutation({ videoId, position });
|
|
333
|
+
},
|
|
334
|
+
[saveMutation]
|
|
335
|
+
);
|
|
336
|
+
const onLoad = React2.useCallback(
|
|
337
|
+
async (videoId) => {
|
|
338
|
+
const result = await convex.query(load, { videoId });
|
|
339
|
+
if (!result) return null;
|
|
340
|
+
return {
|
|
341
|
+
lastPosition: result.lastPosition,
|
|
342
|
+
completedAt: result.completedAt ? new Date(result.completedAt) : null
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
[convex, load]
|
|
346
|
+
);
|
|
347
|
+
const onMarkComplete = React2.useCallback(
|
|
348
|
+
async (videoId) => {
|
|
349
|
+
await markCompleteMutation({ videoId });
|
|
350
|
+
},
|
|
351
|
+
[markCompleteMutation]
|
|
352
|
+
);
|
|
353
|
+
const onReset = React2.useCallback(
|
|
354
|
+
async (videoId) => {
|
|
355
|
+
await resetMutation({ videoId });
|
|
356
|
+
},
|
|
357
|
+
[resetMutation]
|
|
358
|
+
);
|
|
359
|
+
const value = React2.useMemo(
|
|
360
|
+
() => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
|
|
361
|
+
[onSave, onLoad, onMarkComplete, onReset]
|
|
362
|
+
);
|
|
363
|
+
return /* @__PURE__ */ jsx2(ProgressContext, { value, children });
|
|
364
|
+
}
|
|
365
|
+
function useDevultur() {
|
|
366
|
+
const context = React2.use(DevulturContext);
|
|
367
|
+
if (!context) throw new Error("useDevultur must be used within <DevulturProvider>");
|
|
368
|
+
return context;
|
|
369
|
+
}
|
|
370
|
+
function useDevulturProgress() {
|
|
371
|
+
const context = React2.use(ProgressContext);
|
|
372
|
+
if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
|
|
373
|
+
return context;
|
|
374
|
+
}
|
|
375
|
+
function useMediaUrl(key, options) {
|
|
376
|
+
const { token, baseUrl } = useDevultur();
|
|
377
|
+
return React2.useMemo(() => {
|
|
378
|
+
if (!key) return null;
|
|
379
|
+
if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
|
|
380
|
+
const isPublic = visibilityFromKey(key) === "public";
|
|
381
|
+
return buildMediaUrl(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
|
|
382
|
+
}, [key, baseUrl, token, options]);
|
|
383
|
+
}
|
|
384
|
+
async function readImageDimensions(file) {
|
|
385
|
+
if (typeof createImageBitmap !== "function" || !file.type.startsWith("image/")) return {};
|
|
386
|
+
try {
|
|
387
|
+
const bitmap = await createImageBitmap(file);
|
|
388
|
+
const dimensions = { width: bitmap.width, height: bitmap.height };
|
|
389
|
+
bitmap.close();
|
|
390
|
+
return dimensions;
|
|
391
|
+
} catch {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function useVideoProps(media, options) {
|
|
396
|
+
const { token, baseUrl, sessionSecret } = useDevultur();
|
|
397
|
+
const locales = options?.locales;
|
|
398
|
+
return React2.useMemo(() => {
|
|
399
|
+
if (media?.kind !== "video") return null;
|
|
400
|
+
if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
|
|
401
|
+
const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
|
|
402
|
+
const captions = (locales ?? []).flatMap((locale) => {
|
|
403
|
+
const path = mediaCaptionPath(media, locale);
|
|
404
|
+
if (!path) return [];
|
|
405
|
+
const label = CaptionLocaleLabel[locale] ?? locale;
|
|
406
|
+
return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
|
|
407
|
+
});
|
|
408
|
+
return {
|
|
409
|
+
src: buildMediaUrl(baseUrl, mediaPath(media), { token: viewerToken }),
|
|
410
|
+
token: viewerToken,
|
|
411
|
+
captions,
|
|
412
|
+
sessionSecret: media.visibility === "private" ? sessionSecret : void 0
|
|
413
|
+
};
|
|
414
|
+
}, [media, baseUrl, token, locales, sessionSecret]);
|
|
415
|
+
}
|
|
416
|
+
function Image({
|
|
417
|
+
media,
|
|
418
|
+
width,
|
|
419
|
+
height,
|
|
420
|
+
fit,
|
|
421
|
+
format,
|
|
422
|
+
quality,
|
|
423
|
+
fallback = null,
|
|
424
|
+
externalFallback,
|
|
425
|
+
render,
|
|
426
|
+
alt,
|
|
427
|
+
...imgProps
|
|
428
|
+
}) {
|
|
429
|
+
const { url, reissueToken, sessionSecret } = useDevultur();
|
|
430
|
+
const [failed, setFailed] = React2.useState(false);
|
|
431
|
+
const [blobSrc, setBlobSrc] = React2.useState(null);
|
|
432
|
+
const retriedRef = React2.useRef(false);
|
|
433
|
+
const isPrivate = media?.visibility === "private";
|
|
434
|
+
React2.useEffect(() => {
|
|
435
|
+
retriedRef.current = false;
|
|
436
|
+
setFailed(false);
|
|
437
|
+
setBlobSrc(null);
|
|
438
|
+
}, [media]);
|
|
439
|
+
React2.useEffect(() => {
|
|
440
|
+
if (!isPrivate || failed || !media) return;
|
|
441
|
+
let cancelled = false;
|
|
442
|
+
let objectUrl = null;
|
|
443
|
+
const targetUrl = url(media, { width, height, fit, format, quality });
|
|
444
|
+
(async () => {
|
|
445
|
+
try {
|
|
446
|
+
const response = await fetch(targetUrl, {
|
|
447
|
+
headers: sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0
|
|
448
|
+
});
|
|
449
|
+
if (response.ok) {
|
|
450
|
+
objectUrl = URL.createObjectURL(await response.blob());
|
|
451
|
+
if (cancelled) {
|
|
452
|
+
URL.revokeObjectURL(objectUrl);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
setBlobSrc(objectUrl);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (response.status === 401 && !retriedRef.current && await isSessionMismatch(response)) {
|
|
459
|
+
retriedRef.current = true;
|
|
460
|
+
await reissueToken();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
if (!cancelled) setFailed(true);
|
|
466
|
+
})();
|
|
467
|
+
return () => {
|
|
468
|
+
cancelled = true;
|
|
469
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
470
|
+
};
|
|
471
|
+
}, [isPrivate, failed, media, url, sessionSecret, reissueToken, width, height, fit, format, quality]);
|
|
472
|
+
const handleError = React2.useCallback(() => setFailed(true), []);
|
|
473
|
+
let src;
|
|
474
|
+
if (failed || !media) {
|
|
475
|
+
src = externalFallback ?? null;
|
|
476
|
+
} else if (isPrivate) {
|
|
477
|
+
src = blobSrc;
|
|
478
|
+
} else {
|
|
479
|
+
src = url(media, { width, height, fit, format, quality });
|
|
480
|
+
}
|
|
481
|
+
if (!src) return /* @__PURE__ */ jsx2(Fragment, { children: fallback });
|
|
482
|
+
if (render) {
|
|
483
|
+
return React2.cloneElement(render, {
|
|
484
|
+
style: { ...render.props.style, backgroundImage: `url("${src}")` }
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return /* @__PURE__ */ jsx2(
|
|
488
|
+
"img",
|
|
489
|
+
{
|
|
490
|
+
src,
|
|
491
|
+
alt,
|
|
492
|
+
width: width ?? media?.width,
|
|
493
|
+
height: height ?? media?.height,
|
|
494
|
+
onError: handleError,
|
|
495
|
+
...imgProps
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
function DownloadLink({ media, filename, children, ...anchorProps }) {
|
|
500
|
+
const { url, token, baseUrl, sessionSecret, reissueToken } = useDevultur();
|
|
501
|
+
const handleClick = React2.useCallback(
|
|
502
|
+
async (event) => {
|
|
503
|
+
if (media?.visibility !== "private" || !baseUrl) return;
|
|
504
|
+
event.preventDefault();
|
|
505
|
+
const headers = sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
|
|
506
|
+
const buildUrl = (viewerToken) => mediaUrl(baseUrl, media, { download: filename ?? true, token: viewerToken });
|
|
507
|
+
let response = await fetch(buildUrl(token ?? void 0), { headers });
|
|
508
|
+
if (response.status === 401 && await isSessionMismatch(response)) {
|
|
509
|
+
const fresh = await reissueToken();
|
|
510
|
+
if (fresh) response = await fetch(buildUrl(fresh), { headers });
|
|
511
|
+
}
|
|
512
|
+
if (!response.ok) return;
|
|
513
|
+
const objectUrl = URL.createObjectURL(await response.blob());
|
|
514
|
+
const anchor = document.createElement("a");
|
|
515
|
+
anchor.href = objectUrl;
|
|
516
|
+
anchor.download = filename ?? media.filename ?? "download";
|
|
517
|
+
anchor.click();
|
|
518
|
+
URL.revokeObjectURL(objectUrl);
|
|
519
|
+
},
|
|
520
|
+
[media, baseUrl, token, filename, sessionSecret, reissueToken]
|
|
521
|
+
);
|
|
522
|
+
if (!media) return null;
|
|
523
|
+
return /* @__PURE__ */ jsx2("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
|
|
524
|
+
}
|
|
525
|
+
function useDevulturMedia(key) {
|
|
526
|
+
const apiCtx = React2.use(DevulturApiContext);
|
|
527
|
+
if (!apiCtx?.getMedia) {
|
|
528
|
+
throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
|
|
529
|
+
}
|
|
530
|
+
const record = useQuery(apiCtx.getMedia, key ? { key } : "skip");
|
|
531
|
+
if (!record) {
|
|
532
|
+
return {
|
|
533
|
+
status: MediaStatus.IDLE,
|
|
534
|
+
progress: null,
|
|
535
|
+
error: null,
|
|
536
|
+
failureReason: null,
|
|
537
|
+
retriable: null,
|
|
538
|
+
isQueued: false,
|
|
539
|
+
isTranscoding: false,
|
|
540
|
+
isCaptioning: false,
|
|
541
|
+
isReady: false,
|
|
542
|
+
isFailed: false,
|
|
543
|
+
playlistUrl: null,
|
|
544
|
+
thumbnailKey: null,
|
|
545
|
+
captionLocales: [],
|
|
546
|
+
duration: null,
|
|
547
|
+
isLoading: record === void 0
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const status = record.transcodeStatus ?? MediaStatus.IDLE;
|
|
551
|
+
const isReady = status === MediaStatus.READY;
|
|
552
|
+
const isFailed = status === MediaStatus.FAILED;
|
|
553
|
+
const progress = record.transcodeProgress ? {
|
|
554
|
+
current: record.transcodeProgress.current,
|
|
555
|
+
total: record.transcodeProgress.total,
|
|
556
|
+
percent: record.transcodeProgress.total > 0 ? Math.round(
|
|
557
|
+
record.transcodeProgress.current / record.transcodeProgress.total * 100
|
|
558
|
+
) : 0,
|
|
559
|
+
phase: record.transcodeProgress.phase
|
|
560
|
+
} : null;
|
|
561
|
+
return {
|
|
562
|
+
status,
|
|
563
|
+
progress,
|
|
564
|
+
error: record.transcodeError ?? null,
|
|
565
|
+
failureReason: isFailed ? record.failureReason ?? "internal" : null,
|
|
566
|
+
retriable: isFailed ? record.retriable ?? true : null,
|
|
567
|
+
isQueued: status === MediaStatus.QUEUED,
|
|
568
|
+
isTranscoding: status === MediaStatus.TRANSCODING,
|
|
569
|
+
isCaptioning: status === MediaStatus.CAPTIONING,
|
|
570
|
+
isReady,
|
|
571
|
+
isFailed,
|
|
572
|
+
playlistUrl: record.playlistUrl ?? null,
|
|
573
|
+
thumbnailKey: record.thumbnailKey ?? null,
|
|
574
|
+
captionLocales: record.captionLocales ?? [],
|
|
575
|
+
duration: record.duration ?? null,
|
|
576
|
+
isLoading: false
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function useVideoState(media, options) {
|
|
580
|
+
const { token, baseUrl, sessionSecret } = useDevultur();
|
|
581
|
+
const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
|
|
582
|
+
const locales = options?.locales;
|
|
583
|
+
return React2.useMemo(() => {
|
|
584
|
+
if (media?.kind !== "video") return { phase: "idle" };
|
|
585
|
+
if (status.isFailed) {
|
|
586
|
+
return {
|
|
587
|
+
phase: "failed",
|
|
588
|
+
reason: status.failureReason ?? "internal",
|
|
589
|
+
message: status.error ?? "Video processing failed",
|
|
590
|
+
retriable: status.retriable ?? true
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (status.isReady) {
|
|
594
|
+
if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
|
|
595
|
+
const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
|
|
596
|
+
const playlist = buildMediaUrl(baseUrl, mediaPath(media), { token: viewerToken });
|
|
597
|
+
const captions = (locales ?? status.captionLocales).flatMap((locale) => {
|
|
598
|
+
const path = mediaCaptionPath(media, locale);
|
|
599
|
+
if (!path) return [];
|
|
600
|
+
const label = CaptionLocaleLabel[locale] ?? locale;
|
|
601
|
+
return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
|
|
602
|
+
});
|
|
603
|
+
return {
|
|
604
|
+
phase: "ready",
|
|
605
|
+
media,
|
|
606
|
+
playlist,
|
|
607
|
+
duration: status.duration ?? 0,
|
|
608
|
+
captions,
|
|
609
|
+
playerProps: {
|
|
610
|
+
src: playlist,
|
|
611
|
+
token: viewerToken,
|
|
612
|
+
captions,
|
|
613
|
+
sessionSecret: media.visibility === "private" ? sessionSecret : void 0
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (status.isQueued) return { phase: "queued" };
|
|
618
|
+
if (status.isTranscoding || status.isCaptioning) {
|
|
619
|
+
const step = status.progress?.phase ?? (status.isCaptioning ? "captioning" : "encoding");
|
|
620
|
+
return { phase: "processing", step, percent: status.progress?.percent ?? 0 };
|
|
621
|
+
}
|
|
622
|
+
return { phase: "idle" };
|
|
623
|
+
}, [media, status, baseUrl, token, locales, sessionSecret]);
|
|
624
|
+
}
|
|
625
|
+
function useMediaUpload(options) {
|
|
626
|
+
const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
|
|
627
|
+
const api = React2.use(DevulturApiContext);
|
|
628
|
+
if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
|
|
629
|
+
const createUploadUrlAction = useAction(api.createUploadUrl);
|
|
630
|
+
const deleteMediaAction = useAction(api.deleteMedia);
|
|
631
|
+
const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
|
|
632
|
+
const [phase, setPhase] = React2.useState("idle");
|
|
633
|
+
const [percent, setPercent] = React2.useState(0);
|
|
634
|
+
const [currentFile, setCurrentFile] = React2.useState(null);
|
|
635
|
+
const [media, setMedia] = React2.useState(null);
|
|
636
|
+
const [uploadFailure, setUploadFailure] = React2.useState(null);
|
|
637
|
+
const [processRequested, setProcessRequested] = React2.useState(false);
|
|
638
|
+
const abortRef = React2.useRef(null);
|
|
639
|
+
const lastFileRef = React2.useRef(null);
|
|
640
|
+
const lastKeyRef = React2.useRef(null);
|
|
641
|
+
const videoState = useVideoState(media, { locales });
|
|
642
|
+
const triggerProcess = React2.useCallback(
|
|
643
|
+
(key) => {
|
|
644
|
+
if (!api.processVideo) {
|
|
645
|
+
throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
|
|
646
|
+
}
|
|
647
|
+
lastKeyRef.current = key;
|
|
648
|
+
setProcessRequested(true);
|
|
649
|
+
processVideoAction({ key }).catch((err) => {
|
|
650
|
+
console.error("useMediaUpload: could not request the transcode", err);
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
[api.processVideo, processVideoAction]
|
|
654
|
+
);
|
|
655
|
+
const runUpload = React2.useCallback(
|
|
656
|
+
async (file) => {
|
|
657
|
+
const controller = new AbortController();
|
|
658
|
+
abortRef.current = controller;
|
|
659
|
+
lastFileRef.current = file;
|
|
660
|
+
setUploadFailure(null);
|
|
661
|
+
setMedia(null);
|
|
662
|
+
setProcessRequested(false);
|
|
663
|
+
setCurrentFile(file);
|
|
664
|
+
setPercent(0);
|
|
665
|
+
setPhase("uploading");
|
|
666
|
+
const fail = (reason, message) => {
|
|
667
|
+
const failure = { stage: "upload", reason, retriable: true, message };
|
|
668
|
+
setUploadFailure(failure);
|
|
669
|
+
setPhase("upload-failed");
|
|
670
|
+
onError?.(failure);
|
|
671
|
+
return null;
|
|
672
|
+
};
|
|
673
|
+
let result;
|
|
674
|
+
try {
|
|
675
|
+
result = await createUploadUrlAction({
|
|
676
|
+
filename: file.name,
|
|
677
|
+
contentType: file.type,
|
|
678
|
+
visibility
|
|
679
|
+
});
|
|
680
|
+
} catch (err) {
|
|
681
|
+
return fail("rejected", err instanceof Error ? err.message : "Could not start the upload");
|
|
682
|
+
}
|
|
683
|
+
const dimensions = await readImageDimensions(file);
|
|
684
|
+
try {
|
|
685
|
+
await uploadFileWithProgress(result.url, file, { onProgress: setPercent, signal: controller.signal });
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
688
|
+
setPhase("idle");
|
|
689
|
+
setCurrentFile(null);
|
|
690
|
+
setPercent(0);
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
const rejected = err instanceof UploadHttpError && (err.status === 413 || err.status === 415);
|
|
694
|
+
return fail(rejected ? "rejected" : "network", err instanceof Error ? err.message : "The upload failed");
|
|
695
|
+
}
|
|
696
|
+
const uploaded = {
|
|
697
|
+
id: result.id,
|
|
698
|
+
kind: result.kind,
|
|
699
|
+
visibility: result.visibility,
|
|
700
|
+
filename: file.name,
|
|
701
|
+
...dimensions
|
|
702
|
+
};
|
|
703
|
+
setMedia(uploaded);
|
|
704
|
+
setPhase("uploaded");
|
|
705
|
+
if (replaces) deleteMediaAction({ key: mediaPath(replaces) }).catch(() => {
|
|
706
|
+
});
|
|
707
|
+
onComplete?.(uploaded);
|
|
708
|
+
if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
|
|
709
|
+
return uploaded;
|
|
710
|
+
},
|
|
711
|
+
[createUploadUrlAction, deleteMediaAction, triggerProcess, visibility, replaces, process, onComplete, onError]
|
|
712
|
+
);
|
|
713
|
+
const cancel = React2.useCallback(() => {
|
|
714
|
+
abortRef.current?.abort();
|
|
715
|
+
}, []);
|
|
716
|
+
const reset = React2.useCallback(() => {
|
|
717
|
+
abortRef.current?.abort();
|
|
718
|
+
setPhase("idle");
|
|
719
|
+
setPercent(0);
|
|
720
|
+
setCurrentFile(null);
|
|
721
|
+
setMedia(null);
|
|
722
|
+
setUploadFailure(null);
|
|
723
|
+
setProcessRequested(false);
|
|
724
|
+
}, []);
|
|
725
|
+
const retry = React2.useCallback(() => {
|
|
726
|
+
if (phase === "upload-failed" && lastFileRef.current) {
|
|
727
|
+
runUpload(lastFileRef.current);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
|
|
731
|
+
}, [phase, runUpload, triggerProcess]);
|
|
732
|
+
React2.useEffect(() => () => abortRef.current?.abort(), []);
|
|
733
|
+
const state = React2.useMemo(
|
|
734
|
+
() => deriveUploadState(
|
|
735
|
+
{ phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
|
|
736
|
+
videoState,
|
|
737
|
+
retry
|
|
738
|
+
),
|
|
739
|
+
[phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
|
|
740
|
+
);
|
|
741
|
+
const upload = React2.useCallback((file) => runUpload(file), [runUpload]);
|
|
742
|
+
return { upload, state, cancel, reset };
|
|
743
|
+
}
|
|
744
|
+
export {
|
|
745
|
+
CropUpload,
|
|
746
|
+
DevulturProvider,
|
|
747
|
+
DownloadLink,
|
|
748
|
+
Image,
|
|
749
|
+
useDevultur,
|
|
750
|
+
useDevulturMedia,
|
|
751
|
+
useDevulturProgress,
|
|
752
|
+
useMediaUpload,
|
|
753
|
+
useMediaUrl,
|
|
754
|
+
useVideoProps,
|
|
755
|
+
useVideoState
|
|
756
|
+
};
|