@giveitsmaller/sdk 0.4.0 → 0.7.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.
Files changed (50) hide show
  1. package/dist/_audit.js +67 -0
  2. package/dist/builder.d.ts +406 -0
  3. package/dist/builder.js +706 -0
  4. package/dist/client.d.ts +96 -2
  5. package/dist/client.js +968 -33
  6. package/dist/credentials.d.ts +61 -0
  7. package/dist/credentials.js +200 -0
  8. package/dist/ergonomic/preset_resolver.d.ts +75 -0
  9. package/dist/ergonomic/preset_resolver.js +568 -0
  10. package/dist/ergonomic/presets/_translate.d.ts +11 -0
  11. package/dist/ergonomic/presets/_translate.js +35 -0
  12. package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
  13. package/dist/ergonomic/presets/audio_compress.js +45 -0
  14. package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
  15. package/dist/ergonomic/presets/document_epub_compress.js +34 -0
  16. package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
  17. package/dist/ergonomic/presets/document_odf_compress.js +34 -0
  18. package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
  19. package/dist/ergonomic/presets/document_office_compress.js +40 -0
  20. package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
  21. package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
  22. package/dist/ergonomic/presets/image_compress.d.ts +43 -0
  23. package/dist/ergonomic/presets/image_compress.js +95 -0
  24. package/dist/ergonomic/presets/index.d.ts +77 -0
  25. package/dist/ergonomic/presets/index.js +216 -0
  26. package/dist/ergonomic/presets/video_compress.d.ts +30 -0
  27. package/dist/ergonomic/presets/video_compress.js +83 -0
  28. package/dist/errors.d.ts +251 -1
  29. package/dist/errors.js +268 -0
  30. package/dist/generated/sdk_spec/enums.d.ts +195 -0
  31. package/dist/generated/sdk_spec/enums.js +127 -0
  32. package/dist/generated/sdk_spec/errors.d.ts +16 -0
  33. package/dist/generated/sdk_spec/errors.js +473 -0
  34. package/dist/generated/sdk_spec/index.d.ts +4 -0
  35. package/dist/generated/sdk_spec/index.js +7 -0
  36. package/dist/generated/sdk_spec/presets.d.ts +6 -0
  37. package/dist/generated/sdk_spec/presets.js +157 -0
  38. package/dist/generated/sdk_spec/version.d.ts +3 -0
  39. package/dist/generated/sdk_spec/version.js +6 -0
  40. package/dist/gisl.d.ts +112 -0
  41. package/dist/gisl.js +266 -0
  42. package/dist/index.d.ts +17 -7
  43. package/dist/index.js +33 -3
  44. package/dist/merge.d.ts +142 -0
  45. package/dist/merge.js +411 -0
  46. package/dist/sse.d.ts +20 -1
  47. package/dist/sse.js +62 -3
  48. package/dist/types.d.ts +144 -14
  49. package/dist/types.js +18 -0
  50. package/package.json +2 -2
@@ -0,0 +1,568 @@
1
+ // T4b — compress preset resolver (ticket 27rE1fZn).
2
+ //
3
+ // Walks five precedence layers (low → high) for a single compress
4
+ // operation call and emits both:
5
+ // - `wireOptions` — snake_case payload ready to drop into the operation
6
+ // argument the low-level SDK sends on the wire.
7
+ // - `resolvedOptions` — debug projection: which layer contributed each
8
+ // field, plus presetVersion + optional
9
+ // presetConfigHash.
10
+ //
11
+ // Layer order (lowest precedence first; later layers overwrite earlier):
12
+ // 1. `*PresetOptions.shippedDefaultsFor(optimize)` — SDK defaults from
13
+ // F3 PRESETS. Skipped entirely when `optimize` is unset.
14
+ // 2. `client.presetDefaults.cellFor(media, op, optimize)` — caller-side
15
+ // defaults registered via `presetDefaults()...` and passed into
16
+ // `gisl.create({ presetDefaults: ... })`.
17
+ // 3. Scoped layer (reserved for T4c — `withPresetDefaults` derive).
18
+ // Always empty in T4b; the slot exists so T4c is a pure addition.
19
+ // 4. Per-call `presetOverrides` argument.
20
+ // 5. Explicit per-call knobs (existing operation argument fields —
21
+ // `quality: 100`, `codec: 'h264'`, …).
22
+ //
23
+ // Validation runs AFTER merge so post-merge-only invariants
24
+ // (targetSize + codec, Lossless + quality) catch combinations where
25
+ // e.g. the explicit knob and a layered default disagree. Resolver
26
+ // throws `GislConfigError` BEFORE any network round-trip, with a
27
+ // `resolvedSnapshot` showing the would-have-been-sent payload.
28
+ //
29
+ // `targetSize` on video accepts:
30
+ // - integer bytes (>=0) — passed through raw
31
+ // - case-insensitive string with B / KB / MB / GB / TB suffix —
32
+ // BINARY multipliers (1 KB = 1024, 1 MB = 2^20, …). User-pinned
33
+ // 2026-05-28 (see memory targetsize-unit-decision); matches the
34
+ // plan's canonical example and the contract minimum.
35
+ // On a parse, the resolver also writes `encoding_mode='target_size'`
36
+ // to the wire; conversely if `crf` is explicit, `encoding_mode='crf'`.
37
+ import { createHash } from 'node:crypto';
38
+ import { GislConfigError } from '../errors.js';
39
+ import { ImageCompressPresetOptions, AudioCompressPresetOptions, VideoCompressPresetOptions, DocumentPdfCompressPresetOptions, DocumentOfficeCompressPresetOptions, DocumentOdfCompressPresetOptions, DocumentEpubCompressPresetOptions, } from './presets/index.js';
40
+ /** Bumped on any change to a `*PresetOptions.shippedDefaultsFor(...)` cell value. */
41
+ export const PRESET_VERSION = '1.0';
42
+ // ---------------------------------------------------------------------------
43
+ // Wire-field alias map (declarative — NOT generic toSnakeCase).
44
+ // ---------------------------------------------------------------------------
45
+ //
46
+ // Lifted from docs/plans/sdk-ergonomics/plan.md §11a. Maps camelCase
47
+ // ergonomic-DTO field names to their snake_case wire counterparts.
48
+ // Fields whose ergonomic name IS the wire name (`mode`, `quality`,
49
+ // `codec`, …) are NOT in this map — `applyAlias` returns them
50
+ // unchanged. A generic snake-case regex would mistranslate names like
51
+ // `iccProfile` to `i_c_c_profile`; the declarative map is the only
52
+ // safe path.
53
+ const WIRE_ALIASES = Object.freeze({
54
+ iccProfile: 'icc_profile',
55
+ autoOrient: 'auto_orient',
56
+ outputFormat: 'output_format',
57
+ sampleRate: 'sample_rate',
58
+ audioCodec: 'audio_codec',
59
+ audioBitrate: 'audio_bitrate',
60
+ flattenForms: 'flatten_forms',
61
+ imageQuality: 'image_quality',
62
+ stripMacros: 'strip_macros',
63
+ stripHiddenData: 'strip_hidden_data',
64
+ stripUnusedFonts: 'strip_unused_fonts',
65
+ stripMetadata: 'strip_metadata',
66
+ stripUnusedStyles: 'strip_unused_styles',
67
+ fontSubsetting: 'font_subsetting',
68
+ stripUnusedCss: 'strip_unused_css',
69
+ // `targetSize` is a resolver-only ergonomic field — translated below
70
+ // into `target_size_bytes` + `encoding_mode`. Don't emit it under
71
+ // its own snake form.
72
+ });
73
+ function applyAlias(camelKey) {
74
+ return WIRE_ALIASES[camelKey] ?? camelKey;
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // targetSize parser
78
+ // ---------------------------------------------------------------------------
79
+ const TARGET_SIZE_MULTIPLIERS = Object.freeze({
80
+ B: 1,
81
+ KB: 1024,
82
+ MB: 1024 ** 2,
83
+ GB: 1024 ** 3,
84
+ TB: 1024 ** 4,
85
+ });
86
+ /**
87
+ * Parse a `targetSize` value into a positive integer byte count.
88
+ *
89
+ * - Integer input passes through after non-negative + finite checks.
90
+ * - String input must match `<number><unit?>` with unit in
91
+ * `B|KB|MB|GB|TB` (case-insensitive). Multipliers are BINARY
92
+ * (1 KB = 1024). Decimal fractions allowed in the magnitude
93
+ * (`'1.5GB'` → `1.5 * 2^30` rounded down to integer bytes).
94
+ *
95
+ * Throws {@link GislConfigError} with `reason: 'invalid_target_size'`
96
+ * on any other input — including negative numbers, infinities, missing
97
+ * magnitude, unknown unit, or zero magnitude.
98
+ *
99
+ * @internal — exported for unit tests.
100
+ */
101
+ export function _parseTargetSize(value) {
102
+ if (typeof value === 'number') {
103
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
104
+ throw new GislConfigError(`targetSize integer must be a positive whole byte count; got ${String(value)}.`, {
105
+ reason: 'invalid_target_size',
106
+ conflictingFields: ['targetSize'],
107
+ suggestion: 'Pass a positive integer or a suffixed string like "50MB".',
108
+ });
109
+ }
110
+ return value;
111
+ }
112
+ if (typeof value !== 'string') {
113
+ throw new GislConfigError(`targetSize must be a positive integer (bytes) or a string like "50MB"; got ${typeof value}.`, {
114
+ reason: 'invalid_target_size',
115
+ conflictingFields: ['targetSize'],
116
+ suggestion: 'Pass a positive integer or a suffixed string like "50MB".',
117
+ });
118
+ }
119
+ const trimmed = value.trim();
120
+ // Case-insensitive: `^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$` (unit
121
+ // optional → defaults to B).
122
+ const match = /^(\d+(?:\.\d+)?)\s*([A-Za-z]+)?$/.exec(trimmed);
123
+ if (match === null) {
124
+ throw new GislConfigError(`targetSize string '${value}' is not a valid size — expected '<number><B|KB|MB|GB|TB>'.`, {
125
+ reason: 'invalid_target_size',
126
+ conflictingFields: ['targetSize'],
127
+ suggestion: "Use '50MB', '1.5GB', or a raw integer byte count.",
128
+ });
129
+ }
130
+ const magnitudeStr = match[1];
131
+ const unitStr = match[2] === undefined ? 'B' : match[2].toUpperCase();
132
+ if (!Object.hasOwn(TARGET_SIZE_MULTIPLIERS, unitStr)) {
133
+ throw new GislConfigError(`targetSize unit '${match[2] ?? ''}' is not recognised — expected B / KB / MB / GB / TB.`, {
134
+ reason: 'invalid_target_size',
135
+ conflictingFields: ['targetSize'],
136
+ suggestion: 'Use one of B / KB / MB / GB / TB (binary; 1 KB = 1024).',
137
+ });
138
+ }
139
+ const magnitude = Number.parseFloat(magnitudeStr);
140
+ if (!Number.isFinite(magnitude) || magnitude <= 0) {
141
+ throw new GislConfigError(`targetSize magnitude '${magnitudeStr}' must be a positive number.`, {
142
+ reason: 'invalid_target_size',
143
+ conflictingFields: ['targetSize'],
144
+ });
145
+ }
146
+ const bytes = Math.floor(magnitude * TARGET_SIZE_MULTIPLIERS[unitStr]);
147
+ if (bytes <= 0) {
148
+ throw new GislConfigError(`targetSize '${value}' resolves to zero bytes after binary multiplication.`, {
149
+ reason: 'invalid_target_size',
150
+ conflictingFields: ['targetSize'],
151
+ });
152
+ }
153
+ return bytes;
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Per-media DTO accessors
157
+ // ---------------------------------------------------------------------------
158
+ //
159
+ // Three things change per (media, op) tuple: which `*PresetOptions`
160
+ // leaf supplies sdkDefaults, the typed shape of the `presetOverrides`
161
+ // argument, and the cellFor() return type. T4b only ships `compress`;
162
+ // future ops will append rows here.
163
+ function sdkDefaultRecord(media, op, optimize) {
164
+ if (op !== 'compress') {
165
+ throw new GislConfigError(`Preset resolution is only wired for compress operations today; got op='${op}'.`, { reason: 'unsupported_op' });
166
+ }
167
+ switch (media) {
168
+ case 'image':
169
+ return { ...ImageCompressPresetOptions.shippedDefaultsFor(optimize) };
170
+ case 'audio':
171
+ return { ...AudioCompressPresetOptions.shippedDefaultsFor(optimize) };
172
+ case 'video':
173
+ return { ...VideoCompressPresetOptions.shippedDefaultsFor(optimize) };
174
+ case 'document_pdf':
175
+ return { ...DocumentPdfCompressPresetOptions.shippedDefaultsFor(optimize) };
176
+ case 'document_office':
177
+ return { ...DocumentOfficeCompressPresetOptions.shippedDefaultsFor(optimize) };
178
+ case 'document_odf':
179
+ return { ...DocumentOdfCompressPresetOptions.shippedDefaultsFor(optimize) };
180
+ case 'document_epub':
181
+ return { ...DocumentEpubCompressPresetOptions.shippedDefaultsFor(optimize) };
182
+ }
183
+ }
184
+ /**
185
+ * Look up the `(media, op, optimize)` cell in a `PresetDefaults` and
186
+ * return a plain Record. Shared between layer 2 (clientDefault) and
187
+ * layer 3 (scopedDefault — T4c) — both layers ask the same question
188
+ * against different `PresetDefaults` references. Returns `undefined`
189
+ * when the defaults aren't supplied or the cell wasn't registered.
190
+ */
191
+ function presetDefaultsCellRecord(defaults, media, op, optimize) {
192
+ if (defaults === undefined)
193
+ return undefined;
194
+ if (op !== 'compress')
195
+ return undefined;
196
+ // Each overload returns `<Specific>PresetOptions | undefined`. We
197
+ // erase the per-media type at runtime by spreading the instance
198
+ // into a plain Record. `cellFor` returns `undefined` when no delta
199
+ // was registered for the tuple — the resolver treats that the same
200
+ // as "this layer did not participate."
201
+ let cell;
202
+ switch (media) {
203
+ case 'image':
204
+ cell = defaults.cellFor('image', 'compress', optimize);
205
+ break;
206
+ case 'audio':
207
+ cell = defaults.cellFor('audio', 'compress', optimize);
208
+ break;
209
+ case 'video':
210
+ cell = defaults.cellFor('video', 'compress', optimize);
211
+ break;
212
+ case 'document_pdf':
213
+ cell = defaults.cellFor('document_pdf', 'compress', optimize);
214
+ break;
215
+ case 'document_office':
216
+ cell = defaults.cellFor('document_office', 'compress', optimize);
217
+ break;
218
+ case 'document_odf':
219
+ cell = defaults.cellFor('document_odf', 'compress', optimize);
220
+ break;
221
+ case 'document_epub':
222
+ cell = defaults.cellFor('document_epub', 'compress', optimize);
223
+ break;
224
+ }
225
+ if (cell === undefined)
226
+ return undefined;
227
+ return { ...cell };
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // presetOverrides type-mismatch detection
231
+ // ---------------------------------------------------------------------------
232
+ //
233
+ // The caller passes `presetOverrides: ImageCompressPresetOptionsInput`
234
+ // (a plain object), `VideoCompressPresetOptionsInput`, etc. We check
235
+ // for class instances (where the call site used `Image.from(...)`) AND
236
+ // for clearly-typed plain objects whose key set only intersects with a
237
+ // non-matching media.
238
+ const MEDIA_FIELDS = Object.freeze({
239
+ image: new Set(['mode', 'quality', 'width', 'height', 'fit', 'metadata', 'iccProfile', 'autoOrient', 'progressive', 'outputFormat']),
240
+ audio: new Set(['bitrate', 'channels', 'sampleRate', 'normalize']),
241
+ video: new Set(['codec', 'targetSize', 'crf', 'preset', 'width', 'height', 'fit', 'fps', 'faststart', 'audioCodec', 'audioBitrate']),
242
+ document_pdf: new Set(['profile', 'colorspace', 'flattenForms']),
243
+ document_office: new Set(['imageQuality', 'stripMacros', 'stripHiddenData', 'stripUnusedFonts']),
244
+ document_odf: new Set(['imageQuality', 'stripMetadata', 'stripUnusedStyles']),
245
+ document_epub: new Set(['imageQuality', 'fontSubsetting', 'stripUnusedCss']),
246
+ });
247
+ function detectMismatchedOverrides(media, overrides) {
248
+ const expected = MEDIA_FIELDS[media];
249
+ const keys = Object.keys(overrides);
250
+ // Empty override is fine — equivalent to "register no per-call delta."
251
+ if (keys.length === 0)
252
+ return;
253
+ // If EVERY key in overrides is recognised for the operation's media,
254
+ // we accept it. If ALL keys belong to a different media, we throw a
255
+ // type_mismatch. If keys mix recognised + unrecognised, the
256
+ // unknown_field validation downstream will catch the strays.
257
+ const unknownFields = keys.filter((k) => !expected.has(k));
258
+ if (unknownFields.length === 0)
259
+ return;
260
+ // Look up which OTHER media owns every unknown field — if a single
261
+ // OTHER media's field set covers them all, that's a type_mismatch.
262
+ for (const otherMedia of Object.keys(MEDIA_FIELDS)) {
263
+ if (otherMedia === media)
264
+ continue;
265
+ const otherSet = MEDIA_FIELDS[otherMedia];
266
+ if (unknownFields.every((k) => otherSet.has(k))) {
267
+ // PascalCase every underscore-separated segment so multi-segment
268
+ // media (`document_pdf` → `DocumentPdf…`) emit the actual exported
269
+ // class name (code-review MEDIUM: previously emitted
270
+ // `Documentpdf…` which doesn't resolve in user code).
271
+ const className = otherMedia
272
+ .split('_')
273
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
274
+ .join('') + 'CompressPresetOptionsInput';
275
+ throw new GislConfigError(`presetOverrides for operation media '${media}' contained fields from '${otherMedia}': ${unknownFields.join(', ')}.`, {
276
+ reason: 'type_mismatch',
277
+ conflictingFields: unknownFields,
278
+ suggestion: `Pass an ${className} shape, or use the matching builder method.`,
279
+ });
280
+ }
281
+ }
282
+ // Otherwise the unknown fields are nonsense — fall through to the
283
+ // unknown_field validation that runs against the merged record.
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // Deep-merge with provenance tracking
287
+ // ---------------------------------------------------------------------------
288
+ const SOURCE_KEYS = ['sdkDefault', 'clientDefault', 'scopedDefault', 'callPresetOverride', 'explicit'];
289
+ function mergeLayer(acc, layer, source) {
290
+ if (layer === undefined)
291
+ return;
292
+ for (const camelKey of Object.keys(layer)) {
293
+ const value = layer[camelKey];
294
+ if (value === undefined)
295
+ continue;
296
+ const wireKey = applyAlias(camelKey);
297
+ acc.merged[wireKey] = value;
298
+ acc.winners.set(wireKey, source);
299
+ }
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // Validations on the merged wire payload
303
+ // ---------------------------------------------------------------------------
304
+ const KNOWN_WIRE_FIELDS = Object.freeze({
305
+ image: new Set(['mode', 'quality', 'width', 'height', 'fit', 'metadata', 'icc_profile', 'auto_orient', 'progressive', 'output_format']),
306
+ audio: new Set(['bitrate', 'channels', 'sample_rate', 'normalize', 'trim_start', 'trim_end']),
307
+ video: new Set(['codec', 'encoding_mode', 'crf', 'target_size_bytes', 'preset', 'width', 'height', 'fit', 'fps', 'faststart', 'audio_codec', 'audio_bitrate', 'trim_start', 'trim_end']),
308
+ document_pdf: new Set(['profile', 'colorspace', 'pages', 'flatten_forms']),
309
+ document_office: new Set(['image_quality', 'strip_macros', 'strip_hidden_data', 'strip_unused_fonts']),
310
+ document_odf: new Set(['image_quality', 'strip_metadata', 'strip_unused_styles']),
311
+ document_epub: new Set(['image_quality', 'font_subsetting', 'strip_unused_css']),
312
+ });
313
+ function validateMerged(media, merged, explicitKeys, winners) {
314
+ // Unknown-field defence-in-depth: every key must belong to the
315
+ // media's wire surface OR be one of the resolver-derived wire keys
316
+ // (target_size_bytes / encoding_mode for video). `targetSize` itself
317
+ // never reaches `merged` — it is consumed by the resolver before
318
+ // emission.
319
+ const known = KNOWN_WIRE_FIELDS[media];
320
+ for (const key of Object.keys(merged)) {
321
+ if (!known.has(key)) {
322
+ const snapshot = { ...merged };
323
+ throw new GislConfigError(`Resolved wire payload contains unknown field '${key}' for media '${media}'.`, {
324
+ reason: 'unknown_field',
325
+ conflictingFields: [key],
326
+ resolvedSnapshot: Object.freeze(snapshot),
327
+ });
328
+ }
329
+ }
330
+ // Image: `mode: Lossless` + `quality` set is invalid per the wire
331
+ // contract (`depends_on: { mode: lossy }`). Runs on post-merge so a
332
+ // caller passing explicit `quality` and inheriting `mode=Lossless`
333
+ // from a client preset is caught.
334
+ if (media === 'image' && merged.mode === 'lossless' && merged.quality !== undefined) {
335
+ const snapshot = { ...merged };
336
+ throw new GislConfigError(`Image compress: 'quality' is ignored when 'mode' is Lossless — passing both is a configuration bug.`, {
337
+ reason: 'missing_dependency',
338
+ conflictingFields: ['quality', 'mode'],
339
+ resolvedSnapshot: Object.freeze(snapshot),
340
+ suggestion: "Either drop 'quality' for lossless output, or set 'mode' to Lossy.",
341
+ });
342
+ }
343
+ // Video: targetSize-derived encoding_mode='target_size' is only
344
+ // valid for H264 today. Catch the combination post-merge — explicit
345
+ // codec overrides a layered default and either resolution must end
346
+ // up at h264.
347
+ if (media === 'video' && merged.encoding_mode === 'target_size') {
348
+ if (merged.codec !== undefined && merged.codec !== 'h264') {
349
+ const snapshot = { ...merged };
350
+ throw new GislConfigError(`Video compress: 'targetSize' only supports codec 'h264' today; resolved codec is '${String(merged.codec)}'.`, {
351
+ reason: 'invalid_combination',
352
+ conflictingFields: ['targetSize', 'codec'],
353
+ resolvedSnapshot: Object.freeze(snapshot),
354
+ suggestion: "Either use codec H264, or drop targetSize and use crf instead.",
355
+ });
356
+ }
357
+ // Mutual exclusion with explicit crf — the EXPLICIT layer is the
358
+ // one that conflicts. If crf came in only from a lower layer it
359
+ // would have been overwritten by encoding_mode='target_size' (and
360
+ // we strip it below).
361
+ if (explicitKeys.has('crf')) {
362
+ const snapshot = { ...merged };
363
+ throw new GislConfigError(`Video compress: 'targetSize' and 'crf' are mutually exclusive encoding modes.`, {
364
+ reason: 'invalid_combination',
365
+ conflictingFields: ['targetSize', 'crf'],
366
+ resolvedSnapshot: Object.freeze(snapshot),
367
+ suggestion: 'Choose one — drop targetSize to use crf, or drop crf to use targetSize.',
368
+ });
369
+ }
370
+ // crf coming from a lower layer is silently dropped since
371
+ // encoding_mode='target_size' supersedes it. Caller signal is now
372
+ // the audit trail — keep `applied` and `sources.*` consistent
373
+ // (code-review HIGH: orphaned winners entry would surface a
374
+ // phantom crf contribution in the source buckets when the field
375
+ // was actually stripped from the wire — caught by the
376
+ // R1-regression test on PR #123's first real CI run).
377
+ if ('crf' in merged) {
378
+ delete merged.crf;
379
+ winners.delete('crf');
380
+ }
381
+ }
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // presetConfigHash
385
+ // ---------------------------------------------------------------------------
386
+ function canonicalJson(value) {
387
+ // Recursive canonicalisation — sorts keys at every level (code-review
388
+ // MEDIUM: top-level-only sort would let two presetOverrides records
389
+ // with the same logical content but different insertion order
390
+ // produce different sha256 hashes, defeating the deterministic
391
+ // contract). Primitives + arrays delegate to JSON.stringify directly
392
+ // (arrays preserve order — order IS semantic — but our hash inputs
393
+ // currently never contain arrays).
394
+ if (value === null || typeof value !== 'object') {
395
+ return JSON.stringify(value);
396
+ }
397
+ if (Array.isArray(value)) {
398
+ return '[' + value.map(canonicalJson).join(',') + ']';
399
+ }
400
+ const record = value;
401
+ const keys = Object.keys(record).sort();
402
+ return ('{' +
403
+ keys
404
+ .map((k) => JSON.stringify(k) + ':' + canonicalJson(record[k]))
405
+ .join(',') +
406
+ '}');
407
+ }
408
+ function computePresetConfigHash(clientDefault, scopedDefault, callPresetOverride) {
409
+ // Per architect's adjustment 4: hash is present iff a cell was
410
+ // REGISTERED (i.e. one of these three records is defined), not iff
411
+ // any field was set. An empty-delta cell still contributes presence.
412
+ const anyParticipated = clientDefault !== undefined || scopedDefault !== undefined || callPresetOverride !== undefined;
413
+ if (!anyParticipated)
414
+ return undefined;
415
+ const canonical = canonicalJson({
416
+ clientDefault: clientDefault ?? null,
417
+ scopedDefault: scopedDefault ?? null,
418
+ callPresetOverride: callPresetOverride ?? null,
419
+ });
420
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
421
+ }
422
+ // ---------------------------------------------------------------------------
423
+ // Main entry point
424
+ // ---------------------------------------------------------------------------
425
+ /**
426
+ * Resolve the wire payload + introspection projection for a compress
427
+ * operation call. Throws {@link GislConfigError} before any network
428
+ * round-trip when the merged options violate a documented constraint.
429
+ *
430
+ * Layers are applied in fixed order:
431
+ * SDK shipped → client default → scoped (T4c) → callPresetOverride → explicit.
432
+ *
433
+ * `optimize` unset ⇒ layer 1 contributes nothing; `resolvedOptions.preset = null`.
434
+ */
435
+ export function resolveCompressOptions(input) {
436
+ const { media, op, presetDefaults, scopedPresetDefaults, presetOverrides, optimize, explicitOptions } = input;
437
+ if (op !== 'compress') {
438
+ throw new GislConfigError(`Preset resolution is only wired for compress operations today; got op='${op}'.`, { reason: 'unsupported_op' });
439
+ }
440
+ // 0. Type-mismatch detection runs BEFORE the merge so the error
441
+ // points at the typed argument the caller passed, not at the
442
+ // resolved wire shape.
443
+ if (presetOverrides !== undefined) {
444
+ detectMismatchedOverrides(media, presetOverrides);
445
+ }
446
+ // 1-5. Walk layers and accumulate.
447
+ const acc = { merged: {}, winners: new Map() };
448
+ const sdkDefault = optimize === undefined ? undefined : sdkDefaultRecord(media, op, optimize);
449
+ const clientDefault = presetDefaultsCellRecord(presetDefaults, media, op, optimize ?? 'Balanced');
450
+ // Scoped layer (T4c — ULAlOP6j): reads from the derived client's
451
+ // `_scopedPresetDefaults` closure. `undefined` for non-derived
452
+ // clients; otherwise the merged stack from `withPresetDefaults`.
453
+ const scopedDefault = presetDefaultsCellRecord(scopedPresetDefaults, media, op, optimize ?? 'Balanced');
454
+ // optimize-unset skips clientDefault AND scopedDefault — the caller
455
+ // chose to opt out of layered preset resolution entirely. Without an
456
+ // optimize level there's no cell to look up at either layer
457
+ // (architect adjustment 2 — symmetry with clientDefault).
458
+ const effectiveClientDefault = optimize === undefined ? undefined : clientDefault;
459
+ const effectiveScopedDefault = optimize === undefined ? undefined : scopedDefault;
460
+ const effectivePresetOverrides = presetOverrides;
461
+ mergeLayer(acc, sdkDefault, 'sdkDefault');
462
+ mergeLayer(acc, effectiveClientDefault, 'clientDefault');
463
+ mergeLayer(acc, effectiveScopedDefault, 'scopedDefault');
464
+ mergeLayer(acc, effectivePresetOverrides, 'callPresetOverride');
465
+ mergeLayer(acc, explicitOptions, 'explicit');
466
+ // 6. Resolve `targetSize` (video-only ergonomic field) to wire
467
+ // `target_size_bytes` + set `encoding_mode='target_size'`. The
468
+ // resolver does this on the merged record so a lower layer's
469
+ // targetSize can be overridden by a higher layer setting it to
470
+ // something else (or by an explicit `crf` flipping the encoding
471
+ // mode).
472
+ if (media === 'video') {
473
+ // After alias application above, `targetSize` is still the camelCase key
474
+ // because it has no entry in WIRE_ALIASES (it gets DERIVED, not aliased).
475
+ if ('targetSize' in acc.merged) {
476
+ const rawTargetSize = acc.merged.targetSize;
477
+ delete acc.merged.targetSize;
478
+ acc.winners.delete('targetSize');
479
+ const bytes = _parseTargetSize(rawTargetSize);
480
+ // The source that "wins" target_size_bytes / encoding_mode is
481
+ // whichever layer last set the camelCase `targetSize`. We
482
+ // recover that from the original layer records since acc.winners
483
+ // already lost it on the delete.
484
+ // Walk the precedence chain HIGH → LOW (architect adjustment 1
485
+ // for T4c: scopedDefault inserted between callPresetOverride and
486
+ // clientDefault). Without the scoped arm, a scoped-set targetSize
487
+ // would mis-attribute to sdkDefault and presetConfigHash /
488
+ // sources.scopedDefault would be wrong.
489
+ const targetSizeSource = (() => {
490
+ if (explicitOptions['targetSize'] !== undefined)
491
+ return 'explicit';
492
+ if (effectivePresetOverrides?.['targetSize'] !== undefined)
493
+ return 'callPresetOverride';
494
+ if (effectiveScopedDefault?.['targetSize'] !== undefined)
495
+ return 'scopedDefault';
496
+ if (effectiveClientDefault?.['targetSize'] !== undefined)
497
+ return 'clientDefault';
498
+ return 'sdkDefault';
499
+ })();
500
+ acc.merged.target_size_bytes = bytes;
501
+ acc.merged.encoding_mode = 'target_size';
502
+ acc.winners.set('target_size_bytes', targetSizeSource);
503
+ acc.winners.set('encoding_mode', targetSizeSource);
504
+ }
505
+ else if (acc.merged.crf !== undefined && acc.merged.encoding_mode === undefined) {
506
+ // Explicit crf with no targetSize: emit encoding_mode='crf' so
507
+ // the wire is unambiguous. Source attribution follows whoever
508
+ // owns `crf` (typically explicit, but a layered default also fine).
509
+ const crfSource = acc.winners.get('crf') ?? 'explicit';
510
+ acc.merged.encoding_mode = 'crf';
511
+ acc.winners.set('encoding_mode', crfSource);
512
+ }
513
+ }
514
+ // 7. Validate the merged payload (post-merge — catches cross-layer
515
+ // disagreements). May throw GislConfigError with resolvedSnapshot.
516
+ const explicitWireKeys = new Set();
517
+ for (const camelKey of Object.keys(explicitOptions)) {
518
+ explicitWireKeys.add(applyAlias(camelKey));
519
+ }
520
+ if (explicitOptions['targetSize'] !== undefined)
521
+ explicitWireKeys.add('targetSize');
522
+ if (explicitOptions['crf'] !== undefined)
523
+ explicitWireKeys.add('crf');
524
+ validateMerged(media, acc.merged, explicitWireKeys, acc.winners);
525
+ // 8. Build the source buckets from the winners map.
526
+ const sources = (() => {
527
+ const buckets = {
528
+ sdkDefault: [],
529
+ clientDefault: [],
530
+ scopedDefault: [],
531
+ callPresetOverride: [],
532
+ explicit: [],
533
+ };
534
+ for (const [wireKey, source] of acc.winners)
535
+ buckets[source].push(wireKey);
536
+ for (const key of SOURCE_KEYS)
537
+ buckets[key].sort();
538
+ return {
539
+ sdkDefault: buckets.sdkDefault,
540
+ clientDefault: buckets.clientDefault,
541
+ scopedDefault: buckets.scopedDefault,
542
+ callPresetOverride: buckets.callPresetOverride,
543
+ explicit: buckets.explicit,
544
+ };
545
+ })();
546
+ // 9. presetConfigHash — present iff any non-SDK layer's cell was
547
+ // registered (architect's adjustment 4).
548
+ const presetConfigHash = computePresetConfigHash(effectiveClientDefault, effectiveScopedDefault, effectivePresetOverrides);
549
+ // 10. Build the ResolvedOptions surface. `overrides` retained for
550
+ // back-compat (mirror of sources.explicit per architect's adjustment 1).
551
+ const resolvedOptions = {
552
+ preset: optimize ?? null,
553
+ applied: { ...acc.merged },
554
+ overrides: sources.explicit,
555
+ presetVersion: PRESET_VERSION,
556
+ sources,
557
+ ...(presetConfigHash !== undefined ? { presetConfigHash } : {}),
558
+ };
559
+ // 11. Final wire payload — drop any field whose merged value is
560
+ // undefined (mergeLayer already skips them, but defence in depth).
561
+ const wireOptions = {};
562
+ for (const k of Object.keys(acc.merged)) {
563
+ const v = acc.merged[k];
564
+ if (v !== undefined)
565
+ wireOptions[k] = v;
566
+ }
567
+ return { wireOptions, resolvedOptions };
568
+ }
@@ -0,0 +1,11 @@
1
+ import { ERGONOMIC_ENUMS } from '../../generated/sdk_spec/enums.js';
2
+ type EnumName = keyof typeof ERGONOMIC_ENUMS;
3
+ /**
4
+ * Translate a PRESETS member-name string to its wire backing value via
5
+ * the named ergonomic enum. Throws if `memberName` is not a key of the
6
+ * enum const — protects against typos / drift in the generator output.
7
+ *
8
+ * @internal
9
+ */
10
+ export declare function translateEnum(enumName: EnumName, memberName: string): string | number;
11
+ export {};
@@ -0,0 +1,35 @@
1
+ // Internal: translate F3 PRESETS member-name strings to wire backing values
2
+ // via the ergonomic enum const maps emitted at
3
+ // `../../generated/sdk_spec/enums.ts`.
4
+ //
5
+ // **Why this exists:** the F3 generator emits preset cell values as
6
+ // ergonomic-enum MEMBER NAMES (e.g. `"Lossy"`, `"Smallest"`, `"_96"`),
7
+ // NOT wire backing values (`"lossy"`, `"smallest"`, `96`). Member names
8
+ // keep the contracts presets.yaml readable; the SDK is responsible for
9
+ // resolving them to the wire shape before they hit the workflow payload.
10
+ //
11
+ // **Lookup grammar:** `(ERGONOMIC_ENUMS[<EnumName>] as Record<string, unknown>)[memberName]`.
12
+ // Primitives (booleans, numbers, strings whose target field is not an
13
+ // enum-typed field) pass through unchanged. Unknown member names for an
14
+ // enum-typed field throw — silent fall-through would ship the literal
15
+ // member-name to the wire.
16
+ import { ERGONOMIC_ENUMS } from '../../generated/sdk_spec/enums.js';
17
+ /**
18
+ * Translate a PRESETS member-name string to its wire backing value via
19
+ * the named ergonomic enum. Throws if `memberName` is not a key of the
20
+ * enum const — protects against typos / drift in the generator output.
21
+ *
22
+ * @internal
23
+ */
24
+ export function translateEnum(enumName, memberName) {
25
+ const enumObj = ERGONOMIC_ENUMS[enumName];
26
+ // `Object.hasOwn` rather than the `in` operator — `in` follows the
27
+ // prototype chain and would silently accept inherited members like
28
+ // `toString`/`hasOwnProperty`, returning a function value to the wire.
29
+ if (!Object.hasOwn(enumObj, memberName)) {
30
+ throw new Error(`PRESETS ergonomic translation: '${memberName}' is not a member of ${enumName}. ` +
31
+ `Known members: ${Object.keys(enumObj).join(', ')}. ` +
32
+ `This usually means the F3 generator emitted a member name that no longer exists in the ergonomic enum — regenerate or fix the contracts source.`);
33
+ }
34
+ return enumObj[memberName];
35
+ }
@@ -0,0 +1,16 @@
1
+ import { AudioBitrate, AudioSampleRate, OptimizeFor } from '../../generated/sdk_spec/enums.js';
2
+ export interface AudioCompressPresetOptionsInput {
3
+ readonly bitrate?: AudioBitrate;
4
+ readonly channels?: number;
5
+ readonly sampleRate?: AudioSampleRate;
6
+ readonly normalize?: boolean;
7
+ }
8
+ export declare class AudioCompressPresetOptions {
9
+ readonly bitrate?: AudioBitrate;
10
+ readonly channels?: number;
11
+ readonly sampleRate?: AudioSampleRate;
12
+ readonly normalize?: boolean;
13
+ private constructor();
14
+ static from(input: AudioCompressPresetOptionsInput): AudioCompressPresetOptions;
15
+ static shippedDefaultsFor(level: OptimizeFor): AudioCompressPresetOptions;
16
+ }