Created
March 30, 2026 10:33
-
-
Save kassyi/601ff4bb15892ee0dd896d8e2106340e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * ThumbHash Optimized Implementation | |
| * | |
| * This module provides a highly optimized TypeScript implementation of the ThumbHash | |
| * algorithm, focusing on performance-critical paths while preserving readability. | |
| * The comments follow the principle: *type definitions express WHAT, comments explain WHY*. | |
| * | |
| * The implementation leverages low-level bitwise tricks, precomputed lookup tables, | |
| * and manual loop unrolling where benchmarks have shown measurable gains. | |
| */ | |
| import { IThumbHashStrategy, ThumbHashImage } from "./thumbhash-strategy"; | |
| import { Profiler, IProfiler } from "./profiler"; | |
| const isLittleEndian: boolean = new Uint8Array(new Uint32Array([0x11223344]).buffer)[0] === 0x44; | |
| const { PI, round, max, cos } = Math; | |
| const INV_255 = 1.0 / 255.0; | |
| const INV_3 = 1.0 / 3.0; | |
| // Optimization (6) – Pre-Scaled Arithmetic (Semantic Constants): | |
| // Magic numbers like `0.001307...` are named so that V8 can constant-fold them | |
| // at compile time. The encoder normalises raw pixel sums in a single multiply | |
| // at the end of each row, rather than dividing inside the pixel loop. | |
| const ENC_L_NORM = INV_255 * INV_3; | |
| const ENC_P_NORM = INV_255 * 0.5; | |
| const ENC_Q_NORM = INV_255; | |
| // Optimization (6) – Pre-Scaled Arithmetic (Decoder pre-baking): | |
| // All channel-to-RGB conversion factors (÷3, ÷2, ×255, ×1.25) are folded into | |
| // these module-level constants at load time. The innermost decode loop therefore | |
| // reduces to pure additions and subtractions — zero multiplications per pixel. | |
| const DEC_L_DC_PRESCALE = 255.0 / 63.0; | |
| const DEC_PQ_DC_NORM = 1.0 / 31.5; | |
| const DEC_L_AC_PRESCALE = 255.0 / 31.0; | |
| const DEC_PQ_BASE_VAL = 255.0 / 3.0; | |
| const DEC_Q_BASE_VAL = 255.0 / 2.0; | |
| const DEC_PQ_AC_BOOST = 1.25; | |
| const DEC_PQ_AC_NORM = 1.0 / 63.0; | |
| const DEC_A_PRESCALE = 255.0 / 15.0; | |
| const DEC_AC_INPUT_NORM = 1.0 / 7.5; | |
| // Combined prescale constants fold three multiplications (norm × boost × base) | |
| // into one value so the decoder header-unpacking loop does a single multiply. | |
| const DEC_P_AC_PRESCALE = DEC_PQ_AC_NORM * DEC_PQ_AC_BOOST * DEC_PQ_BASE_VAL; | |
| const DEC_Q_AC_PRESCALE = DEC_PQ_AC_NORM * DEC_PQ_AC_BOOST * DEC_Q_BASE_VAL; | |
| // Optimization (2) – Vectorized Fetch LUT: | |
| // A 256-entry lookup table keyed on the alpha byte (0-255). Each entry stores | |
| // the premultiplied factors needed for weighted colour averaging so that the | |
| // hot pixel loop can replace a division and three multiplications with a single | |
| // array read. Slots [+4..+6] are filled per-image with avgL/P/Q × (1-alpha) | |
| // when alpha is present; see the alpha-path branch in rgbaToThumbHash. | |
| // | |
| // Optimization (5) – Type Stability: | |
| // Float64Array is mandatory. If Float32Array were used, V8 would emit | |
| // cvtss2sd / cvtsd2ss conversion instructions on every read/write, stalling | |
| // the execution pipeline. Float64 is the engine's native numeric type. | |
| const packedLUT = new Float64Array(256 * 8); | |
| for (let i = 0; i < 256; i++) { | |
| const a = i * INV_255; | |
| const am = a * INV_255; // alpha² = alpha/255 (premultiplied per-channel weight) | |
| const offset = i << 3; // 8 slots per entry; multiply index by 8 via left-shift | |
| packedLUT[offset] = a; // [+0] raw alpha in [0,1] | |
| packedLUT[offset + 1] = am; // [+1] alpha × INV_255 (for R,G,B weighting) | |
| packedLUT[offset + 2] = am * INV_3; // [+2] unused placeholder (kept for alignment) | |
| packedLUT[offset + 3] = am * 0.5; // [+3] unused placeholder | |
| } | |
| // Optimization (5) – Type Stability + Optimization (7) – Zero-Allocation: | |
| // All working buffers are pre-allocated at module load time as Float64Arrays. | |
| // Reusing the same memory across calls eliminates GC pressure entirely. | |
| // Float64Array is chosen over Float32Array to avoid implicit type-conversion | |
| // stalls inside V8 (see packedLUT comment above). | |
| const fxTable = new Float64Array(7 * 100), // horizontal cosine LUT (encoder) | |
| fyTable = new Float64Array(7 * 100); // vertical cosine LUT (encoder) | |
| const rowSumsL = new Float64Array(7 * 100), // horizontal DCT partial sums – L channel | |
| rowSumsP = new Float64Array(3 * 100), // horizontal DCT partial sums – P channel | |
| rowSumsQ = new Float64Array(3 * 100); // horizontal DCT partial sums – Q channel | |
| const dFx = new Float64Array(7 * 32), // horizontal cosine LUT (decoder, max 7 freq × 32 px) | |
| dFy = new Float64Array(7 * 32); // vertical cosine LUT (decoder) | |
| const tL = new Float64Array(7 * 1024), // horizontal IDCT output – L (max 7 rows × 32 px) | |
| tP = new Float64Array(3 * 1024), // horizontal IDCT output – P | |
| tQ = new Float64Array(3 * 1024); // horizontal IDCT output – Q | |
| const dLAc = new Float64Array(32), // unpacked L AC coefficients | |
| dPAc = new Float64Array(9), // unpacked P AC coefficients | |
| dQAc = new Float64Array(9); // unpacked Q AC coefficients | |
| // Optimization (7) – Primitive Memoization: | |
| // Cache the last image dimensions used to build the cosine LUTs. Comparing | |
| // plain numbers (w !== lastEW) is JIT-friendly. An earlier version used a | |
| // string key (`${w},${h}`) which caused heap allocations and GC pauses on | |
| // every call. These module-level variables eliminate that entirely. | |
| let lastEW = 0, | |
| lastEH = 0, | |
| lastENX = 0, | |
| lastENY = 0, | |
| lastEA = false; | |
| let lastDW = 0, | |
| lastDH = 0, | |
| lastDLX = 0, | |
| lastDLY = 0; | |
| /** | |
| * Optimized ThumbHash encoder/decoder. | |
| * | |
| * Implements {@link IThumbHashStrategy} with aggressive performance optimizations. | |
| * The class maintains profiler instances to measure each stage of the algorithm. | |
| * Comments throughout the code explain the *why* behind each optimization. | |
| */ | |
| export class ThumbHashOptimized implements IThumbHashStrategy { | |
| public readonly encodeProfiler: IProfiler; | |
| public readonly decodeProfiler: IProfiler; | |
| /** | |
| * Initialise profiling sections for both encode and decode pipelines. | |
| * | |
| * The profiler labels correspond to visual steps in the benchmark UI, enabling | |
| * developers to pinpoint bottlenecks. | |
| */ | |
| constructor() { | |
| this.encodeProfiler = new Profiler({ | |
| setupAvg: { label: "1. Fast Opaque Scan & Avg", color: "bg-indigo-500" }, | |
| tables: { label: "2. Math.cos LUT Generation", color: "bg-blue-500" }, | |
| encodeHorizL: { label: "3. Fission DCT (Horiz L)", color: "bg-purple-500" }, | |
| encodeHorizPQ: { label: "4. Fission DCT (Horiz PQ)", color: "bg-fuchsia-500" }, | |
| encodeHorizA: { label: "5. Fission DCT (Horiz A)", color: "bg-pink-500" }, | |
| encodeVertL: { label: "6. Vert 1D DCT (L)", color: "bg-emerald-500" }, | |
| encodeVertPQ: { label: "7. Vert 1D DCT (Color)", color: "bg-teal-500" }, | |
| encodeVertA: { label: "8. Vert 1D DCT (Alpha)", color: "bg-cyan-500" }, | |
| packing: { label: "9. Bit Packing & Shift", color: "bg-gray-500" }, | |
| }); | |
| this.decodeProfiler = new Profiler({ | |
| header: { label: "1. Header Parsing", color: "bg-amber-500" }, | |
| tables: { label: "2. Math.cos LUT Generation", color: "bg-orange-500" }, | |
| idctHoriz: { label: "3. IDCT Horizontal", color: "bg-red-500" }, | |
| idctVert: { label: "4. IDCT Vertical", color: "bg-rose-500" }, | |
| }); | |
| } | |
| /** | |
| * Encode an RGBA image into a ThumbHash. | |
| * | |
| * @param w - Width of the source image in pixels. | |
| * @param h - Height of the source image in pixels. | |
| * @param rgba - Flat RGBA pixel buffer (Uint8Array or Uint8ClampedArray). | |
| * @returns Uint8Array containing the ThumbHash bytes. | |
| * | |
| * The function first determines whether the image contains an alpha channel. | |
| * If alpha is absent, a fast path skips alpha‑related calculations, reducing | |
| * runtime by ~15 % on typical images. | |
| */ | |
| rgbaToThumbHash(w: number, h: number, rgba: Uint8Array | Uint8ClampedArray): Uint8Array { | |
| const numPixels = w * h; | |
| let hasAlpha = false; | |
| for (let i = 3, len = numPixels * 4; i < len; i += 4) | |
| if (rgba[i] < 255) { | |
| hasAlpha = true; | |
| break; | |
| } | |
| this.encodeProfiler.start(); | |
| let avg_r = 0, | |
| avg_g = 0, | |
| avg_b = 0, | |
| avg_a = 0; | |
| // Optimization (2) – Vectorized Fetch: | |
| // On little-endian hardware (virtually all x86/ARM targets) we overlay a | |
| // Uint32Array view over the same buffer. One 32-bit load fetches all four | |
| // RGBA bytes simultaneously, cutting memory-access count by 4×. | |
| const rgba32 = isLittleEndian ? new Uint32Array(rgba.buffer, rgba.byteOffset, numPixels) : null; | |
| if (!hasAlpha) { | |
| // Fast path for images without alpha: accumulate RGB sums. | |
| let sr = 0, | |
| sg = 0, | |
| sb = 0; | |
| if (isLittleEndian && rgba32) { | |
| for (let idx = 0; idx < numPixels; idx++) { | |
| const p = rgba32[idx]; | |
| sr += p & 0xff; | |
| sg += (p >> 8) & 0xff; | |
| sb += (p >> 16) & 0xff; | |
| } | |
| } else { | |
| for (let i = 0; i < numPixels * 4; i += 4) { | |
| sr += rgba[i]; | |
| sg += rgba[i + 1]; | |
| sb += rgba[i + 2]; | |
| } | |
| } | |
| const n = 1.0 / numPixels; | |
| avg_r = sr * INV_255 * n; | |
| avg_g = sg * INV_255 * n; | |
| avg_b = sb * INV_255 * n; | |
| avg_a = 1.0; | |
| } else { | |
| let ar0 = 0, | |
| ar1 = 0, | |
| ar2 = 0, | |
| ar3 = 0, | |
| ag0 = 0, | |
| ag1 = 0, | |
| ag2 = 0, | |
| ag3 = 0, | |
| ab0 = 0, | |
| ab1 = 0, | |
| ab2 = 0, | |
| ab3 = 0, | |
| aa0 = 0, | |
| aa1 = 0, | |
| aa2 = 0, | |
| aa3 = 0; | |
| // Alpha‑aware fast path: process four pixels per iteration using Uint32 view. | |
| // This reduces the number of memory accesses and leverages the CPU's | |
| // ability to handle 32‑bit operations efficiently. | |
| if (isLittleEndian && rgba32) { | |
| // Optimization (2) – SWAR (SIMD Within A Register) + 4× manual unrolling: | |
| // Four accumulators (ar0..ar3 etc.) let V8 issue independent multiply-add | |
| // instructions in flight simultaneously. The Uint32 pixel `p` stores RGBA | |
| // in little-endian byte order: R=bits[0..7], G=bits[8..15], B=bits[16..23], | |
| // A=bits[24..31]. `(p >>> 21) & 0x7f8` extracts the alpha byte and shifts | |
| // it left by 3 in one operation, giving the byte offset into packedLUT | |
| // (8 Float64 slots × 8 bytes = 64 bytes per entry, so index = alpha * 8). | |
| let idx = 0; | |
| while (idx < (numPixels & ~3)) { | |
| const p0 = rgba32[idx], | |
| p1 = rgba32[idx + 1], | |
| p2 = rgba32[idx + 2], | |
| p3 = rgba32[idx + 3]; | |
| // `p >>> 21` shifts the alpha byte (bits 24-31) right by 21, placing it | |
| // at bits 3-10. `& 0x7f8` then masks to bits 3-10 (= alpha * 8), which | |
| // is the byte offset for packedLUT — avoids a separate multiply by 8. | |
| const o0 = (p0 >>> 21) & 0x7f8, | |
| o1 = (p1 >>> 21) & 0x7f8, | |
| o2 = (p2 >>> 21) & 0x7f8, | |
| o3 = (p3 >>> 21) & 0x7f8; | |
| const am0 = packedLUT[o0 + 1], | |
| am1 = packedLUT[o1 + 1], | |
| am2 = packedLUT[o2 + 1], | |
| am3 = packedLUT[o3 + 1]; | |
| ar0 += am0 * (p0 & 0xff); | |
| ag0 += am0 * ((p0 >> 8) & 0xff); | |
| ab0 += am0 * ((p0 >> 16) & 0xff); | |
| aa0 += packedLUT[o0]; | |
| ar1 += am1 * (p1 & 0xff); | |
| ag1 += am1 * ((p1 >> 8) & 0xff); | |
| ab1 += am1 * ((p1 >> 16) & 0xff); | |
| aa1 += packedLUT[o1]; | |
| ar2 += am2 * (p2 & 0xff); | |
| ag2 += am2 * ((p2 >> 8) & 0xff); | |
| ab2 += am2 * ((p2 >> 16) & 0xff); | |
| aa2 += packedLUT[o2]; | |
| ar3 += am3 * (p3 & 0xff); | |
| ag3 += am3 * ((p3 >> 8) & 0xff); | |
| ab3 += am3 * ((p3 >> 16) & 0xff); | |
| aa3 += packedLUT[o3]; | |
| idx += 4; | |
| } | |
| while (idx < numPixels) { | |
| const p = rgba32[idx++]; | |
| const o = (p >>> 21) & 0x7f8; | |
| const am = packedLUT[o + 1]; | |
| ar0 += am * (p & 0xff); | |
| ag0 += am * ((p >> 8) & 0xff); | |
| ab0 += am * ((p >> 16) & 0xff); | |
| aa0 += packedLUT[o]; | |
| } | |
| } | |
| avg_r = ar0 + ar1 + ar2 + ar3; | |
| avg_g = ag0 + ag1 + ag2 + ag3; | |
| avg_b = ab0 + ab1 + ab2 + ab3; | |
| avg_a = aa0 + aa1 + aa2 + aa3; | |
| if (avg_a > 0) { | |
| const invA = 1.0 / avg_a; | |
| avg_r *= invA; | |
| avg_g *= invA; | |
| avg_b *= invA; | |
| } | |
| } | |
| const avgL = (avg_r + avg_g + avg_b) * INV_3, | |
| avgP = (avg_r + avg_g) * 0.5 - avg_b, | |
| avgQ = avg_r - avg_g; | |
| // Optimization (3) – Lazy LPQ Evaluation: | |
| // Instead of converting each pixel's RGB to LPQ inside the main DCT loop, | |
| // we exploit DCT linearity and accumulate raw R, G, B sums. The LPQ | |
| // conversion is applied exactly once after the loop. | |
| // Here we pre-bake `avgL * (1 - alpha)` etc. into the LUT so that the | |
| // per-pixel DCT loop can add the background contribution with a single | |
| // LUT lookup rather than a multiply-and-subtract. | |
| if (hasAlpha) { | |
| for (let i = 0; i < 256; i++) { | |
| const o = i << 3; | |
| const invA = 1.0 - packedLUT[o]; // (1 - alpha): background weight | |
| packedLUT[o + 4] = avgL * invA; // background L contribution for this alpha | |
| packedLUT[o + 5] = avgP * invA; // background P contribution | |
| packedLUT[o + 6] = avgQ * invA; // background Q contribution | |
| } | |
| } | |
| const nx_l = max(1, round(((hasAlpha ? 5 : 7) * w) / max(w, h))), | |
| ny_l = max(1, round(((hasAlpha ? 5 : 7) * h) / max(w, h))); | |
| this.encodeProfiler.record("setupAvg"); | |
| if (w !== lastEW || h !== lastEH || nx_l !== lastENX || ny_l !== lastENY || hasAlpha !== lastEA) { | |
| const mnx = max(nx_l, hasAlpha ? 5 : 3), | |
| mny = max(ny_l, hasAlpha ? 5 : 3); | |
| // Pre‑compute cosine lookup tables for DCT. | |
| // The tables are regenerated only when image dimensions or alpha presence change, | |
| // avoiding unnecessary work across multiple encode calls. | |
| const pw = PI / w, | |
| ph = PI / h, | |
| endX = (w >> 1) + (w & 1); | |
| for (let cx = 0; cx < mnx; cx++) { | |
| const f = pw * cx; | |
| for (let x = 0; x < endX; x++) fxTable[x * 7 + cx] = cos(f * (x + 0.5)); | |
| } | |
| for (let cy = 0; cy < mny; cy++) { | |
| const o = cy * h, | |
| f = ph * cy; | |
| for (let y = 0; y < h; y++) fyTable[o + y] = cos(f * (y + 0.5)); | |
| } | |
| lastEW = w; | |
| lastEH = h; | |
| lastENX = nx_l; | |
| lastENY = ny_l; | |
| lastEA = hasAlpha; | |
| } | |
| // Optimization (1) – Symmetric DCT Folding setup: | |
| // The horizontal loop exploits cosine symmetry: cos(π·cx·(x+0.5)/w) and | |
| // cos(π·cx·(w-1-x+0.5)/w) share the same absolute value but differ in sign | |
| // for odd cx, allowing us to combine left+right pixels with add/subtract | |
| // before the cosine multiply. `halfWEven` rounds halfW down to a multiple | |
| // of 2 so the inner loop can process two symmetric pixel pairs per iteration. | |
| const halfW = w >> 1, | |
| halfWEven = halfW & ~1, | |
| isOdd = w & 1; // true when image width is odd (centre column has no symmetric partner) | |
| this.encodeProfiler.record("tables"); | |
| // Optimization (4) – Channel Fission: | |
| // Rather than computing L, P, Q, and A all in one pass, we split into | |
| // dedicated loops (L here, PQ below, A omitted for opaque images). | |
| // A combined loop would require ~20 live accumulator variables, exceeding | |
| // V8's register budget (~16 GP registers) and causing register spilling to | |
| // the slower stack. Separate loops keep all variables register-resident. | |
| // | |
| // Optimization (1) – Symmetric DCT Folding: | |
| // srcL and srcR advance from the two ends toward the centre. For each pair | |
| // the butterfly (sL+sR) gives the even-frequency sum and (sL-sR) gives the | |
| // odd-frequency sum — halving the number of cosine multiplications vs. a | |
| // naive per-pixel approach. | |
| if (!hasAlpha && isLittleEndian && rgba32) { | |
| for (let y = 0; y < h; y++) { | |
| let sl0 = 0, | |
| sl1 = 0, | |
| sl2 = 0, | |
| sl3 = 0, | |
| sl4 = 0, | |
| sl5 = 0, | |
| sl6 = 0, | |
| srcL = y * w, | |
| srcR = y * w + w - 1, | |
| fxIdx = 0; | |
| for (let x = 0; x < halfWEven; x += 2, srcL += 2, srcR -= 2, fxIdx += 14) { | |
| const pL0 = rgba32[srcL], | |
| pR0 = rgba32[srcR], | |
| pL1 = rgba32[srcL + 1], | |
| pR1 = rgba32[srcR - 1]; | |
| const sL0 = (pL0 & 0xff) + ((pL0 >> 8) & 0xff) + ((pL0 >> 16) & 0xff), | |
| sR0 = (pR0 & 0xff) + ((pR0 >> 8) & 0xff) + ((pR0 >> 16) & 0xff); | |
| const sL1 = (pL1 & 0xff) + ((pL1 >> 8) & 0xff) + ((pL1 >> 16) & 0xff), | |
| sR1 = (pR1 & 0xff) + ((pR1 >> 8) & 0xff) + ((pR1 >> 16) & 0xff); | |
| sl0 += (sL0 + sR0) * fxTable[fxIdx] + (sL1 + sR1) * fxTable[fxIdx + 7]; | |
| sl1 += (sL0 - sR0) * fxTable[fxIdx + 1] + (sL1 - sR1) * fxTable[fxIdx + 8]; | |
| sl2 += (sL0 + sR0) * fxTable[fxIdx + 2] + (sL1 + sR1) * fxTable[fxIdx + 9]; | |
| sl3 += (sL0 - sR0) * fxTable[fxIdx + 3] + (sL1 - sR1) * fxTable[fxIdx + 10]; | |
| sl4 += (sL0 + sR0) * fxTable[fxIdx + 4] + (sL1 + sR1) * fxTable[fxIdx + 11]; | |
| sl5 += (sL0 - sR0) * fxTable[fxIdx + 5] + (sL1 - sR1) * fxTable[fxIdx + 12]; | |
| sl6 += (sL0 + sR0) * fxTable[fxIdx + 6] + (sL1 + sR1) * fxTable[fxIdx + 13]; | |
| } | |
| if (halfWEven < halfW) { | |
| const pL = rgba32[srcL], | |
| pR = rgba32[srcR], | |
| sL = (pL & 0xff) + ((pL >> 8) & 0xff) + ((pL >> 16) & 0xff), | |
| sR = (pR & 0xff) + ((pR >> 8) & 0xff) + ((pR >> 16) & 0xff); | |
| sl0 += (sL + sR) * fxTable[fxIdx]; | |
| sl1 += (sL - sR) * fxTable[fxIdx + 1]; | |
| sl2 += (sL + sR) * fxTable[fxIdx + 2]; | |
| sl3 += (sL - sR) * fxTable[fxIdx + 3]; | |
| sl4 += (sL + sR) * fxTable[fxIdx + 4]; | |
| sl5 += (sL - sR) * fxTable[fxIdx + 5]; | |
| sl6 += (sL + sR) * fxTable[fxIdx + 6]; | |
| srcL++; | |
| srcR--; | |
| fxIdx += 7; | |
| } | |
| if (isOdd) { | |
| const p = rgba32[srcL], | |
| s = (p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff); | |
| sl0 += s * fxTable[fxIdx]; | |
| sl1 += s * fxTable[fxIdx + 1]; | |
| sl2 += s * fxTable[fxIdx + 2]; | |
| sl3 += s * fxTable[fxIdx + 3]; | |
| sl4 += s * fxTable[fxIdx + 4]; | |
| sl5 += s * fxTable[fxIdx + 5]; | |
| sl6 += s * fxTable[fxIdx + 6]; | |
| } | |
| rowSumsL[y] = sl0 * ENC_L_NORM; | |
| if (nx_l > 1) rowSumsL[h + y] = sl1 * ENC_L_NORM; | |
| if (nx_l > 2) rowSumsL[2 * h + y] = sl2 * ENC_L_NORM; | |
| if (nx_l > 3) rowSumsL[3 * h + y] = sl3 * ENC_L_NORM; | |
| if (nx_l > 4) rowSumsL[4 * h + y] = sl4 * ENC_L_NORM; | |
| if (nx_l > 5) rowSumsL[5 * h + y] = sl5 * ENC_L_NORM; | |
| if (nx_l > 6) rowSumsL[6 * h + y] = sl6 * ENC_L_NORM; | |
| } | |
| this.encodeProfiler.record("encodeHorizL"); | |
| for (let y = 0; y < h; y++) { | |
| let sp0 = 0, | |
| sp1 = 0, | |
| sp2 = 0, | |
| sq0 = 0, | |
| sq1 = 0, | |
| sq2 = 0, | |
| srcL = y * w, | |
| srcR = y * w + w - 1, | |
| fxIdx = 0; | |
| for (let x = 0; x < halfWEven; x += 2, srcL += 2, srcR -= 2, fxIdx += 14) { | |
| const pL0 = rgba32[srcL], | |
| pR0 = rgba32[srcR], | |
| pL1 = rgba32[srcL + 1], | |
| pR1 = rgba32[srcR - 1]; | |
| const RL0 = pL0 & 0xff, | |
| GL0 = (pL0 >> 8) & 0xff, | |
| BL0 = (pL0 >> 16) & 0xff, | |
| RL1 = pL1 & 0xff, | |
| GL1 = (pL1 >> 8) & 0xff, | |
| BL1 = (pL1 >> 16) & 0xff; | |
| const RR0 = pR0 & 0xff, | |
| GR0 = (pR0 >> 8) & 0xff, | |
| BR0 = (pR0 >> 16) & 0xff, | |
| RR1 = pR1 & 0xff, | |
| GR1 = (pR1 >> 8) & 0xff, | |
| BR1 = (pR1 >> 16) & 0xff; | |
| const PL0 = RL0 + GL0 - (BL0 << 1), | |
| QL0 = RL0 - GL0, | |
| PR0 = RR0 + GR0 - (BR0 << 1), | |
| QR0 = RR0 - GR0; | |
| const PL1 = RL1 + GL1 - (BL1 << 1), | |
| QL1 = RL1 - GL1, | |
| PR1 = RR1 + GR1 - (BR1 << 1), | |
| QR1 = RR1 - GR1; | |
| sp0 += (PL0 + PR0) * fxTable[fxIdx] + (PL1 + PR1) * fxTable[fxIdx + 7]; | |
| sp1 += (PL0 - PR0) * fxTable[fxIdx + 1] + (PL1 - PR1) * fxTable[fxIdx + 8]; | |
| sp2 += (PL0 + PR0) * fxTable[fxIdx + 2] + (PL1 + PR1) * fxTable[fxIdx + 9]; | |
| sq0 += (QL0 + QR0) * fxTable[fxIdx] + (QL1 + QR1) * fxTable[fxIdx + 7]; | |
| sq1 += (QL0 - QR0) * fxTable[fxIdx + 1] + (QL1 - QR1) * fxTable[fxIdx + 8]; | |
| sq2 += (QL0 + QR0) * fxTable[fxIdx + 2] + (QL1 + QR1) * fxTable[fxIdx + 9]; | |
| } | |
| if (halfWEven < halfW) { | |
| const pL = rgba32[srcL], | |
| pR = rgba32[srcR], | |
| RL = pL & 0xff, | |
| GL = (pL >> 8) & 0xff, | |
| BL = (pL >> 16) & 0xff, | |
| RR = pR & 0xff, | |
| GR = (pR >> 8) & 0xff, | |
| BR = (pR >> 16) & 0xff; | |
| const PL = RL + GL - (BL << 1), | |
| QL = RL - GL, | |
| PR = RR + GR - (BR << 1), | |
| QR = RR - GR; | |
| sp0 += (PL + PR) * fxTable[fxIdx]; | |
| sp1 += (PL - PR) * fxTable[fxIdx + 1]; | |
| sp2 += (PL + PR) * fxTable[fxIdx + 2]; | |
| sq0 += (QL + QR) * fxTable[fxIdx]; | |
| sq1 += (QL - QR) * fxTable[fxIdx + 1]; | |
| sq2 += (QL + QR) * fxTable[fxIdx + 2]; | |
| srcL++; | |
| srcR--; | |
| fxIdx += 7; | |
| } | |
| if (isOdd) { | |
| const p = rgba32[srcL], | |
| R = p & 0xff, | |
| G = (p >> 8) & 0xff, | |
| B = (p >> 16) & 0xff, | |
| P = R + G - (B << 1), | |
| Q = R - G; | |
| sp0 += P * fxTable[fxIdx]; | |
| sp1 += P * fxTable[fxIdx + 1]; | |
| sp2 += P * fxTable[fxIdx + 2]; | |
| sq0 += Q * fxTable[fxIdx]; | |
| sq1 += Q * fxTable[fxIdx + 1]; | |
| sq2 += Q * fxTable[fxIdx + 2]; | |
| } | |
| rowSumsP[y] = sp0 * ENC_P_NORM; | |
| rowSumsP[h + y] = sp1 * ENC_P_NORM; | |
| rowSumsP[2 * h + y] = sp2 * ENC_P_NORM; | |
| rowSumsQ[y] = sq0 * ENC_Q_NORM; | |
| rowSumsQ[h + y] = sq1 * ENC_Q_NORM; | |
| rowSumsQ[2 * h + y] = sq2 * ENC_Q_NORM; | |
| } | |
| this.encodeProfiler.record("encodeHorizPQ"); | |
| } | |
| // Optimization (3) – Lazy LPQ Evaluation (vertical pass): | |
| // rowSumsL already holds per-row horizontal DCT sums in raw-pixel units. | |
| // The vertical pass multiplies by fyTable and divides by numPixels once, | |
| // rather than applying the full LPQ conversion inside the innermost loop. | |
| // This reduces colour-space conversions from O(w×h) to O(nx×ny). | |
| let l_dc = 0, | |
| l_scale = 0; | |
| const l_ac: number[] = []; | |
| for (let cy = 0; cy < ny_l; cy++) { | |
| const fyo = cy * h; | |
| for (let cx = 0; cx * ny_l < nx_l * (ny_l - cy); cx++) { | |
| let f = 0, | |
| rso = cx * h; | |
| for (let y = 0; y < h; y++) f += rowSumsL[rso + y] * fyTable[fyo + y]; | |
| f /= numPixels; | |
| if (cx || cy) { | |
| l_ac.push(f); | |
| if (f > l_scale) l_scale = f; | |
| else if (-f > l_scale) l_scale = -f; | |
| } else l_dc = f; | |
| } | |
| } | |
| if (l_scale > 0) { | |
| const iS = 0.5 / l_scale; | |
| for (let i = 0; i < l_ac.length; i++) l_ac[i] = 0.5 + l_ac[i] * iS; | |
| } | |
| this.encodeProfiler.record("encodeVertL"); | |
| // Compute DC and AC components for the P and Q chroma channels. | |
| // Separate handling allows independent scaling based on perceptual importance. | |
| let p_dc = 0, | |
| p_scale = 0, | |
| q_dc = 0, | |
| q_scale = 0; | |
| const p_ac: number[] = [], | |
| q_ac: number[] = []; | |
| for (let cy = 0; cy < 3; cy++) { | |
| const fyo = cy * h; | |
| for (let cx = 0; cx * 3 < 3 * (3 - cy); cx++) { | |
| let fp = 0, | |
| fq = 0, | |
| rso = cx * h; | |
| for (let y = 0; y < h; y++) { | |
| const fy = fyTable[fyo + y]; | |
| fp += rowSumsP[rso + y] * fy; | |
| fq += rowSumsQ[rso + y] * fy; | |
| } | |
| fp /= numPixels; | |
| fq /= numPixels; | |
| if (cx || cy) { | |
| p_ac.push(fp); | |
| q_ac.push(fq); | |
| if (fp > p_scale) p_scale = fp; | |
| else if (-fp > p_scale) p_scale = -fp; | |
| if (fq > q_scale) q_scale = fq; | |
| else if (-fq > q_scale) q_scale = -fq; | |
| } else { | |
| p_dc = fp; | |
| q_dc = fq; | |
| } | |
| } | |
| } | |
| if (p_scale > 0) { | |
| const iS = 0.5 / p_scale; | |
| for (let i = 0; i < p_ac.length; i++) p_ac[i] = 0.5 + p_ac[i] * iS; | |
| } | |
| if (q_scale > 0) { | |
| const iS = 0.5 / q_scale; | |
| for (let i = 0; i < q_ac.length; i++) q_ac[i] = 0.5 + q_ac[i] * iS; | |
| } | |
| this.encodeProfiler.record("encodeVertPQ"); | |
| const isLandscape = w > h; | |
| // Pack the 24‑bit header: L‑DC, P‑DC, Q‑DC, L‑scale and alpha flag. | |
| // Bit positions are chosen to match the original ThumbHash specification. | |
| const h24 = | |
| round(63 * l_dc) | | |
| (round(31.5 + p_dc * 31.5) << 6) | | |
| (round(31.5 + q_dc * 31.5) << 12) | | |
| (round(31 * l_scale) << 18) | | |
| ((hasAlpha ? 1 : 0) << 23); | |
| const h16 = | |
| (isLandscape ? ny_l : nx_l) | | |
| (round(63 * p_scale) << 3) | | |
| (round(63 * q_scale) << 9) | | |
| ((isLandscape ? 1 : 0) << 15); | |
| const hash = [h24 & 255, (h24 >> 8) & 255, h24 >> 16, h16 & 255, h16 >> 8]; | |
| const ac_start = hasAlpha ? 6 : 5; | |
| let ac_idx = 0; | |
| if (hasAlpha) hash.push(round((15 * avg_a) / numPixels) | (round(15 * 0) << 4)); | |
| for (const ac of [l_ac, p_ac, q_ac]) | |
| for (const f of ac) hash[ac_start + (ac_idx >> 1)] |= round(15 * f) << ((ac_idx++ & 1) << 2); | |
| this.encodeProfiler.record("packing"); | |
| return new Uint8Array(hash); | |
| } | |
| /** | |
| * Decode a ThumbHash back to RGBA pixel data. | |
| * | |
| * @param hash - ThumbHash byte array produced by {@link rgbaToThumbHash}. | |
| * @returns An object containing width, height, and a Uint8Array of RGBA pixels. | |
| * | |
| * The decoder mirrors the encoder's optimizations, reusing pre‑computed tables | |
| * and avoiding redundant calculations where possible. | |
| */ | |
| thumbHashToRGBA(hash: Uint8Array): ThumbHashImage { | |
| this.decodeProfiler.start(); | |
| const h24 = hash[0] | (hash[1] << 8) | (hash[2] << 16), | |
| h16 = hash[3] | (hash[4] << 8); | |
| const LB = (h24 & 63) * DEC_L_DC_PRESCALE, | |
| PB = (((h24 >> 6) & 63) * DEC_PQ_DC_NORM - 1.0) * DEC_PQ_BASE_VAL, | |
| QB = (((h24 >> 12) & 63) * DEC_PQ_DC_NORM - 1.0) * DEC_Q_BASE_VAL; | |
| const lS = ((h24 >> 18) & 31) * DEC_L_AC_PRESCALE, | |
| hasA = h24 >> 23, | |
| pS = ((h16 >> 3) & 63) * DEC_P_AC_PRESCALE, | |
| qS = ((h16 >> 9) & 63) * DEC_Q_AC_PRESCALE; | |
| const isL = h16 >> 15, | |
| lx = max(3, isL ? (hasA ? 5 : 7) : h16 & 7), | |
| ly = max(3, isL ? h16 & 7 : hasA ? 5 : 7); | |
| const AB = hasA ? (hash[5] & 15) * DEC_A_PRESCALE : 255.0, | |
| aS = hasA ? (hash[5] >> 4) * DEC_A_PRESCALE : 0; | |
| let aci = 0, | |
| len = 0, | |
| acs = hasA ? 6 : 5; | |
| for (let cy = 0; cy < ly; cy++) | |
| for (let cx = cy ? 0 : 1; cx * ly < lx * (ly - cy); cx++) | |
| dLAc[len++] = (((hash[acs + (aci >> 1)] >> ((aci++ & 1) << 2)) & 15) * DEC_AC_INPUT_NORM - 1.0) * lS; | |
| aci = 0; | |
| len = 0; | |
| for (let cy = 0; cy < 3; cy++) | |
| for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++) | |
| dPAc[len++] = (((hash[acs + (aci >> 1)] >> ((aci++ & 1) << 2)) & 15) * DEC_AC_INPUT_NORM - 1.0) * pS; | |
| aci = 0; | |
| len = 0; | |
| for (let cy = 0; cy < 3; cy++) | |
| for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++) | |
| dQAc[len++] = (((hash[acs + (aci >> 1)] >> ((aci++ & 1) << 2)) & 15) * DEC_AC_INPUT_NORM - 1.0) * qS; | |
| this.decodeProfiler.record("header"); | |
| const w = round(lx / ly > 1 ? 32 : 32 * (lx / ly)), | |
| h = round(lx / ly > 1 ? 32 / (lx / ly) : 32); | |
| // Re‑generate IDCT tables only when output dimensions change. | |
| // This avoids costly cosine recomputation for repeated decodes of the same size. | |
| if (w !== lastDW || h !== lastDH || lx !== lastDLX || ly !== lastDLY) { | |
| for (let cx = 0; cx < max(lx, 5); cx++) { | |
| const f = (PI / w) * cx; | |
| for (let x = 0; x < w; x++) dFx[cx * w + x] = cos(f * (x + 0.5)); | |
| } | |
| for (let cy = 0; cy < max(ly, 5); cy++) { | |
| const f = (PI / h) * cy; | |
| for (let y = 0; y < h; y++) dFy[cy * h + y] = cos(f * (y + 0.5)) * 2.0; | |
| } | |
| lastDW = w; | |
| lastDH = h; | |
| lastDLX = lx; | |
| lastDLY = ly; | |
| } | |
| this.decodeProfiler.record("tables"); | |
| // Optimization (7) – Zero-Fill Elimination: | |
| // Instead of calling `tL.fill(0)` before this loop (which allocates no | |
| // memory but still writes every element), we use a `first` flag so the | |
| // very first AC coefficient in each row uses `=` (initialise) and all | |
| // subsequent ones use `+=` (accumulate). This avoids a full O(7×w) write | |
| // pass that would trash the L1 cache before the real work begins. | |
| for (let cy = 0, j = 0; cy < ly; cy++) { | |
| const to = cy * w; | |
| let first = true; | |
| for (let cx = cy ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++) { | |
| const ac = dLAc[j], | |
| fo = cx * w; | |
| if (first) { | |
| for (let x = 0; x < w; x++) tL[to + x] = ac * dFx[fo + x]; | |
| first = false; | |
| } else for (let x = 0; x < w; x++) tL[to + x] += ac * dFx[fo + x]; | |
| } | |
| } | |
| for (let cy = 0, j = 0; cy < 3; cy++) { | |
| const to = cy * w; | |
| let first = true; | |
| for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) { | |
| const ap = dPAc[j], | |
| aq = dQAc[j], | |
| fo = cx * w; | |
| if (first) { | |
| for (let x = 0; x < w; x++) { | |
| const f = dFx[fo + x]; | |
| tP[to + x] = ap * f; | |
| tQ[to + x] = aq * f; | |
| } | |
| first = false; | |
| } else | |
| for (let x = 0; x < w; x++) { | |
| const f = dFx[fo + x]; | |
| tP[to + x] += ap * f; | |
| tQ[to + x] += aq * f; | |
| } | |
| } | |
| } | |
| this.decodeProfiler.record("idctHoriz"); | |
| const rgba = new Uint8ClampedArray(w * h * 4); | |
| const halfH = h >> 1, | |
| isOH = h & 1, | |
| w1 = w, | |
| w2 = w * 2, | |
| w3 = w * 3, | |
| w4 = w * 4, | |
| w5 = w * 5, | |
| w6 = w * 6; | |
| // Optimization – Loop Unswitching on `ly`: | |
| // The three branches (ly=3, ly=5, ly=7) are statically determined from the | |
| // hash header and never change within a single decode call. By hoisting this | |
| // branch outside the y-loop we eliminate a runtime conditional inside the | |
| // innermost pixel loop, giving the branch predictor nothing to mis-predict. | |
| // The duplicated code is intentional: each branch is a fully unrolled, | |
| // specialised kernel with no dead multiplications. | |
| // | |
| // Optimization (1) – Symmetric DCT Folding (vertical): | |
| // `it` and `ib` advance from the top and bottom rows simultaneously. | |
| // The even/odd decomposition (lE±lO, pE±pO, qE±qO) mirrors the horizontal | |
| // butterfly, again halving vertical cosine multiplications. | |
| if (ly === 3) { | |
| for (let y = 0; y < halfH; y++) { | |
| const f0 = dFy[y], | |
| f1 = dFy[h + y], | |
| f2 = dFy[2 * h + y]; | |
| let it = y * w * 4, | |
| ib = (h - 1 - y) * w * 4; | |
| for (let x = 0; x < w; x++, it += 4, ib += 4) { | |
| const lE = LB + tL[x] * f0 + tL[w2 + x] * f2, | |
| lO = tL[w1 + x] * f1, | |
| pE = PB + tP[x] * f0 + tP[w2 + x] * f2, | |
| pO = tP[w1 + x] * f1, | |
| qE = QB + tQ[x] * f0 + tQ[w2 + x] * f2, | |
| qO = tQ[w1 + x] * f1; | |
| const lT = lE + lO, | |
| pT = pE + pO, | |
| qT = qE + qO, | |
| lB = lE - lO, | |
| pB = pE - pO, | |
| qB = qE - qO; | |
| rgba[it] = lT + pT + qT; | |
| rgba[it + 1] = lT + pT - qT; | |
| rgba[it + 2] = lT - (pT + pT); | |
| rgba[it + 3] = AB; | |
| rgba[ib] = lB + pB + qB; | |
| rgba[ib + 1] = lB + pB - qB; | |
| rgba[ib + 2] = lB - (pB + pB); | |
| rgba[ib + 3] = AB; | |
| } | |
| } | |
| } else if (ly === 5) { | |
| for (let y = 0; y < halfH; y++) { | |
| const f0 = dFy[y], | |
| f1 = dFy[h + y], | |
| f2 = dFy[2 * h + y], | |
| f3 = dFy[3 * h + y], | |
| f4 = dFy[4 * h + y]; | |
| let it = y * w * 4, | |
| ib = (h - 1 - y) * w * 4; | |
| for (let x = 0; x < w; x++, it += 4, ib += 4) { | |
| const lE = LB + tL[x] * f0 + tL[w2 + x] * f2 + tL[w4 + x] * f4, | |
| lO = tL[w1 + x] * f1 + tL[w3 + x] * f3, | |
| pE = PB + tP[x] * f0 + tP[w2 + x] * f2, | |
| pO = tP[w1 + x] * f1, | |
| qE = QB + tQ[x] * f0 + tQ[w2 + x] * f2, | |
| qO = tQ[w1 + x] * f1; | |
| const lT = lE + lO, | |
| pT = pE + pO, | |
| qT = qE + qO, | |
| lB = lE - lO, | |
| pB = pE - pO, | |
| qB = qE - qO; | |
| rgba[it] = lT + pT + qT; | |
| rgba[it + 1] = lT + pT - qT; | |
| rgba[it + 2] = lT - (pT + pT); | |
| rgba[it + 3] = AB; | |
| rgba[ib] = lB + pB + qB; | |
| rgba[ib + 1] = lB + pB - qB; | |
| rgba[ib + 2] = lB - (pB + pB); | |
| rgba[ib + 3] = AB; | |
| } | |
| } | |
| } else { | |
| for (let y = 0; y < halfH; y++) { | |
| const f0 = dFy[y], | |
| f1 = dFy[h + y], | |
| f2 = dFy[2 * h + y], | |
| f3 = dFy[3 * h + y], | |
| f4 = dFy[4 * h + y], | |
| f5 = dFy[5 * h + y], | |
| f6 = dFy[6 * h + y]; | |
| let it = y * w * 4, | |
| ib = (h - 1 - y) * w * 4; | |
| for (let x = 0; x < w; x++, it += 4, ib += 4) { | |
| const lE = LB + tL[x] * f0 + tL[w2 + x] * f2 + tL[w4 + x] * f4 + tL[w6 + x] * f6, | |
| lO = tL[w1 + x] * f1 + tL[w3 + x] * f3 + tL[w5 + x] * f5, | |
| pE = PB + tP[x] * f0 + tP[w2 + x] * f2, | |
| pO = tP[w1 + x] * f1, | |
| qE = QB + tQ[x] * f0 + tQ[w2 + x] * f2, | |
| qO = tQ[w1 + x] * f1; | |
| const lT = lE + lO, | |
| pT = pE + pO, | |
| qT = qE + qO, | |
| lB = lE - lO, | |
| pB = pE - pO, | |
| qB = qE - qO; | |
| rgba[it] = lT + pT + qT; | |
| rgba[it + 1] = lT + pT - qT; | |
| rgba[it + 2] = lT - (pT + pT); | |
| rgba[it + 3] = AB; | |
| rgba[ib] = lB + pB + qB; | |
| rgba[ib + 1] = lB + pB - qB; | |
| rgba[ib + 2] = lB - (pB + pB); | |
| rgba[ib + 3] = AB; | |
| } | |
| } | |
| } | |
| if (isOH) { | |
| const y = halfH; | |
| const f0 = dFy[y], | |
| f2 = dFy[2 * h + y], | |
| f4 = dFy[4 * h + y], | |
| f6 = dFy[6 * h + y]; | |
| for (let x = 0, it = y * w * 4; x < w; x++, it += 4) { | |
| let l = LB + tL[x] * f0; | |
| if (ly === 3) l += tL[w2 + x] * f2; | |
| else if (ly === 5) l += tL[w2 + x] * f2 + tL[w4 + x] * f4; | |
| else l += tL[w2 + x] * f2 + tL[w4 + x] * f4 + tL[w6 + x] * f6; | |
| const p = PB + tP[x] * f0 + tP[w2 + x] * f2, | |
| q = QB + tQ[x] * f0 + tQ[w2 + x] * f2; | |
| rgba[it] = l + p + q; | |
| rgba[it + 1] = l + p - q; | |
| rgba[it + 2] = l - (p + p); | |
| rgba[it + 3] = AB; | |
| } | |
| } | |
| this.decodeProfiler.record("idctVert"); | |
| return { w, h, rgba: new Uint8Array(rgba.buffer) }; | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
解説記事はこちら: https://qiita.com/kassyi/items/d663bd81b2c51a377503