Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions extensions/dicom-video/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const base = require('../../jest.config.base.js');

module.exports = {
...base,
moduleNameMapper: {
...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
},
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/app/**/*.test.js"
// ]
};
20 changes: 20 additions & 0 deletions extensions/dicom-video/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import { id } from './id';

const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFDicomVideoViewport');
});

const OHIFDicomVideoViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};

/**
*
*/
Expand All @@ -10,6 +23,13 @@ const dicomVideoExtension = {
*/
id,

/**
* A plain HTML5 video viewport, for modes that do not use the Cornerstone
* viewport (which plays video through its own VIDEO viewport type).
*/
getViewportModule() {
return [{ name: 'dicom-video', component: OHIFDicomVideoViewport }];
},
getSopClassHandlerModule,
};

Expand Down
104 changes: 104 additions & 0 deletions extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from 'react';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import OHIFDicomVideoViewport from './OHIFDicomVideoViewport';

const mockViewportRef = { register: jest.fn(), unregister: jest.fn() };

jest.mock('@ohif/core', () => ({
useViewportElementRegistration: () => mockViewportRef,
}));

const renderViewport = displaySet =>
render(
React.createElement(OHIFDicomVideoViewport, {
displaySets: [displaySet],
viewportId: 'viewport-1',
})
);

const getVideo = container => container.querySelector('video');

describe('OHIFDicomVideoViewport', () => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
});

it('plays the URL returned by getVideoUrl and revokes it on unmount', async () => {
const revoke = jest.fn();
const getVideoUrl = jest.fn().mockResolvedValue({ url: 'blob:video-1', revoke });

const { container, unmount } = renderViewport({
videoUrl: 'https://wado.example/video',
getVideoUrl,
});

await waitFor(() => expect(getVideo(container)).not.toBeNull());
const video = getVideo(container);
expect(video.getAttribute('src')).toBe('blob:video-1');
expect(video.hasAttribute('controls')).toBe(true);
expect(getVideoUrl).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) });
expect(mockViewportRef.register).toHaveBeenCalled();

unmount();
expect(revoke).toHaveBeenCalledTimes(1);
expect(mockViewportRef.unregister).toHaveBeenCalled();
});

it('falls back to videoUrl when the data source has no rendered URL', async () => {
const { container } = renderViewport({ videoUrl: 'https://wado.example/video' });

await waitFor(() => expect(getVideo(container)).not.toBeNull());
expect(getVideo(container).getAttribute('src')).toBe('https://wado.example/video');
});

it('shows a message when the video cannot be fetched', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const getVideoUrl = jest.fn().mockRejectedValue(new Error('network'));

const { container } = renderViewport({ getVideoUrl });

expect(await screen.findByText('Video could not be loaded')).toBeTruthy();
expect(getVideo(container)).toBeNull();
warn.mockRestore();
});

it('shows a message when the data source returns no URL', async () => {
renderViewport({ getVideoUrl: jest.fn().mockResolvedValue({ url: null }) });

expect(await screen.findByText('Video could not be loaded')).toBeTruthy();
});

it('revokes a URL that resolves after unmount', async () => {
const revoke = jest.fn();
let resolveUrl;
const getVideoUrl = jest.fn(
() =>
new Promise(resolve => {
resolveUrl = resolve;
})
);

const { unmount } = renderViewport({ getVideoUrl });
const [{ signal }] = getVideoUrl.mock.calls[0];
unmount();
expect(signal.aborted).toBe(true);

await act(async () => {
resolveUrl({ url: 'blob:late', revoke });
});
expect(revoke).toHaveBeenCalledTimes(1);
});

it('rejects more than one display set', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() =>
render(
React.createElement(OHIFDicomVideoViewport, {
displaySets: [{ videoUrl: 'a' }, { videoUrl: 'b' }],
})
)
).toThrow('only one display set');
error.mockRestore();
});
});
115 changes: 115 additions & 0 deletions extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React, { useEffect, useState } from 'react';
import { useViewportElementRegistration } from '@ohif/core';

type OHIFDicomVideoViewportProps = {
displaySets: AppTypes.DisplaySet[];
viewportId?: string;
};

type LoadedVideo = {
// The display set the result was loaded for; a result for any other display
// set is stale and is ignored until the new load finishes.
displaySet: AppTypes.DisplaySet;
url: string | null;
failed: boolean;
};

/**
* getVideoUrl fetches with the data source's auth headers and returns an
* object URL; without it the direct WADO URL is used as-is.
*/
async function fetchVideoUrl(
videoUrl,
getVideoUrl,
signal: AbortSignal
): Promise<{ url?: string; revoke?: () => void } | undefined> {
if (getVideoUrl) {
return getVideoUrl({ signal });
}
return { url: await videoUrl };
}

/**
* Plays a DICOM video display set in a native HTML5 <video> element.
*
* This is the non-Cornerstone path for video: modes whose image viewports are
* not Cornerstone (e.g. the NiiVue mode) map the dicom-video SOP class handler
* here. Measurement and image tools do not apply; the browser's own playback
* controls are shown.
*/
function OHIFDicomVideoViewport({
displaySets,
viewportId = 'video-viewport',
}: OHIFDicomVideoViewportProps) {
const [loaded, setLoaded] = useState<LoadedVideo | null>(null);
const { register: registerViewportElement, unregister: unregisterViewportElement } =
useViewportElementRegistration(viewportId);

if (displaySets && displaySets.length > 1) {
throw new Error(
'OHIFDicomVideoViewport: only one display set is supported for dicom video right now'
);
}

const displaySet = displaySets[0];
const { videoUrl, getVideoUrl } = displaySet;
const current = loaded?.displaySet === displaySet ? loaded : null;

useEffect(() => {
let isCancelled = false;
let revokeUrl;
const abortController = new AbortController();

fetchVideoUrl(videoUrl, getVideoUrl, abortController.signal).then(
result => {
if (isCancelled) {
result?.revoke?.();
return;
}

revokeUrl = result?.revoke;
setLoaded({ displaySet, url: result?.url || null, failed: !result?.url });
},
error => {
console.warn('Failed to load video', error);
if (!isCancelled) {
setLoaded({ displaySet, url: null, failed: true });
}
}
);

return () => {
isCancelled = true;
abortController.abort();
revokeUrl?.();
};
}, [displaySet, videoUrl, getVideoUrl]);

return (
<div
className="bg-primary-black text-foreground flex h-full w-full items-center justify-center"
ref={el => {
registerViewportElement(el);
return () => unregisterViewportElement();
}}
data-viewport-id={viewportId}
data-cy="dicom-video-viewport"
>
{current?.failed ? (
<div>Video could not be loaded</div>
) : (
current?.url && (
<video
src={current.url}
controls
preload="auto"
className="h-full w-full object-contain"
onError={() => setLoaded({ ...current, url: null, failed: true })}
/>
)
)}
</div>
);
}

export default OHIFDicomVideoViewport;
Loading