diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue index 9541f4360..2bfbedb95 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWebcam.vue @@ -30,7 +30,7 @@
{{ t('g.clickToStopLivePreview', 'Click to stop live preview') }} @@ -81,10 +81,15 @@ import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' -import { api } from '@/scripts/api' import { app } from '@/scripts/app' import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import { + DEFAULT_VIDEO_HEIGHT, + DEFAULT_VIDEO_WIDTH, + useWebcamCapture +} from '../composables/useWebcamCapture' + const { nodeManager } = useVueNodeLifecycle() const props = defineProps<{ @@ -93,34 +98,44 @@ const props = defineProps<{ nodeId: string }>() -const isCameraOn = ref(false) -const isShowingPreview = ref(false) -const isInitializingCamera = ref(false) -const originalWidgets = ref([]) +// Refs for video elements const videoRef = ref() const videoContainerRef = ref() -const stream = ref(null) -// Track pending video event listeners for cleanup -const pendingVideoCleanup = ref<(() => void) | null>(null) const isHovered = useElementHover(videoContainerRef) -// Instance-specific elements for capture - created per component instance -const canvas = ref(null) -const persistentVideo = ref(null) -const capturedImageUrl = ref(null) -const lastUploadedPath = ref(null) +const originalWidgets = ref([]) +// Use the webcam capture composable +const { + isShowingPreview, + capturedImageUrl, + lastUploadedPath, + startCameraPreview, + stopCameraPreview, + restartCameraPreview, + capturePhoto, + uploadImage, + clearCapturedImage, + initializeElements, + cleanup +} = useWebcamCapture({ + videoRef, + readonly: props.readonly, + onCameraStart: () => showWidgets() +}) + +// Constants for widget names const TOGGLED_WIDGET_NAMES = new Set(['height', 'width', 'capture_on_queue']) const CAPTURE_WIDGET_NAME = 'capture' const RETAKE_WIDGET_NAME = 'retake' -const DEFAULT_VIDEO_WIDTH = 640 -const DEFAULT_VIDEO_HEIGHT = 480 +// Widget update types type WidgetTransformer = (widgets: IBaseWidget[]) => IBaseWidget[] interface WidgetUpdateOptions { dirtyCanvas?: boolean } +// LiteGraph node access const litegraphNode = computed(() => { if (!props.nodeId || !app.rootGraph) return null return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null @@ -132,6 +147,7 @@ function withLitegraphNode(handler: (node: LGraphNode) => T) { return handler(node) } +// Widget management functions function setNodeWidgets( node: LGraphNode, widgets: IBaseWidget[], @@ -165,10 +181,8 @@ function applyWidgetVisibility( if (!TOGGLED_WIDGET_NAMES.has(widget.name)) return widget if (widget.name === 'capture_on_queue') { - // Mutate in place to preserve object identity for serializeValue closure widget.type = 'selectToggle' widget.label = 'Capture Image' - // Default to false (Manual mode) - only set if undefined/null if (widget.value === undefined || widget.value === null) { widget.value = false } @@ -183,7 +197,6 @@ function applyWidgetVisibility( return widget } - // For width/height, mutate options in place widget.options = { ...widget.options, hidden @@ -219,9 +232,19 @@ function createActionWidget({ } } +function removeWidgetsByName(names: string[]) { + withLitegraphNode((node) => { + if (!node.widgets?.length) return + updateNodeWidgets(node, (widgets) => + widgets.filter((widget) => !names.includes(widget.name)) + ) + nodeManager.value?.refreshVueWidgets(String(node.id)) + }) +} + +// Capture mode handling function updateCaptureButtonVisibility(isOnRunMode: boolean) { withLitegraphNode((node) => { - // Update the LiteGraph widget options const captureWidget = node.widgets?.find( (w) => w.name === CAPTURE_WIDGET_NAME ) @@ -232,7 +255,6 @@ function updateCaptureButtonVisibility(isOnRunMode: boolean) { } } - // Update Vue state directly to trigger reactivity nodeManager.value?.updateVueWidgetOptions( String(node.id), CAPTURE_WIDGET_NAME, @@ -243,7 +265,6 @@ function updateCaptureButtonVisibility(isOnRunMode: boolean) { }) } -// Computed to get capture_on_queue widget value from Vue state const captureOnQueueValue = computed(() => { const vueNodeData = nodeManager.value?.vueNodeData.get(props.nodeId) const widget = vueNodeData?.widgets?.find( @@ -255,16 +276,12 @@ const captureOnQueueValue = computed(() => { async function handleModeChange(isOnRunMode: boolean) { updateCaptureButtonVisibility(isOnRunMode) - // When switching to "On Run" mode, clear captured image and restart camera if (isOnRunMode && capturedImageUrl.value) { - capturedImageUrl.value = null - lastUploadedPath.value = null - // Remove retake button and restart camera preview + clearCapturedImage() removeWidgetsByName([RETAKE_WIDGET_NAME]) await startCameraPreview() } - // When switching to "Manually" mode, ensure capture button exists and is visible if (!isOnRunMode) { withLitegraphNode((node) => { const hasRetakeButton = node.widgets?.some( @@ -274,14 +291,13 @@ async function handleModeChange(isOnRunMode: boolean) { (w) => w.name === CAPTURE_WIDGET_NAME ) - // If there's no retake button and no capture button, add the capture button if (!hasRetakeButton && !hasCaptureButton) { updateNodeWidgets(node, (widgets) => { const captureWidget = createActionWidget({ name: CAPTURE_WIDGET_NAME, label: t('g.capturePhoto', 'Capture Photo'), iconClass: 'icon-[lucide--camera]', - onClick: () => captureImage(node) + onClick: () => handleCaptureImage(node) }) return [...widgets, captureWidget] }) @@ -292,10 +308,8 @@ async function handleModeChange(isOnRunMode: boolean) { } function setupCaptureOnQueueWatcher() { - // Set initial visibility updateCaptureButtonVisibility(captureOnQueueValue.value) - // Watch for changes using Vue reactivity watch( captureOnQueueValue, (isOnRunMode) => { @@ -305,17 +319,7 @@ function setupCaptureOnQueueWatcher() { ) } -function removeWidgetsByName(names: string[]) { - withLitegraphNode((node) => { - if (!node.widgets?.length) return - updateNodeWidgets(node, (widgets) => - widgets.filter((widget) => !names.includes(widget.name)) - ) - // Refresh Vue state to pick up widget removal - nodeManager.value?.refreshVueWidgets(String(node.id)) - }) -} - +// Widget lifecycle function storeOriginalWidgets() { withLitegraphNode((node) => { if (!node.widgets) return @@ -327,15 +331,12 @@ function hideWidgets() { withLitegraphNode((node) => { if (!node.widgets?.length) return - // Set default values AND apply visibility in one pass - // Mutate widgets in place to preserve object identity for serializeValue closure updateNodeWidgets( node, (widgets) => widgets.map((widget) => { applyWidgetVisibility(widget, true) - // Set default values for width and height if not already set const needsDefault = widget.value === undefined || widget.value === null || @@ -354,7 +355,6 @@ function hideWidgets() { { dirtyCanvas: false } ) - // Refresh Vue state to pick up the hidden widgets nodeManager.value?.refreshVueWidgets(String(node.id)) }) } @@ -374,11 +374,9 @@ function setupSerializeValue() { (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 - capture and upload immediately const dataUrl = capturePhoto(node) if (!dataUrl) { const err = t('g.failedToCaptureImage', 'Failed to capture image') @@ -388,7 +386,6 @@ function setupSerializeValue() { const path = await uploadImage(dataUrl, node) return path } else { - // Manual mode: validate image was captured if (!lastUploadedPath.value || !node.imgs?.length) { const err = t('g.noWebcamImageCaptured', 'No webcam image captured') useToastStore().addAlert(err) @@ -402,7 +399,6 @@ function setupSerializeValue() { function showWidgets() { withLitegraphNode((node) => { - // Get current capture_on_queue value to determine initial button visibility const captureOnQueueWidget = node.widgets?.find( (w) => w.name === 'capture_on_queue' ) @@ -421,10 +417,9 @@ function showWidgets() { name: CAPTURE_WIDGET_NAME, label: t('g.capturePhoto', 'Capture Photo'), iconClass: 'icon-[lucide--camera]', - onClick: () => captureImage(node) + onClick: () => handleCaptureImage(node) }) - // Hide capture button if in "On Run" mode if (isOnRunMode) { captureWidget.options = { ...captureWidget.options, @@ -435,91 +430,13 @@ function showWidgets() { return [...sanitizedWidgets, captureWidget] }) - // Refresh Vue state to pick up the new widgets nodeManager.value?.refreshVueWidgets(String(node.id)) - - // Set up watcher to toggle capture button visibility when mode changes setupCaptureOnQueueWatcher() }) } -function capturePhoto(node: LGraphNode) { - if (!node) return null - - // Use visible video element if available, otherwise use persistent video - const videoElement = - videoRef.value ?? (stream.value?.active ? persistentVideo.value : null) - if (!videoElement || !canvas.value) return null - - const widthWidget = node.widgets?.find((w) => toRaw(w).name === 'width') - const heightWidget = node.widgets?.find((w) => toRaw(w).name === 'height') - - const width = (widthWidget?.value as number) || DEFAULT_VIDEO_WIDTH - const height = (heightWidget?.value as number) || DEFAULT_VIDEO_HEIGHT - - canvas.value.width = width - canvas.value.height = height - - const ctx = canvas.value.getContext('2d') - if (!ctx) return null - - ctx.drawImage(videoElement, 0, 0, width, height) - return canvas.value.toDataURL('image/png') -} - -async function uploadImage( - dataUrl: string, - node: LGraphNode -): Promise { - try { - if (!canvas.value) throw new Error('Canvas not initialized') - - const blob = await new Promise((resolve, reject) => { - canvas.value!.toBlob((b) => { - if (b) resolve(b) - else reject(new Error('Failed to convert canvas to blob')) - }) - }) - - const name = `${+new Date()}.png` - const file = new File([blob], name) - const body = new FormData() - body.append('image', file) - body.append('subfolder', 'webcam') - body.append('type', 'temp') - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) - - if (resp.status !== 200) { - const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}` - useToastStore().addAlert(err) - throw new Error(err) - } - - const uploadedPath = `webcam/${name} [temp]` - lastUploadedPath.value = uploadedPath - - const img = new Image() - img.onload = () => { - node.imgs = [img] - app.graph.setDirtyCanvas(true) - } - img.src = dataUrl - - return uploadedPath - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - useToastStore().addAlert( - t('g.errorCapturingImage', { error: errorMessage }) - ) - return null - } -} - -async function captureImage(node: LGraphNode) { +// Capture and retake handlers +async function handleCaptureImage(node: LGraphNode) { const dataUrl = capturePhoto(node) if (!dataUrl) return @@ -541,173 +458,31 @@ async function captureImage(node: LGraphNode) { return [...preserved, retakeWidget] }) - // Refresh Vue state to pick up the new widgets nodeManager.value?.refreshVueWidgets(String(node.id)) } async function handleRetake() { - capturedImageUrl.value = null - lastUploadedPath.value = null + clearCapturedImage() removeWidgetsByName([RETAKE_WIDGET_NAME]) await restartCameraPreview() } -async function startCameraPreview() { - if (props.readonly) return - - // Prevent concurrent camera initialization attempts - if (isInitializingCamera.value) return - isInitializingCamera.value = true - - capturedImageUrl.value = null - - try { - if (isCameraOn.value && stream.value && stream.value.active) { - isShowingPreview.value = true - await nextTick() - - if (videoRef.value && stream.value) { - videoRef.value.srcObject = stream.value - await videoRef.value.play() - } - - // Ensure persistent video also has the stream for background capture - if ( - persistentVideo.value && - (!persistentVideo.value.srcObject || persistentVideo.value.paused) - ) { - persistentVideo.value.srcObject = stream.value - await persistentVideo.value.play() - } - - return - } - - const cameraStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: false - }) - - stream.value = cameraStream - // Attach stream to persistent video for capture when UI video is hidden - if (persistentVideo.value) { - persistentVideo.value.srcObject = cameraStream - await persistentVideo.value.play() - } - isShowingPreview.value = true - await nextTick() - - if (videoRef.value) { - videoRef.value.srcObject = cameraStream - - await new Promise((resolve, reject) => { - if (!videoRef.value) { - reject(new Error('Video element not found')) - return - } - - const video = videoRef.value - - const cleanup = () => { - video.removeEventListener('loadedmetadata', onLoadedMetadata) - video.removeEventListener('error', onError) - pendingVideoCleanup.value = null - } - - const onLoadedMetadata = () => { - cleanup() - resolve() - } - - const onError = (error: Event) => { - cleanup() - reject(error) - } - - video.addEventListener('loadedmetadata', onLoadedMetadata) - video.addEventListener('error', onError) - - // Store cleanup function for onUnmounted - pendingVideoCleanup.value = cleanup - - setTimeout(() => { - cleanup() - resolve() - }, 1000) - }) - - await videoRef.value.play() - } - - isCameraOn.value = true - showWidgets() - await nextTick() - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - - if (window.isSecureContext) { - useToastStore().addAlert( - t('g.unableToLoadWebcam', { error: errorMessage }) - ) - } else { - useToastStore().addAlert( - t('g.webcamRequiresTLS', { error: errorMessage }) - ) - } - - stopStreamTracks() - isShowingPreview.value = false - } finally { - isInitializingCamera.value = false - } -} - -function stopCameraPreview() { - isShowingPreview.value = false - hideWidgets() // Hide the capture button when stopping preview -} - -async function restartCameraPreview() { - stopStreamTracks() - isShowingPreview.value = false - await startCameraPreview() -} - -function stopStreamTracks() { - if (!stream.value) return - stream.value.getTracks().forEach((track) => track.stop()) - stream.value = null - isCameraOn.value = false -} - -onMounted(async () => { - // Create instance-specific elements for capture - canvas.value = document.createElement('canvas') - persistentVideo.value = document.createElement('video') - persistentVideo.value.autoplay = true - persistentVideo.value.muted = true - persistentVideo.value.playsInline = true - - // Order matters: first set defaults via hideWidgets, THEN store original widgets - // This ensures restoreWidgets() will restore the correct default values +function handleStopPreview() { + stopCameraPreview() + hideWidgets() +} + +// Lifecycle +onMounted(async () => { + initializeElements() hideWidgets() - // Wait for Vue reactivity to process the widget changes await nextTick() storeOriginalWidgets() setupSerializeValue() }) onUnmounted(() => { - // Clean up any pending video event listeners - pendingVideoCleanup.value?.() - stopStreamTracks() + cleanup() restoreWidgets() - - // Clean up instance-specific elements - if (persistentVideo.value) { - persistentVideo.value.srcObject = null - persistentVideo.value = null - } - canvas.value = null }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWebcamCapture.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWebcamCapture.ts new file mode 100644 index 000000000..7090db26e --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useWebcamCapture.ts @@ -0,0 +1,313 @@ +import type { Ref } from 'vue' +import { nextTick, ref } from 'vue' + +import { t } from '@/i18n' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { api } from '@/scripts/api' +import { app } from '@/scripts/app' + +export const DEFAULT_VIDEO_WIDTH = 640 +export const DEFAULT_VIDEO_HEIGHT = 480 + +export interface UseWebcamCaptureOptions { + videoRef: Ref + readonly?: boolean + onCameraStart?: () => void +} + +export interface UseWebcamCaptureReturn { + // State + isCameraOn: Ref + isShowingPreview: Ref + isInitializingCamera: Ref + stream: Ref + capturedImageUrl: Ref + lastUploadedPath: Ref + + // Methods + startCameraPreview: () => Promise + stopCameraPreview: () => void + restartCameraPreview: () => Promise + stopStreamTracks: () => void + capturePhoto: (node: LGraphNode) => string | null + uploadImage: (dataUrl: string, node: LGraphNode) => Promise + clearCapturedImage: () => void + + // Lifecycle + initializeElements: () => void + cleanup: () => void +} + +export function useWebcamCapture( + options: UseWebcamCaptureOptions +): UseWebcamCaptureReturn { + const { videoRef, readonly, onCameraStart } = options + + // State + const isCameraOn = ref(false) + const isShowingPreview = ref(false) + const isInitializingCamera = ref(false) + const stream = ref(null) + const capturedImageUrl = ref(null) + const lastUploadedPath = ref(null) + + // Instance-specific elements + const canvas = ref(null) + const persistentVideo = ref(null) + + // Track pending video event listeners for cleanup + const pendingVideoCleanup = ref<(() => void) | null>(null) + + function initializeElements() { + canvas.value = document.createElement('canvas') + persistentVideo.value = document.createElement('video') + persistentVideo.value.autoplay = true + persistentVideo.value.muted = true + persistentVideo.value.playsInline = true + } + + function cleanup() { + pendingVideoCleanup.value?.() + stopStreamTracks() + + if (persistentVideo.value) { + persistentVideo.value.srcObject = null + persistentVideo.value = null + } + canvas.value = null + } + + function stopStreamTracks() { + if (!stream.value) return + stream.value.getTracks().forEach((track) => track.stop()) + stream.value = null + isCameraOn.value = false + } + + function stopCameraPreview() { + isShowingPreview.value = false + } + + async function restartCameraPreview() { + stopStreamTracks() + isShowingPreview.value = false + await startCameraPreview() + } + + function clearCapturedImage() { + capturedImageUrl.value = null + lastUploadedPath.value = null + } + + async function startCameraPreview() { + if (readonly) return + + // Prevent concurrent camera initialization attempts + if (isInitializingCamera.value) return + isInitializingCamera.value = true + + capturedImageUrl.value = null + + try { + if (isCameraOn.value && stream.value && stream.value.active) { + isShowingPreview.value = true + await nextTick() + + if (videoRef.value && stream.value) { + videoRef.value.srcObject = stream.value + await videoRef.value.play() + } + + // Ensure persistent video also has the stream for background capture + if ( + persistentVideo.value && + (!persistentVideo.value.srcObject || persistentVideo.value.paused) + ) { + persistentVideo.value.srcObject = stream.value + await persistentVideo.value.play() + } + + return + } + + const cameraStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }) + + stream.value = cameraStream + // Attach stream to persistent video for capture when UI video is hidden + if (persistentVideo.value) { + persistentVideo.value.srcObject = cameraStream + await persistentVideo.value.play() + } + isShowingPreview.value = true + await nextTick() + + if (videoRef.value) { + videoRef.value.srcObject = cameraStream + + await new Promise((resolve, reject) => { + if (!videoRef.value) { + reject(new Error('Video element not found')) + return + } + + const video = videoRef.value + + const cleanupListeners = () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata) + video.removeEventListener('error', onError) + pendingVideoCleanup.value = null + } + + const onLoadedMetadata = () => { + cleanupListeners() + resolve() + } + + const onError = (error: Event) => { + cleanupListeners() + reject(error) + } + + video.addEventListener('loadedmetadata', onLoadedMetadata) + video.addEventListener('error', onError) + + // Store cleanup function for onUnmounted + pendingVideoCleanup.value = cleanupListeners + + setTimeout(() => { + cleanupListeners() + resolve() + }, 1000) + }) + + await videoRef.value.play() + } + + isCameraOn.value = true + onCameraStart?.() + await nextTick() + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + + if (window.isSecureContext) { + useToastStore().addAlert( + t('g.unableToLoadWebcam', { error: errorMessage }) + ) + } else { + useToastStore().addAlert( + t('g.webcamRequiresTLS', { error: errorMessage }) + ) + } + + stopStreamTracks() + isShowingPreview.value = false + } finally { + isInitializingCamera.value = false + } + } + + function capturePhoto(node: LGraphNode): string | null { + if (!node) return null + + // Use visible video element if available, otherwise use persistent video + const videoElement = + videoRef.value ?? (stream.value?.active ? persistentVideo.value : null) + if (!videoElement || !canvas.value) return null + + const widthWidget = node.widgets?.find((w) => w.name === 'width') + const heightWidget = node.widgets?.find((w) => w.name === 'height') + + const width = (widthWidget?.value as number) || DEFAULT_VIDEO_WIDTH + const height = (heightWidget?.value as number) || DEFAULT_VIDEO_HEIGHT + + canvas.value.width = width + canvas.value.height = height + + const ctx = canvas.value.getContext('2d') + if (!ctx) return null + + ctx.drawImage(videoElement, 0, 0, width, height) + return canvas.value.toDataURL('image/png') + } + + async function uploadImage( + dataUrl: string, + node: LGraphNode + ): Promise { + try { + if (!canvas.value) throw new Error('Canvas not initialized') + + const blob = await new Promise((resolve, reject) => { + canvas.value!.toBlob((b) => { + if (b) resolve(b) + else reject(new Error('Failed to convert canvas to blob')) + }) + }) + + const name = `${+new Date()}.png` + const file = new File([blob], name) + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'webcam') + body.append('type', 'temp') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status !== 200) { + const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + + const uploadedPath = `webcam/${name} [temp]` + lastUploadedPath.value = uploadedPath + + const img = new Image() + img.onload = () => { + node.imgs = [img] + app.graph.setDirtyCanvas(true) + } + img.src = dataUrl + + return uploadedPath + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + useToastStore().addAlert( + t('g.errorCapturingImage', { error: errorMessage }) + ) + return null + } + } + + return { + // State + isCameraOn, + isShowingPreview, + isInitializingCamera, + stream, + capturedImageUrl, + lastUploadedPath, + + // Methods + startCameraPreview, + stopCameraPreview, + restartCameraPreview, + stopStreamTracks, + capturePhoto, + uploadImage, + clearCapturedImage, + + // Lifecycle + initializeElements, + cleanup + } +}