From cfbb4c708f29a64ee8c8afd545b887484ac74ccc Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Wed, 26 Nov 2025 16:35:00 +0100 Subject: [PATCH] fix: preserve widget object identity for proper reactivity in webcam capture Mutate widget objects in place instead of creating new instances to fix a reactivity issue where serializeValue closure captured stale widget references. Also adds persistent video element for background capture when UI video is hidden. Fixes capture_on_queue toggle not being read correctly during serialization. --- src/locales/en/main.json | 10 +++ .../widgets/components/WidgetWebcam.vue | 77 +++++++++++-------- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 2b867ae17..1daf96df9 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -144,6 +144,16 @@ "choose_file_to_upload": "choose file to upload", "capture": "capture", "capturePhoto": "Capture photo", + "captureModeOnRun": "On Run", + "captureModeManual": "Manually", + "captureImage": "Capture", + "retakePhoto": "Retake photo", + "clickToStopLivePreview": "Click to stop live preview", + "failedToCaptureImage": "Failed to capture image", + "noWebcamImageCaptured": "No webcam image captured", + "errorCapturingImage": "Error capturing image: {error}", + "unableToLoadWebcam": "Unable to load webcam: {error}", + "webcamRequiresTLS": "Unable to load webcam. TLS is required when not on localhost. Error: {error}", "turnOnCamera": "Turn on Camera", "nodes": "Nodes", "community": "Community", diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue index 70d703ed1..0dc81ba79 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue @@ -37,7 +37,7 @@
@@ -84,6 +84,11 @@ const videoContainerRef = ref() const stream = ref(null) const isHovered = useElementHover(videoContainerRef) const canvas = document.createElement('canvas') +// Persistent video element for capture - not in DOM template but keeps stream active +const persistentVideo = document.createElement('video') +persistentVideo.autoplay = true +persistentVideo.muted = true +persistentVideo.playsInline = true const capturedImageUrl = ref(null) const lastUploadedPath = ref(null) @@ -141,31 +146,27 @@ function applyWidgetVisibility( if (!TOGGLED_WIDGET_NAMES.has(widget.name)) return widget if (widget.name === 'capture_on_queue') { - return { - ...widget, - type: 'selectToggle', - label: 'Capture Image', - value: widget.value ?? false, - options: { - ...widget.options, - hidden, - values: [ - { label: 'On Run', value: true }, - { label: 'Manually', value: false } - ] - } + // Mutate in place to preserve object identity for serializeValue closure + widget.type = 'selectToggle' + widget.label = 'Capture Image' + widget.value = widget.value ?? false + widget.options = { + ...widget.options, + hidden, + values: [ + { label: 'On Run', value: true }, + { label: 'Manually', value: false } + ] } + return widget } - // For width/height, explicitly preserve the value to ensure Vue reactivity works - return { - ...widget, - value: widget.value, - options: { - ...widget.options, - hidden - } + // For width/height, mutate options in place + widget.options = { + ...widget.options, + hidden } + return widget } interface ActionWidgetConfig { @@ -216,12 +217,12 @@ function hideWidgets() { if (!node.widgets?.length) return // Set default values AND apply visibility in one pass - // We must replace node.widgets to trigger Vue reactivity (shallowReactive) + // Mutate widgets in place to preserve object identity for serializeValue closure updateNodeWidgets( node, (widgets) => widgets.map((widget) => { - let updatedWidget = applyWidgetVisibility(widget, true) + applyWidgetVisibility(widget, true) // Set default values for width and height if not already set const needsDefault = @@ -231,13 +232,13 @@ function hideWidgets() { widget.value === '' if (widget.name === 'width' && needsDefault) { - updatedWidget = { ...updatedWidget, value: 640 } + widget.value = 640 } if (widget.name === 'height' && needsDefault) { - updatedWidget = { ...updatedWidget, value: 480 } + widget.value = 480 } - return updatedWidget + return widget }), { dirtyCanvas: false } ) @@ -256,14 +257,14 @@ function setupSerializeValue() { imageWidget.serializeValue = async () => { const captureOnQueueWidget = node.widgets?.find( - (w) => toRaw(w).name === 'capture_on_queue' + (w) => w.name === 'capture_on_queue' ) // Strictly check for boolean true (On Run mode) const shouldCaptureOnQueue = captureOnQueueWidget?.value === true if (shouldCaptureOnQueue) { - // Auto-capture when queued + // Auto-capture when queued - capture and upload immediately const dataUrl = capturePhoto(node) if (!dataUrl) { const err = t('g.failedToCaptureImage', 'Failed to capture image') @@ -309,7 +310,12 @@ function showWidgets() { } function capturePhoto(node: LGraphNode) { - if (!node || !videoRef.value) return null + if (!node) return null + + // Use visible video element if available, otherwise use persistent video + const videoElement = + videoRef.value ?? (stream.value?.active ? persistentVideo : null) + if (!videoElement) return null const widthWidget = node.widgets?.find((w) => toRaw(w).name === 'width') const heightWidget = node.widgets?.find((w) => toRaw(w).name === 'height') @@ -323,7 +329,7 @@ function capturePhoto(node: LGraphNode) { const ctx = canvas.getContext('2d') if (!ctx) return null - ctx.drawImage(videoRef.value, 0, 0, width, height) + ctx.drawImage(videoElement, 0, 0, width, height) return canvas.toDataURL('image/png') } @@ -422,6 +428,12 @@ async function startCameraPreview() { await videoRef.value.play() } + // Ensure persistent video also has the stream for background capture + if (!persistentVideo.srcObject || persistentVideo.paused) { + persistentVideo.srcObject = stream.value + await persistentVideo.play() + } + return } @@ -431,6 +443,9 @@ async function startCameraPreview() { }) stream.value = cameraStream + // Attach stream to persistent video for capture when UI video is hidden + persistentVideo.srcObject = cameraStream + await persistentVideo.play() isShowingPreview.value = true await nextTick()