@giveitsmaller/sdk 0.15.0 → 0.17.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.
@@ -9,10 +9,12 @@
9
9
  *
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
- import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
12
+ import { GislConfigError, GislItemFailedError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, _cappedProbeTimeoutMs, } from './builder.js';
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
+ import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
17
+ import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
16
18
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
19
  import { uploadSource, jobOutputSource } from './types.js';
18
20
  // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
@@ -150,10 +152,16 @@ export class RunResult {
150
152
  const rest = {
151
153
  artifacts: this.artifacts.map(file),
152
154
  succeeded: this.succeeded.map((i) => ({ key: i.key, outputs: i.outputs.map(file) })),
153
- failed: this.failed.map((f) => ({
154
- key: f.key,
155
- error: f.error instanceof Error ? f.error.message : String(f.error),
156
- })),
155
+ // Field order (key, error, state, errorMessage?, errorCode?) is fixed to
156
+ // match the PHP ItemFailure::toArray() so JSON-string parity holds; the two
157
+ // optional keys are OMITTED when absent (cancel/expire carry only state),
158
+ // mirroring PHP's omit-when-null (NOT emitted as `undefined`/`null`).
159
+ failed: this.failed.map((f) => {
160
+ const e = f.error;
161
+ const base = { key: f.key, error: e.message, state: e.state };
162
+ const withMsg = e.errorMessage === undefined ? base : { ...base, errorMessage: e.errorMessage };
163
+ return e.errorCode === undefined ? withMsg : { ...withMsg, errorCode: e.errorCode };
164
+ }),
157
165
  };
158
166
  const head = { workflowId: this.workflowId, state: this.state, ok: this.ok };
159
167
  // Insert `url` BETWEEN ok and artifacts when present, matching the PHP
@@ -173,22 +181,15 @@ export class RunResult {
173
181
  }
174
182
  }
175
183
  /**
176
- * Flatten the terminal workflow status + its downloads into a {@link RunResult}.
177
- *
178
- * Shared by {@link Recipe.run} (passes its recipe key) and the file-first
179
- * {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
180
- * passes `null` because a reattached handle carries no recipe key).
181
- *
182
- * **Partition invariant (carries a prior codex-review fix — do NOT let it
183
- * drift):** success is ONLY `state === 'completed'`. Every other terminal
184
- * state — `failed`, `partially_failed`, `cancelled`, `expired`,
185
- * `paused_insufficient_credits` — partitions into `failed[]` so a caller's
186
- * `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
187
- * clean result.
188
- *
189
- * @internal Exported for reuse by the file-first `Handle`; not part of the
190
- * caller-facing fluent surface.
184
+ * Extract the human + machine error from the FIRST failing operation in `ops`
185
+ * (the first op carrying an `errorMessage` OR `errorCode`), reading BOTH from the
186
+ * SAME op so a code from one op can't pair with a message from another. Both are
187
+ * absent for terminal states with no failing op (cancel/expire/credit-pause).
191
188
  */
189
+ function firstOpError(ops) {
190
+ const op = ops.find((o) => o.errorMessage !== undefined || o.errorCode !== undefined);
191
+ return { errorMessage: op?.errorMessage, errorCode: op?.errorCode };
192
+ }
192
193
  export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownloads, key, downloader) {
193
194
  // Flatten to the lean OutputFile[] (the four file-first fields only).
194
195
  const artifacts = [];
@@ -210,14 +211,10 @@ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownload
210
211
  failed = [];
211
212
  }
212
213
  else {
213
- const firstError = (finalStatus.jobs ?? [])
214
- .flatMap((j) => j.operations ?? [])
215
- .map((op) => op.errorMessage)
216
- .find((m) => m !== undefined);
214
+ // First failing op across ALL jobs (downloads path is whole-workflow scoped).
215
+ const { errorMessage, errorCode } = firstOpError((finalStatus.jobs ?? []).flatMap((j) => j.operations ?? []));
217
216
  succeeded = [];
218
- failed = [
219
- { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
220
- ];
217
+ failed = [{ key, error: new GislItemFailedError(key, state, errorMessage, errorCode) }];
221
218
  }
222
219
  return new RunResult(workflowId, state, artifacts, succeeded, failed, downloader);
223
220
  }
@@ -269,12 +266,11 @@ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads
269
266
  succeeded.push({ key, outputs });
270
267
  }
271
268
  else {
272
- const firstError = (job.operations ?? [])
273
- .map((op) => op.errorMessage)
274
- .find((m) => m !== undefined);
269
+ // Per-job scoped: read the error from THIS job's ops only.
270
+ const { errorMessage, errorCode } = firstOpError(job.operations ?? []);
275
271
  failed.push({
276
272
  key,
277
- error: new Error(firstError !== undefined ? `${job.status}: ${firstError}` : String(job.status)),
273
+ error: new GislItemFailedError(key, String(job.status), errorMessage, errorCode),
278
274
  });
279
275
  }
280
276
  }
@@ -356,6 +352,33 @@ export function isArchiveStatus(finalStatus) {
356
352
  }
357
353
  return hasArchive;
358
354
  }
355
+ /**
356
+ * True when a terminal status describes a fluent `file(...).watermark(overlay)`
357
+ * — at least one job ref `watermark` and every OTHER job ref is `src_{i}` (the
358
+ * ids the {@link WatermarkedRecipe} lowering assigns: `src_0` base, `src_1`
359
+ * overlay). Lets {@link Handle.wait}/{@link Handle.result} AND
360
+ * {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
361
+ * the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
362
+ * Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
363
+ * {@link isArchiveStatus}.
364
+ *
365
+ * @internal Exported for the file-first `Handle`; not part of the public API.
366
+ */
367
+ export function isWatermarkStatus(finalStatus) {
368
+ const jobs = finalStatus.jobs ?? [];
369
+ if (jobs.length === 0)
370
+ return false;
371
+ let hasWatermark = false;
372
+ for (const job of jobs) {
373
+ if (job.ref === 'watermark') {
374
+ hasWatermark = true;
375
+ continue;
376
+ }
377
+ if (!_MERGE_SRC_REF.test(job.ref))
378
+ return false;
379
+ }
380
+ return hasWatermark;
381
+ }
359
382
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
360
383
  export const fileInput = {
361
384
  path(path) {
@@ -441,21 +464,23 @@ export class Recipe {
441
464
  * additional per-op convert options.
442
465
  */
443
466
  convert(format, options = {}) {
467
+ // Eager pre-upload key validation (rejects unknown keys + a user-supplied
468
+ // output_format/format, which this verb owns via the `format` argument).
469
+ validateVerbOptions('convert', options);
444
470
  // The convert op's wire key is `output_format` (contract: convert.yaml,
445
- // required, all media), NOT `format`. Spread options FIRST so the explicit
446
- // shorthand wins over an `output_format` key in the bag.
447
- // The shorthand owns the format a stray legacy `format` key in the bag is
448
- // not a valid convert option; drop it so the wire never carries both keys.
449
- const rest = { ...options };
450
- delete rest.format;
451
- return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
471
+ // required, all media), NOT `format`. Validation above guarantees the bag
472
+ // carries neither `format` nor `output_format`, so no drop is needed.
473
+ return this.withStep({ opType: 'convert', options: { ...options, output_format: format } });
452
474
  }
453
475
  /**
454
- * Generate a preview. Width and/or height in pixels; any additional per-op
455
- * thumbnail options pass through. An omitted (`undefined`) value is dropped
456
- * from the wire options (not sent as `undefined`).
476
+ * Generate a preview / resize. `width` AND `height` are required (the contract
477
+ * marks both required for image/video/document); any additional per-op
478
+ * thumbnail option passes through. An omitted (`undefined`) optional value is
479
+ * dropped from the wire options (not sent as `undefined`).
457
480
  */
458
- thumbnail(options = {}) {
481
+ thumbnail(options) {
482
+ validateVerbOptions('thumbnail', options);
483
+ assertThumbnailDimensions(options);
459
484
  const wire = {};
460
485
  for (const [key, value] of Object.entries(options)) {
461
486
  if (value !== undefined)
@@ -463,15 +488,91 @@ export class Recipe {
463
488
  }
464
489
  return this.withStep({ opType: 'thumbnail', options: wire });
465
490
  }
491
+ /**
492
+ * Produce ONE transformed image: keep or change format, plus quality, resize
493
+ * and route-honored controls. The single user-facing image transform — the SDK
494
+ * resolves the route from `(input format, output_format)` against the contract's
495
+ * image-output-routes projection and lowers to that route's wire op:
496
+ * same-format → `compress` (optimiser, `output_format: 'original'`), format-change
497
+ * → `convert` (transcoder, `output_format: <fmt>`). Only options the resolved
498
+ * route honors are sent; a planned or not-honored option throws BEFORE upload.
499
+ * Resize (`width`/`height`/`fit`, via `options` or {@link resize}) stays on the
500
+ * SAME op — one output, never a separate thumbnail.
501
+ *
502
+ * `format` omitted → keep the input format (same-format optimiser route).
503
+ */
504
+ output(format, options = {}) {
505
+ // Eager pre-upload key validation (coarse: rejects keys no image route honors,
506
+ // + a bag-supplied output_format/format which the positional `format` owns).
507
+ validateVerbOptions('output', options);
508
+ const wire = {};
509
+ for (const [key, value] of Object.entries(options)) {
510
+ if (value !== undefined)
511
+ wire[key] = value;
512
+ }
513
+ // Store the REQUESTED format token under `output_format`; lowerOutputStep
514
+ // resolves the route and rewrites it to the wire value ('original' for
515
+ // same-format). Omitted format → no output_format key → same-format route.
516
+ if (format !== undefined)
517
+ wire.output_format = format;
518
+ return this.withStep({ opType: 'output', options: wire });
519
+ }
520
+ /**
521
+ * Resize as part of the Output transform. Merges `width`/`height`/`fit` into the
522
+ * PRECEDING `output()` step (one artifact); if no Output step precedes, appends a
523
+ * same-format Output step carrying the resize. Never emits a `thumbnail` op.
524
+ * `height` is optional — width-only resize preserves aspect ratio. Resize is
525
+ * raster-only (e.g. an SVG input has no resize on its route → throws at lower).
526
+ */
527
+ resize(width, height, fit) {
528
+ const resizeOptions = { width };
529
+ if (height !== undefined)
530
+ resizeOptions.height = height;
531
+ if (fit !== undefined)
532
+ resizeOptions.fit = fit;
533
+ const steps = [...this.steps];
534
+ const last = steps[steps.length - 1];
535
+ if (last !== undefined && last.opType === 'output') {
536
+ steps[steps.length - 1] = { opType: 'output', options: { ...last.options, ...resizeOptions } };
537
+ return new Recipe(this.input, this.recipeKey, steps, this.presetDefaults, this.scopedPresetDefaults, this.client);
538
+ }
539
+ return this.withStep({ opType: 'output', options: resizeOptions });
540
+ }
466
541
  /**
467
542
  * Apply a text watermark. Single-input (the text is an option, not a
468
543
  * secondary file) — lowers to the `text_watermark` op with a `text` option;
469
544
  * `options` carries any additional per-op watermark options.
470
545
  */
471
546
  textWatermark(text, options = {}) {
472
- // Spread options FIRST so the explicit `text` argument is authoritative.
547
+ // Eager pre-upload validation (rejects unknown keys + a user-supplied `text`,
548
+ // which this verb owns via the first argument).
549
+ validateVerbOptions('textWatermark', options);
473
550
  return this.withStep({ opType: 'text_watermark', options: { ...options, text } });
474
551
  }
552
+ /**
553
+ * Composite an image OVERLAY onto this file (a multi-input op). `overlay` is a
554
+ * secondary file-NODE (a {@link Recipe} — e.g. `client.file('logo.png')`),
555
+ * itself optionally processed first. Routes by THIS file's effective media:
556
+ * image base → `image_watermark` (stable), video base → `video_watermark`
557
+ * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
558
+ * throw locally BEFORE any upload (the planned-op gate). `options` carries the
559
+ * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
560
+ * `overlay_width`). Returns a {@link WatermarkedRecipe} (chain post-watermark
561
+ * `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
562
+ * {@link textWatermark} (single-input text overlay).
563
+ */
564
+ watermark(overlay, options = {}) {
565
+ // Eager pre-upload key validation (against image_watermark ∪ video_watermark,
566
+ // since the base media may be undetectable here; routing is gated separately).
567
+ validateVerbOptions('watermark', options);
568
+ // Eager gate when the base media is KNOWN (unit-testable pre-upload); an
569
+ // undetectable base is DEFERRED — re-checked pre-upload in run()/submit().
570
+ const base = _watermarkEffectiveBase(this.input, this.steps);
571
+ if (base.media !== undefined)
572
+ _resolveWatermarkWireOp(base);
573
+ _validateWatermarkOverlay(overlay);
574
+ return new WatermarkedRecipe(this.input, this.steps, overlay, options, [], this.presetDefaults, this.scopedPresetDefaults, this.client);
575
+ }
475
576
  /**
476
577
  * Lower this recipe to a workflow-create payload against a resolved upload
477
578
  * id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
@@ -487,7 +588,7 @@ export class Recipe {
487
588
  * fixed id). Not part of the caller-facing fluent surface.
488
589
  */
489
590
  toWorkflowPayload(fileId, callbackUrl) {
490
- const operations = this.steps.map((step) => this.lowerStep(step));
591
+ const operations = this.steps.map((step, i) => this.lowerStep(step, i));
491
592
  // Key order (source, operations) matches the PHP `toWire()` so the
492
593
  // JSON-string serialisation is byte-identical across languages.
493
594
  const job = { source: uploadSource(fileId), operations };
@@ -509,6 +610,15 @@ export class Recipe {
509
610
  get recipeSteps() {
510
611
  return this.steps;
511
612
  }
613
+ /**
614
+ * The primary input this recipe operates on. Read by {@link WatermarkedRecipe}
615
+ * to lift an overlay Recipe's input (for upload + media inference + src-job
616
+ * lowering) without making the ctor field public.
617
+ * @internal
618
+ */
619
+ get recipeInput() {
620
+ return this.input;
621
+ }
512
622
  /**
513
623
  * Execute the recipe end-to-end: upload the input (when required), create
514
624
  * the workflow, await a terminal state (SSE with poll fallback), then
@@ -684,15 +794,119 @@ export class Recipe {
684
794
  withStep(step) {
685
795
  return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
686
796
  }
687
- lowerStep(step) {
688
- const options = step.opType === 'compress' ? this.lowerCompressOptions(step.options) : { ...step.options };
797
+ lowerStep(step, stepIndex) {
798
+ // The internal `output` step lowers to a `compress`/`convert` wire op per the
799
+ // route projection (it owns its own type + options resolution + gating).
800
+ if (step.opType === 'output')
801
+ return this.lowerOutputStep(step, stepIndex);
802
+ // After the early return, `step.opType` narrows to the wire op kinds.
803
+ const options = step.opType === 'compress'
804
+ ? this.lowerCompressOptions(step.options, stepIndex)
805
+ : { ...step.options };
689
806
  // Empty options omit the `options` wire key entirely, so TS (undefined →
690
807
  // absent) and PHP (null → absent) serialise byte-identically.
691
808
  return Object.keys(options).length === 0
692
809
  ? { type: step.opType }
693
810
  : { type: step.opType, options };
694
811
  }
695
- lowerCompressOptions(stepOptions) {
812
+ /**
813
+ * Lower an `output` step to its route's wire op. Resolves the route from the
814
+ * (chain-folded) input format token + the requested `output_format`, then emits
815
+ * `compress` (same_format) or `convert` (format_change) carrying only the
816
+ * route-honored options. A planned option (e.g. `lossless`), an option not
817
+ * honored on the resolved route (e.g. `progressive` on a format-change), a
818
+ * planned per-value (e.g. `metadata: 'keep'`), or an unrepresentable route all
819
+ * throw a typed {@link GislConfigError} BEFORE upload. Resize (`width`/`height`/
820
+ * `fit`) is input-keyed (raster only) and rides whichever op the route selects.
821
+ */
822
+ lowerOutputStep(step, stepIndex) {
823
+ const requested = typeof step.options.output_format === 'string' ? step.options.output_format : undefined;
824
+ const inputToken = this.outputInputToken(stepIndex);
825
+ if (inputToken === undefined) {
826
+ // Undetectable input (bare upload id / unnamed blob) → the route can't be
827
+ // resolved. Only the legacy compress facade for a facade-managed output
828
+ // (webp) + quality is expressible without knowing the input; anything else
829
+ // (resize, a same-format optimise, a non-facade target) needs a detectable
830
+ // input. Mirrors lowerCompressOptions' media_unknown fail-fast.
831
+ if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
832
+ const facade = { output_format: requested };
833
+ for (const [key, value] of Object.entries(step.options)) {
834
+ if (key === 'output_format' || value === undefined)
835
+ continue;
836
+ if (key !== 'quality') {
837
+ throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
838
+ 'a path with an extension (or a named/typed Blob) rather than a bare upload id.', { reason: 'media_unknown', conflictingFields: [key] });
839
+ }
840
+ facade[key] = value;
841
+ }
842
+ return { type: 'compress', options: facade };
843
+ }
844
+ throw new GislConfigError('output() needs a detectable input format to resolve the route (same-format optimise vs ' +
845
+ 'format-change transcode); reference the file by a path with an extension, or a Blob with ' +
846
+ 'a media type / filename, rather than a bare upload id.', { reason: 'media_unknown', conflictingFields: ['output_format'] });
847
+ }
848
+ const resolved = resolveOutputRoute(inputToken, requested);
849
+ if (resolved === undefined) {
850
+ throw new GislConfigError(`output(): cannot produce ${requested === undefined ? 'this output' : `'${requested}'`} ` +
851
+ `from a '${inputToken}' input — no such image Output route.`, { reason: 'unsupported_route', conflictingFields: ['output_format'] });
852
+ }
853
+ const wireOptions = { output_format: resolved.outputFormatWire };
854
+ for (const [key, value] of Object.entries(step.options)) {
855
+ if (key === 'output_format' || value === undefined)
856
+ continue;
857
+ if (resolved.planned.has(key)) {
858
+ throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
859
+ `for '${resolved.inputToken}' images (planned). It will work once stable-flipped.`, { reason: 'feature_not_available', conflictingFields: [key] });
860
+ }
861
+ if (!resolved.honored.has(key)) {
862
+ throw new GislConfigError(`output(): '${key}' is not honored on the ${resolved.route} route ` +
863
+ `(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
864
+ 'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
865
+ }
866
+ if (isPlannedValue(resolved.inputToken, key, value)) {
867
+ throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
868
+ }
869
+ wireOptions[key] = value;
870
+ }
871
+ return { type: resolved.sourceOp, options: wireOptions };
872
+ }
873
+ /**
874
+ * The input format token an `output` step at `uptoIndex` operates on — the
875
+ * original input's token, FOLDED through preceding `convert`/`output` steps that
876
+ * change the format (mirrors {@link compressMediaHint}). Undefined when the input
877
+ * media is not inferable (a bare upload id / unnamed, untyped Blob).
878
+ */
879
+ outputInputToken(uptoIndex) {
880
+ let token = this.inputFormatToken();
881
+ if (uptoIndex === undefined)
882
+ return token;
883
+ for (let i = 0; i < uptoIndex; i++) {
884
+ const prior = this.steps[i];
885
+ if (prior.opType === 'convert' || prior.opType === 'output') {
886
+ const fmt = prior.options.output_format;
887
+ // A same-format `output` step carries no output_format (or 'original') →
888
+ // token unchanged; a format target (e.g. 'webp') advances it.
889
+ if (typeof fmt === 'string')
890
+ token = tokenForPath(`f.${fmt}`) ?? token;
891
+ }
892
+ }
893
+ return token;
894
+ }
895
+ /** The original input's image format token (path ext / Blob type / Blob name). */
896
+ inputFormatToken() {
897
+ if (this.input.kind === 'path')
898
+ return tokenForPath(this.input.path);
899
+ if (this.input.kind === 'blob') {
900
+ const blob = this.input.blob;
901
+ const fromType = blob.type ? tokenForMime(blob.type) : undefined;
902
+ if (fromType !== undefined)
903
+ return fromType;
904
+ const name = blob.name;
905
+ return name !== undefined ? tokenForPath(name) : undefined;
906
+ }
907
+ return undefined; // uploadId — undetectable
908
+ }
909
+ lowerCompressOptions(stepOptions, uptoIndex) {
696
910
  // Mirror the op-first resolver precedence (OperationBuilder._resolve in
697
911
  // builder.ts): optimize = preset layer, presetOverrides = callPresetOverride
698
912
  // layer, the rest = explicit layer.
@@ -716,7 +930,7 @@ export class Recipe {
716
930
  : typeof presetOverrides;
717
931
  throw new GislConfigError(`compress 'presetOverrides' must be a *CompressPresetOptions object; got ${got}.`, { reason: 'invalid_preset_overrides', conflictingFields: ['presetOverrides'] });
718
932
  }
719
- const media = this.compressMediaHint();
933
+ const media = this.compressMediaHint(uptoIndex);
720
934
  if (media === undefined) {
721
935
  // Cannot infer a media class (a Blob without a recognised name, or a
722
936
  // bare upload id) → preset resolution is impossible. Fail FAST rather
@@ -739,7 +953,7 @@ export class Recipe {
739
953
  }
740
954
  const input = { media, op: 'compress', explicitOptions };
741
955
  if (media === 'audio') {
742
- input.audioLossless = this.compressAudioLossless();
956
+ input.audioLossless = this.compressAudioLossless(uptoIndex);
743
957
  }
744
958
  if (this.presetDefaults !== undefined) {
745
959
  input.presetDefaults = this.presetDefaults;
@@ -758,16 +972,52 @@ export class Recipe {
758
972
  }
759
973
  return { ...resolveCompressOptions(input).wireOptions };
760
974
  }
761
- compressMediaHint() {
762
- if (this.input.kind === 'path') {
975
+ /** Media of the original input (no chain context) — used by the probe gate. */
976
+ inputMedia() {
977
+ if (this.input.kind === 'path')
763
978
  return _detectCompressMedia(this.input.path);
764
- }
765
- if (this.input.kind === 'blob') {
979
+ if (this.input.kind === 'blob')
766
980
  return _detectCompressMedia(this.input.blob);
767
- }
768
981
  return undefined;
769
982
  }
770
- compressAudioLossless() {
983
+ /**
984
+ * The media class a `compress` step at `uptoIndex` actually operates on. With no
985
+ * chain context (`uptoIndex` undefined) this is the original input's media. With
986
+ * context, FOLD the preceding `convert` steps: each `convert(output_format)` changes
987
+ * the media the next step sees (56N4chXY / N8eESzQN — a chain like
988
+ * `mp3 -> convert(flac) -> compress` must resolve against flac, not mp3). Reuses the
989
+ * synthetic-filename detection precedent from {@link MergedRecipe} (`merged.<ext>`).
990
+ */
991
+ compressMediaHint(uptoIndex) {
992
+ let media = this.inputMedia();
993
+ if (uptoIndex === undefined)
994
+ return media;
995
+ for (let i = 0; i < uptoIndex; i++) {
996
+ const step = this.steps[i];
997
+ if (step.opType === 'convert') {
998
+ const fmt = step.options.output_format;
999
+ if (typeof fmt === 'string')
1000
+ media = _resolveConvertOutputMedia(media, fmt);
1001
+ }
1002
+ }
1003
+ return media;
1004
+ }
1005
+ /**
1006
+ * Whether the media a `compress` step at `uptoIndex` operates on is lossless audio.
1007
+ * Determined by the most recent preceding `convert` target (`flac`/`wav` -> lossless)
1008
+ * when there is one, else by the original input. Lossless is unaffected by the
1009
+ * video/ogg guard (ogg is never lossless either way).
1010
+ */
1011
+ compressAudioLossless(uptoIndex) {
1012
+ if (uptoIndex !== undefined) {
1013
+ for (let i = uptoIndex - 1; i >= 0; i--) {
1014
+ const step = this.steps[i];
1015
+ if (step.opType === 'convert') {
1016
+ const fmt = step.options.output_format;
1017
+ return typeof fmt === 'string' ? _detectAudioLossless(`f.${fmt}`) : false;
1018
+ }
1019
+ }
1020
+ }
771
1021
  if (this.input.kind === 'path')
772
1022
  return _detectAudioLossless(this.input.path);
773
1023
  if (this.input.kind === 'blob')
@@ -775,6 +1025,252 @@ export class Recipe {
775
1025
  return false;
776
1026
  }
777
1027
  }
1028
+ /**
1029
+ * Media of a `convert` step's output, given the media of its source. Reuses the
1030
+ * extension classifier on a synthetic `f.<format>`, with ONE guard: a video source
1031
+ * converted to `ogg` stays video (an OGG *video* container — `ogg` otherwise lands in
1032
+ * the audio extension list, which would mis-resolve a video output to audio). A video
1033
+ * source to `gif` is left as the classifier's `image` result (animated-GIF compress is
1034
+ * image-class). Per the 56N4chXY plan review (architect + karen).
1035
+ */
1036
+ function _resolveConvertOutputMedia(source, outputFormat) {
1037
+ if (source === 'video' && outputFormat.toLowerCase() === 'ogg')
1038
+ return 'video';
1039
+ return _detectCompressMedia(`f.${outputFormat}`);
1040
+ }
1041
+ // ── Watermark routing + planned-op gating (FF4a) ────────────────────────────
1042
+ /**
1043
+ * The single SDK-side source of truth for which `(wire op, base mime)`
1044
+ * combinations the file-first `watermark()` verb may emit, and their
1045
+ * availability. The generated typed metadata sidecar does NOT carry the
1046
+ * supported-mime allowlist (`MimeGroupMetadata` has no `mimes` field and
1047
+ * `per_mime_availability` is empty for these ops), so this hand table is the
1048
+ * gate's source — PINNED to the generated `availability.json` by a conformance
1049
+ * test (mirrors the wire-key-conformance pattern): a contract regen that
1050
+ * changes the supported mimes or availability of `image_watermark` /
1051
+ * `video_watermark` fails that test. The gate reads ONLY this table.
1052
+ * @internal
1053
+ */
1054
+ export const WATERMARK_CAPABILITY = {
1055
+ image_watermark: {
1056
+ image: { mimes: ['image/jpeg', 'image/png', 'image/webp'], availability: 'stable' },
1057
+ image_gif: { mimes: ['image/gif'], availability: 'planned' },
1058
+ },
1059
+ video_watermark: {
1060
+ video: { mimes: ['video/mp4', 'video/webm'], availability: 'beta' },
1061
+ },
1062
+ };
1063
+ const _WATERMARK_SHIPPABLE = new Set(['stable', 'beta']);
1064
+ // extension → canonical MIME for the watermark gate. Covers the supported
1065
+ // formats PLUS common known-but-unsupported ones so the gate throws an
1066
+ // actionable "unsupported subtype" rather than silently routing a format the
1067
+ // server will reject.
1068
+ const _WATERMARK_EXT_MIME = {
1069
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
1070
+ avif: 'image/avif', heic: 'image/heic', heif: 'image/heif', tiff: 'image/tiff', tif: 'image/tiff', bmp: 'image/bmp',
1071
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska',
1072
+ avi: 'video/x-msvideo', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', m4v: 'video/x-m4v',
1073
+ };
1074
+ function _watermarkPathMime(path) {
1075
+ const ext = path.toLowerCase().split('.').pop();
1076
+ return ext !== undefined ? _WATERMARK_EXT_MIME[ext] : undefined;
1077
+ }
1078
+ /**
1079
+ * The watermark MIME of a base/overlay Blob. Mirrors `_detectCompressMedia`'s
1080
+ * mime-first-else-filename precedence: a declared `Blob.type` is used ONLY when
1081
+ * it is media-bearing (image/ video/ audio/ — params stripped, lowercased); a
1082
+ * generic/unknown type (e.g. `application/octet-stream`) falls back to the
1083
+ * `File.name` extension, so a filename-hinted in-memory base routes like a path.
1084
+ */
1085
+ function _watermarkBlobMime(blob) {
1086
+ const raw = blob.type ? blob.type.split(';')[0].trim().toLowerCase() : '';
1087
+ if (raw.startsWith('image/') || raw.startsWith('video/') || raw.startsWith('audio/')) {
1088
+ return raw;
1089
+ }
1090
+ return _watermarkPathMime(blob.name ?? '');
1091
+ }
1092
+ /**
1093
+ * Resolve the effective `(media, mime)` a watermark op operates on, folding the
1094
+ * preceding `convert` (output media + format) AND `thumbnail` (always an image
1095
+ * output) steps — mirrors {@link Recipe.compressMediaHint}'s convert fold, plus
1096
+ * the thumbnail→image rule (codex review). Reused for the base and the overlay.
1097
+ */
1098
+ function _watermarkEffectiveBase(input, steps) {
1099
+ let media = input.kind === 'path'
1100
+ ? _detectCompressMedia(input.path)
1101
+ : input.kind === 'blob'
1102
+ ? _detectCompressMedia(input.blob)
1103
+ : undefined;
1104
+ let mime = input.kind === 'path'
1105
+ ? _watermarkPathMime(input.path)
1106
+ : input.kind === 'blob'
1107
+ ? _watermarkBlobMime(input.blob)
1108
+ : undefined;
1109
+ for (const step of steps) {
1110
+ if (step.opType === 'convert') {
1111
+ const fmt = step.options.output_format;
1112
+ if (typeof fmt === 'string') {
1113
+ media = _resolveConvertOutputMedia(media, fmt);
1114
+ mime = _WATERMARK_EXT_MIME[fmt.toLowerCase()];
1115
+ }
1116
+ }
1117
+ else if (step.opType === 'thumbnail') {
1118
+ // A thumbnail of a video/PDF/image is always an image output.
1119
+ media = 'image';
1120
+ mime = 'image/png';
1121
+ }
1122
+ }
1123
+ // Recover the coarse media from a usable (already-normalised) mime when the
1124
+ // case-sensitive media classifier could not (e.g. an oddly-cased `Image/PNG`
1125
+ // content-type) — keeps the gate self-consistent: a usable mime implies media.
1126
+ if (media === undefined && mime !== undefined) {
1127
+ if (mime.startsWith('image/'))
1128
+ media = 'image';
1129
+ else if (mime.startsWith('video/'))
1130
+ media = 'video';
1131
+ else if (mime.startsWith('audio/'))
1132
+ media = 'audio';
1133
+ }
1134
+ return { media, mime };
1135
+ }
1136
+ /**
1137
+ * Resolve the wire op (`image_watermark` / `video_watermark`) for a watermark
1138
+ * base, or THROW {@link GislConfigError} pre-upload — the planned-op gate. The
1139
+ * capability is read from {@link WATERMARK_CAPABILITY} (data-driven, contract-
1140
+ * pinned): a base mime in a `{stable,beta}` group routes; a `planned` group
1141
+ * (animated GIF base) throws; a known image/video subtype outside the allowlist
1142
+ * (AVIF/HEIC/MOV/…) throws "unsupported"; audio/document throw "not supported".
1143
+ * An undetectable base media throws an actionable error (the caller defers the
1144
+ * eager check at `.watermark()` time and re-runs this pre-upload).
1145
+ */
1146
+ function _resolveWatermarkWireOp(base) {
1147
+ const { media, mime } = base;
1148
+ if (media === undefined) {
1149
+ throw new GislConfigError("watermark needs a detectable base media to route to image_watermark / video_watermark, " +
1150
+ 'but the input has no inferable type (a pre-uploaded file id or unnamed/typeless Blob carries ' +
1151
+ 'no extension or MIME). Use a path with a file extension, a Blob with a type, or a named resource.', { reason: 'media_unknown' });
1152
+ }
1153
+ if (mime !== undefined) {
1154
+ for (const wireOp of Object.keys(WATERMARK_CAPABILITY)) {
1155
+ const groups = WATERMARK_CAPABILITY[wireOp];
1156
+ for (const group of Object.values(groups)) {
1157
+ if (group.mimes.includes(mime)) {
1158
+ if (_WATERMARK_SHIPPABLE.has(group.availability))
1159
+ return wireOp;
1160
+ throw new GislConfigError(`watermark for ${mime} bases is not yet available (${wireOp} is '${group.availability}'). ` +
1161
+ 'The contract schema is defined but the server returns feature_not_available until it ships.', { reason: 'feature_not_available' });
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ if (media === 'image' || media === 'video') {
1167
+ throw new GislConfigError(`watermark does not support ${mime ?? media} base files. image_watermark accepts ` +
1168
+ 'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
1169
+ 'Convert the base to a supported format first.', { reason: 'unsupported_media' });
1170
+ }
1171
+ throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
1172
+ '(audio overlay and luma matte are planned operations). Use textWatermark() for document/text watermarks.', { reason: 'unsupported_media' });
1173
+ }
1174
+ /**
1175
+ * Validate a watermark overlay locally: the overlay role is always an IMAGE.
1176
+ * A KNOWN non-image overlay (audio/video/document) throws pre-upload; an
1177
+ * undetectable overlay media is ALLOWED (it doesn't affect routing, so the
1178
+ * server enforces it). The overlay's effective media folds its own steps.
1179
+ */
1180
+ function _validateWatermarkOverlay(overlay) {
1181
+ const { media } = _watermarkEffectiveBase(overlay.recipeInput, overlay.recipeSteps);
1182
+ if (media !== undefined && media !== 'image') {
1183
+ throw new GislConfigError(`watermark overlay must be an image; got a ${media} overlay. The overlay is the watermark image ` +
1184
+ 'composited onto the base — pass an image file (or a recipe whose output is an image).', { reason: 'invalid_overlay_media', conflictingFields: ['overlay'] });
1185
+ }
1186
+ }
1187
+ function _lowerWatermarkOp(wireOp, options) {
1188
+ // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width) are
1189
+ // already wire keys; empty options omit the `options` key (byte-identical to PHP).
1190
+ const wire = { ...options };
1191
+ return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1192
+ }
1193
+ /**
1194
+ * Shared multi-input upload-then-create tail for the multi-input recipes
1195
+ * ({@link FilesRecipe}, {@link MergedRecipe}, {@link ArchivedRecipe},
1196
+ * {@link WatermarkedRecipe}). Uploads each fresh input (passing through upload
1197
+ * progress), tracks the multipart-video uploads for the best-effort
1198
+ * probe-before-create, then builds the payload via `toPayload` and creates the
1199
+ * workflow. Abort + deadline are re-checked between every phase, exactly as the
1200
+ * per-recipe copies did before this was extracted (xxy5Rlsy).
1201
+ *
1202
+ * Recipe-specific behaviour stays with the caller: `validatePreUpload()` runs
1203
+ * BEFORE this call (Merged/Archived/Watermarked), and the input source
1204
+ * (`this.inputs` vs `this.inputsInOrder()`) plus the timeout-message nouns
1205
+ * (`uploadsLabel`/`workflowLabel`) are passed in so the thrown messages are
1206
+ * byte-identical to the originals.
1207
+ */
1208
+ async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
1209
+ const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
1210
+ const fileIds = [];
1211
+ // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1212
+ // carries no local mime/size, so it is excluded — never probed).
1213
+ const probeTargets = [];
1214
+ for (const input of inputs) {
1215
+ // Fail fast between uploads — a deadline that elapses mid-batch should not
1216
+ // force every remaining input to upload before throwing.
1217
+ _checkAborted(signal);
1218
+ if (deadline !== undefined && Date.now() >= deadline) {
1219
+ throw new GislTimeoutError(`maxWait elapsed during ${uploadsLabel} uploads before all inputs were uploaded`);
1220
+ }
1221
+ if (input.kind === 'uploadId') {
1222
+ fileIds.push(input.fileId);
1223
+ }
1224
+ else {
1225
+ const source = input.kind === 'path' ? input.path : input.blob;
1226
+ const up = await client.uploadFile(source, {
1227
+ signal,
1228
+ ...(onProgress !== undefined
1229
+ ? {
1230
+ onProgress: (uploadedBytes, totalBytes) => {
1231
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1232
+ },
1233
+ }
1234
+ : {}),
1235
+ });
1236
+ fileIds.push(up.fileId);
1237
+ probeTargets.push({
1238
+ fileId: up.fileId,
1239
+ isVideo: _detectCompressMedia(source) === 'video',
1240
+ sizeBytes: up.sizeBytes,
1241
+ });
1242
+ }
1243
+ }
1244
+ _checkAborted(signal);
1245
+ if (deadline !== undefined && Date.now() >= deadline) {
1246
+ throw new GislTimeoutError(`Uploads completed but maxWait elapsed before ${workflowLabel} could be created`);
1247
+ }
1248
+ // Best-effort probe-before-create for the multipart-video inputs. Run the
1249
+ // waits CONCURRENTLY (Promise.all): each is bounded by the SAME capped
1250
+ // timeout, so the aggregate wall-clock stays ~timeout rather than N×timeout.
1251
+ // The cap is the remaining maxWait budget so the waits cannot push
1252
+ // createWorkflow past the caller's deadline. Never-bounce, so a give-up just
1253
+ // proceeds.
1254
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1255
+ await Promise.all(probeTargets.map((t) => client.maybeWaitForVideoProbe(t.fileId, {
1256
+ enabled: probeBeforeCreate ?? true,
1257
+ isVideo: t.isVideo,
1258
+ sizeBytes: t.sizeBytes,
1259
+ timeoutMs: cappedProbeTimeoutMs,
1260
+ signal,
1261
+ })));
1262
+ // A cancel arriving during a FINAL successful probe request must not still
1263
+ // create the workflow (the probe waits return landed without a final abort
1264
+ // re-check), so check here BEFORE createWorkflow.
1265
+ _checkAborted(signal);
1266
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
1267
+ if (deadline !== undefined && Date.now() >= deadline) {
1268
+ throw new GislTimeoutError(`Probe wait completed but maxWait elapsed before ${workflowLabel} could be created`);
1269
+ }
1270
+ const created = await client.createWorkflow(toPayload(fileIds, webhook));
1271
+ _checkAborted(signal);
1272
+ return created;
1273
+ }
778
1274
  /**
779
1275
  * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
780
1276
  * returns a `FilesRecipe`; the op-chain methods (`compress`, `convert`,
@@ -821,15 +1317,15 @@ export class FilesRecipe {
821
1317
  compress(optimize, options = {}) {
822
1318
  return this.withStep(this.baseRecipe().compress(optimize, options));
823
1319
  }
824
- /** Change every input's format. `format` lowers to the contract `output_format` wire key (via {@link Recipe.convert}), NOT `format`. */
1320
+ /** Change every input's format. `format` lowers to the contract `output_format` wire key (via {@link Recipe.convert}), NOT `format`. Option keys are validated (via the base {@link Recipe}) before any upload. */
825
1321
  convert(format, options = {}) {
826
1322
  return this.withStep(this.baseRecipe().convert(format, options));
827
1323
  }
828
- /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
829
- thumbnail(options = {}) {
1324
+ /** Generate a preview of every input. `width` AND `height` are required; validated via the base {@link Recipe} before any upload. */
1325
+ thumbnail(options) {
830
1326
  return this.withStep(this.baseRecipe().thumbnail(options));
831
1327
  }
832
- /** Apply the same text watermark to every input. */
1328
+ /** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
833
1329
  textWatermark(text, options = {}) {
834
1330
  return this.withStep(this.baseRecipe().textWatermark(text, options));
835
1331
  }
@@ -1000,69 +1496,16 @@ export class FilesRecipe {
1000
1496
  * deadline checks are skipped.
1001
1497
  */
1002
1498
  async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1003
- const fileIds = [];
1004
- // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1005
- // carries no local mime/size, so it is excluded — never probed).
1006
- const probeTargets = [];
1007
- for (const input of this.inputs) {
1008
- // Fail fast between uploads — a deadline that elapses mid-batch should
1009
- // not force every remaining input to upload before throwing.
1010
- _checkAborted(signal);
1011
- if (deadline !== undefined && Date.now() >= deadline) {
1012
- throw new GislTimeoutError('maxWait elapsed during fan-out uploads before all inputs were uploaded');
1013
- }
1014
- if (input.kind === 'uploadId') {
1015
- fileIds.push(input.fileId);
1016
- }
1017
- else {
1018
- const source = input.kind === 'path' ? input.path : input.blob;
1019
- const up = await this.client.uploadFile(source, {
1020
- signal,
1021
- ...(onProgress !== undefined
1022
- ? {
1023
- onProgress: (uploadedBytes, totalBytes) => {
1024
- onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1025
- },
1026
- }
1027
- : {}),
1028
- });
1029
- fileIds.push(up.fileId);
1030
- probeTargets.push({
1031
- fileId: up.fileId,
1032
- isVideo: _detectCompressMedia(source) === 'video',
1033
- sizeBytes: up.sizeBytes,
1034
- });
1035
- }
1036
- }
1037
- _checkAborted(signal);
1038
- if (deadline !== undefined && Date.now() >= deadline) {
1039
- throw new GislTimeoutError('Uploads completed but maxWait elapsed before workflow could be created');
1040
- }
1041
- // Best-effort probe-before-create for the multipart-video inputs. Run the
1042
- // waits CONCURRENTLY (Promise.all): each is bounded by the SAME capped
1043
- // timeout, so the aggregate wall-clock stays ~timeout rather than N×timeout.
1044
- // The cap is the remaining maxWait budget so the waits cannot push
1045
- // createWorkflow past the caller's deadline. Never-bounce, so a give-up
1046
- // just proceeds.
1047
- const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1048
- await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1049
- enabled: probeBeforeCreate ?? true,
1050
- isVideo: t.isVideo,
1051
- sizeBytes: t.sizeBytes,
1052
- timeoutMs: cappedProbeTimeoutMs,
1499
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1500
+ webhook,
1501
+ deadline,
1502
+ onProgress,
1053
1503
  signal,
1054
- })));
1055
- // A cancel arriving during a FINAL successful probe request must not still
1056
- // create the workflow (the probe waits return landed without a final abort
1057
- // re-check), so check here BEFORE createWorkflow.
1058
- _checkAborted(signal);
1059
- // RE-CHECK the deadline AFTER the probe waits (they consume time).
1060
- if (deadline !== undefined && Date.now() >= deadline) {
1061
- throw new GislTimeoutError('Probe wait completed but maxWait elapsed before workflow could be created');
1062
- }
1063
- const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1064
- _checkAborted(signal);
1065
- return created;
1504
+ probeBeforeCreate,
1505
+ probeTimeoutMs,
1506
+ uploadsLabel: 'fan-out',
1507
+ workflowLabel: 'workflow',
1508
+ });
1066
1509
  }
1067
1510
  /**
1068
1511
  * The shared single-file {@link Recipe} that captures the op chain (input is
@@ -1125,19 +1568,16 @@ export class MergedRecipe {
1125
1568
  options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
1126
1569
  });
1127
1570
  }
1128
- /** Change the merged output's format. See {@link Recipe.convert}. */
1571
+ /** Change the merged output's format. See {@link Recipe.convert}. Option keys validated pre-upload. */
1129
1572
  convert(format, options = {}) {
1130
- // The convert op's wire key is `output_format` (contract: convert.yaml,
1131
- // required, all media), NOT `format`. Spread options FIRST so the explicit
1132
- // shorthand wins over an `output_format` key in the bag.
1133
- // The shorthand owns the format → a stray legacy `format` key in the bag is
1134
- // not a valid convert option; drop it so the wire never carries both keys.
1135
- const rest = { ...options };
1136
- delete rest.format;
1137
- return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
1138
- }
1139
- /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
1140
- thumbnail(options = {}) {
1573
+ validateVerbOptions('convert', options);
1574
+ // Validation guarantees the bag carries neither `format` nor `output_format`.
1575
+ return this.withStep({ opType: 'convert', options: { ...options, output_format: format } });
1576
+ }
1577
+ /** Thumbnail the merged output. `width` AND `height` are required; validated pre-upload. */
1578
+ thumbnail(options) {
1579
+ validateVerbOptions('thumbnail', options);
1580
+ assertThumbnailDimensions(options);
1141
1581
  const wire = {};
1142
1582
  for (const [key, value] of Object.entries(options)) {
1143
1583
  if (value !== undefined)
@@ -1259,65 +1699,16 @@ export class MergedRecipe {
1259
1699
  */
1260
1700
  async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1261
1701
  this.validatePreUpload();
1262
- const fileIds = [];
1263
- // Per-input video detection: merge's inferMediaKind decides the OUTPUT
1264
- // media, not each input's, so detect per input via _detectCompressMedia.
1265
- // A pre-uploaded id carries no local mime/size, so it is never probed.
1266
- const probeTargets = [];
1267
- for (const input of this.inputs) {
1268
- _checkAborted(signal);
1269
- if (deadline !== undefined && Date.now() >= deadline) {
1270
- throw new GislTimeoutError('maxWait elapsed during merge uploads before all inputs were uploaded');
1271
- }
1272
- if (input.kind === 'uploadId') {
1273
- fileIds.push(input.fileId);
1274
- }
1275
- else {
1276
- const source = input.kind === 'path' ? input.path : input.blob;
1277
- const up = await this.client.uploadFile(source, {
1278
- signal,
1279
- ...(onProgress !== undefined
1280
- ? {
1281
- onProgress: (uploadedBytes, totalBytes) => {
1282
- onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1283
- },
1284
- }
1285
- : {}),
1286
- });
1287
- fileIds.push(up.fileId);
1288
- probeTargets.push({
1289
- fileId: up.fileId,
1290
- isVideo: _detectCompressMedia(source) === 'video',
1291
- sizeBytes: up.sizeBytes,
1292
- });
1293
- }
1294
- }
1295
- _checkAborted(signal);
1296
- if (deadline !== undefined && Date.now() >= deadline) {
1297
- throw new GislTimeoutError('Uploads completed but maxWait elapsed before the merge workflow could be created');
1298
- }
1299
- // Best-effort, concurrent probe-before-create for the multipart-video
1300
- // inputs (never-bounce; each capped to the remaining maxWait budget so the
1301
- // waits cannot push createWorkflow past the caller's deadline).
1302
- const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1303
- await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1304
- enabled: probeBeforeCreate ?? true,
1305
- isVideo: t.isVideo,
1306
- sizeBytes: t.sizeBytes,
1307
- timeoutMs: cappedProbeTimeoutMs,
1702
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1703
+ webhook,
1704
+ deadline,
1705
+ onProgress,
1308
1706
  signal,
1309
- })));
1310
- // A cancel arriving during a FINAL successful probe request must not still
1311
- // create the workflow (the probe waits return landed without a final abort
1312
- // re-check), so check here BEFORE createWorkflow.
1313
- _checkAborted(signal);
1314
- // RE-CHECK the deadline AFTER the probe waits (they consume time).
1315
- if (deadline !== undefined && Date.now() >= deadline) {
1316
- throw new GislTimeoutError('Probe wait completed but maxWait elapsed before the merge workflow could be created');
1317
- }
1318
- const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1319
- _checkAborted(signal);
1320
- return created;
1707
+ probeBeforeCreate,
1708
+ probeTimeoutMs,
1709
+ uploadsLabel: 'merge',
1710
+ workflowLabel: 'the merge workflow',
1711
+ });
1321
1712
  }
1322
1713
  /**
1323
1714
  * Reject an invalid combine BEFORE any upload fires — mirrors the operation-
@@ -1524,65 +1915,16 @@ export class ArchivedRecipe {
1524
1915
  // ---------------------------------------------------------------------------
1525
1916
  async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1526
1917
  this.validatePreUpload();
1527
- const fileIds = [];
1528
- // Archive is media-agnostic (no inference) — detect per input via
1529
- // _detectCompressMedia so only video uploads are probed. A pre-uploaded id
1530
- // carries no local mime/size, so it is never probed.
1531
- const probeTargets = [];
1532
- for (const input of this.inputs) {
1533
- _checkAborted(signal);
1534
- if (deadline !== undefined && Date.now() >= deadline) {
1535
- throw new GislTimeoutError('maxWait elapsed during archive uploads before all inputs were uploaded');
1536
- }
1537
- if (input.kind === 'uploadId') {
1538
- fileIds.push(input.fileId);
1539
- }
1540
- else {
1541
- const source = input.kind === 'path' ? input.path : input.blob;
1542
- const up = await this.client.uploadFile(source, {
1543
- signal,
1544
- ...(onProgress !== undefined
1545
- ? {
1546
- onProgress: (uploadedBytes, totalBytes) => {
1547
- onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1548
- },
1549
- }
1550
- : {}),
1551
- });
1552
- fileIds.push(up.fileId);
1553
- probeTargets.push({
1554
- fileId: up.fileId,
1555
- isVideo: _detectCompressMedia(source) === 'video',
1556
- sizeBytes: up.sizeBytes,
1557
- });
1558
- }
1559
- }
1560
- _checkAborted(signal);
1561
- if (deadline !== undefined && Date.now() >= deadline) {
1562
- throw new GislTimeoutError('Uploads completed but maxWait elapsed before the archive workflow could be created');
1563
- }
1564
- // Best-effort, concurrent probe-before-create for the multipart-video
1565
- // inputs (never-bounce; each capped to the remaining maxWait budget so the
1566
- // waits cannot push createWorkflow past the caller's deadline).
1567
- const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1568
- await Promise.all(probeTargets.map((t) => this.client.maybeWaitForVideoProbe(t.fileId, {
1569
- enabled: probeBeforeCreate ?? true,
1570
- isVideo: t.isVideo,
1571
- sizeBytes: t.sizeBytes,
1572
- timeoutMs: cappedProbeTimeoutMs,
1918
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1919
+ webhook,
1920
+ deadline,
1921
+ onProgress,
1573
1922
  signal,
1574
- })));
1575
- // A cancel arriving during a FINAL successful probe request must not still
1576
- // create the workflow (the probe waits return landed without a final abort
1577
- // re-check), so check here BEFORE createWorkflow.
1578
- _checkAborted(signal);
1579
- // RE-CHECK the deadline AFTER the probe waits (they consume time).
1580
- if (deadline !== undefined && Date.now() >= deadline) {
1581
- throw new GislTimeoutError('Probe wait completed but maxWait elapsed before the archive workflow could be created');
1582
- }
1583
- const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1584
- _checkAborted(signal);
1585
- return created;
1923
+ probeBeforeCreate,
1924
+ probeTimeoutMs,
1925
+ uploadsLabel: 'archive',
1926
+ workflowLabel: 'the archive workflow',
1927
+ });
1586
1928
  }
1587
1929
  /**
1588
1930
  * Reject an invalid bundle BEFORE any upload fires — the archive schema allows
@@ -1610,3 +1952,215 @@ export class ArchivedRecipe {
1610
1952
  return out;
1611
1953
  }
1612
1954
  }
1955
+ /**
1956
+ * The single-output recipe you're in AFTER `file(base).watermark(overlay, …)`
1957
+ * (FF4a). Composites an image OVERLAY onto the base (image_watermark for image
1958
+ * bases, video_watermark for video bases — routed at lowering by the base's
1959
+ * effective media). A multi-input op: base + overlay each enter via their own
1960
+ * `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
1961
+ * steps lower into those jobs), and the `watermark` job consumes them via
1962
+ * `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
1963
+ * `compress`/`convert`/`thumbnail` chain onto the watermark output. Mirrors
1964
+ * {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
1965
+ */
1966
+ export class WatermarkedRecipe {
1967
+ baseInput;
1968
+ baseSteps;
1969
+ overlay;
1970
+ watermarkOptions;
1971
+ postSteps;
1972
+ presetDefaults;
1973
+ scopedPresetDefaults;
1974
+ client;
1975
+ constructor(baseInput, baseSteps, overlay, watermarkOptions, postSteps = [], presetDefaults, scopedPresetDefaults, client) {
1976
+ this.baseInput = baseInput;
1977
+ this.baseSteps = baseSteps;
1978
+ this.overlay = overlay;
1979
+ this.watermarkOptions = watermarkOptions;
1980
+ this.postSteps = postSteps;
1981
+ this.presetDefaults = presetDefaults;
1982
+ this.scopedPresetDefaults = scopedPresetDefaults;
1983
+ this.client = client;
1984
+ }
1985
+ /** Reduce the watermarked output's size. See {@link Recipe.compress}. */
1986
+ compress(optimize, options = {}) {
1987
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
1988
+ const allowed = Object.values(OptimizeFor).join(', ');
1989
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
1990
+ }
1991
+ return this.withStep({
1992
+ opType: 'compress',
1993
+ options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
1994
+ });
1995
+ }
1996
+ /** Change the watermarked output's format. See {@link Recipe.convert}. Option keys validated pre-upload. */
1997
+ convert(format, options = {}) {
1998
+ validateVerbOptions('convert', options);
1999
+ // Validation guarantees the bag carries neither `format` nor `output_format`.
2000
+ return this.withStep({ opType: 'convert', options: { ...options, output_format: format } });
2001
+ }
2002
+ /** Thumbnail the watermarked output. `width` AND `height` are required; validated pre-upload. */
2003
+ thumbnail(options) {
2004
+ validateVerbOptions('thumbnail', options);
2005
+ assertThumbnailDimensions(options);
2006
+ const wire = {};
2007
+ for (const [key, value] of Object.entries(options)) {
2008
+ if (value !== undefined)
2009
+ wire[key] = value;
2010
+ }
2011
+ return this.withStep({ opType: 'thumbnail', options: wire });
2012
+ }
2013
+ /**
2014
+ * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2015
+ * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
2016
+ * them via `job_output` (role base/overlay) and whose `operations[]` is
2017
+ * `[image_watermark|video_watermark, ...post-watermark ops]`. `fileIds` is
2018
+ * `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
2019
+ * is undetectable/unsupported (the planned-op gate).
2020
+ *
2021
+ * @internal Consumed by {@link run}/{@link submit} (after upload) + the parity harness.
2022
+ */
2023
+ toWorkflowPayload(fileIds, callbackUrl) {
2024
+ const wireOp = _resolveWatermarkWireOp(_watermarkEffectiveBase(this.baseInput, this.baseSteps));
2025
+ const baseId = fileIds[0];
2026
+ const overlayId = fileIds[1];
2027
+ // src_0: the base (its preceding steps, else a lossless passthrough).
2028
+ const baseOps = this.baseSteps.length > 0
2029
+ ? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2030
+ .toWorkflowPayload(baseId).jobs[0].operations
2031
+ : [{ type: 'passthrough' }];
2032
+ // src_1: the overlay recipe (its own steps, else a lossless passthrough).
2033
+ const overlayOps = this.overlay.recipeSteps.length > 0
2034
+ ? this.overlay.toWorkflowPayload(overlayId).jobs[0].operations
2035
+ : [{ type: 'passthrough' }];
2036
+ // Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
2037
+ const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
2038
+ const srcOverlay = { id: 'src_1', source: uploadSource(overlayId), operations: overlayOps };
2039
+ const inputs = [
2040
+ { source: jobOutputSource('src_0'), role: 'base' },
2041
+ { source: jobOutputSource('src_1'), role: 'overlay' },
2042
+ ];
2043
+ const operations = [
2044
+ _lowerWatermarkOp(wireOp, this.watermarkOptions),
2045
+ ...this.lowerPostSteps(wireOp),
2046
+ ];
2047
+ const watermarkJob = { id: 'watermark', inputs, operations };
2048
+ const jobs = [srcBase, srcOverlay, watermarkJob];
2049
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2050
+ }
2051
+ /** The number of post-watermark ops chained so far (introspection / tests). */
2052
+ get stepCount() {
2053
+ return this.postSteps.length;
2054
+ }
2055
+ /**
2056
+ * Execute end-to-end: upload base + overlay, create the watermark workflow,
2057
+ * await terminal (SSE with poll fallback), then resolve ONLY the watermark
2058
+ * output into a {@link RunResult}. Requires a client bound at construction.
2059
+ * Mirrors {@link MergedRecipe.run}.
2060
+ */
2061
+ async run(options = {}) {
2062
+ const signal = options.signal;
2063
+ const onProgress = options.onProgress;
2064
+ if (this.client === undefined) {
2065
+ throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2066
+ }
2067
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2068
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2069
+ let finalStatus;
2070
+ try {
2071
+ finalStatus = await _consumeSseToTerminal(this.client, {
2072
+ workflowId: created.workflowId,
2073
+ deadline,
2074
+ signal,
2075
+ onProgress,
2076
+ });
2077
+ }
2078
+ catch (err) {
2079
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
2080
+ throw err;
2081
+ }
2082
+ finalStatus = await _pollToTerminal(this.client, {
2083
+ workflowId: created.workflowId,
2084
+ deadline,
2085
+ signal,
2086
+ pollIntervalMs: options.pollIntervalMs,
2087
+ });
2088
+ }
2089
+ if (Date.now() >= deadline) {
2090
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2091
+ }
2092
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2093
+ if (Date.now() >= deadline) {
2094
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2095
+ }
2096
+ // Project ONLY the watermark job's output — the `src_*` passthrough jobs
2097
+ // re-expose the raw base/overlay uploads, which are plumbing.
2098
+ const watermarkDownloads = downloads.downloads.filter((d) => d.ref === 'watermark');
2099
+ const downloader = new LazyHttpDownloader();
2100
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
2101
+ }
2102
+ /**
2103
+ * Fire-and-forget: upload base + overlay + create the watermark workflow
2104
+ * (wiring `webhook` into `callback_url` when given), return a client-bound
2105
+ * {@link Handle}. Does NOT wait for terminal status. Mirrors {@link MergedRecipe.submit}.
2106
+ */
2107
+ async submit(webhook, options) {
2108
+ if (this.client === undefined) {
2109
+ throw new GislConfigError('WatermarkedRecipe.submit() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2110
+ }
2111
+ const created = await this._uploadAllAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
2112
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
2113
+ }
2114
+ // ---------------------------------------------------------------------------
2115
+ /** Base + overlay inputs, in upload/lowering order (`[base, overlay]`). */
2116
+ inputsInOrder() {
2117
+ return [this.baseInput, this.overlay.recipeInput];
2118
+ }
2119
+ /**
2120
+ * Validate the watermark BEFORE any upload: the base must route to a shippable
2121
+ * wire op (throws for undetectable/unsupported/planned bases), and the overlay
2122
+ * must be an image. Shared by {@link run}/{@link submit}. Mirrors
2123
+ * {@link MergedRecipe.validatePreUpload}.
2124
+ */
2125
+ validatePreUpload() {
2126
+ _resolveWatermarkWireOp(_watermarkEffectiveBase(this.baseInput, this.baseSteps));
2127
+ _validateWatermarkOverlay(this.overlay);
2128
+ }
2129
+ /**
2130
+ * Upload base + overlay (verbatim for a pre-uploaded id; uploading a path /
2131
+ * blob otherwise) then create ONE watermark workflow. Validates pre-upload.
2132
+ * Shared first half of {@link run} + {@link submit}; mirrors
2133
+ * {@link MergedRecipe._uploadAllAndCreate}.
2134
+ */
2135
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
2136
+ this.validatePreUpload();
2137
+ return _uploadInputsAndCreate(this.client, this.inputsInOrder(), (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
2138
+ webhook,
2139
+ deadline,
2140
+ onProgress,
2141
+ signal,
2142
+ probeBeforeCreate,
2143
+ probeTimeoutMs,
2144
+ uploadsLabel: 'watermark',
2145
+ workflowLabel: 'the watermark workflow',
2146
+ });
2147
+ }
2148
+ /**
2149
+ * Lower the post-watermark chain over a synthetic input whose extension
2150
+ * matches the watermark OUTPUT media (image→png, video→mp4) so
2151
+ * `compress(optimize)` resolves the correct preset — mirrors
2152
+ * {@link MergedRecipe.lowerPostSteps}.
2153
+ */
2154
+ lowerPostSteps(wireOp) {
2155
+ if (this.postSteps.length === 0) {
2156
+ return [];
2157
+ }
2158
+ const ext = wireOp === 'video_watermark' ? 'mp4' : 'png';
2159
+ const synthetic = fileInput.path(`watermarked.${ext}`);
2160
+ const recipe = new Recipe(synthetic, undefined, this.postSteps, this.presetDefaults, this.scopedPresetDefaults);
2161
+ return recipe.toWorkflowPayload('watermarked').jobs[0].operations;
2162
+ }
2163
+ withStep(step) {
2164
+ return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
2165
+ }
2166
+ }