rapier-jxl 1.1.21 → 2.0.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/effort.mjs ADDED
@@ -0,0 +1,217 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Rapier JXL's effort door: the core's `encode` with a search above it. {effort: 1} (the default) writes the core's
3
+ // stream byte for byte; a higher effort prices more candidates for every channel of an exact picture and keeps the
4
+ // smallest stream: never larger than the effort below it, effort 1's on a tie. The rungs, each from its effort up:
5
+ // 2. the specification's self-correcting (weighted) predictor, one context per channel;
6
+ // 3. the weighted predictor with its channel's tokens split by the predictor's own error, neighbouring intervals of
7
+ // libjxl's cut points merged wherever a shared prefix code costs less.
8
+ // A lossy request is effort 1's. Above effort 1 an exact picture's job spends its first half (fractions up to 0.5)
9
+ // writing effort 1's stream and its second searching, so a caller's clock can tell the search's own pace; `hurry`
10
+ // ends the search at its next step with the smallest stream written so far. Every choice is integer arithmetic: the
11
+ // same input and options write the same bytes everywhere.
12
+ import {LIMITS, BitWriter, complete, part} from './bits.mjs';
13
+ import {writeImageHeader, writeModularFrameHeader, groupLayout, assembleCodestream, GROUP_DIM} from './frame.mjs';
14
+ import {buildCode, writePrefixCode} from './prefix.mjs';
15
+ import {AVERAGE_PREDICTOR, GRADIENT_PREDICTOR, ALPHABET, leaf, split, channelTree, writeTree, writeModularHeader, writeChannelHistograms, codeChannel} from './modular.mjs';
16
+ import {inspectPixels, losslessSteps} from './lossless.mjs';
17
+ import {lossySteps} from './lossy.mjs';
18
+ import {WEIGHTED_PREDICTOR, WEIGHTED_PROPERTY, WEIGHTED_CUTS, codeWeighted} from './weighted.mjs';
19
+ import {fault, admitOptions, admitPixels, job} from './admit.mjs';
20
+
21
+ export {LIMITS};
22
+
23
+ export function encode(data, width, height, options) { return complete(encodeSteps(data, width, height, options)); }
24
+
25
+ // The options as every door reads them, and the effort, which this door alone reads (the core, one rung, does not): a
26
+ // whole number from 1 to 9, 1 when absent.
27
+ export function encodeSteps(data, width, height, options) {
28
+ const {quality, colorSpace} = admitOptions(options), effort = options?.effort === undefined ? 1 : options.effort;
29
+ if (!Number.isInteger(effort) || effort < 1 || effort > 9) throw fault('JXL_INPUT', 'Effort is a whole number from 1 to 9.');
30
+ admitPixels(data, width, height);
31
+ return job(effortSteps(data, width, height, quality, colorSpace, effort));
32
+ }
33
+
34
+ // Effort 1 is the core's work (index.mjs), step for step; the rungs run after it and keep a smaller stream if they
35
+ // find one. A search that runs out of memory leaves effort 1's stream standing.
36
+ function* effortSteps(data, width, height, quality, colorSpace, effort) {
37
+ const shape = inspectPixels(data, width, height);
38
+ if (quality < 100) {
39
+ if (!shape.palette) return yield* lossySteps(data, width, height, {quality, shape, colorSpace});
40
+ const exact = yield* part(losslessSteps(data, width, height, {shape, colorSpace}), 0, 2);
41
+ let bytes;
42
+ try { bytes = yield* part(lossySteps(data, width, height, {quality, shape, colorSpace}), 1, 2); }
43
+ catch (error) { if (!(error instanceof RangeError) || error.code) throw error; bytes = exact; }
44
+ return exact.length <= bytes.length ? exact : bytes;
45
+ }
46
+ if (effort < 2) return yield* losslessSteps(data, width, height, {shape, colorSpace});
47
+ let best = yield* part(losslessSteps(data, width, height, {shape, colorSpace}), 0, 2);
48
+ try {
49
+ const bytes = yield* part(searchSteps(data, width, height, shape.palette ? {...shape, palette: null} : shape, colorSpace, effort), 1, 2);
50
+ if (bytes && bytes.length < best.length) best = bytes;
51
+ } catch (error) { if (!(error instanceof RangeError) || error.code) throw error; }
52
+ return best;
53
+ }
54
+
55
+ // A token's raw bits: a residual token s above zero carries s - 1 of them, an LZ77 length token above 235 s - 236.
56
+ const raw = s => Math.max(0, s < 224 ? s - 1 : s - 236);
57
+ // A histogram's complete cost in bits: its prefix code's header and every token's code and raw bits.
58
+ function cost(freqs) {
59
+ const code = buildCode(freqs), writer = new BitWriter(128);
60
+ writePrefixCode(writer, code);
61
+ let bits = writer.bitLength;
62
+ for (let s = 0; s < freqs.length; s++) if (freqs[s]) bits += freqs[s] * (code.lengths[s] + raw(s));
63
+ return bits;
64
+ }
65
+ const sum = histograms => { const out = new Uint32Array(ALPHABET); for (const h of histograms) for (let s = 0; s < ALPHABET; s++) out[s] += h[s]; return out; };
66
+
67
+ // The direct plan of lossless.mjs (YCoCg, 256-pixel groups), every channel counted over the whole picture under effort
68
+ // 1's predictor (chosen by samples, as the core chooses it) and under the weighted one; each rung's plan takes every
69
+ // channel's cheapest, and the plans are written the cheapest way. Nothing written (null) when no plan leaves effort 1.
70
+ function* searchSteps(rgba, width, height, shape, colorSpace, effort) {
71
+ const {channels} = shape, layout = groupLayout(width, height), groups = layout.groupsX * layout.groupsY;
72
+ const planes = Array.from({length: channels}, () => new Int16Array(GROUP_DIM * GROUP_DIM));
73
+ const fill = (x0, y0, w, h) => {
74
+ for (let y = 0; y < h; y++) {
75
+ let i = ((y0 + y) * width + x0) * 4, at = y * w;
76
+ for (let x = 0; x < w; x++, i += 4, at++) {
77
+ if (channels >= 3) {
78
+ const r = rgba[i], g = rgba[i + 1], b = rgba[i + 2];
79
+ const co = r - b, tmp = b + (co >> 1), cg = g - tmp;
80
+ planes[0][at] = tmp + (cg >> 1); planes[1][at] = co; planes[2][at] = cg;
81
+ if (channels === 4) planes[3][at] = rgba[i + 3];
82
+ } else { planes[0][at] = rgba[i]; if (channels === 2) planes[1][at] = rgba[i + 3]; }
83
+ }
84
+ }
85
+ };
86
+ const group = (g, visit) => {
87
+ const x0 = g % layout.groupsX * GROUP_DIM, y0 = (g / layout.groupsX | 0) * GROUP_DIM, w = Math.min(GROUP_DIM, width - x0), h = Math.min(GROUP_DIM, height - y0);
88
+ fill(x0, y0, w, h);
89
+ visit(w, h);
90
+ };
91
+ // Effort 1's predictor per channel: three 32-pixel samples priced under gradient and average, as the core does.
92
+ const sw = Math.min(width, 32), sh = Math.min(height, 32);
93
+ const sampled = [GRADIENT_PREDICTOR, AVERAGE_PREDICTOR].map(predictor => ({predictor, freqs: planes.map(() => new Uint32Array(ALPHABET))}));
94
+ for (const fraction of [0, 0.5, 1]) {
95
+ fill(Math.floor((width - sw) * fraction), Math.floor((height - sh) * fraction), sw, sh);
96
+ for (const candidate of sampled) for (let c = 0; c < channels; c++) codeChannel(null, candidate.freqs[c], planes[c], sw, sh, leaf(candidate.predictor));
97
+ }
98
+ const first = planes.map((_, c) => leaf(sampled[cost(sampled[1].freqs[c]) < cost(sampled[0].freqs[c]) ? 1 : 0].predictor));
99
+ // Every channel counted under effort 1's predictor and under the weighted one, its tokens kept by error interval.
100
+ // A step is a group of the counting pass (the first half of the search's fractions) or of a plan's writing (the
101
+ // second half, shared by the plans written), so the pace of the steps holds whether one plan is written or two.
102
+ const contexts = WEIGHTED_CUTS.length + 1, identity = Int32Array.from({length: contexts}, (_, k) => k);
103
+ const firstCounts = planes.map(() => new Uint32Array(ALPHABET)), intervals = planes.map(() => Array.from({length: contexts}, () => new Uint32Array(ALPHABET)));
104
+ let done = 0;
105
+ for (let g = 0; g < groups; g++) {
106
+ group(g, (w, h) => {
107
+ for (let c = 0; c < channels; c++) {
108
+ codeChannel(null, firstCounts[c], planes[c], w, h, first[c]);
109
+ codeWeighted(null, intervals[c], planes[c], w, h, 0, identity);
110
+ }
111
+ });
112
+ if (yield ++done / (2 * groups)) return null;
113
+ }
114
+ // Rung 2: each channel's cheaper of effort 1's predictor and the weighted one in one context.
115
+ const rung2 = planes.map((_, c) => {
116
+ const plan = {cost: cost(firstCounts[c]), leaves: [first[c]], freqs: [firstCounts[c]], cuts: []};
117
+ const whole = sum(intervals[c]), single = cost(whole);
118
+ return single < plan.cost ? {cost: single, leaves: [leaf(WEIGHTED_PREDICTOR)], freqs: [whole], cuts: []} : plan;
119
+ });
120
+ // Rung 3: or its intervals grouped into runs of neighbours, the grouping of least cost found exactly by dynamic
121
+ // programming. best[j] is the least cost of intervals 0..j-1 grouped, `from[j]` where its last group begins; an
122
+ // interval no token reaches leaves the merged cost as it was.
123
+ const rung3 = effort < 3 ? rung2 : rung2.map((plan, c) => {
124
+ const filled = intervals[c].map(h => h.some(v => v));
125
+ const best = new Float64Array(contexts + 1).fill(Infinity), from = new Int32Array(contexts + 1);
126
+ best[0] = 0;
127
+ for (let j = 1; j <= contexts; j++) {
128
+ const merged = new Uint32Array(ALPHABET);
129
+ let bits = 0;
130
+ for (let i = j - 1; i >= 0; i--) {
131
+ if (filled[i]) { const h = intervals[c][i]; for (let s = 0; s < ALPHABET; s++) merged[s] += h[s]; bits = cost(merged); }
132
+ // Every group is a leaf of the tree and an entry of the context map: 24 bits more, so an interval no token
133
+ // reaches joins its neighbour.
134
+ const price = best[i] + bits + 24;
135
+ if (price < best[j]) { best[j] = price; from[j] = i; }
136
+ }
137
+ }
138
+ if (!(best[contexts] < plan.cost)) return plan;
139
+ const groupsOf = [];
140
+ for (let j = contexts; j > 0; j = from[j]) groupsOf.unshift([from[j], j]);
141
+ return {cost: best[contexts], leaves: groupsOf.map(() => leaf(WEIGHTED_PREDICTOR)), freqs: groupsOf.map(([i, j]) => sum(intervals[c].slice(i, j))), cuts: groupsOf.slice(0, -1).map(([, j]) => WEIGHTED_CUTS[j - 1]),
142
+ contextOf: Int32Array.from({length: contexts}, (_, k) => groupsOf.findIndex(([i, j]) => k >= i && k < j))};
143
+ });
144
+ const candidates = [rung2, rung3].filter((plans, k) => k ? plans.some((plan, c) => plan !== rung2[c]) : plans.some((plan, c) => plan.leaves[0] !== first[c]));
145
+ if (!candidates.length) return null;
146
+ // The tree: one subtree per channel on the channel property, and inside a split channel a balanced tree on the
147
+ // weighted predictor's property, values above a cut to the left.
148
+ const byError = (leaves, cuts) => {
149
+ const build = (lo, hi) => { if (lo === hi) return leaves[lo]; const mid = (lo + hi) >> 1; return split(WEIGHTED_PROPERTY, cuts[mid], build(mid + 1, hi), build(lo, mid)); };
150
+ return build(0, leaves.length - 1);
151
+ };
152
+ const assemble = plans => ({tree: channelTree(plans.map(plan => byError(plan.leaves, plan.cuts))), leaves: plans.flatMap(plan => plan.leaves), freqs: plans.flatMap(plan => plan.freqs)});
153
+ // A plan's price in bits: its tree and histograms as written, and every token's code and raw bits. Two plans' streams
154
+ // differ by their prices and at most 27 bits a section more (a section's padding, and its size's field in the table
155
+ // of contents, 12 to 32 bits) and a byte: a plan that far cheaper is written alone, closer plans both.
156
+ const price = plans => {
157
+ const {tree, leaves, freqs} = assemble(plans), w = new BitWriter(4096);
158
+ const histograms = writeChannelHistograms(w, writeTree(w, tree), freqs, l => leaves.indexOf(l));
159
+ let bits = w.bitLength;
160
+ freqs.forEach((f, i) => { const {lengths} = histograms[i + 1].code; for (let s = 0; s < f.length; s++) if (f[s]) bits += f[s] * (lengths[s] + raw(s)); });
161
+ return bits;
162
+ };
163
+ let chosen = candidates;
164
+ if (candidates.length > 1) {
165
+ const [a, b] = candidates.map(price), margin = 27 * (layout.single ? 1 : groups + 1) + 8;
166
+ if (b + margin <= a) chosen = [candidates[1]];
167
+ else if (a + margin <= b) chosen = [candidates[0]];
168
+ }
169
+ let written = 0;
170
+ const next = () => 0.5 + ++written / (2 * chosen.length * groups);
171
+ let smallest = null;
172
+ for (const plans of chosen) {
173
+ const bytes = yield* write(plans);
174
+ if (!bytes) return smallest;
175
+ if (!smallest || bytes.length < smallest.length) smallest = bytes;
176
+ }
177
+ return smallest;
178
+
179
+ // A plan's stream, a group per step; null when hurried.
180
+ function* write(plans) {
181
+ const {tree, leaves, freqs} = assemble(plans);
182
+ const transforms = channels >= 3 ? [{type: 'rct', beginC: 0, rctType: 6}] : [];
183
+ const header = new BitWriter(256);
184
+ writeImageHeader(header, width, height, shape.colour, shape.alpha, {colorSpace});
185
+ writeModularFrameHeader(header, {alpha: shape.alpha});
186
+ const global = new BitWriter(4096);
187
+ global.write(1, 1); // default DC quantisation
188
+ global.write(1, 1); // a global tree
189
+ const histograms = writeChannelHistograms(global, writeTree(global, tree), freqs, l => leaves.indexOf(l));
190
+ writeModularHeader(global, {useGlobalTree: true, transforms});
191
+ const codes = plans.map(plan => plan.leaves.map(l => histograms[leaves.indexOf(l) + 1].code));
192
+ const code = (w, c, width, height) => {
193
+ const plan = plans[c];
194
+ if (plan.leaves[0].predictor === WEIGHTED_PREDICTOR) codeWeighted(w, codes[c], planes[c], width, height, 0, plan.contextOf);
195
+ else codeChannel(w, codes[c][0], planes[c], width, height, plan.leaves[0]);
196
+ };
197
+ const sections = [];
198
+ if (layout.single) {
199
+ group(0, (w, h) => { for (let c = 0; c < channels; c++) code(global, c, w, h); });
200
+ if (yield next()) return null;
201
+ sections.push(global.finish());
202
+ } else {
203
+ sections.push(global.finish());
204
+ for (let i = 0; i < layout.dcGroupsX * layout.dcGroupsY + 1; i++) sections.push(new Uint8Array(0));
205
+ for (let g = 0; g < groups; g++) {
206
+ group(g, (w, h) => {
207
+ const section = new BitWriter(w * h * channels + 64);
208
+ writeModularHeader(section, {useGlobalTree: true, transforms: []});
209
+ for (let c = 0; c < channels; c++) code(section, c, w, h);
210
+ sections.push(section.finish());
211
+ });
212
+ if (yield next()) return null;
213
+ }
214
+ }
215
+ return assembleCodestream(header, sections);
216
+ }
217
+ }
package/entropy.mjs CHANGED
@@ -45,10 +45,25 @@ export class TokenCounts {
45
45
  const slots = [0, 0, 0];
46
46
  function hybridTokenOf(config, value) { hybridToken(config, value, slots); return slots[0]; }
47
47
 
48
+ // log2 in arithmetic every engine rounds alike (Math.log2 does not): the exponent from the integer part's leading
49
+ // zeros and exact powers of two, then 2 atanh(z) / ln 2 as its series, z = (x - 1) / (x + 1) below 0.172 in magnitude.
50
+ const HALVES = [1];
51
+ for (let e = 1; e < 32; e++) HALVES.push(HALVES[e - 1] / 2);
52
+ function log2(x) {
53
+ let exponent = 0;
54
+ if (x >= 2 && x < 4294967296) { exponent = 31 - Math.clz32(x); x *= HALVES[exponent]; }
55
+ while (x >= 1.4142135623730951) { x /= 2; exponent++; }
56
+ while (x < 0.7071067811865476) { x *= 2; exponent--; }
57
+ const z = (x - 1) / (x + 1), z2 = z * z;
58
+ let term = z, sum = z;
59
+ for (let k = 3; k < 30; k += 2) sum += (term *= z2) / k;
60
+ return exponent + sum * 2.885390081777927;
61
+ }
62
+
48
63
  function entropyBits(freqs) {
49
64
  let total = 0, bits = 0;
50
65
  for (const f of freqs) total += f;
51
- for (const f of freqs) if (f) bits += f * Math.log2(total / f);
66
+ for (const f of freqs) if (f) bits += f * log2(total / f);
52
67
  return bits;
53
68
  }
54
69
 
package/frame.mjs CHANGED
@@ -8,8 +8,9 @@ function writeSize(w, size) {
8
8
  w.writeU32([[9, 1], [13, 1], [18, 1], [30, 1]], size);
9
9
  }
10
10
 
11
- // 8 bits per sample; `colour` 1 (grey) or 3 (sRGB); an 8-bit alpha channel when `alpha`. Frames start byte-aligned.
12
- export function writeImageHeader(w, width, height, colour, alpha, {xyb = false, orientation = 1} = {}) {
11
+ // 8 bits per sample; `colour` 1 (grey) or 3 (sRGB, or Display P3 when `colorSpace` says so); an 8-bit alpha channel
12
+ // when `alpha`. Frames start byte-aligned.
13
+ export function writeImageHeader(w, width, height, colour, alpha, {xyb = false, orientation = 1, colorSpace} = {}) {
13
14
  w.write(16, 0x0AFF);
14
15
  w.write(1, 0); // not the small size form
15
16
  writeSize(w, height);
@@ -23,7 +24,11 @@ export function writeImageHeader(w, width, height, colour, alpha, {xyb = false,
23
24
  if (alpha) w.write(3, 0b1_01); // one extra channel (2), all default (1): 8-bit alpha
24
25
  else w.write(2, 0);
25
26
  w.write(1, xyb ? 1 : 0); // xyb_encoded
26
- if (colour === 3) w.write(1, 1); // colour encoding all default: sRGB
27
+ if (colour === 3 && colorSpace === 'display-p3') {
28
+ // Display P3, fields in reverse order as below: not default (1), no ICC (1), RGB (2), D65 (2), the P3 primaries
29
+ // (selector 2 and 11 - 2 in 4), no gamma (1), the sRGB curve (selector 2 and 13 - 2 in 4), relative intent (2).
30
+ w.write(21, 0b01_1011_10_0_1001_10_01_00_0_0);
31
+ } else if (colour === 3) w.write(1, 1); // colour encoding all default: sRGB
27
32
  else {
28
33
  // Fixed grey encoding. Groups below are the fields in reverse order because the writer sends the low bits
29
34
  // first: not default (1), no ICC (1), grey (2), D65 (2), no gamma (1), sRGB selector (2) and enum (4), intent (2).
@@ -35,13 +40,14 @@ export function writeImageHeader(w, width, height, colour, alpha, {xyb = false,
35
40
  w.zeroPadToByte();
36
41
  }
37
42
 
38
- // A lossless modular frame, the last frame, no filters, one pass, 256-pixel groups, replace blending.
39
- export function writeModularFrameHeader(w, {alpha}) {
43
+ // A modular frame, the last frame, no filters, one pass, groups of 128 << shift pixels (256 unless asked), replace
44
+ // blending.
45
+ export function writeModularFrameHeader(w, {alpha, shift = 1}) {
40
46
  // Fixed beginning, low bits first: not all default (1), regular frame (2), modular (1), default flags (2),
41
47
  // not YCbCr (1), no upsampling (2).
42
48
  w.write(9, 0b00_0_00_1_00_0);
43
49
  if (alpha) w.write(2, 0); // no extra-channel upsampling
44
- w.write(2, 1); // group size shift 1: 256
50
+ w.write(2, shift); // the group size shift
45
51
  writeFrameHeaderEnd(w, alpha);
46
52
  }
47
53
 
@@ -61,9 +67,10 @@ export function writeTOC(w, sizes) {
61
67
  w.zeroPadToByte();
62
68
  }
63
69
 
64
- export function groupLayout(width, height) {
65
- const groupsX = Math.ceil(width / GROUP_DIM), groupsY = Math.ceil(height / GROUP_DIM);
66
- const dcGroupsX = Math.ceil(width / DC_GROUP_DIM), dcGroupsY = Math.ceil(height / DC_GROUP_DIM);
70
+ // The groups of a frame whose groups are `dim` pixels on a side, its DC groups eight times that.
71
+ export function groupLayout(width, height, dim = GROUP_DIM) {
72
+ const groupsX = Math.ceil(width / dim), groupsY = Math.ceil(height / dim);
73
+ const dcGroupsX = Math.ceil(width / (8 * dim)), dcGroupsY = Math.ceil(height / (8 * dim));
67
74
  return {groupsX, groupsY, dcGroupsX, dcGroupsY, single: groupsX === 1 && groupsY === 1};
68
75
  }
69
76
 
package/index.d.mts CHANGED
@@ -1,27 +1,23 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Rapier JXL: the types of the checked entries, the limits and the error codes. The raw entries beneath the
3
- // checked ones take input the checked entries have already admitted; their shapes are the modules' own.
2
+ // Rapier JXL's core: the type of encode, the shape of the limits (each door has its own, by its memory) and the error
3
+ // codes every door shares.
4
4
  export type Pixels = Uint8Array | Uint8ClampedArray;
5
5
  export interface Limits { readonly bytes: number; readonly pixels: number; readonly edge: number; }
6
- /** The 16 MiB codestream, 24 million pixels, 16,384 on a side; a larger ask is refused before any work. */
6
+ /** The core's limits: the 16 MiB codestream, 24 million pixels, 16,384 on a side; a larger ask is refused before any
7
+ * work. The photo door takes 40 million pixels and the JPEG carrier 64 million, each by its memory. */
7
8
  export const LIMITS: Limits;
8
- export interface EncodeOptions { /** 1 to 100; 100 (the default) is exact. */ quality?: number; }
9
- export interface Transcoded {
10
- /** A bare JPEG XL codestream, the .jxl file's bytes. */ bytes: Uint8Array;
11
- /** The picture's size as shown, swapped when the Exif orientation turns it. */ width: number; height: number;
12
- /** The Exif orientation, 1 to 8, kept in the JPEG XL header. */ orientation: number;
9
+ export interface EncodeOptions {
10
+ /** 1 to 100; 100 is exact. */ quality?: number;
11
+ /** The samples' colour space, declared in the header; 'srgb' by default. */ colorSpace?: 'srgb' | 'display-p3';
12
+ /** 1 to 9, a whole number, read by /effort, which runs its highest rung at or below it; the core has rung 1 alone. */ effort?: number;
13
13
  }
14
14
  export type ErrorCode = 'JXL_INPUT' | 'JXL_DIMENSIONS' | 'JXL_SIZE' | 'JXL_MEMORY' | 'JXL_JPEG';
15
15
  /** Every refusal is an Error carrying one of the five codes. */
16
16
  export interface EncoderError extends Error { code: ErrorCode; }
17
17
  /** Straight RGBA bytes, row by row, to a bare JPEG XL codestream; quality 100 (the default) is exact. */
18
18
  export function encode(data: Pixels, width: number, height: number, options?: EncodeOptions): Uint8Array;
19
- export function encodeLosslessRGBA(data: Pixels, width: number, height: number): Uint8Array;
20
- export function encodeLossyRGBA(data: Pixels, width: number, height: number, quality?: number): Uint8Array;
21
- /** A JPEG's bytes to JPEG XL, its coefficients carried; a JPEG this path does not take is refused as JXL_JPEG. */
22
- export function transcode(jpeg: Uint8Array): Transcoded;
23
- export function encodeLossless(data: Pixels, width: number, height: number, options?: {shape?: unknown}): Uint8Array;
24
- export function encodeLossy(data: Pixels, width: number, height: number, options: {quality: number; shape?: unknown}): Uint8Array;
25
- export function transcodeJPEG(jpeg: Uint8Array, parsed: unknown): Uint8Array;
26
- export function parseJPEG(jpeg: Uint8Array): unknown;
27
- export function inspectPixels(data: Pixels, width: number, height: number): unknown;
19
+ /** A door's work as steps: iterate for the fraction done, in (0, 1], the last exactly 1; `bytes` holds the stream after
20
+ * the last step. Leaving the loop cancels. `hurry` asks a search to finish with the best candidate it has priced. */
21
+ export interface Job extends Generator<number, Uint8Array, undefined> { bytes: Uint8Array | null; hurry: boolean; }
22
+ /** encode's work and bytes, a group of one pass per step; the call is admitted at once. */
23
+ export function encodeSteps(data: Pixels, width: number, height: number, options?: EncodeOptions): Job;
package/index.mjs CHANGED
@@ -1,94 +1,41 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Rapier JXL: a JPEG XL encoder in pure JavaScript, the one Rapier's page carries. This is the module's face for
3
- // other apps; Rapier's own worker takes the modules directly (images/encoder.mjs). No WebAssembly, no build step,
4
- // no dependency, nothing read or fetched at run time: pixels in, a bare codestream out.
5
- import {inspectPixels, encodeLossless} from './lossless.mjs';
6
- import {encodeLossy} from './lossy.mjs';
7
- import {transcodeJPEG} from './vardct.mjs';
8
- import {parseJPEG} from './jpeg.mjs';
9
- import {LIMITS} from './bits.mjs';
2
+ // Rapier JXL: a JPEG XL encoder in pure JavaScript, the one Rapier's page carries. This is the core: pixels in, a bare
3
+ // codestream out, exact or lossy, alpha exact. A JPEG is carried by jpeg.mjs and a photograph written by photo.mjs;
4
+ // a module's author takes the layers from writer.mjs. No WebAssembly, no build step, no dependency, nothing read or
5
+ // fetched at run time.
6
+ import {inspectPixels, losslessSteps} from './lossless.mjs';
7
+ import {lossySteps} from './lossy.mjs';
8
+ import {LIMITS, complete, part} from './bits.mjs';
9
+ import {admitOptions, admitPixels, job} from './admit.mjs';
10
10
 
11
11
  // What one call takes at most (bits.mjs): the 16 MiB codestream, 24 million pixels, 16,384 on a side. Larger asks are
12
12
  // refused with a coded error before any work, the way a decoder would refuse them after it.
13
13
  export {LIMITS};
14
14
 
15
- const fault = (code, message) => Object.assign(new Error(message), {code});
16
- function admitSize(width, height) {
17
- if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw fault('JXL_INPUT', 'A picture is at least one pixel wide and high, in whole pixels.');
18
- if (width > LIMITS.edge || height > LIMITS.edge) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.edge + ' pixels on a side.');
19
- if (width * height > LIMITS.pixels) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.pixels.toLocaleString('en-US') + ' pixels.');
20
- }
21
- function admitPixels(data, width, height) {
22
- admitSize(width, height);
23
- if (!(data instanceof Uint8Array) && !(data instanceof Uint8ClampedArray)) throw fault('JXL_INPUT', 'Pixels are a Uint8Array or Uint8ClampedArray of RGBA bytes.');
24
- if (data.length !== width * height * 4) throw fault('JXL_INPUT', 'Pixels are width * height * 4 bytes: straight (not premultiplied) RGBA, row by row.');
25
- }
26
- function admitQuality(quality) {
27
- if (typeof quality !== 'number' || !Number.isFinite(quality) || quality < 1 || quality > 100) throw fault('JXL_INPUT', 'Quality is a number from 1 to 100.');
28
- }
29
- function answer(bytes) {
30
- if (bytes.length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
31
- return bytes;
32
- }
33
- function guard(work) {
34
- try { return work(); }
35
- catch (error) { if (error instanceof RangeError && !error.code) throw fault('JXL_MEMORY', 'Not enough memory for this picture.'); throw error; }
36
- }
15
+ // Straight RGBA bytes, row by row, to a codestream. Quality 100 (the default) is exact: an opaque alpha is dropped, a
16
+ // grey picture keeps one channel, up to 2048 colours use a palette when its complete stream is smaller, and direct
17
+ // colour goes through reversible YCoCg with gradient or average prediction chosen by samples. Quality 1 to 99 is lossy
18
+ // modular on libjxl's quality-to-distance curve (not equivalent PSNR), alpha still exact; a picture of few colours is
19
+ // answered exact when that is fewer bytes. The samples are sRGB, or Display P3 with {colorSpace: 'display-p3'}: the
20
+ // header declares the space and the samples are written as they are. The options are read before any work.
21
+ export function encode(data, width, height, options) { return complete(encodeSteps(data, width, height, options)); }
37
22
 
38
- // Exact: every pixel comes back as it went in. An opaque alpha is dropped, a grey picture keeps one channel,
39
- // up to 2048 colours may use a palette when its complete stream wins. Direct colour uses reversible YCoCg
40
- // with sampled gradient/average prediction; compare the complete direct and palette streams by actual bytes.
41
- export function encodeLosslessRGBA(data, width, height) {
23
+ // The same work as a job (admit.mjs): the call admitted at once, then a group of one pass per step, the same bytes.
24
+ export function encodeSteps(data, width, height, options) {
25
+ const {quality, colorSpace} = admitOptions(options);
42
26
  admitPixels(data, width, height);
43
- return guard(() => answer(encodeLossless(data, width, height, {shape: inspectPixels(data, width, height)})));
27
+ return job(pixelSteps(data, width, height, quality, colorSpace));
44
28
  }
45
29
 
46
- // Lossy modular at a quality from 1 to 99, using libjxl's quality-to-distance curve (not equivalent PSNR).
47
- // Alpha stays exact. A picture of few colours is answered
48
- // exact when that is fewer bytes. Quality 100 is the lossless answer.
49
- export function encodeLossyRGBA(data, width, height, quality = 90) {
50
- admitPixels(data, width, height);
51
- admitQuality(quality);
52
- return guard(() => {
53
- const shape = inspectPixels(data, width, height);
54
- if (quality >= 100) return answer(encodeLossless(data, width, height, {shape}));
55
- if (!shape.palette) return answer(encodeLossy(data, width, height, {quality, shape}));
56
- // The exact stream of a few-colour picture is cheap to make (256-pixel sections), so it is made first: a lossy
57
- // attempt that runs out of memory (its planes and Squeeze copies cost eight bytes a pixel and more) still answers.
58
- const exact = encodeLossless(data, width, height, {shape});
59
- let bytes;
60
- try { bytes = encodeLossy(data, width, height, {quality, shape}); }
61
- catch (error) { if (!(error instanceof RangeError) || error.code) throw error; bytes = exact; }
62
- return answer(exact.length <= bytes.length ? exact : bytes);
63
- });
30
+ function* pixelSteps(data, width, height, quality, colorSpace) {
31
+ const shape = inspectPixels(data, width, height);
32
+ if (quality >= 100) return yield* losslessSteps(data, width, height, {shape, colorSpace});
33
+ if (!shape.palette) return yield* lossySteps(data, width, height, {quality, shape, colorSpace});
34
+ // The exact stream of a few-colour picture is cheap to make (256-pixel sections), so it is made first: a lossy
35
+ // attempt that runs out of memory (its planes and Squeeze copies cost eight bytes a pixel and more) still answers.
36
+ const exact = yield* part(losslessSteps(data, width, height, {shape, colorSpace}), 0, 2);
37
+ let bytes;
38
+ try { bytes = yield* part(lossySteps(data, width, height, {quality, shape, colorSpace}), 1, 2); }
39
+ catch (error) { if (!(error instanceof RangeError) || error.code) throw error; bytes = exact; }
40
+ return exact.length <= bytes.length ? exact : bytes;
64
41
  }
65
-
66
- // One call for both: quality 100 (the default) is exact, anything lower is lossy. The options are read before any
67
- // work: a quality that is not a number from 1 to 100 is JXL_INPUT, never a silent choice.
68
- export function encode(data, width, height, options = {}) {
69
- if (options === null || typeof options !== 'object') throw fault('JXL_INPUT', 'Options are an object: {quality}.');
70
- const quality = options.quality === undefined ? 100 : options.quality;
71
- admitQuality(quality);
72
- return quality >= 100 ? encodeLosslessRGBA(data, width, height) : encodeLossyRGBA(data, width, height, quality);
73
- }
74
-
75
- // A JPEG carried as its coefficients into JPEG XL: the quantised DCT coefficients, quantisation tables, colour and
76
- // subsampling kept, only the entropy coding changed, so the picture decodes to the JPEG's own pixels at about a
77
- // fifth fewer bytes. Baseline, extended sequential and progressive scans, with restarts; 8-bit; grey, YCbCr or RGB;
78
- // an Exif orientation kept. Not carried: the JPEG reconstruction data (the JPEG file itself cannot be rebuilt from
79
- // the stream), ICC, Exif beyond the orientation, XMP. A JPEG this path does not take (arithmetic coding, 12-bit,
80
- // lossless, CMYK, a DNL height, a colour profile other than sRGB, a file cut short) is refused as JXL_JPEG; decode
81
- // it and encode its pixels instead.
82
- export function transcode(jpeg) {
83
- if (!(jpeg instanceof Uint8Array) || !jpeg.length) throw fault('JXL_INPUT', 'A JPEG is a non-empty Uint8Array.');
84
- if (jpeg.length > LIMITS.bytes) throw fault('JXL_SIZE', 'A JPEG is at most 16 MiB.');
85
- return guard(() => {
86
- const parsed = parseJPEG(jpeg);
87
- admitSize(parsed.width, parsed.height);
88
- const bytes = answer(transcodeJPEG(jpeg, parsed));
89
- const swapped = parsed.orientation >= 5;
90
- return {bytes, width: swapped ? parsed.height : parsed.width, height: swapped ? parsed.width : parsed.height, orientation: parsed.orientation};
91
- });
92
- }
93
-
94
- export {encodeLossless, encodeLossy, transcodeJPEG, parseJPEG, inspectPixels};