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:
bymyself
2026-02-25 00:28:08 -08:00
parent ec523ae3a4
commit 4a3ef3b42b
3 changed files with 355 additions and 0 deletions

View File

@@ -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

View 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()
})
})

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