@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.
- package/dist/_audit.js +12 -0
- package/dist/builder.d.ts +1 -0
- package/dist/client.d.ts +8 -1
- package/dist/client.js +27 -32
- package/dist/ergonomic/option_types.d.ts +34 -1
- package/dist/ergonomic/option_types.js +5 -1
- package/dist/ergonomic/option_validation.d.ts +19 -1
- package/dist/ergonomic/option_validation.js +34 -1
- package/dist/errors.d.ts +57 -1
- package/dist/errors.js +82 -1
- package/dist/file-first.d.ts +153 -3
- package/dist/file-first.js +417 -115
- package/dist/gisl.d.ts +72 -3
- package/dist/gisl.js +72 -2
- package/dist/index.core.d.ts +8 -6
- package/dist/index.core.js +9 -1
- package/dist/merge.d.ts +12 -0
- package/dist/merge.js +12 -0
- package/dist/retry-metadata.d.ts +37 -0
- package/dist/retry-metadata.js +86 -0
- package/dist/sse.d.ts +2 -1
- package/dist/sse.js +26 -6
- package/dist/types.d.ts +43 -1
- package/package.json +2 -2
package/dist/file-first.js
CHANGED
|
@@ -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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
1225
|
-
// already wire keys; empty options omit
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
+
}
|