mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat: add useGLSLPreview composable and LGraphNode integration
Vue composable that enables client-side GLSL shader preview: - Watches widgetValueStore for float/int uniform changes - Lazily creates GLSLPreviewEngine on first widget change - Renders shader via WebGL2 and injects blob URL preview - 50ms debounce for smooth slider interaction - Integrates with LGraphNode.vue for GLSLShader nodes - Unit tests for composable activation and cleanup logic Amp-Thread-ID: https://ampcode.com/threads/T-019c98e6-1705-703c-bfdc-d1453abb74bb
This commit is contained in:
@@ -288,6 +288,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
@@ -665,6 +666,8 @@ const lgraphNode = computed(() => {
|
||||
// reaching through lgraphNode for promoted preview resolution.
|
||||
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
|
||||
|
||||
useGLSLPreview(lgraphNode)
|
||||
|
||||
const showAdvancedInputsButton = computed(() => {
|
||||
const node = lgraphNode.value
|
||||
if (!node) return false
|
||||
|
||||
130
src/renderer/glsl/useGLSLPreview.test.ts
Normal file
130
src/renderer/glsl/useGLSLPreview.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
vi.mock('@/renderer/glsl/useGLSLRenderer', () => {
|
||||
const init = vi.fn(() => true)
|
||||
const compileFragment = vi.fn(() => ({ success: true, log: '' }))
|
||||
const setResolution = vi.fn()
|
||||
const setFloatUniform = vi.fn()
|
||||
const setIntUniform = vi.fn()
|
||||
const bindInputImage = vi.fn()
|
||||
const render = vi.fn()
|
||||
const toBlob = vi.fn(() => Promise.resolve(new Blob(['test'])))
|
||||
const dispose = vi.fn()
|
||||
|
||||
return {
|
||||
useGLSLRenderer: () => ({
|
||||
init,
|
||||
compileFragment,
|
||||
setResolution,
|
||||
setFloatUniform,
|
||||
setIntUniform,
|
||||
bindInputImage,
|
||||
render,
|
||||
toBlob,
|
||||
dispose
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mockGetNodeOutputs = vi.fn()
|
||||
const mockSetNodePreviewsByNodeId = vi.fn()
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
getNodeOutputs: mockGetNodeOutputs,
|
||||
setNodePreviewsByNodeId: mockSetNodePreviewsByNodeId,
|
||||
nodeOutputs: ref({})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => {
|
||||
const widgetMap = new Map<string, { value: unknown }>()
|
||||
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
|
||||
widgetMap.get(name)
|
||||
)
|
||||
return {
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget,
|
||||
_widgetMap: widgetMap
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
nodeIdToNodeLocatorId: (id: string | number) => String(id),
|
||||
nodeToNodeLocatorId: (node: { id: string | number }) => String(node.id)
|
||||
})
|
||||
}))
|
||||
|
||||
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'GLSLShader',
|
||||
inputs: [],
|
||||
graph: { id: 'test-graph-id' },
|
||||
getInputNode: vi.fn(() => null),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function wrapNode(
|
||||
node: LGraphNode | null
|
||||
): MaybeRefOrGetter<LGraphNode | null> {
|
||||
return ref(node) as MaybeRefOrGetter<LGraphNode | null>
|
||||
}
|
||||
|
||||
describe('useGLSLPreview', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:test')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
it('does not activate for non-GLSLShader nodes', () => {
|
||||
const node = createMockNode({ type: 'KSampler' })
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not activate before first execution', () => {
|
||||
const node = createMockNode()
|
||||
mockGetNodeOutputs.mockReturnValue(undefined)
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('activates for GLSLShader nodes with execution output', () => {
|
||||
const node = createMockNode()
|
||||
mockGetNodeOutputs.mockReturnValue({
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
})
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes lastError as null initially', () => {
|
||||
const node = createMockNode()
|
||||
const { lastError } = useGLSLPreview(wrapNode(node))
|
||||
expect(lastError.value).toBe(null)
|
||||
})
|
||||
|
||||
it('does not activate for null node', () => {
|
||||
const { isActive } = useGLSLPreview(wrapNode(null))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans up on dispose', () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = useGLSLPreview(wrapNode(node))
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
222
src/renderer/glsl/useGLSLPreview.ts
Normal file
222
src/renderer/glsl/useGLSLPreview.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { useGLSLRenderer } from '@/renderer/glsl/useGLSLRenderer'
|
||||
|
||||
const GLSL_NODE_TYPE = 'GLSLShader'
|
||||
const DEBOUNCE_MS = 50
|
||||
const DEFAULT_SIZE = 512
|
||||
|
||||
export function useGLSLPreview(
|
||||
nodeMaybe: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const lastError = ref<string | null>(null)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const renderer = useGLSLRenderer()
|
||||
let rendererReady = false
|
||||
let currentBlobUrl: string | null = null
|
||||
|
||||
const nodeRef = computed(() => toValue(nodeMaybe) ?? null)
|
||||
|
||||
const isGLSLNode = computed(() => nodeRef.value?.type === GLSL_NODE_TYPE)
|
||||
|
||||
const graphId = computed(() => nodeRef.value?.graph?.id as UUID | undefined)
|
||||
|
||||
const nodeId = computed(() => nodeRef.value?.id as NodeId | undefined)
|
||||
|
||||
const hasExecutionOutput = computed(() => {
|
||||
const node = nodeRef.value
|
||||
if (!node) return false
|
||||
return !!nodeOutputStore.getNodeOutputs(node)?.images?.length
|
||||
})
|
||||
|
||||
const isActive = computed(() => isGLSLNode.value && hasExecutionOutput.value)
|
||||
|
||||
const shaderSource = computed(() => {
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (!gId || nId == null) return undefined
|
||||
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
|
||||
| string
|
||||
| undefined
|
||||
})
|
||||
|
||||
const floatValues = computed(() => {
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (!gId || nId == null) return []
|
||||
|
||||
const values: number[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, `floats.u_float${i}`)
|
||||
if (widget === undefined) break
|
||||
values.push(Number(widget.value) || 0)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
const intValues = computed(() => {
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (!gId || nId == null) return []
|
||||
|
||||
const values: number[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, `ints.u_int${i}`)
|
||||
if (widget === undefined) break
|
||||
values.push(Number(widget.value) || 0)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
function revokeBlobUrl(): void {
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl)
|
||||
currentBlobUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function loadInputImages(): void {
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return
|
||||
|
||||
let imageSlotIndex = 0
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
const input = node.inputs[slot]
|
||||
if (!input.name.startsWith('images.image')) continue
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) continue
|
||||
|
||||
const imgs = upstreamNode.imgs
|
||||
if (imgs?.length) {
|
||||
renderer.bindInputImage(imageSlotIndex, imgs[0])
|
||||
}
|
||||
imageSlotIndex++
|
||||
}
|
||||
}
|
||||
|
||||
function getResolution(): [number, number] {
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
const input = node.inputs[slot]
|
||||
if (!input.name.startsWith('images.image')) continue
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode?.imgs?.length) continue
|
||||
|
||||
const img = upstreamNode.imgs[0]
|
||||
return [
|
||||
img.naturalWidth || DEFAULT_SIZE,
|
||||
img.naturalHeight || DEFAULT_SIZE
|
||||
]
|
||||
}
|
||||
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (gId && nId != null) {
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (widthWidget && heightWidget) {
|
||||
return [
|
||||
Number(widthWidget.value) || DEFAULT_SIZE,
|
||||
Number(heightWidget.value) || DEFAULT_SIZE
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
async function renderPreview(): Promise<void> {
|
||||
const source = shaderSource.value
|
||||
if (!source || !isActive.value) return
|
||||
|
||||
if (!rendererReady) {
|
||||
const [w, h] = getResolution()
|
||||
if (!renderer.init(w, h)) {
|
||||
lastError.value = 'WebGL2 not available'
|
||||
return
|
||||
}
|
||||
rendererReady = true
|
||||
}
|
||||
|
||||
const result = renderer.compileFragment(source)
|
||||
if (!result.success) {
|
||||
lastError.value = result.log
|
||||
return
|
||||
}
|
||||
lastError.value = null
|
||||
|
||||
const [w, h] = getResolution()
|
||||
renderer.setResolution(w, h)
|
||||
|
||||
loadInputImages()
|
||||
|
||||
for (let i = 0; i < floatValues.value.length; i++) {
|
||||
renderer.setFloatUniform(i, floatValues.value[i])
|
||||
}
|
||||
for (let i = 0; i < intValues.value.length; i++) {
|
||||
renderer.setIntUniform(i, intValues.value[i])
|
||||
}
|
||||
|
||||
renderer.render()
|
||||
|
||||
const blob = await renderer.toBlob()
|
||||
revokeBlobUrl()
|
||||
currentBlobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const nId = nodeId.value
|
||||
if (nId != null) {
|
||||
nodeOutputStore.setNodePreviewsByNodeId(nId, [currentBlobUrl])
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedRender = debounce((): void => {
|
||||
void renderPreview()
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
watch(
|
||||
() => [floatValues.value, intValues.value] as const,
|
||||
() => {
|
||||
if (isActive.value) debouncedRender()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(shaderSource, () => {
|
||||
if (isActive.value) debouncedRender()
|
||||
})
|
||||
|
||||
function dispose(): void {
|
||||
debouncedRender.cancel()
|
||||
revokeBlobUrl()
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
onScopeDispose(dispose)
|
||||
|
||||
return {
|
||||
isActive,
|
||||
lastError,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user