@giveitsmaller/sdk 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/builder.d.ts CHANGED
@@ -41,6 +41,20 @@ import type { PresetDefaults, PresetMedia } from './ergonomic/presets/index.js';
41
41
  * @internal — exported for tests + the preset resolver.
42
42
  */
43
43
  export declare function _detectCompressMedia(input: string | Blob): PresetMedia | undefined;
44
+ /**
45
+ * Best-effort classification of whether an audio input is LOSSLESS
46
+ * (flac/wav) vs lossy. The worker rejects `bitrate` on lossless audio
47
+ * (compress.yaml / contracts iakhSy3E), so the preset resolver uses this
48
+ * to drop the shipped-preset bitrate for clear-cut lossless inputs.
49
+ *
50
+ * Detection is filename/MIME only — it CANNOT probe the actual codec, so
51
+ * any ambiguous or unknown input classifies as lossy (keep bitrate). The
52
+ * worker stays authoritative: a user-supplied bitrate on a lossless file
53
+ * still reaches the wire and earns a deliberate 422.
54
+ *
55
+ * @internal — exported for tests + the preset resolver.
56
+ */
57
+ export declare function _detectAudioLossless(input: string | Blob): boolean;
44
58
  /**
45
59
  * Lightweight artifact reference passed to a `.mapEach(...)` fn. Mirrors
46
60
  * the subset of `Artifact` a fan-out callback can use to construct the
package/dist/builder.js CHANGED
@@ -103,6 +103,59 @@ export function _detectCompressMedia(input) {
103
103
  return 'document_office';
104
104
  return undefined;
105
105
  }
106
+ // LOSSLESS audio per the compress.yaml contract (contracts iakhSy3E) —
107
+ // flac/wav ONLY. Everything else audio (mp3/mpeg, aac, ogg, m4a/mp4, opus)
108
+ // and any unknown input is treated as lossy so the shipped-preset bitrate is
109
+ // kept. `aiff` is deliberately absent — not a contract audio format.
110
+ const LOSSLESS_AUDIO_MIMES = new Set([
111
+ 'audio/flac',
112
+ 'audio/x-flac',
113
+ 'audio/wav',
114
+ 'audio/x-wav',
115
+ 'audio/wave',
116
+ ]);
117
+ const LOSSLESS_AUDIO_EXTENSIONS = new Set(['flac', 'wav']);
118
+ /**
119
+ * Best-effort classification of whether an audio input is LOSSLESS
120
+ * (flac/wav) vs lossy. The worker rejects `bitrate` on lossless audio
121
+ * (compress.yaml / contracts iakhSy3E), so the preset resolver uses this
122
+ * to drop the shipped-preset bitrate for clear-cut lossless inputs.
123
+ *
124
+ * Detection is filename/MIME only — it CANNOT probe the actual codec, so
125
+ * any ambiguous or unknown input classifies as lossy (keep bitrate). The
126
+ * worker stays authoritative: a user-supplied bitrate on a lossless file
127
+ * still reaches the wire and earns a deliberate 422.
128
+ *
129
+ * @internal — exported for tests + the preset resolver.
130
+ */
131
+ export function _detectAudioLossless(input) {
132
+ let filename;
133
+ let mime;
134
+ if (typeof input === 'string') {
135
+ filename = input;
136
+ }
137
+ else {
138
+ mime = input.type !== '' ? input.type : undefined;
139
+ const named = input.name;
140
+ if (typeof named === 'string')
141
+ filename = named;
142
+ }
143
+ // MIME-first if a recognised audio MIME is present — Blob.type is canonical.
144
+ // Strip any MIME parameters (`audio/flac; codecs=flac`) before the exact-set
145
+ // lookup so a parameterised type still classifies as lossless — `media` is
146
+ // already `audio` via the prefix check, so a miss would wrongly keep the
147
+ // bitrate (codex 18b6b684).
148
+ if (mime !== undefined && mime.startsWith('audio/')) {
149
+ const bareMime = mime.split(';')[0].trim().toLowerCase();
150
+ return LOSSLESS_AUDIO_MIMES.has(bareMime);
151
+ }
152
+ if (filename === undefined)
153
+ return false;
154
+ const ext = filename.toLowerCase().split('.').pop();
155
+ if (ext === undefined)
156
+ return false;
157
+ return LOSSLESS_AUDIO_EXTENSIONS.has(ext);
158
+ }
106
159
  // ---------------------------------------------------------------------------
107
160
  // OperationBuilder
108
161
  // ---------------------------------------------------------------------------
@@ -177,6 +230,9 @@ export class OperationBuilder {
177
230
  op: 'compress',
178
231
  explicitOptions,
179
232
  };
233
+ if (media === 'audio') {
234
+ input.audioLossless = _detectAudioLossless(this.input);
235
+ }
180
236
  if (this.presetDefaults !== undefined) {
181
237
  input.presetDefaults = this.presetDefaults;
182
238
  }
@@ -36,6 +36,12 @@ export interface ResolveCompressOptionsInput {
36
36
  * layer.
37
37
  */
38
38
  readonly explicitOptions: Readonly<Record<string, unknown>>;
39
+ /**
40
+ * Classifier result from media detection. When `true` on audio, the
41
+ * shipped-preset (sdkDefault) bitrate is dropped — the worker rejects
42
+ * `bitrate` on lossless outputs (flac/wav — contracts iakhSy3E).
43
+ */
44
+ readonly audioLossless?: boolean;
39
45
  }
40
46
  /**
41
47
  * Output of {@link resolveCompressOptions}. `wireOptions` is the
@@ -432,7 +432,7 @@ function computePresetConfigHash(clientDefault, scopedDefault, callPresetOverrid
432
432
  * `optimize` unset ⇒ layer 1 contributes nothing; `resolvedOptions.preset = null`.
433
433
  */
434
434
  export function resolveCompressOptions(input) {
435
- const { media, op, presetDefaults, scopedPresetDefaults, presetOverrides, optimize, explicitOptions } = input;
435
+ const { media, op, presetDefaults, scopedPresetDefaults, presetOverrides, optimize, explicitOptions, audioLossless } = input;
436
436
  if (op !== 'compress') {
437
437
  throw new GislConfigError(`Preset resolution is only wired for compress operations today; got op='${op}'.`, { reason: 'unsupported_op' });
438
438
  }
@@ -510,6 +510,15 @@ export function resolveCompressOptions(input) {
510
510
  acc.winners.set('encoding_mode', crfSource);
511
511
  }
512
512
  }
513
+ // audio_compress bakes a bitrate (Size 96 / Balanced 192 / Quality 320); the
514
+ // worker rejects `bitrate` on lossless outputs (flac/wav — contracts iakhSy3E).
515
+ // Drop ONLY the shipped-preset (sdkDefault) bitrate for clear-cut lossless
516
+ // audio; any user-supplied bitrate (client/scoped default, per-call override
517
+ // or explicit) is left for the worker to reject — no silent-ignore.
518
+ if (media === 'audio' && audioLossless === true && acc.winners.get('bitrate') === 'sdkDefault') {
519
+ delete acc.merged.bitrate;
520
+ acc.winners.delete('bitrate');
521
+ }
513
522
  // 7. Validate the merged payload (post-merge — catches cross-layer
514
523
  // disagreements). May throw GislConfigError with resolvedSnapshot.
515
524
  const explicitWireKeys = new Set();
package/dist/errors.d.ts CHANGED
@@ -342,6 +342,18 @@ export declare class GislChainCardinalityMismatchError extends GislConfigError {
342
342
  readonly attemptedOperation: string;
343
343
  constructor(previousOperation: string, attemptedOperation: string);
344
344
  }
345
+ /**
346
+ * Thrown by `.bundle(...)` when the target builder's terminal job is already an
347
+ * `archive` op — double-bundle prevention (a builder that already produces an
348
+ * archive cannot be bundled again). No HTTP: raised during lowering, before any
349
+ * upload. Per the lowering spec
350
+ * (`docs/plans/sdk-ergonomics/lowering.md:484`, id `bundle_already_archived_error`).
351
+ * Dormant until `.bundle()` ships (wpHoJhuo) — the type lands here so that PR is
352
+ * a pure addition.
353
+ */
354
+ export declare class GislBundleAlreadyArchivedError extends GislConfigError {
355
+ constructor();
356
+ }
345
357
  export declare class GislTimeoutError extends GislError {
346
358
  constructor(message: string);
347
359
  }
package/dist/errors.js CHANGED
@@ -349,6 +349,22 @@ export class GislChainCardinalityMismatchError extends GislConfigError {
349
349
  this.attemptedOperation = attemptedOperation;
350
350
  }
351
351
  }
352
+ /**
353
+ * Thrown by `.bundle(...)` when the target builder's terminal job is already an
354
+ * `archive` op — double-bundle prevention (a builder that already produces an
355
+ * archive cannot be bundled again). No HTTP: raised during lowering, before any
356
+ * upload. Per the lowering spec
357
+ * (`docs/plans/sdk-ergonomics/lowering.md:484`, id `bundle_already_archived_error`).
358
+ * Dormant until `.bundle()` ships (wpHoJhuo) — the type lands here so that PR is
359
+ * a pure addition.
360
+ */
361
+ export class GislBundleAlreadyArchivedError extends GislConfigError {
362
+ constructor() {
363
+ super('This builder already produces an archive (bundle); .bundle() cannot be ' +
364
+ 'applied to an already-bundled builder.');
365
+ this.name = 'GislBundleAlreadyArchivedError';
366
+ }
367
+ }
352
368
  export class GislTimeoutError extends GislError {
353
369
  constructor(message) {
354
370
  super(message);
@@ -329,23 +329,32 @@ export declare class Recipe {
329
329
  /**
330
330
  * Reduce file size. `optimize` selects a per-media preset (resolved to
331
331
  * concrete wire fields at lower-time, exactly as `client.compress()` does).
332
+ * `options` carries the full per-op options bag (mirrors
333
+ * `client.compress(input, options)`); the explicit `optimize` param wins
334
+ * over any `optimize` key in the bag.
332
335
  */
333
- compress(optimize?: OptimizeFor): Recipe;
334
- /** Change format. `format` is lowered verbatim to the `format` wire option. */
335
- convert(format: string): Recipe;
336
+ compress(optimize?: OptimizeFor, options?: Record<string, unknown>): Recipe;
336
337
  /**
337
- * Generate a preview. Width and/or height in pixels; an omitted dimension is
338
- * dropped from the wire options (not sent as `undefined`).
338
+ * Change format. The `format` shorthand lowers to the `output_format` wire
339
+ * option (the convert op's wire key per the contract); `options` carries any
340
+ * additional per-op convert options.
341
+ */
342
+ convert(format: string, options?: Record<string, unknown>): Recipe;
343
+ /**
344
+ * Generate a preview. Width and/or height in pixels; any additional per-op
345
+ * thumbnail options pass through. An omitted (`undefined`) value is dropped
346
+ * from the wire options (not sent as `undefined`).
339
347
  */
340
348
  thumbnail(options?: {
341
349
  width?: number;
342
350
  height?: number;
343
- }): Recipe;
351
+ } & Record<string, unknown>): Recipe;
344
352
  /**
345
353
  * Apply a text watermark. Single-input (the text is an option, not a
346
- * secondary file) — lowers to the `text_watermark` op with a `text` option.
354
+ * secondary file) — lowers to the `text_watermark` op with a `text` option;
355
+ * `options` carries any additional per-op watermark options.
347
356
  */
348
- textWatermark(text: string): Recipe;
357
+ textWatermark(text: string, options?: Record<string, unknown>): Recipe;
349
358
  /**
350
359
  * Lower this recipe to a workflow-create payload against a resolved upload
351
360
  * id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
@@ -417,6 +426,7 @@ export declare class Recipe {
417
426
  private lowerStep;
418
427
  private lowerCompressOptions;
419
428
  private compressMediaHint;
429
+ private compressAudioLossless;
420
430
  }
421
431
  /**
422
432
  * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
@@ -455,16 +465,16 @@ export declare class FilesRecipe {
455
465
  * preset). Reuses {@link Recipe}'s validation — a directly-constructed
456
466
  * lowering builds an internal Recipe that throws the same `GislConfigError`.
457
467
  */
458
- compress(optimize?: OptimizeFor): FilesRecipe;
468
+ compress(optimize?: OptimizeFor, options?: Record<string, unknown>): FilesRecipe;
459
469
  /** Change every input's format. `format` lowers verbatim to the `format` option. */
460
- convert(format: string): FilesRecipe;
470
+ convert(format: string, options?: Record<string, unknown>): FilesRecipe;
461
471
  /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
462
472
  thumbnail(options?: {
463
473
  width?: number;
464
474
  height?: number;
465
- }): FilesRecipe;
475
+ } & Record<string, unknown>): FilesRecipe;
466
476
  /** Apply the same text watermark to every input. */
467
- textWatermark(text: string): FilesRecipe;
477
+ textWatermark(text: string, options?: Record<string, unknown>): FilesRecipe;
468
478
  /**
469
479
  * Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
470
480
  * single-output {@link MergedRecipe} you chain further ops on
@@ -587,14 +597,14 @@ export declare class MergedRecipe {
587
597
  private readonly client?;
588
598
  constructor(inputs: readonly FileInput[], mergeOptions: MergeOptions, postSteps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
589
599
  /** Reduce the merged output's size. See {@link Recipe.compress}. */
590
- compress(optimize?: OptimizeFor): MergedRecipe;
600
+ compress(optimize?: OptimizeFor, options?: Record<string, unknown>): MergedRecipe;
591
601
  /** Change the merged output's format. See {@link Recipe.convert}. */
592
- convert(format: string): MergedRecipe;
602
+ convert(format: string, options?: Record<string, unknown>): MergedRecipe;
593
603
  /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
594
604
  thumbnail(options?: {
595
605
  width?: number;
596
606
  height?: number;
597
- }): MergedRecipe;
607
+ } & Record<string, unknown>): MergedRecipe;
598
608
  /**
599
609
  * Lower to the merge DAG: one `passthrough` source job per input + one
600
610
  * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
@@ -10,7 +10,7 @@
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
12
  import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
- import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
13
+ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
@@ -421,36 +421,56 @@ export class Recipe {
421
421
  /**
422
422
  * Reduce file size. `optimize` selects a per-media preset (resolved to
423
423
  * concrete wire fields at lower-time, exactly as `client.compress()` does).
424
+ * `options` carries the full per-op options bag (mirrors
425
+ * `client.compress(input, options)`); the explicit `optimize` param wins
426
+ * over any `optimize` key in the bag.
424
427
  */
425
- compress(optimize) {
428
+ compress(optimize, options = {}) {
426
429
  if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
427
430
  const allowed = Object.values(OptimizeFor).join(', ');
428
431
  throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
429
432
  }
430
- return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
433
+ return this.withStep({
434
+ opType: 'compress',
435
+ options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
436
+ });
431
437
  }
432
- /** Change format. `format` is lowered verbatim to the `format` wire option. */
433
- convert(format) {
434
- return this.withStep({ opType: 'convert', options: { format } });
438
+ /**
439
+ * Change format. The `format` shorthand lowers to the `output_format` wire
440
+ * option (the convert op's wire key per the contract); `options` carries any
441
+ * additional per-op convert options.
442
+ */
443
+ convert(format, options = {}) {
444
+ // The convert op's wire key is `output_format` (contract: convert.yaml,
445
+ // required, all media), NOT `format`. Spread options FIRST so the explicit
446
+ // shorthand wins over an `output_format` key in the bag.
447
+ // The shorthand owns the format → a stray legacy `format` key in the bag is
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 } });
435
452
  }
436
453
  /**
437
- * Generate a preview. Width and/or height in pixels; an omitted dimension is
438
- * dropped from the wire options (not sent as `undefined`).
454
+ * Generate a preview. Width and/or height in pixels; any additional per-op
455
+ * thumbnail options pass through. An omitted (`undefined`) value is dropped
456
+ * from the wire options (not sent as `undefined`).
439
457
  */
440
458
  thumbnail(options = {}) {
441
459
  const wire = {};
442
- if (options.width !== undefined)
443
- wire.width = options.width;
444
- if (options.height !== undefined)
445
- wire.height = options.height;
460
+ for (const [key, value] of Object.entries(options)) {
461
+ if (value !== undefined)
462
+ wire[key] = value;
463
+ }
446
464
  return this.withStep({ opType: 'thumbnail', options: wire });
447
465
  }
448
466
  /**
449
467
  * Apply a text watermark. Single-input (the text is an option, not a
450
- * secondary file) — lowers to the `text_watermark` op with a `text` option.
468
+ * secondary file) — lowers to the `text_watermark` op with a `text` option;
469
+ * `options` carries any additional per-op watermark options.
451
470
  */
452
- textWatermark(text) {
453
- return this.withStep({ opType: 'text_watermark', options: { text } });
471
+ textWatermark(text, options = {}) {
472
+ // Spread options FIRST so the explicit `text` argument is authoritative.
473
+ return this.withStep({ opType: 'text_watermark', options: { ...options, text } });
454
474
  }
455
475
  /**
456
476
  * Lower this recipe to a workflow-create payload against a resolved upload
@@ -636,21 +656,55 @@ export class Recipe {
636
656
  ? { type: step.opType }
637
657
  : { type: step.opType, options };
638
658
  }
639
- lowerCompressOptions(options) {
640
- const optimize = options.optimize;
659
+ lowerCompressOptions(stepOptions) {
660
+ // Mirror the op-first resolver precedence (OperationBuilder._resolve in
661
+ // builder.ts): optimize = preset layer, presetOverrides = callPresetOverride
662
+ // layer, the rest = explicit layer.
663
+ const { optimize, presetOverrides, ...explicitOptions } = stepOptions;
664
+ // Validate the special bag keys at this chokepoint (every compress lowers
665
+ // through here). The chain methods' shorthand-param guard only covers a
666
+ // PARAM-supplied optimize; a bag-supplied optimize / presetOverrides must be
667
+ // validated too, so a bad value raises the typed SDK error rather than
668
+ // surfacing as a raw preset-lookup error or TypeError downstream. Mirrors
669
+ // PHP coerceOptimize + OperationBuilder::normalisePresetOverrides.
670
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
671
+ const allowed = Object.values(OptimizeFor).join(', ');
672
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
673
+ }
674
+ if (presetOverrides !== undefined &&
675
+ (presetOverrides === null || typeof presetOverrides !== 'object' || Array.isArray(presetOverrides))) {
676
+ const got = Array.isArray(presetOverrides)
677
+ ? 'array'
678
+ : presetOverrides === null
679
+ ? 'null'
680
+ : typeof presetOverrides;
681
+ throw new GislConfigError(`compress 'presetOverrides' must be a *CompressPresetOptions object; got ${got}.`, { reason: 'invalid_preset_overrides', conflictingFields: ['presetOverrides'] });
682
+ }
641
683
  const media = this.compressMediaHint();
642
684
  if (media === undefined) {
643
685
  // Cannot infer a media class (a Blob without a recognised name, or a
644
686
  // bare upload id) → preset resolution is impossible. Fail FAST rather
645
687
  // than silently dropping an explicit `optimize`; bare compress() is fine.
688
+ // When no optimize is set, pass any explicit options through verbatim
689
+ // (exactly as op-first `_resolve` does when media is undefined).
646
690
  if (optimize !== undefined) {
647
691
  throw new GislConfigError(`compress(optimize: ${String(optimize)}) needs a media type to resolve the preset, but the ` +
648
692
  'input has no inferable media (a pre-uploaded file id or unnamed Blob carries no extension). ' +
649
693
  'Use a path with a file extension, or call compress() without optimize.', { reason: 'media_unknown', conflictingFields: ['optimize'] });
650
694
  }
651
- return {};
695
+ // presetOverrides override a resolved preset; with no media there is no
696
+ // preset to override, so fail fast rather than silently dropping them.
697
+ if (presetOverrides !== undefined) {
698
+ throw new GislConfigError('compress(presetOverrides) needs a media type to resolve the preset to override, but the ' +
699
+ 'input has no inferable media (a pre-uploaded file id or unnamed Blob carries no extension). ' +
700
+ 'Use a path with a file extension.', { reason: 'media_unknown', conflictingFields: ['presetOverrides'] });
701
+ }
702
+ return { ...explicitOptions };
703
+ }
704
+ const input = { media, op: 'compress', explicitOptions };
705
+ if (media === 'audio') {
706
+ input.audioLossless = this.compressAudioLossless();
652
707
  }
653
- const input = { media, op: 'compress', explicitOptions: {} };
654
708
  if (this.presetDefaults !== undefined) {
655
709
  input.presetDefaults = this.presetDefaults;
656
710
  }
@@ -658,6 +712,11 @@ export class Recipe {
658
712
  input.scopedPresetDefaults =
659
713
  this.scopedPresetDefaults;
660
714
  }
715
+ if (presetOverrides !== undefined) {
716
+ // Validated above to be a non-null, non-array object.
717
+ input.presetOverrides =
718
+ presetOverrides;
719
+ }
661
720
  if (optimize !== undefined) {
662
721
  input.optimize = optimize;
663
722
  }
@@ -672,6 +731,13 @@ export class Recipe {
672
731
  }
673
732
  return undefined;
674
733
  }
734
+ compressAudioLossless() {
735
+ if (this.input.kind === 'path')
736
+ return _detectAudioLossless(this.input.path);
737
+ if (this.input.kind === 'blob')
738
+ return _detectAudioLossless(this.input.blob);
739
+ return false;
740
+ }
675
741
  }
676
742
  /**
677
743
  * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
@@ -716,20 +782,20 @@ export class FilesRecipe {
716
782
  * preset). Reuses {@link Recipe}'s validation — a directly-constructed
717
783
  * lowering builds an internal Recipe that throws the same `GislConfigError`.
718
784
  */
719
- compress(optimize) {
720
- return this.withStep(this.baseRecipe().compress(optimize));
785
+ compress(optimize, options = {}) {
786
+ return this.withStep(this.baseRecipe().compress(optimize, options));
721
787
  }
722
788
  /** Change every input's format. `format` lowers verbatim to the `format` option. */
723
- convert(format) {
724
- return this.withStep(this.baseRecipe().convert(format));
789
+ convert(format, options = {}) {
790
+ return this.withStep(this.baseRecipe().convert(format, options));
725
791
  }
726
792
  /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
727
793
  thumbnail(options = {}) {
728
794
  return this.withStep(this.baseRecipe().thumbnail(options));
729
795
  }
730
796
  /** Apply the same text watermark to every input. */
731
- textWatermark(text) {
732
- return this.withStep(this.baseRecipe().textWatermark(text));
797
+ textWatermark(text, options = {}) {
798
+ return this.withStep(this.baseRecipe().textWatermark(text, options));
733
799
  }
734
800
  /**
735
801
  * Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
@@ -981,24 +1047,34 @@ export class MergedRecipe {
981
1047
  this.client = client;
982
1048
  }
983
1049
  /** Reduce the merged output's size. See {@link Recipe.compress}. */
984
- compress(optimize) {
1050
+ compress(optimize, options = {}) {
985
1051
  if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
986
1052
  const allowed = Object.values(OptimizeFor).join(', ');
987
1053
  throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
988
1054
  }
989
- return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
1055
+ return this.withStep({
1056
+ opType: 'compress',
1057
+ options: { ...options, ...(optimize !== undefined ? { optimize } : {}) },
1058
+ });
990
1059
  }
991
1060
  /** Change the merged output's format. See {@link Recipe.convert}. */
992
- convert(format) {
993
- return this.withStep({ opType: 'convert', options: { format } });
1061
+ convert(format, options = {}) {
1062
+ // The convert op's wire key is `output_format` (contract: convert.yaml,
1063
+ // required, all media), NOT `format`. Spread options FIRST so the explicit
1064
+ // shorthand wins over an `output_format` key in the bag.
1065
+ // The shorthand owns the format → a stray legacy `format` key in the bag is
1066
+ // not a valid convert option; drop it so the wire never carries both keys.
1067
+ const rest = { ...options };
1068
+ delete rest.format;
1069
+ return this.withStep({ opType: 'convert', options: { ...rest, output_format: format } });
994
1070
  }
995
1071
  /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
996
1072
  thumbnail(options = {}) {
997
1073
  const wire = {};
998
- if (options.width !== undefined)
999
- wire.width = options.width;
1000
- if (options.height !== undefined)
1001
- wire.height = options.height;
1074
+ for (const [key, value] of Object.entries(options)) {
1075
+ if (value !== undefined)
1076
+ wire[key] = value;
1077
+ }
1002
1078
  return this.withStep({ opType: 'thumbnail', options: wire });
1003
1079
  }
1004
1080
  /**
@@ -3,7 +3,7 @@ export { parseSseStream } from './sse.js';
3
3
  export type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, PreflightClipError, PreflightClipsResult, GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, MultiInputSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
4
4
  export { uploadSource, jobOutputSource, externalImportSource, connectionSource, } from './types.js';
5
5
  export type { GislConfigErrorMetadata } from './errors.js';
6
- export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislNoSuchKeyError, GislSinkError, GislResultNotReadyError, } from './errors.js';
6
+ export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislResultNotReadyError, } from './errors.js';
7
7
  export type { GislApiErrorOptions, GislUploadCapKind } from './errors.js';
8
8
  export { RunResult } from './file-first.js';
9
9
  export type { OutputFile, ItemResult, ItemFailure, Manifest, Downloader, } from './file-first.js';
@@ -23,6 +23,9 @@ GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedE
23
23
  // methods on OperationBuilder ship; type + audit registration land
24
24
  // here so the future chain-method PR is a pure addition).
25
25
  GislChainCardinalityMismatchError,
26
+ // P4d / hv3FpLjm — double-bundle prevention; raised by `.bundle()` (wpHoJhuo).
27
+ // Dormant until `.bundle()` ships, so the type lands here as a pure addition.
28
+ GislBundleAlreadyArchivedError,
26
29
  // FF1 / 3BIxEnfR — file-first result sink errors.
27
30
  GislNoSuchKeyError, GislSinkError,
28
31
  // FF5a / Ao8RPVxD — thrown by the file-first Handle.result() when the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
5
  "license": "MIT",
6
6
  "type": "module",