Merge branch 'bugfix/streamContent_leaks' into 'v3'

ui_migration bugfix(MJPEG) fix logic and lifecycle leaks at StreamDisplay, MiniStreamDisplay keeping request open in the background.

Closes #550

See merge request openflexure/openflexure-microscope-server!605
This commit is contained in:
Julian Stirling 2026-06-05 10:04:38 +00:00
commit 674e52e224
13 changed files with 204 additions and 55 deletions

View file

@ -5,7 +5,7 @@
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget" class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
> >
<img <img
v-if="isVisible" v-show="isVisible && streamEnabled"
ref="click-frame" ref="click-frame"
class="uk-align-center uk-margin-remove-bottom" class="uk-align-center uk-margin-remove-bottom"
:src="streamImgUri" :src="streamImgUri"
@ -19,33 +19,87 @@ import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js"; import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia"; import { mapState } from "pinia";
const ONE_PIXEL_FALLBACK = "data:image/PNG;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
// Export main app // Export main app
export default { export default {
name: "MiniStreamDisplay", name: "MiniStreamDisplay",
props: {
streamId: {
type: [String],
required: true,
},
},
setup() {
const store = useSettingsStore();
return {
store,
};
},
data: function () { data: function () {
return { return {
isVisible: false, isVisible: false,
observerTimeout: null,
}; };
}, },
computed: { computed: {
...mapState(useSettingsStore, ["baseUri"]), ...mapState(useSettingsStore, ["baseUri", "disableStream", "ready"]),
streamImgUri() { streamEnabled: function () {
return `${this.baseUri}/camera/mjpeg_stream`; return this.ready && !this.disableStream;
},
streamImgUri: function () {
// Require BOTH visibility AND the enabled state
if (this.isVisible && this.streamEnabled) {
const url = new URL(`${this.baseUri}/camera/mjpeg_stream`);
url.searchParams.append("streamId", this.streamId);
return url.toString();
}
// Force the browser to kill the connection by loading a 1 pixel image
return ONE_PIXEL_FALLBACK;
}, },
}, },
mounted() { mounted() {
useIntersectionObserver( const { stop } = useIntersectionObserver(
this.$refs.streamDisplay, this.$refs.streamDisplay,
([{ isIntersecting }]) => { ([{ isIntersecting }]) => {
// Always update local visibility instantly
this.isVisible = isIntersecting; this.isVisible = isIntersecting;
// Clear any previous pending timers to prevent ghost fires
clearTimeout(this.observerTimeout);
if (isIntersecting) {
// If it's TRUE, we don't want to wait! Turn the stream on immediately.
this.store.addStream(this.streamId);
} else {
// If it's FALSE, wait 50ms before telling the store.
// If a TRUE event fires in the next 50ms (like during a tab switch),
// this timeout will be cancelled and the stream will never flicker off!
this.observerTimeout = setTimeout(() => {
this.store.removeStream(this.streamId);
}, 50);
}
}, },
{ { threshold: 0.0 },
threshold: 0.0,
},
); );
this.stopIntersectionObserver = stop;
},
beforeUnmount() {
clearTimeout(this.observerTimeout); // Clean up the timer!
if (this.stopIntersectionObserver) {
this.stopIntersectionObserver();
}
this.store.removeStream(this.streamId);
// Override img source to single pixel and drop MJPEG stream
const imgElement = this.$refs["click-frame"];
if (imgElement) {
imgElement.src = ONE_PIXEL_FALLBACK;
}
}, },
}; };
</script> </script>

View file

@ -2,7 +2,11 @@
<div ref="modal" class="" uk-modal="bg-close: false; esc-close: false; stack: true;"> <div ref="modal" class="" uk-modal="bg-close: false; esc-close: false; stack: true;">
<div id="status-modal" class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical"> <div id="status-modal" class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical">
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<mini-stream-display v-if="displayStream" class="uk-margin-small-bottom" /> <mini-stream-display
v-if="displayStream"
:stream-id="setStreamId"
class="uk-margin-small-bottom"
/>
<action-log-display :log="log" :task-status="taskStatus" /> <action-log-display :log="log" :task-status="taskStatus" />
<div id="progress-and-cancel-row"> <div id="progress-and-cancel-row">
<div class="stretchy"> <div class="stretchy">
@ -79,6 +83,13 @@ export default {
emits: ["terminateTask"], emits: ["terminateTask"],
data() {
return {
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
};
},
methods: { methods: {
show() { show() {
UIkit.modal(this.$refs.modal).show(); UIkit.modal(this.$refs.modal).show();

View file

@ -68,8 +68,8 @@ export default {
this.$refs["calibrationModalEl"].addEventListener("hidden", this.onHide); this.$refs["calibrationModalEl"].addEventListener("hidden", this.onHide);
// Check which Things are available on mount. // Check which Things are available on mount.
const allCalibrationTasks = { const allCalibrationTasks = {
camera: cameraCalibrationTask, camera: markRaw(cameraCalibrationTask),
camera_stage_mapping: cameraStageMappingTask, camera_stage_mapping: markRaw(cameraStageMappingTask),
}; };
this.availableCalibrationTasks = Object.fromEntries( this.availableCalibrationTasks = Object.fromEntries(
Object.entries(allCalibrationTasks).filter(([thing]) => this.thingAvailable(thing)), Object.entries(allCalibrationTasks).filter(([thing]) => this.thingAvailable(thing)),

View file

@ -18,7 +18,7 @@ import runCsmStep from "./csmSteps/runCsmStep.vue";
import { markRaw } from "vue"; import { markRaw } from "vue";
export default { export default {
name: "CameraCalibrationTask", name: "CameraStageMappingTask",
components: { components: {
calibrationWizardTask, calibrationWizardTask,
}, },

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<slot></slot> <slot></slot>
<miniStreamDisplay class="mini-preview" /> <miniStreamDisplay class="mini-preview" :stream-id="setStreamId" />
<slot name="below-stream"></slot> <slot name="below-stream"></slot>
</div> </div>
</template> </template>
@ -15,6 +15,13 @@ export default {
components: { components: {
miniStreamDisplay, miniStreamDisplay,
}, },
data() {
return {
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
};
},
}; };
</script> </script>

View file

@ -53,7 +53,7 @@ mode.
</div> </div>
<div v-if="taskStarted"> <div v-if="taskStarted">
<h2 v-if="taskInfoTitle" style="text-align: center">{{ taskInfoTitle }}</h2> <h2 v-if="taskInfoTitle" style="text-align: center">{{ taskInfoTitle }}</h2>
<mini-stream-display v-if="taskInfoStream" /> <mini-stream-display v-if="taskInfoStream" :stream-id="setStreamId" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" /> <action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<div class="uk-width-1-1 uk-flex uk-flex-center"> <div class="uk-width-1-1 uk-flex uk-flex-center">
<div class="uk-width-2-3"> <div class="uk-width-2-3">
@ -141,6 +141,7 @@ export default {
progress: null, progress: null,
log: [], log: [],
pollTimer: null, pollTimer: null,
setStreamId: this.$options.name,
}; };
}, },

View file

@ -34,6 +34,7 @@ import axios from "axios";
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import positionControl from "./positionControl.vue"; import positionControl from "./positionControl.vue";
import autofocusControl from "./autofocusControl.vue"; import autofocusControl from "./autofocusControl.vue";
import { eventBus } from "../../../eventBus.js";
export default { export default {
name: "PaneControl", name: "PaneControl",
@ -61,8 +62,12 @@ export default {
this.modalError("No image URI returned from capture task."); this.modalError("No image URI returned from capture task.");
return; return;
} }
// Emit flash capture stream animation
eventBus.emit("globalFlashStream");
// To save the returned data, we make a virtual link and click it // To save the returned data, we make a virtual link and click it
let imageResponse = await axios.get(imageUri, { responseType: "blob" }); let imageResponse = await axios.get(imageUri, { responseType: "blob" });
const url = window.URL.createObjectURL(new Blob([imageResponse.data])); const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;

View file

@ -5,7 +5,7 @@
<paneControl /> <paneControl />
</div> </div>
<div class="view-component uk-width-expand"> <div class="view-component uk-width-expand">
<streamDisplay /> <streamDisplay :stream-id="setStreamId" />
</div> </div>
</div> </div>
</template> </template>
@ -21,6 +21,13 @@ export default {
paneControl, paneControl,
streamDisplay, streamDisplay,
}, },
data() {
return {
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
};
},
}; };
</script> </script>

View file

@ -10,7 +10,7 @@
<CSMCalibrationSettings /> <CSMCalibrationSettings />
</div> </div>
<div id="mini-stream"> <div id="mini-stream">
<miniStreamDisplay /> <miniStreamDisplay :stream-id="setStreamId" />
</div> </div>
</div> </div>
</div> </div>
@ -28,6 +28,13 @@ export default {
CSMCalibrationSettings, CSMCalibrationSettings,
miniStreamDisplay, miniStreamDisplay,
}, },
data() {
return {
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
};
},
}; };
</script> </script>

View file

@ -15,7 +15,7 @@
</div> </div>
<div id="mini-stream"> <div id="mini-stream">
<miniStreamDisplay /> <miniStreamDisplay :stream-id="setStreamId" />
</div> </div>
</div> </div>
</div> </div>
@ -41,6 +41,8 @@ export default {
data() { data() {
return { return {
manualCameraSettings: [], manualCameraSettings: [],
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
}; };
}, },

View file

@ -17,7 +17,7 @@
class="image-fit" class="image-fit"
:src="lastStitchedImage" :src="lastStitchedImage"
/> />
<streamDisplay v-else /> <streamDisplay v-else :stream-id="setStreamId" />
<template #controls> <template #controls>
<slideScanControls /> <slideScanControls />
<label class="uk-form-label" for="form-stacked-text">Sample ID</label> <label class="uk-form-label" for="form-stacked-text">Sample ID</label>
@ -81,6 +81,8 @@ export default {
lastStitchedImage: null, lastStitchedImage: null,
scanComplete: false, scanComplete: false,
scan_name: "", scan_name: "",
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
}; };
}, },

View file

@ -5,10 +5,9 @@
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget" class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
> >
<img <img
v-if="isVisible" v-show="isVisible && streamEnabled"
ref="click-frame" ref="click-frame"
class="uk-align-center uk-margin-remove-bottom" class="uk-align-center uk-margin-remove-bottom"
:hidden="!streamEnabled"
:src="streamImgUri" :src="streamImgUri"
alt="Stream" alt="Stream"
@dblclick="clickMonitor" @dblclick="clickMonitor"
@ -36,10 +35,19 @@ import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js"; import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia"; import { mapState } from "pinia";
const ONE_PIXEL_FALLBACK = "data:image/PNG;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
// Export main app // Export main app
export default { export default {
name: "StreamDisplay", name: "StreamDisplay",
props: {
streamId: {
type: [String],
required: true,
},
},
setup() { setup() {
const store = useSettingsStore(); const store = useSettingsStore();
return { return {
@ -52,7 +60,7 @@ export default {
isVisible: false, isVisible: false,
displaySize: [0, 0], displaySize: [0, 0],
displayPosition: [0, 0], displayPosition: [0, 0],
resizeTimeoutId: setTimeout(this.doneResizing, 500), resizeTimeoutId: null,
}; };
}, },
@ -61,42 +69,47 @@ export default {
streamEnabled: function () { streamEnabled: function () {
return this.ready && !this.disableStream; return this.ready && !this.disableStream;
}, },
thisStreamOpen: function () {
// Only a single MJPEG connection should be open at a time
return !(this.displaySize[0] == 0) && !(this.displaySize[1] == 0);
},
streamImgUri: function () { streamImgUri: function () {
return `${this.baseUri}/camera/mjpeg_stream`; // Only request the real stream if it's enabled AND currently visible on screen
if (this.isVisible && this.streamEnabled) {
const url = new URL(`${this.baseUri}/camera/mjpeg_stream`);
url.searchParams.append("streamId", this.streamId);
return url.toString();
}
// Force the browser to kill the connection by loading a 1 pixel image
return ONE_PIXEL_FALLBACK;
}, },
}, },
mounted() { mounted() {
//set up an intersection observer // Initial Timeout
useIntersectionObserver( this.resizeTimeoutId = setTimeout(this.handleDoneResize, 500);
// set up an intersection observer
// We capture the 'stop' function returned by VueUse so we can kill it later
const { stop } = useIntersectionObserver(
this.$refs.streamDisplay, this.$refs.streamDisplay,
([{ isIntersecting }]) => { ([{ isIntersecting }]) => {
this.isVisible = isIntersecting; this.isVisible = isIntersecting;
}, },
{ { threshold: 0.0 },
threshold: 0.0,
},
); );
this.stopIntersectionObserver = stop;
// A global signal listener to flash the stream element // A global signal listener to flash the stream element
this.onFlashStream = () => { eventBus.on("globalFlashStream", this.flashStream);
this.flashStream();
};
eventBus.on("globalFlashStream", this.onFlashStream);
// Mutation observer // Mutation observer
this.sizeObserver = new ResizeObserver(() => { this.sizeObserver = new ResizeObserver(() => {
this.handleResize(); // For any element attached to the observer, run handleResize() on change this.handleResize(); // For any element attached to the observer, run handleResize() on change
}); });
// Fetch streamDisplay component by ref
if (this.$refs.streamDisplay && this.$refs.streamDisplay.parentNode) { // Safely fetch the DOM element (handles both native HTML and Vue components)
const streamDisplayElement = this.$refs.streamDisplay.parentNode; const streamElement = this.$refs.streamDisplay?.$el || this.$refs.streamDisplay;
if (streamElement && streamElement.parentNode) {
// Attach streamDisplay to the size observer // Attach streamDisplay to the size observer
this.sizeObserver.observe(streamDisplayElement); this.sizeObserver.observe(streamElement.parentNode);
} }
}, },
@ -104,24 +117,58 @@ export default {
// Do nothing: preview stream now runs all the time // Do nothing: preview stream now runs all the time
}, },
beforeUnmount: function () { beforeUnmount() {
// Disconnect the size observer // Kill the resize timeout
this.sizeObserver.disconnect(); clearTimeout(this.resizeTimeoutId);
// Remove from the array of active streams
this.store.removeStream(this.$.uid); // Kill the flash animation timeout (Fixes Beth's MR comment)
// close signal if (this.flashTimeoutId) {
eventBus.off("globalFlashStream", this.onFlashStream); clearTimeout(this.flashTimeoutId);
}
// Kill the Intersection Observer
if (this.stopIntersectionObserver) {
this.stopIntersectionObserver();
}
// Unregister the event bus listener
eventBus.off("globalFlashStream", this.flashStream);
// Disconnect the size Observer
if (this.sizeObserver) {
this.sizeObserver.disconnect();
this.store.removeStream(this.streamId);
}
const imgElement = this.$refs["click-frame"];
if (imgElement) {
imgElement.src = ONE_PIXEL_FALLBACK;
}
}, },
methods: { methods: {
flashStream: function () { flashStream: function () {
// Run an animation that flashes the stream (for capture feedback) // Run an animation that flashes the stream (for capture feedback)
let element = this.$refs.streamDisplay; let element = this.$refs.streamDisplay;
// Defensive check in case the ref isn't available
if (!element) return;
element.classList.remove("uk-animation-fade"); element.classList.remove("uk-animation-fade");
element.offsetHeight; /* trigger reflow */ /* trigger reflow */
element.offsetHeight;
element.classList.add("uk-animation-fade"); element.classList.add("uk-animation-fade");
setTimeout(function () {
element.classList.remove("uk-animation-fade"); // Clear the timer if flashStream is called again before the 800ms is up
if (this.flashTimeoutId) {
clearTimeout(this.flashTimeoutId);
}
// Track the timeout ID
this.flashTimeoutId = setTimeout(() => {
// Extra defensive check just in case
if (element && element.classList) {
element.classList.remove("uk-animation-fade");
}
}, 800); }, 800);
}, },
@ -158,17 +205,16 @@ export default {
handleDoneResize: function () { handleDoneResize: function () {
// Recalculate size // Recalculate size
this.recalculateSize(); this.recalculateSize();
// Handle closed stream // Handle closed stream
if (this.displaySize[0] == 0 && this.displaySize[1] == 0) { if (this.displaySize[0] == 0 && this.displaySize[1] == 0) {
// If this stream was previously active // If this stream was previously active
if (this.store.activeStreams[this.$.uid] == true) { if (this.store.activeStreams[this.streamId] == true) {
this.store.removeStream(this.$.uid); this.store.removeStream(this.streamId);
} }
// If resized to anything other than zero // If resized to anything other than zero
} else { } else {
this.store.addStream(this.$.uid); this.store.addStream(this.streamId);
} }
}, },

View file

@ -2,7 +2,7 @@
<!-- Grid managing tab content --> <!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="view-component uk-width-expand"> <div class="view-component uk-width-expand">
<streamDisplay /> <streamDisplay :stream-id="setStreamId" />
</div> </div>
</div> </div>
</template> </template>
@ -16,5 +16,12 @@ export default {
components: { components: {
streamDisplay, streamDisplay,
}, },
data() {
return {
// This adds the parent name as value for prop streamId
setStreamId: this.$options.name,
};
},
}; };
</script> </script>