@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.
- package/README.md +1 -1
- 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/image_output_routes.d.ts +6 -5
- package/dist/ergonomic/image_output_routes.js +29 -21
- package/dist/ergonomic/option_types.d.ts +85 -11
- package/dist/ergonomic/option_types.js +11 -6
- package/dist/ergonomic/option_validation.d.ts +21 -3
- package/dist/ergonomic/option_validation.js +36 -3
- package/dist/ergonomic/preset_resolver.d.ts +1 -1
- package/dist/ergonomic/preset_resolver.js +6 -7
- package/dist/ergonomic/presets/document_epub_compress.d.ts +0 -2
- package/dist/ergonomic/presets/document_epub_compress.js +2 -7
- package/dist/ergonomic/presets/document_odf_compress.d.ts +0 -2
- package/dist/ergonomic/presets/document_odf_compress.js +2 -7
- package/dist/ergonomic/presets/document_office_compress.d.ts +0 -2
- package/dist/ergonomic/presets/document_office_compress.js +2 -7
- package/dist/errors.d.ts +57 -1
- package/dist/errors.js +82 -1
- package/dist/file-first.d.ts +195 -8
- package/dist/file-first.js +462 -124
- package/dist/generated/sdk_spec/enums.d.ts +4 -2
- package/dist/generated/sdk_spec/enums.js +11 -5
- package/dist/generated/sdk_spec/presets.js +3 -12
- package/dist/generated/sdk_spec/version.d.ts +3 -3
- package/dist/generated/sdk_spec/version.js +3 -3
- 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
|
@@ -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,
|
|
138
|
-
* artifacts, succeeded, failed) is fixed to match
|
|
139
|
-
* reference so JSON-string parity holds (FF1 shape
|
|
140
|
-
* fixture). `url`
|
|
141
|
-
*
|
|
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
|
|
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 `
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
-
? { ...
|
|
173
|
-
: { ...
|
|
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
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
1189
|
-
// 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).
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
+
}
|