@giveitsmaller/sdk 0.12.1 → 0.14.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
@@ -12,34 +12,42 @@ Node.js 18+ required.
12
12
 
13
13
  ## Quickstart
14
14
 
15
+ The SDK is **file-first**: you start from a file (`.file(path)` for one,
16
+ `.files([paths])` for many) and call operations *on* it — upload, workflow
17
+ creation, and waiting all happen for you.
18
+
15
19
  ```ts
16
- import { GislClient, uploadSource, OperationType } from '@giveitsmaller/sdk';
17
-
18
- const client = new GislClient({
19
- baseUrl: 'https://api.giveitsmaller.com',
20
- apiKey: 'REPLACE_ME_API_KEY',
21
- });
22
-
23
- const upload = await client.uploadFile('./photo.jpg');
24
-
25
- const workflow = await client.createWorkflow({
26
- jobs: [
27
- {
28
- id: 'compressed',
29
- source: uploadSource(upload.fileId),
30
- operations: [
31
- { type: OperationType.compress, options: { mode: 'lossy', quality: 80 } },
32
- ],
33
- },
34
- ],
35
- });
36
-
37
- await client.waitForWorkflow(workflow.workflowId);
38
-
39
- const dls = await client.getWorkflowDownloads(workflow.workflowId);
40
- console.log('Compressed:', dls.downloads[0].files[0].downloadUrl);
20
+ import { gisl, OptimizeFor } from '@giveitsmaller/sdk';
21
+
22
+ // baseUrl defaults to https://api.giveitsmaller.com; the key can also come
23
+ // from GISL_API_KEY or ~/.gisl/credentials.
24
+ const client = await gisl.create({ apiKey: 'sk_...' });
25
+
26
+ // One file:
27
+ const result = await client
28
+ .file('./photo.jpg')
29
+ .compress(OptimizeFor.Balanced)
30
+ .run({ maxWait: '5m' });
31
+
32
+ console.log(result.url); // pre-signed download URL
33
+
34
+ // Many files (fan-out) — the same chain:
35
+ const many = await client
36
+ .files(['./a.jpg', './b.png'])
37
+ .compress(OptimizeFor.Balanced)
38
+ .run();
39
+
40
+ for (const artifact of many.artifacts) console.log(artifact.url);
41
41
  ```
42
42
 
43
+ > **Operation-first / low-level also ships.** A lower-level
44
+ > `client.compress(path, { ... }).run()` form (and `thumbnail` / `convert`) is
45
+ > available, and the raw wire client — `client.createWorkflow({ jobs: [...] })`
46
+ > with `uploadSource` / `OperationType` for hand-built job DAGs — is the advanced
47
+ > escape hatch. File-first is the recommended direction the
48
+ > [examples](https://github.com/AntonioCS/giveitsmaller-sdks/tree/main/docs/typescript/examples)
49
+ > build on.
50
+
43
51
  > **Reusing an upload id across clients?** An upload created by an
44
52
  > authenticated caller is owned by that caller. If you persist a `fileId` and
45
53
  > later reference it (via `fileInput.uploadId(id)`) from a client configured
package/dist/builder.d.ts CHANGED
@@ -253,10 +253,26 @@ export interface RunOptions {
253
253
  readonly useSSE?: boolean;
254
254
  /** Override the poll interval used by the fallback (ms). */
255
255
  readonly pollIntervalMs?: number;
256
+ /**
257
+ * Best-effort probe-before-create for a VIDEO upload that went multipart:
258
+ * after upload, before createWorkflow, wait for the server's probe to land
259
+ * so it admits the parallel video split. Default `true`; set `false` to
260
+ * skip the wait entirely. Never-bounce — a give-up just proceeds to create.
261
+ */
262
+ readonly probeBeforeCreate?: boolean;
263
+ /** Overall timeout (ms) for the probe-before-create wait. */
264
+ readonly probeTimeoutMs?: number;
256
265
  }
257
266
  export interface SubmitOptions {
258
267
  /** Webhook URL — wired to `WorkflowCreateRequest.callback_url`. */
259
268
  readonly webhook: string;
269
+ /**
270
+ * Best-effort probe-before-create for a VIDEO upload that went multipart.
271
+ * Default `true`; set `false` to skip the wait. See {@link RunOptions}.
272
+ */
273
+ readonly probeBeforeCreate?: boolean;
274
+ /** Overall timeout (ms) for the probe-before-create wait. */
275
+ readonly probeTimeoutMs?: number;
260
276
  }
261
277
  /**
262
278
  * Captures the (op-type, input, options) tuple for one ergonomic-layer
@@ -405,6 +421,17 @@ export declare function _projectResult(status: WorkflowStatusResponse, jobDownlo
405
421
  }[], appliedOptions: Record<string, unknown>, resolvedOptionsOverride?: ResolvedOptions): Result;
406
422
  /** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
407
423
  export declare function _checkAborted(signal: AbortSignal | undefined): void;
424
+ /**
425
+ * Cap a best-effort probe-before-create timeout to the remaining `maxWait`
426
+ * budget so the probe wait can never push createWorkflow past the caller's
427
+ * deadline. Under a deadline an UNSET `probeTimeoutMs` becomes the remaining
428
+ * budget (never the 30s waitForProbe default); a set value is clamped to the
429
+ * remaining budget. With no deadline (the `submit()` fire-and-forget path),
430
+ * `probeTimeoutMs` passes through unchanged.
431
+ *
432
+ * @internal — exported for reuse by `file-first.ts` + `merge.ts`.
433
+ */
434
+ export declare function _cappedProbeTimeoutMs(probeTimeoutMs: number | undefined, deadline: number | undefined): number | undefined;
408
435
  /**
409
436
  * Parse a `maxWait` argument: number = milliseconds; string with suffix
410
437
  * `ms` / `s` / `m` / `h`. Throws if the string is malformed.
package/dist/builder.js CHANGED
@@ -275,6 +275,24 @@ export class OperationBuilder {
275
275
  if (Date.now() >= deadline) {
276
276
  throw new GislTimeoutError(`Upload completed but maxWait elapsed before workflow could be created`);
277
277
  }
278
+ // Best-effort probe-before-create for a multipart video upload (never-bounce).
279
+ // Capped to the remaining maxWait budget so a slow probe cannot push
280
+ // createWorkflow past the caller's deadline.
281
+ await this.client.maybeWaitForVideoProbe(uploadResp.fileId, {
282
+ enabled: options.probeBeforeCreate ?? true,
283
+ isVideo: _detectCompressMedia(this.input) === 'video',
284
+ sizeBytes: uploadResp.sizeBytes,
285
+ timeoutMs: _cappedProbeTimeoutMs(options.probeTimeoutMs, deadline),
286
+ signal,
287
+ });
288
+ // A cancel arriving during the FINAL successful probe request must not still
289
+ // create the workflow (maybeWaitForVideoProbe returns landed without a final
290
+ // abort re-check), so check here BEFORE createWorkflow.
291
+ _checkAborted(signal);
292
+ // RE-CHECK the deadline AFTER the probe wait (it consumes time).
293
+ if (Date.now() >= deadline) {
294
+ throw new GislTimeoutError('Probe wait completed but maxWait elapsed before workflow could be created');
295
+ }
278
296
  // 2. Build + create the workflow.
279
297
  const job = {
280
298
  id: 'op',
@@ -320,6 +338,13 @@ export class OperationBuilder {
320
338
  // call before the upload — same fail-early contract as run().
321
339
  const resolved = this._resolve();
322
340
  const uploadResp = await this.client.uploadFile(this.input);
341
+ // Best-effort probe-before-create for a multipart video upload (never-bounce).
342
+ await this.client.maybeWaitForVideoProbe(uploadResp.fileId, {
343
+ enabled: options.probeBeforeCreate ?? true,
344
+ isVideo: _detectCompressMedia(this.input) === 'video',
345
+ sizeBytes: uploadResp.sizeBytes,
346
+ timeoutMs: options.probeTimeoutMs,
347
+ });
323
348
  const job = {
324
349
  id: 'op',
325
350
  source: uploadSource(uploadResp.fileId),
@@ -759,6 +784,23 @@ export function _checkAborted(signal) {
759
784
  throw new DOMException('Aborted', 'AbortError');
760
785
  }
761
786
  }
787
+ /**
788
+ * Cap a best-effort probe-before-create timeout to the remaining `maxWait`
789
+ * budget so the probe wait can never push createWorkflow past the caller's
790
+ * deadline. Under a deadline an UNSET `probeTimeoutMs` becomes the remaining
791
+ * budget (never the 30s waitForProbe default); a set value is clamped to the
792
+ * remaining budget. With no deadline (the `submit()` fire-and-forget path),
793
+ * `probeTimeoutMs` passes through unchanged.
794
+ *
795
+ * @internal — exported for reuse by `file-first.ts` + `merge.ts`.
796
+ */
797
+ export function _cappedProbeTimeoutMs(probeTimeoutMs, deadline) {
798
+ if (deadline === undefined) {
799
+ return probeTimeoutMs;
800
+ }
801
+ const remaining = Math.max(0, deadline - Date.now());
802
+ return probeTimeoutMs !== undefined ? Math.min(probeTimeoutMs, remaining) : remaining;
803
+ }
762
804
  async function sleep(ms, signal) {
763
805
  return await new Promise((resolve, reject) => {
764
806
  const t = setTimeout(() => {
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AudioWatermarkDecodeRequest, AudioWatermarkDecodeResponse, ExternalImportCreatedResponse, ExternalImportRequest, LoginUserRequest, LoginUser200ResponseData, ContactRequest, CreditsBalanceResponse, CreditsUsageResponse, UploadResponse, UploadProbeResponse, WorkflowCancelResponse, WorkflowCreateResponse, WorkflowResumeResponse, WorkflowStatusResponse, WorkflowListResponse, WorkflowSummary, WorkflowDownloadResponse, MetadataResponse, RetryResponse } from '@giveitsmaller/contracts/openapi';
2
- import type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, GislClientConfig, GislSseEvent, PreflightClipsResult, UploadOptions, WaitOptions, WorkflowCreatePayload, _Sdk3HandCodedKeepaliveResult, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignPartsResult } from './types.js';
2
+ import type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, GislClientConfig, GislSseEvent, PreflightClipsResult, ProbeWaitOptions, ProbeWaitResult, UploadOptions, WaitOptions, WorkflowCreatePayload, _Sdk3HandCodedKeepaliveResult, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignPartsResult } from './types.js';
3
3
  export declare const MULTIPART_CONCURRENCY_DEFAULT: 4;
4
4
  export declare const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE: number;
5
5
  export interface ValidationDetail {
@@ -306,15 +306,67 @@ export declare class GislClient {
306
306
  /**
307
307
  * Probe an uploaded file for workflow-readiness — detects corruption,
308
308
  * unsupported codecs, and pre-assigns the processing class the server
309
- * would route the file to. Designed for the long-form merge edge case
310
- * where a single bad input would fail the whole workflow.
309
+ * would route the file to. For video uploads the probe also lands the
310
+ * codec + duration the server needs to admit the parallel split, so
311
+ * calling this (or {@link waitForProbe}) before workflow-create is the
312
+ * structural unlock for the fast video path on the multipart flow.
311
313
  *
312
- * Currently `availability: planned` calls return
313
- * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
314
- * support ships. Idempotent: probing the same `fileId` twice returns
315
- * the cached result.
314
+ * Endpoint availability is `stable`. The probe runs asynchronously after
315
+ * upload: until the result has landed, this returns `422`
316
+ * `feature_not_available` (surfaced as {@link GislFeatureNotAvailableError})
317
+ * i.e. that 422 means "probe not landed yet", NOT "not implemented". Once
318
+ * landed it returns a `200` with any `probeStatus`. Idempotent: probing the
319
+ * same `fileId` twice returns the cached result. See {@link waitForProbe}
320
+ * for a bounded poll that turns this into a single ready/gave-up answer.
316
321
  */
317
- probeUpload(fileId: string): Promise<UploadProbeResponse>;
322
+ probeUpload(fileId: string, options?: {
323
+ signal?: AbortSignal;
324
+ }): Promise<UploadProbeResponse>;
325
+ /**
326
+ * Bounded poll of {@link probeUpload} until the probe lands — the helper
327
+ * frontends call between upload-complete and workflow-create so the server
328
+ * sees the video's codec + duration and admits the ~3× parallel split.
329
+ *
330
+ * Loop (per the API wire contract):
331
+ * - `422 feature_not_available` → probe not landed yet → keep polling
332
+ * (exponential full-jitter backoff, honouring a `Retry-After` header when
333
+ * present, clamped to the remaining budget).
334
+ * - any `200` → STOP. Resolves `{ landed: true, probe }` regardless of
335
+ * `probeStatus` (ok / corrupt / unsupported_codec / missing_metadata) —
336
+ * the server + fan-out gate decide split-vs-single from the landed
337
+ * metadata; the SDK does not interpret it.
338
+ * - `5xx` (prober crash) → retry a couple of times, then give up.
339
+ * - timeout → give up.
340
+ *
341
+ * **Never bounces:** on give-up (timeout / repeated 5xx / transport) it
342
+ * resolves `{ landed: false, reason }` rather than throwing, so the caller
343
+ * proceeds to create the workflow anyway (the server's size heuristic routes
344
+ * it; worst case = today's single-task behaviour). Genuine failures —
345
+ * `404 upload_not_found`, auth errors, or caller abort — DO propagate (they
346
+ * are not "probe not ready"), so a real problem is never silently swallowed.
347
+ *
348
+ * `timeoutMs` bounds the OVERALL poll, checked between attempts; each
349
+ * in-flight probe request is bounded by the client's own per-request timeout
350
+ * (a hung request surfaces as a transient and the next deadline check gives
351
+ * up). So a single slow probe may run up to one client-request-timeout before
352
+ * the wait returns.
353
+ */
354
+ waitForProbe(fileId: string, options?: ProbeWaitOptions): Promise<ProbeWaitResult>;
355
+ /**
356
+ * Best-effort probe-before-create for a VIDEO upload that went multipart.
357
+ * No-op unless enabled AND isVideo AND the upload exceeded the multipart
358
+ * threshold (i.e. it was a multipart upload — small single-shot videos skip
359
+ * the wait). Delegates to {@link waitForProbe} (never-bounce): a give-up just
360
+ * returns; genuine failures / caller abort propagate. The caller passes
361
+ * `isVideo` so the low-level client never imports ergonomic media detection.
362
+ */
363
+ maybeWaitForVideoProbe(fileId: string, opts: {
364
+ enabled: boolean;
365
+ isVideo: boolean;
366
+ sizeBytes?: number;
367
+ timeoutMs?: number;
368
+ signal?: AbortSignal;
369
+ }): Promise<void>;
318
370
  /**
319
371
  * Probe N uploaded files in parallel and partition the results by
320
372
  * outcome. Returns `{ ok, rejected, errors }` so the caller can
package/dist/client.js CHANGED
@@ -112,6 +112,51 @@ function isRetryableStatus(status) {
112
112
  function isRetryableNetworkError(err) {
113
113
  return err instanceof TypeError;
114
114
  }
115
+ // Parse an HTTP `Retry-After` header into milliseconds. Accepts the two RFC
116
+ // 9110 forms: delta-seconds (e.g. "5") or an HTTP-date. Returns `undefined`
117
+ // for an absent / unparseable / negative value (caller falls back to its own
118
+ // backoff). A past HTTP-date clamps to 0.
119
+ function parseRetryAfterMs(headerValue) {
120
+ if (headerValue === undefined)
121
+ return undefined;
122
+ const trimmed = headerValue.trim();
123
+ if (trimmed === '')
124
+ return undefined;
125
+ let ms;
126
+ if (/^\d+$/.test(trimmed)) {
127
+ ms = Number(trimmed) * 1000;
128
+ }
129
+ else {
130
+ const when = Date.parse(trimmed);
131
+ if (Number.isNaN(when))
132
+ return undefined;
133
+ ms = when - Date.now();
134
+ }
135
+ // A non-positive Retry-After (e.g. "0" or a past HTTP-date) must NOT short-
136
+ // circuit the backoff to zero — treat it as absent so the caller falls back
137
+ // to jitter and the loop can't busy-poll until timeout.
138
+ return ms > 0 ? ms : undefined;
139
+ }
140
+ // Cancellable sleep for poll loops. Resolves after `ms`, or rejects with
141
+ // `GislAbortError` if `signal` aborts. Resolves immediately for ms <= 0.
142
+ function cancellableSleep(ms, signal) {
143
+ if (signal?.aborted)
144
+ return Promise.reject(new GislAbortError('waitForProbe aborted'));
145
+ if (ms <= 0)
146
+ return Promise.resolve();
147
+ return new Promise((resolve, reject) => {
148
+ const onAbort = () => {
149
+ clearTimeout(timer);
150
+ signal?.removeEventListener('abort', onAbort);
151
+ reject(new GislAbortError('waitForProbe aborted'));
152
+ };
153
+ const timer = setTimeout(() => {
154
+ signal?.removeEventListener('abort', onAbort);
155
+ resolve();
156
+ }, ms);
157
+ signal?.addEventListener('abort', onAbort, { once: true });
158
+ });
159
+ }
115
160
  // Full-jitter exponential backoff: delay = random(0, base * 2^attemptIndex).
116
161
  // AWS SDK guidance for shared-throttling sources like S3 — keeps competing
117
162
  // clients from synchronising their retries.
@@ -1979,19 +2024,133 @@ export class GislClient {
1979
2024
  /**
1980
2025
  * Probe an uploaded file for workflow-readiness — detects corruption,
1981
2026
  * unsupported codecs, and pre-assigns the processing class the server
1982
- * would route the file to. Designed for the long-form merge edge case
1983
- * where a single bad input would fail the whole workflow.
2027
+ * would route the file to. For video uploads the probe also lands the
2028
+ * codec + duration the server needs to admit the parallel split, so
2029
+ * calling this (or {@link waitForProbe}) before workflow-create is the
2030
+ * structural unlock for the fast video path on the multipart flow.
1984
2031
  *
1985
- * Currently `availability: planned` calls return
1986
- * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
1987
- * support ships. Idempotent: probing the same `fileId` twice returns
1988
- * the cached result.
2032
+ * Endpoint availability is `stable`. The probe runs asynchronously after
2033
+ * upload: until the result has landed, this returns `422`
2034
+ * `feature_not_available` (surfaced as {@link GislFeatureNotAvailableError})
2035
+ * i.e. that 422 means "probe not landed yet", NOT "not implemented". Once
2036
+ * landed it returns a `200` with any `probeStatus`. Idempotent: probing the
2037
+ * same `fileId` twice returns the cached result. See {@link waitForProbe}
2038
+ * for a bounded poll that turns this into a single ready/gave-up answer.
1989
2039
  */
1990
- async probeUpload(fileId) {
2040
+ async probeUpload(fileId, options = {}) {
1991
2041
  return this.request('POST', `/api/uploads/${encodeURIComponent(fileId)}/probe`, {
1992
2042
  deserialize: UploadProbeResponseFromJSON,
2043
+ signal: options.signal,
1993
2044
  });
1994
2045
  }
2046
+ /**
2047
+ * Bounded poll of {@link probeUpload} until the probe lands — the helper
2048
+ * frontends call between upload-complete and workflow-create so the server
2049
+ * sees the video's codec + duration and admits the ~3× parallel split.
2050
+ *
2051
+ * Loop (per the API wire contract):
2052
+ * - `422 feature_not_available` → probe not landed yet → keep polling
2053
+ * (exponential full-jitter backoff, honouring a `Retry-After` header when
2054
+ * present, clamped to the remaining budget).
2055
+ * - any `200` → STOP. Resolves `{ landed: true, probe }` regardless of
2056
+ * `probeStatus` (ok / corrupt / unsupported_codec / missing_metadata) —
2057
+ * the server + fan-out gate decide split-vs-single from the landed
2058
+ * metadata; the SDK does not interpret it.
2059
+ * - `5xx` (prober crash) → retry a couple of times, then give up.
2060
+ * - timeout → give up.
2061
+ *
2062
+ * **Never bounces:** on give-up (timeout / repeated 5xx / transport) it
2063
+ * resolves `{ landed: false, reason }` rather than throwing, so the caller
2064
+ * proceeds to create the workflow anyway (the server's size heuristic routes
2065
+ * it; worst case = today's single-task behaviour). Genuine failures —
2066
+ * `404 upload_not_found`, auth errors, or caller abort — DO propagate (they
2067
+ * are not "probe not ready"), so a real problem is never silently swallowed.
2068
+ *
2069
+ * `timeoutMs` bounds the OVERALL poll, checked between attempts; each
2070
+ * in-flight probe request is bounded by the client's own per-request timeout
2071
+ * (a hung request surfaces as a transient and the next deadline check gives
2072
+ * up). So a single slow probe may run up to one client-request-timeout before
2073
+ * the wait returns.
2074
+ */
2075
+ async waitForProbe(fileId, options = {}) {
2076
+ const timeoutMs = sanitiseBaseMs(options.timeoutMs, 30_000);
2077
+ const signal = options.signal;
2078
+ const start = Date.now();
2079
+ const deadline = start + timeoutMs;
2080
+ const BASE_BACKOFF_MS = 250;
2081
+ const MAX_PROBER_RETRIES = 2;
2082
+ let attempt = 0;
2083
+ // Counts transient probe-call failures (5xx + transport + per-request
2084
+ // timeout) — repeated transients give up so the caller creates anyway
2085
+ // (never-bounce).
2086
+ let transientFailures = 0;
2087
+ for (;;) {
2088
+ if (signal?.aborted)
2089
+ throw new GislAbortError('waitForProbe aborted');
2090
+ const remainingBeforeAttempt = deadline - Date.now();
2091
+ if (remainingBeforeAttempt <= 0)
2092
+ return { landed: false, reason: 'timeout' };
2093
+ attempt += 1;
2094
+ options.onPoll?.({ attempt, elapsedMs: Date.now() - start });
2095
+ let retryAfterMs;
2096
+ try {
2097
+ const probe = await this.probeUpload(fileId, { signal });
2098
+ return { landed: true, probe };
2099
+ }
2100
+ catch (err) {
2101
+ // A CALLER abort always propagates (it is not "probe not ready").
2102
+ if (signal?.aborted) {
2103
+ throw err instanceof GislAbortError ? err : new GislAbortError('waitForProbe aborted');
2104
+ }
2105
+ if (err instanceof GislFeatureNotAvailableError) {
2106
+ // Not landed yet — keep polling.
2107
+ retryAfterMs = parseRetryAfterMs(err.responseHeaders?.['retry-after']);
2108
+ }
2109
+ else if ((err instanceof GislApiError && err.statusCode >= 500) ||
2110
+ err instanceof GislTimeoutError ||
2111
+ isRetryableNetworkError(err)) {
2112
+ // Transient probe-call failure — a 5xx, the client's own per-request
2113
+ // timeout, or a transport error. Retry a couple of times, then give
2114
+ // up (never-bounce: the caller creates anyway).
2115
+ transientFailures += 1;
2116
+ if (transientFailures > MAX_PROBER_RETRIES) {
2117
+ return { landed: false, reason: 'prober_error' };
2118
+ }
2119
+ retryAfterMs =
2120
+ err instanceof GislApiError
2121
+ ? parseRetryAfterMs(err.responseHeaders?.['retry-after'])
2122
+ : undefined;
2123
+ }
2124
+ else {
2125
+ // 404 upload_not_found, auth, validation, or any other typed/unexpected
2126
+ // error is a real failure, not "probe not ready" — propagate.
2127
+ throw err;
2128
+ }
2129
+ }
2130
+ const remaining = deadline - Date.now();
2131
+ if (remaining <= 0)
2132
+ return { landed: false, reason: 'timeout' };
2133
+ const backoff = retryAfterMs ?? fullJitterDelay(BASE_BACKOFF_MS, attempt - 1);
2134
+ await cancellableSleep(Math.min(backoff, remaining), signal);
2135
+ if (Date.now() >= deadline)
2136
+ return { landed: false, reason: 'timeout' };
2137
+ }
2138
+ }
2139
+ /**
2140
+ * Best-effort probe-before-create for a VIDEO upload that went multipart.
2141
+ * No-op unless enabled AND isVideo AND the upload exceeded the multipart
2142
+ * threshold (i.e. it was a multipart upload — small single-shot videos skip
2143
+ * the wait). Delegates to {@link waitForProbe} (never-bounce): a give-up just
2144
+ * returns; genuine failures / caller abort propagate. The caller passes
2145
+ * `isVideo` so the low-level client never imports ergonomic media detection.
2146
+ */
2147
+ async maybeWaitForVideoProbe(fileId, opts) {
2148
+ if (!opts.enabled || !opts.isVideo)
2149
+ return;
2150
+ if (opts.sizeBytes === undefined || opts.sizeBytes <= this.multipartThreshold)
2151
+ return;
2152
+ await this.waitForProbe(fileId, { timeoutMs: opts.timeoutMs, signal: opts.signal });
2153
+ }
1995
2154
  /**
1996
2155
  * Probe N uploaded files in parallel and partition the results by
1997
2156
  * outcome. Returns `{ ok, rejected, errors }` so the caller can
@@ -68,6 +68,7 @@ export interface ResolveCompressOptionsOutput {
68
68
  * @internal — exported for unit tests.
69
69
  */
70
70
  export declare function _parseTargetSize(value: unknown): number;
71
+ export declare const KNOWN_WIRE_FIELDS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
71
72
  /**
72
73
  * Resolve the wire payload + introspection projection for a compress
73
74
  * operation call. Throws {@link GislConfigError} before any network
@@ -300,7 +300,10 @@ function mergeLayer(acc, layer, source) {
300
300
  // ---------------------------------------------------------------------------
301
301
  // Validations on the merged wire payload
302
302
  // ---------------------------------------------------------------------------
303
- const KNOWN_WIRE_FIELDS = Object.freeze({
303
+ // Exported so the wire-key conformance guard (tests/unit/wire-key-conformance.test.ts)
304
+ // can pin this hand-maintained allowlist to the generated contract metadata: every
305
+ // field the resolver may emit MUST be a real contract option key for `compress`.
306
+ export const KNOWN_WIRE_FIELDS = Object.freeze({
304
307
  image: new Set(['mode', 'quality', 'metadata', 'icc_profile', 'progressive', 'output_format']),
305
308
  audio: new Set(['bitrate', 'channels', 'sample_rate', 'normalize', 'trim_start', 'trim_end']),
306
309
  video: new Set(['codec', 'encoding_mode', 'crf', 'target_size_bytes', 'preset', 'width', 'height', 'fit', 'fps', 'faststart', 'audio_codec', 'audio_bitrate', 'trim_start', 'trim_end']),
@@ -396,6 +396,8 @@ export declare class Recipe {
396
396
  onProgress?: (event: ProgressEvent) => void;
397
397
  signal?: AbortSignal;
398
398
  pollIntervalMs?: number;
399
+ probeBeforeCreate?: boolean;
400
+ probeTimeoutMs?: number;
399
401
  }): Promise<RunResult>;
400
402
  /**
401
403
  * Fire-and-forget the recipe: upload the input (when required), create the
@@ -409,8 +411,14 @@ export declare class Recipe {
409
411
  * sent. Mirrors the PHP `Recipe.submit()`.
410
412
  *
411
413
  * @param webhook Absolute callback URL the server POSTs lifecycle events to.
414
+ * @param options Opt-out (`probeBeforeCreate: false`) / tune (`probeTimeoutMs`)
415
+ * the best-effort video probe-before-create. Kept as a 2nd optional param so
416
+ * the existing positional `webhook` arg stays backward compatible.
412
417
  */
413
- submit(webhook?: string): Promise<Handle>;
418
+ submit(webhook?: string, options?: {
419
+ probeBeforeCreate?: boolean;
420
+ probeTimeoutMs?: number;
421
+ }): Promise<Handle>;
414
422
  /**
415
423
  * Resolve the upload id (verbatim for a pre-uploaded id; uploading a path /
416
424
  * blob otherwise, emitting `{phase:'upload'}` progress), check the post-upload
@@ -466,7 +474,7 @@ export declare class FilesRecipe {
466
474
  * lowering builds an internal Recipe that throws the same `GislConfigError`.
467
475
  */
468
476
  compress(optimize?: OptimizeFor, options?: Record<string, unknown>): FilesRecipe;
469
- /** Change every input's format. `format` lowers verbatim to the `format` option. */
477
+ /** Change every input's format. `format` lowers to the contract `output_format` wire key (via {@link Recipe.convert}), NOT `format`. */
470
478
  convert(format: string, options?: Record<string, unknown>): FilesRecipe;
471
479
  /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
472
480
  thumbnail(options?: {
@@ -530,6 +538,8 @@ export declare class FilesRecipe {
530
538
  onProgress?: (event: ProgressEvent) => void;
531
539
  signal?: AbortSignal;
532
540
  pollIntervalMs?: number;
541
+ probeBeforeCreate?: boolean;
542
+ probeTimeoutMs?: number;
533
543
  }): Promise<RunResult>;
534
544
  /**
535
545
  * Fire-and-forget the fan-out: upload every input, create ONE multi-job
@@ -546,8 +556,13 @@ export declare class FilesRecipe {
546
556
  * Mirrors the single-file {@link Recipe.submit}.
547
557
  *
548
558
  * @param webhook Absolute callback URL the server POSTs lifecycle events to.
559
+ * @param options Opt-out / tune the best-effort video probe-before-create
560
+ * (2nd optional param so the positional `webhook` arg stays compatible).
549
561
  */
550
- submit(webhook?: string): Promise<Handle>;
562
+ submit(webhook?: string, options?: {
563
+ probeBeforeCreate?: boolean;
564
+ probeTimeoutMs?: number;
565
+ }): Promise<Handle>;
551
566
  /**
552
567
  * Upload every input (verbatim for a pre-uploaded id; uploading a path /
553
568
  * blob otherwise, emitting `{phase:'upload'}` progress) then create ONE
@@ -633,13 +648,22 @@ export declare class MergedRecipe {
633
648
  onProgress?: (event: ProgressEvent) => void;
634
649
  signal?: AbortSignal;
635
650
  pollIntervalMs?: number;
651
+ probeBeforeCreate?: boolean;
652
+ probeTimeoutMs?: number;
636
653
  }): Promise<RunResult>;
637
654
  /**
638
655
  * Fire-and-forget: upload + create the merge workflow (wiring `webhook` into
639
656
  * `callback_url` when given), return a client-bound {@link Handle}. Does NOT
640
657
  * wait for terminal status. Mirrors {@link Recipe.submit}.
658
+ *
659
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
660
+ * @param options Opt-out / tune the best-effort video probe-before-create
661
+ * (2nd optional param so the positional `webhook` arg stays compatible).
641
662
  */
642
- submit(webhook?: string): Promise<Handle>;
663
+ submit(webhook?: string, options?: {
664
+ probeBeforeCreate?: boolean;
665
+ probeTimeoutMs?: number;
666
+ }): Promise<Handle>;
643
667
  /**
644
668
  * Upload every input (verbatim for a pre-uploaded id; uploading a path / blob
645
669
  * otherwise, emitting `{phase:'upload'}` progress) then create ONE merge
@@ -723,13 +747,22 @@ export declare class ArchivedRecipe {
723
747
  onProgress?: (event: ProgressEvent) => void;
724
748
  signal?: AbortSignal;
725
749
  pollIntervalMs?: number;
750
+ probeBeforeCreate?: boolean;
751
+ probeTimeoutMs?: number;
726
752
  }): Promise<RunResult>;
727
753
  /**
728
754
  * Fire-and-forget: upload + create the archive workflow (wiring `webhook` into
729
755
  * `callback_url` when given), return a client-bound {@link Handle}. Mirrors
730
756
  * {@link MergedRecipe.submit}.
757
+ *
758
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
759
+ * @param options Opt-out / tune the best-effort video probe-before-create
760
+ * (2nd optional param so the positional `webhook` arg stays compatible).
731
761
  */
732
- submit(webhook?: string): Promise<Handle>;
762
+ submit(webhook?: string, options?: {
763
+ probeBeforeCreate?: boolean;
764
+ probeTimeoutMs?: number;
765
+ }): Promise<Handle>;
733
766
  private _uploadAllAndCreate;
734
767
  /**
735
768
  * Reject an invalid bundle BEFORE any upload fires — the archive schema allows
@@ -10,7 +10,7 @@
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
12
  import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
- import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
13
+ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, _cappedProbeTimeoutMs, } from './builder.js';
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
@@ -529,7 +529,7 @@ export class Recipe {
529
529
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
530
530
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
531
531
  // (which passes a webhook → callback_url). run() passes no webhook.
532
- const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal);
532
+ const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
533
533
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
534
534
  // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
535
535
  let finalStatus;
@@ -587,8 +587,11 @@ export class Recipe {
587
587
  * sent. Mirrors the PHP `Recipe.submit()`.
588
588
  *
589
589
  * @param webhook Absolute callback URL the server POSTs lifecycle events to.
590
+ * @param options Opt-out (`probeBeforeCreate: false`) / tune (`probeTimeoutMs`)
591
+ * the best-effort video probe-before-create. Kept as a 2nd optional param so
592
+ * the existing positional `webhook` arg stays backward compatible.
590
593
  */
591
- async submit(webhook) {
594
+ async submit(webhook, options) {
592
595
  if (this.client === undefined) {
593
596
  throw new GislConfigError('Recipe.submit() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
594
597
  }
@@ -597,7 +600,7 @@ export class Recipe {
597
600
  // own request timeout, not an arbitrary submit-side cap. Pass `undefined`
598
601
  // so the post-upload deadline check is skipped: a 300s cap here would throw
599
602
  // on a slow-but-successful big upload before createWorkflow (codex).
600
- const created = await this._uploadAndCreate(webhook, undefined);
603
+ const created = await this._uploadAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
601
604
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
602
605
  }
603
606
  /**
@@ -610,10 +613,13 @@ export class Recipe {
610
613
  * The post-upload deadline check carries a prior codex fix (9a117f04eb59): a
611
614
  * slow upload must not proceed to createWorkflow past the deadline.
612
615
  */
613
- async _uploadAndCreate(webhook, deadline, onProgress, signal) {
616
+ async _uploadAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
614
617
  // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
615
618
  // a path / blob is uploaded now, emitting {phase:'upload'} progress.
616
619
  let fileId;
620
+ // A pre-uploaded id carries no local mime/size, so the video probe-gate is
621
+ // skipped for it (no `up` to read sizeBytes from).
622
+ let uploadSizeBytes;
617
623
  if (this.input.kind === 'uploadId') {
618
624
  fileId = this.input.fileId;
619
625
  }
@@ -630,6 +636,7 @@ export class Recipe {
630
636
  : {}),
631
637
  });
632
638
  fileId = up.fileId;
639
+ uploadSizeBytes = up.sizeBytes;
633
640
  }
634
641
  _checkAborted(signal);
635
642
  // run() passes a whole-run deadline (the codex 9a117f04eb59 fix: a slow
@@ -638,6 +645,35 @@ export class Recipe {
638
645
  if (deadline !== undefined && Date.now() >= deadline) {
639
646
  throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
640
647
  }
648
+ // Best-effort probe-before-create: for a VIDEO upload that went multipart,
649
+ // let the server see the codec + duration before createWorkflow so it
650
+ // admits the ~3× parallel split. Never-bounce — a give-up just proceeds.
651
+ // The probe wait is CAPPED to the remaining maxWait budget so a slow probe
652
+ // cannot push createWorkflow past the caller's deadline (an unset
653
+ // probeTimeoutMs under a deadline becomes the remaining budget, never the
654
+ // 30s default).
655
+ // Skip a pre-uploaded (`uploadId`) input entirely — no local mime/size to
656
+ // gate on (mirrors the multi-input seams, which omit uploadId inputs from
657
+ // their probe targets).
658
+ if (this.input.kind !== 'uploadId') {
659
+ await this.client.maybeWaitForVideoProbe(fileId, {
660
+ enabled: probeBeforeCreate ?? true,
661
+ isVideo: this.compressMediaHint() === 'video',
662
+ sizeBytes: uploadSizeBytes,
663
+ timeoutMs: _cappedProbeTimeoutMs(probeTimeoutMs, deadline),
664
+ signal,
665
+ });
666
+ }
667
+ // A cancel arriving during the FINAL successful probe request must not still
668
+ // create the workflow (maybeWaitForVideoProbe returns landed without a final
669
+ // abort re-check), so check here BEFORE createWorkflow.
670
+ _checkAborted(signal);
671
+ // RE-CHECK the deadline AFTER the probe wait: the wait itself consumes time,
672
+ // so a workflow must not be created past maxWait even when the wait was
673
+ // capped (mirrors the post-upload check above).
674
+ if (deadline !== undefined && Date.now() >= deadline) {
675
+ throw new GislTimeoutError('Probe wait completed but maxWait elapsed before workflow could be created');
676
+ }
641
677
  // 2. Create the workflow from the lowered payload (callback_url built into
642
678
  // the payload at construction when a webhook is given).
643
679
  const payload = this.toWorkflowPayload(fileId, webhook);
@@ -785,7 +821,7 @@ export class FilesRecipe {
785
821
  compress(optimize, options = {}) {
786
822
  return this.withStep(this.baseRecipe().compress(optimize, options));
787
823
  }
788
- /** Change every input's format. `format` lowers verbatim to the `format` option. */
824
+ /** Change every input's format. `format` lowers to the contract `output_format` wire key (via {@link Recipe.convert}), NOT `format`. */
789
825
  convert(format, options = {}) {
790
826
  return this.withStep(this.baseRecipe().convert(format, options));
791
827
  }
@@ -884,7 +920,7 @@ export class FilesRecipe {
884
920
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
885
921
  // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
886
922
  // submit() (which passes a webhook → callback_url and no deadline).
887
- const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
923
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
888
924
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
889
925
  // `partially_failed` is a normal terminal state here (the helper treats it
890
926
  // as terminal); only caller-aborted / deadline / API errors propagate.
@@ -941,12 +977,14 @@ export class FilesRecipe {
941
977
  * Mirrors the single-file {@link Recipe.submit}.
942
978
  *
943
979
  * @param webhook Absolute callback URL the server POSTs lifecycle events to.
980
+ * @param options Opt-out / tune the best-effort video probe-before-create
981
+ * (2nd optional param so the positional `webhook` arg stays compatible).
944
982
  */
945
- async submit(webhook) {
983
+ async submit(webhook, options) {
946
984
  if (this.client === undefined) {
947
985
  throw new GislConfigError('FilesRecipe.submit() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
948
986
  }
949
- const created = await this._uploadAllAndCreate(webhook, undefined);
987
+ const created = await this._uploadAllAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
950
988
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
951
989
  }
952
990
  /**
@@ -961,8 +999,11 @@ export class FilesRecipe {
961
999
  * createWorkflow past maxWait); `submit()` passes `undefined`, so the
962
1000
  * deadline checks are skipped.
963
1001
  */
964
- async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1002
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
965
1003
  const fileIds = [];
1004
+ // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1005
+ // carries no local mime/size, so it is excluded — never probed).
1006
+ const probeTargets = [];
966
1007
  for (const input of this.inputs) {
967
1008
  // Fail fast between uploads — a deadline that elapses mid-batch should
968
1009
  // not force every remaining input to upload before throwing.
@@ -986,12 +1027,39 @@ export class FilesRecipe {
986
1027
  : {}),
987
1028
  });
988
1029
  fileIds.push(up.fileId);
1030
+ probeTargets.push({
1031
+ fileId: up.fileId,
1032
+ isVideo: _detectCompressMedia(source) === 'video',
1033
+ sizeBytes: up.sizeBytes,
1034
+ });
989
1035
  }
990
1036
  }
991
1037
  _checkAborted(signal);
992
1038
  if (deadline !== undefined && Date.now() >= deadline) {
993
1039
  throw new GislTimeoutError('Uploads completed but maxWait elapsed before workflow could be created');
994
1040
  }
1041
+ // Best-effort probe-before-create for the multipart-video inputs. Run the
1042
+ // waits CONCURRENTLY (Promise.all): each is bounded by the SAME capped
1043
+ // timeout, so the aggregate wall-clock stays ~timeout rather than N×timeout.
1044
+ // The cap is the remaining maxWait budget so the waits cannot push
1045
+ // createWorkflow past the caller's deadline. Never-bounce, so a give-up
1046
+ // just proceeds.
1047
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1048
+ await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1049
+ enabled: probeBeforeCreate ?? true,
1050
+ isVideo: t.isVideo,
1051
+ sizeBytes: t.sizeBytes,
1052
+ timeoutMs: cappedProbeTimeoutMs,
1053
+ signal,
1054
+ })));
1055
+ // A cancel arriving during a FINAL successful probe request must not still
1056
+ // create the workflow (the probe waits return landed without a final abort
1057
+ // re-check), so check here BEFORE createWorkflow.
1058
+ _checkAborted(signal);
1059
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
1060
+ if (deadline !== undefined && Date.now() >= deadline) {
1061
+ throw new GislTimeoutError('Probe wait completed but maxWait elapsed before workflow could be created');
1062
+ }
995
1063
  const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
996
1064
  _checkAborted(signal);
997
1065
  return created;
@@ -1129,7 +1197,7 @@ export class MergedRecipe {
1129
1197
  throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1130
1198
  }
1131
1199
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1132
- const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1200
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1133
1201
  let finalStatus;
1134
1202
  try {
1135
1203
  finalStatus = await _consumeSseToTerminal(this.client, {
@@ -1170,12 +1238,16 @@ export class MergedRecipe {
1170
1238
  * Fire-and-forget: upload + create the merge workflow (wiring `webhook` into
1171
1239
  * `callback_url` when given), return a client-bound {@link Handle}. Does NOT
1172
1240
  * wait for terminal status. Mirrors {@link Recipe.submit}.
1241
+ *
1242
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
1243
+ * @param options Opt-out / tune the best-effort video probe-before-create
1244
+ * (2nd optional param so the positional `webhook` arg stays compatible).
1173
1245
  */
1174
- async submit(webhook) {
1246
+ async submit(webhook, options) {
1175
1247
  if (this.client === undefined) {
1176
1248
  throw new GislConfigError('MergedRecipe.submit() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1177
1249
  }
1178
- const created = await this._uploadAllAndCreate(webhook, undefined);
1250
+ const created = await this._uploadAllAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
1179
1251
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1180
1252
  }
1181
1253
  // ---------------------------------------------------------------------------
@@ -1185,9 +1257,13 @@ export class MergedRecipe {
1185
1257
  * workflow. Rejects fewer than 2 inputs BEFORE any upload fires. Shared first
1186
1258
  * half of {@link run} + {@link submit}.
1187
1259
  */
1188
- async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1260
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1189
1261
  this.validatePreUpload();
1190
1262
  const fileIds = [];
1263
+ // Per-input video detection: merge's inferMediaKind decides the OUTPUT
1264
+ // media, not each input's, so detect per input via _detectCompressMedia.
1265
+ // A pre-uploaded id carries no local mime/size, so it is never probed.
1266
+ const probeTargets = [];
1191
1267
  for (const input of this.inputs) {
1192
1268
  _checkAborted(signal);
1193
1269
  if (deadline !== undefined && Date.now() >= deadline) {
@@ -1209,12 +1285,36 @@ export class MergedRecipe {
1209
1285
  : {}),
1210
1286
  });
1211
1287
  fileIds.push(up.fileId);
1288
+ probeTargets.push({
1289
+ fileId: up.fileId,
1290
+ isVideo: _detectCompressMedia(source) === 'video',
1291
+ sizeBytes: up.sizeBytes,
1292
+ });
1212
1293
  }
1213
1294
  }
1214
1295
  _checkAborted(signal);
1215
1296
  if (deadline !== undefined && Date.now() >= deadline) {
1216
1297
  throw new GislTimeoutError('Uploads completed but maxWait elapsed before the merge workflow could be created');
1217
1298
  }
1299
+ // Best-effort, concurrent probe-before-create for the multipart-video
1300
+ // inputs (never-bounce; each capped to the remaining maxWait budget so the
1301
+ // waits cannot push createWorkflow past the caller's deadline).
1302
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1303
+ await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1304
+ enabled: probeBeforeCreate ?? true,
1305
+ isVideo: t.isVideo,
1306
+ sizeBytes: t.sizeBytes,
1307
+ timeoutMs: cappedProbeTimeoutMs,
1308
+ signal,
1309
+ })));
1310
+ // A cancel arriving during a FINAL successful probe request must not still
1311
+ // create the workflow (the probe waits return landed without a final abort
1312
+ // re-check), so check here BEFORE createWorkflow.
1313
+ _checkAborted(signal);
1314
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
1315
+ if (deadline !== undefined && Date.now() >= deadline) {
1316
+ throw new GislTimeoutError('Probe wait completed but maxWait elapsed before the merge workflow could be created');
1317
+ }
1218
1318
  const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1219
1319
  _checkAborted(signal);
1220
1320
  return created;
@@ -1369,7 +1469,7 @@ export class ArchivedRecipe {
1369
1469
  throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1370
1470
  }
1371
1471
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1372
- const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1472
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1373
1473
  let finalStatus;
1374
1474
  try {
1375
1475
  finalStatus = await _consumeSseToTerminal(this.client, {
@@ -1409,18 +1509,26 @@ export class ArchivedRecipe {
1409
1509
  * Fire-and-forget: upload + create the archive workflow (wiring `webhook` into
1410
1510
  * `callback_url` when given), return a client-bound {@link Handle}. Mirrors
1411
1511
  * {@link MergedRecipe.submit}.
1512
+ *
1513
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
1514
+ * @param options Opt-out / tune the best-effort video probe-before-create
1515
+ * (2nd optional param so the positional `webhook` arg stays compatible).
1412
1516
  */
1413
- async submit(webhook) {
1517
+ async submit(webhook, options) {
1414
1518
  if (this.client === undefined) {
1415
1519
  throw new GislConfigError('ArchivedRecipe.submit() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1416
1520
  }
1417
- const created = await this._uploadAllAndCreate(webhook, undefined);
1521
+ const created = await this._uploadAllAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
1418
1522
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1419
1523
  }
1420
1524
  // ---------------------------------------------------------------------------
1421
- async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1525
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1422
1526
  this.validatePreUpload();
1423
1527
  const fileIds = [];
1528
+ // Archive is media-agnostic (no inference) — detect per input via
1529
+ // _detectCompressMedia so only video uploads are probed. A pre-uploaded id
1530
+ // carries no local mime/size, so it is never probed.
1531
+ const probeTargets = [];
1424
1532
  for (const input of this.inputs) {
1425
1533
  _checkAborted(signal);
1426
1534
  if (deadline !== undefined && Date.now() >= deadline) {
@@ -1442,12 +1550,36 @@ export class ArchivedRecipe {
1442
1550
  : {}),
1443
1551
  });
1444
1552
  fileIds.push(up.fileId);
1553
+ probeTargets.push({
1554
+ fileId: up.fileId,
1555
+ isVideo: _detectCompressMedia(source) === 'video',
1556
+ sizeBytes: up.sizeBytes,
1557
+ });
1445
1558
  }
1446
1559
  }
1447
1560
  _checkAborted(signal);
1448
1561
  if (deadline !== undefined && Date.now() >= deadline) {
1449
1562
  throw new GislTimeoutError('Uploads completed but maxWait elapsed before the archive workflow could be created');
1450
1563
  }
1564
+ // Best-effort, concurrent probe-before-create for the multipart-video
1565
+ // inputs (never-bounce; each capped to the remaining maxWait budget so the
1566
+ // waits cannot push createWorkflow past the caller's deadline).
1567
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1568
+ await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1569
+ enabled: probeBeforeCreate ?? true,
1570
+ isVideo: t.isVideo,
1571
+ sizeBytes: t.sizeBytes,
1572
+ timeoutMs: cappedProbeTimeoutMs,
1573
+ signal,
1574
+ })));
1575
+ // A cancel arriving during a FINAL successful probe request must not still
1576
+ // create the workflow (the probe waits return landed without a final abort
1577
+ // re-check), so check here BEFORE createWorkflow.
1578
+ _checkAborted(signal);
1579
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
1580
+ if (deadline !== undefined && Date.now() >= deadline) {
1581
+ throw new GislTimeoutError('Probe wait completed but maxWait elapsed before the archive workflow could be created');
1582
+ }
1451
1583
  const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1452
1584
  _checkAborted(signal);
1453
1585
  return created;
@@ -1,6 +1,6 @@
1
1
  export { GislClient, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE } from './client.js';
2
2
  export { parseSseStream } from './sse.js';
3
- export type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, PreflightClipError, PreflightClipsResult, GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, MultiInputSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
3
+ export type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, PreflightClipError, PreflightClipsResult, ProbeWaitOptions, ProbeWaitResult, GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, MultiInputSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
4
4
  export { uploadSource, jobOutputSource, externalImportSource, connectionSource, } from './types.js';
5
5
  export type { GislConfigErrorMetadata } from './errors.js';
6
6
  export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislResultNotReadyError, } from './errors.js';
package/dist/merge.d.ts CHANGED
@@ -137,6 +137,15 @@ export declare class MergeBuilder {
137
137
  private planSequence;
138
138
  private inferMediaKind;
139
139
  private uploadUniqueAssets;
140
+ /**
141
+ * Best-effort, concurrent probe-before-create for the multipart-video
142
+ * inputs (never-bounce; each bounded by the SAME capped timeout, so the
143
+ * aggregate wall-clock stays ~timeout rather than N×timeout). When `deadline`
144
+ * is set (the `run()` path) the timeout is capped to the remaining maxWait
145
+ * budget so the waits cannot push createWorkflow past the caller's deadline;
146
+ * `submit()` passes `undefined` (fire-and-forget, no cap).
147
+ */
148
+ private waitForVideoProbes;
140
149
  private buildPayload;
141
150
  private opOptionsForResolved;
142
151
  private awaitTerminal;
package/dist/merge.js CHANGED
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { uploadSource, jobOutputSource } from './types.js';
29
29
  import { GislConfigError, GislNetworkError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, SseEndedWithoutTerminal, } from './errors.js';
30
- import { _checkAborted, _consumeSseToTerminal, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
30
+ import { _cappedProbeTimeoutMs, _checkAborted, _consumeSseToTerminal, _detectCompressMedia, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
31
31
  import { Handle } from './handle.js';
32
32
  /**
33
33
  * Construct a path-asset. Bare-string arguments to `merge(...)` are
@@ -91,15 +91,28 @@ export class MergeBuilder {
91
91
  const plan = this.planSequence();
92
92
  // 2. Upload each unique asset exactly ONCE. Pass the deadline so the
93
93
  // upload loop can abort mid-batch on a slow connection.
94
+ const probeTargets = [];
94
95
  const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {
95
96
  signal,
96
97
  onProgress,
97
98
  deadline,
99
+ probeTargets,
98
100
  });
99
101
  _checkAborted(signal);
100
102
  if (Date.now() >= deadline) {
101
103
  throw new GislTimeoutError(`Upload(s) completed but maxWait elapsed before merge workflow could be created`);
102
104
  }
105
+ // Best-effort probe-before-create for the multipart-video inputs (capped to
106
+ // the remaining maxWait budget).
107
+ await this.waitForVideoProbes(probeTargets, options.probeBeforeCreate, options.probeTimeoutMs, signal, deadline);
108
+ // A cancel arriving during a FINAL successful probe request must not still
109
+ // create the workflow (the probe waits return landed without a final abort
110
+ // re-check), so check here BEFORE createWorkflow.
111
+ _checkAborted(signal);
112
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
113
+ if (Date.now() >= deadline) {
114
+ throw new GislTimeoutError(`Probe wait completed but maxWait elapsed before merge workflow could be created`);
115
+ }
103
116
  // 3. Build the merge JobDefinitionPayload (multi-input).
104
117
  const payload = this.buildPayload(plan, uploadedByAssetId);
105
118
  const created = await this.client.createWorkflow(payload);
@@ -135,7 +148,11 @@ export class MergeBuilder {
135
148
  }
136
149
  async submit(options) {
137
150
  const plan = this.planSequence();
138
- const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {});
151
+ const probeTargets = [];
152
+ const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, { probeTargets });
153
+ // Best-effort probe-before-create for the multipart-video inputs.
154
+ // Fire-and-forget — no deadline, so no cap (mirrors Recipe::submit()).
155
+ await this.waitForVideoProbes(probeTargets, options.probeBeforeCreate, options.probeTimeoutMs, undefined, undefined);
139
156
  const payload = this.buildPayload(plan, uploadedByAssetId);
140
157
  payload.callback_url = options.webhook;
141
158
  const created = await this.client.createWorkflow(payload);
@@ -281,9 +298,32 @@ export class MergeBuilder {
281
298
  }
282
299
  const resp = await this.client.uploadFile(a.path, uploadOpts);
283
300
  uploaded.set(id, resp.fileId);
301
+ opts.probeTargets?.push({
302
+ fileId: resp.fileId,
303
+ isVideo: _detectCompressMedia(a.path) === 'video',
304
+ sizeBytes: resp.sizeBytes,
305
+ });
284
306
  }
285
307
  return uploaded;
286
308
  }
309
+ /**
310
+ * Best-effort, concurrent probe-before-create for the multipart-video
311
+ * inputs (never-bounce; each bounded by the SAME capped timeout, so the
312
+ * aggregate wall-clock stays ~timeout rather than N×timeout). When `deadline`
313
+ * is set (the `run()` path) the timeout is capped to the remaining maxWait
314
+ * budget so the waits cannot push createWorkflow past the caller's deadline;
315
+ * `submit()` passes `undefined` (fire-and-forget, no cap).
316
+ */
317
+ async waitForVideoProbes(probeTargets, probeBeforeCreate, probeTimeoutMs, signal, deadline) {
318
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
319
+ await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
320
+ enabled: probeBeforeCreate ?? true,
321
+ isVideo: t.isVideo,
322
+ sizeBytes: t.sizeBytes,
323
+ timeoutMs: cappedProbeTimeoutMs,
324
+ signal,
325
+ })));
326
+ }
287
327
  buildPayload(plan, uploadedByAssetId) {
288
328
  // p0SuJEeK — the API rejects upload-direct multi-input
289
329
  // (`MultiInputSource` excludes the `upload` leaf: "use type=job_output").
package/dist/types.d.ts CHANGED
@@ -287,6 +287,43 @@ export interface WaitOptions {
287
287
  /** Called after each poll with current status */
288
288
  onPoll?: (status: string) => void;
289
289
  }
290
+ export interface ProbeWaitOptions {
291
+ /**
292
+ * Overall wall-clock bound for the poll loop in ms (default: 30000). On
293
+ * elapse the loop gives up and resolves `{ landed: false, reason: 'timeout' }`
294
+ * — it never throws for a slow probe (the caller then creates anyway).
295
+ */
296
+ timeoutMs?: number;
297
+ /** Abort the wait early; aborting rejects the promise with `GislAbortError`. */
298
+ signal?: AbortSignal;
299
+ /**
300
+ * Fires once per poll attempt — drive an "analysing video…" UI in the gap
301
+ * between upload-complete and workflow-create. `attempt` is 1-based;
302
+ * `elapsedMs` is wall-clock since the wait started.
303
+ */
304
+ onPoll?: (info: {
305
+ attempt: number;
306
+ elapsedMs: number;
307
+ }) => void;
308
+ }
309
+ export interface ProbeWaitResult {
310
+ /**
311
+ * True iff the probe landed — i.e. `POST /api/uploads/{id}/probe` returned
312
+ * 200 (ANY `probeStatus`: ok / corrupt / unsupported_codec / missing_metadata).
313
+ * The caller proceeds to create the workflow either way; a landed probe lets
314
+ * the server admit the parallel video split.
315
+ */
316
+ landed: boolean;
317
+ /** The landed probe response — present iff `landed`. */
318
+ probe?: UploadProbeResponse;
319
+ /**
320
+ * Why the wait gave up WITHOUT a landed probe — present iff `!landed`.
321
+ * `timeout` = the bound elapsed; `prober_error` = repeated 5xx from the
322
+ * prober. In both cases the caller should create anyway (never-bounce); the
323
+ * server's size heuristic routes the job (single-task worst case).
324
+ */
325
+ reason?: 'timeout' | 'prober_error';
326
+ }
290
327
  export type GislSseEvent = {
291
328
  event: typeof SseEventType.operation_progress;
292
329
  data: SseOperationProgressData;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "node": ">=18"
32
32
  },
33
33
  "dependencies": {
34
- "@giveitsmaller/contracts": "^0.17.0"
34
+ "@giveitsmaller/contracts": "^0.18.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22",