diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c322152a..19657001 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { + "@bioimagetools/capability-manifest": "^0.1.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", @@ -398,6 +399,15 @@ "node": ">=18" } }, + "node_modules/@bioimagetools/capability-manifest": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.1.0.tgz", + "integrity": "sha512-FvVN2J3BCCx1fsgL1aFpDnpoNSEH5MeZ3FkGOaOX+4p1cnpMl71nAERI4SOXM00UAZxuj/cDpiR81OHuc2r1jQ==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -2437,7 +2447,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5863,7 +5872,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/frontend/package.json b/frontend/package.json index 12a3c0ba..608b9bfe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "test": "vitest" }, "dependencies": { + "@bioimagetools/capability-manifest": "^0.1.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 3df57288..5188d8a4 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -17,6 +17,61 @@ import { } from '@/omezarr-helper'; import { buildUrl } from '@/utils'; import * as zarr from 'zarrita'; +import { + getCompatibleViewers, + type OmeZarrMetadata +} from '@bioimagetools/capability-manifest'; + +/** + * Convert Fileglancer's internal Metadata to the library's OmeZarrMetadata format + * Only converts if this is a valid OME-Zarr dataset (has version) + */ +function convertToOmeZarrMetadata( + metadata: ZarrMetadata +): OmeZarrMetadata | null { + if (!metadata?.multiscale) { + return null; + } + + // If version is null/undefined, this is not a proper OME-Zarr dataset + // Return null to fall back to legacy logic + const version = metadata.multiscale.version; + if (!version) { + return null; + } + + // Convert axes from multiscale to the expected format, ensuring no null values + const axes = + metadata.multiscale.axes?.map((axis: any) => ({ + name: axis.name, + type: axis.type ?? undefined, + unit: axis.unit ?? undefined + })) || []; + + // Build the OmeZarrMetadata object + const omeZarrMetadata: OmeZarrMetadata = { + version: version as '0.4' | '0.5', + axes, + multiscales: [ + { + ...metadata.multiscale, + version, + axes + } + ] + }; + + // Add optional fields if they exist + if (metadata.omero) { + omeZarrMetadata.omero = metadata.omero; + } + + if (metadata.labels && metadata.labels.length > 0) { + omeZarrMetadata.labels = metadata.labels; + } + + return omeZarrMetadata; +} export type { OpenWithToolUrls, ZarrMetadata }; export type PendingToolKey = keyof OpenWithToolUrls | null; @@ -102,62 +157,93 @@ export default function useZarrMetadata() { copy: url || '' } as OpenWithToolUrls; - // Determine which tools should be available based on metadata type - if (metadata?.multiscale) { - // OME-Zarr - all urls for v2; no avivator for v3 + // Convert metadata to OmeZarrMetadata format and get compatible viewers + const omeZarrMetadata = convertToOmeZarrMetadata(metadata); + let compatibleViewers: string[] = []; + + if (omeZarrMetadata) { + try { + compatibleViewers = getCompatibleViewers(omeZarrMetadata); + log.debug('Compatible viewers from library:', compatibleViewers); + } catch (error) { + log.error('Error getting compatible viewers:', error); + // Fall back to assuming it's OME-Zarr if we have multiscale + compatibleViewers = metadata?.multiscale ? ['Neuroglancer'] : []; + } + } + + // Determine which tools should be available based on compatible viewers + const isOmeZarr = omeZarrMetadata !== null; + + if (isOmeZarr) { + // OME-Zarr dataset + const hasNeuroglancer = compatibleViewers.includes('Neuroglancer'); + const hasAvivator = + compatibleViewers.includes('Vizarr') || + compatibleViewers.includes('Avivator'); + if (url) { - if (effectiveZarrVersion === 2) { + // Avivator/Vizarr - only for v2 and if compatible + if (effectiveZarrVersion === 2 && hasAvivator) { openWithToolUrls.avivator = buildUrl(avivatorBaseUrl, null, { image_url: url }); } else { openWithToolUrls.avivator = null; } - // Populate with actual URLs when proxied path is available + + // Validator - always available for OME-Zarr openWithToolUrls.validator = buildUrl(validatorBaseUrl, null, { source: url }); + + // Vol-E - keep as-is for now (not in library) openWithToolUrls.vole = buildUrl(voleBaseUrl, null, { url }); - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - try { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForOmeZarr( - url, - effectiveZarrVersion, - layerType, - metadata.multiscale, - metadata.arr, - metadata.labels, - metadata.omero, - useLegacyMultichannelApproach - ); - } catch (error) { - log.error( - 'Error generating Neuroglancer state for OME-Zarr:', - error - ); + + // Neuroglancer - if compatible + if (hasNeuroglancer) { + if (disableNeuroglancerStateGeneration) { openWithToolUrls.neuroglancer = neuroglancerBaseUrl + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType && metadata.multiscale) { + try { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForOmeZarr( + url, + effectiveZarrVersion, + layerType, + metadata.multiscale, + metadata.arr, + metadata.labels, + metadata.omero, + useLegacyMultichannelApproach + ); + } catch (error) { + log.error( + 'Error generating Neuroglancer state for OME-Zarr:', + error + ); + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } else { + openWithToolUrls.neuroglancer = ''; } + } else { + openWithToolUrls.neuroglancer = ''; } } else { - // No proxied URL - show all tools as available but empty + // No proxied URL - show compatible tools as available but empty openWithToolUrls.validator = ''; openWithToolUrls.vole = ''; - // if this is a zarr version 2, then set the url to blank which will show - // the icon before a data link has been generated. Setting it to null for - // all other versions, eg zarr v3 means the icon will not be present before - // a data link is generated. - openWithToolUrls.avivator = effectiveZarrVersion === 2 ? '' : null; - openWithToolUrls.neuroglancer = ''; + openWithToolUrls.avivator = + effectiveZarrVersion === 2 && hasAvivator ? '' : null; + openWithToolUrls.neuroglancer = hasNeuroglancer ? '' : ''; } } else { // Non-OME Zarr - only Neuroglancer available diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 831d2026..2791c5fb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,15 +1,33 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; +import { initializeViewerManifests } from '@bioimagetools/capability-manifest'; import './index.css'; import App from './App'; import { queryClient } from './queryClient'; -createRoot(document.getElementById('root')!).render( - - - - - -); +// Expose initialization function for tests +// This allows Playwright tests to call initializeViewerManifests via page.evaluate() +// since it needs to run in the browser context (uses fetch API), not Node.js context +declare global { + interface Window { + initializeViewerManifests?: typeof initializeViewerManifests; + } +} + +window.initializeViewerManifests = initializeViewerManifests; + +const startApp = async () => { + await initializeViewerManifests(); + + createRoot(document.getElementById('root')!).render( + + + + + + ); +}; + +void startApp(); diff --git a/frontend/ui-tests/fixtures/fileglancer-fixture.ts b/frontend/ui-tests/fixtures/fileglancer-fixture.ts index d2e783d9..f0cccdbe 100644 --- a/frontend/ui-tests/fixtures/fileglancer-fixture.ts +++ b/frontend/ui-tests/fixtures/fileglancer-fixture.ts @@ -22,6 +22,19 @@ const openFileglancer = async (page: Page) => { // Wait for the app to be ready await page.waitForSelector('text=Log In', { timeout: 10000 }); + // Initialize viewer manifests before login + // We call this via page.evaluate() because initializeViewerManifests uses browser APIs + // (fetch) and must run in the browser context, not the Node.js context where Playwright runs + await page.evaluate(async () => { + // @ts-ignore - accessing window-scoped function from main.tsx + if (window.initializeViewerManifests) { + await window.initializeViewerManifests(); + console.log('[Test] Viewer manifests initialized'); + } else { + console.warn('[Test] initializeViewerManifests not found on window'); + } + }); + // Perform login const loginForm = page.getByRole('textbox', { name: 'Username' }); const loginSubmitBtn = page.getByRole('button', { name: 'Log In' });