@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.
- package/dist/_audit.js +3 -0
- package/dist/client.d.ts +19 -3
- package/dist/client.js +34 -3
- package/dist/ergonomic/preset_resolver.d.ts +9 -2
- package/dist/ergonomic/preset_resolver.js +10 -2
- package/dist/file-first.d.ts +172 -0
- package/dist/file-first.js +590 -190
- package/dist/generated/sdk_spec/presets.js +2 -5
- package/dist/generated/sdk_spec/version.d.ts +3 -3
- package/dist/generated/sdk_spec/version.js +3 -3
- package/dist/handle.js +9 -1
- package/dist/index.core.d.ts +2 -0
- package/dist/index.core.js +5 -0
- package/dist/types.d.ts +21 -0
- package/package.json +2 -2
package/dist/file-first.js
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
+
}
|