mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-19 03:47:31 +00:00
Compare commits
37 Commits
refactor/e
...
webcam-cap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882d4c186a | ||
|
|
d5751aea13 | ||
|
|
ac879765fc | ||
|
|
bc8569080d | ||
|
|
0c50251943 | ||
|
|
e7481c5c36 | ||
|
|
3d5aef4aa0 | ||
|
|
474b15c48b | ||
|
|
1c37a3633d | ||
|
|
1e384c6735 | ||
|
|
e092986cfd | ||
|
|
51ab1541dd | ||
|
|
8031a832f4 | ||
|
|
7b109df599 | ||
|
|
f7f0c05d1a | ||
|
|
335b72bc62 | ||
|
|
f8ede78dc6 | ||
|
|
af8ad95af1 | ||
|
|
cd5f6fd0b9 | ||
|
|
68b6159f99 | ||
|
|
4b628724bd | ||
|
|
cfbb4c708f | ||
|
|
dc60b54ab4 | ||
|
|
ad85956c77 | ||
|
|
f9b7e51b63 | ||
|
|
59761eb65d | ||
|
|
16b6b5bbbc | ||
|
|
ca335baac9 | ||
|
|
48c21f6f25 | ||
|
|
a455c7be4c | ||
|
|
e1693dc341 | ||
|
|
a65b063a98 | ||
|
|
7fc51c6d96 | ||
|
|
0ac768722c | ||
|
|
450b8b9954 | ||
|
|
74aadb3227 | ||
|
|
6f4c335566 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user