mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 12:11:06 +00:00
Compare commits
13 Commits
feat/node-
...
feat/glsl-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ae6d22c55 | ||
|
|
198146b36c | ||
|
|
1ac892ece1 | ||
|
|
720bc6004a | ||
|
|
145b36cfa9 | ||
|
|
3d4ff94678 | ||
|
|
4a3ef3b42b | ||
|
|
ec523ae3a4 | ||
|
|
e90c1f83e1 | ||
|
|
62c523dd55 | ||
|
|
b4b6c4f565 | ||
|
|
ca10d7d9e3 | ||
|
|
8cd60b548a |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
212
src/renderer/glsl/useGLSLPreview.test.ts
Normal file
212
src/renderer/glsl/useGLSLPreview.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
294
src/renderer/glsl/useGLSLPreview.ts
Normal file
294
src/renderer/glsl/useGLSLPreview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
src/renderer/glsl/useGLSLRenderer.test.ts
Normal file
59
src/renderer/glsl/useGLSLRenderer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user