refactor: extract camera logic into useWebcamCapture composable

- Create useWebcamCapture composable for camera/stream management
- Move camera initialization, capture, and upload logic to composable
- Reduce WidgetWebcam.vue size by separating concerns
- Add onCameraStart callback for widget setup after camera initializes
This commit is contained in:
Johnpaul
2025-11-27 04:35:59 +01:00
parent 1c37a3633d
commit 474b15c48b
2 changed files with 372 additions and 284 deletions

View File

@@ -30,7 +30,7 @@
<div
v-if="isHovered"
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center rounded-lg bg-black/50"
@click="stopCameraPreview"
@click="handleStopPreview"
>
<div class="text-base-foreground mb-4 text-base">
{{ 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<IBaseWidget[]>([])
// Refs for video elements
const videoRef = ref<HTMLVideoElement>()
const videoContainerRef = ref<HTMLElement>()
const stream = ref<MediaStream | null>(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<HTMLCanvasElement | null>(null)
const persistentVideo = ref<HTMLVideoElement | null>(null)
const capturedImageUrl = ref<string | null>(null)
const lastUploadedPath = ref<string | null>(null)
const originalWidgets = ref<IBaseWidget[]>([])
// 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<T>(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<string | null> {
try {
if (!canvas.value) throw new Error('Canvas not initialized')
const blob = await new Promise<Blob>((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<void>((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
})
</script>

View File

@@ -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<HTMLVideoElement | undefined>
readonly?: boolean
onCameraStart?: () => void
}
export interface UseWebcamCaptureReturn {
// State
isCameraOn: Ref<boolean>
isShowingPreview: Ref<boolean>
isInitializingCamera: Ref<boolean>
stream: Ref<MediaStream | null>
capturedImageUrl: Ref<string | null>
lastUploadedPath: Ref<string | null>
// Methods
startCameraPreview: () => Promise<void>
stopCameraPreview: () => void
restartCameraPreview: () => Promise<void>
stopStreamTracks: () => void
capturePhoto: (node: LGraphNode) => string | null
uploadImage: (dataUrl: string, node: LGraphNode) => Promise<string | null>
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<MediaStream | null>(null)
const capturedImageUrl = ref<string | null>(null)
const lastUploadedPath = ref<string | null>(null)
// Instance-specific elements
const canvas = ref<HTMLCanvasElement | null>(null)
const persistentVideo = ref<HTMLVideoElement | null>(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<void>((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<string | null> {
try {
if (!canvas.value) throw new Error('Canvas not initialized')
const blob = await new Promise<Blob>((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
}
}