@giveitsmaller/sdk 0.6.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.
- package/dist/_audit.js +60 -0
- package/dist/builder.d.ts +406 -0
- package/dist/builder.js +706 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +28 -2
- package/dist/credentials.d.ts +61 -0
- package/dist/credentials.js +200 -0
- package/dist/ergonomic/preset_resolver.d.ts +75 -0
- package/dist/ergonomic/preset_resolver.js +568 -0
- package/dist/ergonomic/presets/_translate.d.ts +11 -0
- package/dist/ergonomic/presets/_translate.js +35 -0
- package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
- package/dist/ergonomic/presets/audio_compress.js +45 -0
- package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_epub_compress.js +34 -0
- package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_odf_compress.js +34 -0
- package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
- package/dist/ergonomic/presets/document_office_compress.js +40 -0
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
- package/dist/ergonomic/presets/image_compress.d.ts +43 -0
- package/dist/ergonomic/presets/image_compress.js +95 -0
- package/dist/ergonomic/presets/index.d.ts +77 -0
- package/dist/ergonomic/presets/index.js +216 -0
- package/dist/ergonomic/presets/video_compress.d.ts +30 -0
- package/dist/ergonomic/presets/video_compress.js +83 -0
- package/dist/errors.d.ts +147 -1
- package/dist/errors.js +161 -0
- package/dist/generated/sdk_spec/enums.d.ts +195 -0
- package/dist/generated/sdk_spec/enums.js +127 -0
- package/dist/generated/sdk_spec/errors.d.ts +16 -0
- package/dist/generated/sdk_spec/errors.js +473 -0
- package/dist/generated/sdk_spec/index.d.ts +4 -0
- package/dist/generated/sdk_spec/index.js +7 -0
- package/dist/generated/sdk_spec/presets.d.ts +6 -0
- package/dist/generated/sdk_spec/presets.js +157 -0
- package/dist/generated/sdk_spec/version.d.ts +3 -0
- package/dist/generated/sdk_spec/version.js +6 -0
- package/dist/gisl.d.ts +112 -0
- package/dist/gisl.js +266 -0
- package/dist/index.d.ts +15 -5
- package/dist/index.js +32 -4
- package/dist/merge.d.ts +142 -0
- package/dist/merge.js +411 -0
- package/dist/types.d.ts +12 -14
- package/dist/types.js +18 -0
- 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
|
+
}
|