From 0b012b283ca1e9f81f140a1c77b35a38fd059a4c Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Thu, 10 Oct 2024 15:18:51 -0700 Subject: [PATCH 1/6] add thumbnail loading placeholder --- .../LazilyRenderedThumbnail.module.css | 16 ++++++++++ .../FileList/LazilyRenderedThumbnail.tsx | 29 ++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/core/components/FileList/LazilyRenderedThumbnail.module.css b/packages/core/components/FileList/LazilyRenderedThumbnail.module.css index 9e9c6de7..b45768b9 100644 --- a/packages/core/components/FileList/LazilyRenderedThumbnail.module.css +++ b/packages/core/components/FileList/LazilyRenderedThumbnail.module.css @@ -47,3 +47,19 @@ text-align: center; width: 100%; } + +.thumbnailPlaceholder { + width: 100%; + height: 100%; + background-color: var(--primary-text-color); + display: flex; + justify-content: center; + align-items: center; +} + +.spinnerContainer { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} diff --git a/packages/core/components/FileList/LazilyRenderedThumbnail.tsx b/packages/core/components/FileList/LazilyRenderedThumbnail.tsx index f1f4d4b6..8e777c4b 100644 --- a/packages/core/components/FileList/LazilyRenderedThumbnail.tsx +++ b/packages/core/components/FileList/LazilyRenderedThumbnail.tsx @@ -62,9 +62,15 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr }, [fileSelection, fileSet, overallIndex]); const [thumbnailPath, setThumbnailPath] = React.useState(); + const [isLoading, setIsLoading] = React.useState(true); + React.useEffect(() => { if (file) { - file.getPathToThumbnail().then(setThumbnailPath); + setIsLoading(true); + file.getPathToThumbnail().then((path) => { + setThumbnailPath(path); + setIsLoading(false); + }); } }, [file]); @@ -107,12 +113,21 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr [styles.focused]: isFocused, })} > - + {isLoading ? ( +
+ +
+ ) : ( + + )}
Date: Fri, 11 Oct 2024 11:49:58 -0700 Subject: [PATCH 2/6] update file thumbnail --- .../core/components/FileDetails/index.tsx | 11 +++++++--- .../LazilyRenderedThumbnail.module.css | 18 +-------------- .../FileList/LazilyRenderedThumbnail.tsx | 22 ++++++------------- .../FileThumbnail/FileThumbnail.module.css | 8 +++++++ .../core/components/FileThumbnail/index.tsx | 20 ++++++++++++++++- 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index 2eccb696..a7b02e21 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -38,7 +38,7 @@ function resizeHandleOnMouseDown(mouseDownEvent: React.MouseEvent= 175) { // If primary button (left-click) is still pressed and newWidth is still greater - // than the minimum width we want for the pane + // than the minimum width we want for the pane rootElement.style.setProperty(FILE_DETAILS_WIDTH_ATTRIBUTE, `${newWidth}px`); } else { // Remove this listener if user releases the primary button @@ -73,11 +73,16 @@ export default function FileDetails(props: Props) { const dispatch = useDispatch(); const [fileDetails, isLoading] = useFileDetails(); const [thumbnailPath, setThumbnailPath] = React.useState(); + const [isThumbnailLoading, setIsThumbnailLoading] = React.useState(true); const stackTokens: IStackTokens = { childrenGap: 12 + " " + 20 }; React.useEffect(() => { if (fileDetails) { - fileDetails.getPathToThumbnail().then(setThumbnailPath); + setIsThumbnailLoading(true); + fileDetails.getPathToThumbnail().then((path) => { + setThumbnailPath(path); + setIsThumbnailLoading(false); + }); } }, [fileDetails]); @@ -125,8 +130,8 @@ export default function FileDetails(props: Props) {
- {isLoading ? ( -
- -
- ) : ( - - )} +
+ +
+ ); + } + // Render no thumbnail icon if no URI is provided if (!props.uri) { return ( From da9d54fa87834e680603ea1faccd5b4a6c05a5a3 Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Fri, 11 Oct 2024 12:15:49 -0700 Subject: [PATCH 3/6] undefined thumbnmail for unrenderable zarrs --- .../FileDetail/RenderZarrThumbnailURL.ts | 139 +++++++++--------- 1 file changed, 72 insertions(+), 67 deletions(-) diff --git a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts index 4926a345..85107ddd 100644 --- a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts +++ b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts @@ -45,82 +45,87 @@ function transformAxes(originalArray: AxisData[]): TransformedAxes[] { * and determines a slice that would be appropriate to display as a thumbnail and creates a DataURL * of that slice to provide to users. */ -export async function renderZarrThumbnailURL(zarrUrl: string): Promise { - // read base image into store - const store = new zarr.FetchStore(zarrUrl); - const root = zarr.root(store); - const group = await zarr.open(root, { kind: "group" }); +export async function renderZarrThumbnailURL(zarrUrl: string): Promise { + try { + // read base image into store + const store = new zarr.FetchStore(zarrUrl); + const root = zarr.root(store); + const group = await zarr.open(root, { kind: "group" }); - // Check that image has readable metadata structure. - // Because zarr images are just directories there is variation here. - if ( - !group.attrs || - !Array.isArray(group.attrs.multiscales) || - group.attrs.multiscales.length === 0 - ) { - throw new Error("Invalid multiscales attribute structure"); - } + // Check that image has readable metadata structure. + // Because zarr images are just directories there is variation here. + if ( + !group.attrs || + !Array.isArray(group.attrs.multiscales) || + group.attrs.multiscales.length === 0 + ) { + throw new Error("Invalid multiscales attribute structure"); + } - // Access the image metadata and find lowest resolution. - const { multiscales } = group.attrs; - const datasets = multiscales[0].datasets; - const lowestResolutionDataset = datasets[datasets.length - 1]; - const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path); - const lowestResolution = await zarr.open(lowestResolutionLocation, { kind: "array" }); + // Access the image metadata and find lowest resolution. + const { multiscales } = group.attrs; + const datasets = multiscales[0].datasets; + const lowestResolutionDataset = datasets[datasets.length - 1]; + const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path); + const lowestResolution = await zarr.open(lowestResolutionLocation, { kind: "array" }); - // Determine a slice of the image that has the best chance at being a good thumbnail. - // X and Y will have full range, while Z will be the middle integer. - // Non-spatial dims will default to None. - const axes = transformAxes(multiscales[0].axes); - const zIndex = axes.findIndex((item) => item.name === "z"); - if (zIndex !== -1) { - const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2); - axes[zIndex].value = zSliceIndex; - } + // Determine a slice of the image that has the best chance at being a good thumbnail. + // X and Y will have full range, while Z will be the middle integer. + // Non-spatial dims will default to None. + const axes = transformAxes(multiscales[0].axes); + const zIndex = axes.findIndex((item) => item.name === "z"); + if (zIndex !== -1) { + const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2); + axes[zIndex].value = zSliceIndex; + } - // Create a view (get data) of the determined lowest resolution using the - // choices made for each axes. - const lowestResolutionView = await zarr.get( - lowestResolution, - axes.map((item) => item.value) - ); - const u16data = lowestResolutionView.data as Uint16Array; + // Create a view (get data) of the determined lowest resolution using the + // choices made for each axes. + const lowestResolutionView = await zarr.get( + lowestResolution, + axes.map((item) => item.value) + ); + const u16data = lowestResolutionView.data as Uint16Array; - // Normalize Data to improve image visability. - const min = Math.min(...u16data); - const max = Math.max(...u16data); - const normalizedData = new Uint8Array(u16data.length); - for (let i = 0; i < u16data.length; i++) { - normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min)); - } + // Normalize Data to improve image visibility. + const min = Math.min(...u16data); + const max = Math.max(...u16data); + const normalizedData = new Uint8Array(u16data.length); + for (let i = 0; i < u16data.length; i++) { + normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min)); + } - // Build a canvas to put image data onto. - const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")]; - const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")]; - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); + // Build a canvas to put image data onto. + const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")]; + const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")]; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } + if (!context) { + throw new Error("Failed to get canvas context"); + } - // Draw data to canvas in grayscale. - const imageData = context.createImageData(width, height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = (y * width + x) * 4; - const value = normalizedData[y * width + x]; - imageData.data[idx] = value; // Red - imageData.data[idx + 1] = value; // Green - imageData.data[idx + 2] = value; // Blue - imageData.data[idx + 3] = 255; // Alpha + // Draw data to canvas in grayscale. + const imageData = context.createImageData(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const value = normalizedData[y * width + x]; + imageData.data[idx] = value; // Red + imageData.data[idx + 1] = value; // Green + imageData.data[idx + 2] = value; // Blue + imageData.data[idx + 3] = 255; // Alpha + } } - } - context.putImageData(imageData, 0, 0); + context.putImageData(imageData, 0, 0); - // Convert data to data URL - return canvas.toDataURL("image/png"); + // Convert data to data URL + return canvas.toDataURL("image/png"); + } catch (error) { + console.error("Failed to render Zarr thumbnail:", error); + return undefined; // Return undefined if an error occurs + } } From 11a5b4244ed66352917ca886370419cfc4ac310f Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Tue, 15 Oct 2024 12:29:50 -0700 Subject: [PATCH 4/6] retry render thumbnail --- .../FileDetail/RenderZarrThumbnailURL.ts | 189 +++++++++++------- 1 file changed, 113 insertions(+), 76 deletions(-) diff --git a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts index 85107ddd..7d1168ca 100644 --- a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts +++ b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts @@ -38,94 +38,131 @@ function transformAxes(originalArray: AxisData[]): TransformedAxes[] { value: item.type !== "space" ? 0 : null, })); } +/** + * Helper function to handle retry logic with timeout for async operations. + * It retries the operation up to the specified number of times and aborts if it takes too long. + */ +async function retryWithTimeout(fn: () => Promise, retries = 3, timeout = 5000): Promise { + let attempt = 0; + while (attempt < retries) { + try { + return await withTimeout(fn(), timeout); + } catch (error) { + attempt++; + console.warn(`Attempt ${attempt} failed. Retrying...`, error); + if (attempt >= retries) { + throw new Error(`Operation failed after ${retries} attempts: ${error}`); + } + } + } + throw new Error("Unexpected error in retry logic"); +} /** - * The purpose of this function is to attempt to render a usable thumbnail using the lowest - * resolution present in a zarr image's metadata. This function looks at the available dimensions - * and determines a slice that would be appropriate to display as a thumbnail and creates a DataURL - * of that slice to provide to users. + * Helper function to enforce a timeout on async operations. + */ +async function withTimeout(promise: Promise, ms: number): Promise { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms) + ); + return Promise.race([promise, timeout]); +} + +/** + * Main function to attempt to render a usable thumbnail using the lowest + * resolution present in a zarr image's metadata. */ export async function renderZarrThumbnailURL(zarrUrl: string): Promise { try { - // read base image into store - const store = new zarr.FetchStore(zarrUrl); - const root = zarr.root(store); - const group = await zarr.open(root, { kind: "group" }); - - // Check that image has readable metadata structure. - // Because zarr images are just directories there is variation here. - if ( - !group.attrs || - !Array.isArray(group.attrs.multiscales) || - group.attrs.multiscales.length === 0 - ) { - throw new Error("Invalid multiscales attribute structure"); - } + return await retryWithTimeout( + async () => { + console.log("Starting Zarr thumbnail rendering for:", zarrUrl); - // Access the image metadata and find lowest resolution. - const { multiscales } = group.attrs; - const datasets = multiscales[0].datasets; - const lowestResolutionDataset = datasets[datasets.length - 1]; - const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path); - const lowestResolution = await zarr.open(lowestResolutionLocation, { kind: "array" }); - - // Determine a slice of the image that has the best chance at being a good thumbnail. - // X and Y will have full range, while Z will be the middle integer. - // Non-spatial dims will default to None. - const axes = transformAxes(multiscales[0].axes); - const zIndex = axes.findIndex((item) => item.name === "z"); - if (zIndex !== -1) { - const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2); - axes[zIndex].value = zSliceIndex; - } + // read base image into store + const store = new zarr.FetchStore(zarrUrl); + const root = zarr.root(store); + const group = await zarr.open(root, { kind: "group" }); - // Create a view (get data) of the determined lowest resolution using the - // choices made for each axes. - const lowestResolutionView = await zarr.get( - lowestResolution, - axes.map((item) => item.value) - ); - const u16data = lowestResolutionView.data as Uint16Array; - - // Normalize Data to improve image visibility. - const min = Math.min(...u16data); - const max = Math.max(...u16data); - const normalizedData = new Uint8Array(u16data.length); - for (let i = 0; i < u16data.length; i++) { - normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min)); - } + // Check that image has readable metadata structure. + // Because zarr images are just directories there is variation here. + if ( + !group.attrs || + !Array.isArray(group.attrs.multiscales) || + group.attrs.multiscales.length === 0 + ) { + throw new Error("Invalid multiscales attribute structure"); + } - // Build a canvas to put image data onto. - const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")]; - const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")]; - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); + // Access the image metadata and find lowest resolution. + const { multiscales } = group.attrs; + const datasets = multiscales[0].datasets; + const lowestResolutionDataset = datasets[datasets.length - 1]; + const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path); + const lowestResolution = await zarr.open(lowestResolutionLocation, { + kind: "array", + }); - if (!context) { - throw new Error("Failed to get canvas context"); - } + // Determine a slice of the image that has the best chance at being a good thumbnail. + // X and Y will have full range, while Z will be the middle integer. + // Non-spatial dims will default to None. + const axes = transformAxes(multiscales[0].axes); + const zIndex = axes.findIndex((item) => item.name === "z"); + if (zIndex !== -1) { + const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2); + axes[zIndex].value = zSliceIndex; + } - // Draw data to canvas in grayscale. - const imageData = context.createImageData(width, height); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = (y * width + x) * 4; - const value = normalizedData[y * width + x]; - imageData.data[idx] = value; // Red - imageData.data[idx + 1] = value; // Green - imageData.data[idx + 2] = value; // Blue - imageData.data[idx + 3] = 255; // Alpha - } - } + // Create a view (get data) of the determined lowest resolution using the + // choices made for each axes. + const lowestResolutionView = await zarr.get( + lowestResolution, + axes.map((item) => item.value) + ); + const u16data = lowestResolutionView.data as Uint16Array; - context.putImageData(imageData, 0, 0); + // Normalize Data to improve image visibility. + const min = Math.min(...u16data); + const max = Math.max(...u16data); + const normalizedData = new Uint8Array(u16data.length); + for (let i = 0; i < u16data.length; i++) { + normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min)); + } - // Convert data to data URL - return canvas.toDataURL("image/png"); + // Build a canvas to put image data onto. + const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")]; + const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")]; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("Failed to get canvas context"); + } + + // Draw data to canvas in grayscale. + const imageData = context.createImageData(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const value = normalizedData[y * width + x]; + imageData.data[idx] = value; // Red + imageData.data[idx + 1] = value; // Green + imageData.data[idx + 2] = value; // Blue + imageData.data[idx + 3] = 255; // Alpha + } + } + + context.putImageData(imageData, 0, 0); + + // Convert data to data URL + return canvas.toDataURL("image/png"); + }, + 3, + 5000 + ); } catch (error) { - console.error("Failed to render Zarr thumbnail:", error); - return undefined; // Return undefined if an error occurs + console.error("Failed to render Zarr thumbnail after 3 attempts:", error); + return undefined; // Return undefined if all attempts fail } } From 478fbd2d80b96884e30648830079e4285557fbf1 Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Tue, 15 Oct 2024 12:41:23 -0700 Subject: [PATCH 5/6] default to being a square if missing fields --- packages/core/components/FileThumbnail/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/components/FileThumbnail/index.tsx b/packages/core/components/FileThumbnail/index.tsx index 9186d0f0..c632f5b0 100644 --- a/packages/core/components/FileThumbnail/index.tsx +++ b/packages/core/components/FileThumbnail/index.tsx @@ -28,7 +28,7 @@ export default function FileThumbnail(props: Props) { styles.fileThumbnail, styles.thumbnailPlaceholder )} - style={{ height: props.height, width: props.width }} + style={{ height: props.height || props.width, width: props.width || props.height }} > @@ -52,7 +52,7 @@ export default function FileThumbnail(props: Props) { ); } From dcce3a53988fff6be5dbfdfd0dd0d19fcbda3357 Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Wed, 16 Oct 2024 14:43:06 -0700 Subject: [PATCH 6/6] update margins and remove logs --- packages/core/components/FileThumbnail/FileThumbnail.module.css | 1 + packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/components/FileThumbnail/FileThumbnail.module.css b/packages/core/components/FileThumbnail/FileThumbnail.module.css index a3d6d89b..7c9f5711 100644 --- a/packages/core/components/FileThumbnail/FileThumbnail.module.css +++ b/packages/core/components/FileThumbnail/FileThumbnail.module.css @@ -15,4 +15,5 @@ align-items: center; background-color: var(--border-color); border: 5px var(--primary-background-color); + margin: auto; } diff --git a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts index 7d1168ca..8b4b9e63 100644 --- a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts +++ b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts @@ -76,8 +76,6 @@ export async function renderZarrThumbnailURL(zarrUrl: string): Promise { - console.log("Starting Zarr thumbnail rendering for:", zarrUrl); - // read base image into store const store = new zarr.FetchStore(zarrUrl); const root = zarr.root(store);