Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b826748
feat(cine): add ultrasound multi-frame DICOM playback
PaulHax May 14, 2026
0840313
refactor(image): unify thumbnail API and replace instanceof with volu…
PaulHax May 14, 2026
452fff6
feat(cine): per-view render buffers for multi-view playback
PaulHax May 14, 2026
3cc4aef
refactor(image): drop unused ThumbnailStrategy parameter
PaulHax May 14, 2026
8236569
fix(views): don't steal active view on programmatic slice changes
PaulHax May 14, 2026
47d2dc7
fix(tools): make Reveal Slice work for cine images
PaulHax May 14, 2026
4795212
style(format): apply prettier to cine and ultrasound files
PaulHax May 15, 2026
c4327b2
fix(views): keep active view selected after data binding
PaulHax May 15, 2026
ee8edc1
fix(cine): delay slice mapper until frame decode
PaulHax May 15, 2026
c95fd5a
fix(cine): fall back for unsupported multiframe formats
PaulHax May 15, 2026
fae1271
feat(cine): add playback controls
PaulHax May 15, 2026
0554880
fix: handle cine data view replacement
PaulHax May 15, 2026
461472e
fix: address cine merge review items
PaulHax May 15, 2026
6086c23
fix(cine): address remaining review items
PaulHax May 15, 2026
61509fe
refactor(cine): fold tool-slice fallback into getRenderSlice
PaulHax May 15, 2026
7df617c
test(cine): cover playback, dual-view focus, and remount rendering
PaulHax May 15, 2026
e1d01f5
refactor(cine): simplify PlayControls config helpers and watches
PaulHax May 15, 2026
03dd6cf
fix(cine): hide view-type switcher and force axial for cine views
PaulHax May 15, 2026
245b7e7
refactor(cine): inline single-use computeds and dedupe cine checks
PaulHax May 15, 2026
2b0278c
refactor(cine): inline single-use helpers in DicomCineImage
PaulHax May 15, 2026
bcf408c
fix: harden cine dicom import
PaulHax May 15, 2026
245b856
fix(views): restore camera before clipping-range reset on remount
PaulHax May 15, 2026
7ea1122
fix(vtk): cancel pending interactor animation on dispose
PaulHax May 15, 2026
f77da91
test: load segment group fixture directly
PaulHax May 15, 2026
24de941
fix(cine): resolve view axis through cine override for slice updates
PaulHax May 15, 2026
1baca1e
test: extend state manifest e2e timeout
PaulHax May 15, 2026
931f6a0
refactor(cine): tighten comments and types from simplify review
PaulHax May 15, 2026
a1f793e
refactor(cine): route remaining view-axis call sites through getEffec…
PaulHax May 15, 2026
fb5acea
refactor(cine): dedupe helpers and guard no-op config writes
PaulHax May 15, 2026
446f955
perf(cine): decode JPEG frames in a worker and prefetch next frame
PaulHax May 15, 2026
042de68
perf(cine): pack RGB in the JPEG decode worker
PaulHax May 15, 2026
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
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"deep-equal": "^2.2.3",
"dicom-parser": "^1.8.21",
"dicomweb-client-typed": "^0.8.6",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
2 changes: 1 addition & 1 deletion src/components/LayoutGridItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ function onDrop(event: DragEvent) {

const droppedImageID = event.dataTransfer?.getData(IMAGE_DRAG_MEDIA_TYPE);
if (droppedImageID) {
viewStore.setDataForView(props.viewId, droppedImageID);
viewStore.setActiveView(props.viewId);
viewStore.setDataForView(props.viewId, droppedImageID);
}
}
</script>
Expand Down
10 changes: 3 additions & 7 deletions src/components/PatientStudyVolumeBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { computed, defineComponent, reactive, toRefs, watch } from 'vue';
import type { PropType } from 'vue';
import GroupableItem from '@/src/components/GroupableItem.vue';
import { DataSelection, isDicomImage } from '@/src/utils/dataSelection';
import { ThumbnailStrategy } from '@/src/core/streaming/chunkImage';
import { useImageCacheStore } from '@/src/store/image-cache';
import DicomChunkImage from '@/src/core/streaming/dicomChunkImage';
import { getDisplayName, useDICOMStore } from '@/src/store/datasets-dicom';
import { useDatasetStore } from '@/src/store/datasets';
import { useMultiSelection } from '@/src/composables/useMultiSelection';
Expand Down Expand Up @@ -100,13 +98,11 @@ export default defineComponent({
}

const image = imageCacheStore.imageById[key];
if (!image || !(image instanceof DicomChunkImage)) return;
if (!image) return;

try {
const thumb = await image.getThumbnail(
ThumbnailStrategy.MiddleSlice
);
if (thumb !== null) {
const thumb = await image.getThumbnail();
if (thumb) {
thumbnailCache[cacheKey] = { kind: 'image', value: thumb };
} else {
thumbnailCache[cacheKey] = {
Expand Down
199 changes: 199 additions & 0 deletions src/components/PlayControls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, ref, toRefs, watch } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import { Maybe } from '@/src/types';
import { useSliceConfig } from '@/src/composables/useSliceConfig';
import { getCineImage } from '@/src/core/cine/isCineImage';
import {
clampCineFps,
MAX_CINE_FPS,
MIN_CINE_FPS,
useCinePlaybackStore,
} from '@/src/store/view-configs/cine-playback';

type Props = {
viewId: string;
imageId: Maybe<string>;
};

const props = defineProps<Props>();
const { imageId, viewId } = toRefs(props);

const cine = computed(() => getCineImage(imageId.value));
const { slice, range } = useSliceConfig(viewId, imageId);
const playbackStore = useCinePlaybackStore();

const playbackConfig = computed(() =>
playbackStore.getConfig(
viewId.value,
imageId.value,
cine.value?.header.frameTimeMs
)
);

function patchConfig(
clipId: Maybe<string>,
patch: Parameters<typeof playbackStore.updateConfig>[2]
) {
if (!clipId) return;
playbackStore.updateConfig(
viewId.value,
clipId,
patch,
getCineImage(clipId)?.header.frameTimeMs
);
}

const playing = computed({
get: () => playbackConfig.value.playing,
set: (value: boolean) => patchConfig(imageId.value, { playing: value }),
});

const fps = computed({
get: () => playbackConfig.value.fps,
set: (value: number) => patchConfig(imageId.value, { fps: value }),
});

const period = computed(() => Math.round(1000 / fps.value));

const { pause, resume } = useIntervalFn(
() => {
const [min, max] = range.value;
if (max <= min) return;
const next = (slice.value ?? min) + 1;
slice.value = next > max ? min : next;
},
period,
{ immediate: false, immediateCallback: false }
);

watch(
playing,
(isPlaying) => {
if (isPlaying) resume();
else pause();
},
{ immediate: true }
);

// Pause both sides on clip switch so an incoming clip with stored playing=true
// doesn't auto-resume.
watch(imageId, (nextImageId, previousImageId) => {
if (nextImageId === previousImageId) return;
patchConfig(previousImageId, { playing: false });
patchConfig(nextImageId, { playing: false });
});

const fpsInput = ref(String(fps.value));
watch(fps, (value) => {
fpsInput.value = String(value);
});

function clampFpsInput(event: Event) {
const input = event.target as HTMLInputElement;
fpsInput.value = input.value;
if (fpsInput.value.trim() === '') return;

const clamped = clampCineFps(fpsInput.value);
if (clamped == null) return;
fps.value = clamped;
fpsInput.value = String(clamped);
input.value = fpsInput.value;
}

function commitFpsInput() {
const clamped = clampCineFps(fpsInput.value) ?? fps.value;
fps.value = clamped;
fpsInput.value = String(clamped);
}

function togglePlay() {
playing.value = !playing.value;
}
</script>

<template>
<div v-if="cine" class="play-controls pointer-events-all" @dblclick.stop>
<button
type="button"
class="play-btn"
:title="playing ? 'Pause' : 'Play'"
:aria-pressed="playing"
:aria-label="playing ? 'Pause cine playback' : 'Play cine playback'"
@click="togglePlay"
>
<v-icon size="14">{{ playing ? 'mdi-pause' : 'mdi-play' }}</v-icon>
</button>
<span class="fps-control">
<input
:value="fpsInput"
type="number"
:min="MIN_CINE_FPS"
:max="MAX_CINE_FPS"
class="fps-input"
title="Frames per second"
@input="clampFpsInput"
@blur="commitFpsInput"
/>
<span class="fps-suffix">FPS</span>
</span>
</div>
</template>

<style scoped>
.play-controls {
display: inline-flex;
align-items: center;
gap: 4px;
color: #fff;
font-size: inherit;
line-height: inherit;
}

.play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
opacity: 1;
}

.play-btn :deep(.v-icon) {
opacity: 1;
}

.play-btn:hover {
color: rgba(255, 255, 255, 0.8);
}

.fps-control {
display: inline-flex;
align-items: center;
gap: 4px;
}

.fps-input {
width: 44px;
background: transparent;
color: inherit;
border: none;
padding: 0 4px;
text-align: right;
font: inherit;
opacity: 1;
}

.fps-input:focus {
outline: 1px solid rgba(255, 255, 255, 0.3);
}

.fps-suffix {
opacity: 1;
}
</style>
60 changes: 35 additions & 25 deletions src/components/SliceViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
<bounding-rectangle :points="selectionPoints" />
</svg>
<scalar-probe
v-if="!isCine"
:base-rep="baseSliceRep"
:layer-reps="layerSliceReps"
:segment-groups-reps="segSliceReps"
Expand All @@ -158,10 +159,11 @@
</template>

<script setup lang="ts">
import { ref, toRefs, computed } from 'vue';
import { ref, toRefs, computed, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import { getLPSAxisFromDir } from '@/src/utils/lps';
import { useProbeStore } from '@/src/store/probe';
import { getEffectiveViewAxis } from '@/src/core/cine/getEffectiveViewAxis';
import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue';
import { VtkViewApi } from '@/src/types/vtk-types';
import { Tools } from '@/src/store/tools/types';
Expand All @@ -186,6 +188,7 @@ import { useAnnotationToolStore, useToolStore } from '@/src/store/tools';
import { doesToolFrameMatchViewAxis } from '@/src/composables/annotationTool';
import { useWebGLWatchdog } from '@/src/composables/useWebGLWatchdog';
import { useSliceConfig } from '@/src/composables/useSliceConfig';
import { isCineImage } from '@/src/core/cine/isCineImage';
import VtkSliceViewWindowManipulator from '@/src/components/vtk/VtkSliceViewWindowManipulator.vue';
import VtkSliceViewSlicingManipulator from '@/src/components/vtk/VtkSliceViewSlicingManipulator.vue';
import VtkSliceViewSlicingKeyManipulator from '@/src/components/vtk/VtkSliceViewSlicingKeyManipulator.vue';
Expand All @@ -195,17 +198,13 @@ import vtkMouseCameraTrackballZoomToMouseManipulator from '@kitware/vtk.js/Inter
import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { useViewStore } from '@/src/store/views';
import { LPSAxis } from '@/src/types/lps';
import { ViewInfo2D } from '@/src/types/views';
import { get2DViewingVectors } from '@/src/utils/getViewingVectors';

interface Props {
viewId: string;
}

interface SliceViewerOptions {
orientation: LPSAxis;
}

const vtkView = ref<VtkViewApi>();
const baseSliceRep = ref();
const layerSliceReps = ref([]);
Expand All @@ -215,17 +214,24 @@ const props = defineProps<Props>();
const { viewId } = toRefs(props);

const viewStore = useViewStore();
const viewInfo = computed(() => viewStore.getView(viewId.value)!);
const viewOptions = computed(
() => viewInfo.value.options as SliceViewerOptions
);
const viewInfo = computed(() => viewStore.getView(viewId.value) as ViewInfo2D);

const viewingVectors = computed(() =>
get2DViewingVectors(viewOptions.value.orientation)
// base image
const {
currentImageID,
currentLayers,
currentImageMetadata,
currentImageData,
isImageLoading,
} = useCurrentImage();

const isCine = computed(() => isCineImage(currentImageID.value));
const viewAxis = computed(() =>
getEffectiveViewAxis(viewInfo.value, currentImageID.value)
);
const viewingVectors = computed(() => get2DViewingVectors(viewAxis.value));
const viewDirection = computed(() => viewingVectors.value.viewDirection);
const viewUp = computed(() => viewingVectors.value.viewUp);
const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value));

const hover = ref(false);

Expand All @@ -240,23 +246,27 @@ useViewAnimationListener(vtkView, viewId, '2D');

// active tool
const { currentTool } = storeToRefs(useToolStore());
const windowingManipulatorProps = computed(() =>
currentTool.value === Tools.WindowLevel ? { button: 1 } : { button: -1 }
);

// base image
const {
currentImageID,
currentLayers,
currentImageMetadata,
currentImageData,
isImageLoading,
} = useCurrentImage();
const { slice: currentSlice, range: sliceRange } = useSliceConfig(
viewId,
currentImageID
);

const windowingManipulatorProps = computed(() => {
// W/L is meaningless for 8-bit display-encoded cine; keep the manipulator
// off so dragging doesn't crush colors.
if (currentTool.value !== Tools.WindowLevel) return { button: -1 };
if (isCine.value) return { button: -1 };
return { button: 1 };
});

// Scalar probe samples the canonical image; for cine that's frame-0-only.
// Unmount it for cine views and clear any stale probe data on switch.
const probeStore = useProbeStore();
watch(isCine, (cine) => {
if (cine) probeStore.clearProbeData();
});

onVTKEvent(currentImageData, 'onModified', () => {
vtkView.value?.requestRender();
});
Expand Down
Loading
Loading