mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 01:39:47 +00:00
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:
@@ -30,7 +30,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isHovered"
|
v-if="isHovered"
|
||||||
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center rounded-lg bg-black/50"
|
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">
|
<div class="text-base-foreground mb-4 text-base">
|
||||||
{{ t('g.clickToStopLivePreview', 'Click to stop live preview') }}
|
{{ 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 { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { api } from '@/scripts/api'
|
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_VIDEO_HEIGHT,
|
||||||
|
DEFAULT_VIDEO_WIDTH,
|
||||||
|
useWebcamCapture
|
||||||
|
} from '../composables/useWebcamCapture'
|
||||||
|
|
||||||
const { nodeManager } = useVueNodeLifecycle()
|
const { nodeManager } = useVueNodeLifecycle()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -93,34 +98,44 @@ const props = defineProps<{
|
|||||||
nodeId: string
|
nodeId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isCameraOn = ref(false)
|
// Refs for video elements
|
||||||
const isShowingPreview = ref(false)
|
|
||||||
const isInitializingCamera = ref(false)
|
|
||||||
const originalWidgets = ref<IBaseWidget[]>([])
|
|
||||||
const videoRef = ref<HTMLVideoElement>()
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
const videoContainerRef = ref<HTMLElement>()
|
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)
|
const isHovered = useElementHover(videoContainerRef)
|
||||||
// Instance-specific elements for capture - created per component instance
|
const originalWidgets = ref<IBaseWidget[]>([])
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
|
||||||
const persistentVideo = ref<HTMLVideoElement | null>(null)
|
|
||||||
const capturedImageUrl = ref<string | null>(null)
|
|
||||||
const lastUploadedPath = ref<string | null>(null)
|
|
||||||
|
|
||||||
|
// 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 TOGGLED_WIDGET_NAMES = new Set(['height', 'width', 'capture_on_queue'])
|
||||||
const CAPTURE_WIDGET_NAME = 'capture'
|
const CAPTURE_WIDGET_NAME = 'capture'
|
||||||
const RETAKE_WIDGET_NAME = 'retake'
|
const RETAKE_WIDGET_NAME = 'retake'
|
||||||
const DEFAULT_VIDEO_WIDTH = 640
|
|
||||||
const DEFAULT_VIDEO_HEIGHT = 480
|
|
||||||
|
|
||||||
|
// Widget update types
|
||||||
type WidgetTransformer = (widgets: IBaseWidget[]) => IBaseWidget[]
|
type WidgetTransformer = (widgets: IBaseWidget[]) => IBaseWidget[]
|
||||||
|
|
||||||
interface WidgetUpdateOptions {
|
interface WidgetUpdateOptions {
|
||||||
dirtyCanvas?: boolean
|
dirtyCanvas?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LiteGraph node access
|
||||||
const litegraphNode = computed(() => {
|
const litegraphNode = computed(() => {
|
||||||
if (!props.nodeId || !app.rootGraph) return null
|
if (!props.nodeId || !app.rootGraph) return null
|
||||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||||
@@ -132,6 +147,7 @@ function withLitegraphNode<T>(handler: (node: LGraphNode) => T) {
|
|||||||
return handler(node)
|
return handler(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget management functions
|
||||||
function setNodeWidgets(
|
function setNodeWidgets(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
widgets: IBaseWidget[],
|
widgets: IBaseWidget[],
|
||||||
@@ -165,10 +181,8 @@ function applyWidgetVisibility(
|
|||||||
if (!TOGGLED_WIDGET_NAMES.has(widget.name)) return widget
|
if (!TOGGLED_WIDGET_NAMES.has(widget.name)) return widget
|
||||||
|
|
||||||
if (widget.name === 'capture_on_queue') {
|
if (widget.name === 'capture_on_queue') {
|
||||||
// Mutate in place to preserve object identity for serializeValue closure
|
|
||||||
widget.type = 'selectToggle'
|
widget.type = 'selectToggle'
|
||||||
widget.label = 'Capture Image'
|
widget.label = 'Capture Image'
|
||||||
// Default to false (Manual mode) - only set if undefined/null
|
|
||||||
if (widget.value === undefined || widget.value === null) {
|
if (widget.value === undefined || widget.value === null) {
|
||||||
widget.value = false
|
widget.value = false
|
||||||
}
|
}
|
||||||
@@ -183,7 +197,6 @@ function applyWidgetVisibility(
|
|||||||
return widget
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
// For width/height, mutate options in place
|
|
||||||
widget.options = {
|
widget.options = {
|
||||||
...widget.options,
|
...widget.options,
|
||||||
hidden
|
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) {
|
function updateCaptureButtonVisibility(isOnRunMode: boolean) {
|
||||||
withLitegraphNode((node) => {
|
withLitegraphNode((node) => {
|
||||||
// Update the LiteGraph widget options
|
|
||||||
const captureWidget = node.widgets?.find(
|
const captureWidget = node.widgets?.find(
|
||||||
(w) => w.name === CAPTURE_WIDGET_NAME
|
(w) => w.name === CAPTURE_WIDGET_NAME
|
||||||
)
|
)
|
||||||
@@ -232,7 +255,6 @@ function updateCaptureButtonVisibility(isOnRunMode: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Vue state directly to trigger reactivity
|
|
||||||
nodeManager.value?.updateVueWidgetOptions(
|
nodeManager.value?.updateVueWidgetOptions(
|
||||||
String(node.id),
|
String(node.id),
|
||||||
CAPTURE_WIDGET_NAME,
|
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 captureOnQueueValue = computed(() => {
|
||||||
const vueNodeData = nodeManager.value?.vueNodeData.get(props.nodeId)
|
const vueNodeData = nodeManager.value?.vueNodeData.get(props.nodeId)
|
||||||
const widget = vueNodeData?.widgets?.find(
|
const widget = vueNodeData?.widgets?.find(
|
||||||
@@ -255,16 +276,12 @@ const captureOnQueueValue = computed(() => {
|
|||||||
async function handleModeChange(isOnRunMode: boolean) {
|
async function handleModeChange(isOnRunMode: boolean) {
|
||||||
updateCaptureButtonVisibility(isOnRunMode)
|
updateCaptureButtonVisibility(isOnRunMode)
|
||||||
|
|
||||||
// When switching to "On Run" mode, clear captured image and restart camera
|
|
||||||
if (isOnRunMode && capturedImageUrl.value) {
|
if (isOnRunMode && capturedImageUrl.value) {
|
||||||
capturedImageUrl.value = null
|
clearCapturedImage()
|
||||||
lastUploadedPath.value = null
|
|
||||||
// Remove retake button and restart camera preview
|
|
||||||
removeWidgetsByName([RETAKE_WIDGET_NAME])
|
removeWidgetsByName([RETAKE_WIDGET_NAME])
|
||||||
await startCameraPreview()
|
await startCameraPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
// When switching to "Manually" mode, ensure capture button exists and is visible
|
|
||||||
if (!isOnRunMode) {
|
if (!isOnRunMode) {
|
||||||
withLitegraphNode((node) => {
|
withLitegraphNode((node) => {
|
||||||
const hasRetakeButton = node.widgets?.some(
|
const hasRetakeButton = node.widgets?.some(
|
||||||
@@ -274,14 +291,13 @@ async function handleModeChange(isOnRunMode: boolean) {
|
|||||||
(w) => w.name === CAPTURE_WIDGET_NAME
|
(w) => w.name === CAPTURE_WIDGET_NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
// If there's no retake button and no capture button, add the capture button
|
|
||||||
if (!hasRetakeButton && !hasCaptureButton) {
|
if (!hasRetakeButton && !hasCaptureButton) {
|
||||||
updateNodeWidgets(node, (widgets) => {
|
updateNodeWidgets(node, (widgets) => {
|
||||||
const captureWidget = createActionWidget({
|
const captureWidget = createActionWidget({
|
||||||
name: CAPTURE_WIDGET_NAME,
|
name: CAPTURE_WIDGET_NAME,
|
||||||
label: t('g.capturePhoto', 'Capture Photo'),
|
label: t('g.capturePhoto', 'Capture Photo'),
|
||||||
iconClass: 'icon-[lucide--camera]',
|
iconClass: 'icon-[lucide--camera]',
|
||||||
onClick: () => captureImage(node)
|
onClick: () => handleCaptureImage(node)
|
||||||
})
|
})
|
||||||
return [...widgets, captureWidget]
|
return [...widgets, captureWidget]
|
||||||
})
|
})
|
||||||
@@ -292,10 +308,8 @@ async function handleModeChange(isOnRunMode: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupCaptureOnQueueWatcher() {
|
function setupCaptureOnQueueWatcher() {
|
||||||
// Set initial visibility
|
|
||||||
updateCaptureButtonVisibility(captureOnQueueValue.value)
|
updateCaptureButtonVisibility(captureOnQueueValue.value)
|
||||||
|
|
||||||
// Watch for changes using Vue reactivity
|
|
||||||
watch(
|
watch(
|
||||||
captureOnQueueValue,
|
captureOnQueueValue,
|
||||||
(isOnRunMode) => {
|
(isOnRunMode) => {
|
||||||
@@ -305,17 +319,7 @@ function setupCaptureOnQueueWatcher() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeWidgetsByName(names: string[]) {
|
// Widget lifecycle
|
||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeOriginalWidgets() {
|
function storeOriginalWidgets() {
|
||||||
withLitegraphNode((node) => {
|
withLitegraphNode((node) => {
|
||||||
if (!node.widgets) return
|
if (!node.widgets) return
|
||||||
@@ -327,15 +331,12 @@ function hideWidgets() {
|
|||||||
withLitegraphNode((node) => {
|
withLitegraphNode((node) => {
|
||||||
if (!node.widgets?.length) return
|
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(
|
updateNodeWidgets(
|
||||||
node,
|
node,
|
||||||
(widgets) =>
|
(widgets) =>
|
||||||
widgets.map((widget) => {
|
widgets.map((widget) => {
|
||||||
applyWidgetVisibility(widget, true)
|
applyWidgetVisibility(widget, true)
|
||||||
|
|
||||||
// Set default values for width and height if not already set
|
|
||||||
const needsDefault =
|
const needsDefault =
|
||||||
widget.value === undefined ||
|
widget.value === undefined ||
|
||||||
widget.value === null ||
|
widget.value === null ||
|
||||||
@@ -354,7 +355,6 @@ function hideWidgets() {
|
|||||||
{ dirtyCanvas: false }
|
{ dirtyCanvas: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Refresh Vue state to pick up the hidden widgets
|
|
||||||
nodeManager.value?.refreshVueWidgets(String(node.id))
|
nodeManager.value?.refreshVueWidgets(String(node.id))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -374,11 +374,9 @@ function setupSerializeValue() {
|
|||||||
(w) => w.name === 'capture_on_queue'
|
(w) => w.name === 'capture_on_queue'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Strictly check for boolean true (On Run mode)
|
|
||||||
const shouldCaptureOnQueue = captureOnQueueWidget?.value === true
|
const shouldCaptureOnQueue = captureOnQueueWidget?.value === true
|
||||||
|
|
||||||
if (shouldCaptureOnQueue) {
|
if (shouldCaptureOnQueue) {
|
||||||
// Auto-capture when queued - capture and upload immediately
|
|
||||||
const dataUrl = capturePhoto(node)
|
const dataUrl = capturePhoto(node)
|
||||||
if (!dataUrl) {
|
if (!dataUrl) {
|
||||||
const err = t('g.failedToCaptureImage', 'Failed to capture image')
|
const err = t('g.failedToCaptureImage', 'Failed to capture image')
|
||||||
@@ -388,7 +386,6 @@ function setupSerializeValue() {
|
|||||||
const path = await uploadImage(dataUrl, node)
|
const path = await uploadImage(dataUrl, node)
|
||||||
return path
|
return path
|
||||||
} else {
|
} else {
|
||||||
// Manual mode: validate image was captured
|
|
||||||
if (!lastUploadedPath.value || !node.imgs?.length) {
|
if (!lastUploadedPath.value || !node.imgs?.length) {
|
||||||
const err = t('g.noWebcamImageCaptured', 'No webcam image captured')
|
const err = t('g.noWebcamImageCaptured', 'No webcam image captured')
|
||||||
useToastStore().addAlert(err)
|
useToastStore().addAlert(err)
|
||||||
@@ -402,7 +399,6 @@ function setupSerializeValue() {
|
|||||||
|
|
||||||
function showWidgets() {
|
function showWidgets() {
|
||||||
withLitegraphNode((node) => {
|
withLitegraphNode((node) => {
|
||||||
// Get current capture_on_queue value to determine initial button visibility
|
|
||||||
const captureOnQueueWidget = node.widgets?.find(
|
const captureOnQueueWidget = node.widgets?.find(
|
||||||
(w) => w.name === 'capture_on_queue'
|
(w) => w.name === 'capture_on_queue'
|
||||||
)
|
)
|
||||||
@@ -421,10 +417,9 @@ function showWidgets() {
|
|||||||
name: CAPTURE_WIDGET_NAME,
|
name: CAPTURE_WIDGET_NAME,
|
||||||
label: t('g.capturePhoto', 'Capture Photo'),
|
label: t('g.capturePhoto', 'Capture Photo'),
|
||||||
iconClass: 'icon-[lucide--camera]',
|
iconClass: 'icon-[lucide--camera]',
|
||||||
onClick: () => captureImage(node)
|
onClick: () => handleCaptureImage(node)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hide capture button if in "On Run" mode
|
|
||||||
if (isOnRunMode) {
|
if (isOnRunMode) {
|
||||||
captureWidget.options = {
|
captureWidget.options = {
|
||||||
...captureWidget.options,
|
...captureWidget.options,
|
||||||
@@ -435,91 +430,13 @@ function showWidgets() {
|
|||||||
return [...sanitizedWidgets, captureWidget]
|
return [...sanitizedWidgets, captureWidget]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh Vue state to pick up the new widgets
|
|
||||||
nodeManager.value?.refreshVueWidgets(String(node.id))
|
nodeManager.value?.refreshVueWidgets(String(node.id))
|
||||||
|
|
||||||
// Set up watcher to toggle capture button visibility when mode changes
|
|
||||||
setupCaptureOnQueueWatcher()
|
setupCaptureOnQueueWatcher()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function capturePhoto(node: LGraphNode) {
|
// Capture and retake handlers
|
||||||
if (!node) return null
|
async function handleCaptureImage(node: LGraphNode) {
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const dataUrl = capturePhoto(node)
|
const dataUrl = capturePhoto(node)
|
||||||
if (!dataUrl) return
|
if (!dataUrl) return
|
||||||
|
|
||||||
@@ -541,173 +458,31 @@ async function captureImage(node: LGraphNode) {
|
|||||||
return [...preserved, retakeWidget]
|
return [...preserved, retakeWidget]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh Vue state to pick up the new widgets
|
|
||||||
nodeManager.value?.refreshVueWidgets(String(node.id))
|
nodeManager.value?.refreshVueWidgets(String(node.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRetake() {
|
async function handleRetake() {
|
||||||
capturedImageUrl.value = null
|
clearCapturedImage()
|
||||||
lastUploadedPath.value = null
|
|
||||||
removeWidgetsByName([RETAKE_WIDGET_NAME])
|
removeWidgetsByName([RETAKE_WIDGET_NAME])
|
||||||
await restartCameraPreview()
|
await restartCameraPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startCameraPreview() {
|
function handleStopPreview() {
|
||||||
if (props.readonly) return
|
stopCameraPreview()
|
||||||
|
hideWidgets()
|
||||||
// Prevent concurrent camera initialization attempts
|
}
|
||||||
if (isInitializingCamera.value) return
|
|
||||||
isInitializingCamera.value = true
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
capturedImageUrl.value = null
|
initializeElements()
|
||||||
|
|
||||||
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
|
|
||||||
hideWidgets()
|
hideWidgets()
|
||||||
// Wait for Vue reactivity to process the widget changes
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
storeOriginalWidgets()
|
storeOriginalWidgets()
|
||||||
setupSerializeValue()
|
setupSerializeValue()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// Clean up any pending video event listeners
|
cleanup()
|
||||||
pendingVideoCleanup.value?.()
|
|
||||||
stopStreamTracks()
|
|
||||||
restoreWidgets()
|
restoreWidgets()
|
||||||
|
|
||||||
// Clean up instance-specific elements
|
|
||||||
if (persistentVideo.value) {
|
|
||||||
persistentVideo.value.srcObject = null
|
|
||||||
persistentVideo.value = null
|
|
||||||
}
|
|
||||||
canvas.value = null
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user