@giveitsmaller/sdk 0.16.0 → 0.18.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 +12 -2
- package/dist/client.d.ts +8 -1
- package/dist/client.js +12 -1
- 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 +1 -1
- package/dist/ergonomic/preset_resolver.js +35 -33
- 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 +78 -58
- package/dist/file-first.js +220 -66
- package/dist/generated/sdk_spec/enums.d.ts +10 -46
- package/dist/generated/sdk_spec/enums.js +14 -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/index.core.d.ts +5 -4
- package/dist/index.core.js +3 -1
- package/package.json +3 -3
package/dist/file-first.d.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Mirrors `packages/php/src/FileFirst/*`.
|
|
11
11
|
*/
|
|
12
|
+
import { GislItemFailedError } from './errors.js';
|
|
12
13
|
import { type ProgressEvent } from './builder.js';
|
|
13
14
|
import type { GislClient } from './client.js';
|
|
14
15
|
import type { OperationDownload, WorkflowStatusResponse } from '@giveitsmaller/contracts/openapi';
|
|
16
|
+
import type { ConvertOptions, ThumbnailOptions, TextWatermarkOptions, WatermarkOptions, OutputOptions, OutputFit } from './ergonomic/option_types.js';
|
|
15
17
|
import { OptimizeFor } from './generated/sdk_spec/enums.js';
|
|
16
18
|
import type { PresetDefaults } from './ergonomic/presets/index.js';
|
|
17
19
|
import type { WorkflowCreatePayload } from './types.js';
|
|
@@ -66,15 +68,16 @@ export interface ItemResult {
|
|
|
66
68
|
readonly outputs: readonly OutputFile[];
|
|
67
69
|
}
|
|
68
70
|
/**
|
|
69
|
-
* One failed entry in {@link RunResult.failed}: an input that did not
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* One failed entry in {@link RunResult.failed}: an input that did not produce a
|
|
72
|
+
* deliverable, paired with the cause. One bad input does not sink the rest of a
|
|
73
|
+
* multi-input run. `error` is a typed {@link GislItemFailedError} carrying the
|
|
74
|
+
* terminal `state` plus the failing operation's `errorMessage`/`errorCode` (when
|
|
75
|
+
* present), so the caller can branch on the failure WITHOUT string-parsing.
|
|
76
|
+
* Mirrors the PHP `ItemFailure`.
|
|
74
77
|
*/
|
|
75
78
|
export interface ItemFailure {
|
|
76
79
|
readonly key: string | null;
|
|
77
|
-
readonly error:
|
|
80
|
+
readonly error: GislItemFailedError;
|
|
78
81
|
}
|
|
79
82
|
/**
|
|
80
83
|
* Return value of {@link RunResult.downloadTo} — the local paths written,
|
|
@@ -159,27 +162,13 @@ export declare class RunResult {
|
|
|
159
162
|
failed: readonly {
|
|
160
163
|
key: string | null;
|
|
161
164
|
error: string;
|
|
165
|
+
state: string;
|
|
166
|
+
errorMessage?: string;
|
|
167
|
+
errorCode?: string;
|
|
162
168
|
}[];
|
|
163
169
|
};
|
|
164
170
|
private requireDownloader;
|
|
165
171
|
}
|
|
166
|
-
/**
|
|
167
|
-
* Flatten the terminal workflow status + its downloads into a {@link RunResult}.
|
|
168
|
-
*
|
|
169
|
-
* Shared by {@link Recipe.run} (passes its recipe key) and the file-first
|
|
170
|
-
* {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
|
|
171
|
-
* passes `null` because a reattached handle carries no recipe key).
|
|
172
|
-
*
|
|
173
|
-
* **Partition invariant (carries a prior codex-review fix — do NOT let it
|
|
174
|
-
* drift):** success is ONLY `state === 'completed'`. Every other terminal
|
|
175
|
-
* state — `failed`, `partially_failed`, `cancelled`, `expired`,
|
|
176
|
-
* `paused_insufficient_credits` — partitions into `failed[]` so a caller's
|
|
177
|
-
* `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
|
|
178
|
-
* clean result.
|
|
179
|
-
*
|
|
180
|
-
* @internal Exported for reuse by the file-first `Handle`; not part of the
|
|
181
|
-
* caller-facing fluent surface.
|
|
182
|
-
*/
|
|
183
172
|
export declare function projectDownloadsToRunResult(workflowId: string, finalStatus: WorkflowStatusResponse, jobDownloads: readonly {
|
|
184
173
|
files: readonly OperationDownload[];
|
|
185
174
|
}[], key: string | null, downloader?: Downloader): RunResult;
|
|
@@ -306,7 +295,7 @@ export declare const fileInput: {
|
|
|
306
295
|
};
|
|
307
296
|
/** One step in a {@link Recipe}'s chain — an op kind + captured ergonomic args. */
|
|
308
297
|
interface RecipeStep {
|
|
309
|
-
readonly opType: 'compress' | 'convert' | 'thumbnail' | 'text_watermark';
|
|
298
|
+
readonly opType: 'compress' | 'convert' | 'thumbnail' | 'text_watermark' | 'output';
|
|
310
299
|
readonly options: Readonly<Record<string, unknown>>;
|
|
311
300
|
}
|
|
312
301
|
/**
|
|
@@ -352,22 +341,42 @@ export declare class Recipe {
|
|
|
352
341
|
* option (the convert op's wire key per the contract); `options` carries any
|
|
353
342
|
* additional per-op convert options.
|
|
354
343
|
*/
|
|
355
|
-
convert(format: string, options?:
|
|
344
|
+
convert(format: string, options?: ConvertOptions): Recipe;
|
|
356
345
|
/**
|
|
357
|
-
* Generate a preview.
|
|
358
|
-
*
|
|
359
|
-
*
|
|
346
|
+
* Generate a preview / resize. `width` AND `height` are required (the contract
|
|
347
|
+
* marks both required for image/video/document); any additional per-op
|
|
348
|
+
* thumbnail option passes through. An omitted (`undefined`) optional value is
|
|
349
|
+
* dropped from the wire options (not sent as `undefined`).
|
|
360
350
|
*/
|
|
361
|
-
thumbnail(options
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
351
|
+
thumbnail(options: ThumbnailOptions): Recipe;
|
|
352
|
+
/**
|
|
353
|
+
* Produce ONE transformed image: keep or change format, plus quality, resize
|
|
354
|
+
* and route-honored controls. The single user-facing image transform — the SDK
|
|
355
|
+
* resolves the route from `(input format, output_format)` against the contract's
|
|
356
|
+
* image-output-routes projection and lowers to that route's wire op:
|
|
357
|
+
* same-format → `compress` (optimiser, `output_format: 'original'`), format-change
|
|
358
|
+
* → `convert` (transcoder, `output_format: <fmt>`). Only options the resolved
|
|
359
|
+
* route honors are sent; a planned or not-honored option throws BEFORE upload.
|
|
360
|
+
* Resize (`width`/`height`/`fit`, via `options` or {@link resize}) stays on the
|
|
361
|
+
* SAME op — one output, never a separate thumbnail.
|
|
362
|
+
*
|
|
363
|
+
* `format` omitted → keep the input format (same-format optimiser route).
|
|
364
|
+
*/
|
|
365
|
+
output(format?: string, options?: OutputOptions): Recipe;
|
|
366
|
+
/**
|
|
367
|
+
* Resize as part of the Output transform. Merges `width`/`height`/`fit` into the
|
|
368
|
+
* PRECEDING `output()` step (one artifact); if no Output step precedes, appends a
|
|
369
|
+
* same-format Output step carrying the resize. Never emits a `thumbnail` op.
|
|
370
|
+
* `height` is optional — width-only resize preserves aspect ratio. Resize is
|
|
371
|
+
* raster-only (e.g. an SVG input has no resize on its route → throws at lower).
|
|
372
|
+
*/
|
|
373
|
+
resize(width: number, height?: number, fit?: OutputFit): Recipe;
|
|
365
374
|
/**
|
|
366
375
|
* Apply a text watermark. Single-input (the text is an option, not a
|
|
367
376
|
* secondary file) — lowers to the `text_watermark` op with a `text` option;
|
|
368
377
|
* `options` carries any additional per-op watermark options.
|
|
369
378
|
*/
|
|
370
|
-
textWatermark(text: string, options?:
|
|
379
|
+
textWatermark(text: string, options?: TextWatermarkOptions): Recipe;
|
|
371
380
|
/**
|
|
372
381
|
* Composite an image OVERLAY onto this file (a multi-input op). `overlay` is a
|
|
373
382
|
* secondary file-NODE (a {@link Recipe} — e.g. `client.file('logo.png')`),
|
|
@@ -380,7 +389,7 @@ export declare class Recipe {
|
|
|
380
389
|
* `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
|
|
381
390
|
* {@link textWatermark} (single-input text overlay).
|
|
382
391
|
*/
|
|
383
|
-
watermark(overlay: Recipe, options?:
|
|
392
|
+
watermark(overlay: Recipe, options?: WatermarkOptions): WatermarkedRecipe;
|
|
384
393
|
/**
|
|
385
394
|
* Lower this recipe to a workflow-create payload against a resolved upload
|
|
386
395
|
* id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
|
|
@@ -465,6 +474,26 @@ export declare class Recipe {
|
|
|
465
474
|
private _uploadAndCreate;
|
|
466
475
|
private withStep;
|
|
467
476
|
private lowerStep;
|
|
477
|
+
/**
|
|
478
|
+
* Lower an `output` step to its route's wire op. Resolves the route from the
|
|
479
|
+
* (chain-folded) input format token + the requested `output_format`, then emits
|
|
480
|
+
* `compress` (same_format) or `convert` (format_change) carrying only the
|
|
481
|
+
* route-honored options. A planned option (e.g. `lossless`), an option not
|
|
482
|
+
* honored on the resolved route (e.g. `progressive` on a format-change), a
|
|
483
|
+
* planned per-value (e.g. `metadata: 'keep'`), or an unrepresentable route all
|
|
484
|
+
* throw a typed {@link GislConfigError} BEFORE upload. Resize (`width`/`height`/
|
|
485
|
+
* `fit`) is input-keyed (raster only) and rides whichever op the route selects.
|
|
486
|
+
*/
|
|
487
|
+
private lowerOutputStep;
|
|
488
|
+
/**
|
|
489
|
+
* The input format token an `output` step at `uptoIndex` operates on — the
|
|
490
|
+
* original input's token, FOLDED through preceding `convert`/`output` steps that
|
|
491
|
+
* change the format (mirrors {@link compressMediaHint}). Undefined when the input
|
|
492
|
+
* media is not inferable (a bare upload id / unnamed, untyped Blob).
|
|
493
|
+
*/
|
|
494
|
+
private outputInputToken;
|
|
495
|
+
/** The original input's image format token (path ext / Blob type / Blob name). */
|
|
496
|
+
private inputFormatToken;
|
|
468
497
|
private lowerCompressOptions;
|
|
469
498
|
/** Media of the original input (no chain context) — used by the probe gate. */
|
|
470
499
|
private inputMedia;
|
|
@@ -555,15 +584,12 @@ export declare class FilesRecipe {
|
|
|
555
584
|
* lowering builds an internal Recipe that throws the same `GislConfigError`.
|
|
556
585
|
*/
|
|
557
586
|
compress(optimize?: OptimizeFor, options?: Record<string, unknown>): FilesRecipe;
|
|
558
|
-
/** Change every input's format. `format` lowers to the contract `output_format` wire key (via {@link Recipe.convert}), NOT `format`. */
|
|
559
|
-
convert(format: string, options?:
|
|
560
|
-
/** Generate a preview of every input.
|
|
561
|
-
thumbnail(options
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
} & Record<string, unknown>): FilesRecipe;
|
|
565
|
-
/** Apply the same text watermark to every input. */
|
|
566
|
-
textWatermark(text: string, options?: Record<string, unknown>): FilesRecipe;
|
|
587
|
+
/** 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. */
|
|
588
|
+
convert(format: string, options?: ConvertOptions): FilesRecipe;
|
|
589
|
+
/** Generate a preview of every input. `width` AND `height` are required; validated via the base {@link Recipe} before any upload. */
|
|
590
|
+
thumbnail(options: ThumbnailOptions): FilesRecipe;
|
|
591
|
+
/** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
|
|
592
|
+
textWatermark(text: string, options?: TextWatermarkOptions): FilesRecipe;
|
|
567
593
|
/**
|
|
568
594
|
* Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
|
|
569
595
|
* single-output {@link MergedRecipe} you chain further ops on
|
|
@@ -694,13 +720,10 @@ export declare class MergedRecipe {
|
|
|
694
720
|
constructor(inputs: readonly FileInput[], mergeOptions: MergeOptions, postSteps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
|
|
695
721
|
/** Reduce the merged output's size. See {@link Recipe.compress}. */
|
|
696
722
|
compress(optimize?: OptimizeFor, options?: Record<string, unknown>): MergedRecipe;
|
|
697
|
-
/** Change the merged output's format. See {@link Recipe.convert}. */
|
|
698
|
-
convert(format: string, options?:
|
|
699
|
-
/** Thumbnail the merged output.
|
|
700
|
-
thumbnail(options
|
|
701
|
-
width?: number;
|
|
702
|
-
height?: number;
|
|
703
|
-
} & Record<string, unknown>): MergedRecipe;
|
|
723
|
+
/** Change the merged output's format. See {@link Recipe.convert}. Option keys validated pre-upload. */
|
|
724
|
+
convert(format: string, options?: ConvertOptions): MergedRecipe;
|
|
725
|
+
/** Thumbnail the merged output. `width` AND `height` are required; validated pre-upload. */
|
|
726
|
+
thumbnail(options: ThumbnailOptions): MergedRecipe;
|
|
704
727
|
/**
|
|
705
728
|
* Lower to the merge DAG: one `passthrough` source job per input + one
|
|
706
729
|
* `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
|
|
@@ -877,16 +900,13 @@ export declare class WatermarkedRecipe {
|
|
|
877
900
|
private readonly presetDefaults?;
|
|
878
901
|
private readonly scopedPresetDefaults?;
|
|
879
902
|
private readonly client?;
|
|
880
|
-
constructor(baseInput: FileInput, baseSteps: readonly RecipeStep[], overlay: Recipe, watermarkOptions:
|
|
903
|
+
constructor(baseInput: FileInput, baseSteps: readonly RecipeStep[], overlay: Recipe, watermarkOptions: WatermarkOptions, postSteps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
|
|
881
904
|
/** Reduce the watermarked output's size. See {@link Recipe.compress}. */
|
|
882
905
|
compress(optimize?: OptimizeFor, options?: Record<string, unknown>): WatermarkedRecipe;
|
|
883
|
-
/** Change the watermarked output's format. See {@link Recipe.convert}. */
|
|
884
|
-
convert(format: string, options?:
|
|
885
|
-
/** Thumbnail the watermarked output.
|
|
886
|
-
thumbnail(options
|
|
887
|
-
width?: number;
|
|
888
|
-
height?: number;
|
|
889
|
-
} & Record<string, unknown>): WatermarkedRecipe;
|
|
906
|
+
/** Change the watermarked output's format. See {@link Recipe.convert}. Option keys validated pre-upload. */
|
|
907
|
+
convert(format: string, options?: ConvertOptions): WatermarkedRecipe;
|
|
908
|
+
/** Thumbnail the watermarked output. `width` AND `height` are required; validated pre-upload. */
|
|
909
|
+
thumbnail(options: ThumbnailOptions): WatermarkedRecipe;
|
|
890
910
|
/**
|
|
891
911
|
* Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
|
|
892
912
|
* passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
|
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
|
}
|
|
@@ -468,21 +464,23 @@ export class Recipe {
|
|
|
468
464
|
* additional per-op convert options.
|
|
469
465
|
*/
|
|
470
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);
|
|
471
470
|
// The convert op's wire key is `output_format` (contract: convert.yaml,
|
|
472
|
-
// required, all media), NOT `format`.
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
// not a valid convert option; drop it so the wire never carries both keys.
|
|
476
|
-
const rest = { ...options };
|
|
477
|
-
delete rest.format;
|
|
478
|
-
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 } });
|
|
479
474
|
}
|
|
480
475
|
/**
|
|
481
|
-
* Generate a preview.
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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`).
|
|
484
480
|
*/
|
|
485
|
-
thumbnail(options
|
|
481
|
+
thumbnail(options) {
|
|
482
|
+
validateVerbOptions('thumbnail', options);
|
|
483
|
+
assertThumbnailDimensions(options);
|
|
486
484
|
const wire = {};
|
|
487
485
|
for (const [key, value] of Object.entries(options)) {
|
|
488
486
|
if (value !== undefined)
|
|
@@ -490,13 +488,65 @@ export class Recipe {
|
|
|
490
488
|
}
|
|
491
489
|
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
492
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
|
+
}
|
|
493
541
|
/**
|
|
494
542
|
* Apply a text watermark. Single-input (the text is an option, not a
|
|
495
543
|
* secondary file) — lowers to the `text_watermark` op with a `text` option;
|
|
496
544
|
* `options` carries any additional per-op watermark options.
|
|
497
545
|
*/
|
|
498
546
|
textWatermark(text, options = {}) {
|
|
499
|
-
//
|
|
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);
|
|
500
550
|
return this.withStep({ opType: 'text_watermark', options: { ...options, text } });
|
|
501
551
|
}
|
|
502
552
|
/**
|
|
@@ -512,6 +562,9 @@ export class Recipe {
|
|
|
512
562
|
* {@link textWatermark} (single-input text overlay).
|
|
513
563
|
*/
|
|
514
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);
|
|
515
568
|
// Eager gate when the base media is KNOWN (unit-testable pre-upload); an
|
|
516
569
|
// undetectable base is DEFERRED — re-checked pre-upload in run()/submit().
|
|
517
570
|
const base = _watermarkEffectiveBase(this.input, this.steps);
|
|
@@ -742,6 +795,11 @@ export class Recipe {
|
|
|
742
795
|
return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
743
796
|
}
|
|
744
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.
|
|
745
803
|
const options = step.opType === 'compress'
|
|
746
804
|
? this.lowerCompressOptions(step.options, stepIndex)
|
|
747
805
|
: { ...step.options };
|
|
@@ -751,6 +809,103 @@ export class Recipe {
|
|
|
751
809
|
? { type: step.opType }
|
|
752
810
|
: { type: step.opType, options };
|
|
753
811
|
}
|
|
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
|
+
}
|
|
754
909
|
lowerCompressOptions(stepOptions, uptoIndex) {
|
|
755
910
|
// Mirror the op-first resolver precedence (OperationBuilder._resolve in
|
|
756
911
|
// builder.ts): optimize = preset layer, presetOverrides = callPresetOverride
|
|
@@ -1162,15 +1317,15 @@ export class FilesRecipe {
|
|
|
1162
1317
|
compress(optimize, options = {}) {
|
|
1163
1318
|
return this.withStep(this.baseRecipe().compress(optimize, options));
|
|
1164
1319
|
}
|
|
1165
|
-
/** 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. */
|
|
1166
1321
|
convert(format, options = {}) {
|
|
1167
1322
|
return this.withStep(this.baseRecipe().convert(format, options));
|
|
1168
1323
|
}
|
|
1169
|
-
/** Generate a preview of every input.
|
|
1170
|
-
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) {
|
|
1171
1326
|
return this.withStep(this.baseRecipe().thumbnail(options));
|
|
1172
1327
|
}
|
|
1173
|
-
/** 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}. */
|
|
1174
1329
|
textWatermark(text, options = {}) {
|
|
1175
1330
|
return this.withStep(this.baseRecipe().textWatermark(text, options));
|
|
1176
1331
|
}
|
|
@@ -1413,19 +1568,16 @@ export class MergedRecipe {
|
|
|
1413
1568
|
options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
|
|
1414
1569
|
});
|
|
1415
1570
|
}
|
|
1416
|
-
/** 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. */
|
|
1417
1572
|
convert(format, options = {}) {
|
|
1418
|
-
|
|
1419
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
}
|
|
1427
|
-
/** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
|
|
1428
|
-
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);
|
|
1429
1581
|
const wire = {};
|
|
1430
1582
|
for (const [key, value] of Object.entries(options)) {
|
|
1431
1583
|
if (value !== undefined)
|
|
@@ -1841,14 +1993,16 @@ export class WatermarkedRecipe {
|
|
|
1841
1993
|
options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
|
|
1842
1994
|
});
|
|
1843
1995
|
}
|
|
1844
|
-
/** Change the watermarked output's format. See {@link Recipe.convert}. */
|
|
1996
|
+
/** Change the watermarked output's format. See {@link Recipe.convert}. Option keys validated pre-upload. */
|
|
1845
1997
|
convert(format, options = {}) {
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
return this.withStep({ opType: 'convert', options: { ...
|
|
1849
|
-
}
|
|
1850
|
-
/** Thumbnail the watermarked output.
|
|
1851
|
-
thumbnail(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);
|
|
1852
2006
|
const wire = {};
|
|
1853
2007
|
for (const [key, value] of Object.entries(options)) {
|
|
1854
2008
|
if (value !== undefined)
|