@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.
- package/README.md +21 -0
- package/dist/_audit.js +15 -2
- package/dist/client.d.ts +27 -4
- package/dist/client.js +46 -4
- package/dist/ergonomic/image_output_routes.d.ts +89 -0
- package/dist/ergonomic/image_output_routes.js +155 -0
- package/dist/ergonomic/option_types.d.ts +147 -0
- package/dist/ergonomic/option_types.js +57 -0
- package/dist/ergonomic/option_validation.d.ts +58 -0
- package/dist/ergonomic/option_validation.js +143 -0
- package/dist/ergonomic/preset_resolver.d.ts +7 -6
- package/dist/ergonomic/preset_resolver.js +43 -39
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +3 -5
- package/dist/ergonomic/presets/document_pdf_compress.js +10 -12
- package/dist/ergonomic/presets/image_compress.d.ts +4 -11
- package/dist/ergonomic/presets/image_compress.js +12 -25
- package/dist/ergonomic/presets/index.d.ts +21 -1
- package/dist/ergonomic/presets/index.js +4 -4
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +34 -0
- package/dist/file-first.d.ts +241 -49
- package/dist/file-first.js +803 -249
- package/dist/generated/sdk_spec/enums.d.ts +8 -46
- package/dist/generated/sdk_spec/enums.js +12 -33
- package/dist/generated/sdk_spec/presets.js +9 -20
- package/dist/generated/sdk_spec/version.d.ts +3 -3
- package/dist/generated/sdk_spec/version.js +3 -3
- package/dist/gisl.d.ts +8 -1
- package/dist/gisl.js +12 -0
- package/dist/handle.js +9 -1
- package/dist/index.core.d.ts +7 -4
- package/dist/index.core.js +8 -1
- package/dist/types.d.ts +21 -0
- package/package.json +3 -3
package/dist/file-first.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
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`.
|
|
446
|
-
//
|
|
447
|
-
|
|
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.
|
|
455
|
-
*
|
|
456
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
1499
|
+
return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
|
|
1500
|
+
webhook,
|
|
1501
|
+
deadline,
|
|
1502
|
+
onProgress,
|
|
1053
1503
|
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;
|
|
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
|
-
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
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,
|
|
1702
|
+
return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
|
|
1703
|
+
webhook,
|
|
1704
|
+
deadline,
|
|
1705
|
+
onProgress,
|
|
1308
1706
|
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;
|
|
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
|
-
|
|
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,
|
|
1918
|
+
return _uploadInputsAndCreate(this.client, this.inputs, (fileIds, callbackUrl) => this.toWorkflowPayload(fileIds, callbackUrl), {
|
|
1919
|
+
webhook,
|
|
1920
|
+
deadline,
|
|
1921
|
+
onProgress,
|
|
1573
1922
|
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;
|
|
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
|
+
}
|