@giveitsmaller/sdk 0.14.0 → 0.16.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.
@@ -356,6 +356,33 @@ export function isArchiveStatus(finalStatus) {
356
356
  }
357
357
  return hasArchive;
358
358
  }
359
+ /**
360
+ * True when a terminal status describes a fluent `file(...).watermark(overlay)`
361
+ * — at least one job ref `watermark` and every OTHER job ref is `src_{i}` (the
362
+ * ids the {@link WatermarkedRecipe} lowering assigns: `src_0` base, `src_1`
363
+ * overlay). Lets {@link Handle.wait}/{@link Handle.result} AND
364
+ * {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
365
+ * the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
366
+ * Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
367
+ * {@link isArchiveStatus}.
368
+ *
369
+ * @internal Exported for the file-first `Handle`; not part of the public API.
370
+ */
371
+ export function isWatermarkStatus(finalStatus) {
372
+ const jobs = finalStatus.jobs ?? [];
373
+ if (jobs.length === 0)
374
+ return false;
375
+ let hasWatermark = false;
376
+ for (const job of jobs) {
377
+ if (job.ref === 'watermark') {
378
+ hasWatermark = true;
379
+ continue;
380
+ }
381
+ if (!_MERGE_SRC_REF.test(job.ref))
382
+ return false;
383
+ }
384
+ return hasWatermark;
385
+ }
359
386
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
360
387
  export const fileInput = {
361
388
  path(path) {
@@ -472,6 +499,27 @@ export class Recipe {
472
499
  // Spread options FIRST so the explicit `text` argument is authoritative.
473
500
  return this.withStep({ opType: 'text_watermark', options: { ...options, text } });
474
501
  }
502
+ /**
503
+ * Composite an image OVERLAY onto this file (a multi-input op). `overlay` is a
504
+ * secondary file-NODE (a {@link Recipe} — e.g. `client.file('logo.png')`),
505
+ * itself optionally processed first. Routes by THIS file's effective media:
506
+ * image base → `image_watermark` (stable), video base → `video_watermark`
507
+ * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
508
+ * throw locally BEFORE any upload (the planned-op gate). `options` carries the
509
+ * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
510
+ * `overlay_width`). Returns a {@link WatermarkedRecipe} (chain post-watermark
511
+ * `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
512
+ * {@link textWatermark} (single-input text overlay).
513
+ */
514
+ watermark(overlay, options = {}) {
515
+ // Eager gate when the base media is KNOWN (unit-testable pre-upload); an
516
+ // undetectable base is DEFERRED — re-checked pre-upload in run()/submit().
517
+ const base = _watermarkEffectiveBase(this.input, this.steps);
518
+ if (base.media !== undefined)
519
+ _resolveWatermarkWireOp(base);
520
+ _validateWatermarkOverlay(overlay);
521
+ return new WatermarkedRecipe(this.input, this.steps, overlay, options, [], this.presetDefaults, this.scopedPresetDefaults, this.client);
522
+ }
475
523
  /**
476
524
  * Lower this recipe to a workflow-create payload against a resolved upload
477
525
  * id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
@@ -487,7 +535,7 @@ export class Recipe {
487
535
  * fixed id). Not part of the caller-facing fluent surface.
488
536
  */
489
537
  toWorkflowPayload(fileId, callbackUrl) {
490
- const operations = this.steps.map((step) => this.lowerStep(step));
538
+ const operations = this.steps.map((step, i) => this.lowerStep(step, i));
491
539
  // Key order (source, operations) matches the PHP `toWire()` so the
492
540
  // JSON-string serialisation is byte-identical across languages.
493
541
  const job = { source: uploadSource(fileId), operations };
@@ -509,6 +557,15 @@ export class Recipe {
509
557
  get recipeSteps() {
510
558
  return this.steps;
511
559
  }
560
+ /**
561
+ * The primary input this recipe operates on. Read by {@link WatermarkedRecipe}
562
+ * to lift an overlay Recipe's input (for upload + media inference + src-job
563
+ * lowering) without making the ctor field public.
564
+ * @internal
565
+ */
566
+ get recipeInput() {
567
+ return this.input;
568
+ }
512
569
  /**
513
570
  * Execute the recipe end-to-end: upload the input (when required), create
514
571
  * the workflow, await a terminal state (SSE with poll fallback), then
@@ -684,15 +741,17 @@ export class Recipe {
684
741
  withStep(step) {
685
742
  return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
686
743
  }
687
- lowerStep(step) {
688
- const options = step.opType === 'compress' ? this.lowerCompressOptions(step.options) : { ...step.options };
744
+ lowerStep(step, stepIndex) {
745
+ const options = step.opType === 'compress'
746
+ ? this.lowerCompressOptions(step.options, stepIndex)
747
+ : { ...step.options };
689
748
  // Empty options omit the `options` wire key entirely, so TS (undefined →
690
749
  // absent) and PHP (null → absent) serialise byte-identically.
691
750
  return Object.keys(options).length === 0
692
751
  ? { type: step.opType }
693
752
  : { type: step.opType, options };
694
753
  }
695
- lowerCompressOptions(stepOptions) {
754
+ lowerCompressOptions(stepOptions, uptoIndex) {
696
755
  // Mirror the op-first resolver precedence (OperationBuilder._resolve in
697
756
  // builder.ts): optimize = preset layer, presetOverrides = callPresetOverride
698
757
  // layer, the rest = explicit layer.
@@ -716,7 +775,7 @@ export class Recipe {
716
775
  : typeof presetOverrides;
717
776
  throw new GislConfigError(`compress 'presetOverrides' must be a *CompressPresetOptions object; got ${got}.`, { reason: 'invalid_preset_overrides', conflictingFields: ['presetOverrides'] });
718
777
  }
719
- const media = this.compressMediaHint();
778
+ const media = this.compressMediaHint(uptoIndex);
720
779
  if (media === undefined) {
721
780
  // Cannot infer a media class (a Blob without a recognised name, or a
722
781
  // bare upload id) → preset resolution is impossible. Fail FAST rather
@@ -739,7 +798,7 @@ export class Recipe {
739
798
  }
740
799
  const input = { media, op: 'compress', explicitOptions };
741
800
  if (media === 'audio') {
742
- input.audioLossless = this.compressAudioLossless();
801
+ input.audioLossless = this.compressAudioLossless(uptoIndex);
743
802
  }
744
803
  if (this.presetDefaults !== undefined) {
745
804
  input.presetDefaults = this.presetDefaults;
@@ -758,16 +817,52 @@ export class Recipe {
758
817
  }
759
818
  return { ...resolveCompressOptions(input).wireOptions };
760
819
  }
761
- compressMediaHint() {
762
- if (this.input.kind === 'path') {
820
+ /** Media of the original input (no chain context) — used by the probe gate. */
821
+ inputMedia() {
822
+ if (this.input.kind === 'path')
763
823
  return _detectCompressMedia(this.input.path);
764
- }
765
- if (this.input.kind === 'blob') {
824
+ if (this.input.kind === 'blob')
766
825
  return _detectCompressMedia(this.input.blob);
767
- }
768
826
  return undefined;
769
827
  }
770
- compressAudioLossless() {
828
+ /**
829
+ * The media class a `compress` step at `uptoIndex` actually operates on. With no
830
+ * chain context (`uptoIndex` undefined) this is the original input's media. With
831
+ * context, FOLD the preceding `convert` steps: each `convert(output_format)` changes
832
+ * the media the next step sees (56N4chXY / N8eESzQN — a chain like
833
+ * `mp3 -> convert(flac) -> compress` must resolve against flac, not mp3). Reuses the
834
+ * synthetic-filename detection precedent from {@link MergedRecipe} (`merged.<ext>`).
835
+ */
836
+ compressMediaHint(uptoIndex) {
837
+ let media = this.inputMedia();
838
+ if (uptoIndex === undefined)
839
+ return media;
840
+ for (let i = 0; i < uptoIndex; i++) {
841
+ const step = this.steps[i];
842
+ if (step.opType === 'convert') {
843
+ const fmt = step.options.output_format;
844
+ if (typeof fmt === 'string')
845
+ media = _resolveConvertOutputMedia(media, fmt);
846
+ }
847
+ }
848
+ return media;
849
+ }
850
+ /**
851
+ * Whether the media a `compress` step at `uptoIndex` operates on is lossless audio.
852
+ * Determined by the most recent preceding `convert` target (`flac`/`wav` -> lossless)
853
+ * when there is one, else by the original input. Lossless is unaffected by the
854
+ * video/ogg guard (ogg is never lossless either way).
855
+ */
856
+ compressAudioLossless(uptoIndex) {
857
+ if (uptoIndex !== undefined) {
858
+ for (let i = uptoIndex - 1; i >= 0; i--) {
859
+ const step = this.steps[i];
860
+ if (step.opType === 'convert') {
861
+ const fmt = step.options.output_format;
862
+ return typeof fmt === 'string' ? _detectAudioLossless(`f.${fmt}`) : false;
863
+ }
864
+ }
865
+ }
771
866
  if (this.input.kind === 'path')
772
867
  return _detectAudioLossless(this.input.path);
773
868
  if (this.input.kind === 'blob')
@@ -775,6 +870,252 @@ export class Recipe {
775
870
  return false;
776
871
  }
777
872
  }
873
+ /**
874
+ * Media of a `convert` step's output, given the media of its source. Reuses the
875
+ * extension classifier on a synthetic `f.<format>`, with ONE guard: a video source
876
+ * converted to `ogg` stays video (an OGG *video* container — `ogg` otherwise lands in
877
+ * the audio extension list, which would mis-resolve a video output to audio). A video
878
+ * source to `gif` is left as the classifier's `image` result (animated-GIF compress is
879
+ * image-class). Per the 56N4chXY plan review (architect + karen).
880
+ */
881
+ function _resolveConvertOutputMedia(source, outputFormat) {
882
+ if (source === 'video' && outputFormat.toLowerCase() === 'ogg')
883
+ return 'video';
884
+ return _detectCompressMedia(`f.${outputFormat}`);
885
+ }
886
+ // ── Watermark routing + planned-op gating (FF4a) ────────────────────────────
887
+ /**
888
+ * The single SDK-side source of truth for which `(wire op, base mime)`
889
+ * combinations the file-first `watermark()` verb may emit, and their
890
+ * availability. The generated typed metadata sidecar does NOT carry the
891
+ * supported-mime allowlist (`MimeGroupMetadata` has no `mimes` field and
892
+ * `per_mime_availability` is empty for these ops), so this hand table is the
893
+ * gate's source — PINNED to the generated `availability.json` by a conformance
894
+ * test (mirrors the wire-key-conformance pattern): a contract regen that
895
+ * changes the supported mimes or availability of `image_watermark` /
896
+ * `video_watermark` fails that test. The gate reads ONLY this table.
897
+ * @internal
898
+ */
899
+ export const WATERMARK_CAPABILITY = {
900
+ image_watermark: {
901
+ image: { mimes: ['image/jpeg', 'image/png', 'image/webp'], availability: 'stable' },
902
+ image_gif: { mimes: ['image/gif'], availability: 'planned' },
903
+ },
904
+ video_watermark: {
905
+ video: { mimes: ['video/mp4', 'video/webm'], availability: 'beta' },
906
+ },
907
+ };
908
+ const _WATERMARK_SHIPPABLE = new Set(['stable', 'beta']);
909
+ // extension → canonical MIME for the watermark gate. Covers the supported
910
+ // formats PLUS common known-but-unsupported ones so the gate throws an
911
+ // actionable "unsupported subtype" rather than silently routing a format the
912
+ // server will reject.
913
+ const _WATERMARK_EXT_MIME = {
914
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
915
+ avif: 'image/avif', heic: 'image/heic', heif: 'image/heif', tiff: 'image/tiff', tif: 'image/tiff', bmp: 'image/bmp',
916
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska',
917
+ avi: 'video/x-msvideo', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', m4v: 'video/x-m4v',
918
+ };
919
+ function _watermarkPathMime(path) {
920
+ const ext = path.toLowerCase().split('.').pop();
921
+ return ext !== undefined ? _WATERMARK_EXT_MIME[ext] : undefined;
922
+ }
923
+ /**
924
+ * The watermark MIME of a base/overlay Blob. Mirrors `_detectCompressMedia`'s
925
+ * mime-first-else-filename precedence: a declared `Blob.type` is used ONLY when
926
+ * it is media-bearing (image/ video/ audio/ — params stripped, lowercased); a
927
+ * generic/unknown type (e.g. `application/octet-stream`) falls back to the
928
+ * `File.name` extension, so a filename-hinted in-memory base routes like a path.
929
+ */
930
+ function _watermarkBlobMime(blob) {
931
+ const raw = blob.type ? blob.type.split(';')[0].trim().toLowerCase() : '';
932
+ if (raw.startsWith('image/') || raw.startsWith('video/') || raw.startsWith('audio/')) {
933
+ return raw;
934
+ }
935
+ return _watermarkPathMime(blob.name ?? '');
936
+ }
937
+ /**
938
+ * Resolve the effective `(media, mime)` a watermark op operates on, folding the
939
+ * preceding `convert` (output media + format) AND `thumbnail` (always an image
940
+ * output) steps — mirrors {@link Recipe.compressMediaHint}'s convert fold, plus
941
+ * the thumbnail→image rule (codex review). Reused for the base and the overlay.
942
+ */
943
+ function _watermarkEffectiveBase(input, steps) {
944
+ let media = input.kind === 'path'
945
+ ? _detectCompressMedia(input.path)
946
+ : input.kind === 'blob'
947
+ ? _detectCompressMedia(input.blob)
948
+ : undefined;
949
+ let mime = input.kind === 'path'
950
+ ? _watermarkPathMime(input.path)
951
+ : input.kind === 'blob'
952
+ ? _watermarkBlobMime(input.blob)
953
+ : undefined;
954
+ for (const step of steps) {
955
+ if (step.opType === 'convert') {
956
+ const fmt = step.options.output_format;
957
+ if (typeof fmt === 'string') {
958
+ media = _resolveConvertOutputMedia(media, fmt);
959
+ mime = _WATERMARK_EXT_MIME[fmt.toLowerCase()];
960
+ }
961
+ }
962
+ else if (step.opType === 'thumbnail') {
963
+ // A thumbnail of a video/PDF/image is always an image output.
964
+ media = 'image';
965
+ mime = 'image/png';
966
+ }
967
+ }
968
+ // Recover the coarse media from a usable (already-normalised) mime when the
969
+ // case-sensitive media classifier could not (e.g. an oddly-cased `Image/PNG`
970
+ // content-type) — keeps the gate self-consistent: a usable mime implies media.
971
+ if (media === undefined && mime !== undefined) {
972
+ if (mime.startsWith('image/'))
973
+ media = 'image';
974
+ else if (mime.startsWith('video/'))
975
+ media = 'video';
976
+ else if (mime.startsWith('audio/'))
977
+ media = 'audio';
978
+ }
979
+ return { media, mime };
980
+ }
981
+ /**
982
+ * Resolve the wire op (`image_watermark` / `video_watermark`) for a watermark
983
+ * base, or THROW {@link GislConfigError} pre-upload — the planned-op gate. The
984
+ * capability is read from {@link WATERMARK_CAPABILITY} (data-driven, contract-
985
+ * pinned): a base mime in a `{stable,beta}` group routes; a `planned` group
986
+ * (animated GIF base) throws; a known image/video subtype outside the allowlist
987
+ * (AVIF/HEIC/MOV/…) throws "unsupported"; audio/document throw "not supported".
988
+ * An undetectable base media throws an actionable error (the caller defers the
989
+ * eager check at `.watermark()` time and re-runs this pre-upload).
990
+ */
991
+ function _resolveWatermarkWireOp(base) {
992
+ const { media, mime } = base;
993
+ if (media === undefined) {
994
+ throw new GislConfigError("watermark needs a detectable base media to route to image_watermark / video_watermark, " +
995
+ 'but the input has no inferable type (a pre-uploaded file id or unnamed/typeless Blob carries ' +
996
+ 'no extension or MIME). Use a path with a file extension, a Blob with a type, or a named resource.', { reason: 'media_unknown' });
997
+ }
998
+ if (mime !== undefined) {
999
+ for (const wireOp of Object.keys(WATERMARK_CAPABILITY)) {
1000
+ const groups = WATERMARK_CAPABILITY[wireOp];
1001
+ for (const group of Object.values(groups)) {
1002
+ if (group.mimes.includes(mime)) {
1003
+ if (_WATERMARK_SHIPPABLE.has(group.availability))
1004
+ return wireOp;
1005
+ throw new GislConfigError(`watermark for ${mime} bases is not yet available (${wireOp} is '${group.availability}'). ` +
1006
+ 'The contract schema is defined but the server returns feature_not_available until it ships.', { reason: 'feature_not_available' });
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ if (media === 'image' || media === 'video') {
1012
+ throw new GislConfigError(`watermark does not support ${mime ?? media} base files. image_watermark accepts ` +
1013
+ 'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
1014
+ 'Convert the base to a supported format first.', { reason: 'unsupported_media' });
1015
+ }
1016
+ throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
1017
+ '(audio overlay and luma matte are planned operations). Use textWatermark() for document/text watermarks.', { reason: 'unsupported_media' });
1018
+ }
1019
+ /**
1020
+ * Validate a watermark overlay locally: the overlay role is always an IMAGE.
1021
+ * A KNOWN non-image overlay (audio/video/document) throws pre-upload; an
1022
+ * undetectable overlay media is ALLOWED (it doesn't affect routing, so the
1023
+ * server enforces it). The overlay's effective media folds its own steps.
1024
+ */
1025
+ function _validateWatermarkOverlay(overlay) {
1026
+ const { media } = _watermarkEffectiveBase(overlay.recipeInput, overlay.recipeSteps);
1027
+ if (media !== undefined && media !== 'image') {
1028
+ throw new GislConfigError(`watermark overlay must be an image; got a ${media} overlay. The overlay is the watermark image ` +
1029
+ 'composited onto the base — pass an image file (or a recipe whose output is an image).', { reason: 'invalid_overlay_media', conflictingFields: ['overlay'] });
1030
+ }
1031
+ }
1032
+ function _lowerWatermarkOp(wireOp, options) {
1033
+ // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width) are
1034
+ // already wire keys; empty options omit the `options` key (byte-identical to PHP).
1035
+ const wire = { ...options };
1036
+ return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1037
+ }
1038
+ /**
1039
+ * Shared multi-input upload-then-create tail for the multi-input recipes
1040
+ * ({@link FilesRecipe}, {@link MergedRecipe}, {@link ArchivedRecipe},
1041
+ * {@link WatermarkedRecipe}). Uploads each fresh input (passing through upload
1042
+ * progress), tracks the multipart-video uploads for the best-effort
1043
+ * probe-before-create, then builds the payload via `toPayload` and creates the
1044
+ * workflow. Abort + deadline are re-checked between every phase, exactly as the
1045
+ * per-recipe copies did before this was extracted (xxy5Rlsy).
1046
+ *
1047
+ * Recipe-specific behaviour stays with the caller: `validatePreUpload()` runs
1048
+ * BEFORE this call (Merged/Archived/Watermarked), and the input source
1049
+ * (`this.inputs` vs `this.inputsInOrder()`) plus the timeout-message nouns
1050
+ * (`uploadsLabel`/`workflowLabel`) are passed in so the thrown messages are
1051
+ * byte-identical to the originals.
1052
+ */
1053
+ async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
1054
+ const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
1055
+ const fileIds = [];
1056
+ // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1057
+ // carries no local mime/size, so it is excluded — never probed).
1058
+ const probeTargets = [];
1059
+ for (const input of inputs) {
1060
+ // Fail fast between uploads — a deadline that elapses mid-batch should not
1061
+ // force every remaining input to upload before throwing.
1062
+ _checkAborted(signal);
1063
+ if (deadline !== undefined && Date.now() >= deadline) {
1064
+ throw new GislTimeoutError(`maxWait elapsed during ${uploadsLabel} uploads before all inputs were uploaded`);
1065
+ }
1066
+ if (input.kind === 'uploadId') {
1067
+ fileIds.push(input.fileId);
1068
+ }
1069
+ else {
1070
+ const source = input.kind === 'path' ? input.path : input.blob;
1071
+ const up = await client.uploadFile(source, {
1072
+ signal,
1073
+ ...(onProgress !== undefined
1074
+ ? {
1075
+ onProgress: (uploadedBytes, totalBytes) => {
1076
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1077
+ },
1078
+ }
1079
+ : {}),
1080
+ });
1081
+ fileIds.push(up.fileId);
1082
+ probeTargets.push({
1083
+ fileId: up.fileId,
1084
+ isVideo: _detectCompressMedia(source) === 'video',
1085
+ sizeBytes: up.sizeBytes,
1086
+ });
1087
+ }
1088
+ }
1089
+ _checkAborted(signal);
1090
+ if (deadline !== undefined && Date.now() >= deadline) {
1091
+ throw new GislTimeoutError(`Uploads completed but maxWait elapsed before ${workflowLabel} could be created`);
1092
+ }
1093
+ // Best-effort probe-before-create for the multipart-video inputs. Run the
1094
+ // waits CONCURRENTLY (Promise.all): each is bounded by the SAME capped
1095
+ // timeout, so the aggregate wall-clock stays ~timeout rather than N×timeout.
1096
+ // The cap is the remaining maxWait budget so the waits cannot push
1097
+ // createWorkflow past the caller's deadline. Never-bounce, so a give-up just
1098
+ // proceeds.
1099
+ const cappedProbeTimeoutMs = _cappedProbeTimeoutMs(probeTimeoutMs, deadline);
1100
+ await Promise.all(probeTargets.map((t) => client.maybeWaitForVideoProbe(t.fileId, {
1101
+ enabled: probeBeforeCreate ?? true,
1102
+ isVideo: t.isVideo,
1103
+ sizeBytes: t.sizeBytes,
1104
+ timeoutMs: cappedProbeTimeoutMs,
1105
+ signal,
1106
+ })));
1107
+ // A cancel arriving during a FINAL successful probe request must not still
1108
+ // create the workflow (the probe waits return landed without a final abort
1109
+ // re-check), so check here BEFORE createWorkflow.
1110
+ _checkAborted(signal);
1111
+ // RE-CHECK the deadline AFTER the probe waits (they consume time).
1112
+ if (deadline !== undefined && Date.now() >= deadline) {
1113
+ throw new GislTimeoutError(`Probe wait completed but maxWait elapsed before ${workflowLabel} could be created`);
1114
+ }
1115
+ const created = await client.createWorkflow(toPayload(fileIds, webhook));
1116
+ _checkAborted(signal);
1117
+ return created;
1118
+ }
778
1119
  /**
779
1120
  * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
780
1121
  * returns a `FilesRecipe`; the op-chain methods (`compress`, `convert`,
@@ -1000,69 +1341,16 @@ export class FilesRecipe {
1000
1341
  * deadline checks are skipped.
1001
1342
  */
1002
1343
  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,
1344
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1345
+ webhook,
1346
+ deadline,
1347
+ onProgress,
1053
1348
  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;
1349
+ probeBeforeCreate,
1350
+ probeTimeoutMs,
1351
+ uploadsLabel: 'fan-out',
1352
+ workflowLabel: 'workflow',
1353
+ });
1066
1354
  }
1067
1355
  /**
1068
1356
  * The shared single-file {@link Recipe} that captures the op chain (input is
@@ -1259,65 +1547,16 @@ export class MergedRecipe {
1259
1547
  */
1260
1548
  async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1261
1549
  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,
1550
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1551
+ webhook,
1552
+ deadline,
1553
+ onProgress,
1308
1554
  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;
1555
+ probeBeforeCreate,
1556
+ probeTimeoutMs,
1557
+ uploadsLabel: 'merge',
1558
+ workflowLabel: 'the merge workflow',
1559
+ });
1321
1560
  }
1322
1561
  /**
1323
1562
  * Reject an invalid combine BEFORE any upload fires — mirrors the operation-
@@ -1524,65 +1763,16 @@ export class ArchivedRecipe {
1524
1763
  // ---------------------------------------------------------------------------
1525
1764
  async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1526
1765
  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,
1766
+ return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1767
+ webhook,
1768
+ deadline,
1769
+ onProgress,
1573
1770
  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;
1771
+ probeBeforeCreate,
1772
+ probeTimeoutMs,
1773
+ uploadsLabel: 'archive',
1774
+ workflowLabel: 'the archive workflow',
1775
+ });
1586
1776
  }
1587
1777
  /**
1588
1778
  * Reject an invalid bundle BEFORE any upload fires — the archive schema allows
@@ -1610,3 +1800,213 @@ export class ArchivedRecipe {
1610
1800
  return out;
1611
1801
  }
1612
1802
  }
1803
+ /**
1804
+ * The single-output recipe you're in AFTER `file(base).watermark(overlay, …)`
1805
+ * (FF4a). Composites an image OVERLAY onto the base (image_watermark for image
1806
+ * bases, video_watermark for video bases — routed at lowering by the base's
1807
+ * effective media). A multi-input op: base + overlay each enter via their own
1808
+ * `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
1809
+ * steps lower into those jobs), and the `watermark` job consumes them via
1810
+ * `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
1811
+ * `compress`/`convert`/`thumbnail` chain onto the watermark output. Mirrors
1812
+ * {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
1813
+ */
1814
+ export class WatermarkedRecipe {
1815
+ baseInput;
1816
+ baseSteps;
1817
+ overlay;
1818
+ watermarkOptions;
1819
+ postSteps;
1820
+ presetDefaults;
1821
+ scopedPresetDefaults;
1822
+ client;
1823
+ constructor(baseInput, baseSteps, overlay, watermarkOptions, postSteps = [], presetDefaults, scopedPresetDefaults, client) {
1824
+ this.baseInput = baseInput;
1825
+ this.baseSteps = baseSteps;
1826
+ this.overlay = overlay;
1827
+ this.watermarkOptions = watermarkOptions;
1828
+ this.postSteps = postSteps;
1829
+ this.presetDefaults = presetDefaults;
1830
+ this.scopedPresetDefaults = scopedPresetDefaults;
1831
+ this.client = client;
1832
+ }
1833
+ /** Reduce the watermarked output's size. See {@link Recipe.compress}. */
1834
+ compress(optimize, options = {}) {
1835
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
1836
+ const allowed = Object.values(OptimizeFor).join(', ');
1837
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
1838
+ }
1839
+ return this.withStep({
1840
+ opType: 'compress',
1841
+ options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
1842
+ });
1843
+ }
1844
+ /** Change the watermarked output's format. See {@link Recipe.convert}. */
1845
+ convert(format, options = {}) {
1846
+ const rest = { ...options };
1847
+ delete rest.format;
1848
+ return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
1849
+ }
1850
+ /** Thumbnail the watermarked output. Omitted dimensions are dropped from the wire options. */
1851
+ thumbnail(options = {}) {
1852
+ const wire = {};
1853
+ for (const [key, value] of Object.entries(options)) {
1854
+ if (value !== undefined)
1855
+ wire[key] = value;
1856
+ }
1857
+ return this.withStep({ opType: 'thumbnail', options: wire });
1858
+ }
1859
+ /**
1860
+ * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
1861
+ * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
1862
+ * them via `job_output` (role base/overlay) and whose `operations[]` is
1863
+ * `[image_watermark|video_watermark, ...post-watermark ops]`. `fileIds` is
1864
+ * `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
1865
+ * is undetectable/unsupported (the planned-op gate).
1866
+ *
1867
+ * @internal Consumed by {@link run}/{@link submit} (after upload) + the parity harness.
1868
+ */
1869
+ toWorkflowPayload(fileIds, callbackUrl) {
1870
+ const wireOp = _resolveWatermarkWireOp(_watermarkEffectiveBase(this.baseInput, this.baseSteps));
1871
+ const baseId = fileIds[0];
1872
+ const overlayId = fileIds[1];
1873
+ // src_0: the base (its preceding steps, else a lossless passthrough).
1874
+ const baseOps = this.baseSteps.length > 0
1875
+ ? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
1876
+ .toWorkflowPayload(baseId).jobs[0].operations
1877
+ : [{ type: 'passthrough' }];
1878
+ // src_1: the overlay recipe (its own steps, else a lossless passthrough).
1879
+ const overlayOps = this.overlay.recipeSteps.length > 0
1880
+ ? this.overlay.toWorkflowPayload(overlayId).jobs[0].operations
1881
+ : [{ type: 'passthrough' }];
1882
+ // Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
1883
+ const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
1884
+ const srcOverlay = { id: 'src_1', source: uploadSource(overlayId), operations: overlayOps };
1885
+ const inputs = [
1886
+ { source: jobOutputSource('src_0'), role: 'base' },
1887
+ { source: jobOutputSource('src_1'), role: 'overlay' },
1888
+ ];
1889
+ const operations = [
1890
+ _lowerWatermarkOp(wireOp, this.watermarkOptions),
1891
+ ...this.lowerPostSteps(wireOp),
1892
+ ];
1893
+ const watermarkJob = { id: 'watermark', inputs, operations };
1894
+ const jobs = [srcBase, srcOverlay, watermarkJob];
1895
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1896
+ }
1897
+ /** The number of post-watermark ops chained so far (introspection / tests). */
1898
+ get stepCount() {
1899
+ return this.postSteps.length;
1900
+ }
1901
+ /**
1902
+ * Execute end-to-end: upload base + overlay, create the watermark workflow,
1903
+ * await terminal (SSE with poll fallback), then resolve ONLY the watermark
1904
+ * output into a {@link RunResult}. Requires a client bound at construction.
1905
+ * Mirrors {@link MergedRecipe.run}.
1906
+ */
1907
+ async run(options = {}) {
1908
+ const signal = options.signal;
1909
+ const onProgress = options.onProgress;
1910
+ if (this.client === undefined) {
1911
+ throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
1912
+ }
1913
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1914
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1915
+ let finalStatus;
1916
+ try {
1917
+ finalStatus = await _consumeSseToTerminal(this.client, {
1918
+ workflowId: created.workflowId,
1919
+ deadline,
1920
+ signal,
1921
+ onProgress,
1922
+ });
1923
+ }
1924
+ catch (err) {
1925
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1926
+ throw err;
1927
+ }
1928
+ finalStatus = await _pollToTerminal(this.client, {
1929
+ workflowId: created.workflowId,
1930
+ deadline,
1931
+ signal,
1932
+ pollIntervalMs: options.pollIntervalMs,
1933
+ });
1934
+ }
1935
+ if (Date.now() >= deadline) {
1936
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1937
+ }
1938
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1939
+ if (Date.now() >= deadline) {
1940
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1941
+ }
1942
+ // Project ONLY the watermark job's output — the `src_*` passthrough jobs
1943
+ // re-expose the raw base/overlay uploads, which are plumbing.
1944
+ const watermarkDownloads = downloads.downloads.filter((d) => d.ref === 'watermark');
1945
+ const downloader = new LazyHttpDownloader();
1946
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
1947
+ }
1948
+ /**
1949
+ * Fire-and-forget: upload base + overlay + create the watermark workflow
1950
+ * (wiring `webhook` into `callback_url` when given), return a client-bound
1951
+ * {@link Handle}. Does NOT wait for terminal status. Mirrors {@link MergedRecipe.submit}.
1952
+ */
1953
+ async submit(webhook, options) {
1954
+ if (this.client === undefined) {
1955
+ throw new GislConfigError('WatermarkedRecipe.submit() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
1956
+ }
1957
+ const created = await this._uploadAllAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
1958
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1959
+ }
1960
+ // ---------------------------------------------------------------------------
1961
+ /** Base + overlay inputs, in upload/lowering order (`[base, overlay]`). */
1962
+ inputsInOrder() {
1963
+ return [this.baseInput, this.overlay.recipeInput];
1964
+ }
1965
+ /**
1966
+ * Validate the watermark BEFORE any upload: the base must route to a shippable
1967
+ * wire op (throws for undetectable/unsupported/planned bases), and the overlay
1968
+ * must be an image. Shared by {@link run}/{@link submit}. Mirrors
1969
+ * {@link MergedRecipe.validatePreUpload}.
1970
+ */
1971
+ validatePreUpload() {
1972
+ _resolveWatermarkWireOp(_watermarkEffectiveBase(this.baseInput, this.baseSteps));
1973
+ _validateWatermarkOverlay(this.overlay);
1974
+ }
1975
+ /**
1976
+ * Upload base + overlay (verbatim for a pre-uploaded id; uploading a path /
1977
+ * blob otherwise) then create ONE watermark workflow. Validates pre-upload.
1978
+ * Shared first half of {@link run} + {@link submit}; mirrors
1979
+ * {@link MergedRecipe._uploadAllAndCreate}.
1980
+ */
1981
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
1982
+ this.validatePreUpload();
1983
+ return _uploadInputsAndCreate(this.client, this.inputsInOrder(), (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
1984
+ webhook,
1985
+ deadline,
1986
+ onProgress,
1987
+ signal,
1988
+ probeBeforeCreate,
1989
+ probeTimeoutMs,
1990
+ uploadsLabel: 'watermark',
1991
+ workflowLabel: 'the watermark workflow',
1992
+ });
1993
+ }
1994
+ /**
1995
+ * Lower the post-watermark chain over a synthetic input whose extension
1996
+ * matches the watermark OUTPUT media (image→png, video→mp4) so
1997
+ * `compress(optimize)` resolves the correct preset — mirrors
1998
+ * {@link MergedRecipe.lowerPostSteps}.
1999
+ */
2000
+ lowerPostSteps(wireOp) {
2001
+ if (this.postSteps.length === 0) {
2002
+ return [];
2003
+ }
2004
+ const ext = wireOp === 'video_watermark' ? 'mp4' : 'png';
2005
+ const synthetic = fileInput.path(`watermarked.${ext}`);
2006
+ const recipe = new Recipe(synthetic, undefined, this.postSteps, this.presetDefaults, this.scopedPresetDefaults);
2007
+ return recipe.toWorkflowPayload('watermarked').jobs[0].operations;
2008
+ }
2009
+ withStep(step) {
2010
+ return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
2011
+ }
2012
+ }