diff --git a/packages/dicom-codec/src/codecs/codecFactory.js b/packages/dicom-codec/src/codecs/codecFactory.js
index 25fc2d5..7085a27 100644
--- a/packages/dicom-codec/src/codecs/codecFactory.js
+++ b/packages/dicom-codec/src/codecs/codecFactory.js
@@ -249,11 +249,20 @@ function encode(context, codecConfig, imageFrame, imageInfo, options = {}) {
* @returns Object containing decoded image frame and imageInfo (current) data
*
*/
-function decode(context, codecConfig, imageFrame, imageInfo) {
+function decode(context, codecConfig, imageFrame, imageInfo, options = {}) {
if (!imageFrame?.length) {
throw new Error("Image frame not defined for decoding");
}
- const decoderInstance = new codecConfig.Decoder();
+ const reuseDecoder = options.reuseDecoder === true;
+ let decoderInstance;
+ if (reuseDecoder) {
+ if (!codecConfig.reusedDecoder) {
+ codecConfig.reusedDecoder = new codecConfig.Decoder();
+ }
+ decoderInstance = codecConfig.reusedDecoder;
+ } else {
+ decoderInstance = new codecConfig.Decoder();
+ }
const { length } = imageFrame;
// get pointer to the source/encoded bit stream buffer in WASM memory
@@ -277,8 +286,9 @@ function decode(context, codecConfig, imageFrame, imageInfo) {
// get information about the decoded image
const decodedImageInfo = decoderInstance.getFrameInfo();
- // cleanup allocated memory
- decoderInstance.delete();
+ if (!reuseDecoder) {
+ decoderInstance.delete();
+ }
const processInfo = {
duration: context.timer.getDuration(),
diff --git a/packages/dicom-codec/src/codecs/htj2k.js b/packages/dicom-codec/src/codecs/htj2k.js
index 8f830a0..d294c46 100644
--- a/packages/dicom-codec/src/codecs/htj2k.js
+++ b/packages/dicom-codec/src/codecs/htj2k.js
@@ -29,7 +29,9 @@ async function decode(imageFrame, imageInfo) {
codecWasmModule,
codecWrapper.decoderName,
(context) => {
- return codecFactory.decode(context, codecWrapper, imageFrame, imageInfo);
+ return codecFactory.decode(context, codecWrapper, imageFrame, imageInfo, {
+ reuseDecoder: true,
+ });
}
);
}
diff --git a/packages/openjphjs/bench/decode.bench.js b/packages/openjphjs/bench/decode.bench.js
index 2c65bfb..101f6d5 100644
--- a/packages/openjphjs/bench/decode.bench.js
+++ b/packages/openjphjs/bench/decode.bench.js
@@ -8,16 +8,11 @@
// "warm" = a shared decoder/encoder that has already done 5 decode/encode
// passes at module load (untimed). The bench body is the 6th+ call.
//
-// Important caveat for HTJ2K: cornerstone3D's decodeHTJ2K.ts:69 actually
-// creates a fresh `new HTJ2KDecoder()` for every frame (a comment in
-// that file notes reuse is "much slower for some reason"). So for
-// HTJ2K specifically, the production-cost approximation is:
+// HTJ2K production path (dicom-codec / cornerstone codecs) reuses a single
+// HTJ2KDecoder across frames. Per-frame cost ≈ decode — warm.
//
-// per-frame cost ≈ instantiate+destroy HTJ2KDecoder + decode — cold
-//
-// The "warm" HTJ2K decode bench remains useful for regression detection
-// on the openjph decoder kernel itself, but isn't what cornerstone3D
-// actually pays per frame.
+// "cold" benches still model a fresh decoder per frame for lifecycle regressions.
+// "warm" benches model the reused-decoder production path.
//
// Bench bodies are symmetric between cold and warm — the only difference
// is module-load state, so the cold/warm delta isolates first-call
diff --git a/packages/openjphjs/extern/openjph b/packages/openjphjs/extern/openjph
index e01c7b7..d964a6e 160000
--- a/packages/openjphjs/extern/openjph
+++ b/packages/openjphjs/extern/openjph
@@ -1 +1 @@
-Subproject commit e01c7b7f9e7ecbb15cf13bb45661c9a41ab7fec6
+Subproject commit d964a6e9e0f69c4dbd506171196c030ef927c28c
diff --git a/packages/openjphjs/src/CMakeLists.txt b/packages/openjphjs/src/CMakeLists.txt
index 83a9abe..4ab4f54 100644
--- a/packages/openjphjs/src/CMakeLists.txt
+++ b/packages/openjphjs/src/CMakeLists.txt
@@ -1,7 +1,12 @@
add_executable(openjphjs jslib.cpp)
-target_link_libraries(openjphjs PRIVATE openjphsimd)
+target_link_libraries(openjphjs PRIVATE openjph)
+target_include_directories(
+ openjphjs
+ PRIVATE
+ ${PROJECT_SOURCE_DIR}/extern/openjph/src/core/openjph
+)
target_compile_options(openjphjs PRIVATE -DOJPH_ENABLE_WASM_SIMD -msimd128)
target_compile_features(openjphjs PUBLIC cxx_std_11)
set_target_properties(
@@ -11,7 +16,7 @@ set_target_properties(
-O3 \
-s WASM=1 \
--bind \
- -s DISABLE_EXCEPTION_CATCHING=1 \
+ -s DISABLE_EXCEPTION_CATCHING=0 \
-s ASSERTIONS=0 \
-s MODULARIZE=1 \
-s NO_EXIT_RUNTIME=1 \
diff --git a/packages/openjphjs/src/HTJ2KDecoder.hpp b/packages/openjphjs/src/HTJ2KDecoder.hpp
index dcce11a..fce92b1 100644
--- a/packages/openjphjs/src/HTJ2KDecoder.hpp
+++ b/packages/openjphjs/src/HTJ2KDecoder.hpp
@@ -118,10 +118,17 @@ class HTJ2KDecoder
///
void readHeader()
{
- ojph::codestream codestream;
- ojph::mem_infile mem_file;
- mem_file.open(pEncoded_->data(), pEncoded_->size());
- readHeader_(codestream, mem_file);
+ try
+ {
+ ojph::codestream codestream;
+ ojph::mem_infile mem_file;
+ mem_file.open(pEncoded_->data(), pEncoded_->size());
+ readHeader_(codestream, mem_file);
+ }
+ catch (const std::exception &e)
+ {
+ OJPH_INFO(0x00010020, "readHeader failed: %s", e.what());
+ }
}
///
@@ -148,11 +155,18 @@ class HTJ2KDecoder
///
void decode()
{
- ojph::codestream codestream;
- ojph::mem_infile mem_file;
- mem_file.open(pEncoded_->data(), pEncoded_->size());
- readHeader_(codestream, mem_file);
- decode_(codestream, frameInfo_, 0);
+ try
+ {
+ ojph::codestream codestream;
+ ojph::mem_infile mem_file;
+ mem_file.open(pEncoded_->data(), pEncoded_->size());
+ readHeader_(codestream, mem_file);
+ decode_(codestream, frameInfo_, 0);
+ }
+ catch (const std::exception &e)
+ {
+ OJPH_INFO(0x00010021, "decode failed (likely truncated stream): %s", e.what());
+ }
}
///
@@ -163,11 +177,18 @@ class HTJ2KDecoder
///
void decodeSubResolution(size_t decompositionLevel)
{
- ojph::codestream codestream;
- ojph::mem_infile mem_file;
- mem_file.open(pEncoded_->data(), pEncoded_->size());
- readHeader_(codestream, mem_file);
- decode_(codestream, frameInfo_, decompositionLevel);
+ try
+ {
+ ojph::codestream codestream;
+ ojph::mem_infile mem_file;
+ mem_file.open(pEncoded_->data(), pEncoded_->size());
+ readHeader_(codestream, mem_file);
+ decode_(codestream, frameInfo_, decompositionLevel);
+ }
+ catch (const std::exception &e)
+ {
+ OJPH_INFO(0x00010022, "decodeSubResolution failed: %s", e.what());
+ }
}
///
diff --git a/packages/openjphjs/test/node/index.js b/packages/openjphjs/test/node/index.js
index 25de3a5..da8a7c7 100644
--- a/packages/openjphjs/test/node/index.js
+++ b/packages/openjphjs/test/node/index.js
@@ -2,103 +2,119 @@
// SPDX-License-Identifier: MIT
let openjphjs = require("../../dist/openjphjs.js")
+const assert = require("assert")
const fs = require("fs")
+const path = require("path")
-function decode(openjph, encodedImagePath, iterations = 100) {
- const encodedBitStream = fs.readFileSync(encodedImagePath)
- const decoder = new openjph.HTJ2KDecoder()
- const encodedBuffer = decoder.getEncodedBuffer(encodedBitStream.length)
- encodedBuffer.set(encodedBitStream)
+const rawPath = path.resolve(__dirname, "../fixtures/raw/CT1.RAW")
+const frameInfo = {
+ width: 512,
+ height: 512,
+ bitsPerSample: 16,
+ componentCount: 1,
+ isSigned: true,
+ isUsingColorTransform: false,
+}
+
+function encodeFrame(openjph, rawBytes, imageFrame, options = {}) {
+ const encoder = new openjph.HTJ2KEncoder()
+ const decodedBytes = encoder.getDecodedBuffer(imageFrame)
+ decodedBytes.set(rawBytes)
- // do the actual benchmark
- const beginDecode = process.hrtime()
- for (var i = 0; i < iterations; i++) {
- decoder.decode()
+ if (typeof options.lossless === "boolean") {
+ encoder.setQuality(options.lossless, options.quantizationStep || 0)
}
- const decodeDuration = process.hrtime(beginDecode) // hrtime returns seconds/nanoseconds tuple
- const decodeDurationInSeconds =
- decodeDuration[0] + decodeDuration[1] / 1000000000
- // Print out information about the decode
- console.log(
- "Decode of " +
- encodedImagePath +
- " took " +
- (decodeDurationInSeconds / iterations) * 1000 +
- " ms"
- )
- const frameInfo = decoder.getFrameInfo()
- console.log(" frameInfo = ", frameInfo)
- console.log(" imageOffset = ", decoder.getImageOffset())
- var decoded = decoder.getDecodedBuffer()
- console.log(" decoded length = ", decoded.length)
+ encoder.encode()
+ const encoded = Uint8Array.from(encoder.getEncodedBuffer())
+ encoder.delete()
+ return encoded
+}
+function decodeFrame(openjph, encodedBytes) {
+ const decoder = new openjph.HTJ2KDecoder()
+ const encodedBuffer = decoder.getEncodedBuffer(encodedBytes.length)
+ encodedBuffer.set(encodedBytes)
+ decoder.decode()
+ const decoded = Uint8Array.from(decoder.getDecodedBuffer())
+ const decodedFrameInfo = decoder.getFrameInfo()
decoder.delete()
+ return { decoded, decodedFrameInfo }
}
-function encode(
- openjph,
- pathToUncompressedImageFrame,
- imageFrame,
- pathToJ2CFile,
- iterations = 100
-) {
- const uncompressedImageFrame = fs.readFileSync(pathToUncompressedImageFrame)
- console.log("uncompressedImageFrame.length:", uncompressedImageFrame.length)
- const encoder = new openjph.HTJ2KEncoder()
- const decodedBytes = encoder.getDecodedBuffer(imageFrame)
- decodedBytes.set(uncompressedImageFrame)
- //encoder.setQuality(false, 0.001);
+function meanAbsoluteErrorI16(originalBytes, decodedBytes) {
+ assert.strictEqual(
+ decodedBytes.length,
+ originalBytes.length,
+ "Decoded byte length mismatch"
+ )
+
+ const original = new Int16Array(
+ originalBytes.buffer,
+ originalBytes.byteOffset,
+ originalBytes.byteLength / Int16Array.BYTES_PER_ELEMENT
+ )
+ const decoded = new Int16Array(
+ decodedBytes.buffer,
+ decodedBytes.byteOffset,
+ decodedBytes.byteLength / Int16Array.BYTES_PER_ELEMENT
+ )
- const encodeBegin = process.hrtime()
- for (var i = 0; i < iterations; i++) {
- encoder.encode()
+ let absoluteErrorSum = 0
+ for (let i = 0; i < original.length; i++) {
+ absoluteErrorSum += Math.abs(original[i] - decoded[i])
}
- const encodeDuration = process.hrtime(encodeBegin)
- const encodeDurationInSeconds =
- encodeDuration[0] + encodeDuration[1] / 1000000000
- // print out information about the encode
- console.log(
- "Encode of " +
- pathToUncompressedImageFrame +
- " took " +
- (encodeDurationInSeconds / iterations) * 1000 +
- " ms"
+ return absoluteErrorSum / original.length
+}
+
+function runLossyRoundTripTest(openjph, rawBytes) {
+ const encodedLossy = encodeFrame(openjph, rawBytes, frameInfo, {
+ lossless: false,
+ quantizationStep: 8,
+ })
+ const { decoded, decodedFrameInfo } = decodeFrame(openjph, encodedLossy)
+ const mae = meanAbsoluteErrorI16(rawBytes, decoded)
+
+ assert.strictEqual(decodedFrameInfo.width, frameInfo.width)
+ assert.strictEqual(decodedFrameInfo.height, frameInfo.height)
+ console.log(`Heavy lossy round-trip MAE: ${mae.toFixed(2)}`)
+ assert.ok(mae < 1500, `Heavy lossy MAE too large: ${mae}`)
+}
+
+function runTruncatedLosslessDecodeTest(openjph, rawBytes) {
+ const encodedLossless = encodeFrame(openjph, rawBytes, frameInfo, {
+ lossless: true,
+ quantizationStep: 0,
+ })
+ const truncatedSize = Math.min(10 * 1024, encodedLossless.length)
+ const truncatedBitstream = encodedLossless.slice(0, truncatedSize)
+ const { decoded, decodedFrameInfo } = decodeFrame(openjph, truncatedBitstream)
+ assert.ok(
+ decoded.length > 0,
+ `Expected a minimally decodable image from ${truncatedSize} bytes`
)
- const encodedBytes = encoder.getEncodedBuffer()
- console.log(" encoded length=", encodedBytes.length)
+ const mae = meanAbsoluteErrorI16(rawBytes, decoded)
- if (pathToJ2CFile) {
- //fs.writeFileSync(pathToJ2CFile, encodedBytes);
- }
- // cleanup allocated memory
- encoder.delete()
+ assert.strictEqual(decodedFrameInfo.width, frameInfo.width)
+ assert.strictEqual(decodedFrameInfo.height, frameInfo.height)
+ console.log(
+ `Truncated lossless decode MAE (${truncatedSize} bytes kept): ${mae.toFixed(2)}`
+ )
+ assert.ok(mae > 10, `Expected degradation with truncated stream, MAE: ${mae}`)
+ assert.ok(mae < 300, `Truncated lossless MAE too large: ${mae}`)
}
function main(openjph) {
- decode(openjph, "../fixtures/j2c/CT2.j2c")
- decode(openjph, "../../extern/OpenJPH/subprojects/js/html/test.j2c")
-
- encode(
- openjph,
- "../fixtures/raw/CT1.RAW",
- {
- width: 512,
- height: 512,
- bitsPerSample: 16,
- componentCount: 1,
- isSigned: true,
- },
- "../fixtures/j2c/CT1.j2c"
- )
+ const rawBytes = fs.readFileSync(rawPath)
+ runLossyRoundTripTest(openjph, rawBytes)
+ runTruncatedLosslessDecodeTest(openjph, rawBytes)
+ console.log("openjphjs node tests passed")
}
if (typeof openjphjs !== "undefined") {
- console.log("testing openjphjs...")
- openjphjs().then(function (openjphwasm) {
- main(openjphwasm)
- })
+ console.log("running openjphjs node tests...")
+ openjphjs().then(main)
} else {
- console.warn("openjphjs isn't defined");
+ console.warn("openjphjs isn't defined")
}
diff --git a/packages/openjphjs/test/truncated.test.js b/packages/openjphjs/test/truncated.test.js
new file mode 100644
index 0000000..7d4e07f
--- /dev/null
+++ b/packages/openjphjs/test/truncated.test.js
@@ -0,0 +1,287 @@
+import { beforeAll, describe, expect, it } from "vitest"
+import { existsSync, readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import { dirname, resolve } from "node:path"
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const distDir = resolve(__dirname, "../dist")
+const fixturesDir = resolve(__dirname, "fixtures")
+
+const ct1Encoded = readFileSync(resolve(fixturesDir, "j2c/CT1.j2c"))
+const ct1Raw = readFileSync(resolve(fixturesDir, "raw/CT1.RAW"))
+
+const frameInfo = {
+ width: 512,
+ height: 512,
+ bitsPerSample: 16,
+ componentCount: 1,
+ isSigned: true,
+ isUsingColorTransform: false,
+}
+
+const TRUNCATED_BYTE_LIMIT = 10 * 1024
+const LOSSY_QUANTIZATION_STEP = 8
+
+async function loadModule(modulePath) {
+ const mod = await import(modulePath)
+ const factory = mod.default ?? mod
+ return await factory()
+}
+
+function meanAbsoluteErrorI16(originalBytes, decodedBytes) {
+ expect(decodedBytes.length).toBe(originalBytes.length)
+
+ const original = new Int16Array(
+ originalBytes.buffer,
+ originalBytes.byteOffset,
+ originalBytes.byteLength / Int16Array.BYTES_PER_ELEMENT
+ )
+ const decoded = new Int16Array(
+ decodedBytes.buffer,
+ decodedBytes.byteOffset,
+ decodedBytes.byteLength / Int16Array.BYTES_PER_ELEMENT
+ )
+
+ let absoluteErrorSum = 0
+ for (let i = 0; i < original.length; i++) {
+ absoluteErrorSum += Math.abs(original[i] - decoded[i])
+ }
+
+ return absoluteErrorSum / original.length
+}
+
+function encodeFrame(codec, rawBytes, imageFrame, options = {}) {
+ const encoder = new codec.HTJ2KEncoder()
+ encoder.getDecodedBuffer(imageFrame).set(rawBytes)
+
+ if (typeof options.lossless === "boolean") {
+ encoder.setQuality(options.lossless, options.quantizationStep || 0)
+ }
+
+ encoder.encode()
+ const encoded = Uint8Array.from(encoder.getEncodedBuffer())
+ encoder.delete()
+ return encoded
+}
+
+function decodeFrame(codec, encodedBytes) {
+ const decoder = new codec.HTJ2KDecoder()
+ decoder.getEncodedBuffer(encodedBytes.length).set(encodedBytes)
+ decoder.decode()
+ const decoded = Uint8Array.from(decoder.getDecodedBuffer())
+ const decodedFrameInfo = decoder.getFrameInfo()
+ decoder.delete()
+ return { decoded, decodedFrameInfo }
+}
+
+/** Median wall-clock ms over `samples` timed calls after `warmup` untimed iterations. */
+function medianDecodeMs(runDecode, { warmup = 2, samples = 7 } = {}) {
+ for (let i = 0; i < warmup; i++) runDecode()
+
+ const times = []
+ for (let i = 0; i < samples; i++) {
+ const t0 = performance.now()
+ runDecode()
+ times.push(performance.now() - t0)
+ }
+
+ times.sort((a, b) => a - b)
+ return times[Math.floor(times.length / 2)]
+}
+
+const modulePath = "../dist/openjphjs.js"
+const isBuilt = existsSync(resolve(distDir, "openjphjs.js"))
+
+describe("openjphjs HTJ2K truncated and lossy decode", () => {
+ let codec
+ let encodedLossless
+ let encodedLossy
+ let truncatedBitstream
+
+ beforeAll(async () => {
+ if (!isBuilt) return
+ codec = await loadModule(modulePath)
+ encodedLossless = encodeFrame(codec, ct1Raw, frameInfo, {
+ lossless: true,
+ quantizationStep: 0,
+ })
+ encodedLossy = encodeFrame(codec, ct1Raw, frameInfo, {
+ lossless: false,
+ quantizationStep: LOSSY_QUANTIZATION_STEP,
+ })
+ const truncatedSize = Math.min(TRUNCATED_BYTE_LIMIT, encodedLossless.length)
+ truncatedBitstream = encodedLossless.slice(0, truncatedSize)
+ })
+
+ it.skipIf(!isBuilt)(
+ "decodes a heavily truncated lossless bitstream with bounded error",
+ () => {
+ const truncatedSize = truncatedBitstream.length
+ const { decoded, decodedFrameInfo } = decodeFrame(codec, truncatedBitstream)
+
+ expect(decoded.length).toBeGreaterThan(0)
+ expect(decodedFrameInfo.width).toBe(frameInfo.width)
+ expect(decodedFrameInfo.height).toBe(frameInfo.height)
+
+ const mae = meanAbsoluteErrorI16(ct1Raw, decoded)
+ expect(mae).toBeGreaterThan(10)
+ expect(mae).toBeLessThan(300)
+ console.log(
+ `Truncated lossless decode MAE (${truncatedSize} bytes kept): ${mae.toFixed(2)}`
+ )
+ }
+ )
+
+ it.skipIf(!isBuilt)("decodes a heavy lossy encode with bounded error", () => {
+ const { decoded, decodedFrameInfo } = decodeFrame(codec, encodedLossy)
+
+ expect(decodedFrameInfo.width).toBe(frameInfo.width)
+ expect(decodedFrameInfo.height).toBe(frameInfo.height)
+
+ const mae = meanAbsoluteErrorI16(ct1Raw, decoded)
+ expect(mae).toBeLessThan(1500)
+ console.log(`Heavy lossy round-trip MAE: ${mae.toFixed(2)}`)
+ })
+})
+
+describe("openjphjs HTJ2K decode performance", () => {
+ let codec
+ let encodedLossless
+ let encodedLossy
+ let truncatedBitstream
+
+ beforeAll(async () => {
+ if (!isBuilt) return
+ codec = await loadModule(modulePath)
+ encodedLossless = encodeFrame(codec, ct1Raw, frameInfo, {
+ lossless: true,
+ quantizationStep: 0,
+ })
+ encodedLossy = encodeFrame(codec, ct1Raw, frameInfo, {
+ lossless: false,
+ quantizationStep: LOSSY_QUANTIZATION_STEP,
+ })
+ const truncatedSize = Math.min(TRUNCATED_BYTE_LIMIT, encodedLossless.length)
+ truncatedBitstream = encodedLossless.slice(0, truncatedSize)
+ })
+
+ it.skipIf(!isBuilt)(
+ "full, truncated, and lossy decodes complete within expected wall-clock bounds (reused decoder)",
+ () => {
+ const fullDecoder = new codec.HTJ2KDecoder()
+ const truncatedDecoder = new codec.HTJ2KDecoder()
+ const lossyDecoder = new codec.HTJ2KDecoder()
+
+ const fullMs = medianDecodeMs(() => {
+ fullDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+ fullDecoder.decode()
+ fullDecoder.getDecodedBuffer()
+ })
+
+ const truncatedMs = medianDecodeMs(() => {
+ truncatedDecoder
+ .getEncodedBuffer(truncatedBitstream.length)
+ .set(truncatedBitstream)
+ truncatedDecoder.decode()
+ truncatedDecoder.getDecodedBuffer()
+ })
+
+ const lossyMs = medianDecodeMs(() => {
+ lossyDecoder.getEncodedBuffer(encodedLossy.length).set(encodedLossy)
+ lossyDecoder.decode()
+ lossyDecoder.getDecodedBuffer()
+ })
+
+ fullDecoder.delete()
+ truncatedDecoder.delete()
+ lossyDecoder.delete()
+
+ console.log(
+ `Decode median ms — full CT1.j2c: ${fullMs.toFixed(2)}, truncated (${truncatedBitstream.length} B): ${truncatedMs.toFixed(2)}, lossy q=${LOSSY_QUANTIZATION_STEP}: ${lossyMs.toFixed(2)}`
+ )
+
+ // Sanity ceilings for CI runners (generous; catches hangs/regressions).
+ expect(fullMs).toBeLessThan(8000)
+ expect(truncatedMs).toBeLessThan(8000)
+ expect(lossyMs).toBeLessThan(8000)
+
+ // Truncated streams carry far fewer bytes; decode should not be slower than full.
+ expect(truncatedMs).toBeLessThan(fullMs * 2.5)
+ }
+ )
+})
+
+describe("openjphjs HTJ2K decoder reuse (memory release)", () => {
+ let codec
+
+ beforeAll(async () => {
+ if (isBuilt) codec = await loadModule(modulePath)
+ })
+
+ it.skipIf(!isBuilt)(
+ "reuses one HTJ2KDecoder for 500 decodes with stable time at iterations 5, 50, and 500",
+ () => {
+ const decoder = new codec.HTJ2KDecoder()
+ const milestoneIterations = [5, 50, 500]
+ const timesAt = {}
+
+ for (let i = 1; i <= 500; i++) {
+ const t0 = performance.now()
+ decoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+ decoder.decode()
+ decoder.getDecodedBuffer()
+ const elapsed = performance.now() - t0
+
+ if (milestoneIterations.includes(i)) {
+ timesAt[i] = elapsed
+ }
+ }
+
+ decoder.delete()
+
+ console.log(
+ `Reused decoder decode ms — iteration 5: ${timesAt[5].toFixed(2)}, 50: ${timesAt[50].toFixed(2)}, 500: ${timesAt[500].toFixed(2)}`
+ )
+
+ const samples = [timesAt[5], timesAt[50], timesAt[500]]
+ const minMs = Math.min(...samples)
+ const maxMs = Math.max(...samples)
+ const ratio = maxMs / minMs
+
+ console.log(
+ `Reused decoder min/max ratio at milestones: ${ratio.toFixed(2)} (min ${minMs.toFixed(2)} ms, max ${maxMs.toFixed(2)} ms)`
+ )
+
+ // Memory retained across reuse should not drive large slowdowns in this release.
+ expect(ratio).toBeLessThan(6)
+ expect(maxMs).toBeLessThan(8000)
+ }
+ )
+
+ it.skipIf(!isBuilt)(
+ "reused decoder is faster than instantiate+decode+destroy per frame",
+ () => {
+ const reusedDecoder = new codec.HTJ2KDecoder()
+ const t0 = performance.now()
+ reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+ reusedDecoder.decode()
+ reusedDecoder.getDecodedBuffer()
+ const reusedMs = performance.now() - t0
+ reusedDecoder.delete()
+
+ const t1 = performance.now()
+ const fresh = new codec.HTJ2KDecoder()
+ fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+ fresh.decode()
+ fresh.getDecodedBuffer()
+ fresh.delete()
+ const freshMs = performance.now() - t1
+
+ console.log(
+ `Single decode — reused decoder: ${reusedMs.toFixed(2)} ms, fresh decoder: ${freshMs.toFixed(2)} ms`
+ )
+
+ expect(reusedMs).toBeLessThan(freshMs)
+ }
+ )
+})