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
+ }
+}