@giveitsmaller/sdk 0.18.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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/_audit.js +12 -0
  3. package/dist/builder.d.ts +1 -0
  4. package/dist/client.d.ts +8 -1
  5. package/dist/client.js +27 -32
  6. package/dist/ergonomic/image_output_routes.d.ts +6 -5
  7. package/dist/ergonomic/image_output_routes.js +29 -21
  8. package/dist/ergonomic/option_types.d.ts +85 -11
  9. package/dist/ergonomic/option_types.js +11 -6
  10. package/dist/ergonomic/option_validation.d.ts +21 -3
  11. package/dist/ergonomic/option_validation.js +36 -3
  12. package/dist/ergonomic/preset_resolver.d.ts +1 -1
  13. package/dist/ergonomic/preset_resolver.js +6 -7
  14. package/dist/ergonomic/presets/document_epub_compress.d.ts +0 -2
  15. package/dist/ergonomic/presets/document_epub_compress.js +2 -7
  16. package/dist/ergonomic/presets/document_odf_compress.d.ts +0 -2
  17. package/dist/ergonomic/presets/document_odf_compress.js +2 -7
  18. package/dist/ergonomic/presets/document_office_compress.d.ts +0 -2
  19. package/dist/ergonomic/presets/document_office_compress.js +2 -7
  20. package/dist/errors.d.ts +57 -1
  21. package/dist/errors.js +82 -1
  22. package/dist/file-first.d.ts +195 -8
  23. package/dist/file-first.js +462 -124
  24. package/dist/generated/sdk_spec/enums.d.ts +4 -2
  25. package/dist/generated/sdk_spec/enums.js +11 -5
  26. package/dist/generated/sdk_spec/presets.js +3 -12
  27. package/dist/generated/sdk_spec/version.d.ts +3 -3
  28. package/dist/generated/sdk_spec/version.js +3 -3
  29. package/dist/gisl.d.ts +72 -3
  30. package/dist/gisl.js +72 -2
  31. package/dist/index.core.d.ts +8 -6
  32. package/dist/index.core.js +9 -1
  33. package/dist/merge.d.ts +12 -0
  34. package/dist/merge.js +12 -0
  35. package/dist/retry-metadata.d.ts +37 -0
  36. package/dist/retry-metadata.js +86 -0
  37. package/dist/sse.d.ts +2 -1
  38. package/dist/sse.js +26 -6
  39. package/dist/types.d.ts +43 -1
  40. package/package.json +2 -2
@@ -40,6 +40,10 @@ import { Handle } from './handle.js';
40
40
  * - `ok`: true iff `failed` is empty. (A boolean — the partition lists are
41
41
  * `succeeded`/`failed`; resolves the design doc's `ok` bool-vs-list
42
42
  * contradiction.)
43
+ * - `targetSizeMissed`: derived target-size signal — undefined when no output
44
+ * reports a target-size outcome (not a target_size run); otherwise true iff
45
+ * some artifact has `targetSizeMet === false`. Omitted from the JSON when
46
+ * undefined so non-target-size runs keep the common-case shape.
43
47
  * - `state`: lifecycle state (`completed` | `failed` | ...). Named `state`,
44
48
  * NOT `status`, matching the file-first `StatusSnapshot.state`.
45
49
  * - sinks fetch via the injected {@link Downloader}; a result with no
@@ -56,6 +60,14 @@ export class RunResult {
56
60
  url;
57
61
  /** True iff {@link failed} is empty. */
58
62
  ok;
63
+ /**
64
+ * Whether any output missed its requested byte target. Derived from the
65
+ * per-output {@link OutputFile.targetSizeMet}: undefined when NO artifact
66
+ * reports a target-size outcome (every `targetSizeMet` undefined — not a
67
+ * target_size run); otherwise true iff some artifact has
68
+ * `targetSizeMet === false`.
69
+ */
70
+ targetSizeMissed;
59
71
  constructor(workflowId, state, artifacts, succeeded, failed, downloader) {
60
72
  this.workflowId = workflowId;
61
73
  this.state = state;
@@ -65,6 +77,9 @@ export class RunResult {
65
77
  this.downloader = downloader;
66
78
  this.url = artifacts.length === 1 ? artifacts[0].url : undefined;
67
79
  this.ok = failed.length === 0;
80
+ this.targetSizeMissed = artifacts.every((a) => a.targetSizeMet === undefined)
81
+ ? undefined
82
+ : artifacts.some((a) => a.targetSizeMet === false);
68
83
  }
69
84
  /**
70
85
  * Address a succeeded input by the `key:` given to `file()`. Duplicate keys
@@ -134,20 +149,29 @@ export class RunResult {
134
149
  return { paths };
135
150
  }
136
151
  /**
137
- * Plain-object projection. Field ORDER (workflowId, state, ok, url?,
138
- * artifacts, succeeded, failed) is fixed to match the PHP `toArray()`
139
- * reference so JSON-string parity holds (FF1 shape assertion + FF2b harness
140
- * fixture). `url` is omitted entirely when undefined — `JSON.stringify`
141
- * then produces the identical shape to PHP's omit-when-null `toArray()`.
152
+ * Plain-object projection. Field ORDER (workflowId, state, ok,
153
+ * targetSizeMissed?, url?, artifacts, succeeded, failed) is fixed to match
154
+ * the PHP `toArray()` reference so JSON-string parity holds (FF1 shape
155
+ * assertion + FF2b harness fixture). `targetSizeMissed` + `url` are omitted
156
+ * entirely when undefined `JSON.stringify` then produces the identical
157
+ * shape to PHP's omit-when-null `toArray()`.
142
158
  */
143
159
  toJSON() {
144
- // Re-project each OutputFile to exactly its four fields so structurally
160
+ // Re-project each OutputFile to exactly its known fields so structurally
145
161
  // compatible inputs carrying extra properties can't leak into the JSON.
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.
146
166
  const file = (o) => ({
147
167
  url: o.url,
148
168
  filename: o.filename,
149
169
  sizeBytes: o.sizeBytes,
150
170
  operation: o.operation,
171
+ ...(o.chosenQuality !== undefined ? { chosenQuality: o.chosenQuality } : {}),
172
+ ...(o.targetSizeMet !== undefined ? { targetSizeMet: o.targetSizeMet } : {}),
173
+ ...(o.measuredQuality !== undefined ? { measuredQuality: o.measuredQuality } : {}),
174
+ ...(o.qualityMetric !== undefined ? { qualityMetric: o.qualityMetric } : {}),
151
175
  });
152
176
  const rest = {
153
177
  artifacts: this.artifacts.map(file),
@@ -164,13 +188,18 @@ export class RunResult {
164
188
  }),
165
189
  };
166
190
  const head = { workflowId: this.workflowId, state: this.state, ok: this.ok };
167
- // Insert `url` BETWEEN ok and artifacts when present, matching the PHP
168
- // toArray() field order (workflowId, state, ok, url?, artifacts, ...) so
169
- // JSON-string parity holds. Omitted entirely when undefined (PHP omits
170
- // null), so `JSON.stringify` produces the identical shape.
191
+ // Insert `targetSizeMissed` immediately after `ok` (before `url`) when
192
+ // present, then `url` BETWEEN it and artifacts, matching the PHP toArray()
193
+ // field order (workflowId, state, ok, targetSizeMissed?, url?, artifacts,
194
+ // ...) so JSON-string parity holds. Both are omitted entirely when
195
+ // undefined (PHP omits null), so `JSON.stringify` produces the identical
196
+ // shape.
197
+ const headWithMissed = this.targetSizeMissed === undefined
198
+ ? head
199
+ : { ...head, targetSizeMissed: this.targetSizeMissed };
171
200
  return this.url === undefined
172
- ? { ...head, ...rest }
173
- : { ...head, url: this.url, ...rest };
201
+ ? { ...headWithMissed, ...rest }
202
+ : { ...headWithMissed, url: this.url, ...rest };
174
203
  }
175
204
  requireDownloader() {
176
205
  if (this.downloader === undefined) {
@@ -200,6 +229,12 @@ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownload
200
229
  filename: f.filename,
201
230
  sizeBytes: f.sizeBytes,
202
231
  operation: f.operation,
232
+ // Omit-when-absent on the LIVE OutputFile too (not just toJSON): a
233
+ // non-target-size output carries no chosenQuality/targetSizeMet key.
234
+ ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
235
+ ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
236
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
237
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
203
238
  });
204
239
  }
205
240
  }
@@ -259,6 +294,12 @@ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads
259
294
  filename: f.filename,
260
295
  sizeBytes: f.sizeBytes,
261
296
  operation: f.operation,
297
+ // Omit-when-absent on the LIVE OutputFile too (mirrors toJSON + the
298
+ // single-job projector).
299
+ ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
300
+ ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
301
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
302
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
262
303
  }));
263
304
  // The flat artifacts[] keeps every job's outputs in job order.
264
305
  artifacts.push(...outputs);
@@ -404,6 +445,48 @@ export const fileInput = {
404
445
  return { kind: 'uploadId', fileId };
405
446
  },
406
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
+ }
407
490
  /**
408
491
  * The file-first builder value. `client.file(path)` returns a `Recipe`;
409
492
  * single-input operations called on it (`compress`, `convert`, `thumbnail`,
@@ -488,6 +571,27 @@ export class Recipe {
488
571
  }
489
572
  return this.withStep({ opType: 'thumbnail', options: wire });
490
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
+ }
491
595
  /**
492
596
  * Produce ONE transformed image: keep or change format, plus quality, resize
493
597
  * and route-honored controls. The single user-facing image transform — the SDK
@@ -557,7 +661,8 @@ export class Recipe {
557
661
  * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
558
662
  * throw locally BEFORE any upload (the planned-op gate). `options` carries the
559
663
  * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
560
- * `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
561
666
  * `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
562
667
  * {@link textWatermark} (single-input text overlay).
563
668
  */
@@ -640,35 +745,17 @@ export class Recipe {
640
745
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
641
746
  // (which passes a webhook → callback_url). run() passes no webhook.
642
747
  const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
643
- // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
644
- // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
645
- let finalStatus;
646
- try {
647
- finalStatus = await _consumeSseToTerminal(this.client, {
648
- workflowId: created.workflowId,
649
- deadline,
650
- signal,
651
- onProgress,
652
- });
653
- }
654
- catch (err) {
655
- // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
656
- // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
657
- // Everything else — caller-deadline, abort, an API error from /events, an
658
- // onProgress callback throw (propagates as-is, NOT wrapped), anything
659
- // unexpected — MUST propagate; re-issuing the same doomed request via poll
660
- // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
661
- // marker discipline.
662
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
663
- throw err;
664
- }
665
- finalStatus = await _pollToTerminal(this.client, {
666
- workflowId: created.workflowId,
667
- deadline,
668
- signal,
669
- pollIntervalMs: options.pollIntervalMs,
670
- });
671
- }
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
+ });
672
759
  // 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
673
760
  // downloads, so check before issuing the request (mirrors builder.ts).
674
761
  if (Date.now() >= deadline) {
@@ -1055,6 +1142,8 @@ export const WATERMARK_CAPABILITY = {
1055
1142
  image_watermark: {
1056
1143
  image: { mimes: ['image/jpeg', 'image/png', 'image/webp'], availability: 'stable' },
1057
1144
  image_gif: { mimes: ['image/gif'], availability: 'planned' },
1145
+ image_tiff: { mimes: ['image/tiff'], availability: 'stable' },
1146
+ image_bmp: { mimes: ['image/bmp'], availability: 'stable' },
1058
1147
  },
1059
1148
  video_watermark: {
1060
1149
  video: { mimes: ['video/mp4', 'video/webm'], availability: 'beta' },
@@ -1185,8 +1274,9 @@ function _validateWatermarkOverlay(overlay) {
1185
1274
  }
1186
1275
  }
1187
1276
  function _lowerWatermarkOp(wireOp, options) {
1188
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width) are
1189
- // 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).
1190
1280
  const wire = { ...options };
1191
1281
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1192
1282
  }
@@ -1325,6 +1415,10 @@ export class FilesRecipe {
1325
1415
  thumbnail(options) {
1326
1416
  return this.withStep(this.baseRecipe().thumbnail(options));
1327
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
+ }
1328
1422
  /** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
1329
1423
  textWatermark(text, options = {}) {
1330
1424
  return this.withStep(this.baseRecipe().textWatermark(text, options));
@@ -1420,26 +1514,14 @@ export class FilesRecipe {
1420
1514
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
1421
1515
  // `partially_failed` is a normal terminal state here (the helper treats it
1422
1516
  // as terminal); only caller-aborted / deadline / API errors propagate.
1423
- let finalStatus;
1424
- try {
1425
- finalStatus = await _consumeSseToTerminal(this.client, {
1426
- workflowId: created.workflowId,
1427
- deadline,
1428
- signal,
1429
- onProgress,
1430
- });
1431
- }
1432
- catch (err) {
1433
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1434
- throw err;
1435
- }
1436
- finalStatus = await _pollToTerminal(this.client, {
1437
- workflowId: created.workflowId,
1438
- deadline,
1439
- signal,
1440
- pollIntervalMs: options.pollIntervalMs,
1441
- });
1442
- }
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
+ });
1443
1525
  // 4. Fetch downloads + project per-job into the partitioned RunResult.
1444
1526
  if (Date.now() >= deadline) {
1445
1527
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
@@ -1585,6 +1667,16 @@ export class MergedRecipe {
1585
1667
  }
1586
1668
  return this.withStep({ opType: 'thumbnail', options: wire });
1587
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
+ }
1588
1680
  /**
1589
1681
  * Lower to the merge DAG: one `passthrough` source job per input + one
1590
1682
  * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
@@ -1638,26 +1730,14 @@ export class MergedRecipe {
1638
1730
  }
1639
1731
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1640
1732
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1641
- let finalStatus;
1642
- try {
1643
- finalStatus = await _consumeSseToTerminal(this.client, {
1644
- workflowId: created.workflowId,
1645
- deadline,
1646
- signal,
1647
- onProgress,
1648
- });
1649
- }
1650
- catch (err) {
1651
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1652
- throw err;
1653
- }
1654
- finalStatus = await _pollToTerminal(this.client, {
1655
- workflowId: created.workflowId,
1656
- deadline,
1657
- signal,
1658
- pollIntervalMs: options.pollIntervalMs,
1659
- });
1660
- }
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
+ });
1661
1741
  if (Date.now() >= deadline) {
1662
1742
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1663
1743
  }
@@ -1861,26 +1941,14 @@ export class ArchivedRecipe {
1861
1941
  }
1862
1942
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1863
1943
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1864
- let finalStatus;
1865
- try {
1866
- finalStatus = await _consumeSseToTerminal(this.client, {
1867
- workflowId: created.workflowId,
1868
- deadline,
1869
- signal,
1870
- onProgress,
1871
- });
1872
- }
1873
- catch (err) {
1874
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1875
- throw err;
1876
- }
1877
- finalStatus = await _pollToTerminal(this.client, {
1878
- workflowId: created.workflowId,
1879
- deadline,
1880
- signal,
1881
- pollIntervalMs: options.pollIntervalMs,
1882
- });
1883
- }
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
+ });
1884
1952
  if (Date.now() >= deadline) {
1885
1953
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1886
1954
  }
@@ -2010,6 +2078,16 @@ export class WatermarkedRecipe {
2010
2078
  }
2011
2079
  return this.withStep({ opType: 'thumbnail', options: wire });
2012
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
+ }
2013
2091
  /**
2014
2092
  * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2015
2093
  * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
@@ -2066,26 +2144,14 @@ export class WatermarkedRecipe {
2066
2144
  }
2067
2145
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2068
2146
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2069
- let finalStatus;
2070
- try {
2071
- finalStatus = await _consumeSseToTerminal(this.client, {
2072
- workflowId: created.workflowId,
2073
- deadline,
2074
- signal,
2075
- onProgress,
2076
- });
2077
- }
2078
- catch (err) {
2079
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
2080
- throw err;
2081
- }
2082
- finalStatus = await _pollToTerminal(this.client, {
2083
- workflowId: created.workflowId,
2084
- deadline,
2085
- signal,
2086
- pollIntervalMs: options.pollIntervalMs,
2087
- });
2088
- }
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
+ });
2089
2155
  if (Date.now() >= deadline) {
2090
2156
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2091
2157
  }
@@ -2164,3 +2230,275 @@ export class WatermarkedRecipe {
2164
2230
  return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
2165
2231
  }
2166
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
+ }