@giveitsmaller/sdk 0.19.0 → 0.21.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/LICENSE +202 -0
- package/README.md +8 -15
- package/dist/_audit.js +14 -4
- package/dist/builder.d.ts +5 -4
- package/dist/builder.js +16 -16
- package/dist/client.d.ts +17 -7
- package/dist/client.js +116 -62
- package/dist/ergonomic/image_output_routes.d.ts +30 -0
- package/dist/ergonomic/image_output_routes.js +82 -11
- 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/ergonomic/preset_resolver.d.ts +2 -2
- package/dist/ergonomic/preset_resolver.js +6 -10
- package/dist/ergonomic/presets/index.d.ts +9 -8
- package/dist/ergonomic/presets/index.js +1 -9
- package/dist/errors.d.ts +105 -3
- package/dist/errors.js +136 -2
- package/dist/file-first.d.ts +234 -16
- package/dist/file-first.js +692 -175
- package/dist/generated/sdk_spec/enums.d.ts +0 -26
- package/dist/generated/sdk_spec/enums.js +0 -16
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +12 -0
- package/dist/generated/sdk_spec/presets.js +0 -14
- package/dist/generated/sdk_spec/version.d.ts +2 -2
- package/dist/generated/sdk_spec/version.js +2 -2
- package/dist/gisl.d.ts +93 -5
- package/dist/gisl.js +72 -2
- package/dist/handle.d.ts +6 -1
- package/dist/handle.js +42 -13
- package/dist/index.core.d.ts +10 -8
- package/dist/index.core.js +11 -3
- package/dist/merge.d.ts +12 -0
- package/dist/merge.js +14 -2
- 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 +54 -4
- package/dist/types.js +1 -0
- package/package.json +3 -3
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
- package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
package/dist/file-first.js
CHANGED
|
@@ -14,7 +14,7 @@ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pol
|
|
|
14
14
|
import { LazyHttpDownloader } from './lazy-downloader.js';
|
|
15
15
|
import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
|
|
16
16
|
import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
|
|
17
|
-
import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
|
|
17
|
+
import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
|
|
18
18
|
import { OptimizeFor } from './generated/sdk_spec/enums.js';
|
|
19
19
|
import { uploadSource, jobOutputSource } from './types.js';
|
|
20
20
|
// Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
|
|
@@ -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);
|
|
@@ -334,10 +341,54 @@ export function isFanoutStatus(finalStatus) {
|
|
|
334
341
|
return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
|
|
335
342
|
}
|
|
336
343
|
const _MERGE_SRC_REF = /^src_\d+$/;
|
|
344
|
+
/**
|
|
345
|
+
* Job id/ref for the DOWNSTREAM job that carries post-`sole_op` steps. A
|
|
346
|
+
* `sole_op` op (image_watermark / video_watermark / merge — ADR-0025) MUST be
|
|
347
|
+
* the only op in its job, so when a caller chains `compress()` / `convert()` /
|
|
348
|
+
* `thumbnail()` / `transform()` after `watermark()` / `merge()`, those steps
|
|
349
|
+
* lower into this separate job that consumes the sole_op output via
|
|
350
|
+
* `job_output` (the server derives the DAG from the `from` reference — no
|
|
351
|
+
* explicit `workflow_edges` needed). When present it is the TERMINAL deliverable,
|
|
352
|
+
* so `run()` / the {@link Handle} project THIS job's output, and the status-shape
|
|
353
|
+
* detectors accept it alongside the sole_op + `src_{i}` refs. PIiUit28.
|
|
354
|
+
*/
|
|
355
|
+
export const _POST_STEP_JOB_REF = 'post';
|
|
356
|
+
/**
|
|
357
|
+
* Job id/ref for the UPSTREAM job carrying any steps that PRECEDE a single-input
|
|
358
|
+
* `sole_op` op (e.g. `.compress().textWatermark()`): the pre-steps run in this
|
|
359
|
+
* job, the `sole_op` job then consumes its output via `job_output`. Distinct
|
|
360
|
+
* from the multi-input `src_{i}` fan-in refs. IQc01rj0.
|
|
361
|
+
*/
|
|
362
|
+
export const _PRE_STEP_JOB_REF = 'pre';
|
|
363
|
+
/**
|
|
364
|
+
* Wire op types the API marks `sole_op` (ADR-0025) — the op MUST be the ONLY op
|
|
365
|
+
* in its job. Mirrors `operation-capabilities.json` `operations.<op>.sole_op`,
|
|
366
|
+
* inlined as a browser-safe const (the raw-JSON sidecar subpath is Node-only)
|
|
367
|
+
* and PINNED to that projection by `sole-op-conformance.test.ts` — a contract
|
|
368
|
+
* regen that flips an op's `sole_op` fails there. The single-input
|
|
369
|
+
* {@link Recipe.toWorkflowPayload} reads THIS set to split a chain at every
|
|
370
|
+
* sole_op boundary into a `job_output`-linked job chain (so
|
|
371
|
+
* `.textWatermark('x').compress()` lowers to a valid DAG, not a contract-invalid
|
|
372
|
+
* co-bundled job). Mirrored by PHP `Recipe::SOLE_OP_TYPES`. IQc01rj0.
|
|
373
|
+
* @internal
|
|
374
|
+
*/
|
|
375
|
+
export const SOLE_OP_TYPES = new Set([
|
|
376
|
+
'archive',
|
|
377
|
+
'audio_overlay',
|
|
378
|
+
'audio_to_video',
|
|
379
|
+
'custom_luma',
|
|
380
|
+
'image_watermark',
|
|
381
|
+
'merge',
|
|
382
|
+
'split',
|
|
383
|
+
'text_watermark',
|
|
384
|
+
'video_text_watermark',
|
|
385
|
+
'video_watermark',
|
|
386
|
+
]);
|
|
337
387
|
/**
|
|
338
388
|
* True when a terminal status describes a fluent `files([...]).merge(...)`
|
|
339
389
|
* combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
|
|
340
|
-
* (the ids the {@link MergedRecipe} lowering
|
|
390
|
+
* or the downstream `post` job (the ids the {@link MergedRecipe} lowering
|
|
391
|
+
* assigns; `post` carries any post-combine steps). The data-driven seam that
|
|
341
392
|
* lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
|
|
342
393
|
* — filtering the `src_*` passthrough plumbing — even after a
|
|
343
394
|
* `client.workflow(id)` reattach (no construction-time marker), matching
|
|
@@ -356,6 +407,9 @@ export function isMergeStatus(finalStatus) {
|
|
|
356
407
|
hasMerge = true;
|
|
357
408
|
continue;
|
|
358
409
|
}
|
|
410
|
+
// The downstream post-`sole_op` steps job (PIiUit28) is part of a merge DAG.
|
|
411
|
+
if (job.ref === _POST_STEP_JOB_REF)
|
|
412
|
+
continue;
|
|
359
413
|
if (!_MERGE_SRC_REF.test(job.ref))
|
|
360
414
|
return false;
|
|
361
415
|
}
|
|
@@ -388,9 +442,10 @@ export function isArchiveStatus(finalStatus) {
|
|
|
388
442
|
}
|
|
389
443
|
/**
|
|
390
444
|
* True when a terminal status describes a fluent `file(...).watermark(overlay)`
|
|
391
|
-
* — at least one job ref `watermark` and every OTHER job ref is `src_{i}`
|
|
392
|
-
* ids the {@link WatermarkedRecipe} lowering
|
|
393
|
-
* overlay
|
|
445
|
+
* — at least one job ref `watermark` and every OTHER job ref is `src_{i}` or
|
|
446
|
+
* the downstream `post` job (the ids the {@link WatermarkedRecipe} lowering
|
|
447
|
+
* assigns: `src_0` base, `src_1` overlay, `post` any post-watermark steps).
|
|
448
|
+
* Lets {@link Handle.wait}/{@link Handle.result} AND
|
|
394
449
|
* {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
|
|
395
450
|
* the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
|
|
396
451
|
* Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
|
|
@@ -408,11 +463,117 @@ export function isWatermarkStatus(finalStatus) {
|
|
|
408
463
|
hasWatermark = true;
|
|
409
464
|
continue;
|
|
410
465
|
}
|
|
466
|
+
// The downstream post-`sole_op` steps job (PIiUit28) is part of a watermark DAG.
|
|
467
|
+
if (job.ref === _POST_STEP_JOB_REF)
|
|
468
|
+
continue;
|
|
411
469
|
if (!_MERGE_SRC_REF.test(job.ref))
|
|
412
470
|
return false;
|
|
413
471
|
}
|
|
414
472
|
return hasWatermark;
|
|
415
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* True when a terminal status describes a SINGLE-INPUT `sole_op` chain — e.g.
|
|
476
|
+
* `.textWatermark('x').compress()` lowered to a `text_watermark` job + a
|
|
477
|
+
* downstream `post` job (and an optional upstream `pre` job for steps before the
|
|
478
|
+
* sole_op). Every job ref is a `sole_op` wire type ({@link SOLE_OP_TYPES}) or the
|
|
479
|
+
* `pre`/`post` chain refs, with NO `src_{i}` fan-in ref (which distinguishes it
|
|
480
|
+
* from the multi-input merge/watermark/archive DAGs). Lets a submitted/reattached
|
|
481
|
+
* {@link Handle} project ONLY the terminal deliverable — filtering the
|
|
482
|
+
* intermediate sole_op artifact — without builder state. IQc01rj0.
|
|
483
|
+
*
|
|
484
|
+
* @internal Exported for the file-first `Handle`; not part of the public API.
|
|
485
|
+
*/
|
|
486
|
+
export function isSoleOpChainStatus(finalStatus) {
|
|
487
|
+
const jobs = finalStatus.jobs ?? [];
|
|
488
|
+
if (jobs.length === 0)
|
|
489
|
+
return false;
|
|
490
|
+
let hasSoleOp = false;
|
|
491
|
+
for (const job of jobs) {
|
|
492
|
+
if (SOLE_OP_TYPES.has(job.ref)) {
|
|
493
|
+
hasSoleOp = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (job.ref === _PRE_STEP_JOB_REF || job.ref === _POST_STEP_JOB_REF)
|
|
497
|
+
continue;
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return hasSoleOp;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The terminal deliverable ref for a single-input `sole_op` chain status: the
|
|
504
|
+
* downstream `post` job when present, else the `sole_op` job itself (the ref in
|
|
505
|
+
* {@link SOLE_OP_TYPES}). Mirrors how the merge/watermark paths pick their
|
|
506
|
+
* terminal via {@link terminalOutputRef} in `handle.ts`. IQc01rj0.
|
|
507
|
+
*
|
|
508
|
+
* @internal
|
|
509
|
+
*/
|
|
510
|
+
export function soleOpChainDeliverableRef(finalStatus) {
|
|
511
|
+
// Determine the terminal from the DAG (the job refs), NOT from which downloads
|
|
512
|
+
// exist: if the `post` job is in the graph it IS the deliverable even when it
|
|
513
|
+
// FAILED and produced no download — filtering to it then yields no artifact +
|
|
514
|
+
// the failure surfaces via the status, rather than silently exposing the
|
|
515
|
+
// successful intermediate sole_op artifact (codex).
|
|
516
|
+
const refs = (finalStatus.jobs ?? []).map((j) => j.ref);
|
|
517
|
+
const soleOpRef = refs.find((r) => SOLE_OP_TYPES.has(r)) ?? '';
|
|
518
|
+
return refs.includes(_POST_STEP_JOB_REF) ? _POST_STEP_JOB_REF : soleOpRef;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Split a single-input operation chain into a `job_output`-linked job chain at
|
|
522
|
+
* its `sole_op` boundary (IQc01rj0). A `sole_op` op (ADR-0025) MUST be the ONLY
|
|
523
|
+
* op in its job, so `.textWatermark('x').compress()` cannot lower to one
|
|
524
|
+
* co-bundled job — the `text_watermark` runs alone (job id = its op type) and
|
|
525
|
+
* the trailing steps lower into a downstream `post` job that consumes it via
|
|
526
|
+
* `job_output`; steps that PRECEDE the sole_op run in an upstream `pre` job.
|
|
527
|
+
*
|
|
528
|
+
* Returns a single job verbatim (NO `id`) when no split is needed — no sole_op,
|
|
529
|
+
* or a lone sole_op already alone — so the vast majority of recipes keep their
|
|
530
|
+
* byte-identical one-job shape. Throws when the chain carries more than one
|
|
531
|
+
* sole_op op (a single-input recipe reaches at most one sole_op verb today;
|
|
532
|
+
* supporting N is a follow-up).
|
|
533
|
+
*/
|
|
534
|
+
function _splitSingleInputJobs(ops, fileId) {
|
|
535
|
+
const soleCount = ops.reduce((n, op) => (SOLE_OP_TYPES.has(op.type) ? n + 1 : n), 0);
|
|
536
|
+
if (soleCount > 1) {
|
|
537
|
+
throw new GislConfigError('This recipe chains more than one sole_op operation (e.g. two textWatermark() steps), ' +
|
|
538
|
+
'which is not supported yet — apply them as separate workflows.', { reason: 'multi_sole_op_unsupported' });
|
|
539
|
+
}
|
|
540
|
+
const i = ops.findIndex((op) => SOLE_OP_TYPES.has(op.type));
|
|
541
|
+
if (i === -1 || (i === 0 && ops.length === 1)) {
|
|
542
|
+
// No sole_op, or a lone sole_op already alone → one job, no id (unchanged).
|
|
543
|
+
return [{ source: uploadSource(fileId), operations: [...ops] }];
|
|
544
|
+
}
|
|
545
|
+
const preOps = ops.slice(0, i);
|
|
546
|
+
const soleOp = ops[i];
|
|
547
|
+
const postOps = ops.slice(i + 1);
|
|
548
|
+
const jobs = [];
|
|
549
|
+
if (preOps.length > 0) {
|
|
550
|
+
jobs.push({ id: _PRE_STEP_JOB_REF, source: uploadSource(fileId), operations: [...preOps] });
|
|
551
|
+
}
|
|
552
|
+
jobs.push({
|
|
553
|
+
id: soleOp.type,
|
|
554
|
+
source: preOps.length > 0 ? jobOutputSource(_PRE_STEP_JOB_REF) : uploadSource(fileId),
|
|
555
|
+
operations: [soleOp],
|
|
556
|
+
});
|
|
557
|
+
if (postOps.length > 0) {
|
|
558
|
+
jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource(soleOp.type), operations: [...postOps] });
|
|
559
|
+
}
|
|
560
|
+
return jobs;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The single job of a NESTED single-input lowering, asserting it did not split
|
|
564
|
+
* (IQc01rj0). A nested Recipe used as a watermark base/overlay or a fan-out
|
|
565
|
+
* entry that itself carries a `sole_op` op alongside OTHER steps splits into a
|
|
566
|
+
* job chain; folding that chain into the OUTER DAG (as a `src_{i}`/`file-{i}`
|
|
567
|
+
* job) is not supported yet, so fail fast rather than silently drop the
|
|
568
|
+
* split-off downstream job (the whole point of the split).
|
|
569
|
+
*/
|
|
570
|
+
function _nestedSingleJob(payload, context) {
|
|
571
|
+
if (payload.jobs.length !== 1) {
|
|
572
|
+
throw new GislConfigError(`A ${context} recipe chains a sole_op op (e.g. textWatermark()) alongside other steps, which ` +
|
|
573
|
+
'is not supported here yet — apply the sole_op step in a standalone recipe.', { reason: 'nested_sole_op_unsupported' });
|
|
574
|
+
}
|
|
575
|
+
return payload.jobs[0];
|
|
576
|
+
}
|
|
416
577
|
/** Named constructors for {@link FileInput} — mirror the PHP static factories. */
|
|
417
578
|
export const fileInput = {
|
|
418
579
|
path(path) {
|
|
@@ -438,6 +599,48 @@ export const fileInput = {
|
|
|
438
599
|
return { kind: 'uploadId', fileId };
|
|
439
600
|
},
|
|
440
601
|
};
|
|
602
|
+
/**
|
|
603
|
+
* Await a workflow to a terminal status — SSE first with a poll fallback, or
|
|
604
|
+
* poll-direct when `useSSE` is false. The single shared implementation behind
|
|
605
|
+
* every file-first `run()` (Recipe, FilesRecipe, MergedRecipe, ArchivedRecipe,
|
|
606
|
+
* WatermarkedRecipe), mirroring the operation-first
|
|
607
|
+
* `OperationBuilder.awaitTerminal` (in `builder.ts`). Callers pass
|
|
608
|
+
* `useSSE: options.useSSE ?? true` so the default stays SSE-first (today's
|
|
609
|
+
* behaviour); `useSSE: false` skips the SSE attempt entirely and polls —
|
|
610
|
+
* useful when an intermediary proxy blocks SSE.
|
|
611
|
+
*
|
|
612
|
+
* @internal Not part of the caller-facing fluent surface.
|
|
613
|
+
*/
|
|
614
|
+
async function _awaitTerminal(client, args) {
|
|
615
|
+
if (args.useSSE) {
|
|
616
|
+
try {
|
|
617
|
+
return await _consumeSseToTerminal(client, {
|
|
618
|
+
workflowId: args.workflowId,
|
|
619
|
+
deadline: args.deadline,
|
|
620
|
+
signal: args.signal,
|
|
621
|
+
onProgress: args.onProgress,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
// TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
|
|
626
|
+
// (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
|
|
627
|
+
// Everything else — caller-deadline, abort, an API error from /events, an
|
|
628
|
+
// onProgress callback throw (propagates as-is, NOT wrapped), anything
|
|
629
|
+
// unexpected — MUST propagate; re-issuing the same doomed request via poll
|
|
630
|
+
// would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
|
|
631
|
+
// marker discipline.
|
|
632
|
+
if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
|
|
633
|
+
throw err;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return await _pollToTerminal(client, {
|
|
638
|
+
workflowId: args.workflowId,
|
|
639
|
+
deadline: args.deadline,
|
|
640
|
+
signal: args.signal,
|
|
641
|
+
pollIntervalMs: args.pollIntervalMs,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
441
644
|
/**
|
|
442
645
|
* The file-first builder value. `client.file(path)` returns a `Recipe`;
|
|
443
646
|
* single-input operations called on it (`compress`, `convert`, `thumbnail`,
|
|
@@ -522,6 +725,27 @@ export class Recipe {
|
|
|
522
725
|
}
|
|
523
726
|
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
524
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Geometric transform: rotate (0/90/180/270°) and/or flip. Chainable — the
|
|
730
|
+
* canonical single-job order is `transform → convert → compress → thumbnail`,
|
|
731
|
+
* so downstream size options refer to the final (post-transform) frame.
|
|
732
|
+
*
|
|
733
|
+
* Passthrough: `rotate`/`flip` are forwarded as-is; the SDK does NOT narrow
|
|
734
|
+
* per media (a `flip` on a PDF input passes SDK validation but the server
|
|
735
|
+
* rejects it — documents rotate only). The transform op is `availability:
|
|
736
|
+
* planned` today, so workflow-create returns `feature_not_available` (422)
|
|
737
|
+
* until the per-media Lambdas ship. A no-op (`rotate:0` + `flip:none`) is
|
|
738
|
+
* rejected server-side as `invalid_options`.
|
|
739
|
+
*/
|
|
740
|
+
transform(options = {}) {
|
|
741
|
+
validateVerbOptions('transform', options);
|
|
742
|
+
const wire = {};
|
|
743
|
+
for (const [key, value] of Object.entries(options)) {
|
|
744
|
+
if (value !== undefined)
|
|
745
|
+
wire[key] = value;
|
|
746
|
+
}
|
|
747
|
+
return this.withStep({ opType: 'transform', options: wire });
|
|
748
|
+
}
|
|
525
749
|
/**
|
|
526
750
|
* Produce ONE transformed image: keep or change format, plus quality, resize
|
|
527
751
|
* and route-honored controls. The single user-facing image transform — the SDK
|
|
@@ -591,7 +815,8 @@ export class Recipe {
|
|
|
591
815
|
* (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
|
|
592
816
|
* throw locally BEFORE any upload (the planned-op gate). `options` carries the
|
|
593
817
|
* wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
|
|
594
|
-
* `overlay_width`). Returns a
|
|
818
|
+
* `overlay_width`, or `overlays[]` for the multi-overlay stack). Returns a
|
|
819
|
+
* {@link WatermarkedRecipe} (chain post-watermark
|
|
595
820
|
* `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
|
|
596
821
|
* {@link textWatermark} (single-input text overlay).
|
|
597
822
|
*/
|
|
@@ -623,10 +848,25 @@ export class Recipe {
|
|
|
623
848
|
*/
|
|
624
849
|
toWorkflowPayload(fileId, callbackUrl) {
|
|
625
850
|
const operations = this.steps.map((step, i) => this.lowerStep(step, i));
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
851
|
+
// Split at any `sole_op` boundary into a `job_output`-linked chain (IQc01rj0);
|
|
852
|
+
// a chain with no sole_op stays a single byte-identical job. Job key order
|
|
853
|
+
// (id?, source, operations) matches the PHP `toWire()` — byte-identical JSON.
|
|
854
|
+
const jobs = _splitSingleInputJobs(operations, fileId);
|
|
855
|
+
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Trigger the per-step lowering purely for its validation side effects
|
|
859
|
+
* (route honoring, planned / out-of-enum values, `media_unknown`), discarding
|
|
860
|
+
* the result. Called BEFORE uploading bytes so a route-invalid recipe fails
|
|
861
|
+
* fast instead of after the upload is spent — parity with PHP
|
|
862
|
+
* `assertOperationsLowerable`. Lowering reads only `steps` + the input token,
|
|
863
|
+
* not the upload id, so this is a faithful preflight (0azjb6Rg).
|
|
864
|
+
*/
|
|
865
|
+
assertOperationsLowerable() {
|
|
866
|
+
const ops = this.steps.map((step, i) => this.lowerStep(step, i));
|
|
867
|
+
// Also run the sole_op split so a multi-sole_op recipe fails pre-upload
|
|
868
|
+
// (IQc01rj0). The placeholder id is discarded — only the throw matters.
|
|
869
|
+
_splitSingleInputJobs(ops, 'preflight');
|
|
630
870
|
}
|
|
631
871
|
/** The result-addressing key passed to `file()`, or undefined. */
|
|
632
872
|
key() {
|
|
@@ -670,54 +910,43 @@ export class Recipe {
|
|
|
670
910
|
if (this.client === undefined) {
|
|
671
911
|
throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
|
|
672
912
|
}
|
|
673
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
913
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
674
914
|
// 1+2. Upload (when required) + create the workflow. Shared with submit()
|
|
675
915
|
// (which passes a webhook → callback_url). run() passes no webhook.
|
|
676
916
|
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
|
-
}
|
|
917
|
+
// 3. Wait to terminal status — SSE first, poll on a genuine SSE error
|
|
918
|
+
// (or poll-direct when `useSSE: false`). Caller-aborted + deadline-elapsed
|
|
919
|
+
// errors MUST propagate (not transient) — see _awaitTerminal.
|
|
920
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
921
|
+
workflowId: created.workflowId,
|
|
922
|
+
deadline,
|
|
923
|
+
signal,
|
|
924
|
+
onProgress,
|
|
925
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
926
|
+
useSSE: options.useSSE ?? true,
|
|
927
|
+
});
|
|
706
928
|
// 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
|
|
707
929
|
// downloads, so check before issuing the request (mirrors builder.ts).
|
|
708
930
|
if (Date.now() >= deadline) {
|
|
709
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
931
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
710
932
|
}
|
|
711
933
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
712
934
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
713
935
|
// cannot return a success past the advertised maxWait deadline.
|
|
714
936
|
if (Date.now() >= deadline) {
|
|
715
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
937
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
716
938
|
}
|
|
717
939
|
// Download URLs from getWorkflowDownloads are pre-signed and require no SDK
|
|
718
940
|
// auth, so the downloader issues a plain unauthenticated fetch.
|
|
719
941
|
const downloader = new LazyHttpDownloader();
|
|
720
|
-
|
|
942
|
+
// A single-input `sole_op` split (IQc01rj0, e.g. textWatermark().compress())
|
|
943
|
+
// produces a job chain — project ONLY the terminal deliverable, filtering the
|
|
944
|
+
// intermediate sole_op artifact (mirrors the merge/watermark terminal filter
|
|
945
|
+
// and Handle.project so submit()/reattach agree with run()).
|
|
946
|
+
const runDownloads = isSoleOpChainStatus(finalStatus)
|
|
947
|
+
? downloads.downloads.filter((d) => d.ref === soleOpChainDeliverableRef(finalStatus))
|
|
948
|
+
: downloads.downloads;
|
|
949
|
+
return projectDownloadsToRunResult(created.workflowId, finalStatus, runDownloads, this.recipeKey ?? null, downloader);
|
|
721
950
|
}
|
|
722
951
|
/**
|
|
723
952
|
* Fire-and-forget the recipe: upload the input (when required), create the
|
|
@@ -742,7 +971,7 @@ export class Recipe {
|
|
|
742
971
|
// submit() is fire-and-forget — NO whole-run deadline. The upload may be
|
|
743
972
|
// large (a multi-GB master, example 12) and is bounded by the HTTP client's
|
|
744
973
|
// own request timeout, not an arbitrary submit-side cap. Pass `undefined`
|
|
745
|
-
// so the post-upload deadline check is skipped: a
|
|
974
|
+
// so the post-upload deadline check is skipped: a 600s cap here would throw
|
|
746
975
|
// on a slow-but-successful big upload before createWorkflow (codex).
|
|
747
976
|
const created = await this._uploadAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
|
|
748
977
|
return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
|
|
@@ -758,6 +987,13 @@ export class Recipe {
|
|
|
758
987
|
* slow upload must not proceed to createWorkflow past the deadline.
|
|
759
988
|
*/
|
|
760
989
|
async _uploadAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
|
|
990
|
+
// 0. Preflight the operation lowering BEFORE any upload so a route-invalid
|
|
991
|
+
// recipe (an unhonored / planned / out-of-enum option, or media_unknown on a
|
|
992
|
+
// bare upload-id input) fails fast instead of after the upload bytes are
|
|
993
|
+
// spent — parity with PHP's uploadAndCreate (0azjb6Rg). Lowering does not
|
|
994
|
+
// depend on the upload id, so a clean preflight guarantees the real
|
|
995
|
+
// toWorkflowPayload() lowering below also succeeds.
|
|
996
|
+
this.assertOperationsLowerable();
|
|
761
997
|
// 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
|
|
762
998
|
// a path / blob is uploaded now, emitting {phase:'upload'} progress.
|
|
763
999
|
let fileId;
|
|
@@ -897,6 +1133,15 @@ export class Recipe {
|
|
|
897
1133
|
`(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
|
|
898
1134
|
'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
|
|
899
1135
|
}
|
|
1136
|
+
// Enum-membership gate (rtkzl9gr): reject a value outside the option's
|
|
1137
|
+
// enum before upload (e.g. `metadata: 'keep'` on avif/svg, whose enum is
|
|
1138
|
+
// ['strip','all']). same_format only — the compress option enums apply
|
|
1139
|
+
// definitionally there; a format_change routes via convert, whose enums
|
|
1140
|
+
// may differ, so we leave it to the (conservative) planned gate.
|
|
1141
|
+
if (resolved.route === 'same_format' && isUnknownEnumValue(resolved.inputToken, key, value)) {
|
|
1142
|
+
throw new GislConfigError(`output(): '${key}: ${String(value)}' is not an accepted value for '${key}' on ` +
|
|
1143
|
+
`'${resolved.inputToken}' images. Check the values this format's route accepts.`, { reason: 'invalid_option_value', conflictingFields: [key] });
|
|
1144
|
+
}
|
|
900
1145
|
if (isPlannedValue(resolved.inputToken, key, value)) {
|
|
901
1146
|
throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
|
|
902
1147
|
}
|
|
@@ -1204,8 +1449,8 @@ function _resolveWatermarkWireOp(base) {
|
|
|
1204
1449
|
'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
|
|
1205
1450
|
'Convert the base to a supported format first.', { reason: 'unsupported_media' });
|
|
1206
1451
|
}
|
|
1207
|
-
throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
|
|
1208
|
-
'(
|
|
1452
|
+
throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases. ` +
|
|
1453
|
+
'textWatermark() is image-only, so it is not an alternative for document or audio bases.', { reason: 'unsupported_media' });
|
|
1209
1454
|
}
|
|
1210
1455
|
/**
|
|
1211
1456
|
* Validate a watermark overlay locally: the overlay role is always an IMAGE.
|
|
@@ -1221,8 +1466,9 @@ function _validateWatermarkOverlay(overlay) {
|
|
|
1221
1466
|
}
|
|
1222
1467
|
}
|
|
1223
1468
|
function _lowerWatermarkOp(wireOp, options) {
|
|
1224
|
-
// Watermark options (anchor/opacity/margin_x/margin_y/overlay_width
|
|
1225
|
-
// already wire keys; empty options omit
|
|
1469
|
+
// Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
|
|
1470
|
+
// multi-overlay overlays[] stack) are already wire keys; empty options omit
|
|
1471
|
+
// the `options` key (byte-identical to PHP).
|
|
1226
1472
|
const wire = { ...options };
|
|
1227
1473
|
return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
|
|
1228
1474
|
}
|
|
@@ -1361,6 +1607,10 @@ export class FilesRecipe {
|
|
|
1361
1607
|
thumbnail(options) {
|
|
1362
1608
|
return this.withStep(this.baseRecipe().thumbnail(options));
|
|
1363
1609
|
}
|
|
1610
|
+
/** Apply the same geometric transform (rotate/flip) to every input. Validated via the base {@link Recipe}. */
|
|
1611
|
+
transform(options = {}) {
|
|
1612
|
+
return this.withStep(this.baseRecipe().transform(options));
|
|
1613
|
+
}
|
|
1364
1614
|
/** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
|
|
1365
1615
|
textWatermark(text, options = {}) {
|
|
1366
1616
|
return this.withStep(this.baseRecipe().textWatermark(text, options));
|
|
@@ -1421,7 +1671,7 @@ export class FilesRecipe {
|
|
|
1421
1671
|
toWorkflowPayload(fileIds, callbackUrl) {
|
|
1422
1672
|
const jobs = this.inputs.map((input, i) => {
|
|
1423
1673
|
const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
|
|
1424
|
-
const oneJob = single.toWorkflowPayload(fileIds[i])
|
|
1674
|
+
const oneJob = _nestedSingleJob(single.toWorkflowPayload(fileIds[i]), 'fan-out');
|
|
1425
1675
|
// Key order (id, source, operations) matches the PHP `toWire()` so the
|
|
1426
1676
|
// JSON-string serialisation is byte-identical across languages.
|
|
1427
1677
|
return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
|
|
@@ -1449,42 +1699,30 @@ export class FilesRecipe {
|
|
|
1449
1699
|
if (this.client === undefined) {
|
|
1450
1700
|
throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
|
|
1451
1701
|
}
|
|
1452
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
1702
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1453
1703
|
// 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
|
|
1454
1704
|
// submit() (which passes a webhook → callback_url and no deadline).
|
|
1455
1705
|
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
1456
1706
|
// 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
|
|
1457
1707
|
// `partially_failed` is a normal terminal state here (the helper treats it
|
|
1458
1708
|
// 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
|
-
}
|
|
1709
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
1710
|
+
workflowId: created.workflowId,
|
|
1711
|
+
deadline,
|
|
1712
|
+
signal,
|
|
1713
|
+
onProgress,
|
|
1714
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1715
|
+
useSSE: options.useSSE ?? true,
|
|
1716
|
+
});
|
|
1479
1717
|
// 4. Fetch downloads + project per-job into the partitioned RunResult.
|
|
1480
1718
|
if (Date.now() >= deadline) {
|
|
1481
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
1719
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1482
1720
|
}
|
|
1483
1721
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1484
1722
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1485
1723
|
// cannot return a success past the advertised maxWait deadline.
|
|
1486
1724
|
if (Date.now() >= deadline) {
|
|
1487
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
1725
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1488
1726
|
}
|
|
1489
1727
|
// keyByRef maps each job ref ("file-{i}") to the partition key. Today the
|
|
1490
1728
|
// key is just the index string; the Map seam leaves room for the FF3b
|
|
@@ -1569,9 +1807,10 @@ export class FilesRecipe {
|
|
|
1569
1807
|
*
|
|
1570
1808
|
* **Lowering (one workflow):** each input is uploaded once and wrapped in its
|
|
1571
1809
|
* own single-input `passthrough` source job (`src_N`); the `merge` job consumes
|
|
1572
|
-
* those via `job_output` inputs (array order = play order)
|
|
1573
|
-
* op
|
|
1574
|
-
* convert / thumbnail
|
|
1810
|
+
* those via `job_output` inputs (array order = play order). `merge` is
|
|
1811
|
+
* `sole_op` (ADR-0025), so it is the ONLY op in its job; any post-combine ops
|
|
1812
|
+
* (compress / convert / thumbnail / transform) lower into a downstream `post`
|
|
1813
|
+
* job that consumes the merged output via `job_output`. The
|
|
1575
1814
|
* merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
|
|
1576
1815
|
* lowers identically to the operation-first `client.merge()`.
|
|
1577
1816
|
*
|
|
@@ -1621,11 +1860,21 @@ export class MergedRecipe {
|
|
|
1621
1860
|
}
|
|
1622
1861
|
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
1623
1862
|
}
|
|
1863
|
+
/** Geometric transform (rotate/flip) of the merged output. Passthrough; see {@link Recipe.transform}. */
|
|
1864
|
+
transform(options = {}) {
|
|
1865
|
+
validateVerbOptions('transform', options);
|
|
1866
|
+
const wire = {};
|
|
1867
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1868
|
+
if (value !== undefined)
|
|
1869
|
+
wire[key] = value;
|
|
1870
|
+
}
|
|
1871
|
+
return this.withStep({ opType: 'transform', options: wire });
|
|
1872
|
+
}
|
|
1624
1873
|
/**
|
|
1625
1874
|
* Lower to the merge DAG: one `passthrough` source job per input + one
|
|
1626
|
-
* `merge` job whose `operations[]` is `[merge
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1875
|
+
* `merge` job whose `operations[]` is exactly `[merge]` (sole_op). The merge
|
|
1876
|
+
* job's `inputs[]` consume the source jobs via `job_output` in input (play)
|
|
1877
|
+
* order; any post-combine ops lower into a downstream `post` job.
|
|
1629
1878
|
*
|
|
1630
1879
|
* @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
|
|
1631
1880
|
* (with a webhook), and the cross-language parity harness (with fixed ids).
|
|
@@ -1641,12 +1890,19 @@ export class MergedRecipe {
|
|
|
1641
1890
|
sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
|
|
1642
1891
|
inputs.push({ source: jobOutputSource(srcId) });
|
|
1643
1892
|
});
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1893
|
+
// `merge` is `sole_op` (ADR-0025): the op MUST be alone in its job.
|
|
1894
|
+
// Post-merge steps lower into a DOWNSTREAM job (see {@link _POST_STEP_JOB_REF})
|
|
1895
|
+
// that consumes the merge output via `job_output`. PIiUit28.
|
|
1896
|
+
const mergeJob = {
|
|
1897
|
+
id: 'merge',
|
|
1898
|
+
inputs,
|
|
1899
|
+
operations: [{ type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) }],
|
|
1900
|
+
};
|
|
1649
1901
|
const jobs = [...sourceJobs, mergeJob];
|
|
1902
|
+
const postOps = this.lowerPostSteps(mediaKind);
|
|
1903
|
+
if (postOps.length > 0) {
|
|
1904
|
+
jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('merge'), operations: postOps });
|
|
1905
|
+
}
|
|
1650
1906
|
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
1651
1907
|
}
|
|
1652
1908
|
/** The number of inputs being combined (introspection / tests). */
|
|
@@ -1672,41 +1928,31 @@ export class MergedRecipe {
|
|
|
1672
1928
|
if (this.client === undefined) {
|
|
1673
1929
|
throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
|
|
1674
1930
|
}
|
|
1675
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
1931
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1676
1932
|
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
|
-
}
|
|
1933
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
1934
|
+
workflowId: created.workflowId,
|
|
1935
|
+
deadline,
|
|
1936
|
+
signal,
|
|
1937
|
+
onProgress,
|
|
1938
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1939
|
+
useSSE: options.useSSE ?? true,
|
|
1940
|
+
});
|
|
1697
1941
|
if (Date.now() >= deadline) {
|
|
1698
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
1942
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1699
1943
|
}
|
|
1700
1944
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1701
1945
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1702
1946
|
// cannot return a success past the advertised maxWait deadline.
|
|
1703
1947
|
if (Date.now() >= deadline) {
|
|
1704
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
1705
|
-
}
|
|
1706
|
-
// Project ONLY the
|
|
1707
|
-
// re-expose the raw uploads
|
|
1708
|
-
//
|
|
1709
|
-
|
|
1948
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1949
|
+
}
|
|
1950
|
+
// Project ONLY the terminal deliverable — the `src_*` passthrough jobs
|
|
1951
|
+
// re-expose the raw uploads (plumbing). Post-merge steps lower into the
|
|
1952
|
+
// downstream `_POST_STEP_JOB_REF` job, which is then the deliverable;
|
|
1953
|
+
// otherwise the `merge` job is (mirrors merge.ts + PHP).
|
|
1954
|
+
const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'merge';
|
|
1955
|
+
const mergeDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
|
|
1710
1956
|
const downloader = new LazyHttpDownloader();
|
|
1711
1957
|
return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
|
|
1712
1958
|
}
|
|
@@ -1895,36 +2141,24 @@ export class ArchivedRecipe {
|
|
|
1895
2141
|
if (this.client === undefined) {
|
|
1896
2142
|
throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
|
|
1897
2143
|
}
|
|
1898
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
2144
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1899
2145
|
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
|
-
}
|
|
2146
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
2147
|
+
workflowId: created.workflowId,
|
|
2148
|
+
deadline,
|
|
2149
|
+
signal,
|
|
2150
|
+
onProgress,
|
|
2151
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
2152
|
+
useSSE: options.useSSE ?? true,
|
|
2153
|
+
});
|
|
1920
2154
|
if (Date.now() >= deadline) {
|
|
1921
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
2155
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1922
2156
|
}
|
|
1923
2157
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1924
2158
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1925
2159
|
// cannot return a success past the advertised maxWait deadline.
|
|
1926
2160
|
if (Date.now() >= deadline) {
|
|
1927
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
2161
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1928
2162
|
}
|
|
1929
2163
|
// Project ONLY the archive job's output — the `src_*` passthrough jobs
|
|
1930
2164
|
// re-expose the raw uploads, which are plumbing, not the deliverable.
|
|
@@ -1996,7 +2230,8 @@ export class ArchivedRecipe {
|
|
|
1996
2230
|
* `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
|
|
1997
2231
|
* steps lower into those jobs), and the `watermark` job consumes them via
|
|
1998
2232
|
* `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
|
|
1999
|
-
* `compress`/`convert`/`thumbnail`
|
|
2233
|
+
* `compress`/`convert`/`thumbnail`/`transform` steps lower into a downstream
|
|
2234
|
+
* `post` job on the watermark output (`image_watermark` is `sole_op`). Mirrors
|
|
2000
2235
|
* {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
|
|
2001
2236
|
*/
|
|
2002
2237
|
export class WatermarkedRecipe {
|
|
@@ -2046,11 +2281,22 @@ export class WatermarkedRecipe {
|
|
|
2046
2281
|
}
|
|
2047
2282
|
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
2048
2283
|
}
|
|
2284
|
+
/** Geometric transform (rotate/flip) of the watermarked output. Passthrough; see {@link Recipe.transform}. */
|
|
2285
|
+
transform(options = {}) {
|
|
2286
|
+
validateVerbOptions('transform', options);
|
|
2287
|
+
const wire = {};
|
|
2288
|
+
for (const [key, value] of Object.entries(options)) {
|
|
2289
|
+
if (value !== undefined)
|
|
2290
|
+
wire[key] = value;
|
|
2291
|
+
}
|
|
2292
|
+
return this.withStep({ opType: 'transform', options: wire });
|
|
2293
|
+
}
|
|
2049
2294
|
/**
|
|
2050
2295
|
* Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
|
|
2051
2296
|
* passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
|
|
2052
|
-
* them via `job_output` (role base/overlay)
|
|
2053
|
-
*
|
|
2297
|
+
* them via `job_output` (role base/overlay). The watermark op is `sole_op`
|
|
2298
|
+
* (ADR-0025), so `operations[]` is exactly `[image_watermark|video_watermark]`;
|
|
2299
|
+
* any post-watermark ops lower into a downstream `post` job. `fileIds` is
|
|
2054
2300
|
* `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
|
|
2055
2301
|
* is undetectable/unsupported (the planned-op gate).
|
|
2056
2302
|
*
|
|
@@ -2062,12 +2308,12 @@ export class WatermarkedRecipe {
|
|
|
2062
2308
|
const overlayId = fileIds[1];
|
|
2063
2309
|
// src_0: the base (its preceding steps, else a lossless passthrough).
|
|
2064
2310
|
const baseOps = this.baseSteps.length > 0
|
|
2065
|
-
? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
|
|
2066
|
-
.toWorkflowPayload(baseId).
|
|
2311
|
+
? _nestedSingleJob(new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
|
|
2312
|
+
.toWorkflowPayload(baseId), 'watermark base').operations
|
|
2067
2313
|
: [{ type: 'passthrough' }];
|
|
2068
2314
|
// src_1: the overlay recipe (its own steps, else a lossless passthrough).
|
|
2069
2315
|
const overlayOps = this.overlay.recipeSteps.length > 0
|
|
2070
|
-
? this.overlay.toWorkflowPayload(overlayId).
|
|
2316
|
+
? _nestedSingleJob(this.overlay.toWorkflowPayload(overlayId), 'watermark overlay').operations
|
|
2071
2317
|
: [{ type: 'passthrough' }];
|
|
2072
2318
|
// Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
|
|
2073
2319
|
const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
|
|
@@ -2076,12 +2322,20 @@ export class WatermarkedRecipe {
|
|
|
2076
2322
|
{ source: jobOutputSource('src_0'), role: 'base' },
|
|
2077
2323
|
{ source: jobOutputSource('src_1'), role: 'overlay' },
|
|
2078
2324
|
];
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
const watermarkJob = {
|
|
2325
|
+
// `image_watermark` / `video_watermark` are `sole_op` (ADR-0025): the op
|
|
2326
|
+
// MUST be alone in its job. Post-watermark steps lower into a DOWNSTREAM
|
|
2327
|
+
// job (see {@link _POST_STEP_JOB_REF}) that consumes the watermark output
|
|
2328
|
+
// via `job_output`. PIiUit28.
|
|
2329
|
+
const watermarkJob = {
|
|
2330
|
+
id: 'watermark',
|
|
2331
|
+
inputs,
|
|
2332
|
+
operations: [_lowerWatermarkOp(wireOp, this.watermarkOptions)],
|
|
2333
|
+
};
|
|
2084
2334
|
const jobs = [srcBase, srcOverlay, watermarkJob];
|
|
2335
|
+
const postOps = this.lowerPostSteps(wireOp);
|
|
2336
|
+
if (postOps.length > 0) {
|
|
2337
|
+
jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('watermark'), operations: postOps });
|
|
2338
|
+
}
|
|
2085
2339
|
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
2086
2340
|
}
|
|
2087
2341
|
/** The number of post-watermark ops chained so far (introspection / tests). */
|
|
@@ -2100,38 +2354,29 @@ export class WatermarkedRecipe {
|
|
|
2100
2354
|
if (this.client === undefined) {
|
|
2101
2355
|
throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
|
|
2102
2356
|
}
|
|
2103
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
2357
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
2104
2358
|
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
|
-
}
|
|
2359
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
2360
|
+
workflowId: created.workflowId,
|
|
2361
|
+
deadline,
|
|
2362
|
+
signal,
|
|
2363
|
+
onProgress,
|
|
2364
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
2365
|
+
useSSE: options.useSSE ?? true,
|
|
2366
|
+
});
|
|
2125
2367
|
if (Date.now() >= deadline) {
|
|
2126
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
2368
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
2127
2369
|
}
|
|
2128
2370
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
2129
2371
|
if (Date.now() >= deadline) {
|
|
2130
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
2131
|
-
}
|
|
2132
|
-
// Project ONLY the
|
|
2133
|
-
// re-expose the raw base/overlay uploads
|
|
2134
|
-
|
|
2372
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
2373
|
+
}
|
|
2374
|
+
// Project ONLY the terminal deliverable — the `src_*` passthrough jobs
|
|
2375
|
+
// re-expose the raw base/overlay uploads (plumbing). When post-watermark
|
|
2376
|
+
// steps were chained they lowered into the downstream `_POST_STEP_JOB_REF`
|
|
2377
|
+
// job, which is now the deliverable; otherwise the `watermark` job is.
|
|
2378
|
+
const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'watermark';
|
|
2379
|
+
const watermarkDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
|
|
2135
2380
|
const downloader = new LazyHttpDownloader();
|
|
2136
2381
|
return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
|
|
2137
2382
|
}
|
|
@@ -2200,3 +2445,275 @@ export class WatermarkedRecipe {
|
|
|
2200
2445
|
return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
2201
2446
|
}
|
|
2202
2447
|
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Identity key for batch cross-entry upload dedupe — mirrors the merge
|
|
2450
|
+
* builder's `assetIdentity` (`merge.ts`). Two batch entries whose inputs share
|
|
2451
|
+
* an identity upload ONCE and point both jobs at the shared fileId. `path` uses
|
|
2452
|
+
* the EXACT caller-provided string (no trim / normalise / case-fold, so
|
|
2453
|
+
* `'./a.jpg'` and `'/abs/a.jpg'` do NOT dedupe — by design); `blob` uses
|
|
2454
|
+
* referential identity via a run-local token map (two distinct-but-equal Blobs
|
|
2455
|
+
* still upload twice); `uploadId` uses the fileId itself (already upload-free,
|
|
2456
|
+
* so deduping it is a pure no-op). `blobTokens` is threaded in so a single
|
|
2457
|
+
* planning pass shares one token space.
|
|
2458
|
+
*/
|
|
2459
|
+
function inputIdentity(input, blobTokens) {
|
|
2460
|
+
switch (input.kind) {
|
|
2461
|
+
case 'path':
|
|
2462
|
+
return `path:${input.path}`;
|
|
2463
|
+
case 'uploadId':
|
|
2464
|
+
return `id:${input.fileId}`;
|
|
2465
|
+
case 'blob': {
|
|
2466
|
+
let token = blobTokens.get(input.blob);
|
|
2467
|
+
if (token === undefined) {
|
|
2468
|
+
token = blobTokens.size;
|
|
2469
|
+
blobTokens.set(input.blob, token);
|
|
2470
|
+
}
|
|
2471
|
+
return `blob:${token}`;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* The keyed multi-recipe batch builder (FF7 / MFaCjL8d). `client.batch([r1, r2, …])`
|
|
2477
|
+
* runs N DISTINCT single-input keyed {@link Recipe}s as ONE workflow; the
|
|
2478
|
+
* partitioned {@link RunResult} addresses each entry's outputs by the caller key
|
|
2479
|
+
* given at `client.file(input, key)` time (`res.byKey('hero')`), and one failed
|
|
2480
|
+
* entry lands in `failed` without sinking the rest.
|
|
2481
|
+
*
|
|
2482
|
+
* **v1 scope (locked):** `.run()` only (no `submit()` / reattach — a follow-up);
|
|
2483
|
+
* single-input {@link Recipe} entries only — the multi-input builders
|
|
2484
|
+
* ({@link FilesRecipe}, {@link MergedRecipe}, {@link WatermarkedRecipe},
|
|
2485
|
+
* {@link ArchivedRecipe}) are REJECTED pre-upload. Cross-entry upload dedupe
|
|
2486
|
+
* IS applied (1LwSJcz1): two entries sourcing the SAME input (by
|
|
2487
|
+
* {@link inputIdentity}) upload ONCE and share the resulting fileId —
|
|
2488
|
+
* correctness-neutral (same bytes → same per-job output), it only elides
|
|
2489
|
+
* redundant uploads. Observable caveat: `onProgress` upload-phase events drop
|
|
2490
|
+
* to one-per-UNIQUE input rather than one-per-entry.
|
|
2491
|
+
*
|
|
2492
|
+
* **Lowering (one workflow):** for each entry `i`, lower its single job via
|
|
2493
|
+
* {@link Recipe.toWorkflowPayload} and re-id it `b{i}` — a POSITIONAL namespace
|
|
2494
|
+
* DISTINCT from the fan-out `file-{i}` / merge-archive-watermark `src_{i}` refs so
|
|
2495
|
+
* a future reattach can't misdetect the wire as a fan-out / merge. `keyByRef`
|
|
2496
|
+
* maps each `b{i}` ref to that entry's caller key, so
|
|
2497
|
+
* {@link projectMultiJobToRunResult} partitions per entry (1 job ↔ 1 key:
|
|
2498
|
+
* `completed` → `succeeded`, else → `failed` with a {@link GislItemFailedError}).
|
|
2499
|
+
*
|
|
2500
|
+
* **Immutability:** the ctor is CLIENT-ONLY (the ordered entries + the client) —
|
|
2501
|
+
* entries are already-built Recipes that captured their own preset defaults at
|
|
2502
|
+
* `client.file(...)` time, so batch never re-plumbs
|
|
2503
|
+
* presetDefaults/scopedPresetDefaults. Mirrors the PHP `BatchRecipe`.
|
|
2504
|
+
*/
|
|
2505
|
+
export class BatchRecipe {
|
|
2506
|
+
client;
|
|
2507
|
+
recipes;
|
|
2508
|
+
constructor(recipes, client) {
|
|
2509
|
+
this.client = client;
|
|
2510
|
+
// DEFENSIVE COPY (TS-only): snapshot the caller's array so a later mutation
|
|
2511
|
+
// of it (splice/push after construction, or during an in-flight run()) can't
|
|
2512
|
+
// desync the uploaded fileIds from the lowered jobs/keys — validation,
|
|
2513
|
+
// upload, lowering + keyByRef all iterate this frozen order. PHP is
|
|
2514
|
+
// value-semantics-safe already (arrays copy on pass).
|
|
2515
|
+
this.recipes = [...recipes];
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Execute the batch end-to-end: validate + lowering-preflight EVERY entry
|
|
2519
|
+
* BEFORE any upload, upload each entry's input, create ONE multi-job workflow
|
|
2520
|
+
* (one `b{i}` job per entry), await a terminal state (SSE with poll fallback,
|
|
2521
|
+
* honouring `useSSE`), then partition the per-job downloads into a keyed
|
|
2522
|
+
* {@link RunResult}. `partially_failed` is a NORMAL terminal state here — the
|
|
2523
|
+
* completed entries land in `succeeded`, the rest in `failed`.
|
|
2524
|
+
*
|
|
2525
|
+
* Requires a client bound at construction time — `gisl().batch([...])` wires
|
|
2526
|
+
* it; a directly-constructed {@link BatchRecipe} (e.g. a lowering-only test)
|
|
2527
|
+
* throws {@link GislConfigError}. Mirrors the fan-out {@link FilesRecipe.run}.
|
|
2528
|
+
*/
|
|
2529
|
+
async run(options = {}) {
|
|
2530
|
+
const signal = options.signal;
|
|
2531
|
+
const onProgress = options.onProgress;
|
|
2532
|
+
if (this.client === undefined) {
|
|
2533
|
+
throw new GislConfigError('BatchRecipe.run() requires a client; build the batch via gisl().batch([...]) rather than constructing BatchRecipe directly.', { reason: 'no_client' });
|
|
2534
|
+
}
|
|
2535
|
+
// Validate + lowering-preflight EVERY entry BEFORE any upload: a structural
|
|
2536
|
+
// violation (bad type / missing / duplicate key) or an invalid lowering
|
|
2537
|
+
// aborts here so no input uploads. NOTE — like FilesRecipe, TS does NOT
|
|
2538
|
+
// pre-check path readability: a nonexistent/unreadable path surfaces INSIDE
|
|
2539
|
+
// uploadFile during upload, so an earlier entry's input may already be
|
|
2540
|
+
// uploaded when a later entry's path fails. (PHP pre-checks path/resource
|
|
2541
|
+
// uploadability; this TS/PHP difference mirrors each language's existing
|
|
2542
|
+
// FilesRecipe behavior and is intentionally NOT closed here — a TS
|
|
2543
|
+
// path-precheck would diverge batch from FilesRecipe.)
|
|
2544
|
+
this.validatePreUpload();
|
|
2545
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
2546
|
+
// 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
|
|
2547
|
+
// sends NO webhook (run()-only), so `callback_url` is omitted from the
|
|
2548
|
+
// payload (the closure receives `callbackUrl` undefined).
|
|
2549
|
+
// Dedupe cross-entry uploads: collapse to the first-appearance-unique input
|
|
2550
|
+
// list, upload each unique input ONCE, then expand the returned unique
|
|
2551
|
+
// fileIds back to one-per-entry (in entry order) so the b{i} jobs + keyByRef
|
|
2552
|
+
// stay N-length and correctness-neutral. See planUploads / inputIdentity.
|
|
2553
|
+
const { uniqueInputs, entryToUnique } = this.planUploads();
|
|
2554
|
+
const created = await _uploadInputsAndCreate(this.client, uniqueInputs, (uniqueFileIds, callbackUrl) => this.toWorkflowPayload(entryToUnique.map((u) => uniqueFileIds[u]), callbackUrl), {
|
|
2555
|
+
webhook: undefined,
|
|
2556
|
+
deadline,
|
|
2557
|
+
onProgress,
|
|
2558
|
+
signal,
|
|
2559
|
+
probeBeforeCreate: options.probeBeforeCreate,
|
|
2560
|
+
probeTimeoutMs: options.probeTimeoutMs,
|
|
2561
|
+
uploadsLabel: 'batch',
|
|
2562
|
+
workflowLabel: 'the batch workflow',
|
|
2563
|
+
});
|
|
2564
|
+
// 3. Wait to terminal status — SSE first, poll on a genuine SSE error (or
|
|
2565
|
+
// poll-direct when `useSSE: false`). Caller-aborted + deadline errors
|
|
2566
|
+
// propagate (not transient) — see _awaitTerminal.
|
|
2567
|
+
const finalStatus = await _awaitTerminal(this.client, {
|
|
2568
|
+
workflowId: created.workflowId,
|
|
2569
|
+
deadline,
|
|
2570
|
+
signal,
|
|
2571
|
+
onProgress,
|
|
2572
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
2573
|
+
useSSE: options.useSSE ?? true,
|
|
2574
|
+
});
|
|
2575
|
+
// 4. Fetch downloads + project per-job into the keyed RunResult.
|
|
2576
|
+
if (Date.now() >= deadline) {
|
|
2577
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
2578
|
+
}
|
|
2579
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
2580
|
+
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
2581
|
+
// cannot return a success past the advertised maxWait deadline.
|
|
2582
|
+
if (Date.now() >= deadline) {
|
|
2583
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
2584
|
+
}
|
|
2585
|
+
const downloader = new LazyHttpDownloader();
|
|
2586
|
+
return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Lower the batch to ONE multi-job workflow-create payload against a list of
|
|
2590
|
+
* resolved upload ids (one per entry, in entry order). Each entry `i` becomes
|
|
2591
|
+
* ONE job re-id'd `b{i}` carrying that entry's lowered `source` + `operations`.
|
|
2592
|
+
* Composes the single-file {@link Recipe.toWorkflowPayload} per entry so each
|
|
2593
|
+
* keeps its own media-hint + preset resolution and lowering logic is not
|
|
2594
|
+
* duplicated. `callback_url` is built in ONLY when a webhook is supplied
|
|
2595
|
+
* (batch v1 run() supplies none, so it is omitted).
|
|
2596
|
+
*
|
|
2597
|
+
* @internal Consumed by {@link run} (after uploading) and the cross-language
|
|
2598
|
+
* golden-payload lowering test (with fixed ids). Not caller-facing.
|
|
2599
|
+
*/
|
|
2600
|
+
toWorkflowPayload(fileIds, callbackUrl) {
|
|
2601
|
+
const jobs = this.recipes.map((entry, i) => {
|
|
2602
|
+
const oneJob = _nestedSingleJob(entry.toWorkflowPayload(fileIds[i]), 'batch');
|
|
2603
|
+
// Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
|
|
2604
|
+
// merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
|
|
2605
|
+
// `toWire()` so the JSON serialisation is byte-identical across languages.
|
|
2606
|
+
return { id: `b${i}`, source: oneJob.source, operations: oneJob.operations };
|
|
2607
|
+
});
|
|
2608
|
+
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
2609
|
+
}
|
|
2610
|
+
/** The number of recipe entries in this batch (introspection / tests). */
|
|
2611
|
+
get recipeCount() {
|
|
2612
|
+
return this.recipes.length;
|
|
2613
|
+
}
|
|
2614
|
+
// ---------------------------------------------------------------------------
|
|
2615
|
+
/**
|
|
2616
|
+
* Validate the batch AND lowering-preflight every entry BEFORE any upload
|
|
2617
|
+
* fires — an invalid entry costs no bandwidth. TWO PASSES (mirrors PHP
|
|
2618
|
+
* `BatchRecipe`'s structural-loop-then-preflight-loop), throwing
|
|
2619
|
+
* {@link GislConfigError}:
|
|
2620
|
+
* 0. empty batch → `no_recipes` (checked first).
|
|
2621
|
+
* PASS 1 (structural, ALL entries in order):
|
|
2622
|
+
* - a KNOWN multi-input builder (checked FIRST — they do NOT extend
|
|
2623
|
+
* {@link Recipe}, so the not-a-Recipe catch-all would otherwise misreport
|
|
2624
|
+
* them as plain type errors) → `multi_input_recipe_unsupported`;
|
|
2625
|
+
* - a non-{@link Recipe} entry → `invalid_recipe`;
|
|
2626
|
+
* - a missing/empty key → `missing_key`;
|
|
2627
|
+
* - a duplicate key → `duplicate_key`.
|
|
2628
|
+
* PASS 2 (lowering preflight, ALL entries): lower each entry (via
|
|
2629
|
+
* {@link Recipe.toWorkflowPayload}) so an invalid lowering throws BEFORE any
|
|
2630
|
+
* upload, mirroring what {@link FilesRecipe} lowers pre-create.
|
|
2631
|
+
*
|
|
2632
|
+
* Two passes so a batch with MULTIPLE distinct violations throws the SAME
|
|
2633
|
+
* reason regardless of entry order (a structural error anywhere wins over a
|
|
2634
|
+
* lowering error elsewhere) — converging TS + PHP error reporting. The
|
|
2635
|
+
* offending key/index rides the MESSAGE (not `conflictingFields`, which is
|
|
2636
|
+
* reserved for wire FIELD names).
|
|
2637
|
+
*/
|
|
2638
|
+
validatePreUpload() {
|
|
2639
|
+
if (this.recipes.length === 0) {
|
|
2640
|
+
throw new GislConfigError('batch() requires at least one recipe. Pass an ordered array of ' +
|
|
2641
|
+
'client.file(input, key).<op>(...) recipes, each with a unique key.', { reason: 'no_recipes' });
|
|
2642
|
+
}
|
|
2643
|
+
// PASS 1 — structural checks across ALL entries in order.
|
|
2644
|
+
const seenKeys = new Set();
|
|
2645
|
+
this.recipes.forEach((rawEntry, i) => {
|
|
2646
|
+
// Treat each entry as unknown for the runtime type guards: the public
|
|
2647
|
+
// signature is ReadonlyArray<Recipe>, but a plain-JS caller can pass
|
|
2648
|
+
// anything, and the multi-input builders are structurally Recipe-adjacent.
|
|
2649
|
+
const entry = rawEntry;
|
|
2650
|
+
// ORDER MATTERS (codex r2 #1): check the KNOWN multi-input builders FIRST.
|
|
2651
|
+
// They do NOT extend Recipe, so the not-a-Recipe catch-all below would
|
|
2652
|
+
// otherwise misreport them as plain caller type errors.
|
|
2653
|
+
if (entry instanceof FilesRecipe ||
|
|
2654
|
+
entry instanceof MergedRecipe ||
|
|
2655
|
+
entry instanceof WatermarkedRecipe ||
|
|
2656
|
+
entry instanceof ArchivedRecipe) {
|
|
2657
|
+
throw new GislConfigError(`batch() entry at index ${i} is a multi-input recipe (${entry.constructor.name}), ` +
|
|
2658
|
+
'which is not supported in batch v1 — batch accepts only single-input keyed recipes ' +
|
|
2659
|
+
'(client.file(input, key).<op>(...)). Run the multi-input recipe on its own.', { reason: 'multi_input_recipe_unsupported' });
|
|
2660
|
+
}
|
|
2661
|
+
// THEN the catch-all: not a Recipe at all (null / string / plain object).
|
|
2662
|
+
if (!(entry instanceof Recipe)) {
|
|
2663
|
+
throw new GislConfigError(`batch() entry at index ${i} is not a recipe. Build each entry via ` +
|
|
2664
|
+
'client.file(input, key).<op>(...) before passing it to batch().', { reason: 'invalid_recipe' });
|
|
2665
|
+
}
|
|
2666
|
+
// Keys are the result address → each entry needs a unique, non-empty key.
|
|
2667
|
+
const key = entry.key();
|
|
2668
|
+
if (key === undefined || key === '') {
|
|
2669
|
+
throw new GislConfigError(`batch() entry at index ${i} has no key. Every batch entry needs a unique non-empty key ` +
|
|
2670
|
+
"(client.file(input, 'key')) to address its result.", { reason: 'missing_key' });
|
|
2671
|
+
}
|
|
2672
|
+
if (seenKeys.has(key)) {
|
|
2673
|
+
throw new GislConfigError(`batch() has a duplicate key '${key}' (entry at index ${i}). Every batch entry needs a unique key.`, { reason: 'duplicate_key' });
|
|
2674
|
+
}
|
|
2675
|
+
seenKeys.add(key);
|
|
2676
|
+
});
|
|
2677
|
+
// PASS 2 — lowering preflight across ALL entries (each is now known to be a
|
|
2678
|
+
// Recipe). Lower each entry now so an invalid lowering (e.g. an undetectable
|
|
2679
|
+
// input + optimize, an unrepresentable output route) throws BEFORE any
|
|
2680
|
+
// upload. The 'preflight' id is a throwaway placeholder — run() re-lowers
|
|
2681
|
+
// against the real upload ids post-upload.
|
|
2682
|
+
this.recipes.forEach((entry) => {
|
|
2683
|
+
entry.toWorkflowPayload('preflight');
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Collapse the entry inputs to a first-appearance-unique list for cross-entry
|
|
2688
|
+
* upload dedupe: two entries sourcing the SAME input (by {@link inputIdentity})
|
|
2689
|
+
* upload ONCE and share the fileId. Returns the ordered `uniqueInputs` plus an
|
|
2690
|
+
* `entryToUnique` index map (length N, entry order) so {@link run} can expand
|
|
2691
|
+
* the unique fileIds back to one-per-entry before {@link toWorkflowPayload} —
|
|
2692
|
+
* keeping the b{i} refs + {@link keyByRef} N-length and correctness-neutral.
|
|
2693
|
+
*/
|
|
2694
|
+
planUploads() {
|
|
2695
|
+
const blobTokens = new Map();
|
|
2696
|
+
const identityToUnique = new Map();
|
|
2697
|
+
const uniqueInputs = [];
|
|
2698
|
+
const entryToUnique = this.recipes.map((entry) => {
|
|
2699
|
+
const input = entry.recipeInput;
|
|
2700
|
+
const id = inputIdentity(input, blobTokens);
|
|
2701
|
+
let uniqueIndex = identityToUnique.get(id);
|
|
2702
|
+
if (uniqueIndex === undefined) {
|
|
2703
|
+
uniqueIndex = uniqueInputs.length;
|
|
2704
|
+
uniqueInputs.push(input);
|
|
2705
|
+
identityToUnique.set(id, uniqueIndex);
|
|
2706
|
+
}
|
|
2707
|
+
return uniqueIndex;
|
|
2708
|
+
});
|
|
2709
|
+
return { uniqueInputs, entryToUnique };
|
|
2710
|
+
}
|
|
2711
|
+
/** Map each `b{i}` job ref to that entry's caller key (validated non-empty). */
|
|
2712
|
+
keyByRef() {
|
|
2713
|
+
const map = new Map();
|
|
2714
|
+
this.recipes.forEach((entry, i) => {
|
|
2715
|
+
map.set(`b${i}`, entry.key() ?? null);
|
|
2716
|
+
});
|
|
2717
|
+
return map;
|
|
2718
|
+
}
|
|
2719
|
+
}
|