@giveitsmaller/sdk 0.19.0 → 0.20.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.
@@ -159,9 +159,10 @@ export class RunResult {
159
159
  toJSON() {
160
160
  // Re-project each OutputFile to exactly its known fields so structurally
161
161
  // compatible inputs carrying extra properties can't leak into the JSON.
162
- // The target-size fields (chosenQuality/targetSizeMet) are OMITTED when
163
- // undefined, mirroring PHP's omit-when-null so non-target-size outputs
164
- // stay byte-identical across languages.
162
+ // The projected optional fields (chosenQuality/targetSizeMet and the
163
+ // auto_quality measuredQuality/qualityMetric) are OMITTED when undefined,
164
+ // mirroring PHP's omit-when-null so outputs lacking them stay
165
+ // byte-identical across languages.
165
166
  const file = (o) => ({
166
167
  url: o.url,
167
168
  filename: o.filename,
@@ -169,6 +170,8 @@ export class RunResult {
169
170
  operation: o.operation,
170
171
  ...(o.chosenQuality !== undefined ? { chosenQuality: o.chosenQuality } : {}),
171
172
  ...(o.targetSizeMet !== undefined ? { targetSizeMet: o.targetSizeMet } : {}),
173
+ ...(o.measuredQuality !== undefined ? { measuredQuality: o.measuredQuality } : {}),
174
+ ...(o.qualityMetric !== undefined ? { qualityMetric: o.qualityMetric } : {}),
172
175
  });
173
176
  const rest = {
174
177
  artifacts: this.artifacts.map(file),
@@ -230,6 +233,8 @@ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownload
230
233
  // non-target-size output carries no chosenQuality/targetSizeMet key.
231
234
  ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
232
235
  ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
236
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
237
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
233
238
  });
234
239
  }
235
240
  }
@@ -293,6 +298,8 @@ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads
293
298
  // single-job projector).
294
299
  ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
295
300
  ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
301
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
302
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
296
303
  }));
297
304
  // The flat artifacts[] keeps every job's outputs in job order.
298
305
  artifacts.push(...outputs);
@@ -438,6 +445,48 @@ export const fileInput = {
438
445
  return { kind: 'uploadId', fileId };
439
446
  },
440
447
  };
448
+ /**
449
+ * Await a workflow to a terminal status — SSE first with a poll fallback, or
450
+ * poll-direct when `useSSE` is false. The single shared implementation behind
451
+ * every file-first `run()` (Recipe, FilesRecipe, MergedRecipe, ArchivedRecipe,
452
+ * WatermarkedRecipe), mirroring the operation-first
453
+ * `OperationBuilder.awaitTerminal` (in `builder.ts`). Callers pass
454
+ * `useSSE: options.useSSE ?? true` so the default stays SSE-first (today's
455
+ * behaviour); `useSSE: false` skips the SSE attempt entirely and polls —
456
+ * useful when an intermediary proxy blocks SSE.
457
+ *
458
+ * @internal Not part of the caller-facing fluent surface.
459
+ */
460
+ async function _awaitTerminal(client, args) {
461
+ if (args.useSSE) {
462
+ try {
463
+ return await _consumeSseToTerminal(client, {
464
+ workflowId: args.workflowId,
465
+ deadline: args.deadline,
466
+ signal: args.signal,
467
+ onProgress: args.onProgress,
468
+ });
469
+ }
470
+ catch (err) {
471
+ // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
472
+ // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
473
+ // Everything else — caller-deadline, abort, an API error from /events, an
474
+ // onProgress callback throw (propagates as-is, NOT wrapped), anything
475
+ // unexpected — MUST propagate; re-issuing the same doomed request via poll
476
+ // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
477
+ // marker discipline.
478
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
479
+ throw err;
480
+ }
481
+ }
482
+ }
483
+ return await _pollToTerminal(client, {
484
+ workflowId: args.workflowId,
485
+ deadline: args.deadline,
486
+ signal: args.signal,
487
+ pollIntervalMs: args.pollIntervalMs,
488
+ });
489
+ }
441
490
  /**
442
491
  * The file-first builder value. `client.file(path)` returns a `Recipe`;
443
492
  * single-input operations called on it (`compress`, `convert`, `thumbnail`,
@@ -522,6 +571,27 @@ export class Recipe {
522
571
  }
523
572
  return this.withStep({ opType: 'thumbnail', options: wire });
524
573
  }
574
+ /**
575
+ * Geometric transform: rotate (0/90/180/270°) and/or flip. Chainable — the
576
+ * canonical single-job order is `transform → convert → compress → thumbnail`,
577
+ * so downstream size options refer to the final (post-transform) frame.
578
+ *
579
+ * Passthrough: `rotate`/`flip` are forwarded as-is; the SDK does NOT narrow
580
+ * per media (a `flip` on a PDF input passes SDK validation but the server
581
+ * rejects it — documents rotate only). The transform op is `availability:
582
+ * planned` today, so workflow-create returns `feature_not_available` (422)
583
+ * until the per-media Lambdas ship. A no-op (`rotate:0` + `flip:none`) is
584
+ * rejected server-side as `invalid_options`.
585
+ */
586
+ transform(options = {}) {
587
+ validateVerbOptions('transform', options);
588
+ const wire = {};
589
+ for (const [key, value] of Object.entries(options)) {
590
+ if (value !== undefined)
591
+ wire[key] = value;
592
+ }
593
+ return this.withStep({ opType: 'transform', options: wire });
594
+ }
525
595
  /**
526
596
  * Produce ONE transformed image: keep or change format, plus quality, resize
527
597
  * and route-honored controls. The single user-facing image transform — the SDK
@@ -591,7 +661,8 @@ export class Recipe {
591
661
  * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
592
662
  * throw locally BEFORE any upload (the planned-op gate). `options` carries the
593
663
  * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
594
- * `overlay_width`). Returns a {@link WatermarkedRecipe} (chain post-watermark
664
+ * `overlay_width`, or `overlays[]` for the multi-overlay stack). Returns a
665
+ * {@link WatermarkedRecipe} (chain post-watermark
595
666
  * `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
596
667
  * {@link textWatermark} (single-input text overlay).
597
668
  */
@@ -674,35 +745,17 @@ export class Recipe {
674
745
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
675
746
  // (which passes a webhook → callback_url). run() passes no webhook.
676
747
  const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
677
- // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
678
- // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
679
- let finalStatus;
680
- try {
681
- finalStatus = await _consumeSseToTerminal(this.client, {
682
- workflowId: created.workflowId,
683
- deadline,
684
- signal,
685
- onProgress,
686
- });
687
- }
688
- catch (err) {
689
- // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
690
- // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
691
- // Everything else — caller-deadline, abort, an API error from /events, an
692
- // onProgress callback throw (propagates as-is, NOT wrapped), anything
693
- // unexpected — MUST propagate; re-issuing the same doomed request via poll
694
- // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
695
- // marker discipline.
696
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
697
- throw err;
698
- }
699
- finalStatus = await _pollToTerminal(this.client, {
700
- workflowId: created.workflowId,
701
- deadline,
702
- signal,
703
- pollIntervalMs: options.pollIntervalMs,
704
- });
705
- }
748
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error
749
+ // (or poll-direct when `useSSE: false`). Caller-aborted + deadline-elapsed
750
+ // errors MUST propagate (not transient) — see _awaitTerminal.
751
+ const finalStatus = await _awaitTerminal(this.client, {
752
+ workflowId: created.workflowId,
753
+ deadline,
754
+ signal,
755
+ onProgress,
756
+ pollIntervalMs: options.pollIntervalMs,
757
+ useSSE: options.useSSE ?? true,
758
+ });
706
759
  // 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
707
760
  // downloads, so check before issuing the request (mirrors builder.ts).
708
761
  if (Date.now() >= deadline) {
@@ -1221,8 +1274,9 @@ function _validateWatermarkOverlay(overlay) {
1221
1274
  }
1222
1275
  }
1223
1276
  function _lowerWatermarkOp(wireOp, options) {
1224
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width) are
1225
- // already wire keys; empty options omit the `options` key (byte-identical to PHP).
1277
+ // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
1278
+ // multi-overlay overlays[] stack) are already wire keys; empty options omit
1279
+ // the `options` key (byte-identical to PHP).
1226
1280
  const wire = { ...options };
1227
1281
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1228
1282
  }
@@ -1361,6 +1415,10 @@ export class FilesRecipe {
1361
1415
  thumbnail(options) {
1362
1416
  return this.withStep(this.baseRecipe().thumbnail(options));
1363
1417
  }
1418
+ /** Apply the same geometric transform (rotate/flip) to every input. Validated via the base {@link Recipe}. */
1419
+ transform(options = {}) {
1420
+ return this.withStep(this.baseRecipe().transform(options));
1421
+ }
1364
1422
  /** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
1365
1423
  textWatermark(text, options = {}) {
1366
1424
  return this.withStep(this.baseRecipe().textWatermark(text, options));
@@ -1456,26 +1514,14 @@ export class FilesRecipe {
1456
1514
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
1457
1515
  // `partially_failed` is a normal terminal state here (the helper treats it
1458
1516
  // as terminal); only caller-aborted / deadline / API errors propagate.
1459
- let finalStatus;
1460
- try {
1461
- finalStatus = await _consumeSseToTerminal(this.client, {
1462
- workflowId: created.workflowId,
1463
- deadline,
1464
- signal,
1465
- onProgress,
1466
- });
1467
- }
1468
- catch (err) {
1469
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1470
- throw err;
1471
- }
1472
- finalStatus = await _pollToTerminal(this.client, {
1473
- workflowId: created.workflowId,
1474
- deadline,
1475
- signal,
1476
- pollIntervalMs: options.pollIntervalMs,
1477
- });
1478
- }
1517
+ const finalStatus = await _awaitTerminal(this.client, {
1518
+ workflowId: created.workflowId,
1519
+ deadline,
1520
+ signal,
1521
+ onProgress,
1522
+ pollIntervalMs: options.pollIntervalMs,
1523
+ useSSE: options.useSSE ?? true,
1524
+ });
1479
1525
  // 4. Fetch downloads + project per-job into the partitioned RunResult.
1480
1526
  if (Date.now() >= deadline) {
1481
1527
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
@@ -1621,6 +1667,16 @@ export class MergedRecipe {
1621
1667
  }
1622
1668
  return this.withStep({ opType: 'thumbnail', options: wire });
1623
1669
  }
1670
+ /** Geometric transform (rotate/flip) of the merged output. Passthrough; see {@link Recipe.transform}. */
1671
+ transform(options = {}) {
1672
+ validateVerbOptions('transform', options);
1673
+ const wire = {};
1674
+ for (const [key, value] of Object.entries(options)) {
1675
+ if (value !== undefined)
1676
+ wire[key] = value;
1677
+ }
1678
+ return this.withStep({ opType: 'transform', options: wire });
1679
+ }
1624
1680
  /**
1625
1681
  * Lower to the merge DAG: one `passthrough` source job per input + one
1626
1682
  * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
@@ -1674,26 +1730,14 @@ export class MergedRecipe {
1674
1730
  }
1675
1731
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1676
1732
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1677
- let finalStatus;
1678
- try {
1679
- finalStatus = await _consumeSseToTerminal(this.client, {
1680
- workflowId: created.workflowId,
1681
- deadline,
1682
- signal,
1683
- onProgress,
1684
- });
1685
- }
1686
- catch (err) {
1687
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1688
- throw err;
1689
- }
1690
- finalStatus = await _pollToTerminal(this.client, {
1691
- workflowId: created.workflowId,
1692
- deadline,
1693
- signal,
1694
- pollIntervalMs: options.pollIntervalMs,
1695
- });
1696
- }
1733
+ const finalStatus = await _awaitTerminal(this.client, {
1734
+ workflowId: created.workflowId,
1735
+ deadline,
1736
+ signal,
1737
+ onProgress,
1738
+ pollIntervalMs: options.pollIntervalMs,
1739
+ useSSE: options.useSSE ?? true,
1740
+ });
1697
1741
  if (Date.now() >= deadline) {
1698
1742
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1699
1743
  }
@@ -1897,26 +1941,14 @@ export class ArchivedRecipe {
1897
1941
  }
1898
1942
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1899
1943
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1900
- let finalStatus;
1901
- try {
1902
- finalStatus = await _consumeSseToTerminal(this.client, {
1903
- workflowId: created.workflowId,
1904
- deadline,
1905
- signal,
1906
- onProgress,
1907
- });
1908
- }
1909
- catch (err) {
1910
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1911
- throw err;
1912
- }
1913
- finalStatus = await _pollToTerminal(this.client, {
1914
- workflowId: created.workflowId,
1915
- deadline,
1916
- signal,
1917
- pollIntervalMs: options.pollIntervalMs,
1918
- });
1919
- }
1944
+ const finalStatus = await _awaitTerminal(this.client, {
1945
+ workflowId: created.workflowId,
1946
+ deadline,
1947
+ signal,
1948
+ onProgress,
1949
+ pollIntervalMs: options.pollIntervalMs,
1950
+ useSSE: options.useSSE ?? true,
1951
+ });
1920
1952
  if (Date.now() >= deadline) {
1921
1953
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1922
1954
  }
@@ -2046,6 +2078,16 @@ export class WatermarkedRecipe {
2046
2078
  }
2047
2079
  return this.withStep({ opType: 'thumbnail', options: wire });
2048
2080
  }
2081
+ /** Geometric transform (rotate/flip) of the watermarked output. Passthrough; see {@link Recipe.transform}. */
2082
+ transform(options = {}) {
2083
+ validateVerbOptions('transform', options);
2084
+ const wire = {};
2085
+ for (const [key, value] of Object.entries(options)) {
2086
+ if (value !== undefined)
2087
+ wire[key] = value;
2088
+ }
2089
+ return this.withStep({ opType: 'transform', options: wire });
2090
+ }
2049
2091
  /**
2050
2092
  * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2051
2093
  * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
@@ -2102,26 +2144,14 @@ export class WatermarkedRecipe {
2102
2144
  }
2103
2145
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2104
2146
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2105
- let finalStatus;
2106
- try {
2107
- finalStatus = await _consumeSseToTerminal(this.client, {
2108
- workflowId: created.workflowId,
2109
- deadline,
2110
- signal,
2111
- onProgress,
2112
- });
2113
- }
2114
- catch (err) {
2115
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
2116
- throw err;
2117
- }
2118
- finalStatus = await _pollToTerminal(this.client, {
2119
- workflowId: created.workflowId,
2120
- deadline,
2121
- signal,
2122
- pollIntervalMs: options.pollIntervalMs,
2123
- });
2124
- }
2147
+ const finalStatus = await _awaitTerminal(this.client, {
2148
+ workflowId: created.workflowId,
2149
+ deadline,
2150
+ signal,
2151
+ onProgress,
2152
+ pollIntervalMs: options.pollIntervalMs,
2153
+ useSSE: options.useSSE ?? true,
2154
+ });
2125
2155
  if (Date.now() >= deadline) {
2126
2156
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2127
2157
  }
@@ -2200,3 +2230,275 @@ export class WatermarkedRecipe {
2200
2230
  return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
2201
2231
  }
2202
2232
  }
2233
+ /**
2234
+ * Identity key for batch cross-entry upload dedupe — mirrors the merge
2235
+ * builder's `assetIdentity` (`merge.ts`). Two batch entries whose inputs share
2236
+ * an identity upload ONCE and point both jobs at the shared fileId. `path` uses
2237
+ * the EXACT caller-provided string (no trim / normalise / case-fold, so
2238
+ * `'./a.jpg'` and `'/abs/a.jpg'` do NOT dedupe — by design); `blob` uses
2239
+ * referential identity via a run-local token map (two distinct-but-equal Blobs
2240
+ * still upload twice); `uploadId` uses the fileId itself (already upload-free,
2241
+ * so deduping it is a pure no-op). `blobTokens` is threaded in so a single
2242
+ * planning pass shares one token space.
2243
+ */
2244
+ function inputIdentity(input, blobTokens) {
2245
+ switch (input.kind) {
2246
+ case 'path':
2247
+ return `path:${input.path}`;
2248
+ case 'uploadId':
2249
+ return `id:${input.fileId}`;
2250
+ case 'blob': {
2251
+ let token = blobTokens.get(input.blob);
2252
+ if (token === undefined) {
2253
+ token = blobTokens.size;
2254
+ blobTokens.set(input.blob, token);
2255
+ }
2256
+ return `blob:${token}`;
2257
+ }
2258
+ }
2259
+ }
2260
+ /**
2261
+ * The keyed multi-recipe batch builder (FF7 / MFaCjL8d). `client.batch([r1, r2, …])`
2262
+ * runs N DISTINCT single-input keyed {@link Recipe}s as ONE workflow; the
2263
+ * partitioned {@link RunResult} addresses each entry's outputs by the caller key
2264
+ * given at `client.file(input, key)` time (`res.byKey('hero')`), and one failed
2265
+ * entry lands in `failed` without sinking the rest.
2266
+ *
2267
+ * **v1 scope (locked):** `.run()` only (no `submit()` / reattach — a follow-up);
2268
+ * single-input {@link Recipe} entries only — the multi-input builders
2269
+ * ({@link FilesRecipe}, {@link MergedRecipe}, {@link WatermarkedRecipe},
2270
+ * {@link ArchivedRecipe}) are REJECTED pre-upload. Cross-entry upload dedupe
2271
+ * IS applied (1LwSJcz1): two entries sourcing the SAME input (by
2272
+ * {@link inputIdentity}) upload ONCE and share the resulting fileId —
2273
+ * correctness-neutral (same bytes → same per-job output), it only elides
2274
+ * redundant uploads. Observable caveat: `onProgress` upload-phase events drop
2275
+ * to one-per-UNIQUE input rather than one-per-entry.
2276
+ *
2277
+ * **Lowering (one workflow):** for each entry `i`, lower its single job via
2278
+ * {@link Recipe.toWorkflowPayload} and re-id it `b{i}` — a POSITIONAL namespace
2279
+ * DISTINCT from the fan-out `file-{i}` / merge-archive-watermark `src_{i}` refs so
2280
+ * a future reattach can't misdetect the wire as a fan-out / merge. `keyByRef`
2281
+ * maps each `b{i}` ref to that entry's caller key, so
2282
+ * {@link projectMultiJobToRunResult} partitions per entry (1 job ↔ 1 key:
2283
+ * `completed` → `succeeded`, else → `failed` with a {@link GislItemFailedError}).
2284
+ *
2285
+ * **Immutability:** the ctor is CLIENT-ONLY (the ordered entries + the client) —
2286
+ * entries are already-built Recipes that captured their own preset defaults at
2287
+ * `client.file(...)` time, so batch never re-plumbs
2288
+ * presetDefaults/scopedPresetDefaults. Mirrors the PHP `BatchRecipe`.
2289
+ */
2290
+ export class BatchRecipe {
2291
+ client;
2292
+ recipes;
2293
+ constructor(recipes, client) {
2294
+ this.client = client;
2295
+ // DEFENSIVE COPY (TS-only): snapshot the caller's array so a later mutation
2296
+ // of it (splice/push after construction, or during an in-flight run()) can't
2297
+ // desync the uploaded fileIds from the lowered jobs/keys — validation,
2298
+ // upload, lowering + keyByRef all iterate this frozen order. PHP is
2299
+ // value-semantics-safe already (arrays copy on pass).
2300
+ this.recipes = [...recipes];
2301
+ }
2302
+ /**
2303
+ * Execute the batch end-to-end: validate + lowering-preflight EVERY entry
2304
+ * BEFORE any upload, upload each entry's input, create ONE multi-job workflow
2305
+ * (one `b{i}` job per entry), await a terminal state (SSE with poll fallback,
2306
+ * honouring `useSSE`), then partition the per-job downloads into a keyed
2307
+ * {@link RunResult}. `partially_failed` is a NORMAL terminal state here — the
2308
+ * completed entries land in `succeeded`, the rest in `failed`.
2309
+ *
2310
+ * Requires a client bound at construction time — `gisl().batch([...])` wires
2311
+ * it; a directly-constructed {@link BatchRecipe} (e.g. a lowering-only test)
2312
+ * throws {@link GislConfigError}. Mirrors the fan-out {@link FilesRecipe.run}.
2313
+ */
2314
+ async run(options = {}) {
2315
+ const signal = options.signal;
2316
+ const onProgress = options.onProgress;
2317
+ if (this.client === undefined) {
2318
+ throw new GislConfigError('BatchRecipe.run() requires a client; build the batch via gisl().batch([...]) rather than constructing BatchRecipe directly.', { reason: 'no_client' });
2319
+ }
2320
+ // Validate + lowering-preflight EVERY entry BEFORE any upload: a structural
2321
+ // violation (bad type / missing / duplicate key) or an invalid lowering
2322
+ // aborts here so no input uploads. NOTE — like FilesRecipe, TS does NOT
2323
+ // pre-check path readability: a nonexistent/unreadable path surfaces INSIDE
2324
+ // uploadFile during upload, so an earlier entry's input may already be
2325
+ // uploaded when a later entry's path fails. (PHP pre-checks path/resource
2326
+ // uploadability; this TS/PHP difference mirrors each language's existing
2327
+ // FilesRecipe behavior and is intentionally NOT closed here — a TS
2328
+ // path-precheck would diverge batch from FilesRecipe.)
2329
+ this.validatePreUpload();
2330
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2331
+ // 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
2332
+ // sends NO webhook (run()-only), so `callback_url` is omitted from the
2333
+ // payload (the closure receives `callbackUrl` undefined).
2334
+ // Dedupe cross-entry uploads: collapse to the first-appearance-unique input
2335
+ // list, upload each unique input ONCE, then expand the returned unique
2336
+ // fileIds back to one-per-entry (in entry order) so the b{i} jobs + keyByRef
2337
+ // stay N-length and correctness-neutral. See planUploads / inputIdentity.
2338
+ const { uniqueInputs, entryToUnique } = this.planUploads();
2339
+ const created = await _uploadInputsAndCreate(this.client, uniqueInputs, (uniqueFileIds, callbackUrl) => this.toWorkflowPayload(entryToUnique.map((u) => uniqueFileIds[u]), callbackUrl), {
2340
+ webhook: undefined,
2341
+ deadline,
2342
+ onProgress,
2343
+ signal,
2344
+ probeBeforeCreate: options.probeBeforeCreate,
2345
+ probeTimeoutMs: options.probeTimeoutMs,
2346
+ uploadsLabel: 'batch',
2347
+ workflowLabel: 'the batch workflow',
2348
+ });
2349
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error (or
2350
+ // poll-direct when `useSSE: false`). Caller-aborted + deadline errors
2351
+ // propagate (not transient) — see _awaitTerminal.
2352
+ const finalStatus = await _awaitTerminal(this.client, {
2353
+ workflowId: created.workflowId,
2354
+ deadline,
2355
+ signal,
2356
+ onProgress,
2357
+ pollIntervalMs: options.pollIntervalMs,
2358
+ useSSE: options.useSSE ?? true,
2359
+ });
2360
+ // 4. Fetch downloads + project per-job into the keyed RunResult.
2361
+ if (Date.now() >= deadline) {
2362
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2363
+ }
2364
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2365
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
2366
+ // cannot return a success past the advertised maxWait deadline.
2367
+ if (Date.now() >= deadline) {
2368
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2369
+ }
2370
+ const downloader = new LazyHttpDownloader();
2371
+ return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
2372
+ }
2373
+ /**
2374
+ * Lower the batch to ONE multi-job workflow-create payload against a list of
2375
+ * resolved upload ids (one per entry, in entry order). Each entry `i` becomes
2376
+ * ONE job re-id'd `b{i}` carrying that entry's lowered `source` + `operations`.
2377
+ * Composes the single-file {@link Recipe.toWorkflowPayload} per entry so each
2378
+ * keeps its own media-hint + preset resolution and lowering logic is not
2379
+ * duplicated. `callback_url` is built in ONLY when a webhook is supplied
2380
+ * (batch v1 run() supplies none, so it is omitted).
2381
+ *
2382
+ * @internal Consumed by {@link run} (after uploading) and the cross-language
2383
+ * golden-payload lowering test (with fixed ids). Not caller-facing.
2384
+ */
2385
+ toWorkflowPayload(fileIds, callbackUrl) {
2386
+ const jobs = this.recipes.map((entry, i) => {
2387
+ const oneJob = entry.toWorkflowPayload(fileIds[i]).jobs[0];
2388
+ // Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
2389
+ // merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
2390
+ // `toWire()` so the JSON serialisation is byte-identical across languages.
2391
+ return { id: `b${i}`, source: oneJob.source, operations: oneJob.operations };
2392
+ });
2393
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2394
+ }
2395
+ /** The number of recipe entries in this batch (introspection / tests). */
2396
+ get recipeCount() {
2397
+ return this.recipes.length;
2398
+ }
2399
+ // ---------------------------------------------------------------------------
2400
+ /**
2401
+ * Validate the batch AND lowering-preflight every entry BEFORE any upload
2402
+ * fires — an invalid entry costs no bandwidth. TWO PASSES (mirrors PHP
2403
+ * `BatchRecipe`'s structural-loop-then-preflight-loop), throwing
2404
+ * {@link GislConfigError}:
2405
+ * 0. empty batch → `no_recipes` (checked first).
2406
+ * PASS 1 (structural, ALL entries in order):
2407
+ * - a KNOWN multi-input builder (checked FIRST — they do NOT extend
2408
+ * {@link Recipe}, so the not-a-Recipe catch-all would otherwise misreport
2409
+ * them as plain type errors) → `multi_input_recipe_unsupported`;
2410
+ * - a non-{@link Recipe} entry → `invalid_recipe`;
2411
+ * - a missing/empty key → `missing_key`;
2412
+ * - a duplicate key → `duplicate_key`.
2413
+ * PASS 2 (lowering preflight, ALL entries): lower each entry (via
2414
+ * {@link Recipe.toWorkflowPayload}) so an invalid lowering throws BEFORE any
2415
+ * upload, mirroring what {@link FilesRecipe} lowers pre-create.
2416
+ *
2417
+ * Two passes so a batch with MULTIPLE distinct violations throws the SAME
2418
+ * reason regardless of entry order (a structural error anywhere wins over a
2419
+ * lowering error elsewhere) — converging TS + PHP error reporting. The
2420
+ * offending key/index rides the MESSAGE (not `conflictingFields`, which is
2421
+ * reserved for wire FIELD names).
2422
+ */
2423
+ validatePreUpload() {
2424
+ if (this.recipes.length === 0) {
2425
+ throw new GislConfigError('batch() requires at least one recipe. Pass an ordered array of ' +
2426
+ 'client.file(input, key).<op>(...) recipes, each with a unique key.', { reason: 'no_recipes' });
2427
+ }
2428
+ // PASS 1 — structural checks across ALL entries in order.
2429
+ const seenKeys = new Set();
2430
+ this.recipes.forEach((rawEntry, i) => {
2431
+ // Treat each entry as unknown for the runtime type guards: the public
2432
+ // signature is ReadonlyArray<Recipe>, but a plain-JS caller can pass
2433
+ // anything, and the multi-input builders are structurally Recipe-adjacent.
2434
+ const entry = rawEntry;
2435
+ // ORDER MATTERS (codex r2 #1): check the KNOWN multi-input builders FIRST.
2436
+ // They do NOT extend Recipe, so the not-a-Recipe catch-all below would
2437
+ // otherwise misreport them as plain caller type errors.
2438
+ if (entry instanceof FilesRecipe ||
2439
+ entry instanceof MergedRecipe ||
2440
+ entry instanceof WatermarkedRecipe ||
2441
+ entry instanceof ArchivedRecipe) {
2442
+ throw new GislConfigError(`batch() entry at index ${i} is a multi-input recipe (${entry.constructor.name}), ` +
2443
+ 'which is not supported in batch v1 — batch accepts only single-input keyed recipes ' +
2444
+ '(client.file(input, key).<op>(...)). Run the multi-input recipe on its own.', { reason: 'multi_input_recipe_unsupported' });
2445
+ }
2446
+ // THEN the catch-all: not a Recipe at all (null / string / plain object).
2447
+ if (!(entry instanceof Recipe)) {
2448
+ throw new GislConfigError(`batch() entry at index ${i} is not a recipe. Build each entry via ` +
2449
+ 'client.file(input, key).<op>(...) before passing it to batch().', { reason: 'invalid_recipe' });
2450
+ }
2451
+ // Keys are the result address → each entry needs a unique, non-empty key.
2452
+ const key = entry.key();
2453
+ if (key === undefined || key === '') {
2454
+ throw new GislConfigError(`batch() entry at index ${i} has no key. Every batch entry needs a unique non-empty key ` +
2455
+ "(client.file(input, 'key')) to address its result.", { reason: 'missing_key' });
2456
+ }
2457
+ if (seenKeys.has(key)) {
2458
+ throw new GislConfigError(`batch() has a duplicate key '${key}' (entry at index ${i}). Every batch entry needs a unique key.`, { reason: 'duplicate_key' });
2459
+ }
2460
+ seenKeys.add(key);
2461
+ });
2462
+ // PASS 2 — lowering preflight across ALL entries (each is now known to be a
2463
+ // Recipe). Lower each entry now so an invalid lowering (e.g. an undetectable
2464
+ // input + optimize, an unrepresentable output route) throws BEFORE any
2465
+ // upload. The 'preflight' id is a throwaway placeholder — run() re-lowers
2466
+ // against the real upload ids post-upload.
2467
+ this.recipes.forEach((entry) => {
2468
+ entry.toWorkflowPayload('preflight');
2469
+ });
2470
+ }
2471
+ /**
2472
+ * Collapse the entry inputs to a first-appearance-unique list for cross-entry
2473
+ * upload dedupe: two entries sourcing the SAME input (by {@link inputIdentity})
2474
+ * upload ONCE and share the fileId. Returns the ordered `uniqueInputs` plus an
2475
+ * `entryToUnique` index map (length N, entry order) so {@link run} can expand
2476
+ * the unique fileIds back to one-per-entry before {@link toWorkflowPayload} —
2477
+ * keeping the b{i} refs + {@link keyByRef} N-length and correctness-neutral.
2478
+ */
2479
+ planUploads() {
2480
+ const blobTokens = new Map();
2481
+ const identityToUnique = new Map();
2482
+ const uniqueInputs = [];
2483
+ const entryToUnique = this.recipes.map((entry) => {
2484
+ const input = entry.recipeInput;
2485
+ const id = inputIdentity(input, blobTokens);
2486
+ let uniqueIndex = identityToUnique.get(id);
2487
+ if (uniqueIndex === undefined) {
2488
+ uniqueIndex = uniqueInputs.length;
2489
+ uniqueInputs.push(input);
2490
+ identityToUnique.set(id, uniqueIndex);
2491
+ }
2492
+ return uniqueIndex;
2493
+ });
2494
+ return { uniqueInputs, entryToUnique };
2495
+ }
2496
+ /** Map each `b{i}` job ref to that entry's caller key (validated non-empty). */
2497
+ keyByRef() {
2498
+ const map = new Map();
2499
+ this.recipes.forEach((entry, i) => {
2500
+ map.set(`b${i}`, entry.key() ?? null);
2501
+ });
2502
+ return map;
2503
+ }
2504
+ }