@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.
@@ -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
- * produce a deliverable, paired with the cause. One bad input does not sink
71
- * the rest of a multi-input run. `error` is `unknown` (mirroring the PHP
72
- * `\Throwable`) so the caller narrows with `instanceof`. Mirrors the PHP
73
- * `ItemFailure`.
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: unknown;
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?: Record<string, unknown>): Recipe;
344
+ convert(format: string, options?: ConvertOptions): Recipe;
356
345
  /**
357
- * Generate a preview. Width and/or height in pixels; any additional per-op
358
- * thumbnail options pass through. An omitted (`undefined`) value is dropped
359
- * from the wire options (not sent as `undefined`).
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
- width?: number;
363
- height?: number;
364
- } & Record<string, unknown>): Recipe;
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?: Record<string, unknown>): Recipe;
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?: Record<string, unknown>): WatermarkedRecipe;
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?: Record<string, unknown>): FilesRecipe;
560
- /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
561
- thumbnail(options?: {
562
- width?: number;
563
- height?: number;
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?: Record<string, unknown>): MergedRecipe;
699
- /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
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: Readonly<Record<string, unknown>>, postSteps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
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?: Record<string, unknown>): WatermarkedRecipe;
885
- /** Thumbnail the watermarked output. Omitted dimensions are dropped from the wire options. */
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
@@ -9,10 +9,12 @@
9
9
  *
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
- import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
12
+ import { GislConfigError, GislItemFailedError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, _cappedProbeTimeoutMs, } from './builder.js';
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
+ import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
17
+ import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
16
18
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
19
  import { uploadSource, jobOutputSource } from './types.js';
18
20
  // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
@@ -150,10 +152,16 @@ export class RunResult {
150
152
  const rest = {
151
153
  artifacts: this.artifacts.map(file),
152
154
  succeeded: this.succeeded.map((i) => ({ key: i.key, outputs: i.outputs.map(file) })),
153
- failed: this.failed.map((f) => ({
154
- key: f.key,
155
- error: f.error instanceof Error ? f.error.message : String(f.error),
156
- })),
155
+ // Field order (key, error, state, errorMessage?, errorCode?) is fixed to
156
+ // match the PHP ItemFailure::toArray() so JSON-string parity holds; the two
157
+ // optional keys are OMITTED when absent (cancel/expire carry only state),
158
+ // mirroring PHP's omit-when-null (NOT emitted as `undefined`/`null`).
159
+ failed: this.failed.map((f) => {
160
+ const e = f.error;
161
+ const base = { key: f.key, error: e.message, state: e.state };
162
+ const withMsg = e.errorMessage === undefined ? base : { ...base, errorMessage: e.errorMessage };
163
+ return e.errorCode === undefined ? withMsg : { ...withMsg, errorCode: e.errorCode };
164
+ }),
157
165
  };
158
166
  const head = { workflowId: this.workflowId, state: this.state, ok: this.ok };
159
167
  // Insert `url` BETWEEN ok and artifacts when present, matching the PHP
@@ -173,22 +181,15 @@ export class RunResult {
173
181
  }
174
182
  }
175
183
  /**
176
- * Flatten the terminal workflow status + its downloads into a {@link RunResult}.
177
- *
178
- * Shared by {@link Recipe.run} (passes its recipe key) and the file-first
179
- * {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
180
- * passes `null` because a reattached handle carries no recipe key).
181
- *
182
- * **Partition invariant (carries a prior codex-review fix — do NOT let it
183
- * drift):** success is ONLY `state === 'completed'`. Every other terminal
184
- * state — `failed`, `partially_failed`, `cancelled`, `expired`,
185
- * `paused_insufficient_credits` — partitions into `failed[]` so a caller's
186
- * `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
187
- * clean result.
188
- *
189
- * @internal Exported for reuse by the file-first `Handle`; not part of the
190
- * caller-facing fluent surface.
184
+ * Extract the human + machine error from the FIRST failing operation in `ops`
185
+ * (the first op carrying an `errorMessage` OR `errorCode`), reading BOTH from the
186
+ * SAME op so a code from one op can't pair with a message from another. Both are
187
+ * absent for terminal states with no failing op (cancel/expire/credit-pause).
191
188
  */
189
+ function firstOpError(ops) {
190
+ const op = ops.find((o) => o.errorMessage !== undefined || o.errorCode !== undefined);
191
+ return { errorMessage: op?.errorMessage, errorCode: op?.errorCode };
192
+ }
192
193
  export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownloads, key, downloader) {
193
194
  // Flatten to the lean OutputFile[] (the four file-first fields only).
194
195
  const artifacts = [];
@@ -210,14 +211,10 @@ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownload
210
211
  failed = [];
211
212
  }
212
213
  else {
213
- const firstError = (finalStatus.jobs ?? [])
214
- .flatMap((j) => j.operations ?? [])
215
- .map((op) => op.errorMessage)
216
- .find((m) => m !== undefined);
214
+ // First failing op across ALL jobs (downloads path is whole-workflow scoped).
215
+ const { errorMessage, errorCode } = firstOpError((finalStatus.jobs ?? []).flatMap((j) => j.operations ?? []));
217
216
  succeeded = [];
218
- failed = [
219
- { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
220
- ];
217
+ failed = [{ key, error: new GislItemFailedError(key, state, errorMessage, errorCode) }];
221
218
  }
222
219
  return new RunResult(workflowId, state, artifacts, succeeded, failed, downloader);
223
220
  }
@@ -269,12 +266,11 @@ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads
269
266
  succeeded.push({ key, outputs });
270
267
  }
271
268
  else {
272
- const firstError = (job.operations ?? [])
273
- .map((op) => op.errorMessage)
274
- .find((m) => m !== undefined);
269
+ // Per-job scoped: read the error from THIS job's ops only.
270
+ const { errorMessage, errorCode } = firstOpError(job.operations ?? []);
275
271
  failed.push({
276
272
  key,
277
- error: new Error(firstError !== undefined ? `${job.status}: ${firstError}` : String(job.status)),
273
+ error: new GislItemFailedError(key, String(job.status), errorMessage, errorCode),
278
274
  });
279
275
  }
280
276
  }
@@ -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`. Spread options FIRST so the explicit
473
- // shorthand wins over an `output_format` key in the bag.
474
- // The shorthand owns the format a stray legacy `format` key in the bag is
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. Width and/or height in pixels; any additional per-op
482
- * thumbnail options pass through. An omitted (`undefined`) value is dropped
483
- * from the wire options (not sent as `undefined`).
476
+ * Generate a preview / resize. `width` AND `height` are required (the contract
477
+ * marks both required for image/video/document); any additional per-op
478
+ * thumbnail option passes through. An omitted (`undefined`) optional value is
479
+ * dropped from the wire options (not sent as `undefined`).
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
- // Spread options FIRST so the explicit `text` argument is authoritative.
547
+ // Eager pre-upload validation (rejects unknown keys + a user-supplied `text`,
548
+ // which this verb owns via the first argument).
549
+ validateVerbOptions('textWatermark', options);
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. Omitted dimensions are dropped from the wire options. */
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
- // The convert op's wire key is `output_format` (contract: convert.yaml,
1419
- // required, all media), NOT `format`. Spread options FIRST so the explicit
1420
- // shorthand wins over an `output_format` key in the bag.
1421
- // The shorthand owns the format → a stray legacy `format` key in the bag is
1422
- // not a valid convert option; drop it so the wire never carries both keys.
1423
- const rest = { ...options };
1424
- delete rest.format;
1425
- return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
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
- const rest = { ...options };
1847
- delete rest.format;
1848
- return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
1849
- }
1850
- /** Thumbnail the watermarked output. Omitted dimensions are dropped from the wire options. */
1851
- thumbnail(options = {}) {
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)