@giveitsmaller/sdk 0.20.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.
@@ -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),
@@ -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 assigns). The data-driven seam that
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}` (the
399
- * ids the {@link WatermarkedRecipe} lowering assigns: `src_0` base, `src_1`
400
- * overlay). Lets {@link Handle.wait}/{@link Handle.result} AND
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
- // Key order (source, operations) matches the PHP `toWire()` so the
698
- // JSON-string serialisation is byte-identical across languages.
699
- const job = { source: uploadSource(fileId), operations };
700
- return callbackUrl === undefined ? { jobs: [job] } : { jobs: [job], callback_url: callbackUrl };
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 ?? 300_000);
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
- return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
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 300s cap here would throw
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;
@@ -950,6 +1133,15 @@ export class Recipe {
950
1133
  `(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
951
1134
  'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
952
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
+ }
953
1145
  if (isPlannedValue(resolved.inputToken, key, value)) {
954
1146
  throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
955
1147
  }
@@ -1257,8 +1449,8 @@ function _resolveWatermarkWireOp(base) {
1257
1449
  'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
1258
1450
  'Convert the base to a supported format first.', { reason: 'unsupported_media' });
1259
1451
  }
1260
- throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
1261
- '(audio overlay and luma matte are planned operations). Use textWatermark() for document/text watermarks.', { reason: 'unsupported_media' });
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' });
1262
1454
  }
1263
1455
  /**
1264
1456
  * Validate a watermark overlay locally: the overlay role is always an IMAGE.
@@ -1479,7 +1671,7 @@ export class FilesRecipe {
1479
1671
  toWorkflowPayload(fileIds, callbackUrl) {
1480
1672
  const jobs = this.inputs.map((input, i) => {
1481
1673
  const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
1482
- const oneJob = single.toWorkflowPayload(fileIds[i]).jobs[0];
1674
+ const oneJob = _nestedSingleJob(single.toWorkflowPayload(fileIds[i]), 'fan-out');
1483
1675
  // Key order (id, source, operations) matches the PHP `toWire()` so the
1484
1676
  // JSON-string serialisation is byte-identical across languages.
1485
1677
  return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
@@ -1507,7 +1699,7 @@ export class FilesRecipe {
1507
1699
  if (this.client === undefined) {
1508
1700
  throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
1509
1701
  }
1510
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1702
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1511
1703
  // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
1512
1704
  // submit() (which passes a webhook → callback_url and no deadline).
1513
1705
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
@@ -1524,13 +1716,13 @@ export class FilesRecipe {
1524
1716
  });
1525
1717
  // 4. Fetch downloads + project per-job into the partitioned RunResult.
1526
1718
  if (Date.now() >= deadline) {
1527
- 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);
1528
1720
  }
1529
1721
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1530
1722
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1531
1723
  // cannot return a success past the advertised maxWait deadline.
1532
1724
  if (Date.now() >= deadline) {
1533
- 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);
1534
1726
  }
1535
1727
  // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
1536
1728
  // key is just the index string; the Map seam leaves room for the FF3b
@@ -1615,9 +1807,10 @@ export class FilesRecipe {
1615
1807
  *
1616
1808
  * **Lowering (one workflow):** each input is uploaded once and wrapped in its
1617
1809
  * own single-input `passthrough` source job (`src_N`); the `merge` job consumes
1618
- * those via `job_output` inputs (array order = play order) and carries the merge
1619
- * op FIRST in its `operations[]`, followed by any post-combine ops (compress /
1620
- * convert / thumbnail) so they run on the merged output in the same job. The
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
1621
1814
  * merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
1622
1815
  * lowers identically to the operation-first `client.merge()`.
1623
1816
  *
@@ -1679,9 +1872,9 @@ export class MergedRecipe {
1679
1872
  }
1680
1873
  /**
1681
1874
  * Lower to the merge DAG: one `passthrough` source job per input + one
1682
- * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
1683
- * merge job's `inputs[]` consume the source jobs via `job_output` in input
1684
- * (play) order.
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.
1685
1878
  *
1686
1879
  * @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
1687
1880
  * (with a webhook), and the cross-language parity harness (with fixed ids).
@@ -1697,12 +1890,19 @@ export class MergedRecipe {
1697
1890
  sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1698
1891
  inputs.push({ source: jobOutputSource(srcId) });
1699
1892
  });
1700
- const operations = [
1701
- { type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
1702
- ...this.lowerPostSteps(mediaKind),
1703
- ];
1704
- const mergeJob = { id: 'merge', inputs, operations };
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
+ };
1705
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
+ }
1706
1906
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1707
1907
  }
1708
1908
  /** The number of inputs being combined (introspection / tests). */
@@ -1728,7 +1928,7 @@ export class MergedRecipe {
1728
1928
  if (this.client === undefined) {
1729
1929
  throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1730
1930
  }
1731
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1931
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1732
1932
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1733
1933
  const finalStatus = await _awaitTerminal(this.client, {
1734
1934
  workflowId: created.workflowId,
@@ -1739,18 +1939,20 @@ export class MergedRecipe {
1739
1939
  useSSE: options.useSSE ?? true,
1740
1940
  });
1741
1941
  if (Date.now() >= deadline) {
1742
- 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);
1743
1943
  }
1744
1944
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1745
1945
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1746
1946
  // cannot return a success past the advertised maxWait deadline.
1747
1947
  if (Date.now() >= deadline) {
1748
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1749
- }
1750
- // Project ONLY the merge job's output — the `src_*` passthrough jobs
1751
- // re-expose the raw uploads, which are plumbing, not the deliverable
1752
- // (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
1753
- const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
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);
1754
1956
  const downloader = new LazyHttpDownloader();
1755
1957
  return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
1756
1958
  }
@@ -1939,7 +2141,7 @@ export class ArchivedRecipe {
1939
2141
  if (this.client === undefined) {
1940
2142
  throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1941
2143
  }
1942
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2144
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1943
2145
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1944
2146
  const finalStatus = await _awaitTerminal(this.client, {
1945
2147
  workflowId: created.workflowId,
@@ -1950,13 +2152,13 @@ export class ArchivedRecipe {
1950
2152
  useSSE: options.useSSE ?? true,
1951
2153
  });
1952
2154
  if (Date.now() >= deadline) {
1953
- 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);
1954
2156
  }
1955
2157
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1956
2158
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1957
2159
  // cannot return a success past the advertised maxWait deadline.
1958
2160
  if (Date.now() >= deadline) {
1959
- 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);
1960
2162
  }
1961
2163
  // Project ONLY the archive job's output — the `src_*` passthrough jobs
1962
2164
  // re-expose the raw uploads, which are plumbing, not the deliverable.
@@ -2028,7 +2230,8 @@ export class ArchivedRecipe {
2028
2230
  * `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
2029
2231
  * steps lower into those jobs), and the `watermark` job consumes them via
2030
2232
  * `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
2031
- * `compress`/`convert`/`thumbnail` chain onto the watermark output. Mirrors
2233
+ * `compress`/`convert`/`thumbnail`/`transform` steps lower into a downstream
2234
+ * `post` job on the watermark output (`image_watermark` is `sole_op`). Mirrors
2032
2235
  * {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
2033
2236
  */
2034
2237
  export class WatermarkedRecipe {
@@ -2091,8 +2294,9 @@ export class WatermarkedRecipe {
2091
2294
  /**
2092
2295
  * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2093
2296
  * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
2094
- * them via `job_output` (role base/overlay) and whose `operations[]` is
2095
- * `[image_watermark|video_watermark, ...post-watermark ops]`. `fileIds` is
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
2096
2300
  * `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
2097
2301
  * is undetectable/unsupported (the planned-op gate).
2098
2302
  *
@@ -2104,12 +2308,12 @@ export class WatermarkedRecipe {
2104
2308
  const overlayId = fileIds[1];
2105
2309
  // src_0: the base (its preceding steps, else a lossless passthrough).
2106
2310
  const baseOps = this.baseSteps.length > 0
2107
- ? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2108
- .toWorkflowPayload(baseId).jobs[0].operations
2311
+ ? _nestedSingleJob(new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2312
+ .toWorkflowPayload(baseId), 'watermark base').operations
2109
2313
  : [{ type: 'passthrough' }];
2110
2314
  // src_1: the overlay recipe (its own steps, else a lossless passthrough).
2111
2315
  const overlayOps = this.overlay.recipeSteps.length > 0
2112
- ? this.overlay.toWorkflowPayload(overlayId).jobs[0].operations
2316
+ ? _nestedSingleJob(this.overlay.toWorkflowPayload(overlayId), 'watermark overlay').operations
2113
2317
  : [{ type: 'passthrough' }];
2114
2318
  // Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
2115
2319
  const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
@@ -2118,12 +2322,20 @@ export class WatermarkedRecipe {
2118
2322
  { source: jobOutputSource('src_0'), role: 'base' },
2119
2323
  { source: jobOutputSource('src_1'), role: 'overlay' },
2120
2324
  ];
2121
- const operations = [
2122
- _lowerWatermarkOp(wireOp, this.watermarkOptions),
2123
- ...this.lowerPostSteps(wireOp),
2124
- ];
2125
- const watermarkJob = { id: 'watermark', inputs, operations };
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
+ };
2126
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
+ }
2127
2339
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2128
2340
  }
2129
2341
  /** The number of post-watermark ops chained so far (introspection / tests). */
@@ -2142,7 +2354,7 @@ export class WatermarkedRecipe {
2142
2354
  if (this.client === undefined) {
2143
2355
  throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2144
2356
  }
2145
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2357
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2146
2358
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2147
2359
  const finalStatus = await _awaitTerminal(this.client, {
2148
2360
  workflowId: created.workflowId,
@@ -2153,15 +2365,18 @@ export class WatermarkedRecipe {
2153
2365
  useSSE: options.useSSE ?? true,
2154
2366
  });
2155
2367
  if (Date.now() >= deadline) {
2156
- 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);
2157
2369
  }
2158
2370
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2159
2371
  if (Date.now() >= deadline) {
2160
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2161
- }
2162
- // Project ONLY the watermark job's output — the `src_*` passthrough jobs
2163
- // re-expose the raw base/overlay uploads, which are plumbing.
2164
- const watermarkDownloads = downloads.downloads.filter((d) => d.ref === 'watermark');
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);
2165
2380
  const downloader = new LazyHttpDownloader();
2166
2381
  return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
2167
2382
  }
@@ -2327,7 +2542,7 @@ export class BatchRecipe {
2327
2542
  // FilesRecipe behavior and is intentionally NOT closed here — a TS
2328
2543
  // path-precheck would diverge batch from FilesRecipe.)
2329
2544
  this.validatePreUpload();
2330
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2545
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2331
2546
  // 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
2332
2547
  // sends NO webhook (run()-only), so `callback_url` is omitted from the
2333
2548
  // payload (the closure receives `callbackUrl` undefined).
@@ -2359,13 +2574,13 @@ export class BatchRecipe {
2359
2574
  });
2360
2575
  // 4. Fetch downloads + project per-job into the keyed RunResult.
2361
2576
  if (Date.now() >= deadline) {
2362
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2577
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
2363
2578
  }
2364
2579
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2365
2580
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
2366
2581
  // cannot return a success past the advertised maxWait deadline.
2367
2582
  if (Date.now() >= deadline) {
2368
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2583
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
2369
2584
  }
2370
2585
  const downloader = new LazyHttpDownloader();
2371
2586
  return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
@@ -2384,7 +2599,7 @@ export class BatchRecipe {
2384
2599
  */
2385
2600
  toWorkflowPayload(fileIds, callbackUrl) {
2386
2601
  const jobs = this.recipes.map((entry, i) => {
2387
- const oneJob = entry.toWorkflowPayload(fileIds[i]).jobs[0];
2602
+ const oneJob = _nestedSingleJob(entry.toWorkflowPayload(fileIds[i]), 'batch');
2388
2603
  // Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
2389
2604
  // merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
2390
2605
  // `toWire()` so the JSON serialisation is byte-identical across languages.
@@ -63,20 +63,6 @@ export declare const AudioSampleRate: {
63
63
  readonly _48000: 48000;
64
64
  };
65
65
  export type AudioSampleRate = typeof AudioSampleRate[keyof typeof AudioSampleRate];
66
- export declare const PdfProfile: {
67
- readonly Screen: "screen";
68
- readonly Ebook: "ebook";
69
- readonly Printer: "printer";
70
- readonly Prepress: "prepress";
71
- };
72
- export type PdfProfile = typeof PdfProfile[keyof typeof PdfProfile];
73
- export declare const PdfColorspace: {
74
- readonly Unchanged: "unchanged";
75
- readonly Rgb: "rgb";
76
- readonly Cmyk: "cmyk";
77
- readonly Grayscale: "grayscale";
78
- };
79
- export type PdfColorspace = typeof PdfColorspace[keyof typeof PdfColorspace];
80
66
  /** Catalog of every ergonomic enum (canonicalName → wire). */
81
67
  export declare const ERGONOMIC_ENUMS: {
82
68
  readonly OptimizeFor: {
@@ -135,16 +121,4 @@ export declare const ERGONOMIC_ENUMS: {
135
121
  readonly _44100: 44100;
136
122
  readonly _48000: 48000;
137
123
  };
138
- readonly PdfProfile: {
139
- readonly Screen: "screen";
140
- readonly Ebook: "ebook";
141
- readonly Printer: "printer";
142
- readonly Prepress: "prepress";
143
- };
144
- readonly PdfColorspace: {
145
- readonly Unchanged: "unchanged";
146
- readonly Rgb: "rgb";
147
- readonly Cmyk: "cmyk";
148
- readonly Grayscale: "grayscale";
149
- };
150
124
  };