Skip to content

Commit b6d70e9

Browse files
committed
fix(cine): seed remounted render buffers during playback
1 parent b860025 commit b6d70e9

3 files changed

Lines changed: 94 additions & 5 deletions

File tree

src/components/vtk/VtkBaseSliceRepresentation.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,22 @@ const cine = computed(() => getCineImage(imageID.value));
9595
// geometry; the mapper renders from this local image so two views can show
9696
// different frames without overwriting each other's pixels.
9797
const cineRender = shallowRef<CineRenderImage | null>(null);
98-
// Pending per-view buffer. Published to cineRender only after decoded pixels land.
98+
// Current per-view buffer. It may be attached before pixels are decoded so the
99+
// actor exists when the first frame lands after a rapid remount.
99100
let localCineRender: CineRenderImage | null = null;
101+
let localCineRenderHasFrame = false;
100102
101103
watchEffect((onCleanup) => {
102104
const cineImage = cine.value;
103105
if (!cineImage) return;
104106
const local = createCineRenderImage(cineImage);
105107
localCineRender = local;
106-
cineRender.value = null;
108+
localCineRenderHasFrame = false;
109+
cineRender.value = local;
107110
onCleanup(() => {
108111
if (localCineRender === local) {
109112
localCineRender = null;
113+
localCineRenderHasFrame = false;
110114
}
111115
if (cineRender.value === local) {
112116
cineRender.value = null;
@@ -157,13 +161,17 @@ watch(
157161
cineImage
158162
.getFrame(frame)
159163
.then((decoded) => {
160-
if (myToken !== frameToken) return;
161164
if (localCineRender !== target) return;
165+
// During playback, a remounted view can request frames faster than a
166+
// JPEG decode finishes. Seed an empty view with the first decoded frame
167+
// so it does not stay black while newer requests are still in flight.
168+
if (myToken !== frameToken && localCineRenderHasFrame) return;
162169
if (!copyDecodedFrameToRgb(decoded, target)) return;
170+
localCineRenderHasFrame = true;
163171
target.dataArray.modified();
164172
target.imageData.modified();
165173
cineRender.value = target;
166-
view.requestRender();
174+
view.requestRender({ immediate: true });
167175
})
168176
.catch(() => {
169177
// getFrame already routed the error through reportError.

tests/specs/cine-rendering.e2e.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,63 @@
11
import { Key } from 'webdriverio';
22
import { volViewPage } from '../pageobjects/volview.page';
3-
import { CINE_US_DATASET } from './configTestUtils';
3+
import {
4+
CINE_US_DATASET,
5+
COLOR3D_JPEG_BASELINE_DICOM,
6+
} from './configTestUtils';
47
import { openUrls } from './utils';
58

69
const PLAY_CONTROLS = '.play-controls';
710
const FRAME_LABEL = '.view-annotations .frame-label';
11+
const VIEW_SELECTOR = 'div[data-testid="vtk-view vtk-two-view"]';
12+
13+
async function getFirstCineCanvasStats() {
14+
return browser.execute((selector: string) => {
15+
const views = Array.from(document.querySelectorAll(selector)).filter(
16+
(view) => {
17+
const rect = view.getBoundingClientRect();
18+
return rect.width > 10 && rect.height > 10;
19+
}
20+
);
21+
const canvas = views[0]?.querySelector(
22+
'canvas'
23+
) as HTMLCanvasElement | null;
24+
if (!canvas) return null;
25+
26+
const context = canvas.getContext('2d');
27+
if (!context) return null;
28+
29+
const { width, height } = canvas;
30+
if (width < 2 || height < 2) return { width, height, nonBlack: 0 };
31+
32+
const pixels = context.getImageData(0, 0, width, height).data;
33+
let nonBlack = 0;
34+
const stride = Math.max(4, Math.floor(pixels.length / 4000 / 4) * 4);
35+
36+
for (let i = 0; i < pixels.length; i += stride) {
37+
if (pixels[i] > 5 || pixels[i + 1] > 5 || pixels[i + 2] > 5) {
38+
nonBlack++;
39+
}
40+
}
41+
42+
return { width, height, nonBlack };
43+
}, VIEW_SELECTOR);
44+
}
45+
46+
async function waitForFirstCineCanvasToRender(timeout = 5000) {
47+
await browser.waitUntil(
48+
async () => {
49+
const stats = await getFirstCineCanvasStats();
50+
return (
51+
!!stats && stats.width > 10 && stats.height > 10 && stats.nonBlack > 0
52+
);
53+
},
54+
{
55+
timeout,
56+
interval: 50,
57+
timeoutMsg: 'Expected cine canvas to stay non-black',
58+
}
59+
);
60+
}
861

962
describe('VolView cine playback', () => {
1063
it('loads a multi-frame ultrasound and scrubs frames', async () => {
@@ -35,4 +88,25 @@ describe('VolView cine playback', () => {
3588
expect(after).toMatch(/^Frame: [1-8] \/ 8$/);
3689
expect(after).not.toBe(initialText);
3790
});
91+
92+
it('keeps rendering after repeated maximize toggles during playback', async () => {
93+
await openUrls([COLOR3D_JPEG_BASELINE_DICOM]);
94+
95+
const playButton = $(`${PLAY_CONTROLS} .play-btn`);
96+
await playButton.waitForDisplayed({ timeout: 10000 });
97+
await playButton.click();
98+
99+
await $(VIEW_SELECTOR).waitForDisplayed({
100+
timeout: 10000,
101+
});
102+
await waitForFirstCineCanvasToRender();
103+
104+
for (let i = 0; i < 12; i++) {
105+
const firstView = $(VIEW_SELECTOR);
106+
await firstView.waitForDisplayed({ timeout: 5000 });
107+
await firstView.doubleClick();
108+
await browser.pause(50);
109+
await waitForFirstCineCanvasToRender(1500);
110+
}
111+
});
38112
});

tests/specs/configTestUtils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export const CINE_US_DATASET = {
8888
name: 'US-MONO2-8-8x-echo.dcm',
8989
} as const;
9090

91+
// 120-frame RGB ultrasound cine encoded as JPEG Baseline. This exercises the
92+
// async browser JPEG decode path used by common compressed ultrasound clips.
93+
export const COLOR3D_JPEG_BASELINE_DICOM = {
94+
url: 'https://raw.githubusercontent.com/pydicom/pydicom-data/master/data_store/data/color3d_jpeg_baseline.dcm',
95+
name: 'color3d_jpeg_baseline.dcm',
96+
} as const;
97+
9198
export type DatasetResource = {
9299
url: string;
93100
name?: string;

0 commit comments

Comments
 (0)