@giveitsmaller/sdk 0.20.0 → 0.22.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/LICENSE +202 -0
- package/README.md +55 -14
- package/dist/_audit.js +2 -4
- package/dist/builder.d.ts +4 -4
- package/dist/builder.js +47 -22
- package/dist/client.d.ts +9 -6
- package/dist/client.js +90 -31
- package/dist/ergonomic/image_output_routes.d.ts +97 -0
- package/dist/ergonomic/image_output_routes.js +227 -25
- package/dist/ergonomic/option_types.d.ts +11 -2
- package/dist/ergonomic/preset_resolver.d.ts +24 -2
- package/dist/ergonomic/preset_resolver.js +100 -10
- package/dist/ergonomic/presets/image_compress.js +16 -4
- package/dist/ergonomic/presets/index.d.ts +9 -8
- package/dist/ergonomic/presets/index.js +1 -9
- package/dist/ergonomic/presets/video_compress.d.ts +14 -0
- package/dist/errors.d.ts +101 -2
- package/dist/errors.js +108 -1
- package/dist/file-first.d.ts +81 -13
- package/dist/file-first.js +329 -69
- package/dist/generated/sdk_spec/enums.d.ts +0 -26
- package/dist/generated/sdk_spec/enums.js +0 -16
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +159 -1
- package/dist/generated/sdk_spec/presets.js +0 -14
- package/dist/generated/sdk_spec/version.d.ts +2 -2
- package/dist/generated/sdk_spec/version.js +2 -2
- package/dist/gisl.d.ts +21 -2
- package/dist/handle.d.ts +6 -1
- package/dist/handle.js +42 -13
- package/dist/index.core.d.ts +4 -4
- package/dist/index.core.js +6 -3
- package/dist/merge.d.ts +23 -0
- package/dist/merge.js +2 -2
- package/dist/sse.js +49 -1
- package/dist/types.d.ts +11 -3
- package/dist/types.js +1 -0
- package/package.json +3 -3
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
- package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
package/dist/client.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// blobByteSource, which never touches these). Kept as a STATIC import (not a
|
|
5
5
|
// dynamic one) so `vi.mock('node:fs/promises')` still intercepts it in tests.
|
|
6
6
|
import { open, stat, basename } from './node-fs.js';
|
|
7
|
-
import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
8
|
-
import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
|
|
7
|
+
import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, LongFormConcurrencyLimitResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
|
|
8
|
+
import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
|
|
9
9
|
// The `Retry-After` millisecond parser lives in the shared retry-metadata
|
|
10
10
|
// module (extracted to break the client ↔ errors circular import); re-imported
|
|
11
11
|
// here so the retry-loop timing stays byte-identical.
|
|
@@ -59,7 +59,25 @@ const S3_MAX_MULTIPART_PARTS = 10_000;
|
|
|
59
59
|
// exists to prevent). codex review (high).
|
|
60
60
|
const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
|
|
61
61
|
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
62
|
-
const DEFAULT_POLL_TIMEOUT_MS =
|
|
62
|
+
const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
|
|
63
|
+
/**
|
|
64
|
+
* Re-tag a bare per-request transport {@link GislTimeoutError} with the workflow
|
|
65
|
+
* it was scoped to, so a timed-out workflow read (status / downloads) stays
|
|
66
|
+
* recoverable. No-op for any other error and for a timeout that already carries
|
|
67
|
+
* an id. See `oYumKo6y`. (PHP transport failures surface as `GislNetworkError`,
|
|
68
|
+
* so this enrichment is TS-only.)
|
|
69
|
+
*/
|
|
70
|
+
function withWorkflowIdOnTimeout(err, workflowId) {
|
|
71
|
+
if (!(err instanceof GislTimeoutError) || err.workflowId !== undefined)
|
|
72
|
+
return err;
|
|
73
|
+
const enriched = new GislTimeoutError(err.message, workflowId);
|
|
74
|
+
// Preserve the original throw site + chain the cause — the enriched error is
|
|
75
|
+
// a re-tag, not a new failure, so diagnostics should still point at the
|
|
76
|
+
// transport timeout.
|
|
77
|
+
enriched.stack = err.stack;
|
|
78
|
+
enriched.cause = err;
|
|
79
|
+
return enriched;
|
|
80
|
+
}
|
|
63
81
|
// Anonymous-read capability header. An anonymous (null-owner) workflow create
|
|
64
82
|
// returns a one-time `cap` token (WorkflowCreateResponse.cap); the session-less
|
|
65
83
|
// caller passes it back on status/downloads/events reads via this header so the
|
|
@@ -98,6 +116,18 @@ function headersToRecord(headers) {
|
|
|
98
116
|
});
|
|
99
117
|
return r;
|
|
100
118
|
}
|
|
119
|
+
// Canonical human-readable fallback when a failure envelope carries NO `message`
|
|
120
|
+
// field. Kept IDENTICAL to the PHP SDK (`GislClient::fallbackErrorMessage`) so a
|
|
121
|
+
// message-absent error surfaces byte-identical `.message` text across SDKs
|
|
122
|
+
// (card U7MACpOj). Used by BOTH error-construction paths (`handleResponse` and
|
|
123
|
+
// the `getSchema` raw-response path) so they cannot drift. The machine code
|
|
124
|
+
// stays on `.errorCode` — this string is human DISPLAY text only; never parse
|
|
125
|
+
// it. `error` absent → `unknown_error` (PHP parity). Historically TS leaked the
|
|
126
|
+
// SCREAMING_SNAKE `error` code as `.message`, diverging from PHP's synthetic
|
|
127
|
+
// sentence; this unifies them on the human-shaped form.
|
|
128
|
+
function fallbackErrorMessage(status, errorCode) {
|
|
129
|
+
return `Request failed with status ${status} (${errorCode ?? 'unknown_error'}).`;
|
|
130
|
+
}
|
|
101
131
|
function isValidationDetails(value) {
|
|
102
132
|
return (Array.isArray(value) &&
|
|
103
133
|
value.length > 0 &&
|
|
@@ -484,13 +514,16 @@ export class GislClient {
|
|
|
484
514
|
};
|
|
485
515
|
// Human-readable text comes from `message` (the I26 localised field).
|
|
486
516
|
// `error` is the stable, never-localised SCREAMING_SNAKE machine code —
|
|
487
|
-
// NOT display text
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
517
|
+
// NOT display text (surfacing it as `.message` regressed consumers that
|
|
518
|
+
// render the human string, x9Lbf6uy). When `message` is absent (the
|
|
519
|
+
// deployed contract guarantees it on conforming error envelopes), fall
|
|
520
|
+
// back to the canonical synthetic sentence — byte-identical to the PHP
|
|
521
|
+
// SDK — instead of leaking the machine code (card U7MACpOj). The code is
|
|
522
|
+
// still carried on `.errorCode`. Machine dispatch keys off `error_type`
|
|
523
|
+
// (below), unchanged.
|
|
492
524
|
const status = response.status;
|
|
493
|
-
const errorMessage = json.message ??
|
|
525
|
+
const errorMessage = json.message ??
|
|
526
|
+
fallbackErrorMessage(status, typeof json.error === 'string' ? json.error : undefined);
|
|
494
527
|
// Validation-details branch first — preserve existing shape so callers
|
|
495
528
|
// matching on `instanceof GislValidationError` keep working.
|
|
496
529
|
if (isValidationDetails(json.details)) {
|
|
@@ -595,6 +628,15 @@ export class GislClient {
|
|
|
595
628
|
tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
|
|
596
629
|
typeof p.maxDurationSeconds === 'number');
|
|
597
630
|
}
|
|
631
|
+
// Long-form concurrency limit (429) — a TIER quota, DISTINCT from a
|
|
632
|
+
// generic infra rate-limit 429. Dispatched on the machine `error` CODE
|
|
633
|
+
// (this envelope carries NO `error_type`); a generic rate-limit 429 has a
|
|
634
|
+
// different/absent code, so it falls through to the base GislApiError
|
|
635
|
+
// where `retryAfterSeconds` applies. The validator re-asserts the code so
|
|
636
|
+
// a malformed envelope falls through rather than mis-typing.
|
|
637
|
+
if (status === 429 && json.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED') {
|
|
638
|
+
tryThrowStructured(LongFormConcurrencyLimitResponseFromJSON, GislLongFormConcurrencyError, (p) => p.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED');
|
|
639
|
+
}
|
|
598
640
|
// 413 = the absolute across-tier cap. The contract models 413 as a
|
|
599
641
|
// plain `ErrorEnvelope` (no `error_type` discriminator, no typed
|
|
600
642
|
// payload — api.yaml), so dispatch purely on status with no FromJSON
|
|
@@ -1603,10 +1645,15 @@ export class GislClient {
|
|
|
1603
1645
|
* authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
|
|
1604
1646
|
*/
|
|
1605
1647
|
async getWorkflowStatus(workflowId, opts = {}) {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
|
|
1650
|
+
deserialize: WorkflowStatusResponseFromJSON,
|
|
1651
|
+
headers: workflowCapabilityHeaders(opts.capability),
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
throw withWorkflowIdOnTimeout(err, workflowId);
|
|
1656
|
+
}
|
|
1610
1657
|
}
|
|
1611
1658
|
/**
|
|
1612
1659
|
* Poll until the workflow reaches a terminal status.
|
|
@@ -1624,7 +1671,7 @@ export class GislClient {
|
|
|
1624
1671
|
return status;
|
|
1625
1672
|
}
|
|
1626
1673
|
if (Date.now() + intervalMs > deadline) {
|
|
1627
|
-
throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms
|
|
1674
|
+
throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`, workflowId);
|
|
1628
1675
|
}
|
|
1629
1676
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1630
1677
|
}
|
|
@@ -1681,10 +1728,15 @@ export class GislClient {
|
|
|
1681
1728
|
* authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
|
|
1682
1729
|
*/
|
|
1683
1730
|
async getWorkflowDownloads(workflowId, opts = {}) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1731
|
+
try {
|
|
1732
|
+
return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
|
|
1733
|
+
deserialize: WorkflowDownloadResponseFromJSON,
|
|
1734
|
+
headers: workflowCapabilityHeaders(opts.capability),
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
catch (err) {
|
|
1738
|
+
throw withWorkflowIdOnTimeout(err, workflowId);
|
|
1739
|
+
}
|
|
1688
1740
|
}
|
|
1689
1741
|
/**
|
|
1690
1742
|
* Stream SSE events for a workflow. Returns an async iterable.
|
|
@@ -1862,22 +1914,26 @@ export class GislClient {
|
|
|
1862
1914
|
return { notModified: true, etag, lastModified };
|
|
1863
1915
|
}
|
|
1864
1916
|
if (!response.ok) {
|
|
1865
|
-
let errorMessage = 'Unknown error';
|
|
1866
1917
|
let errorCode;
|
|
1918
|
+
// Seed with the canonical synthetic sentence (unknown_error) so the
|
|
1919
|
+
// message-absent JSON branch below reuses the SAME helper as
|
|
1920
|
+
// handleResponse + PHP — that message-absent JSON envelope is the
|
|
1921
|
+
// U7MACpOj parity target. A non-JSON body is a rare edge that keeps
|
|
1922
|
+
// this seed (it does NOT claim byte-parity with PHP's non-JSON path,
|
|
1923
|
+
// which throws a distinct GislError).
|
|
1924
|
+
let errorMessage = fallbackErrorMessage(response.status, undefined);
|
|
1867
1925
|
try {
|
|
1868
1926
|
const errJson = (await response.json());
|
|
1869
|
-
// Prefer the human `message`; `error` is the machine code (x9Lbf6uy).
|
|
1870
|
-
if (errJson.message)
|
|
1871
|
-
errorMessage = errJson.message;
|
|
1872
|
-
else if (errJson.error)
|
|
1873
|
-
errorMessage = errJson.error;
|
|
1874
1927
|
// Surface the machine code as errorCode too (parity with handleResponse
|
|
1875
1928
|
// + PHP), even when `message` supplied the human text.
|
|
1876
1929
|
if (typeof errJson.error === 'string')
|
|
1877
1930
|
errorCode = errJson.error;
|
|
1931
|
+
// Prefer the human `message`; else the canonical synthetic sentence —
|
|
1932
|
+
// NOT the raw machine code (x9Lbf6uy / U7MACpOj).
|
|
1933
|
+
errorMessage = errJson.message ?? fallbackErrorMessage(response.status, errorCode);
|
|
1878
1934
|
}
|
|
1879
1935
|
catch {
|
|
1880
|
-
// Non-JSON body — keep generic message, no machine code.
|
|
1936
|
+
// Non-JSON body — keep the generic synthetic message, no machine code.
|
|
1881
1937
|
}
|
|
1882
1938
|
// This throw is OUTSIDE handleResponse (rawResponse:true / 304 path), so
|
|
1883
1939
|
// build the response-header surface from the in-scope `response` here.
|
|
@@ -1949,15 +2005,18 @@ export class GislClient {
|
|
|
1949
2005
|
* via that cookie when the client is configured with
|
|
1950
2006
|
* `useSessionCookie: true`.
|
|
1951
2007
|
*
|
|
1952
|
-
* Failure modes per ticket FX6mbTJD
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
* `
|
|
1957
|
-
* `account_deletion_expired`) → `GislAuthError`.
|
|
2008
|
+
* Failure modes per ticket FX6mbTJD (login narrowed at contracts
|
|
2009
|
+
* v2.166.0 authsec — no 403 account-state branch on login):
|
|
2010
|
+
* - **401** `invalid_credentials` (wrong password, unverified, OR
|
|
2011
|
+
* unknown account — all collapsed for anti-enumeration) →
|
|
2012
|
+
* `GislAuthError`.
|
|
1958
2013
|
* - **429** infrastructure rate-limit → `GislApiError` with
|
|
1959
2014
|
* the `Retry-After` header echoed on the response.
|
|
1960
2015
|
*
|
|
2016
|
+
* The account-status error types (`account_locked` / `account_disabled`
|
|
2017
|
+
* / `account_deleted` / `account_deletion_expired`) still exist but are
|
|
2018
|
+
* emitted on the API-key path + live-session enforcement, not on login.
|
|
2019
|
+
*
|
|
1961
2020
|
* Node session persistence (cookie-jar across processes) is out of
|
|
1962
2021
|
* scope — this method only touches the request side.
|
|
1963
2022
|
*/
|
|
@@ -85,6 +85,103 @@ export declare function resolveOutputRoute(inputToken: string, outputFormat: str
|
|
|
85
85
|
* `compressMetadata` `per_value_availability`; same_format only (the only route
|
|
86
86
|
* where value-level options like `metadata` are honored). Returns false when the
|
|
87
87
|
* option / value / group is unknown (no gate).
|
|
88
|
+
*
|
|
89
|
+
* PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
|
|
90
|
+
* The historical group is still consulted, so **every verdict this returned before still
|
|
91
|
+
* holds** — the change can only turn a missed gate into a gate, never a gate into a
|
|
92
|
+
* pass. That direction matters: a new false ACCEPT would send a request the server
|
|
93
|
+
* rejects, which is the failure this function exists to prevent.
|
|
94
|
+
*
|
|
95
|
+
* Why not "most specific wins", which reads cleaner: it would flip `webp` +
|
|
96
|
+
* `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
|
|
97
|
+
* `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
|
|
98
|
+
* deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
|
|
99
|
+
* group), and whether webp srgb actually works on the server is not something this
|
|
100
|
+
* layer can know. Un-gating it on an inference would be exactly the "confident answer
|
|
101
|
+
* from a check that could not tell you otherwise" pattern. Raised as a question instead.
|
|
102
|
+
*
|
|
103
|
+
* What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
|
|
104
|
+
* generic group does not, so an SVG input previously sailed through the one marker that
|
|
105
|
+
* mattered for it — on this gate and on the `output()` gate that shares it.
|
|
88
106
|
*/
|
|
89
107
|
export declare function isPlannedValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Compress-route enum members per image mime-group, mirroring the shipped
|
|
110
|
+
* `availability/availability.json` `operations.compress.mime_groups.<group>.
|
|
111
|
+
* options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
|
|
112
|
+
* availability sidecar) so the enum-membership gate stays browser-safe, exactly
|
|
113
|
+
* like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
|
|
114
|
+
* dependency on a contracts version that carries the enum in a compact form (a
|
|
115
|
+
* generated-metadata `values` field would fail open on an older published
|
|
116
|
+
* `@giveitsmaller/contracts`). PINNED to `availability.json` by
|
|
117
|
+
* `output-route-conformance.test.ts`; a contract regen that adds/changes an
|
|
118
|
+
* enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
|
|
119
|
+
*
|
|
120
|
+
* `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
|
|
121
|
+
* `keep`) — the reason a value gate that consulted only the generic `image`
|
|
122
|
+
* group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
|
|
123
|
+
* those bases (rtkzl9gr). `output_format` is listed for a faithful projection
|
|
124
|
+
* mirror but is never gated here (the Output lowering owns it positionally).
|
|
125
|
+
*/
|
|
126
|
+
export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
|
|
127
|
+
/**
|
|
128
|
+
* Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
|
|
129
|
+
* input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
|
|
130
|
+
* {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
|
|
131
|
+
* enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
|
|
132
|
+
* is never gated. Meaningful only on the same_format (compress) route, where
|
|
133
|
+
* the compress option enums definitionally apply. Membership is STRICT: a value
|
|
134
|
+
* whose type differs from the string enum members (e.g. numeric `420`) is
|
|
135
|
+
* treated as unknown rather than coerced to a match.
|
|
136
|
+
*/
|
|
137
|
+
export declare function isUnknownEnumValue(inputToken: string, optionKey: string, value: unknown): boolean;
|
|
138
|
+
/** A contract `depends_on` rule for a compress-image output option (ehHU08Hu). */
|
|
139
|
+
type OutputDependsOnRule = {
|
|
140
|
+
readonly requiresKey: string;
|
|
141
|
+
readonly requiresValue: string;
|
|
142
|
+
} | {
|
|
143
|
+
readonly requiresAnyOf: readonly string[];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Contract `depends_on` per compress-image output option, mirroring
|
|
147
|
+
* `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
|
|
148
|
+
* (ehHU08Hu). The rule is option-consistent across every image group that carries
|
|
149
|
+
* the option, so this is a FLAT table (validated group-by-group by
|
|
150
|
+
* `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
|
|
151
|
+
*
|
|
152
|
+
* Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
|
|
153
|
+
* so the gate stays browser-safe with no contracts-version coupling, exactly like
|
|
154
|
+
* {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
|
|
155
|
+
* `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
|
|
156
|
+
*
|
|
157
|
+
* Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
|
|
158
|
+
* checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
|
|
159
|
+
* `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
|
|
160
|
+
* all rejected pre-upload instead of only the one hand-coded case.
|
|
161
|
+
*/
|
|
162
|
+
export declare const OUTPUT_OPTION_DEPENDS_ON: Readonly<Record<string, OutputDependsOnRule>>;
|
|
163
|
+
/**
|
|
164
|
+
* Default of each depended-on key — an ABSENT key resolves to this before the
|
|
165
|
+
* dependency check (the server applies the same default). `encoding_mode`
|
|
166
|
+
* defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
|
|
167
|
+
* but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
|
|
168
|
+
* defaults by the conformance suite.
|
|
169
|
+
*/
|
|
170
|
+
export declare const DEPENDS_ON_KEY_DEFAULTS: Readonly<Record<string, string>>;
|
|
171
|
+
/**
|
|
172
|
+
* The first contract `depends_on` an already-lowered compress-image wire-option
|
|
173
|
+
* set violates for the resolved `route`, or `undefined` when every dependency is
|
|
174
|
+
* satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
|
|
175
|
+
* `invalid_option_combination` with the returned message + conflictingFields.
|
|
176
|
+
* Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
|
|
177
|
+
* the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
|
|
178
|
+
* absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
|
|
179
|
+
* (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
|
|
180
|
+
* in compress + convert) run on BOTH routes. Mirrored by PHP
|
|
181
|
+
* `ImageOutputRoutes::dependsOnViolation`.
|
|
182
|
+
*/
|
|
183
|
+
export declare function dependsOnViolation(wireOptions: Readonly<Record<string, unknown>>, route: 'same_format' | 'format_change'): {
|
|
184
|
+
readonly message: string;
|
|
185
|
+
readonly conflictingFields: readonly string[];
|
|
186
|
+
} | undefined;
|
|
90
187
|
export {};
|
|
@@ -29,8 +29,18 @@
|
|
|
29
29
|
import { compressMetadata } from '@giveitsmaller/contracts/operations';
|
|
30
30
|
/** The resize option keys — input-keyed, raster-only (see module doc). */
|
|
31
31
|
export const RESIZE_KEYS = ['width', 'height', 'fit'];
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Options whose availability follows the INPUT format's raster capability, not
|
|
34
|
+
* the output format — resize (`width`/`height`/`fit`) plus `auto_orient`. The
|
|
35
|
+
* projection lists them on every `format_change` cell (keyed by OUTPUT), so on
|
|
36
|
+
* a format change they must be re-gated against the INPUT's `same_format` cell:
|
|
37
|
+
* a raster input carries them, an SVG (vector) input does not. Before rtkzl9gr
|
|
38
|
+
* only the resize keys were input-gated, so `auto_orient` leaked onto the
|
|
39
|
+
* `svg → raster` route and was rejected server-side.
|
|
40
|
+
*/
|
|
41
|
+
const INPUT_GATED_KEYS = [...RESIZE_KEYS, 'auto_orient'];
|
|
42
|
+
/** Set form of {@link INPUT_GATED_KEYS} for `string`-keyed membership tests. */
|
|
43
|
+
const INPUT_GATED_KEY_SET = new Set(INPUT_GATED_KEYS);
|
|
34
44
|
/** Image area cap shared by every resizable route (projection `max_output_pixels`). */
|
|
35
45
|
export const MAX_OUTPUT_PIXELS = 16_000_000;
|
|
36
46
|
/**
|
|
@@ -117,33 +127,57 @@ export function resolveOutputRoute(inputToken, outputFormat) {
|
|
|
117
127
|
const cell = IMAGE_OUTPUT_ROUTES.format_change[outToken];
|
|
118
128
|
if (cell === undefined)
|
|
119
129
|
return undefined;
|
|
120
|
-
// Resize
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
130
|
+
// Resize + auto_orient are INPUT-gated (see {@link INPUT_GATED_KEYS}). Since
|
|
131
|
+
// v2.103.0 convert is the resize engine, so the projection lists width/height/
|
|
132
|
+
// fit AND auto_orient on EVERY format_change cell — but an SVG INPUT cannot be
|
|
133
|
+
// raster-resized or auto-oriented (the convert worker rejects it). So strip
|
|
134
|
+
// the cell's input-gated keys and re-add only those the INPUT's same_format
|
|
135
|
+
// cell honors: raster inputs carry them, svg does not. The transcoder options
|
|
136
|
+
// (output_format/quality/background/color_profile) ride the cell directly.
|
|
137
|
+
const transcoderHonored = cell.honored.filter((k) => !INPUT_GATED_KEY_SET.has(k));
|
|
127
138
|
const inCell = IMAGE_OUTPUT_ROUTES.same_format[inputToken];
|
|
128
|
-
const
|
|
139
|
+
const inputGated = inCell ? INPUT_GATED_KEYS.filter((k) => inCell.honored.includes(k)) : [];
|
|
129
140
|
return {
|
|
130
141
|
route: 'format_change',
|
|
131
142
|
sourceOp: 'convert',
|
|
132
143
|
outputFormatWire: outToken,
|
|
133
144
|
inputToken,
|
|
134
|
-
honored: new Set([...transcoderHonored, ...
|
|
145
|
+
honored: new Set([...transcoderHonored, ...inputGated]),
|
|
135
146
|
planned: new Set(cell.planned),
|
|
136
147
|
};
|
|
137
148
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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];
|
|
147
181
|
}
|
|
148
182
|
/**
|
|
149
183
|
* Whether a specific VALUE of an option is `availability: 'planned'` for the
|
|
@@ -152,12 +186,180 @@ function compressGroupForToken(token) {
|
|
|
152
186
|
* `compressMetadata` `per_value_availability`; same_format only (the only route
|
|
153
187
|
* where value-level options like `metadata` are honored). Returns false when the
|
|
154
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.
|
|
155
207
|
*/
|
|
156
208
|
export function isPlannedValue(inputToken, optionKey, value) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Compress-route enum members per image mime-group, mirroring the shipped
|
|
218
|
+
* `availability/availability.json` `operations.compress.mime_groups.<group>.
|
|
219
|
+
* options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
|
|
220
|
+
* availability sidecar) so the enum-membership gate stays browser-safe, exactly
|
|
221
|
+
* like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
|
|
222
|
+
* dependency on a contracts version that carries the enum in a compact form (a
|
|
223
|
+
* generated-metadata `values` field would fail open on an older published
|
|
224
|
+
* `@giveitsmaller/contracts`). PINNED to `availability.json` by
|
|
225
|
+
* `output-route-conformance.test.ts`; a contract regen that adds/changes an
|
|
226
|
+
* enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
|
|
227
|
+
*
|
|
228
|
+
* `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
|
|
229
|
+
* `keep`) — the reason a value gate that consulted only the generic `image`
|
|
230
|
+
* group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
|
|
231
|
+
* those bases (rtkzl9gr). `output_format` is listed for a faithful projection
|
|
232
|
+
* mirror but is never gated here (the Output lowering owns it positionally).
|
|
233
|
+
*/
|
|
234
|
+
export const COMPRESS_OPTION_VALUES = {
|
|
235
|
+
image: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
236
|
+
image_jpeg: { chroma_subsampling: ['420', '422', '444'], color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
237
|
+
image_png: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
238
|
+
image_avif: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
239
|
+
image_svg: { metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
|
|
240
|
+
image_webp: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* The compress mime-group whose enum members are authoritative for an image
|
|
244
|
+
* token's SAME_FORMAT route — the exact `image_<token>` group when
|
|
245
|
+
* {@link COMPRESS_OPTION_VALUES} carries one, else the generic `image` group
|
|
246
|
+
* (gif/tiff).
|
|
247
|
+
*
|
|
248
|
+
* Deliberately DISTINCT from {@link compressGroupForToken} (which the planned
|
|
249
|
+
* gate uses). The planned gate routes webp/gif/svg/tiff through the generic
|
|
250
|
+
* `image` group, where cross-format `planned` markers live (e.g. `srgb`).
|
|
251
|
+
* Enum MEMBERSHIP is the opposite: it needs the format-specific enum, because
|
|
252
|
+
* `image_svg`'s `metadata` enum is the narrow `[strip, all]` while the generic
|
|
253
|
+
* group's is `[strip, keep, all]` — so only the specific group rejects
|
|
254
|
+
* `metadata: 'keep'` on SVG (and AVIF, which already maps specifically).
|
|
255
|
+
*/
|
|
256
|
+
function enumGroupForToken(token) {
|
|
257
|
+
const specific = `image_${token}`;
|
|
258
|
+
return COMPRESS_OPTION_VALUES[specific] !== undefined ? specific : 'image';
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
|
|
262
|
+
* input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
|
|
263
|
+
* {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
|
|
264
|
+
* enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
|
|
265
|
+
* is never gated. Meaningful only on the same_format (compress) route, where
|
|
266
|
+
* the compress option enums definitionally apply. Membership is STRICT: a value
|
|
267
|
+
* whose type differs from the string enum members (e.g. numeric `420`) is
|
|
268
|
+
* treated as unknown rather than coerced to a match.
|
|
269
|
+
*/
|
|
270
|
+
export function isUnknownEnumValue(inputToken, optionKey, value) {
|
|
271
|
+
const members = COMPRESS_OPTION_VALUES[enumGroupForToken(inputToken)]?.[optionKey];
|
|
272
|
+
if (members === undefined)
|
|
160
273
|
return false;
|
|
161
|
-
|
|
162
|
-
|
|
274
|
+
return !(typeof value === 'string' && members.includes(value));
|
|
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;
|
|
163
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. */
|