Compare commits

...

13 Commits

Author SHA1 Message Date
bymyself
5ae6d22c55 Merge branch 'main' into feat/glsl-live-preview 2026-03-15 23:06:48 -07:00
bymyself
198146b36c fix: address review feedback - stale render guard, dimension validation, behavioral tests 2026-03-05 03:38:13 -08:00
GitHub Action
1ac892ece1 [automated] Apply ESLint and Oxfmt fixes 2026-03-05 11:16:44 +00:00
Christian Byrne
720bc6004a Merge branch 'main' into feat/glsl-live-preview 2026-03-05 03:14:13 -08:00
bymyself
145b36cfa9 test: add tests for renderer config and preview autogrow extraction
Amp-Thread-ID: https://ampcode.com/threads/T-019ca2a6-bf8d-75fc-b497-d0338562b57f
2026-03-04 21:31:38 -08:00
bymyself
3d4ff94678 fix: cap preview resolution and extract limits from node definition
- Clamp render resolution to 1024px max dimension for browser perf
- Extract maxInputs/maxFloatUniforms/maxIntUniforms from node autogrow config
- Lazily create renderer with node-specific config
- Pass dynamic limits to uniform value loops

Amp-Thread-ID: https://ampcode.com/threads/T-019ca2a6-bf8d-75fc-b497-d0338562b57f
2026-03-04 21:31:38 -08:00
bymyself
4a3ef3b42b 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
2026-03-04 21:31:38 -08:00
bymyself
ec523ae3a4 fix: validate FBO completeness after attachment
Amp-Thread-ID: https://ampcode.com/threads/T-019cbc6d-818c-7027-917e-7194d9c8a50e
2026-03-04 21:31:25 -08:00
bymyself
e90c1f83e1 fix: clean up partial ping-pong resources on init failure
Amp-Thread-ID: https://ampcode.com/threads/T-019cbc6d-818c-7027-917e-7194d9c8a50e
2026-03-04 21:19:17 -08:00
bymyself
62c523dd55 fix: harden GLSL renderer init, input validation, and MRT logic
- Wrap init() in try/catch to honor boolean return contract
- Add bounds check in bindInputImage() for maxInputs
- Simplify drawBuffers: intermediate passes always use single
  COLOR_ATTACHMENT0 (ping-pong FBOs have one attachment)
- Remove unused outputCount/detectOutputCount from renderer

Amp-Thread-ID: https://ampcode.com/threads/T-019cbaf9-b1e8-702a-8fa8-bd83cb01adcc
2026-03-04 18:44:21 -08:00
bymyself
b4b6c4f565 fix: address remaining review feedback on GLSL renderer
- Replace non-null assertions with explicit null checks in
  initPingPongFBOs, getFallbackTexture, readPixels, and toBlob
- Fix drawBuffers logic: use gl.COLOR_ATTACHMENT0 for intermediate
  single-output passes, gl.BACK only for default framebuffer

Amp-Thread-ID: https://ampcode.com/threads/T-019cbaf9-b1e8-702a-8fa8-bd83cb01adcc
2026-03-04 14:37:24 -08:00
bymyself
ca10d7d9e3 fix: address review feedback on GLSL renderer
- Fix texture leak: reuse single fallback texture instead of creating per render
- Use JPEG output (quality 0.92) instead of PNG for Firefox/Safari perf
- Add UNPACK_FLIP_Y_WEBGL to flip images via GPU, remove manual flipVertically
- Accept configurable maxInputs/maxFloatUniforms/maxIntUniforms from caller
- Clean up fallback texture in dispose
- Add comment noting u_prevPass behavioral difference from backend

Amp-Thread-ID: https://ampcode.com/threads/T-019ca2a6-bf8d-75fc-b497-d0338562b57f
2026-03-04 14:32:43 -08:00
bymyself
8cd60b548a feat: add useGLSLRenderer composable and GLSL utility functions
Closure-based Vue composable with lazy init(), pure helper functions at
module scope, and onScopeDispose cleanup. Includes GLSL utility functions
for detecting output count and multi-pass shaders.

Amp-Thread-ID: https://ampcode.com/threads/T-019c98e6-1705-703c-bfdc-d1453abb74bb
2026-03-04 14:32:43 -08:00
5 changed files with 574 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
<div class="px-4 pb-2">
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p
class="m-0 text-sm/relaxed text-muted-foreground"
class="m-0 text-sm text-muted-foreground leading-relaxed"
:class="showManagerHint ? 'pb-3' : 'pb-5'"
>
{{
@@ -17,17 +17,17 @@
v-if="showManagerHint"
keypath="rightSidePanel.missingNodePacks.ossManagerDisabledHint"
tag="p"
class="m-0 pb-5 text-sm/relaxed text-muted-foreground"
class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed"
>
<template #pipCmd>
<code
class="rounded-sm bg-comfy-menu-bg px-1 py-0.5 font-mono text-xs text-comfy-input-foreground"
class="px-1 py-0.5 rounded-sm text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
>pip install -U --pre comfyui-manager</code
>
</template>
<template #flag>
<code
class="rounded-sm bg-comfy-menu-bg px-1 py-0.5 font-mono text-xs text-comfy-input-foreground"
class="px-1 py-0.5 rounded-sm text-xs font-mono bg-comfy-menu-bg text-comfy-input-foreground"
>--enable-manager</code
>
</template>
@@ -49,12 +49,12 @@
v-if="hasInstalledPacksPendingRestart"
variant="primary"
:disabled="isRestarting"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
<span class="min-w-0 truncate">{{
<span class="truncate min-w-0">{{
t('rightSidePanel.missingNodePacks.applyChanges')
}}</span>
</Button>

View File

@@ -272,6 +272,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'
@@ -714,6 +715,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,212 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { MaybeRefOrGetter } from 'vue'
const mockRendererFactory = vi.hoisted(() => {
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()
const lastConfig = { value: undefined as GLSLRendererConfig | undefined }
return {
create: (config?: GLSLRendererConfig) => {
lastConfig.value = config
return {
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
bindInputImage,
render,
toBlob,
dispose
}
},
lastConfig,
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
bindInputImage,
render,
toBlob,
dispose
}
})
vi.mock('@/renderer/glsl/useGLSLRenderer', () => ({
useGLSLRenderer: (config?: GLSLRendererConfig) =>
mockRendererFactory.create(config)
}))
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()
mockRendererFactory.lastConfig.value = undefined
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()
})
describe('autogrow config extraction', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
async function triggerRender(node: LGraphNode) {
mockGetNodeOutputs.mockReturnValue({
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
})
const store = useWidgetValueStore() as unknown as {
_widgetMap: Map<string, { value: unknown }>
}
store._widgetMap.set('fragment_shader', {
value: 'void main() {}'
})
const nodeRef = shallowRef<LGraphNode | null>(null)
useGLSLPreview(nodeRef)
nodeRef.value = node
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
}
it('passes default config when node has no comfyDynamic', async () => {
const node = createMockNode()
await triggerRender(node)
expect(mockRendererFactory.lastConfig.value).toEqual({
maxInputs: 5,
maxFloatUniforms: 5,
maxIntUniforms: 5
})
})
it('extracts autogrow limits from node comfyDynamic', async () => {
const node = createMockNode({
comfyDynamic: {
autogrow: {
images: { min: 1, max: 3 },
floats: { min: 0, max: 8 },
ints: { min: 0, max: 4 }
}
}
})
await triggerRender(node)
expect(mockRendererFactory.lastConfig.value).toEqual({
maxInputs: 3,
maxFloatUniforms: 8,
maxIntUniforms: 4
})
})
})
})

View File

@@ -0,0 +1,294 @@
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 type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { useGLSLRenderer } from '@/renderer/glsl/useGLSLRenderer'
const GLSL_NODE_TYPE = 'GLSLShader'
const DEBOUNCE_MS = 50
const DEFAULT_SIZE = 512
const MAX_PREVIEW_DIMENSION = 1024
interface AutogrowGroup {
max: number
min: number
prefix?: string
}
function getAutogrowLimits(node: LGraphNode): GLSLRendererConfig {
const defaults: GLSLRendererConfig = {
maxInputs: 5,
maxFloatUniforms: 5,
maxIntUniforms: 5
}
if (!('comfyDynamic' in node)) return defaults
const dynamic = node.comfyDynamic
if (
typeof dynamic !== 'object' ||
dynamic === null ||
!('autogrow' in dynamic)
)
return defaults
const groups = dynamic.autogrow as Record<string, AutogrowGroup> | undefined
if (!groups) return defaults
return {
maxInputs: groups['images']?.max ?? defaults.maxInputs,
maxFloatUniforms: groups['floats']?.max ?? defaults.maxFloatUniforms,
maxIntUniforms: groups['ints']?.max ?? defaults.maxIntUniforms
}
}
function normalizeDimension(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SIZE
return parsed
}
function clampResolution(w: number, h: number): [number, number] {
const maxDim = Math.max(w, h)
if (maxDim <= MAX_PREVIEW_DIMENSION) return [w, h]
const scale = MAX_PREVIEW_DIMENSION / maxDim
return [Math.round(w * scale), Math.round(h * scale)]
}
export function useGLSLPreview(
nodeMaybe: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const lastError = ref<string | null>(null)
const widgetValueStore = useWidgetValueStore()
const nodeOutputStore = useNodeOutputStore()
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
let rendererReady = false
let currentBlobUrl: string | null = null
let renderRequestId = 0
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 rendererConfig = computed(() => {
const node = nodeRef.value
if (!node) return { maxInputs: 5, maxFloatUniforms: 5, maxIntUniforms: 5 }
return getAutogrowLimits(node)
})
const floatValues = computed(() => {
const gId = graphId.value
const nId = nodeId.value
if (!gId || nId == null) return []
const values: number[] = []
for (let i = 0; i < rendererConfig.value.maxFloatUniforms; 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 < rendererConfig.value.maxIntUniforms; 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 || !renderer) 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 clampResolution(
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 clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}
function ensureRenderer(): ReturnType<typeof useGLSLRenderer> {
if (!renderer) {
renderer = useGLSLRenderer(rendererConfig.value)
}
return renderer
}
async function renderPreview(): Promise<void> {
const requestId = ++renderRequestId
const source = shaderSource.value
if (!source || !isActive.value) return
const r = ensureRenderer()
try {
if (!rendererReady) {
const [w, h] = getResolution()
if (!r.init(w, h)) {
lastError.value = 'WebGL2 not available'
return
}
rendererReady = true
}
const result = r.compileFragment(source)
if (!result.success) {
lastError.value = result.log
return
}
lastError.value = null
const [w, h] = getResolution()
r.setResolution(w, h)
loadInputImages()
for (let i = 0; i < floatValues.value.length; i++) {
r.setFloatUniform(i, floatValues.value[i])
}
for (let i = 0; i < intValues.value.length; i++) {
r.setIntUniform(i, intValues.value[i])
}
r.render()
const blob = await r.toBlob()
if (requestId !== renderRequestId) return
revokeBlobUrl()
currentBlobUrl = URL.createObjectURL(blob)
const nId = nodeId.value
if (nId != null) {
nodeOutputStore.setNodePreviewsByNodeId(nId, [currentBlobUrl])
}
} catch (error) {
if (requestId !== renderRequestId) return
lastError.value =
error instanceof Error ? error.message : 'Failed to render preview'
}
}
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
}
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onScopeDispose: vi.fn()
}
})
describe('useGLSLRenderer', () => {
it('returns renderer API with expected methods', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
expect(renderer).toHaveProperty('init')
expect(renderer).toHaveProperty('compileFragment')
expect(renderer).toHaveProperty('setResolution')
expect(renderer).toHaveProperty('setFloatUniform')
expect(renderer).toHaveProperty('setIntUniform')
expect(renderer).toHaveProperty('bindInputImage')
expect(renderer).toHaveProperty('render')
expect(renderer).toHaveProperty('readPixels')
expect(renderer).toHaveProperty('toBlob')
expect(renderer).toHaveProperty('dispose')
})
it('init returns false when WebGL2 is unavailable', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
expect(renderer.init(256, 256)).toBe(false)
})
it('compileFragment reports error before initialization', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
const result = renderer.compileFragment('void main() {}')
expect(result.success).toBe(false)
})
it('toBlob rejects before initialization', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
await expect(renderer.toBlob()).rejects.toThrow('Renderer not initialized')
})
it('accepts custom config without error', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const config: GLSLRendererConfig = {
maxInputs: 3,
maxFloatUniforms: 2,
maxIntUniforms: 1
}
const renderer = useGLSLRenderer(config)
expect(renderer.init(256, 256)).toBe(false)
})
})