@giveitsmaller/sdk 0.21.0 → 0.25.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.
@@ -146,15 +146,38 @@ export function resolveOutputRoute(inputToken, outputFormat) {
146
146
  planned: new Set(cell.planned),
147
147
  };
148
148
  }
149
- /** Input token → its `compress.image*` mime-group name (for per-value availability lookup). */
150
- function compressGroupForToken(token) {
151
- if (token === 'jpeg')
152
- return 'image_jpeg';
153
- if (token === 'png')
154
- return 'image_png';
155
- if (token === 'avif')
156
- return 'image_avif';
157
- return 'image'; // webp / gif / svg / tiff
149
+ /**
150
+ * Input token → EVERY `compress.image*` mime-group that can carry a per-value
151
+ * availability marker for it: the format-specific group when the metadata has one,
152
+ * PLUS the generic `image` group. Most specific first.
153
+ *
154
+ * Both are needed, and the old single-group version lost one or the other whichever
155
+ * way it chose (SB1wmTJz):
156
+ * - The generic group carries CROSS-FORMAT markers — `color_profile: 'srgb'` is planned
157
+ * there and nowhere else, so a lookup that resolved only to `image_jpeg` never saw it.
158
+ * - A specific group carries FORMAT-ONLY markers — `image_svg` marks
159
+ * `output_format: 'original'` planned (SVG→SVG optimisation is not built) and the
160
+ * generic group does not, so a lookup that resolved only to `image` never saw THAT.
161
+ *
162
+ * The previous implementation hard-coded `jpeg|png|avif` and fell through to `image`
163
+ * with a trailing `// webp / gif / svg / tiff`. That comment was true when written and
164
+ * silently stopped being true when `image_svg` and `image_webp` were added to the
165
+ * metadata — so SVG inputs missed the one marker that mattered for them, on this gate
166
+ * AND on the `output()` gate that shares it. Deriving the list from the metadata rather
167
+ * than a hand-written token list is what stops it going stale a second time; the
168
+ * mapping is pinned by `output-route-conformance.test.ts`.
169
+ *
170
+ * `gif`/`tiff` correctly yield `['image']` alone — the metadata genuinely has no
171
+ * concrete group for them (verified against its actual key set, not inferred).
172
+ */
173
+ function compressGroupsForToken(token) {
174
+ // The historical mapping, PRESERVED EXACTLY. Every verdict it produced today must
175
+ // keep being produced — see the note on additivity in `isPlannedValue`.
176
+ const legacy = token === 'jpeg' ? 'image_jpeg' : token === 'png' ? 'image_png' : token === 'avif' ? 'image_avif' : 'image';
177
+ const specific = `image_${token}`;
178
+ return specific !== legacy && compressMetadata.mime_groups[specific] !== undefined
179
+ ? [specific, legacy]
180
+ : [legacy];
158
181
  }
159
182
  /**
160
183
  * Whether a specific VALUE of an option is `availability: 'planned'` for the
@@ -163,14 +186,32 @@ function compressGroupForToken(token) {
163
186
  * `compressMetadata` `per_value_availability`; same_format only (the only route
164
187
  * where value-level options like `metadata` are honored). Returns false when the
165
188
  * option / value / group is unknown (no gate).
189
+ *
190
+ * PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
191
+ * The historical group is still consulted, so **every verdict this returned before still
192
+ * holds** — the change can only turn a missed gate into a gate, never a gate into a
193
+ * pass. That direction matters: a new false ACCEPT would send a request the server
194
+ * rejects, which is the failure this function exists to prevent.
195
+ *
196
+ * Why not "most specific wins", which reads cleaner: it would flip `webp` +
197
+ * `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
198
+ * `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
199
+ * deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
200
+ * group), and whether webp srgb actually works on the server is not something this
201
+ * layer can know. Un-gating it on an inference would be exactly the "confident answer
202
+ * from a check that could not tell you otherwise" pattern. Raised as a question instead.
203
+ *
204
+ * What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
205
+ * generic group does not, so an SVG input previously sailed through the one marker that
206
+ * mattered for it — on this gate and on the `output()` gate that shares it.
166
207
  */
167
208
  export function isPlannedValue(inputToken, optionKey, value) {
168
- const group = compressMetadata.mime_groups[compressGroupForToken(inputToken)];
169
- const opt = group?.options[optionKey];
170
- if (opt === undefined)
171
- return false;
172
- const entry = opt.per_value_availability[String(value)];
173
- return entry?.availability === 'planned';
209
+ for (const groupName of compressGroupsForToken(inputToken)) {
210
+ const opt = compressMetadata.mime_groups[groupName]?.options[optionKey];
211
+ if (opt?.per_value_availability[String(value)]?.availability === 'planned')
212
+ return true;
213
+ }
214
+ return false;
174
215
  }
175
216
  /**
176
217
  * Compress-route enum members per image mime-group, mirroring the shipped
@@ -232,3 +273,93 @@ export function isUnknownEnumValue(inputToken, optionKey, value) {
232
273
  return false;
233
274
  return !(typeof value === 'string' && members.includes(value));
234
275
  }
276
+ /**
277
+ * Contract `depends_on` per compress-image output option, mirroring
278
+ * `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
279
+ * (ehHU08Hu). The rule is option-consistent across every image group that carries
280
+ * the option, so this is a FLAT table (validated group-by-group by
281
+ * `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
282
+ *
283
+ * Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
284
+ * so the gate stays browser-safe with no contracts-version coupling, exactly like
285
+ * {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
286
+ * `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
287
+ *
288
+ * Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
289
+ * checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
290
+ * `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
291
+ * all rejected pre-upload instead of only the one hand-coded case.
292
+ */
293
+ export const OUTPUT_OPTION_DEPENDS_ON = {
294
+ quality: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
295
+ lossless: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
296
+ quality_preset: { requiresKey: 'encoding_mode', requiresValue: 'auto_quality' },
297
+ target_size_bytes: { requiresKey: 'encoding_mode', requiresValue: 'target_size' },
298
+ fit: { requiresAnyOf: ['width', 'height'] },
299
+ };
300
+ /**
301
+ * Default of each depended-on key — an ABSENT key resolves to this before the
302
+ * dependency check (the server applies the same default). `encoding_mode`
303
+ * defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
304
+ * but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
305
+ * defaults by the conformance suite.
306
+ */
307
+ export const DEPENDS_ON_KEY_DEFAULTS = {
308
+ encoding_mode: 'quality',
309
+ };
310
+ /**
311
+ * The first contract `depends_on` an already-lowered compress-image wire-option
312
+ * set violates for the resolved `route`, or `undefined` when every dependency is
313
+ * satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
314
+ * `invalid_option_combination` with the returned message + conflictingFields.
315
+ * Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
316
+ * the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
317
+ * absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
318
+ * (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
319
+ * in compress + convert) run on BOTH routes. Mirrored by PHP
320
+ * `ImageOutputRoutes::dependsOnViolation`.
321
+ */
322
+ export function dependsOnViolation(wireOptions, route) {
323
+ for (const [option, rule] of Object.entries(OUTPUT_OPTION_DEPENDS_ON)) {
324
+ // A nullish value is NOT "set" — the contract `set` condition needs a real
325
+ // value, and PHP drops null options before lowering, so treat null == absent
326
+ // for parity (codex: `{ fit: 'max', width: null }` must reject, not bypass).
327
+ if (wireOptions[option] == null)
328
+ continue;
329
+ if ('requiresAnyOf' in rule) {
330
+ if (!rule.requiresAnyOf.some((key) => wireOptions[key] != null)) {
331
+ return {
332
+ conflictingFields: [option, ...rule.requiresAnyOf],
333
+ message: `output(): '${option}' requires at least one of ${rule.requiresAnyOf.join(', ')} to be set ` +
334
+ `(its contract dependency). Set ${rule.requiresAnyOf.join(' or ')}, or drop '${option}'.`,
335
+ };
336
+ }
337
+ continue;
338
+ }
339
+ // Scalar deps in this (compress-image) table are all on `encoding_mode`, a
340
+ // same_format optimiser key — validate them on same_format ONLY. The
341
+ // universal requiresAnyOf dep (fit → width|height) above runs on BOTH routes.
342
+ //
343
+ // A format_change routes via `convert`, which has no encoding_mode and carries
344
+ // its own deps — but those need NO table here (L2Ay7Uak, resolved as a no-op).
345
+ // Every convert image dep is keyed on `output_format`, and the per-target
346
+ // `honored` set the lowering already enforces IS that constraint materialised:
347
+ // `output('gif', { quality: 80 })` is rejected by the honored gate, with a
348
+ // better message, before this function runs. That equivalence is PINNED by
349
+ // `output-route-conformance.test.ts` (+ the PHP mirror), which fails closed if
350
+ // convert ever gains a dep keyed on something other than output_format — which
351
+ // is the case that would genuinely need a gate here.
352
+ if (route !== 'same_format')
353
+ continue;
354
+ const effective = wireOptions[rule.requiresKey] ?? DEPENDS_ON_KEY_DEFAULTS[rule.requiresKey];
355
+ if (effective !== rule.requiresValue) {
356
+ return {
357
+ conflictingFields: [rule.requiresKey, option],
358
+ message: `output(): '${option}' requires ${rule.requiresKey} '${rule.requiresValue}' (its contract ` +
359
+ `dependency), but ${rule.requiresKey} is '${String(effective)}'. Set ${rule.requiresKey}: ` +
360
+ `'${rule.requiresValue}', or drop '${option}'.`,
361
+ };
362
+ }
363
+ }
364
+ return undefined;
365
+ }
@@ -137,6 +137,13 @@ export interface WatermarkOptions {
137
137
  * overlays on one base image (z-order = array index). MUTUALLY EXCLUSIVE with
138
138
  * the flat single-overlay options above; the server rejects mixing the two as
139
139
  * `invalid_options`. image_watermark jpeg/png/webp bases only.
140
+ *
141
+ * NOTE: NOT usable via `watermark()` yet — the facade composites a single
142
+ * overlay (the positional `overlay`, wire source src_1), so `overlays[]` would
143
+ * reference sources it cannot create. `watermark()` rejects it at lowering
144
+ * (`overlays_unsupported`); use the flat single-overlay options above instead.
145
+ * Kept as a valid contract wire key — multi-overlay stacking is a future
146
+ * feature (Vbbdq9C4).
140
147
  */
141
148
  overlays?: WatermarkOverlay[];
142
149
  }
@@ -154,9 +161,11 @@ export type OutputMetadata = 'strip' | 'keep';
154
161
  * Compression mode on the optimiser (same_format) route (contract `encoding_mode`
155
162
  * enum). `quality` (default) drives the encode by the quality slider; `target_size`
156
163
  * targets a byte budget via the worker's encode-measure loop — STABLE since
157
- * contracts v2.108.0 (jpeg/webp/avif).
164
+ * contracts v2.108.0 (jpeg/webp/avif). `auto_quality` lets the worker pick the
165
+ * quality from a named `quality_preset` (its `depends_on`) — the output lowering
166
+ * infers it for you when you set `quality_preset` without an `encoding_mode`.
158
167
  */
159
- export type OutputEncodingMode = 'quality' | 'target_size';
168
+ export type OutputEncodingMode = 'quality' | 'target_size' | 'auto_quality';
160
169
  /** Chroma subsampling for JPEG output (contract `chroma_subsampling` enum, v2.110.0). `420` smallest → `444` highest fidelity. Honored: same_format jpeg only. */
161
170
  export type OutputChromaSubsampling = '420' | '422' | '444';
162
171
  /** ICC colour-profile handling (contract `color_profile` enum, v2.112.0). `keep` preserves the embedded profile; `srgb` converts to sRGB; `strip` removes it. Route/value availability is gated by the output lowering. */
@@ -76,6 +76,28 @@ export interface ResolveCompressOptionsOutput {
76
76
  */
77
77
  export declare function _parseTargetSize(value: unknown): number;
78
78
  export declare const KNOWN_WIRE_FIELDS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
79
+ /**
80
+ * Compress options that are `availability: planned` per mime-group, mirroring the
81
+ * shipped `availability/availability.json`
82
+ * `operations.compress.mime_groups.<group>.options.<opt>.availability`.
83
+ *
84
+ * Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
85
+ * the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
86
+ * — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
87
+ * consumer resolved. A generated-metadata read would FAIL OPEN on an older published
88
+ * contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
89
+ * gate whose entire job is to fail closed.
90
+ *
91
+ * PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
92
+ * which fails closed in BOTH directions — a contract regen that marks a new option
93
+ * `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
94
+ * `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
95
+ *
96
+ * `video.speed` is listed for a faithful projection even though no shipped preset
97
+ * cell emits it; the conformance test pins the whole projection, not just the keys
98
+ * we happen to use today.
99
+ */
100
+ export declare const PLANNED_COMPRESS_OPTIONS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
79
101
  /**
80
102
  * Resolve the wire payload + introspection projection for a compress
81
103
  * operation call. Throws {@link GislConfigError} before any network
@@ -251,6 +251,20 @@ const MEDIA_FIELDS = Object.freeze({
251
251
  document_odf: new Set(['stripMetadata', 'stripUnusedStyles']),
252
252
  document_epub: new Set(['fontSubsetting', 'stripUnusedCss']),
253
253
  });
254
+ const OUT = (key) => ({ verb: 'output', key });
255
+ const CROSS_VERB_OVERRIDES = Object.freeze({
256
+ image: Object.freeze({
257
+ width: OUT('width'), height: OUT('height'), fit: OUT('fit'),
258
+ autoOrient: OUT('auto_orient'), colorProfile: OUT('color_profile'),
259
+ progressive: OUT('progressive'), lossless: OUT('lossless'),
260
+ qualityPreset: OUT('quality_preset'), encodingMode: OUT('encoding_mode'),
261
+ targetSizeBytes: OUT('target_size_bytes'), chromaSubsampling: OUT('chroma_subsampling'),
262
+ optimizationLevel: OUT('optimization_level'), avifSpeed: OUT('avif_speed'),
263
+ }),
264
+ // convert() takes the target format positionally, not as an option.
265
+ audio: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
266
+ video: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
267
+ });
254
268
  function detectMismatchedOverrides(media, overrides) {
255
269
  const expected = MEDIA_FIELDS[media];
256
270
  const keys = Object.keys(overrides);
@@ -264,6 +278,37 @@ function detectMismatchedOverrides(media, overrides) {
264
278
  const unknownFields = keys.filter((k) => !expected.has(k));
265
279
  if (unknownFields.length === 0)
266
280
  return;
281
+ // Options that are real for THIS media but belong to another verb are
282
+ // answered with that verb, before the other-media guess below — otherwise a
283
+ // key both this media and another one recognises (image `width` vs video
284
+ // `width`) gets blamed on the wrong media.
285
+ // Classified PER FIELD, not all-or-nothing: `{ width, codec }` on an image
286
+ // must still tell the caller that `width` is a legal resize on output(),
287
+ // rather than reverting to "you passed video options" for the pair.
288
+ const crossVerb = CROSS_VERB_OVERRIDES[media];
289
+ const crossVerbFields = crossVerb === undefined ? [] : unknownFields.filter((k) => crossVerb[k] !== undefined);
290
+ if (crossVerbFields.length > 0 && crossVerb !== undefined) {
291
+ const strays = unknownFields.filter((k) => crossVerb[k] === undefined);
292
+ const byVerb = new Map();
293
+ for (const k of crossVerbFields) {
294
+ const target = crossVerb[k];
295
+ const list = byVerb.get(target.verb) ?? [];
296
+ list.push(target.key ?? k);
297
+ byVerb.set(target.verb, list);
298
+ }
299
+ const clauses = [...byVerb.entries()].map(([verb, keys]) => verb === 'convert'
300
+ ? `${keys.join(', ')} is the format argument of convert()`
301
+ : `${keys.join(', ')} ${keys.length === 1 ? 'is an option' : 'are options'} on ${verb}()`);
302
+ const outputKeys = byVerb.get('output');
303
+ throw new GislConfigError(`presetOverrides for '${media}' contained ${crossVerbFields.join(', ')}, which ${crossVerbFields.length === 1 ? 'does' : 'do'} not belong on the compress preset surface: ${clauses.join('; ')}.` +
304
+ (strays.length > 0 ? ` Also unrecognised for '${media}': ${strays.join(', ')}.` : ''), {
305
+ reason: 'type_mismatch',
306
+ conflictingFields: unknownFields,
307
+ suggestion: outputKeys !== undefined
308
+ ? `Move ${outputKeys.join(', ')} to output(): .output(format, { ${outputKeys[0]}: … }).`
309
+ : 'Use convert(format) to change the output format.',
310
+ });
311
+ }
267
312
  // Look up which OTHER media owns every unknown field — if a single
268
313
  // OTHER media's field set covers them all, that's a type_mismatch.
269
314
  for (const otherMedia of Object.keys(MEDIA_FIELDS)) {
@@ -319,6 +364,35 @@ export const KNOWN_WIRE_FIELDS = Object.freeze({
319
364
  document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
320
365
  document_epub: new Set(['font_subsetting', 'strip_unused_css']),
321
366
  });
367
+ /**
368
+ * Compress options that are `availability: planned` per mime-group, mirroring the
369
+ * shipped `availability/availability.json`
370
+ * `operations.compress.mime_groups.<group>.options.<opt>.availability`.
371
+ *
372
+ * Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
373
+ * the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
374
+ * — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
375
+ * consumer resolved. A generated-metadata read would FAIL OPEN on an older published
376
+ * contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
377
+ * gate whose entire job is to fail closed.
378
+ *
379
+ * PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
380
+ * which fails closed in BOTH directions — a contract regen that marks a new option
381
+ * `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
382
+ * `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
383
+ *
384
+ * `video.speed` is listed for a faithful projection even though no shipped preset
385
+ * cell emits it; the conformance test pins the whole projection, not just the keys
386
+ * we happen to use today.
387
+ */
388
+ export const PLANNED_COMPRESS_OPTIONS = Object.freeze({
389
+ image: new Set([]),
390
+ audio: new Set([]),
391
+ video: new Set(['speed']),
392
+ document_office: new Set(['strip_hidden_data', 'strip_macros', 'strip_unused_fonts']),
393
+ document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
394
+ document_epub: new Set(['font_subsetting', 'strip_unused_css']),
395
+ });
322
396
  function validateMerged(media, merged, explicitKeys, winners) {
323
397
  // Unknown-field defence-in-depth: every key must belong to the
324
398
  // media's wire surface OR be one of the resolver-derived wire keys
@@ -527,6 +601,26 @@ export function resolveCompressOptions(input) {
527
601
  delete acc.merged.bitrate;
528
602
  acc.winners.delete('bitrate');
529
603
  }
604
+ // A shipped preset must never put an `availability: planned` option on the wire.
605
+ // The API rejects a planned option when the KEY IS PRESENT and ignores it when
606
+ // absent (`CreateWorkflowCommandHandler::recordPlannedFromMap` — a materialized
607
+ // default deliberately does NOT trigger it), so a value WE synthesized becomes a
608
+ // 422 `feature_not_available` at create that the caller never asked for. That is
609
+ // what broke every document compress with an `optimizeFor` in 0.21.0 (5Eksm9s7).
610
+ //
611
+ // Drop ONLY the sdkDefault-sourced ones. A planned option from any other layer is
612
+ // a caller choice — `clientDefault` and `scopedDefault` are user-registered via
613
+ // `gisl.create({ presetDefaults })` / `client.withPresetDefaults(...)`, not baked
614
+ // in by us — and is left in place for the server to refuse honestly. Same rule and
615
+ // same reason as the lossless-bitrate drop above: never silently swallow a key the
616
+ // caller chose. Do NOT "simplify" either of these into an always-drop; a silent
617
+ // no-op on an explicit request is worse than an honest 422.
618
+ for (const key of PLANNED_COMPRESS_OPTIONS[media]) {
619
+ if (acc.winners.get(key) === 'sdkDefault') {
620
+ delete acc.merged[key];
621
+ acc.winners.delete(key);
622
+ }
623
+ }
530
624
  // 7. Validate the merged payload (post-merge — catches cross-layer
531
625
  // disagreements). May throw GislConfigError with resolvedSnapshot.
532
626
  const explicitWireKeys = new Set();
@@ -12,10 +12,22 @@
12
12
  // `mode` + `iccProfile` were REMOVED — the worker is lossy-only and always
13
13
  // strips metadata, so advertising a lossless mode or ICC-profile policy was
14
14
  // an over-claim. `progressive` is still a per-JPEG wire option but is no
15
- // longer carried in the preset cell. `width`/`height`/`fit`/`autoOrient`
16
- // were removed earlier — the image-compress worker never resized (resize-fit
17
- // lives on thumbnail/convert; video keeps its own fit). Per-call knobs are
18
- // deliberately excluded they belong on the per-call argument shape.
15
+ // longer carried in the preset cell.
16
+ //
17
+ // `width`/`height`/`fit`/`autoOrient` were removed earlier on the grounds that
18
+ // "the image-compress worker never resized". CAREFUL that is still true of
19
+ // the Rust optimiser crate and NOT true end-to-end. Since contract v2.97.0
20
+ // ("resize lives inside Output") the API canonicalises an image compress
21
+ // carrying width/height/fit into a `convert` op, and convert IS the resize
22
+ // engine, so such a request returns a genuinely resized file. Their absence
23
+ // here is therefore a SURFACE choice, not a capability limit: resize is
24
+ // expressed via `output()` (see `OutputOptions`), which the compress
25
+ // conformance gate records as CROSS_VERB_ROUTING and self-verifies. Reading
26
+ // this comment as "unsupported" is what produced cySAEZHR. Whether compress()
27
+ // should ALSO carry them is an open ergonomic-expansion decision, not a bug.
28
+ //
29
+ // Per-call knobs are deliberately excluded — they belong on the per-call
30
+ // argument shape.
19
31
  import { shippedDefaultsFor as f3ShippedDefaultsFor } from '../../generated/sdk_spec/presets.js';
20
32
  import { translateEnum } from './_translate.js';
21
33
  export class ImageCompressPresetOptions {
@@ -1,6 +1,26 @@
1
1
  import { VideoCodec, VideoPreset, VideoFit, AudioCodec, AudioBitrate, OptimizeFor } from '../../generated/sdk_spec/enums.js';
2
2
  export interface VideoCompressPresetOptionsInput {
3
3
  readonly codec?: VideoCodec;
4
+ /**
5
+ * Target output size (`'50MB'`-style string — BINARY units, 1 KB = 1024 — or a byte
6
+ * count). Derived by the resolver into `target_size_bytes` + `encoding_mode:
7
+ * 'target_size'`.
8
+ *
9
+ * **NOT AVAILABLE FOR LONG INPUTS.** A compress whose input duration routes to the
10
+ * long-form path rejects it (`reject_long_form_target_size`): that path is
11
+ * single-pass-CRF by construction and two-pass target-size is unbuilt. The request
12
+ * fails during execution, and the SDK cannot warn earlier — routing is decided
13
+ * server-side at create-plan time, so there is nothing here to check it against.
14
+ * The same limit applies to {@link MergeOptions.targetSize}.
15
+ * Short-form compresses honour it normally.
16
+ *
17
+ * The contract CAN now express this — `per_class_availability` scopes an option to
18
+ * a processing class, vendored at v2.195.0 and pinned by
19
+ * `tests/unit/per-class-availability-conformance.test.ts`. That buys an honest 422
20
+ * from the API at CREATE rather than a job dying mid-execution; it does NOT become
21
+ * a client-side gate, because routing is still decided server-side and a duration
22
+ * heuristic here would be wrong at the boundary. Tracked by `zJN6XIi5`.
23
+ */
4
24
  readonly targetSize?: string | number;
5
25
  readonly crf?: number;
6
26
  readonly preset?: VideoPreset;
package/dist/errors.d.ts CHANGED
@@ -159,6 +159,21 @@ export declare class GislBalanceExhaustedError extends GislApiError {
159
159
  export declare class GislLongFormConcurrencyError extends GislApiError {
160
160
  readonly payload: LongFormConcurrencyLimitResponse;
161
161
  constructor(statusCode: number, errorMessage: string, payload: LongFormConcurrencyLimitResponse, path?: string, extra?: Omit<GislApiErrorOptions, 'payload'>);
162
+ /**
163
+ * ALWAYS `false`, overriding the base 429-implies-retryable heuristic
164
+ * (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
165
+ * clears only when an in-flight long-form workflow finishes, so a back-off
166
+ * retries into a wall that no amount of waiting-then-retrying opens. The base
167
+ * accessor reported `true` purely from the status, contradicting this class's
168
+ * own documented handling ("wait on completion or upgrade — do NOT back off")
169
+ * and instructing the one recovery that cannot work.
170
+ *
171
+ * Overridden per-class rather than via a code table because this is the only
172
+ * such code today; the general fix — an explicit taxonomy verdict outranking
173
+ * the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
174
+ * enum (contracts `plwcAqBr`), tracked on UO1xYecu.
175
+ */
176
+ get retryable(): boolean;
162
177
  /** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
163
178
  get upgradeUrl(): string | undefined;
164
179
  }
@@ -374,6 +389,31 @@ export declare class GislConfigError extends GislError {
374
389
  export declare class GislMissingCredentialsError extends GislConfigError {
375
390
  constructor(message: string);
376
391
  }
392
+ /**
393
+ * `streamEvents` was called on a client whose configuration has **no declared
394
+ * SSE stream host**. Local-only — thrown before any I/O.
395
+ *
396
+ * ⚠️ **THIS ERROR IS A CONTROL, NOT A DEFECT.** The stream lives on a second
397
+ * host, and the SDK will not guess it from `baseUrl`. Deriving `stream.*` from
398
+ * `api.*` by string surgery is a *convention*, and a convention is precisely
399
+ * what put production on the gateway path: the frontend's prod build had no
400
+ * stream host configured, fell back to the API host silently, and the failure
401
+ * was invisible until it was measured. Raising here is the loud version of
402
+ * that same situation.
403
+ *
404
+ * Both named environments resolve as of contracts `v2.195.0` (#410), which
405
+ * declared the production stream host. This now fires only for a
406
+ * configuration nothing declares — e.g. a bare `baseUrl` with no
407
+ * `environment` and no `streamBaseUrl`.
408
+ *
409
+ * Recover by passing `{streamBaseUrl}` to `gisl.create()` / `new GislClient()`,
410
+ * setting `GISL_STREAM_BASE_URL`, or constructing with an `{environment}` that
411
+ * declares one. `run()` does NOT surface this error — it treats an undeclared
412
+ * stream host as "SSE unavailable for this configuration" and polls instead.
413
+ */
414
+ export declare class GislStreamHostNotDeclaredError extends GislConfigError {
415
+ constructor(message: string);
416
+ }
377
417
  /**
378
418
  * The caller used `gisl.anonymous()` and then invoked an operation that is
379
419
  * not in the anonymous-capable allowlist. Local-only — thrown before any I/O.
@@ -460,17 +500,153 @@ export declare class GislTimeoutError extends GislError {
460
500
  constructor(message: string, workflowId?: string);
461
501
  }
462
502
  /**
463
- * Transport-level failure: the underlying `fetch` (or other transport) could
464
- * not produce a usable response DNS, TCP, TLS, a mid-stream disconnect, or a
465
- * non-ok status / empty body when fetching a result download. Mirrors the PHP
466
- * `Gisl\Sdk\Errors\GislNetworkError`. Subclasses `GislError` (not
467
- * `GislApiError`) because it carries no contract error envelope. The concrete
468
- * file-first {@link Downloader} raises this when the output URL cannot be read
469
- * (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
503
+ * A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
504
+ * child was still running (the common case) or cleanly between child runs. The
505
+ * parent and some children have ALREADY completed, so re-running the whole batch
506
+ * re-does finished work. This carries their ids so the caller can poll them (via
507
+ * `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
508
+ * work and re-run ONLY the children that were never created.
509
+ *
510
+ * Subclasses {@link GislTimeoutError}, so an existing
511
+ * `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
512
+ * inherited `workflowId` carries the IN-FLIGHT child — the one that was running
513
+ * when the deadline elapsed (a child's own timeout, the common path) — or stays
514
+ * `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
515
+ * child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
516
+ * {@link completedWorkflowIds}, then re-run only the children that never started.
517
+ *
518
+ * NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
519
+ * prevents a byte-identical child re-create from settling a SECOND charge within
520
+ * the dedup window; this error's job is efficient RECOVERY (skip the completed
521
+ * work) + defense-in-depth, not the sole charge guard.
522
+ */
523
+ export declare class GislFanOutTimeoutError extends GislTimeoutError {
524
+ /** The child workflows that completed before the deadline elapsed. */
525
+ readonly completedWorkflowIds: readonly string[];
526
+ /** The parent workflow, which ran to completion before the fan-out began. */
527
+ readonly parentWorkflowId?: string;
528
+ constructor(message: string, opts: {
529
+ completedWorkflowIds: readonly string[];
530
+ parentWorkflowId?: string;
531
+ /**
532
+ * The in-flight child that timed out mid-run (its own deadline elapsed);
533
+ * `undefined` for a clean between-children timeout with no child running.
534
+ */
535
+ workflowId?: string;
536
+ /** The underlying child {@link GislTimeoutError}, preserved for chaining. */
537
+ cause?: unknown;
538
+ });
539
+ }
540
+ /**
541
+ * Base for every failure that happened **off the contract envelope** — the
542
+ * request did not come back as a typed API error, it came back (or failed to)
543
+ * at the transport or raw-HTTP level. Subclasses `GislError` rather than
544
+ * `GislApiError` because there is no error envelope to carry.
545
+ *
546
+ * ⚠️ **NEVER THROWN DIRECTLY — it is a hierarchy node, not an error code
547
+ * (`t2qCrjdr`).** Everything that used to throw it now throws
548
+ * {@link GislTransportError} or {@link GislDownloadHttpError}, because the two
549
+ * cases cannot share one honest answer to "should I retry this?":
550
+ *
551
+ * | case | retry? |
552
+ * |---|---|
553
+ * | DNS / TCP / TLS / mid-stream disconnect | **yes** — transient by nature |
554
+ * | a `404` on a signed download URL | **no** — permanent, retrying burns time |
555
+ *
556
+ * `retryable: true` would recommend retrying a permanent failure and
557
+ * `retryable: false` would discourage retrying a genuine transient one, so
558
+ * contracts correctly refused to declare this class in
559
+ * `sdk-spec/error-taxonomy.yaml` at all. The fix is the split, not a caveat in
560
+ * a description field: **a claim must hold on every path that reaches it.**
561
+ *
562
+ * **Kept as the base ON PURPOSE, so this is not a breaking change.** Every
563
+ * existing `catch (e) { if (e instanceof GislNetworkError) … }` — including the
564
+ * SSE poll-fallback in `builder.ts` / `merge.ts` / `handle.ts` /
565
+ * `file-first.ts` — keeps catching exactly what it caught before. Narrow to a
566
+ * subclass only where you actually need to tell the two apart.
567
+ *
568
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislNetworkError`.
470
569
  */
471
570
  export declare class GislNetworkError extends GislError {
472
571
  constructor(message: string);
473
572
  }
573
+ /**
574
+ * The transport could not deliver a usable response: DNS, TCP, TLS, a
575
+ * mid-stream disconnect, a `fetch` rejection, or a 2xx that arrived with no
576
+ * body at all. **Always retryable** — nothing about these says the request was
577
+ * wrong, only that it did not get through.
578
+ *
579
+ * The empty-body case lives here rather than with
580
+ * {@link GislDownloadHttpError} deliberately: the server said 2xx, so it is not
581
+ * an HTTP-level refusal — a response that promised bytes and delivered none is
582
+ * a delivery failure, and retrying is the right advice.
583
+ *
584
+ * The concrete file-first `Downloader` raises this when an output URL cannot be
585
+ * read (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
586
+ */
587
+ export declare class GislTransportError extends GislNetworkError {
588
+ constructor(message: string);
589
+ /**
590
+ * Always `true`. The request did not get through; nothing about that says it
591
+ * was wrong, so retrying is the correct advice.
592
+ *
593
+ * Present as a real accessor rather than only as prose — an unbacked claim in
594
+ * a docblock is the exact defect `t2qCrjdr` exists to remove, and shipping
595
+ * the split without it would have reproduced it one level down.
596
+ */
597
+ get retryable(): boolean;
598
+ }
599
+ /**
600
+ * The request was never put on the wire because the client refused to send it —
601
+ * a malformed URI or an otherwise unsendable request. **Never retryable:**
602
+ * re-issuing the identical request fails identically, so backing off only
603
+ * wastes the caller's deadline.
604
+ *
605
+ * ⚠️ **THE TWO LANGUAGES DETECT THIS DIFFERENTLY, AND TS DETECTS LESS.** PSR-18
606
+ * distinguishes a network failure (`NetworkExceptionInterface`) from an
607
+ * unsendable request (`RequestExceptionInterface`), so the PHP SDK classifies
608
+ * every such failure. `fetch` surfaces both as an indistinguishable
609
+ * `TypeError`, so the TS SDK can only catch the cases it can see BEFORE the
610
+ * call — today, a URL that does not parse (`http-downloader`). A `fetch`
611
+ * rejection is still reported as {@link GislTransportError}, because guessing
612
+ * would put a permanent failure back in the retryable bucket, which is the very
613
+ * thing this split removed.
614
+ *
615
+ * So: same class, same meaning, same `retryable` in both SDKs — narrower
616
+ * detection in TypeScript. Stated here because a cross-language consumer would
617
+ * otherwise reasonably assume parity of COVERAGE from parity of TYPE.
618
+ */
619
+ export declare class GislRequestNotSentError extends GislNetworkError {
620
+ constructor(message: string);
621
+ /** Always `false` — the request never left, and re-sending it will not change that. */
622
+ get retryable(): boolean;
623
+ }
624
+ /**
625
+ * A download URL answered with a **non-2xx status**. The server was reached and
626
+ * replied; it simply refused. Distinct from {@link GislTransportError} because
627
+ * retrying is usually pointless — and `retryable` says so honestly, derived
628
+ * from the status rather than fixed for the class.
629
+ *
630
+ * `status` is carried as a field so a consumer distinguishing a permanent `404`
631
+ * from a transient `503` does not have to parse the message string — the second
632
+ * half of `t2qCrjdr`.
633
+ *
634
+ * Raised on result-download fetches (signed URLs), NOT on GISL-API calls: an
635
+ * API non-2xx carries a contract error envelope and surfaces as the matching
636
+ * {@link GislApiError} subclass instead.
637
+ */
638
+ export declare class GislDownloadHttpError extends GislNetworkError {
639
+ /** The HTTP status the download URL responded with. */
640
+ readonly status: number;
641
+ constructor(message: string, status: number);
642
+ /**
643
+ * Whether retrying this download could plausibly succeed. Derived from the
644
+ * status by the same rule the API errors use (`408` / `429` / `5xx`), so a
645
+ * `404` reports `false` and a `503` reports `true` — the distinction the
646
+ * unsplit class could not express.
647
+ */
648
+ get retryable(): boolean;
649
+ }
474
650
  /**
475
651
  * Internal control-flow marker (TDqmkWpX): the SSE event stream closed cleanly
476
652
  * WITHOUT a terminal (`workflow_completed`/`failed`/`partially_failed`) event.