@rendobar/sdk 2.1.2 → 3.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/README.md CHANGED
@@ -23,8 +23,10 @@ const job = await client.jobs.create({
23
23
  });
24
24
 
25
25
  // Wait for completion
26
+ import { outputUrl } from "@rendobar/sdk";
27
+
26
28
  const result = await client.jobs.wait(job.id);
27
- console.log(result.outputUrl);
29
+ console.log(outputUrl(result)); // primary download/playable URL
28
30
  ```
29
31
 
30
32
  ## Authentication
@@ -99,6 +101,78 @@ const logs = await client.jobs.logs("job_abc");
99
101
  const types = await client.jobs.types();
100
102
  ```
101
103
 
104
+ ### Job output
105
+
106
+ Every job type returns the same output shape. A completed job carries an
107
+ `output` with four fields:
108
+
109
+ ```typescript
110
+ type Output = {
111
+ data: unknown; // structured result (probe, detections, transcript), or null
112
+ file: OutputFile | null; // the headline file or stream manifest, or null
113
+ files: OutputFile[]; // every produced file (empty for data-only jobs)
114
+ expiresAt: number | null; // Unix ms URL expiry, set when files is non-empty
115
+ };
116
+
117
+ type OutputFile = {
118
+ url: string; // ready-to-fetch, time-limited URL
119
+ path: string; // path within the job output
120
+ type: "video" | "image" | "audio" | "captions" | "playlist" | "data" | "other";
121
+ size: number; // bytes
122
+ meta?: { format?: string; width?: number; height?: number; durationMs?: number };
123
+ };
124
+ ```
125
+
126
+ `output` exists only on a completed job (`status === "complete"`), so narrow on
127
+ status before reading it.
128
+
129
+ ```typescript
130
+ import { outputUrl, jobData } from "@rendobar/sdk";
131
+
132
+ const job = await client.jobs.wait("job_abc");
133
+
134
+ // Headline URL: the single file, or a stream manifest (.m3u8/.mpd). Returns
135
+ // undefined for data-only jobs and pure file sets (no single headline).
136
+ const url = outputUrl(job);
137
+
138
+ // Or read it yourself after a status check
139
+ if (job.status === "complete") {
140
+ console.log(job.output.file?.url);
141
+
142
+ // Iterate every produced file (frame extraction, HLS segments, sprite sets)
143
+ for (const f of job.output.files) {
144
+ console.log(f.type, f.path, f.size, f.url);
145
+ }
146
+ }
147
+
148
+ // A failed job carries a structured error instead
149
+ if (job.status === "failed") {
150
+ console.log(job.error.code, job.error.message, job.error.detail);
151
+ }
152
+ ```
153
+
154
+ For data jobs (`extract.metadata`, `caption.extract`, detections), the answer is
155
+ in `output.data`. It is `unknown` at the contract level because its shape is
156
+ job-type-specific. Use `jobData<T>(job)` to read it with your expected type, or
157
+ read `job.output.data` and narrow it yourself:
158
+
159
+ ```typescript
160
+ import { jobData } from "@rendobar/sdk";
161
+
162
+ type Metadata = { format: string; durationMs: number; width: number; height: number };
163
+
164
+ const job = await client.jobs.wait("job_abc");
165
+ const meta = jobData<Metadata>(job); // Metadata | null
166
+
167
+ if (meta) {
168
+ console.log(meta.format, meta.durationMs);
169
+ }
170
+ ```
171
+
172
+ `T` is your claim about the shape for the job type you submitted. If the input is
173
+ untrusted, validate `output.data` yourself (e.g. with Zod) instead of asserting a
174
+ type.
175
+
102
176
  ## Raw FFmpeg
103
177
 
104
178
  Run arbitrary FFmpeg commands in a sandboxed container. Commands are parsed, sanitized (protocol whitelist/blacklist flags blocked), and executed with a clean process environment.
@@ -120,7 +194,7 @@ const job = await client.jobs.create({
120
194
  });
121
195
 
122
196
  const done = await client.jobs.wait(job.id);
123
- console.log(done.outputUrl);
197
+ console.log(outputUrl(done));
124
198
  ```
125
199
 
126
200
  Prefer named inputs? Pass them in the top-level `inputs` map and reference the keys in the command:
@@ -258,7 +332,7 @@ All response types are exported:
258
332
 
259
333
  ```typescript
260
334
  import type {
261
- Job, JobStep, JobType, JobCreatedResponse,
335
+ Job, JobStep, Output, OutputFile, FileType, JobError, Cost, JobType, JobCreatedResponse,
262
336
  BillingState, Transaction, UsageSummary,
263
337
  WebhookEndpoint, WebhookDelivery,
264
338
  Organization, ApiKey, PaginationMeta,
package/dist/index.cjs CHANGED
@@ -303,13 +303,130 @@ function createBillingResource(request) {
303
303
  }
304
304
  //#endregion
305
305
  //#region src/resources/uploads.ts
306
+ function toBlob(file) {
307
+ return file instanceof Blob ? file : new Blob([file]);
308
+ }
309
+ /**
310
+ * PUT a body to a presigned R2 url with bounded retry.
311
+ *
312
+ * Returns the ETag string when the bucket exposes it, or `null` when the
313
+ * response header is absent (e.g. bucket CORS not exposing ETag). Only
314
+ * network/HTTP failures trigger retries; a missing ETag is not retried because
315
+ * it is a deterministic misconfiguration, not a transient error.
316
+ */
317
+ async function putWithRetry(url, body, signal, attempts = 3) {
318
+ let lastErr;
319
+ for (let i = 0; i < attempts; i++) {
320
+ if (signal?.aborted) throw new Error("Upload aborted");
321
+ try {
322
+ const res = await fetch(url, {
323
+ method: "PUT",
324
+ body,
325
+ signal
326
+ });
327
+ if (!res.ok) throw new Error(`Upload PUT failed: ${res.status}`);
328
+ return res.headers.get("etag");
329
+ } catch (e) {
330
+ lastErr = e;
331
+ if (signal?.aborted) throw e;
332
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 200 * 2 ** i));
333
+ }
334
+ }
335
+ throw lastErr;
336
+ }
337
+ /**
338
+ * Run `fn` over `items` with at most `limit` in flight; preserves order.
339
+ * Workers stop pulling new items once `signal` is aborted, enabling fast
340
+ * cancellation when one part fails.
341
+ */
342
+ async function mapLimit(items, limit, fn, signal) {
343
+ const out = new Array(items.length);
344
+ let next = 0;
345
+ const worker = async () => {
346
+ while (next < items.length) {
347
+ if (signal?.aborted) break;
348
+ const i = next++;
349
+ out[i] = await fn(items[i]);
350
+ }
351
+ };
352
+ const workers = Math.min(Math.max(1, limit), items.length);
353
+ await Promise.all(Array.from({ length: workers }, worker));
354
+ return out;
355
+ }
306
356
  function createUploadsResource(request) {
307
- return { async upload(file, options) {
308
- return request("/uploads", {
357
+ return { async create(file, options) {
358
+ const blob = toBlob(file);
359
+ const size = options.size ?? blob.size;
360
+ if (options.signal?.aborted) throw new Error("Upload aborted");
361
+ const init = await (await request("/assets", {
309
362
  method: "POST",
310
- bodyRaw: file,
311
- query: options?.filename ? { filename: options.filename } : void 0,
312
- signal: options?.signal
363
+ body: {
364
+ filename: options.filename,
365
+ size,
366
+ contentType: options.contentType,
367
+ checksum: options.checksum,
368
+ lifecycle: options.persist ? "persisted" : "ephemeral"
369
+ },
370
+ raw: true,
371
+ signal: options.signal
372
+ })).json();
373
+ if (init.status === "deduplicated") {
374
+ options.onProgress?.({
375
+ loaded: size,
376
+ total: size
377
+ });
378
+ return init.data;
379
+ }
380
+ if (init.status === "presigned") {
381
+ await putWithRetry(init.upload.url, blob, options.signal);
382
+ options.onProgress?.({
383
+ loaded: size,
384
+ total: size
385
+ });
386
+ return request(`/assets/${init.data.id}/complete`, {
387
+ method: "POST",
388
+ body: {},
389
+ signal: options.signal
390
+ });
391
+ }
392
+ const internal = new AbortController();
393
+ const combinedSignal = options.signal ? AbortSignal.any([options.signal, internal.signal]) : internal.signal;
394
+ const { partSize, parts } = init.upload;
395
+ let loaded = 0;
396
+ let partError = null;
397
+ const completed = await mapLimit(parts, options.concurrency ?? 5, async (p) => {
398
+ const start = (p.partNumber - 1) * partSize;
399
+ const chunk = blob.slice(start, Math.min(start + partSize, size));
400
+ let etag;
401
+ try {
402
+ etag = await putWithRetry(p.url, chunk, combinedSignal);
403
+ } catch (e) {
404
+ internal.abort();
405
+ partError = e instanceof Error ? e : new Error(String(e));
406
+ throw partError;
407
+ }
408
+ if (etag === null) {
409
+ internal.abort();
410
+ const corsErr = /* @__PURE__ */ new Error("Multipart upload requires the ETag response header. Configure the R2 bucket CORS to expose 'ETag'.");
411
+ partError = corsErr;
412
+ throw corsErr;
413
+ }
414
+ loaded += chunk.size;
415
+ options.onProgress?.({
416
+ loaded,
417
+ total: size
418
+ });
419
+ return {
420
+ partNumber: p.partNumber,
421
+ etag
422
+ };
423
+ }, combinedSignal);
424
+ if (partError) throw partError;
425
+ completed.sort((a, b) => a.partNumber - b.partNumber);
426
+ return request(`/assets/${init.data.id}/complete`, {
427
+ method: "POST",
428
+ body: { parts: completed },
429
+ signal: options.signal
313
430
  });
314
431
  } };
315
432
  }
@@ -449,12 +566,32 @@ function createBatchesResource(request) {
449
566
  //#endregion
450
567
  //#region src/resources/assets.ts
451
568
  function createAssetsResource(request) {
452
- return { async templates(params, options) {
453
- return request("/assets/templates", {
454
- query: params ? { ...params } : void 0,
455
- signal: options?.signal
456
- });
457
- } };
569
+ return {
570
+ async list(params, options) {
571
+ return request("/assets", {
572
+ query: params,
573
+ signal: options?.signal
574
+ });
575
+ },
576
+ async get(id, options) {
577
+ return request(`/assets/${id}`, { signal: options?.signal });
578
+ },
579
+ async delete(id, options) {
580
+ return request(`/assets/${id}`, {
581
+ method: "DELETE",
582
+ signal: options?.signal
583
+ });
584
+ },
585
+ async download(id, options) {
586
+ return request(`/assets/${id}/download`, { signal: options?.signal });
587
+ },
588
+ async templates(params, options) {
589
+ return request("/assets/templates", {
590
+ query: params,
591
+ signal: options?.signal
592
+ });
593
+ }
594
+ };
458
595
  }
459
596
  //#endregion
460
597
  //#region src/resources/team.ts
@@ -646,6 +783,44 @@ function createClient(config = {}) {
646
783
  };
647
784
  }
648
785
  //#endregion
786
+ //#region src/types.ts
787
+ /**
788
+ * Primary playable/download URL for a completed job, or `undefined`.
789
+ *
790
+ * Returns `output.file.url` — the single download for a file job or the playlist
791
+ * URL for a stream. A pure set (multiple files, no headline) or a data-only job
792
+ * has no single primary URL: inspect `job.output.files` / `job.output.data`.
793
+ */
794
+ function outputUrl(job) {
795
+ if (job.status !== "complete") return void 0;
796
+ return job.output.file?.url;
797
+ }
798
+ /**
799
+ * Typed accessor for a completed job's structured `data`, or `null`.
800
+ *
801
+ * `output.data` is job-type-specific (probe result, detections, transcript) and
802
+ * is `unknown` at the contract level — its shape is documented per job type, not
803
+ * by the API schema. This helper applies the caller-supplied type `T` so you can
804
+ * read `data` without sprinkling assertions at each call site. `T` is your claim
805
+ * about the shape for the job type you submitted; validate it yourself (e.g. with
806
+ * Zod) if the input is untrusted.
807
+ *
808
+ * Returns `null` for non-complete jobs and for jobs with no structured data
809
+ * (pure file transforms).
810
+ *
811
+ * @example
812
+ * type Metadata = { format: string; durationMs: number };
813
+ * const meta = jobData<Metadata>(await client.jobs.wait(id));
814
+ * if (meta) console.log(meta.durationMs);
815
+ */
816
+ function jobData(job) {
817
+ if (job.status !== "complete") return null;
818
+ if (job.output.data == null) return null;
819
+ return job.output.data;
820
+ }
821
+ //#endregion
649
822
  exports.ApiError = ApiError;
650
823
  exports.createClient = createClient;
651
824
  exports.isApiError = isApiError;
825
+ exports.jobData = jobData;
826
+ exports.outputUrl = outputUrl;