Compare commits

...

37 Commits

Author SHA1 Message Date
Alexander Brown
882d4c186a Merge branch 'main' into webcam-capture 2026-02-10 18:20:49 -08:00
Johnpaul
d5751aea13 reorder vue code 2026-01-29 20:05:21 +01:00
Johnpaul Chiwetelu
ac879765fc Merge branch 'main' into webcam-capture 2026-01-29 19:18:43 +01:00
Johnpaul
bc8569080d fix: remove unused exports from useWebcamCapture 2026-01-27 03:14:31 +01:00
Johnpaul Chiwetelu
0c50251943 Merge branch 'main' into webcam-capture 2026-01-27 02:36:26 +01:00
Johnpaul Chiwetelu
e7481c5c36 Merge branch 'main' into webcam-capture 2026-01-26 21:28:09 +01:00
Johnpaul
3d5aef4aa0 fix: merge main into webcam-capture branch 2026-01-26 20:22:51 +01:00
Johnpaul
474b15c48b 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
2025-11-27 04:35:59 +01:00
Johnpaul
1c37a3633d refactor: use instance-specific canvas and video elements
- Move canvas and persistentVideo creation to onMounted
- Store as refs for proper lifecycle management
- Clean up elements in onUnmounted to prevent memory leaks
- Prevents potential conflicts if multiple webcam widgets exist
2025-11-27 04:27:22 +01:00
Johnpaul
1e384c6735 refactor: use vue-i18n for toggle option labels
- Add 'yes', 'on', 'off' keys to locales/en/main.json
- Replace hardcoded strings in WidgetSelectToggle with t() function
2025-11-27 04:21:26 +01:00
Johnpaul
e092986cfd apply review comments 2025-11-27 04:18:53 +01:00
Johnpaul
51ab1541dd fix: properly cleanup video event listeners on unmount
- Store cleanup function reference for pending video event listeners
- Call cleanup in onUnmounted to prevent memory leaks if component
  unmounts while waiting for video metadata
2025-11-27 04:06:19 +01:00
Johnpaul
8031a832f4 fix: prevent race condition in camera initialization
- Add isInitializingCamera flag to prevent concurrent calls to startCameraPreview
- Use finally block to ensure flag is always reset after initialization
2025-11-27 04:04:23 +01:00
Johnpaul
7b109df599 refactor: extract magic numbers into named constants
- Define DEFAULT_VIDEO_WIDTH and DEFAULT_VIDEO_HEIGHT constants
- Replace hardcoded 640 and 480 values with named constants
2025-11-27 04:01:58 +01:00
Johnpaul
f7f0c05d1a fix(security): properly cleanup MediaStream on camera access failure
- Add stopStreamTracks() call in catch block to release any partially
  acquired media tracks when camera access fails
- Prevents privacy and resource leaks from orphaned media streams
2025-11-27 03:57:17 +01:00
Johnpaul
335b72bc62 Fix linting 2025-11-26 23:53:43 +01:00
Johnpaul Chiwetelu
f8ede78dc6 Merge branch 'main' into webcam-capture 2025-11-26 23:29:30 +01:00
Johnpaul
af8ad95af1 fix: hide widgets on mount and improve stop button styling
- Add refreshVueWidgets call in hideWidgets to sync Vue state on mount
- Update stop camera button with rounded container and square icon
- Use bg-destructive-background design token for stop button
2025-11-26 23:25:15 +01:00
Johnpaul
cd5f6fd0b9 feat: restart camera when switching to 'On Run' mode in webcam widget
- Clear captured image and restart camera preview when toggling to On Run
- Restore capture button when switching back to Manual mode
- Ensures smooth UX when changing capture modes
2025-11-26 21:07:22 +01:00
Johnpaul
68b6159f99 feat: add refreshVueWidgets to sync widget changes with Vue state
- Add refreshVueWidgets function to GraphNodeManager for manually syncing
  LiteGraph widget changes with Vue reactive state
- Use refreshVueWidgets after widget modifications (capture, retake, show)
- Fixes retake button not appearing after capturing an image
2025-11-26 20:46:00 +01:00
Johnpaul
4b628724bd feat: hide capture button when 'On Run' mode is selected in webcam widget
- Add updateVueWidgetOptions to GraphNodeManager to update widget options
  and trigger Vue reactivity
- Implement capture button visibility toggle based on capture_on_queue value
- Default capture mode to 'Manually' (false)
- Use Vue watch to reactively hide/show capture button when mode changes
2025-11-26 17:38:38 +01:00
Johnpaul
cfbb4c708f 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.
2025-11-26 16:35:00 +01:00
Johnpaul
dc60b54ab4 feat: Implement image widget serialization to support auto-capture on queue and refine default width/height values. 2025-11-25 23:40:08 +01:00
Johnpaul
ad85956c77 feat: set default width and height values for webcam widgets if not already defined 2025-11-25 22:42:38 +01:00
Johnpaul
f9b7e51b63 feat: enable webcam image capture, display captured image, and manage capture/retake widgets 2025-11-25 03:40:56 +01:00
Johnpaul
59761eb65d feat: Add 'Capture photo' button with icon to webcam widget and update styling. 2025-11-25 02:47:15 +01:00
Johnpaul
16b6b5bbbc Merge remote-tracking branch 'origin/main' into webcam-capture 2025-11-25 02:14:51 +01:00
Johnpaul
ca335baac9 Merge branch 'main' into webcam-capture 2025-11-25 01:53:09 +01:00
Johnpaul
48c21f6f25 feat: enhance WidgetButton component with icon support
Updated button widget to support icon display and improved styling to match design system.

- Add icon rendering using widget.options.iconClass
- Update styling to use semantic tokens
- Use widget.label for display instead of widget.name
- Apply consistent button styling with design system tokens
2025-11-25 01:42:39 +01:00
Johnpaul
a455c7be4c feat: add iconClass option to widget interface
Added optional iconClass property to IWidgetOptions to support icon display in widget buttons.
2025-11-25 01:42:14 +01:00
Johnpaul
e1693dc341 feat: add capture button widget to webcam component
Added programmatic capture button that appears when camera is turned on. The button uses node.addWidget() to integrate with LiteGraph canvas.

- Add captureImage() function to draw video frame to canvas and store in node.imgs
- Add capture button widget in showWidgets() using node.addWidget()
- Create canvas element at module level for efficient reuse
- Widget restoration handled by existing restoreWidgets() cleanup
2025-11-25 01:40:59 +01:00
Johnpaul
a65b063a98 feat: add live camera preview with stop-on-hover to webcam widget
Add video preview functionality with hover overlay that allows users to:
- Click button to start camera and show live preview
- Hover over video to see stop overlay
- Click anywhere on video to hide preview (keeps camera active)
- Click button again to re-show preview without re-requesting permissions

Uses VueUse's useElementHover for automatic hover detection and proper
MediaStream handling with cleanup on unmount.
2025-11-24 22:46:32 +01:00
Johnpaul
7fc51c6d96 fix: prevent Vue reactivity from breaking LiteGraph widget private fields
Use toRaw() and markRaw() when modifying widget objects to prevent Vue's
reactive proxy from wrapping them. This fixes errors when LiteGraph tries
to access private class members like #value in BaseWidget.
2025-11-24 20:44:32 +01:00
Johnpaul
0ac768722c feat: add WidgetSelectToggle and enhance webcam widget with dynamic controls
Implements a proper Vue toggle widget component and enhances the webcam widget to dynamically show/hide related controls based on camera state, with automatic restoration on component unmount.
2025-11-21 20:06:23 +01:00
Johnpaul
450b8b9954 feat: add new webcam widget Vue component. 2025-11-21 01:57:59 +01:00
Johnpaul
74aadb3227 feat: add webcam widget with camera control and dynamic visibility for related properties. 2025-11-21 01:56:29 +01:00
Johnpaul
6f4c335566 WIP fix record audio button color token 2025-11-19 22:53:42 +01:00
5 changed files with 928 additions and 1 deletions

View File

@@ -100,6 +100,9 @@
"save": "Save",
"saving": "Saving",
"no": "No",
"yes": "Yes",
"on": "On",
"off": "Off",
"cancel": "Cancel",
"close": "Close",
"closeDialog": "Close dialog",
@@ -168,6 +171,18 @@
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"capturePhoto": "Capture Photo",
"captureModeOnRun": "On Run",
"captureModeManual": "Manually",
"capturedImage": "Captured Image",
"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",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"community": "Community",

View File

@@ -0,0 +1,98 @@
<template>
<WidgetLayoutField :widget>
<div
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'flex gap-0.5 p-0.5 w-full')"
role="group"
:aria-label="widget.name"
>
<button
v-for="option in options"
:key="String(option.value)"
type="button"
:class="
cn(
'flex-1 px-2 py-1 text-xs font-medium rounded transition-all duration-150',
'bg-transparent border-none',
'focus:outline-none',
modelValue === option.value
? 'bg-interface-menu-component-surface-selected text-base-foreground'
: 'text-muted-foreground hover:bg-interface-menu-component-surface-hovered'
)
"
:aria-pressed="modelValue === option.value"
@click="handleSelect(option.value)"
>
{{ option.label }}
</button>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | boolean>
}>()
const modelValue = defineModel<string | number | boolean>({ required: true })
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
interface ToggleOption {
label: string
value: string | number | boolean
}
const options = computed<ToggleOption[]>(() => {
// Get options from widget spec or widget options
const widgetOptions = props.widget.options?.values || props.widget.spec?.[0]
if (Array.isArray(widgetOptions)) {
// If options are strings/numbers, convert to {label, value} format
return widgetOptions.map((opt) => {
if (
typeof opt === 'object' &&
opt !== null &&
'label' in opt &&
'value' in opt
) {
return opt as ToggleOption
}
return { label: String(opt), value: opt }
})
}
// Default options for boolean widgets
if (typeof modelValue.value === 'boolean') {
return [
{ label: t('g.on', 'On'), value: true },
{ label: t('g.off', 'Off'), value: false }
]
}
// Fallback default options
return [
{ label: t('g.yes', 'Yes'), value: true },
{ label: t('g.no', 'No'), value: false }
]
})
function handleSelect(value: string | number | boolean) {
modelValue.value = value
}
</script>

View File

@@ -0,0 +1,478 @@
<template>
<div class="relative">
<div v-if="capturedImageUrl" class="mb-4">
<img
:src="capturedImageUrl"
class="w-full rounded-lg bg-node-component-surface"
:alt="t('g.capturedImage', 'Captured Image')"
/>
</div>
<div v-else-if="!isShowingPreview" class="mb-4">
<Button
class="text-text-secondary w-full border-0 bg-component-node-widget-background hover:bg-secondary-background-hover"
:disabled="readonly"
@click="restartCameraPreview()"
>
{{ t('g.turnOnCamera', 'Turn on Camera') }}
</Button>
</div>
<div v-else ref="videoContainerRef" class="relative mb-4">
<video
ref="videoRef"
autoplay
muted
playsinline
class="w-full rounded-lg bg-node-component-surface"
/>
<div
v-if="isHovered"
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center rounded-lg bg-black/50"
@click="handleStopPreview"
>
<div class="text-base-foreground mb-4 text-base">
{{ t('g.clickToStopLivePreview', 'Click to stop live preview') }}
</div>
<div
class="flex size-10 items-center justify-center rounded-full bg-destructive-background"
>
<svg
class="size-4 text-white rounded-md"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="2"
width="12"
height="12"
rx="1"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { Button } from 'primevue'
import {
computed,
markRaw,
nextTick,
onMounted,
onUnmounted,
ref,
toRaw,
watch
} from 'vue'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
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 { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
DEFAULT_VIDEO_HEIGHT,
DEFAULT_VIDEO_WIDTH,
useWebcamCapture
} from '../composables/useWebcamCapture'
const { nodeManager } = useVueNodeLifecycle()
/* eslint-disable vue/no-unused-properties */
// widget prop is part of the standard widget component interface
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
nodeId: string
}>()
/* eslint-enable vue/no-unused-properties */
// Refs for video elements
const videoRef = ref<HTMLVideoElement>()
const videoContainerRef = ref<HTMLElement>()
const isHovered = useElementHover(videoContainerRef)
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'
// 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
})
function withLitegraphNode<T>(handler: (node: LGraphNode) => T) {
const node = litegraphNode.value
if (!node) return null
return handler(node)
}
// Widget management functions
function setNodeWidgets(
node: LGraphNode,
widgets: IBaseWidget[],
options: WidgetUpdateOptions = {}
) {
node.widgets = widgets.map((widget) => markRaw(widget))
if (node.graph) {
node.graph._version++
}
if (options.dirtyCanvas ?? true) {
app.graph.setDirtyCanvas(true, true)
}
}
function updateNodeWidgets(
node: LGraphNode,
transformer: WidgetTransformer,
options: WidgetUpdateOptions = {}
) {
const currentWidgets = node.widgets?.map((widget) => toRaw(widget)) ?? []
const updatedWidgets = transformer(currentWidgets)
setNodeWidgets(node, updatedWidgets, options)
}
function applyWidgetVisibility(
widget: IBaseWidget,
hidden: boolean
): IBaseWidget {
if (!TOGGLED_WIDGET_NAMES.has(widget.name)) return widget
if (widget.name === 'capture_on_queue') {
widget.type = 'selectToggle'
widget.label = 'Capture Image'
if (widget.value === undefined || widget.value === null) {
widget.value = false
}
widget.options = {
...widget.options,
hidden,
values: [
{ label: 'On Run', value: true },
{ label: 'Manually', value: false }
]
}
return widget
}
widget.options = {
...widget.options,
hidden
}
return widget
}
interface ActionWidgetConfig {
name: string
label: string
iconClass: string
onClick: () => void
}
function createActionWidget({
name,
label,
iconClass,
onClick
}: ActionWidgetConfig): IBaseWidget {
return {
name,
label,
type: 'button',
value: undefined,
y: 100,
options: {
iconClass,
serialize: false,
hidden: false
},
callback: onClick
}
}
function removeWidgetsByName(names: string[]) {
withLitegraphNode((node) => {
if (!node.widgets?.length) return
updateNodeWidgets(node, (widgets) =>
widgets.filter((widget) => !names.includes(widget.name))
)
})
}
// Capture mode handling
function updateCaptureButtonVisibility(isOnRunMode: boolean) {
withLitegraphNode((node) => {
const captureWidget = node.widgets?.find(
(w) => w.name === CAPTURE_WIDGET_NAME
)
if (captureWidget) {
captureWidget.options = {
...captureWidget.options,
hidden: isOnRunMode
}
}
app.graph.setDirtyCanvas(true, true)
})
}
const captureOnQueueValue = computed(() => {
const vueNodeData = nodeManager.value?.vueNodeData.get(props.nodeId)
const widget = vueNodeData?.widgets?.find(
(w) => w.name === 'capture_on_queue'
)
return widget?.value === true
})
async function handleModeChange(isOnRunMode: boolean) {
updateCaptureButtonVisibility(isOnRunMode)
if (isOnRunMode && capturedImageUrl.value) {
clearCapturedImage()
removeWidgetsByName([RETAKE_WIDGET_NAME])
await startCameraPreview()
}
if (!isOnRunMode) {
withLitegraphNode((node) => {
const hasRetakeButton = node.widgets?.some(
(w) => w.name === RETAKE_WIDGET_NAME
)
const hasCaptureButton = node.widgets?.some(
(w) => w.name === CAPTURE_WIDGET_NAME
)
if (!hasRetakeButton && !hasCaptureButton) {
updateNodeWidgets(node, (widgets) => {
const captureWidget = createActionWidget({
name: CAPTURE_WIDGET_NAME,
label: t('g.capturePhoto', 'Capture Photo'),
iconClass: 'icon-[lucide--camera]',
onClick: () => handleCaptureImage(node)
})
return [...widgets, captureWidget]
})
}
})
}
}
function setupCaptureOnQueueWatcher() {
updateCaptureButtonVisibility(captureOnQueueValue.value)
watch(
captureOnQueueValue,
(isOnRunMode) => {
void handleModeChange(isOnRunMode)
},
{ immediate: false }
)
}
// Widget lifecycle
function storeOriginalWidgets() {
withLitegraphNode((node) => {
if (!node.widgets) return
originalWidgets.value = node.widgets.map((widget) => toRaw(widget))
})
}
function hideWidgets() {
withLitegraphNode((node) => {
if (!node.widgets?.length) return
updateNodeWidgets(
node,
(widgets) =>
widgets.map((widget) => {
applyWidgetVisibility(widget, true)
const needsDefault =
widget.value === undefined ||
widget.value === null ||
widget.value === 0 ||
widget.value === ''
if (widget.name === 'width' && needsDefault) {
widget.value = DEFAULT_VIDEO_WIDTH
}
if (widget.name === 'height' && needsDefault) {
widget.value = DEFAULT_VIDEO_HEIGHT
}
return widget
}),
{ dirtyCanvas: false }
)
})
}
function restoreWidgets() {
if (originalWidgets.value.length === 0) return
withLitegraphNode((node) => setNodeWidgets(node, originalWidgets.value))
}
function setupSerializeValue() {
withLitegraphNode((node) => {
const imageWidget = node.widgets?.find((w) => toRaw(w).name === 'image')
if (!imageWidget) return
imageWidget.serializeValue = async () => {
const captureOnQueueWidget = node.widgets?.find(
(w) => w.name === 'capture_on_queue'
)
const shouldCaptureOnQueue = captureOnQueueWidget?.value === true
if (shouldCaptureOnQueue) {
const dataUrl = capturePhoto(node)
if (!dataUrl) {
const err = t('g.failedToCaptureImage', 'Failed to capture image')
useToastStore().addAlert(err)
throw new Error(err)
}
const path = await uploadImage(dataUrl, node)
return path
} else {
if (!lastUploadedPath.value || !node.imgs?.length) {
const err = t('g.noWebcamImageCaptured', 'No webcam image captured')
useToastStore().addAlert(err)
throw new Error(err)
}
return lastUploadedPath.value
}
}
})
}
function showWidgets() {
withLitegraphNode((node) => {
const captureOnQueueWidget = node.widgets?.find(
(w) => w.name === 'capture_on_queue'
)
const isOnRunMode = captureOnQueueWidget?.value === true
updateNodeWidgets(node, (widgets) => {
const sanitizedWidgets = widgets
.map((widget) => applyWidgetVisibility(widget, false))
.filter(
(widget) =>
widget.name !== RETAKE_WIDGET_NAME &&
widget.name !== CAPTURE_WIDGET_NAME
)
const captureWidget = createActionWidget({
name: CAPTURE_WIDGET_NAME,
label: t('g.capturePhoto', 'Capture Photo'),
iconClass: 'icon-[lucide--camera]',
onClick: () => handleCaptureImage(node)
})
if (isOnRunMode) {
captureWidget.options = {
...captureWidget.options,
hidden: true
}
}
return [...sanitizedWidgets, captureWidget]
})
setupCaptureOnQueueWatcher()
})
}
// Capture and retake handlers
async function handleCaptureImage(node: LGraphNode) {
const dataUrl = capturePhoto(node)
if (!dataUrl) return
capturedImageUrl.value = dataUrl
isShowingPreview.value = false
await uploadImage(dataUrl, node)
updateNodeWidgets(node, (widgets) => {
const preserved = widgets.filter((widget) => widget.type !== 'button')
const retakeWidget = createActionWidget({
name: RETAKE_WIDGET_NAME,
label: t('g.retakePhoto', 'Retake photo'),
iconClass: 'icon-[lucide--rotate-cw]',
onClick: () => handleRetake()
})
return [...preserved, retakeWidget]
})
}
async function handleRetake() {
clearCapturedImage()
removeWidgetsByName([RETAKE_WIDGET_NAME])
await restartCameraPreview()
}
function handleStopPreview() {
stopCameraPreview()
hideWidgets()
}
// Lifecycle
onMounted(async () => {
initializeElements()
hideWidgets()
await nextTick()
storeOriginalWidgets()
setupSerializeValue()
})
onUnmounted(() => {
cleanup()
restoreWidgets()
})
</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
interface UseWebcamCaptureOptions {
videoRef: Ref<HTMLVideoElement | undefined>
readonly?: boolean
onCameraStart?: () => void
}
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
}
}

View File

@@ -48,6 +48,12 @@ const WidgetRecordAudio = defineAsyncComponent(
const AudioPreviewPlayer = defineAsyncComponent(
() => import('../components/audio/AudioPreviewPlayer.vue')
)
const WidgetWebcam = defineAsyncComponent(
() => import('../components/WidgetWebcam.vue')
)
const WidgetSelectToggle = defineAsyncComponent(
() => import('../components/WidgetSelectToggle.vue')
)
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
@@ -66,7 +72,8 @@ export const FOR_TESTING = {
WidgetMarkdown,
WidgetSelect,
WidgetTextarea,
WidgetToggleSwitch
WidgetToggleSwitch,
WidgetSelectToggle
} as const
interface WidgetDefinition {
@@ -159,6 +166,22 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
[
'webcam',
{
component: WidgetWebcam,
aliases: ['WEBCAM'],
essential: false
}
],
[
'selectToggle',
{
component: WidgetSelectToggle,
aliases: ['SELECT_TOGGLE'],
essential: false
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'imagecrop',