@capturly/capture-sdk 2.11.0 → 2.11.2
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/CHANGELOG.md +41 -0
- package/dist/chunk-CZPDQKMD.js +278 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +11 -220
- package/dist/transport.d.ts +15 -1
- package/dist/transport.js +14 -0
- package/dist/types.d.ts +36 -0
- package/package.json +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
1
|
# @capturly/capture-sdk
|
|
2
2
|
|
|
3
|
+
## 2.11.2
|
|
4
|
+
|
|
5
|
+
## 2.11.1
|
|
6
|
+
|
|
7
|
+
### Patch Changes
|
|
8
|
+
|
|
9
|
+
- [#464](https://github.com/Corey-Alan-Consulting/capturly.app/pull/464) [`47c00ea`](https://github.com/Corey-Alan-Consulting/capturly.app/commit/47c00ea9b1943850a4427a78004f85cf165adaf8) Thanks [@NX211](https://github.com/NX211)! - Add a `@capturly/capture-sdk/transport` subpath exposing the bare respondent
|
|
10
|
+
transport (`requestUpload`, `uploadBlob`, `finalizeSubmission`) for hosts that
|
|
11
|
+
bring their own recorder, and a new `fetchCaptureView` call (both entries)
|
|
12
|
+
that returns what the person is being asked — title, prompt, branding,
|
|
13
|
+
release text, limits, and any existing submission — via the new
|
|
14
|
+
`/api/campaigns/respond/view` endpoint. Existing APIs are unchanged.
|
|
15
|
+
|
|
16
|
+
- [#464](https://github.com/Corey-Alan-Consulting/capturly.app/pull/464) [`47c00ea`](https://github.com/Corey-Alan-Consulting/capturly.app/commit/47c00ea9b1943850a4427a78004f85cf165adaf8) Thanks [@NX211](https://github.com/NX211)! - Harden the capture-request respond and collect surfaces, and the recording
|
|
17
|
+
storage lifecycle, per the branch's second review round:
|
|
18
|
+
- OPFS garbage collection now fails closed: an unreadable or corrupt
|
|
19
|
+
recovery marker aborts the sweep instead of reading as "nothing
|
|
20
|
+
referenced" and reclaiming crash-recovery takes.
|
|
21
|
+
- The mobile respondent flow can always be exited (persistent Close on
|
|
22
|
+
every non-recording phase), its API calls time out instead of spinning
|
|
23
|
+
forever, and a storage-full refusal is no longer misreported as
|
|
24
|
+
"take too large" with retry disabled.
|
|
25
|
+
- Respondent links are parsed origin-strictly, malformed escapes are
|
|
26
|
+
refused instead of thrown, and email unsubscribe links stay out of the
|
|
27
|
+
app on iOS (AASA exclude) while Android completes them natively.
|
|
28
|
+
- The web respondent page no longer blanks in Safari private mode, the
|
|
29
|
+
submission-received owner email can no longer be dropped or hang
|
|
30
|
+
finalize (bounded Mailgun timeout, awaited send), and view decoders
|
|
31
|
+
refuse a missing recording time limit instead of defaulting to zero.
|
|
32
|
+
- The desktop Collect screen drops stale detail responses (preventing a
|
|
33
|
+
personal link rendering under the wrong request), shows mapped error
|
|
34
|
+
copy instead of raw API codes, and its badge only polls while signed in.
|
|
35
|
+
- Take Delete / Share controls are reachable by screen readers, clock
|
|
36
|
+
labels can no longer render ":60", and saved filenames defuse Windows
|
|
37
|
+
reserved device names.
|
|
38
|
+
|
|
39
|
+
GET /api/capture-requests is now cursor-paginated (`cursor`/`limit`,
|
|
40
|
+
`nextCursor` in the response; default 50, max 100). List rows and detail
|
|
41
|
+
views group the owning studio as one nullable `studio { id, name }` object
|
|
42
|
+
in place of the previous studioId/studioName pair.
|
|
43
|
+
|
|
3
44
|
## 2.11.0
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
2
|
+
function isSecureTransportUrl(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return false;
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(value);
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (url.protocol === "https:")
|
|
12
|
+
return true;
|
|
13
|
+
if (url.protocol === "http:")
|
|
14
|
+
return LOOPBACK_HOSTS.has(url.hostname);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/types.ts
|
|
19
|
+
var CaptureError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
cause;
|
|
22
|
+
constructor(code, message, cause) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "CaptureError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
if (cause !== void 0) this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/transport.ts
|
|
31
|
+
function isStringMap(value) {
|
|
32
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
33
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
34
|
+
}
|
|
35
|
+
var CLOSED_ERROR = "ended";
|
|
36
|
+
var RATE_LIMITED_ERROR = "rate_limited";
|
|
37
|
+
var SUPERSEDED_ERROR = "superseded";
|
|
38
|
+
function accessBody(access) {
|
|
39
|
+
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
40
|
+
}
|
|
41
|
+
function codeForResponse(status, error) {
|
|
42
|
+
if (error === RATE_LIMITED_ERROR) return "rate_limited";
|
|
43
|
+
if (error === SUPERSEDED_ERROR || status === 409) return "superseded";
|
|
44
|
+
if (error === CLOSED_ERROR || status === 410) return "request_closed";
|
|
45
|
+
if (status === 401 || status === 403 || status === 404) return "link_expired";
|
|
46
|
+
if (status === 413 || status === 429) return "quota_exceeded";
|
|
47
|
+
if (status >= 400 && status < 500) return "invalid_request";
|
|
48
|
+
return "upload_failed";
|
|
49
|
+
}
|
|
50
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
51
|
+
var UPLOAD_STALL_MS = 6e4;
|
|
52
|
+
function linkedAbort(signal, timeoutMs) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
timedOut = true;
|
|
57
|
+
controller.abort();
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
const onAbort = () => controller.abort();
|
|
60
|
+
if (signal?.aborted) controller.abort();
|
|
61
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
62
|
+
return {
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
timedOut: () => timedOut,
|
|
65
|
+
dispose: () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
signal?.removeEventListener("abort", onAbort);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function postJson(baseUrl, path, body, signal) {
|
|
72
|
+
const link = linkedAbort(signal, REQUEST_TIMEOUT_MS);
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: link.signal
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (signal?.aborted) throw new CaptureError("aborted", "The request was cancelled.", err);
|
|
83
|
+
if (link.timedOut()) {
|
|
84
|
+
throw new CaptureError("network", "Capturly did not respond in time.", err);
|
|
85
|
+
}
|
|
86
|
+
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
87
|
+
} finally {
|
|
88
|
+
link.dispose();
|
|
89
|
+
}
|
|
90
|
+
const json = await res.json().catch(() => null);
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const error = typeof json?.error === "string" ? json.error : null;
|
|
93
|
+
throw new CaptureError(
|
|
94
|
+
codeForResponse(res.status, error),
|
|
95
|
+
error ?? `Capturly returned ${res.status}.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return json ?? {};
|
|
99
|
+
}
|
|
100
|
+
function isRecord(value) {
|
|
101
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
}
|
|
103
|
+
var stringOrNull = (v) => typeof v === "string" ? v : null;
|
|
104
|
+
async function fetchCaptureView(args) {
|
|
105
|
+
const json = await postJson(
|
|
106
|
+
args.baseUrl,
|
|
107
|
+
"/api/campaigns/respond/view",
|
|
108
|
+
accessBody(args.access),
|
|
109
|
+
args.signal
|
|
110
|
+
);
|
|
111
|
+
const view = json.view;
|
|
112
|
+
if (!isRecord(view) || typeof view.title !== "string" || typeof view.prompt !== "string") {
|
|
113
|
+
throw new CaptureError("unknown", "Capturly did not return the capture request.");
|
|
114
|
+
}
|
|
115
|
+
const branding = isRecord(view.branding) ? {
|
|
116
|
+
studioName: typeof view.branding.studioName === "string" ? view.branding.studioName : "",
|
|
117
|
+
brandColor: stringOrNull(view.branding.brandColor),
|
|
118
|
+
brandSecondaryColor: stringOrNull(view.branding.brandSecondaryColor),
|
|
119
|
+
brandAccentColor: stringOrNull(view.branding.brandAccentColor),
|
|
120
|
+
coverPath: stringOrNull(view.branding.coverPath)
|
|
121
|
+
} : {
|
|
122
|
+
studioName: "",
|
|
123
|
+
brandColor: null,
|
|
124
|
+
brandSecondaryColor: null,
|
|
125
|
+
brandAccentColor: null,
|
|
126
|
+
coverPath: null
|
|
127
|
+
};
|
|
128
|
+
if (typeof view.maxDurationSec !== "number" || view.maxDurationSec <= 0) {
|
|
129
|
+
throw new CaptureError("unknown", "Capturly returned no recording time limit.");
|
|
130
|
+
}
|
|
131
|
+
const existing = isRecord(view.existing) && typeof view.existing.submissionId === "string" && typeof view.existing.submittedAt === "string" ? {
|
|
132
|
+
submissionId: view.existing.submissionId,
|
|
133
|
+
submittedAt: view.existing.submittedAt,
|
|
134
|
+
durationSec: typeof view.existing.durationSec === "number" ? view.existing.durationSec : 0
|
|
135
|
+
} : null;
|
|
136
|
+
return {
|
|
137
|
+
title: view.title,
|
|
138
|
+
prompt: view.prompt,
|
|
139
|
+
deadline: stringOrNull(view.deadline),
|
|
140
|
+
timezone: typeof view.timezone === "string" ? view.timezone : "UTC",
|
|
141
|
+
maxDurationSec: view.maxDurationSec,
|
|
142
|
+
releaseRequired: view.releaseRequired === true,
|
|
143
|
+
releaseText: typeof view.releaseText === "string" ? view.releaseText : "",
|
|
144
|
+
respondentName: stringOrNull(view.respondentName),
|
|
145
|
+
branding,
|
|
146
|
+
promptVideoUrl: stringOrNull(view.promptVideoUrl),
|
|
147
|
+
targetHeight: typeof view.targetHeight === "number" ? view.targetHeight : 1080,
|
|
148
|
+
existing
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function requestUpload(args) {
|
|
152
|
+
const json = await postJson(
|
|
153
|
+
args.baseUrl,
|
|
154
|
+
"/api/campaigns/respond/upload-url",
|
|
155
|
+
{
|
|
156
|
+
...accessBody(args.access),
|
|
157
|
+
contentType: "video/mp4",
|
|
158
|
+
sizeBytes: args.sizeBytes,
|
|
159
|
+
durationSec: args.durationSec,
|
|
160
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
161
|
+
releaseAccepted: args.releaseAccepted
|
|
162
|
+
},
|
|
163
|
+
args.signal
|
|
164
|
+
);
|
|
165
|
+
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
166
|
+
if (!objectName) {
|
|
167
|
+
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
168
|
+
}
|
|
169
|
+
const url = json.url;
|
|
170
|
+
if (!isSecureTransportUrl(url)) {
|
|
171
|
+
throw new CaptureError("upload_failed", "Capturly returned an unusable upload URL.");
|
|
172
|
+
}
|
|
173
|
+
if (json.method !== "PUT") {
|
|
174
|
+
throw new CaptureError("upload_failed", "Capturly returned an unsupported upload method.");
|
|
175
|
+
}
|
|
176
|
+
const headers = json.headers;
|
|
177
|
+
if (!isStringMap(headers)) {
|
|
178
|
+
throw new CaptureError("upload_failed", "Capturly returned unusable upload headers.");
|
|
179
|
+
}
|
|
180
|
+
return { objectName, url, method: "PUT", headers };
|
|
181
|
+
}
|
|
182
|
+
function uploadBlob(args) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
if (args.signal?.aborted) {
|
|
185
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const xhr = new XMLHttpRequest();
|
|
189
|
+
let stalled = false;
|
|
190
|
+
let cancelled = false;
|
|
191
|
+
let stallTimer;
|
|
192
|
+
const onCallerAbort = () => {
|
|
193
|
+
cancelled = true;
|
|
194
|
+
xhr.abort();
|
|
195
|
+
};
|
|
196
|
+
const armStall = () => {
|
|
197
|
+
clearTimeout(stallTimer);
|
|
198
|
+
stallTimer = setTimeout(() => {
|
|
199
|
+
stalled = true;
|
|
200
|
+
xhr.abort();
|
|
201
|
+
}, UPLOAD_STALL_MS);
|
|
202
|
+
};
|
|
203
|
+
const cleanup = () => {
|
|
204
|
+
clearTimeout(stallTimer);
|
|
205
|
+
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
206
|
+
};
|
|
207
|
+
xhr.open(args.target.method, args.target.url, true);
|
|
208
|
+
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
209
|
+
xhr.setRequestHeader(name, value);
|
|
210
|
+
}
|
|
211
|
+
xhr.upload.onprogress = (event) => {
|
|
212
|
+
armStall();
|
|
213
|
+
if (!event.lengthComputable || !args.onProgress) return;
|
|
214
|
+
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
215
|
+
};
|
|
216
|
+
xhr.onload = () => {
|
|
217
|
+
cleanup();
|
|
218
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
219
|
+
args.onProgress?.(1);
|
|
220
|
+
resolve();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
224
|
+
};
|
|
225
|
+
xhr.onerror = () => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new CaptureError("network", "The upload could not reach storage."));
|
|
228
|
+
};
|
|
229
|
+
xhr.onabort = () => {
|
|
230
|
+
cleanup();
|
|
231
|
+
if (cancelled) {
|
|
232
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
233
|
+
} else if (stalled) {
|
|
234
|
+
reject(new CaptureError("network", "The upload stopped making progress."));
|
|
235
|
+
} else {
|
|
236
|
+
reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
240
|
+
armStall();
|
|
241
|
+
xhr.send(args.blob);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function finalizeSubmission(args) {
|
|
245
|
+
const json = await postJson(
|
|
246
|
+
args.baseUrl,
|
|
247
|
+
"/api/campaigns/respond/finalize",
|
|
248
|
+
{
|
|
249
|
+
...accessBody(args.access),
|
|
250
|
+
objectName: args.objectName,
|
|
251
|
+
durationSec: args.durationSec,
|
|
252
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
253
|
+
releaseAccepted: args.releaseAccepted
|
|
254
|
+
},
|
|
255
|
+
args.signal
|
|
256
|
+
);
|
|
257
|
+
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
258
|
+
if (!submissionId) {
|
|
259
|
+
throw new CaptureError(
|
|
260
|
+
"unknown",
|
|
261
|
+
"The recording was submitted but Capturly did not return its id."
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
submissionId,
|
|
266
|
+
submittedAt: typeof json.submittedAt === "string" ? json.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
267
|
+
replaced: json.replaced === true
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export {
|
|
272
|
+
isSecureTransportUrl,
|
|
273
|
+
CaptureError,
|
|
274
|
+
fetchCaptureView,
|
|
275
|
+
requestUpload,
|
|
276
|
+
uploadBlob,
|
|
277
|
+
finalizeSubmission
|
|
278
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { CaptureClient } from './CaptureClient.js';
|
|
2
|
-
export {
|
|
2
|
+
export { fetchCaptureView } from './transport.js';
|
|
3
|
+
export { CaptureError, type CaptureAccess, type CaptureClientOptions, type CaptureDevice, type CaptureErrorCode, type CaptureRequestBranding, type CaptureRequestView, type CaptureState, type CaptureTake, type PreviewOptions, type SubmitOptions, type SubmitResult, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CaptureError,
|
|
3
|
+
fetchCaptureView,
|
|
4
|
+
finalizeSubmission,
|
|
5
|
+
isSecureTransportUrl,
|
|
6
|
+
requestUpload,
|
|
7
|
+
uploadBlob
|
|
8
|
+
} from "./chunk-CZPDQKMD.js";
|
|
9
|
+
|
|
1
10
|
var MP4_MIME = "video/mp4;codecs=avc1.42E01E,mp4a.40.2";
|
|
2
11
|
function pickMuxStrategy() {
|
|
3
12
|
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(MP4_MIME)) {
|
|
@@ -62,23 +71,6 @@ var RecordingEngineError = class _RecordingEngineError extends Error {
|
|
|
62
71
|
}
|
|
63
72
|
};
|
|
64
73
|
|
|
65
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
66
|
-
function isSecureTransportUrl(value) {
|
|
67
|
-
if (typeof value !== "string")
|
|
68
|
-
return false;
|
|
69
|
-
let url2;
|
|
70
|
-
try {
|
|
71
|
-
url2 = new URL(value);
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
if (url2.protocol === "https:")
|
|
76
|
-
return true;
|
|
77
|
-
if (url2.protocol === "http:")
|
|
78
|
-
return LOOPBACK_HOSTS.has(url2.hostname);
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
74
|
var CHUNK_ALIGNMENT = 256 * 1024;
|
|
83
75
|
var RESUMABLE_CHUNK_BYTES = 32 * CHUNK_ALIGNMENT;
|
|
84
76
|
var CHUNK_TIMEOUT_MS = 4 * 60 * 1e3;
|
|
@@ -4240,208 +4232,6 @@ function commitResources(r, logError) {
|
|
|
4240
4232
|
})();
|
|
4241
4233
|
}
|
|
4242
4234
|
|
|
4243
|
-
// src/types.ts
|
|
4244
|
-
var CaptureError = class extends Error {
|
|
4245
|
-
code;
|
|
4246
|
-
cause;
|
|
4247
|
-
constructor(code, message, cause) {
|
|
4248
|
-
super(message);
|
|
4249
|
-
this.name = "CaptureError";
|
|
4250
|
-
this.code = code;
|
|
4251
|
-
if (cause !== void 0) this.cause = cause;
|
|
4252
|
-
}
|
|
4253
|
-
};
|
|
4254
|
-
|
|
4255
|
-
// src/transport.ts
|
|
4256
|
-
function isStringMap(value) {
|
|
4257
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
4258
|
-
return Object.values(value).every((v) => typeof v === "string");
|
|
4259
|
-
}
|
|
4260
|
-
var CLOSED_ERROR = "ended";
|
|
4261
|
-
var RATE_LIMITED_ERROR = "rate_limited";
|
|
4262
|
-
var SUPERSEDED_ERROR = "superseded";
|
|
4263
|
-
function accessBody(access) {
|
|
4264
|
-
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
4265
|
-
}
|
|
4266
|
-
function codeForResponse(status, error) {
|
|
4267
|
-
if (error === RATE_LIMITED_ERROR) return "rate_limited";
|
|
4268
|
-
if (error === SUPERSEDED_ERROR || status === 409) return "superseded";
|
|
4269
|
-
if (error === CLOSED_ERROR || status === 410) return "request_closed";
|
|
4270
|
-
if (status === 401 || status === 403 || status === 404) return "link_expired";
|
|
4271
|
-
if (status === 413 || status === 429) return "quota_exceeded";
|
|
4272
|
-
if (status >= 400 && status < 500) return "invalid_request";
|
|
4273
|
-
return "upload_failed";
|
|
4274
|
-
}
|
|
4275
|
-
var REQUEST_TIMEOUT_MS = 3e4;
|
|
4276
|
-
var UPLOAD_STALL_MS = 6e4;
|
|
4277
|
-
function linkedAbort(signal, timeoutMs) {
|
|
4278
|
-
const controller = new AbortController();
|
|
4279
|
-
let timedOut = false;
|
|
4280
|
-
const timer = setTimeout(() => {
|
|
4281
|
-
timedOut = true;
|
|
4282
|
-
controller.abort();
|
|
4283
|
-
}, timeoutMs);
|
|
4284
|
-
const onAbort = () => controller.abort();
|
|
4285
|
-
if (signal?.aborted) controller.abort();
|
|
4286
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
4287
|
-
return {
|
|
4288
|
-
signal: controller.signal,
|
|
4289
|
-
timedOut: () => timedOut,
|
|
4290
|
-
dispose: () => {
|
|
4291
|
-
clearTimeout(timer);
|
|
4292
|
-
signal?.removeEventListener("abort", onAbort);
|
|
4293
|
-
}
|
|
4294
|
-
};
|
|
4295
|
-
}
|
|
4296
|
-
async function postJson(baseUrl, path, body, signal) {
|
|
4297
|
-
const link = linkedAbort(signal, REQUEST_TIMEOUT_MS);
|
|
4298
|
-
let res;
|
|
4299
|
-
try {
|
|
4300
|
-
res = await fetch(`${baseUrl}${path}`, {
|
|
4301
|
-
method: "POST",
|
|
4302
|
-
headers: { "content-type": "application/json" },
|
|
4303
|
-
body: JSON.stringify(body),
|
|
4304
|
-
signal: link.signal
|
|
4305
|
-
});
|
|
4306
|
-
} catch (err) {
|
|
4307
|
-
if (signal?.aborted) throw new CaptureError("aborted", "The request was cancelled.", err);
|
|
4308
|
-
if (link.timedOut()) {
|
|
4309
|
-
throw new CaptureError("network", "Capturly did not respond in time.", err);
|
|
4310
|
-
}
|
|
4311
|
-
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
4312
|
-
} finally {
|
|
4313
|
-
link.dispose();
|
|
4314
|
-
}
|
|
4315
|
-
const json = await res.json().catch(() => null);
|
|
4316
|
-
if (!res.ok) {
|
|
4317
|
-
const error = typeof json?.error === "string" ? json.error : null;
|
|
4318
|
-
throw new CaptureError(
|
|
4319
|
-
codeForResponse(res.status, error),
|
|
4320
|
-
error ?? `Capturly returned ${res.status}.`
|
|
4321
|
-
);
|
|
4322
|
-
}
|
|
4323
|
-
return json ?? {};
|
|
4324
|
-
}
|
|
4325
|
-
async function requestUpload(args) {
|
|
4326
|
-
const json = await postJson(
|
|
4327
|
-
args.baseUrl,
|
|
4328
|
-
"/api/campaigns/respond/upload-url",
|
|
4329
|
-
{
|
|
4330
|
-
...accessBody(args.access),
|
|
4331
|
-
contentType: "video/mp4",
|
|
4332
|
-
sizeBytes: args.sizeBytes,
|
|
4333
|
-
durationSec: args.durationSec,
|
|
4334
|
-
...args.displayName ? { displayName: args.displayName } : {},
|
|
4335
|
-
releaseAccepted: args.releaseAccepted
|
|
4336
|
-
},
|
|
4337
|
-
args.signal
|
|
4338
|
-
);
|
|
4339
|
-
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
4340
|
-
if (!objectName) {
|
|
4341
|
-
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
4342
|
-
}
|
|
4343
|
-
const url2 = json.url;
|
|
4344
|
-
if (!isSecureTransportUrl(url2)) {
|
|
4345
|
-
throw new CaptureError("upload_failed", "Capturly returned an unusable upload URL.");
|
|
4346
|
-
}
|
|
4347
|
-
if (json.method !== "PUT") {
|
|
4348
|
-
throw new CaptureError("upload_failed", "Capturly returned an unsupported upload method.");
|
|
4349
|
-
}
|
|
4350
|
-
const headers = json.headers;
|
|
4351
|
-
if (!isStringMap(headers)) {
|
|
4352
|
-
throw new CaptureError("upload_failed", "Capturly returned unusable upload headers.");
|
|
4353
|
-
}
|
|
4354
|
-
return { objectName, url: url2, method: "PUT", headers };
|
|
4355
|
-
}
|
|
4356
|
-
function uploadBlob(args) {
|
|
4357
|
-
return new Promise((resolve, reject) => {
|
|
4358
|
-
if (args.signal?.aborted) {
|
|
4359
|
-
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
4360
|
-
return;
|
|
4361
|
-
}
|
|
4362
|
-
const xhr = new XMLHttpRequest();
|
|
4363
|
-
let stalled = false;
|
|
4364
|
-
let cancelled = false;
|
|
4365
|
-
let stallTimer;
|
|
4366
|
-
const onCallerAbort = () => {
|
|
4367
|
-
cancelled = true;
|
|
4368
|
-
xhr.abort();
|
|
4369
|
-
};
|
|
4370
|
-
const armStall = () => {
|
|
4371
|
-
clearTimeout(stallTimer);
|
|
4372
|
-
stallTimer = setTimeout(() => {
|
|
4373
|
-
stalled = true;
|
|
4374
|
-
xhr.abort();
|
|
4375
|
-
}, UPLOAD_STALL_MS);
|
|
4376
|
-
};
|
|
4377
|
-
const cleanup = () => {
|
|
4378
|
-
clearTimeout(stallTimer);
|
|
4379
|
-
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
4380
|
-
};
|
|
4381
|
-
xhr.open(args.target.method, args.target.url, true);
|
|
4382
|
-
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
4383
|
-
xhr.setRequestHeader(name, value);
|
|
4384
|
-
}
|
|
4385
|
-
xhr.upload.onprogress = (event) => {
|
|
4386
|
-
armStall();
|
|
4387
|
-
if (!event.lengthComputable || !args.onProgress) return;
|
|
4388
|
-
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
4389
|
-
};
|
|
4390
|
-
xhr.onload = () => {
|
|
4391
|
-
cleanup();
|
|
4392
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
4393
|
-
args.onProgress?.(1);
|
|
4394
|
-
resolve();
|
|
4395
|
-
return;
|
|
4396
|
-
}
|
|
4397
|
-
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
4398
|
-
};
|
|
4399
|
-
xhr.onerror = () => {
|
|
4400
|
-
cleanup();
|
|
4401
|
-
reject(new CaptureError("network", "The upload could not reach storage."));
|
|
4402
|
-
};
|
|
4403
|
-
xhr.onabort = () => {
|
|
4404
|
-
cleanup();
|
|
4405
|
-
if (cancelled) {
|
|
4406
|
-
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
4407
|
-
} else if (stalled) {
|
|
4408
|
-
reject(new CaptureError("network", "The upload stopped making progress."));
|
|
4409
|
-
} else {
|
|
4410
|
-
reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
4411
|
-
}
|
|
4412
|
-
};
|
|
4413
|
-
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
4414
|
-
armStall();
|
|
4415
|
-
xhr.send(args.blob);
|
|
4416
|
-
});
|
|
4417
|
-
}
|
|
4418
|
-
async function finalizeSubmission(args) {
|
|
4419
|
-
const json = await postJson(
|
|
4420
|
-
args.baseUrl,
|
|
4421
|
-
"/api/campaigns/respond/finalize",
|
|
4422
|
-
{
|
|
4423
|
-
...accessBody(args.access),
|
|
4424
|
-
objectName: args.objectName,
|
|
4425
|
-
durationSec: args.durationSec,
|
|
4426
|
-
...args.displayName ? { displayName: args.displayName } : {},
|
|
4427
|
-
releaseAccepted: args.releaseAccepted
|
|
4428
|
-
},
|
|
4429
|
-
args.signal
|
|
4430
|
-
);
|
|
4431
|
-
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
4432
|
-
if (!submissionId) {
|
|
4433
|
-
throw new CaptureError(
|
|
4434
|
-
"unknown",
|
|
4435
|
-
"The recording was submitted but Capturly did not return its id."
|
|
4436
|
-
);
|
|
4437
|
-
}
|
|
4438
|
-
return {
|
|
4439
|
-
submissionId,
|
|
4440
|
-
submittedAt: typeof json.submittedAt === "string" ? json.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
4441
|
-
replaced: json.replaced === true
|
|
4442
|
-
};
|
|
4443
|
-
}
|
|
4444
|
-
|
|
4445
4235
|
// src/CaptureClient.ts
|
|
4446
4236
|
var DEFAULT_BASE_URL = "https://capturly.app";
|
|
4447
4237
|
var SLASH = 47;
|
|
@@ -4744,5 +4534,6 @@ var GUM_MESSAGES = {
|
|
|
4744
4534
|
};
|
|
4745
4535
|
export {
|
|
4746
4536
|
CaptureClient,
|
|
4747
|
-
CaptureError
|
|
4537
|
+
CaptureError,
|
|
4538
|
+
fetchCaptureView
|
|
4748
4539
|
};
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import { type CaptureAccess } from './types.js';
|
|
1
|
+
import { CaptureError, type CaptureAccess, type CaptureErrorCode, type CaptureRequestBranding, type CaptureRequestView } from './types.js';
|
|
2
|
+
export { CaptureError };
|
|
3
|
+
export type { CaptureAccess, CaptureErrorCode, CaptureRequestBranding, CaptureRequestView };
|
|
4
|
+
/**
|
|
5
|
+
* Fetch what the person is being asked — title, prompt, branding, release
|
|
6
|
+
* text, limits, and any submission they already made. Same access, same
|
|
7
|
+
* refusal codes as the other calls, so a dead link fails here rather than
|
|
8
|
+
* after a recording exists. Contact links are marked opened by this call,
|
|
9
|
+
* exactly as opening the hosted page would.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchCaptureView(args: {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
access: CaptureAccess;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}): Promise<CaptureRequestView>;
|
|
2
16
|
export interface UploadTarget {
|
|
3
17
|
objectName: string;
|
|
4
18
|
url: string;
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,42 @@ export interface CaptureClientOptions {
|
|
|
24
24
|
onWarning?: (message: string, cause?: unknown) => void;
|
|
25
25
|
}
|
|
26
26
|
export type CaptureState = 'idle' | 'starting' | 'recording' | 'stopping' | 'stopped';
|
|
27
|
+
export interface CaptureRequestBranding {
|
|
28
|
+
studioName: string;
|
|
29
|
+
brandColor: string | null;
|
|
30
|
+
brandSecondaryColor: string | null;
|
|
31
|
+
brandAccentColor: string | null;
|
|
32
|
+
/** Cover-art path under the Capturly origin, or null when unset. */
|
|
33
|
+
coverPath: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What the person is being asked, fetched with the same access the recording
|
|
37
|
+
* uses. Show `releaseText` before recording whenever `releaseRequired` — a
|
|
38
|
+
* consent record is only honest if the person actually saw the text.
|
|
39
|
+
*/
|
|
40
|
+
export interface CaptureRequestView {
|
|
41
|
+
title: string;
|
|
42
|
+
prompt: string;
|
|
43
|
+
deadline: string | null;
|
|
44
|
+
timezone: string;
|
|
45
|
+
maxDurationSec: number;
|
|
46
|
+
releaseRequired: boolean;
|
|
47
|
+
/** Resolved release copy — never empty when `releaseRequired`. */
|
|
48
|
+
releaseText: string;
|
|
49
|
+
/** The person's name for a contact link; null for an open link. */
|
|
50
|
+
respondentName: string | null;
|
|
51
|
+
branding: CaptureRequestBranding;
|
|
52
|
+
/** Short-lived signed URL of the video prompt, when the request has one. */
|
|
53
|
+
promptVideoUrl: string | null;
|
|
54
|
+
/** Frame-height ceiling the owner's plan records at (e.g. 1080). */
|
|
55
|
+
targetHeight: number;
|
|
56
|
+
/** This person's existing submission, when they already answered. */
|
|
57
|
+
existing: {
|
|
58
|
+
submissionId: string;
|
|
59
|
+
submittedAt: string;
|
|
60
|
+
durationSec: number;
|
|
61
|
+
} | null;
|
|
62
|
+
}
|
|
27
63
|
export interface CaptureDevice {
|
|
28
64
|
deviceId: string;
|
|
29
65
|
label: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capturly/capture-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.2",
|
|
4
4
|
"description": "Record full-quality audio and video on your user's own device, from your own product.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"recording",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
36
|
"default": "./dist/index.js"
|
|
37
37
|
},
|
|
38
|
+
"./transport": {
|
|
39
|
+
"types": "./dist/transport.d.ts",
|
|
40
|
+
"default": "./dist/transport.js"
|
|
41
|
+
},
|
|
38
42
|
"./package.json": "./package.json"
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
@@ -51,12 +55,12 @@
|
|
|
51
55
|
"esbuild": "^0.25.0",
|
|
52
56
|
"typescript": "^5.4.0",
|
|
53
57
|
"vitest": "^4.1.7",
|
|
54
|
-
"@capturly/eslint-config": "2.11.
|
|
55
|
-
"@capturly/
|
|
56
|
-
"@capturly/
|
|
58
|
+
"@capturly/eslint-config": "2.11.2",
|
|
59
|
+
"@capturly/recording-engine": "2.11.2",
|
|
60
|
+
"@capturly/tsconfig": "2.11.2"
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
59
|
-
"build": "node build.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
63
|
+
"build": "pnpm clean && node build.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
60
64
|
"test": "vitest run",
|
|
61
65
|
"test:watch": "vitest",
|
|
62
66
|
"lint": "eslint src --ext .ts",
|