@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 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 { CaptureError, type CaptureAccess, type CaptureClientOptions, type CaptureDevice, type CaptureErrorCode, type CaptureState, type CaptureTake, type PreviewOptions, type SubmitOptions, type SubmitResult, } from './types.js';
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
  };
@@ -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;
@@ -0,0 +1,14 @@
1
+ import {
2
+ CaptureError,
3
+ fetchCaptureView,
4
+ finalizeSubmission,
5
+ requestUpload,
6
+ uploadBlob
7
+ } from "./chunk-CZPDQKMD.js";
8
+ export {
9
+ CaptureError,
10
+ fetchCaptureView,
11
+ finalizeSubmission,
12
+ requestUpload,
13
+ uploadBlob
14
+ };
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.0",
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.0",
55
- "@capturly/tsconfig": "2.11.0",
56
- "@capturly/recording-engine": "2.11.0"
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",