diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index 2eccb6965..a7b02e218 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) { (); + 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]); @@ -112,6 +118,7 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr height={thumbnailSize} width={thumbnailSize} uri={thumbnailPath} + loading={isLoading} />
+ +
+ ); + } + // Render no thumbnail icon if no URI is provided if (!props.uri) { return ( @@ -34,7 +52,7 @@ export default function FileThumbnail(props: Props) { ); } diff --git a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts index 4926a3459..8b4b9e63d 100644 --- a/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts +++ b/packages/core/entity/FileDetail/RenderZarrThumbnailURL.ts @@ -38,89 +38,129 @@ 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. */ -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" }); +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]); +} - // 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"); - } +/** + * 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 { + return await retryWithTimeout( + async () => { + // read base image into store + const store = new zarr.FetchStore(zarrUrl); + const root = zarr.root(store); + const group = await zarr.open(root, { kind: "group" }); - // 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" }); + // 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"); + } - // 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; - } + // 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", + }); - // 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; + // 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; + } - // 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)); - } + // 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; - // 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"); + // 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)); + } - if (!context) { - throw new Error("Failed to get canvas context"); - } + // 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"); - // 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 - } - } + if (!context) { + throw new Error("Failed to get canvas context"); + } - context.putImageData(imageData, 0, 0); + // 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 + } + } - // Convert data to data URL - return canvas.toDataURL("image/png"); + 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 after 3 attempts:", error); + return undefined; // Return undefined if all attempts fail + } }