@giveitsmaller/sdk 0.20.0 → 0.22.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 +55 -14
- package/dist/_audit.js +2 -4
- package/dist/builder.d.ts +4 -4
- package/dist/builder.js +47 -22
- package/dist/client.d.ts +9 -6
- package/dist/client.js +90 -31
- package/dist/ergonomic/image_output_routes.d.ts +97 -0
- package/dist/ergonomic/image_output_routes.js +227 -25
- package/dist/ergonomic/option_types.d.ts +11 -2
- package/dist/ergonomic/preset_resolver.d.ts +24 -2
- package/dist/ergonomic/preset_resolver.js +100 -10
- package/dist/ergonomic/presets/image_compress.js +16 -4
- package/dist/ergonomic/presets/index.d.ts +9 -8
- package/dist/ergonomic/presets/index.js +1 -9
- package/dist/ergonomic/presets/video_compress.d.ts +14 -0
- package/dist/errors.d.ts +101 -2
- package/dist/errors.js +108 -1
- package/dist/file-first.d.ts +81 -13
- package/dist/file-first.js +329 -69
- 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 +159 -1
- 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 +21 -2
- package/dist/handle.d.ts +6 -1
- package/dist/handle.js +42 -13
- package/dist/index.core.d.ts +4 -4
- package/dist/index.core.js +6 -3
- package/dist/merge.d.ts +23 -0
- package/dist/merge.js +2 -2
- package/dist/sse.js +49 -1
- package/dist/types.d.ts +11 -3
- 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, dependsOnViolation, 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),
|
|
@@ -341,10 +341,54 @@ export function isFanoutStatus(finalStatus) {
|
|
|
341
341
|
return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
|
|
342
342
|
}
|
|
343
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
|
+
]);
|
|
344
387
|
/**
|
|
345
388
|
* True when a terminal status describes a fluent `files([...]).merge(...)`
|
|
346
389
|
* combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
|
|
347
|
-
* (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
|
|
348
392
|
* lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
|
|
349
393
|
* — filtering the `src_*` passthrough plumbing — even after a
|
|
350
394
|
* `client.workflow(id)` reattach (no construction-time marker), matching
|
|
@@ -363,6 +407,9 @@ export function isMergeStatus(finalStatus) {
|
|
|
363
407
|
hasMerge = true;
|
|
364
408
|
continue;
|
|
365
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;
|
|
366
413
|
if (!_MERGE_SRC_REF.test(job.ref))
|
|
367
414
|
return false;
|
|
368
415
|
}
|
|
@@ -395,9 +442,10 @@ export function isArchiveStatus(finalStatus) {
|
|
|
395
442
|
}
|
|
396
443
|
/**
|
|
397
444
|
* True when a terminal status describes a fluent `file(...).watermark(overlay)`
|
|
398
|
-
* — at least one job ref `watermark` and every OTHER job ref is `src_{i}`
|
|
399
|
-
* ids the {@link WatermarkedRecipe} lowering
|
|
400
|
-
* 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
|
|
401
449
|
* {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
|
|
402
450
|
* the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
|
|
403
451
|
* Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
|
|
@@ -415,11 +463,117 @@ export function isWatermarkStatus(finalStatus) {
|
|
|
415
463
|
hasWatermark = true;
|
|
416
464
|
continue;
|
|
417
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;
|
|
418
469
|
if (!_MERGE_SRC_REF.test(job.ref))
|
|
419
470
|
return false;
|
|
420
471
|
}
|
|
421
472
|
return hasWatermark;
|
|
422
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
|
+
}
|
|
423
577
|
/** Named constructors for {@link FileInput} — mirror the PHP static factories. */
|
|
424
578
|
export const fileInput = {
|
|
425
579
|
path(path) {
|
|
@@ -694,10 +848,25 @@ export class Recipe {
|
|
|
694
848
|
*/
|
|
695
849
|
toWorkflowPayload(fileId, callbackUrl) {
|
|
696
850
|
const operations = this.steps.map((step, i) => this.lowerStep(step, i));
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
|
|
700
|
-
|
|
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');
|
|
701
870
|
}
|
|
702
871
|
/** The result-addressing key passed to `file()`, or undefined. */
|
|
703
872
|
key() {
|
|
@@ -741,7 +910,7 @@ export class Recipe {
|
|
|
741
910
|
if (this.client === undefined) {
|
|
742
911
|
throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
|
|
743
912
|
}
|
|
744
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
913
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
745
914
|
// 1+2. Upload (when required) + create the workflow. Shared with submit()
|
|
746
915
|
// (which passes a webhook → callback_url). run() passes no webhook.
|
|
747
916
|
const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
@@ -759,18 +928,25 @@ export class Recipe {
|
|
|
759
928
|
// 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
|
|
760
929
|
// downloads, so check before issuing the request (mirrors builder.ts).
|
|
761
930
|
if (Date.now() >= deadline) {
|
|
762
|
-
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);
|
|
763
932
|
}
|
|
764
933
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
765
934
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
766
935
|
// cannot return a success past the advertised maxWait deadline.
|
|
767
936
|
if (Date.now() >= deadline) {
|
|
768
|
-
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);
|
|
769
938
|
}
|
|
770
939
|
// Download URLs from getWorkflowDownloads are pre-signed and require no SDK
|
|
771
940
|
// auth, so the downloader issues a plain unauthenticated fetch.
|
|
772
941
|
const downloader = new LazyHttpDownloader();
|
|
773
|
-
|
|
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);
|
|
774
950
|
}
|
|
775
951
|
/**
|
|
776
952
|
* Fire-and-forget the recipe: upload the input (when required), create the
|
|
@@ -795,7 +971,7 @@ export class Recipe {
|
|
|
795
971
|
// submit() is fire-and-forget — NO whole-run deadline. The upload may be
|
|
796
972
|
// large (a multi-GB master, example 12) and is bounded by the HTTP client's
|
|
797
973
|
// own request timeout, not an arbitrary submit-side cap. Pass `undefined`
|
|
798
|
-
// so the post-upload deadline check is skipped: a
|
|
974
|
+
// so the post-upload deadline check is skipped: a 600s cap here would throw
|
|
799
975
|
// on a slow-but-successful big upload before createWorkflow (codex).
|
|
800
976
|
const created = await this._uploadAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
|
|
801
977
|
return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
|
|
@@ -811,6 +987,13 @@ export class Recipe {
|
|
|
811
987
|
* slow upload must not proceed to createWorkflow past the deadline.
|
|
812
988
|
*/
|
|
813
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();
|
|
814
997
|
// 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
|
|
815
998
|
// a path / blob is uploaded now, emitting {phase:'upload'} progress.
|
|
816
999
|
let fileId;
|
|
@@ -918,7 +1101,7 @@ export class Recipe {
|
|
|
918
1101
|
if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
|
|
919
1102
|
const facade = { output_format: requested };
|
|
920
1103
|
for (const [key, value] of Object.entries(step.options)) {
|
|
921
|
-
if (key === 'output_format' || value === undefined)
|
|
1104
|
+
if (key === 'output_format' || value === undefined || value === null)
|
|
922
1105
|
continue;
|
|
923
1106
|
if (key !== 'quality') {
|
|
924
1107
|
throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
|
|
@@ -939,7 +1122,9 @@ export class Recipe {
|
|
|
939
1122
|
}
|
|
940
1123
|
const wireOptions = { output_format: resolved.outputFormatWire };
|
|
941
1124
|
for (const [key, value] of Object.entries(step.options)) {
|
|
942
|
-
|
|
1125
|
+
// Drop a null value (as PHP does) so a null option never reaches the wire
|
|
1126
|
+
// and is treated as absent by the depends_on gate — full null parity (codex).
|
|
1127
|
+
if (key === 'output_format' || value === undefined || value === null)
|
|
943
1128
|
continue;
|
|
944
1129
|
if (resolved.planned.has(key)) {
|
|
945
1130
|
throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
|
|
@@ -950,11 +1135,42 @@ export class Recipe {
|
|
|
950
1135
|
`(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
|
|
951
1136
|
'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
|
|
952
1137
|
}
|
|
1138
|
+
// Enum-membership gate (rtkzl9gr): reject a value outside the option's
|
|
1139
|
+
// enum before upload (e.g. `metadata: 'keep'` on avif/svg, whose enum is
|
|
1140
|
+
// ['strip','all']). same_format only — the compress option enums apply
|
|
1141
|
+
// definitionally there; a format_change routes via convert, whose enums
|
|
1142
|
+
// may differ, so we leave it to the (conservative) planned gate.
|
|
1143
|
+
if (resolved.route === 'same_format' && isUnknownEnumValue(resolved.inputToken, key, value)) {
|
|
1144
|
+
throw new GislConfigError(`output(): '${key}: ${String(value)}' is not an accepted value for '${key}' on ` +
|
|
1145
|
+
`'${resolved.inputToken}' images. Check the values this format's route accepts.`, { reason: 'invalid_option_value', conflictingFields: [key] });
|
|
1146
|
+
}
|
|
953
1147
|
if (isPlannedValue(resolved.inputToken, key, value)) {
|
|
954
1148
|
throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
|
|
955
1149
|
}
|
|
956
1150
|
wireOptions[key] = value;
|
|
957
1151
|
}
|
|
1152
|
+
// quality_preset's contract `depends_on: { encoding_mode: auto_quality }`
|
|
1153
|
+
// (86gAu5Tr) — infer the mode when the caller set NONE so the preset forms a
|
|
1154
|
+
// VALID request. SAME_FORMAT only: encoding_mode is a compress optimiser and
|
|
1155
|
+
// quality_preset isn't honored on a format_change. An explicitly-conflicting
|
|
1156
|
+
// mode is rejected by the general depends_on gate below.
|
|
1157
|
+
if (resolved.route === 'same_format' &&
|
|
1158
|
+
wireOptions.quality_preset !== undefined &&
|
|
1159
|
+
wireOptions.encoding_mode === undefined) {
|
|
1160
|
+
wireOptions.encoding_mode = 'auto_quality';
|
|
1161
|
+
}
|
|
1162
|
+
// General contract `depends_on` validation (ehHU08Hu), scoped per route: the
|
|
1163
|
+
// universal fit→width|height dep runs on BOTH routes (identical in compress +
|
|
1164
|
+
// convert); the encoding_mode-family deps run on same_format only. Subsumes
|
|
1165
|
+
// the 86gAu5Tr auto_quality gate plus target_size_bytes-without-target_size,
|
|
1166
|
+
// fit-without-width/height, and any future compress-image depends_on.
|
|
1167
|
+
const dependency = dependsOnViolation(wireOptions, resolved.route);
|
|
1168
|
+
if (dependency !== undefined) {
|
|
1169
|
+
throw new GislConfigError(dependency.message, {
|
|
1170
|
+
reason: 'invalid_option_combination',
|
|
1171
|
+
conflictingFields: [...dependency.conflictingFields],
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
958
1174
|
return { type: resolved.sourceOp, options: wireOptions };
|
|
959
1175
|
}
|
|
960
1176
|
/**
|
|
@@ -1257,8 +1473,8 @@ function _resolveWatermarkWireOp(base) {
|
|
|
1257
1473
|
'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
|
|
1258
1474
|
'Convert the base to a supported format first.', { reason: 'unsupported_media' });
|
|
1259
1475
|
}
|
|
1260
|
-
throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
|
|
1261
|
-
'(
|
|
1476
|
+
throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases. ` +
|
|
1477
|
+
'textWatermark() is image-only, so it is not an alternative for document or audio bases.', { reason: 'unsupported_media' });
|
|
1262
1478
|
}
|
|
1263
1479
|
/**
|
|
1264
1480
|
* Validate a watermark overlay locally: the overlay role is always an IMAGE.
|
|
@@ -1274,9 +1490,22 @@ function _validateWatermarkOverlay(overlay) {
|
|
|
1274
1490
|
}
|
|
1275
1491
|
}
|
|
1276
1492
|
function _lowerWatermarkOp(wireOp, options) {
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
// the `
|
|
1493
|
+
// `overlays[]` (the multi-overlay stack) is a live contract option but is NOT
|
|
1494
|
+
// reachable through watermark(): the facade composites exactly ONE overlay —
|
|
1495
|
+
// the positional `overlay` (wire source src_1) — so overlays[1..] reference
|
|
1496
|
+
// sources it cannot create, any entry is invalid on a non-image base, and the
|
|
1497
|
+
// contract's `minItems: 1` makes an empty array invalid too. Reject it here at
|
|
1498
|
+
// lowering (mutation-safe — reads the FINAL options, catching a post-watermark()
|
|
1499
|
+
// `opts.overlays = [...]`) and point callers at the single-overlay knobs.
|
|
1500
|
+
// Real multi-overlay stacking is a future feature (Vbbdq9C4).
|
|
1501
|
+
if (options.overlays !== undefined) {
|
|
1502
|
+
throw new GislConfigError("watermark(): 'overlays[]' (multi-overlay stacking) is not supported — watermark() composites a " +
|
|
1503
|
+
'single overlay (the positional overlay argument). Use the top-level anchor / opacity / margin_x / ' +
|
|
1504
|
+
'margin_y / overlay_width options to place it. Multi-overlay stacking is a future feature.', { reason: 'overlays_unsupported', conflictingFields: ['overlays'] });
|
|
1505
|
+
}
|
|
1506
|
+
// The remaining watermark options (anchor/opacity/margin_x/margin_y/
|
|
1507
|
+
// overlay_width) are already wire keys; empty options omit the `options` key
|
|
1508
|
+
// (byte-identical to PHP).
|
|
1280
1509
|
const wire = { ...options };
|
|
1281
1510
|
return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
|
|
1282
1511
|
}
|
|
@@ -1297,6 +1526,14 @@ function _lowerWatermarkOp(wireOp, options) {
|
|
|
1297
1526
|
*/
|
|
1298
1527
|
async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
|
|
1299
1528
|
const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
|
|
1529
|
+
// Preflight: lower the composed chains with placeholder ids so a route-invalid
|
|
1530
|
+
// option (or any lowering-time gate — overlays, sole_op split, route/enum) in
|
|
1531
|
+
// ANY input's chain — a watermark base/overlay, a merge/archive member — throws
|
|
1532
|
+
// BEFORE we spend a single upload byte. The multi-input analog of the
|
|
1533
|
+
// single-input Recipe.assertOperationsLowerable preflight (0azjb6Rg); mirrors
|
|
1534
|
+
// PHP. The placeholder ids never reach the wire — the payload is discarded
|
|
1535
|
+
// (T3ltXsou). toWorkflowPayload is pure, so re-lowering at create is cheap.
|
|
1536
|
+
toPayload(inputs.map((_, i) => `preflight_${i}`));
|
|
1300
1537
|
const fileIds = [];
|
|
1301
1538
|
// Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
|
|
1302
1539
|
// carries no local mime/size, so it is excluded — never probed).
|
|
@@ -1479,7 +1716,7 @@ export class FilesRecipe {
|
|
|
1479
1716
|
toWorkflowPayload(fileIds, callbackUrl) {
|
|
1480
1717
|
const jobs = this.inputs.map((input, i) => {
|
|
1481
1718
|
const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
|
|
1482
|
-
const oneJob = single.toWorkflowPayload(fileIds[i])
|
|
1719
|
+
const oneJob = _nestedSingleJob(single.toWorkflowPayload(fileIds[i]), 'fan-out');
|
|
1483
1720
|
// Key order (id, source, operations) matches the PHP `toWire()` so the
|
|
1484
1721
|
// JSON-string serialisation is byte-identical across languages.
|
|
1485
1722
|
return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
|
|
@@ -1507,7 +1744,7 @@ export class FilesRecipe {
|
|
|
1507
1744
|
if (this.client === undefined) {
|
|
1508
1745
|
throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
|
|
1509
1746
|
}
|
|
1510
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
1747
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1511
1748
|
// 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
|
|
1512
1749
|
// submit() (which passes a webhook → callback_url and no deadline).
|
|
1513
1750
|
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
@@ -1524,13 +1761,13 @@ export class FilesRecipe {
|
|
|
1524
1761
|
});
|
|
1525
1762
|
// 4. Fetch downloads + project per-job into the partitioned RunResult.
|
|
1526
1763
|
if (Date.now() >= deadline) {
|
|
1527
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
1764
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1528
1765
|
}
|
|
1529
1766
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1530
1767
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1531
1768
|
// cannot return a success past the advertised maxWait deadline.
|
|
1532
1769
|
if (Date.now() >= deadline) {
|
|
1533
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
1770
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1534
1771
|
}
|
|
1535
1772
|
// keyByRef maps each job ref ("file-{i}") to the partition key. Today the
|
|
1536
1773
|
// key is just the index string; the Map seam leaves room for the FF3b
|
|
@@ -1615,9 +1852,10 @@ export class FilesRecipe {
|
|
|
1615
1852
|
*
|
|
1616
1853
|
* **Lowering (one workflow):** each input is uploaded once and wrapped in its
|
|
1617
1854
|
* own single-input `passthrough` source job (`src_N`); the `merge` job consumes
|
|
1618
|
-
* those via `job_output` inputs (array order = play order)
|
|
1619
|
-
* op
|
|
1620
|
-
* convert / thumbnail
|
|
1855
|
+
* those via `job_output` inputs (array order = play order). `merge` is
|
|
1856
|
+
* `sole_op` (ADR-0025), so it is the ONLY op in its job; any post-combine ops
|
|
1857
|
+
* (compress / convert / thumbnail / transform) lower into a downstream `post`
|
|
1858
|
+
* job that consumes the merged output via `job_output`. The
|
|
1621
1859
|
* merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
|
|
1622
1860
|
* lowers identically to the operation-first `client.merge()`.
|
|
1623
1861
|
*
|
|
@@ -1679,9 +1917,9 @@ export class MergedRecipe {
|
|
|
1679
1917
|
}
|
|
1680
1918
|
/**
|
|
1681
1919
|
* Lower to the merge DAG: one `passthrough` source job per input + one
|
|
1682
|
-
* `merge` job whose `operations[]` is `[merge
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1920
|
+
* `merge` job whose `operations[]` is exactly `[merge]` (sole_op). The merge
|
|
1921
|
+
* job's `inputs[]` consume the source jobs via `job_output` in input (play)
|
|
1922
|
+
* order; any post-combine ops lower into a downstream `post` job.
|
|
1685
1923
|
*
|
|
1686
1924
|
* @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
|
|
1687
1925
|
* (with a webhook), and the cross-language parity harness (with fixed ids).
|
|
@@ -1697,12 +1935,19 @@ export class MergedRecipe {
|
|
|
1697
1935
|
sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
|
|
1698
1936
|
inputs.push({ source: jobOutputSource(srcId) });
|
|
1699
1937
|
});
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1938
|
+
// `merge` is `sole_op` (ADR-0025): the op MUST be alone in its job.
|
|
1939
|
+
// Post-merge steps lower into a DOWNSTREAM job (see {@link _POST_STEP_JOB_REF})
|
|
1940
|
+
// that consumes the merge output via `job_output`. PIiUit28.
|
|
1941
|
+
const mergeJob = {
|
|
1942
|
+
id: 'merge',
|
|
1943
|
+
inputs,
|
|
1944
|
+
operations: [{ type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) }],
|
|
1945
|
+
};
|
|
1705
1946
|
const jobs = [...sourceJobs, mergeJob];
|
|
1947
|
+
const postOps = this.lowerPostSteps(mediaKind);
|
|
1948
|
+
if (postOps.length > 0) {
|
|
1949
|
+
jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('merge'), operations: postOps });
|
|
1950
|
+
}
|
|
1706
1951
|
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
1707
1952
|
}
|
|
1708
1953
|
/** The number of inputs being combined (introspection / tests). */
|
|
@@ -1728,7 +1973,7 @@ export class MergedRecipe {
|
|
|
1728
1973
|
if (this.client === undefined) {
|
|
1729
1974
|
throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
|
|
1730
1975
|
}
|
|
1731
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
1976
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1732
1977
|
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
1733
1978
|
const finalStatus = await _awaitTerminal(this.client, {
|
|
1734
1979
|
workflowId: created.workflowId,
|
|
@@ -1739,18 +1984,20 @@ export class MergedRecipe {
|
|
|
1739
1984
|
useSSE: options.useSSE ?? true,
|
|
1740
1985
|
});
|
|
1741
1986
|
if (Date.now() >= deadline) {
|
|
1742
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
1987
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1743
1988
|
}
|
|
1744
1989
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1745
1990
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1746
1991
|
// cannot return a success past the advertised maxWait deadline.
|
|
1747
1992
|
if (Date.now() >= deadline) {
|
|
1748
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
1749
|
-
}
|
|
1750
|
-
// Project ONLY the
|
|
1751
|
-
// re-expose the raw uploads
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1993
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1994
|
+
}
|
|
1995
|
+
// Project ONLY the terminal deliverable — the `src_*` passthrough jobs
|
|
1996
|
+
// re-expose the raw uploads (plumbing). Post-merge steps lower into the
|
|
1997
|
+
// downstream `_POST_STEP_JOB_REF` job, which is then the deliverable;
|
|
1998
|
+
// otherwise the `merge` job is (mirrors merge.ts + PHP).
|
|
1999
|
+
const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'merge';
|
|
2000
|
+
const mergeDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
|
|
1754
2001
|
const downloader = new LazyHttpDownloader();
|
|
1755
2002
|
return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
|
|
1756
2003
|
}
|
|
@@ -1939,7 +2186,7 @@ export class ArchivedRecipe {
|
|
|
1939
2186
|
if (this.client === undefined) {
|
|
1940
2187
|
throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
|
|
1941
2188
|
}
|
|
1942
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
2189
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
1943
2190
|
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
1944
2191
|
const finalStatus = await _awaitTerminal(this.client, {
|
|
1945
2192
|
workflowId: created.workflowId,
|
|
@@ -1950,13 +2197,13 @@ export class ArchivedRecipe {
|
|
|
1950
2197
|
useSSE: options.useSSE ?? true,
|
|
1951
2198
|
});
|
|
1952
2199
|
if (Date.now() >= deadline) {
|
|
1953
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
2200
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
1954
2201
|
}
|
|
1955
2202
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
1956
2203
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
1957
2204
|
// cannot return a success past the advertised maxWait deadline.
|
|
1958
2205
|
if (Date.now() >= deadline) {
|
|
1959
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
2206
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
1960
2207
|
}
|
|
1961
2208
|
// Project ONLY the archive job's output — the `src_*` passthrough jobs
|
|
1962
2209
|
// re-expose the raw uploads, which are plumbing, not the deliverable.
|
|
@@ -2028,7 +2275,8 @@ export class ArchivedRecipe {
|
|
|
2028
2275
|
* `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
|
|
2029
2276
|
* steps lower into those jobs), and the `watermark` job consumes them via
|
|
2030
2277
|
* `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
|
|
2031
|
-
* `compress`/`convert`/`thumbnail`
|
|
2278
|
+
* `compress`/`convert`/`thumbnail`/`transform` steps lower into a downstream
|
|
2279
|
+
* `post` job on the watermark output (`image_watermark` is `sole_op`). Mirrors
|
|
2032
2280
|
* {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
|
|
2033
2281
|
*/
|
|
2034
2282
|
export class WatermarkedRecipe {
|
|
@@ -2091,8 +2339,9 @@ export class WatermarkedRecipe {
|
|
|
2091
2339
|
/**
|
|
2092
2340
|
* Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
|
|
2093
2341
|
* passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
|
|
2094
|
-
* them via `job_output` (role base/overlay)
|
|
2095
|
-
*
|
|
2342
|
+
* them via `job_output` (role base/overlay). The watermark op is `sole_op`
|
|
2343
|
+
* (ADR-0025), so `operations[]` is exactly `[image_watermark|video_watermark]`;
|
|
2344
|
+
* any post-watermark ops lower into a downstream `post` job. `fileIds` is
|
|
2096
2345
|
* `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
|
|
2097
2346
|
* is undetectable/unsupported (the planned-op gate).
|
|
2098
2347
|
*
|
|
@@ -2104,12 +2353,12 @@ export class WatermarkedRecipe {
|
|
|
2104
2353
|
const overlayId = fileIds[1];
|
|
2105
2354
|
// src_0: the base (its preceding steps, else a lossless passthrough).
|
|
2106
2355
|
const baseOps = this.baseSteps.length > 0
|
|
2107
|
-
? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
|
|
2108
|
-
.toWorkflowPayload(baseId).
|
|
2356
|
+
? _nestedSingleJob(new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
|
|
2357
|
+
.toWorkflowPayload(baseId), 'watermark base').operations
|
|
2109
2358
|
: [{ type: 'passthrough' }];
|
|
2110
2359
|
// src_1: the overlay recipe (its own steps, else a lossless passthrough).
|
|
2111
2360
|
const overlayOps = this.overlay.recipeSteps.length > 0
|
|
2112
|
-
? this.overlay.toWorkflowPayload(overlayId).
|
|
2361
|
+
? _nestedSingleJob(this.overlay.toWorkflowPayload(overlayId), 'watermark overlay').operations
|
|
2113
2362
|
: [{ type: 'passthrough' }];
|
|
2114
2363
|
// Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
|
|
2115
2364
|
const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
|
|
@@ -2118,12 +2367,20 @@ export class WatermarkedRecipe {
|
|
|
2118
2367
|
{ source: jobOutputSource('src_0'), role: 'base' },
|
|
2119
2368
|
{ source: jobOutputSource('src_1'), role: 'overlay' },
|
|
2120
2369
|
];
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
const watermarkJob = {
|
|
2370
|
+
// `image_watermark` / `video_watermark` are `sole_op` (ADR-0025): the op
|
|
2371
|
+
// MUST be alone in its job. Post-watermark steps lower into a DOWNSTREAM
|
|
2372
|
+
// job (see {@link _POST_STEP_JOB_REF}) that consumes the watermark output
|
|
2373
|
+
// via `job_output`. PIiUit28.
|
|
2374
|
+
const watermarkJob = {
|
|
2375
|
+
id: 'watermark',
|
|
2376
|
+
inputs,
|
|
2377
|
+
operations: [_lowerWatermarkOp(wireOp, this.watermarkOptions)],
|
|
2378
|
+
};
|
|
2126
2379
|
const jobs = [srcBase, srcOverlay, watermarkJob];
|
|
2380
|
+
const postOps = this.lowerPostSteps(wireOp);
|
|
2381
|
+
if (postOps.length > 0) {
|
|
2382
|
+
jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('watermark'), operations: postOps });
|
|
2383
|
+
}
|
|
2127
2384
|
return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
|
|
2128
2385
|
}
|
|
2129
2386
|
/** The number of post-watermark ops chained so far (introspection / tests). */
|
|
@@ -2142,7 +2399,7 @@ export class WatermarkedRecipe {
|
|
|
2142
2399
|
if (this.client === undefined) {
|
|
2143
2400
|
throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
|
|
2144
2401
|
}
|
|
2145
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
2402
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
2146
2403
|
const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
|
|
2147
2404
|
const finalStatus = await _awaitTerminal(this.client, {
|
|
2148
2405
|
workflowId: created.workflowId,
|
|
@@ -2153,15 +2410,18 @@ export class WatermarkedRecipe {
|
|
|
2153
2410
|
useSSE: options.useSSE ?? true,
|
|
2154
2411
|
});
|
|
2155
2412
|
if (Date.now() >= deadline) {
|
|
2156
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
2413
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
2157
2414
|
}
|
|
2158
2415
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
2159
2416
|
if (Date.now() >= deadline) {
|
|
2160
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
2161
|
-
}
|
|
2162
|
-
// Project ONLY the
|
|
2163
|
-
// re-expose the raw base/overlay uploads
|
|
2164
|
-
|
|
2417
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
2418
|
+
}
|
|
2419
|
+
// Project ONLY the terminal deliverable — the `src_*` passthrough jobs
|
|
2420
|
+
// re-expose the raw base/overlay uploads (plumbing). When post-watermark
|
|
2421
|
+
// steps were chained they lowered into the downstream `_POST_STEP_JOB_REF`
|
|
2422
|
+
// job, which is now the deliverable; otherwise the `watermark` job is.
|
|
2423
|
+
const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'watermark';
|
|
2424
|
+
const watermarkDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
|
|
2165
2425
|
const downloader = new LazyHttpDownloader();
|
|
2166
2426
|
return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
|
|
2167
2427
|
}
|
|
@@ -2327,7 +2587,7 @@ export class BatchRecipe {
|
|
|
2327
2587
|
// FilesRecipe behavior and is intentionally NOT closed here — a TS
|
|
2328
2588
|
// path-precheck would diverge batch from FilesRecipe.)
|
|
2329
2589
|
this.validatePreUpload();
|
|
2330
|
-
const deadline = Date.now() + _parseMaxWait(options.maxWait ??
|
|
2590
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
|
|
2331
2591
|
// 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
|
|
2332
2592
|
// sends NO webhook (run()-only), so `callback_url` is omitted from the
|
|
2333
2593
|
// payload (the closure receives `callbackUrl` undefined).
|
|
@@ -2359,13 +2619,13 @@ export class BatchRecipe {
|
|
|
2359
2619
|
});
|
|
2360
2620
|
// 4. Fetch downloads + project per-job into the keyed RunResult.
|
|
2361
2621
|
if (Date.now() >= deadline) {
|
|
2362
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched
|
|
2622
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
|
|
2363
2623
|
}
|
|
2364
2624
|
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
2365
2625
|
// TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
|
|
2366
2626
|
// cannot return a success past the advertised maxWait deadline.
|
|
2367
2627
|
if (Date.now() >= deadline) {
|
|
2368
|
-
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed
|
|
2628
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
|
|
2369
2629
|
}
|
|
2370
2630
|
const downloader = new LazyHttpDownloader();
|
|
2371
2631
|
return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
|
|
@@ -2384,7 +2644,7 @@ export class BatchRecipe {
|
|
|
2384
2644
|
*/
|
|
2385
2645
|
toWorkflowPayload(fileIds, callbackUrl) {
|
|
2386
2646
|
const jobs = this.recipes.map((entry, i) => {
|
|
2387
|
-
const oneJob = entry.toWorkflowPayload(fileIds[i])
|
|
2647
|
+
const oneJob = _nestedSingleJob(entry.toWorkflowPayload(fileIds[i]), 'batch');
|
|
2388
2648
|
// Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
|
|
2389
2649
|
// merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
|
|
2390
2650
|
// `toWire()` so the JSON serialisation is byte-identical across languages.
|