Skip to content

Commit

Permalink
Merge branch 'master' into feature/high-resolution-screenshot
Browse files Browse the repository at this point in the history
  • Loading branch information
seankmartin committed Nov 14, 2024
2 parents 2266dc3 + 39272cd commit 5bcfd9e
Show file tree
Hide file tree
Showing 17 changed files with 554 additions and 90 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -296,5 +296,6 @@ jobs:
- run: ./src/mesh/draco/build.sh
- run: ./src/sliceview/compresso/build.sh
- run: ./src/sliceview/png/build.sh
- run: ./src/sliceview/jxl/build.sh
# Check that there are no differences.
- run: git diff --exit-code
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"url": "git+https://github.com/google/neuroglancer.git"
},
"engines": {
"node": ">=20.10 <21 || >=21.2"
"node": ">=20.11 <21 || >=21.2"
},
"browserslist": [
"last 2 Chrome versions",
Expand Down
37 changes: 37 additions & 0 deletions src/async_computation/decode_jxl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2024 William Silversmith
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { decodeJxl } from "#src/async_computation/decode_jxl_request.js";
import { registerAsyncComputation } from "#src/async_computation/handler.js";
import { decompressJxl } from "#src/sliceview/jxl/index.js";

registerAsyncComputation(
decodeJxl,
async (
data: Uint8Array,
area: number | undefined,
numComponents: number | undefined,
bytesPerPixel: number,
) => {
const result = await decompressJxl(
data,
area,
numComponents,
bytesPerPixel,
);
return { value: result, transfer: [result.uint8Array.buffer] };
},
);
27 changes: 27 additions & 0 deletions src/async_computation/decode_jxl_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2024 William Silversmith
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DecodedImage } from "#src/async_computation/decode_png_request.js";
import { asyncComputation } from "#src/async_computation/index.js";

export const decodeJxl =
asyncComputation<
(
data: Uint8Array,
area: number | undefined,
numComponents: number | undefined,
bytesPerPixel: number,
) => DecodedImage
>("decodeJxl");
1 change: 1 addition & 0 deletions src/datasource/precomputed/async_computation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "#src/async_computation/decode_jpeg.js";
import "#src/async_computation/decode_jxl.js";
import "#src/async_computation/decode_gzip.js";
import "#src/async_computation/decode_compresso.js";
import "#src/async_computation/decode_png.js";
2 changes: 2 additions & 0 deletions src/datasource/precomputed/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { decodeCompressedSegmentationChunk } from "#src/sliceview/backend_chunk_
import { decodeCompressoChunk } from "#src/sliceview/backend_chunk_decoders/compresso.js";
import type { ChunkDecoder } from "#src/sliceview/backend_chunk_decoders/index.js";
import { decodeJpegChunk } from "#src/sliceview/backend_chunk_decoders/jpeg.js";
import { decodeJxlChunk } from "#src/sliceview/backend_chunk_decoders/jxl.js";
import { decodePngChunk } from "#src/sliceview/backend_chunk_decoders/png.js";
import { decodeRawChunk } from "#src/sliceview/backend_chunk_decoders/raw.js";
import type { VolumeChunk } from "#src/sliceview/volume/backend.js";
Expand Down Expand Up @@ -380,6 +381,7 @@ chunkDecoders.set(
);
chunkDecoders.set(VolumeChunkEncoding.COMPRESSO, decodeCompressoChunk);
chunkDecoders.set(VolumeChunkEncoding.PNG, decodePngChunk);
chunkDecoders.set(VolumeChunkEncoding.JXL, decodeJxlChunk);

@registerSharedObject()
export class PrecomputedVolumeChunkSource extends WithParameters(
Expand Down
1 change: 1 addition & 0 deletions src/datasource/precomputed/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export enum VolumeChunkEncoding {
COMPRESSED_SEGMENTATION = 2,
COMPRESSO = 3,
PNG = 4,
JXL = 5,
}

export class VolumeChunkSourceParameters {
Expand Down
170 changes: 84 additions & 86 deletions src/kvstore/special/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
} from "#src/kvstore/index.js";
import { composeByteRangeRequest } from "#src/kvstore/index.js";
import { uncancelableToken } from "#src/util/cancellation.js";
import { HttpError, isNotFoundError } from "#src/util/http_request.js";
import { isNotFoundError } from "#src/util/http_request.js";
import type { SpecialProtocolCredentialsProvider } from "#src/util/special_protocol_request.js";
import { cancellableFetchSpecialOk } from "#src/util/special_protocol_request.js";

Expand Down Expand Up @@ -85,97 +85,95 @@ class SpecialProtocolKvStore implements ReadableKvStore {
const { cancellationToken = uncancelableToken } = options;
let { byteRange: byteRangeRequest } = options;
const url = this.baseUrl + key;
for (let attempt = 0; ; ++attempt) {
try {
const requestInit: RequestInit = {};
const rangeHeader = getRangeHeader(byteRangeRequest);
if (rangeHeader !== undefined) {
requestInit.headers = { range: rangeHeader };
requestInit.cache = byteRangeCacheMode;

try {
// The HTTP spec supports suffixLength requests directly via "Range:
// bytes=-N" requests, which avoids the need for a separate HEAD request.
// However, per
// https://fetch.spec.whatwg.org/#cors-safelisted-request-header a suffix
// length byte range request header will always trigger an OPTIONS preflight
// request, which would otherwise be avoided. This negates the benefit of
// using a suffixLength request directly. Additionally, some servers such as
// the npm http-server package and https://uk1s3.embassy.ebi.ac.uk/ do not
// correctly handle suffixLength requests or do not correctly handle CORS
// preflight requests. To avoid those issues, always just issue a separate
// HEAD request to determine the length.
let totalSize: number | undefined;
if (
byteRangeRequest !== undefined &&
"suffixLength" in byteRangeRequest
) {
const totalSize = await this.getObjectLength(url, options);
byteRangeRequest = composeByteRangeRequest(
{ offset: 0, length: totalSize },
byteRangeRequest,
).outer;
}
const requestInit: RequestInit = {};
const rangeHeader = getRangeHeader(byteRangeRequest);
if (rangeHeader !== undefined) {
requestInit.headers = { range: rangeHeader };
requestInit.cache = byteRangeCacheMode;
}
const { response, data } = await cancellableFetchSpecialOk(
this.credentialsProvider,
url,
requestInit,
async (response) => ({
response,
data: await response.arrayBuffer(),
}),
cancellationToken,
);
let byteRange: ByteRange | undefined;
if (response.status === 206) {
const contentRange = response.headers.get("content-range");
if (contentRange === null) {
// Content-range should always be sent, but some buggy servers don't
// send it.
if (byteRangeRequest !== undefined) {
byteRange = {
offset: byteRangeRequest.offset,
length: data.byteLength,
};
} else {
throw new Error(
"Unexpected HTTP 206 response when no byte range specified.",
);
}
}
const { response, data } = await cancellableFetchSpecialOk(
this.credentialsProvider,
url,
requestInit,
async (response) => ({
response,
data: await response.arrayBuffer(),
}),
cancellationToken,
);
let byteRange: ByteRange | undefined;
let totalSize: number | undefined;
if (response.status === 206) {
const contentRange = response.headers.get("content-range");
if (contentRange === null) {
if (byteRangeRequest !== undefined) {
if ("suffixLength" in byteRangeRequest) {
const objectSize = await this.getObjectLength(url, options);
byteRange = {
offset: objectSize - byteRangeRequest.suffixLength,
length: Number(response.headers.get("content-length")),
};
} else {
byteRange = {
offset: byteRangeRequest.offset,
length: data.byteLength,
};
}
} else {
throw new Error(
"Unexpected HTTP 206 response when no byte range specified.",
);
}
if (contentRange !== null) {
const m = contentRange.match(/bytes ([0-9]+)-([0-9]+)\/([0-9]+|\*)/);
if (m === null) {
throw new Error(
`Invalid content-range header: ${JSON.stringify(contentRange)}`,
);
}
if (contentRange !== null) {
const m = contentRange.match(
/bytes ([0-9]+)-([0-9]+)\/([0-9]+|\*)/,
const beginPos = parseInt(m[1], 10);
const endPos = parseInt(m[2], 10);
if (endPos !== beginPos + data.byteLength - 1) {
throw new Error(
`Length in content-range header ${JSON.stringify(
contentRange,
)} does not match content length ${data.byteLength}`,
);
if (m === null) {
throw new Error(
`Invalid content-range header: ${JSON.stringify(contentRange)}`,
);
}
const beginPos = parseInt(m[1], 10);
const endPos = parseInt(m[2], 10);
if (endPos !== beginPos + data.byteLength - 1) {
throw new Error(
`Length in content-range header ${JSON.stringify(
contentRange,
)} does not match content length ${data.byteLength}`,
);
}
totalSize = m[3] === "*" ? undefined : parseInt(m[3], 10);
byteRange = { offset: beginPos, length: data.byteLength };
}
if (m[3] !== "*") {
totalSize = parseInt(m[3], 10);
}
byteRange = { offset: beginPos, length: data.byteLength };
}
if (byteRange === undefined) {
byteRange = { offset: 0, length: data.byteLength };
totalSize = data.byteLength;
}
return { data: new Uint8Array(data), dataRange: byteRange, totalSize };
} catch (e) {
if (
attempt === 0 &&
e instanceof HttpError &&
e.status === 416 &&
options.byteRange !== undefined &&
"suffixLength" in options.byteRange
) {
// Some servers, such as the npm http-server package, do not support suffixLength
// byte-range requests.
const contentLengthNumber = await this.getObjectLength(url, options);
byteRangeRequest = composeByteRangeRequest(
{ offset: 0, length: contentLengthNumber },
byteRangeRequest,
).outer;
continue;
}
if (isNotFoundError(e)) {
return undefined;
}
throw e;
}
if (byteRange === undefined) {
byteRange = { offset: 0, length: data.byteLength };
totalSize = data.byteLength;
}
return { data: new Uint8Array(data), dataRange: byteRange, totalSize };
} catch (e) {
if (isNotFoundError(e)) {
return undefined;
}
throw e;
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/sliceview/backend_chunk_decoders/jxl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2016 Google Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { decodeJxl } from "#src/async_computation/decode_jxl_request.js";
import { requestAsyncComputation } from "#src/async_computation/request.js";
import { postProcessRawData } from "#src/sliceview/backend_chunk_decoders/postprocess.js";
import type { VolumeChunk } from "#src/sliceview/volume/backend.js";
import type { CancellationToken } from "#src/util/cancellation.js";

export async function decodeJxlChunk(
chunk: VolumeChunk,
cancellationToken: CancellationToken,
response: ArrayBuffer,
) {
const chunkDataSize = chunk.chunkDataSize!;
const { uint8Array: decoded } = await requestAsyncComputation(
decodeJxl,
cancellationToken,
[response],
new Uint8Array(response),
chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2],
chunkDataSize[3] || 1,
1, // bytesPerPixel
);
await postProcessRawData(chunk, cancellationToken, decoded);
}
18 changes: 18 additions & 0 deletions src/sliceview/jxl/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "jxl-wasm"
version = "1.0.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
jxl-oxide = "0.9.1"

[profile.release]
lto = true

[package.metadata.wasm-opt]
memory = { initial = 10, maximum = 100 } # Set initial and max memory in MiB
5 changes: 5 additions & 0 deletions src/sliceview/jxl/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM rust:slim-bullseye

RUN rustup target add wasm32-unknown-unknown


13 changes: 13 additions & 0 deletions src/sliceview/jxl/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash -xve

# This script builds `jxl_decoder.wasm` using emsdk in a docker container.

cd "$(dirname "$0")"

docker build .
docker run \
--rm \
-v ${PWD}:/src \
-u $(id -u):$(id -g) \
$(docker build -q .) \
/src/build_wasm.sh
6 changes: 6 additions & 0 deletions src/sliceview/jxl/build_wasm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/bash -xve

cd /src
cargo build --target wasm32-unknown-unknown --release
cp /src/target/wasm32-unknown-unknown/release/jxl_wasm.wasm /src/jxl_decoder.wasm
rm -r target
Loading

0 comments on commit 5bcfd9e

Please sign in to comment.