Skip to content

Commit 2806264

Browse files
committed
fix(cine): persist current frame
1 parent c45511a commit 2806264

6 files changed

Lines changed: 57 additions & 16 deletions

File tree

src/io/state-file/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
LayersConfig,
2323
SegmentGroupConfig,
2424
VolumeColorConfig,
25+
CinePlaybackViewConfig,
2526
} from '@/src/store/view-configs/types';
2627
import type { LPSAxis } from '@/src/types/lps';
2728
import type {
@@ -270,13 +271,18 @@ const SegmentGroupConfig = z.object({
270271
outlineThickness: z.number(),
271272
}) satisfies z.ZodType<SegmentGroupConfig>;
272273

274+
const CinePlaybackViewConfig = z.object({
275+
frame: z.number(),
276+
}) satisfies z.ZodType<CinePlaybackViewConfig>;
277+
273278
const ViewConfig = z.object({
274279
window: WindowLevelConfig.optional(),
275280
slice: SliceConfig.optional(),
276281
layers: LayersConfig.optional(),
277282
segmentGroup: SegmentGroupConfig.optional(),
278283
camera: CameraConfig.optional(),
279284
volumeColorConfig: VolumeColorConfig.optional(),
285+
cinePlayback: CinePlaybackViewConfig.optional(),
280286
});
281287

282288
export type ViewConfig = z.infer<typeof ViewConfig>;

src/io/state-file/serialize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { migrateManifest } from '@/src/io/state-file/migrations';
1313
import { useViewConfigStore } from '@/src/store/view-configs';
1414

1515
export const MANIFEST = 'manifest.json';
16-
export const MANIFEST_VERSION = '6.2.0';
16+
export const MANIFEST_VERSION = '6.3.0';
1717

1818
export async function serialize() {
1919
const datasetStore = useDatasetStore();

src/store/view-configs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const useViewConfigStore = defineStore('viewConfig', () => {
4646
layerColoringStore.serialize(stateFile);
4747
viewCameraStore.serialize(stateFile);
4848
volumeColoringStore.serialize(stateFile);
49+
cinePlaybackStore.serialize(stateFile);
4950
};
5051

5152
const deserialize = (
@@ -65,6 +66,7 @@ export const useViewConfigStore = defineStore('viewConfig', () => {
6566
layerColoringStore.deserialize(viewID, updatedConfig);
6667
viewCameraStore.deserialize(viewID, updatedConfig);
6768
volumeColoringStore.deserialize(viewID, updatedConfig);
69+
cinePlaybackStore.deserialize(viewID, updatedConfig);
6870
};
6971

7072
const deserializeAll = (

src/store/view-configs/cine-playback.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
patchDoubleKeyRecord,
1010
} from '@/src/utils/doubleKeyRecord';
1111
import { getCineImage } from '@/src/core/cine/isCineImage';
12+
import { createViewConfigSerializer } from '@/src/store/view-configs/common';
13+
import type { StateFile, ViewConfig } from '@/src/io/state-file/schema';
14+
import type { CinePlaybackViewConfig } from '@/src/store/view-configs/types';
1215

1316
export const MIN_CINE_FPS = 1;
1417
export const MAX_CINE_FPS = 120;
@@ -94,12 +97,33 @@ export const useCinePlaybackStore = defineStore('cinePlayback', () => {
9497
}
9598
};
9699

100+
const serialize = (stateFile: StateFile) => {
101+
const frameConfigs: DoubleKeyRecord<CinePlaybackViewConfig> = {};
102+
Object.entries(configs).forEach(([viewID, byDataID]) => {
103+
frameConfigs[viewID] = {};
104+
Object.entries(byDataID).forEach(([dataID, config]) => {
105+
frameConfigs[viewID][dataID] = { frame: config.frame };
106+
});
107+
});
108+
createViewConfigSerializer(frameConfigs, 'cinePlayback')(stateFile);
109+
};
110+
111+
const deserialize = (viewID: string, config: Record<string, ViewConfig>) => {
112+
Object.entries(config).forEach(([dataID, viewConfig]) => {
113+
if (viewConfig.cinePlayback) {
114+
updateConfig(viewID, dataID, { frame: viewConfig.cinePlayback.frame });
115+
}
116+
});
117+
};
118+
97119
return {
98120
configs,
99121
getConfig,
100122
updateConfig,
101123
removeView,
102124
removeData,
125+
serialize,
126+
deserialize,
103127
};
104128
});
105129

src/store/view-configs/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,7 @@ export interface SegmentGroupConfig {
5555
outlineOpacity: number;
5656
outlineThickness: number;
5757
}
58+
59+
export interface CinePlaybackViewConfig {
60+
frame: number;
61+
}

tests/specs/cine-ruler-session.e2e.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
countCineRulerLines,
1414
getCineCanvasCenter,
1515
getCineFrame,
16+
retreatCineFrame,
1617
waitForFrame,
1718
} from './cineTestUtils';
1819

@@ -25,7 +26,7 @@ const placeRulerAtCanvasCenter = async () => {
2526
};
2627

2728
describe('Cine ruler survives save/reload at its placed frame', () => {
28-
it('reloads with the ruler hidden on frame 1 and visible on the placed frame', async () => {
29+
it('reloads at the persisted frame with the ruler visible and hides it on frame 1', async () => {
2930
await openUrls([CINE_US_DATASET]);
3031
await $('.play-controls').waitForDisplayed();
3132
await waitForFrame(1);
@@ -55,13 +56,14 @@ describe('Cine ruler survives save/reload at its placed frame', () => {
5556
await volViewPage.open(`?urls=[tmp/${sessionFileName}]`);
5657
await volViewPage.waitForViews();
5758
await $('.play-controls').waitForDisplayed({ timeout: 30000 });
58-
await waitForFrame(1);
59+
// The cine frame is persisted, so reload restores the cursor to
60+
// placementFrame rather than frame 1.
61+
await waitForFrame(placementFrame);
5962

6063
// Wait for the deserialized ruler to surface in the Measurements list
61-
// before asserting it's hidden on the canvas. Without this anchor a
62-
// regression that loses `frame` (ruler tagged frame 1 instead) could
63-
// race the assertion and pass for the wrong reason; the list entry
64-
// proves deserialization has completed.
64+
// before asserting visibility on the canvas. The list entry proves
65+
// deserialization has completed, so the subsequent canvas checks
66+
// can't race the load.
6567
const annotationsTab = await $(
6668
'button[data-testid="module-tab-Annotations"]'
6769
);
@@ -77,18 +79,21 @@ describe('Cine ruler survives save/reload at its placed frame', () => {
7779
}
7880
);
7981

80-
// On reload the cursor lives at frame 1 (playback frame is not
81-
// persisted). The ruler must be hidden here — if `frame` was lost
82-
// during serialize/deserialize, it would render at frame 1 instead.
83-
expect(await countCineRulerLines()).toBe(0);
82+
// The ruler must be visible at the persisted placement frame — if
83+
// `frame` was lost during serialize/deserialize, it would still
84+
// render here, so we also need to verify it hides elsewhere.
85+
await browser.waitUntil(async () => (await countCineRulerLines()) >= 1, {
86+
timeoutMsg: `Expected the ruler to be visible at the persisted placement frame (${placementFrame}) after reload`,
87+
});
8488

85-
// Scrub forward to the placement frame and the ruler should reappear.
89+
// Scrub back to frame 1 — the ruler must hide there. A regression
90+
// that dropped `frame` would leave it visible on every frame.
8691
await volViewPage.focusFirst2DView();
87-
while ((await getCineFrame())! < placementFrame) {
88-
await advanceCineFrame();
92+
while ((await getCineFrame())! > 1) {
93+
await retreatCineFrame();
8994
}
90-
await browser.waitUntil(async () => (await countCineRulerLines()) >= 1, {
91-
timeoutMsg: `Expected the ruler to reappear at the placement frame (${placementFrame}) after reload`,
95+
await browser.waitUntil(async () => (await countCineRulerLines()) === 0, {
96+
timeoutMsg: 'Expected the ruler to hide on frame 1 after reload',
9297
});
9398
});
9499
});

0 commit comments

Comments
 (0)