Image pipeline (WebGL)
A real image-filter pipeline — Source → Exposure → Saturation → Hue → Blur → Vignette → Result. Each node is a live GLSL fragment pass. Drag a slider and the result re-renders; drop your own image; download the PNG.
import { XenolithGraph, XenolithControls, XenolithPanel, XenolithButton, useEditor, reactWidget, type WidgetProps } from '@xenolithengine/graph-react'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildImagePipeline, downloadImageResult } from '@xenolithengine/demo/image-pipeline'
import { DemoStage } from '../Layout.js'
// Showcase: a real WebGL image pipeline. All the framework-agnostic logic (GLSL runner, filter
// schemas, chain layout, live re-processing, download) lives in @xenolithengine/demo/image-pipeline.
// Setup runs synchronously in `onReady` (event subscriptions live on the editor — no handle to
// thread around); the panel's Download button reads the current result via `useEditor()`.
function ImageInput({ value, setValue }: WidgetProps): React.ReactElement {
const onFile = (file?: File): void => {
if (!file) return
const r = new FileReader()
r.onload = () => setValue(String(r.result))
r.readAsDataURL(file)
}
return (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); onFile(e.dataTransfer.files?.[0]) }}
style={{ position: 'relative', width: '100%', height: '100%', borderRadius: 8, overflow: 'hidden', background: 'rgba(0,0,0,0.25)', border: '1px solid var(--xeno-border)' }}
>
{value
? <img src={String(value)} alt="source" style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
: <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--xeno-muted)', fontSize: 12 }}>Drop an image</div>}
<label style={{ position: 'absolute', bottom: 6, right: 6, fontSize: 10, padding: '3px 8px', borderRadius: 6, background: 'var(--xeno-elevated)', color: 'var(--xeno-text)', cursor: 'pointer', border: '1px solid var(--xeno-border)' }}>
Replace
<input type="file" accept="image/*" hidden onChange={(e) => onFile(e.target.files?.[0])} />
</label>
</div>
)
}
function ImageOutput({ value }: WidgetProps): React.ReactElement {
return (
<div style={{ width: '100%', height: '100%', borderRadius: 8, overflow: 'hidden', background: 'rgba(0,0,0,0.25)', border: '1px solid var(--xeno-border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{value
? <img src={String(value)} alt="result" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
: <span style={{ color: 'var(--xeno-muted)', fontSize: 12 }}>Rendering…</span>}
</div>
)
}
function setupImagePipeline(editor: XenolithEditor): void {
buildImagePipeline(editor, { input: reactWidget(ImageInput), output: reactWidget(ImageOutput) })
}
function PipelinePanel(): React.ReactElement {
const editor = useEditor()
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 200 }}>
<p style={{ margin: 0, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--xeno-muted)' }}>Image pipeline</p>
<XenolithButton style={{ width: '100%' }} onClick={() => downloadImageResult(editor)}>↓ Download result.png</XenolithButton>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.45 }}>
Each node is a live GLSL pass. Drag a slider — the result re-renders. Drop your own image on the Source node.
</span>
</XenolithPanel>
)
}
/** Showcase: real WebGL image filters as a node graph. Thin React shell over the shared core. */
export function ImagePipelineDemo(): React.ReactElement {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupImagePipeline}>
<XenolithControls position="bottom-left" />
<PipelinePanel />
</XenolithGraph>
</DemoStage>
)
} // A tiny real WebGL image pipeline used by ImagePipelineDemo. Each filter node is one GLSL
// fragment pass; the runner chains them with ping-pong framebuffers (source → pass → pass → … →
// canvas) and hands back a PNG data URL. GLSL ES 1.00, single fullscreen triangle, no deps.
export interface Filter {
type: string
title: string
/** Slider widgets shown on the node; values live in node.state keyed by `key`. */
widgets: { id: string; label: string; type: 'slider'; key: string; min: number; max: number; step: number; freeFloating?: boolean }[]
/** Default state for a freshly-instantiated node. */
defaults: Record<string, number>
/** GLSL declarations (uniforms) injected before main(). */
uniforms: string
/** GLSL body that mutates `vec4 c` (the current pixel, pre-sampled from u_tex at v_uv). */
body: string
/** Push this node's state into the program's uniforms for one pass. */
setUniforms(gl: WebGLRenderingContext, prog: WebGLProgram, state: Record<string, unknown>): void
}
const num = (s: Record<string, unknown>, k: string, d: number): number =>
typeof s[k] === 'number' ? (s[k] as number) : d
function f1(gl: WebGLRenderingContext, prog: WebGLProgram, name: string, v: number): void {
const loc = gl.getUniformLocation(prog, name)
if (loc) gl.uniform1f(loc, v)
}
export const FILTERS: Record<string, Filter> = {
Exposure: {
type: 'Exposure', title: 'Exposure',
widgets: [{ id: 'amt', label: 'EV', type: 'slider', key: 'amt', min: -2, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 0.25 },
uniforms: 'uniform float u_amt;',
body: 'c.rgb *= exp2(u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Saturation: {
type: 'Saturation', title: 'Saturation',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 1.45 },
uniforms: 'uniform float u_amt;',
body: 'float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 1)),
},
Hue: {
type: 'Hue', title: 'Hue rotate',
widgets: [{ id: 'amt', label: 'Degrees', type: 'slider', key: 'amt', min: -180, max: 180, step: 1, freeFloating: true }],
defaults: { amt: 25 },
uniforms: 'uniform float u_amt;',
body: [
'const mat3 toYIQ = mat3(0.299, 0.596, 0.211, 0.587, -0.274, -0.523, 0.114, -0.322, 0.312);',
'const mat3 toRGB = mat3(1.0, 1.0, 1.0, 0.956, -0.272, -1.106, 0.621, -0.647, 1.703);',
'vec3 yiq = toYIQ * c.rgb;',
'float hue = atan(yiq.z, yiq.y) + u_amt;',
'float chroma = length(yiq.yz);',
'yiq.y = chroma * cos(hue); yiq.z = chroma * sin(hue);',
'c.rgb = toRGB * yiq;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', (num(s, 'amt', 0) * Math.PI) / 180),
},
Blur: {
type: 'Blur', title: 'Gaussian blur',
widgets: [{ id: 'amt', label: 'Radius', type: 'slider', key: 'amt', min: 0, max: 5, step: 0.1, freeFloating: true }],
defaults: { amt: 1.2 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 o = u_texel * u_amt;',
'vec4 s = texture2D(u_tex, v_uv) * 4.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(-o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, -o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2( o.x, -o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, -o.y));',
'c = s / 16.0;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Vignette: {
type: 'Vignette', title: 'Vignette',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 1, step: 0.01, freeFloating: true }],
defaults: { amt: 0.55 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 d = v_uv - 0.5;',
'float vig = smoothstep(0.8, 0.25, length(d) * 1.4);',
'c.rgb *= mix(1.0, vig, u_amt);',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
}
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`
const PASSTHROUGH = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
void main() { gl_FragColor = texture2D(u_tex, v_uv); }`
function fragFor(filter: Filter): string {
return `precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
${filter.uniforms}
void main() {
vec4 c = texture2D(u_tex, v_uv);
${filter.body}
gl_FragColor = c;
}`
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const sh = gl.createShader(type)!
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader compile failed: ' + gl.getShaderInfoLog(sh))
}
return sh
}
/** A reusable WebGL image-filter runner. Create once, call `run` per pipeline evaluation. */
export class ImagePipelineRunner {
readonly #canvas: HTMLCanvasElement
readonly #gl: WebGLRenderingContext
readonly #programs = new Map<string, WebGLProgram>() // fragment src → program
readonly #vert: WebGLShader
#w = 0
#h = 0
#fbo: WebGLFramebuffer[] = []
#tex: WebGLTexture[] = []
constructor() {
this.#canvas = document.createElement('canvas')
const gl = this.#canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
if (!gl) throw new Error('WebGL unavailable')
this.#gl = gl
this.#vert = compile(gl, gl.VERTEX_SHADER, VERT)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
}
#program(fragSrc: string): WebGLProgram {
const cached = this.#programs.get(fragSrc)
if (cached) return cached
const gl = this.#gl
const prog = gl.createProgram()!
gl.attachShader(prog, this.#vert)
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fragSrc))
gl.bindAttribLocation(prog, 0, 'a_pos')
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('program link failed: ' + gl.getProgramInfoLog(prog))
}
this.#programs.set(fragSrc, prog)
return prog
}
#resize(w: number, h: number): void {
if (w === this.#w && h === this.#h && this.#tex.length) return
const gl = this.#gl
this.#canvas.width = w
this.#canvas.height = h
for (const t of this.#tex) gl.deleteTexture(t)
for (const f of this.#fbo) gl.deleteFramebuffer(f)
this.#tex = []
this.#fbo = []
for (let i = 0; i < 2; i++) {
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
this.#tex.push(tex)
this.#fbo.push(fbo)
}
this.#w = w
this.#h = h
}
/** Run the chain of filters over `image` and return the result as a PNG data URL. */
run(image: HTMLImageElement, passes: { filter: Filter; state: Record<string, unknown> }[]): string {
const gl = this.#gl
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
this.#resize(w, h)
// Upload the source flipped so it renders upright through the framebuffer chain to the canvas.
const srcTex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, srcTex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.viewport(0, 0, w, h)
const list = passes.length ? passes : [{ filter: null as Filter | null, state: {} }]
let input = srcTex
for (let i = 0; i < list.length; i++) {
const last = i === list.length - 1
const entry = list[i]!
const prog = this.#program(entry.filter ? fragFor(entry.filter) : PASSTHROUGH)
gl.useProgram(prog)
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.#fbo[i % 2]!)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, input)
const texLoc = gl.getUniformLocation(prog, 'u_tex')
if (texLoc) gl.uniform1i(texLoc, 0)
const texelLoc = gl.getUniformLocation(prog, 'u_texel')
if (texelLoc) gl.uniform2f(texelLoc, 1 / w, 1 / h)
if (entry.filter) entry.filter.setUniforms(gl, prog, entry.state)
gl.drawArrays(gl.TRIANGLES, 0, 3)
input = this.#tex[i % 2]!
}
gl.deleteTexture(srcTex)
return this.#canvas.toDataURL('image/png')
}
}
/** Procedurally drawn default source image (no bundled asset) — a colorful test card. */
export function defaultSourceImage(): string {
const w = 480, h = 320
const cv = document.createElement('canvas')
cv.width = w; cv.height = h
const ctx = cv.getContext('2d')!
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#1b2a4a'); g.addColorStop(0.5, '#7a3b9e'); g.addColorStop(1, '#e8a04b')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
const discs: [number, number, number, string][] = [
[120, 110, 70, 'rgba(255, 90, 120, 0.85)'],
[330, 90, 55, 'rgba(80, 220, 200, 0.85)'],
[250, 220, 85, 'rgba(255, 215, 90, 0.8)'],
[400, 250, 45, 'rgba(120, 160, 255, 0.85)'],
]
for (const [x, y, r, fill] of discs) {
ctx.fillStyle = fill
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
}
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 3
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo((w / 6) * i, 0); ctx.lineTo((w / 6) * i, h); ctx.stroke() }
return cv.toDataURL('image/png')
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// ─── Framework-agnostic showcase wiring ─────────────────────────────────────────────────────────
// Reused as-is by every framework demo. The only framework-supplied pieces are the two preview
// widget controllers (Source/Result); the schemas, layout, live re-processing and download are shared.
import type { XenolithEditor, NodeSchema, Node } from '@xenolithengine/graph-editor'
import type { CustomWidgetController } from '@xenolithengine/graph-render-pixi'
import { reachableFrom } from '@xenolithengine/graph-core'
export const OUT_RESULT = 'result'
/** Follow the linear chain from the source node and collect the filter nodes (excluding output). */
function chainFilters(editor: XenolithEditor, srcId: string, outId: string): Node[] {
const edges = [...editor.graphEdges()]
const out: Node[] = []
const seen = new Set<string>()
let cur = srcId
for (;;) {
const edge = edges.find((e) => e.from.node === cur)
if (!edge || edge.to.node === outId || seen.has(edge.to.node)) break
seen.add(edge.to.node)
const n = editor.getNode(edge.to.node)
if (!n) break
out.push(n as Node)
cur = edge.to.node
}
return out
}
export interface ImagePipelineWidgets {
input: CustomWidgetController
output: CustomWidgetController
}
export interface ImagePipelineHandle {
outputId: string
process(): Promise<void>
download(): void
}
/** Register schemas, build the Source → filters → Result chain, wire live re-processing on every
* widget change, and return a handle the UI layer drives. Framework-agnostic. */
export function buildImagePipeline(editor: XenolithEditor, widgets: ImagePipelineWidgets): ImagePipelineHandle {
editor.registerWidget('img-input', widgets.input)
editor.registerWidget('img-output', widgets.output)
editor.registry.register({
type: 'Source', title: 'Source image',
pins: [{ kind: 'data', direction: 'out', type: 'image', label: 'Out' }],
widgets: [{ id: 'src', label: '', type: 'custom', renderer: 'img-input', key: 'src', height: 170 }],
} satisfies NodeSchema)
editor.registry.register({
type: 'Output', title: 'Result',
pins: [{ kind: 'data', direction: 'in', type: 'image', label: 'In' }],
widgets: [{ id: OUT_RESULT, label: '', type: 'custom', renderer: 'img-output', key: OUT_RESULT, height: 170 }],
} satisfies NodeSchema)
const filters = Object.values(FILTERS)
for (const f of filters) {
editor.registry.register({
type: f.type, title: f.title,
pins: [
{ kind: 'data', direction: 'in', type: 'image', label: 'In' },
{ kind: 'data', direction: 'out', type: 'image', label: 'Out' },
],
widgets: f.widgets,
})
}
const source = editor.registry.instantiate('Source', { x: 0, y: 40 })
source.size = { x: 300, y: 230 }
source.state['src'] = defaultSourceImage()
editor.addNode(source)
let prev = source
let prevOut = 0
filters.forEach((f, i) => {
const node = editor.registry.instantiate(f.type, { x: 360 + i * 200, y: 110 })
for (const [k, v] of Object.entries(f.defaults)) node.state[k] = v
editor.addNode(node)
editor.connect(prev, prevOut, node, 0)
prev = node
prevOut = 1
})
const output = editor.registry.instantiate('Output', { x: 360 + filters.length * 200 + 40, y: 40 })
output.size = { x: 300, y: 230 }
output.state[OUT_RESULT] = ''
editor.addNode(output)
editor.connect(prev, prevOut, output, 0)
const runner = new ImagePipelineRunner()
let busy = false
const process = async (): Promise<void> => {
if (busy) return
// If the Output is no longer wired up from the Source (an edge was cut), there is no result to
// show — clear it. Otherwise the Result node would keep displaying a stale image that can't exist.
if (!reachableFrom(editor.readGraph(), source.id).has(output.id)) {
editor.setWidgetValue(output.id, OUT_RESULT, '')
return
}
const src = editor.getNode(source.id)?.state['src']
if (typeof src !== 'string' || !src) return
busy = true
try {
const img = await loadImage(src)
const passes = chainFilters(editor, String(source.id), String(output.id)).map((n) => ({ filter: FILTERS[n.type]!, state: n.state }))
const url = runner.run(img, passes)
editor.setWidgetValue(output.id, OUT_RESULT, url)
} catch { /* decode/GL failure — keep the previous result */ } finally { busy = false }
}
// Re-process on any change that affects the result: a widget edit, OR the graph topology changing
// (connect/disconnect/remove) — rewiring the chain (e.g. bypassing a filter) must update the output
// immediately, not only after the next slider move.
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (): void => { clearTimeout(timer); timer = setTimeout(() => { void process() }, 140) }
editor.on('widget:changed', (e) => { if (e.widgetId !== OUT_RESULT) schedule() })
editor.on('edge:connected', schedule)
editor.on('edge:disconnected', schedule)
editor.on('node:removed', schedule)
editor.on('node:added', schedule) // undo of a delete re-adds the node + its edges → re-process
editor.fitView({ padding: 56, maxZoom: 1 })
void process()
const download = (): void => {
const url = editor.getNode(output.id)?.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}
return { outputId: String(output.id), process, download }
}
/** Trigger a PNG download of the current Result node's image. Standalone helper so a host can
* wire a Download button via just `useEditor()` — no need to thread a build-handle around. */
export function downloadImageResult(editor: XenolithEditor): void {
const output = [...editor.graphNodes()].find((n) => n.type === 'Output')
if (!output) return
const url = output.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
} <script setup lang="ts">
import { XenolithGraph, XenolithControls } from '@xenolithengine/graph-vue'
import PipelinePanel from './PipelinePanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<XenolithControls position="bottom-left" />
<PipelinePanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
function onFile(file?: File): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => props.setValue(String(reader.result))
reader.readAsDataURL(file)
}
function onDrop(event: DragEvent): void {
onFile(event.dataTransfer?.files?.[0])
}
function onPick(event: Event): void {
onFile((event.target as HTMLInputElement).files?.[0])
}
</script>
<template>
<div
style="position:relative;width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);"
@dragover.prevent
@drop.prevent="onDrop"
>
<img v-if="value" :src="String(value)" alt="source" style="width:100%;height:100%;object-fit:contain;display:block;" />
<div v-else style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--xeno-muted);font-size:12px;">Drop an image</div>
<label style="position:absolute;bottom:6px;right:6px;font-size:10px;padding:3px 8px;border-radius:6px;background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;border:1px solid var(--xeno-border);">
Replace
<input type="file" accept="image/*" hidden @change="onPick" />
</label>
</div>
</template> <script setup lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-vue'
defineProps<WidgetProps>()
</script>
<template>
<div style="width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);display:flex;align-items:center;justify-content:center;">
<img v-if="value" :src="String(value)" alt="result" style="width:100%;height:100%;object-fit:contain;" />
<span v-else style="color:var(--xeno-muted);font-size:12px;">Rendering…</span>
</div>
</template> <script setup lang="ts">
import { watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor, vueWidget } from '@xenolithengine/graph-vue'
import { buildImagePipeline, downloadImageResult } from '@xenolithengine/demo/image-pipeline'
import ImageInput from './ImageInput.vue'
import ImageOutput from './ImageOutput.vue'
const editor = useEditor()
const input = vueWidget(ImageInput)
const output = vueWidget(ImageOutput)
watch(editor, (ed) => {
if (!ed) return
buildImagePipeline(ed, { input, output })
}, { immediate: true })
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;width:200px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Image pipeline</p>
<XenolithButton style="width:100%" @click="editor && downloadImageResult(editor)">↓ Download result.png</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.45;">
Each node is a live GLSL pass. Drag a slider — the result re-renders. Drop your own image on the Source node.
</span>
</div>
</XenolithPanel>
</template> // A tiny real WebGL image pipeline used by ImagePipelineDemo. Each filter node is one GLSL
// fragment pass; the runner chains them with ping-pong framebuffers (source → pass → pass → … →
// canvas) and hands back a PNG data URL. GLSL ES 1.00, single fullscreen triangle, no deps.
export interface Filter {
type: string
title: string
/** Slider widgets shown on the node; values live in node.state keyed by `key`. */
widgets: { id: string; label: string; type: 'slider'; key: string; min: number; max: number; step: number; freeFloating?: boolean }[]
/** Default state for a freshly-instantiated node. */
defaults: Record<string, number>
/** GLSL declarations (uniforms) injected before main(). */
uniforms: string
/** GLSL body that mutates `vec4 c` (the current pixel, pre-sampled from u_tex at v_uv). */
body: string
/** Push this node's state into the program's uniforms for one pass. */
setUniforms(gl: WebGLRenderingContext, prog: WebGLProgram, state: Record<string, unknown>): void
}
const num = (s: Record<string, unknown>, k: string, d: number): number =>
typeof s[k] === 'number' ? (s[k] as number) : d
function f1(gl: WebGLRenderingContext, prog: WebGLProgram, name: string, v: number): void {
const loc = gl.getUniformLocation(prog, name)
if (loc) gl.uniform1f(loc, v)
}
export const FILTERS: Record<string, Filter> = {
Exposure: {
type: 'Exposure', title: 'Exposure',
widgets: [{ id: 'amt', label: 'EV', type: 'slider', key: 'amt', min: -2, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 0.25 },
uniforms: 'uniform float u_amt;',
body: 'c.rgb *= exp2(u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Saturation: {
type: 'Saturation', title: 'Saturation',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 1.45 },
uniforms: 'uniform float u_amt;',
body: 'float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 1)),
},
Hue: {
type: 'Hue', title: 'Hue rotate',
widgets: [{ id: 'amt', label: 'Degrees', type: 'slider', key: 'amt', min: -180, max: 180, step: 1, freeFloating: true }],
defaults: { amt: 25 },
uniforms: 'uniform float u_amt;',
body: [
'const mat3 toYIQ = mat3(0.299, 0.596, 0.211, 0.587, -0.274, -0.523, 0.114, -0.322, 0.312);',
'const mat3 toRGB = mat3(1.0, 1.0, 1.0, 0.956, -0.272, -1.106, 0.621, -0.647, 1.703);',
'vec3 yiq = toYIQ * c.rgb;',
'float hue = atan(yiq.z, yiq.y) + u_amt;',
'float chroma = length(yiq.yz);',
'yiq.y = chroma * cos(hue); yiq.z = chroma * sin(hue);',
'c.rgb = toRGB * yiq;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', (num(s, 'amt', 0) * Math.PI) / 180),
},
Blur: {
type: 'Blur', title: 'Gaussian blur',
widgets: [{ id: 'amt', label: 'Radius', type: 'slider', key: 'amt', min: 0, max: 5, step: 0.1, freeFloating: true }],
defaults: { amt: 1.2 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 o = u_texel * u_amt;',
'vec4 s = texture2D(u_tex, v_uv) * 4.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(-o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, -o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2( o.x, -o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, -o.y));',
'c = s / 16.0;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Vignette: {
type: 'Vignette', title: 'Vignette',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 1, step: 0.01, freeFloating: true }],
defaults: { amt: 0.55 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 d = v_uv - 0.5;',
'float vig = smoothstep(0.8, 0.25, length(d) * 1.4);',
'c.rgb *= mix(1.0, vig, u_amt);',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
}
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`
const PASSTHROUGH = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
void main() { gl_FragColor = texture2D(u_tex, v_uv); }`
function fragFor(filter: Filter): string {
return `precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
${filter.uniforms}
void main() {
vec4 c = texture2D(u_tex, v_uv);
${filter.body}
gl_FragColor = c;
}`
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const sh = gl.createShader(type)!
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader compile failed: ' + gl.getShaderInfoLog(sh))
}
return sh
}
/** A reusable WebGL image-filter runner. Create once, call `run` per pipeline evaluation. */
export class ImagePipelineRunner {
readonly #canvas: HTMLCanvasElement
readonly #gl: WebGLRenderingContext
readonly #programs = new Map<string, WebGLProgram>() // fragment src → program
readonly #vert: WebGLShader
#w = 0
#h = 0
#fbo: WebGLFramebuffer[] = []
#tex: WebGLTexture[] = []
constructor() {
this.#canvas = document.createElement('canvas')
const gl = this.#canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
if (!gl) throw new Error('WebGL unavailable')
this.#gl = gl
this.#vert = compile(gl, gl.VERTEX_SHADER, VERT)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
}
#program(fragSrc: string): WebGLProgram {
const cached = this.#programs.get(fragSrc)
if (cached) return cached
const gl = this.#gl
const prog = gl.createProgram()!
gl.attachShader(prog, this.#vert)
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fragSrc))
gl.bindAttribLocation(prog, 0, 'a_pos')
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('program link failed: ' + gl.getProgramInfoLog(prog))
}
this.#programs.set(fragSrc, prog)
return prog
}
#resize(w: number, h: number): void {
if (w === this.#w && h === this.#h && this.#tex.length) return
const gl = this.#gl
this.#canvas.width = w
this.#canvas.height = h
for (const t of this.#tex) gl.deleteTexture(t)
for (const f of this.#fbo) gl.deleteFramebuffer(f)
this.#tex = []
this.#fbo = []
for (let i = 0; i < 2; i++) {
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
this.#tex.push(tex)
this.#fbo.push(fbo)
}
this.#w = w
this.#h = h
}
/** Run the chain of filters over `image` and return the result as a PNG data URL. */
run(image: HTMLImageElement, passes: { filter: Filter; state: Record<string, unknown> }[]): string {
const gl = this.#gl
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
this.#resize(w, h)
// Upload the source flipped so it renders upright through the framebuffer chain to the canvas.
const srcTex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, srcTex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.viewport(0, 0, w, h)
const list = passes.length ? passes : [{ filter: null as Filter | null, state: {} }]
let input = srcTex
for (let i = 0; i < list.length; i++) {
const last = i === list.length - 1
const entry = list[i]!
const prog = this.#program(entry.filter ? fragFor(entry.filter) : PASSTHROUGH)
gl.useProgram(prog)
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.#fbo[i % 2]!)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, input)
const texLoc = gl.getUniformLocation(prog, 'u_tex')
if (texLoc) gl.uniform1i(texLoc, 0)
const texelLoc = gl.getUniformLocation(prog, 'u_texel')
if (texelLoc) gl.uniform2f(texelLoc, 1 / w, 1 / h)
if (entry.filter) entry.filter.setUniforms(gl, prog, entry.state)
gl.drawArrays(gl.TRIANGLES, 0, 3)
input = this.#tex[i % 2]!
}
gl.deleteTexture(srcTex)
return this.#canvas.toDataURL('image/png')
}
}
/** Procedurally drawn default source image (no bundled asset) — a colorful test card. */
export function defaultSourceImage(): string {
const w = 480, h = 320
const cv = document.createElement('canvas')
cv.width = w; cv.height = h
const ctx = cv.getContext('2d')!
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#1b2a4a'); g.addColorStop(0.5, '#7a3b9e'); g.addColorStop(1, '#e8a04b')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
const discs: [number, number, number, string][] = [
[120, 110, 70, 'rgba(255, 90, 120, 0.85)'],
[330, 90, 55, 'rgba(80, 220, 200, 0.85)'],
[250, 220, 85, 'rgba(255, 215, 90, 0.8)'],
[400, 250, 45, 'rgba(120, 160, 255, 0.85)'],
]
for (const [x, y, r, fill] of discs) {
ctx.fillStyle = fill
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
}
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 3
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo((w / 6) * i, 0); ctx.lineTo((w / 6) * i, h); ctx.stroke() }
return cv.toDataURL('image/png')
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// ─── Framework-agnostic showcase wiring ─────────────────────────────────────────────────────────
// Reused as-is by every framework demo. The only framework-supplied pieces are the two preview
// widget controllers (Source/Result); the schemas, layout, live re-processing and download are shared.
import type { XenolithEditor, NodeSchema, Node } from '@xenolithengine/graph-editor'
import type { CustomWidgetController } from '@xenolithengine/graph-render-pixi'
import { reachableFrom } from '@xenolithengine/graph-core'
export const OUT_RESULT = 'result'
/** Follow the linear chain from the source node and collect the filter nodes (excluding output). */
function chainFilters(editor: XenolithEditor, srcId: string, outId: string): Node[] {
const edges = [...editor.graphEdges()]
const out: Node[] = []
const seen = new Set<string>()
let cur = srcId
for (;;) {
const edge = edges.find((e) => e.from.node === cur)
if (!edge || edge.to.node === outId || seen.has(edge.to.node)) break
seen.add(edge.to.node)
const n = editor.getNode(edge.to.node)
if (!n) break
out.push(n as Node)
cur = edge.to.node
}
return out
}
export interface ImagePipelineWidgets {
input: CustomWidgetController
output: CustomWidgetController
}
export interface ImagePipelineHandle {
outputId: string
process(): Promise<void>
download(): void
}
/** Register schemas, build the Source → filters → Result chain, wire live re-processing on every
* widget change, and return a handle the UI layer drives. Framework-agnostic. */
export function buildImagePipeline(editor: XenolithEditor, widgets: ImagePipelineWidgets): ImagePipelineHandle {
editor.registerWidget('img-input', widgets.input)
editor.registerWidget('img-output', widgets.output)
editor.registry.register({
type: 'Source', title: 'Source image',
pins: [{ kind: 'data', direction: 'out', type: 'image', label: 'Out' }],
widgets: [{ id: 'src', label: '', type: 'custom', renderer: 'img-input', key: 'src', height: 170 }],
} satisfies NodeSchema)
editor.registry.register({
type: 'Output', title: 'Result',
pins: [{ kind: 'data', direction: 'in', type: 'image', label: 'In' }],
widgets: [{ id: OUT_RESULT, label: '', type: 'custom', renderer: 'img-output', key: OUT_RESULT, height: 170 }],
} satisfies NodeSchema)
const filters = Object.values(FILTERS)
for (const f of filters) {
editor.registry.register({
type: f.type, title: f.title,
pins: [
{ kind: 'data', direction: 'in', type: 'image', label: 'In' },
{ kind: 'data', direction: 'out', type: 'image', label: 'Out' },
],
widgets: f.widgets,
})
}
const source = editor.registry.instantiate('Source', { x: 0, y: 40 })
source.size = { x: 300, y: 230 }
source.state['src'] = defaultSourceImage()
editor.addNode(source)
let prev = source
let prevOut = 0
filters.forEach((f, i) => {
const node = editor.registry.instantiate(f.type, { x: 360 + i * 200, y: 110 })
for (const [k, v] of Object.entries(f.defaults)) node.state[k] = v
editor.addNode(node)
editor.connect(prev, prevOut, node, 0)
prev = node
prevOut = 1
})
const output = editor.registry.instantiate('Output', { x: 360 + filters.length * 200 + 40, y: 40 })
output.size = { x: 300, y: 230 }
output.state[OUT_RESULT] = ''
editor.addNode(output)
editor.connect(prev, prevOut, output, 0)
const runner = new ImagePipelineRunner()
let busy = false
const process = async (): Promise<void> => {
if (busy) return
// If the Output is no longer wired up from the Source (an edge was cut), there is no result to
// show — clear it. Otherwise the Result node would keep displaying a stale image that can't exist.
if (!reachableFrom(editor.readGraph(), source.id).has(output.id)) {
editor.setWidgetValue(output.id, OUT_RESULT, '')
return
}
const src = editor.getNode(source.id)?.state['src']
if (typeof src !== 'string' || !src) return
busy = true
try {
const img = await loadImage(src)
const passes = chainFilters(editor, String(source.id), String(output.id)).map((n) => ({ filter: FILTERS[n.type]!, state: n.state }))
const url = runner.run(img, passes)
editor.setWidgetValue(output.id, OUT_RESULT, url)
} catch { /* decode/GL failure — keep the previous result */ } finally { busy = false }
}
// Re-process on any change that affects the result: a widget edit, OR the graph topology changing
// (connect/disconnect/remove) — rewiring the chain (e.g. bypassing a filter) must update the output
// immediately, not only after the next slider move.
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (): void => { clearTimeout(timer); timer = setTimeout(() => { void process() }, 140) }
editor.on('widget:changed', (e) => { if (e.widgetId !== OUT_RESULT) schedule() })
editor.on('edge:connected', schedule)
editor.on('edge:disconnected', schedule)
editor.on('node:removed', schedule)
editor.on('node:added', schedule) // undo of a delete re-adds the node + its edges → re-process
editor.fitView({ padding: 56, maxZoom: 1 })
void process()
const download = (): void => {
const url = editor.getNode(output.id)?.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}
return { outputId: String(output.id), process, download }
}
/** Trigger a PNG download of the current Result node's image. Standalone helper so a host can
* wire a Download button via just `useEditor()` — no need to thread a build-handle around. */
export function downloadImageResult(editor: XenolithEditor): void {
const output = [...editor.graphNodes()].find((n) => n.type === 'Output')
if (!output) return
const url = output.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
} <script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, XenolithControls, svelteWidget } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildImagePipeline, downloadImageResult } from '@xenolithengine/demo/image-pipeline'
import ImageInput from './ImageInput.svelte'
import ImageOutput from './ImageOutput.svelte'
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
const input = svelteWidget(ImageInput)
const output = svelteWidget(ImageOutput)
function ready(e: { detail: XenolithEditor }): void {
editor = e.detail
panelEditor.set(e.detail)
buildImagePipeline(e.detail, { input, output })
}
</script>
<div use:xenolith={{ resizeToWindow: false }} onready={ready} style="position:absolute;inset:0"></div>
<XenolithControls position="bottom-left" />
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;width:200px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Image pipeline</p>
<XenolithButton style="width:100%" onclick={() => editor && downloadImageResult(editor)}>↓ Download result.png</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.45;">
Each node is a live GLSL pass. Drag a slider — the result re-renders. Drop your own image on the Source node.
</span>
</div>
</XenolithPanel> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue }: WidgetProps = $props()
function onFile(file?: File): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => setValue(String(reader.result))
reader.readAsDataURL(file)
}
</script>
<div
style="position:relative;width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => { e.preventDefault(); onFile(e.dataTransfer?.files?.[0]) }}
>
{#if value}
<img src={String(value)} alt="source" style="width:100%;height:100%;object-fit:contain;display:block;" />
{:else}
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--xeno-muted);font-size:12px;">Drop an image</div>
{/if}
<label style="position:absolute;bottom:6px;right:6px;font-size:10px;padding:3px 8px;border-radius:6px;background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;border:1px solid var(--xeno-border);">
Replace
<input type="file" accept="image/*" hidden onchange={(e) => onFile((e.target as HTMLInputElement).files?.[0])} />
</label>
</div> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value }: WidgetProps = $props()
</script>
<div style="width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);display:flex;align-items:center;justify-content:center;">
{#if value}
<img src={String(value)} alt="result" style="width:100%;height:100%;object-fit:contain;" />
{:else}
<span style="color:var(--xeno-muted);font-size:12px;">Rendering…</span>
{/if}
</div> // A tiny real WebGL image pipeline used by ImagePipelineDemo. Each filter node is one GLSL
// fragment pass; the runner chains them with ping-pong framebuffers (source → pass → pass → … →
// canvas) and hands back a PNG data URL. GLSL ES 1.00, single fullscreen triangle, no deps.
export interface Filter {
type: string
title: string
/** Slider widgets shown on the node; values live in node.state keyed by `key`. */
widgets: { id: string; label: string; type: 'slider'; key: string; min: number; max: number; step: number; freeFloating?: boolean }[]
/** Default state for a freshly-instantiated node. */
defaults: Record<string, number>
/** GLSL declarations (uniforms) injected before main(). */
uniforms: string
/** GLSL body that mutates `vec4 c` (the current pixel, pre-sampled from u_tex at v_uv). */
body: string
/** Push this node's state into the program's uniforms for one pass. */
setUniforms(gl: WebGLRenderingContext, prog: WebGLProgram, state: Record<string, unknown>): void
}
const num = (s: Record<string, unknown>, k: string, d: number): number =>
typeof s[k] === 'number' ? (s[k] as number) : d
function f1(gl: WebGLRenderingContext, prog: WebGLProgram, name: string, v: number): void {
const loc = gl.getUniformLocation(prog, name)
if (loc) gl.uniform1f(loc, v)
}
export const FILTERS: Record<string, Filter> = {
Exposure: {
type: 'Exposure', title: 'Exposure',
widgets: [{ id: 'amt', label: 'EV', type: 'slider', key: 'amt', min: -2, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 0.25 },
uniforms: 'uniform float u_amt;',
body: 'c.rgb *= exp2(u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Saturation: {
type: 'Saturation', title: 'Saturation',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 1.45 },
uniforms: 'uniform float u_amt;',
body: 'float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 1)),
},
Hue: {
type: 'Hue', title: 'Hue rotate',
widgets: [{ id: 'amt', label: 'Degrees', type: 'slider', key: 'amt', min: -180, max: 180, step: 1, freeFloating: true }],
defaults: { amt: 25 },
uniforms: 'uniform float u_amt;',
body: [
'const mat3 toYIQ = mat3(0.299, 0.596, 0.211, 0.587, -0.274, -0.523, 0.114, -0.322, 0.312);',
'const mat3 toRGB = mat3(1.0, 1.0, 1.0, 0.956, -0.272, -1.106, 0.621, -0.647, 1.703);',
'vec3 yiq = toYIQ * c.rgb;',
'float hue = atan(yiq.z, yiq.y) + u_amt;',
'float chroma = length(yiq.yz);',
'yiq.y = chroma * cos(hue); yiq.z = chroma * sin(hue);',
'c.rgb = toRGB * yiq;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', (num(s, 'amt', 0) * Math.PI) / 180),
},
Blur: {
type: 'Blur', title: 'Gaussian blur',
widgets: [{ id: 'amt', label: 'Radius', type: 'slider', key: 'amt', min: 0, max: 5, step: 0.1, freeFloating: true }],
defaults: { amt: 1.2 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 o = u_texel * u_amt;',
'vec4 s = texture2D(u_tex, v_uv) * 4.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(-o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, -o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2( o.x, -o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, -o.y));',
'c = s / 16.0;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Vignette: {
type: 'Vignette', title: 'Vignette',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 1, step: 0.01, freeFloating: true }],
defaults: { amt: 0.55 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 d = v_uv - 0.5;',
'float vig = smoothstep(0.8, 0.25, length(d) * 1.4);',
'c.rgb *= mix(1.0, vig, u_amt);',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
}
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`
const PASSTHROUGH = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
void main() { gl_FragColor = texture2D(u_tex, v_uv); }`
function fragFor(filter: Filter): string {
return `precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
${filter.uniforms}
void main() {
vec4 c = texture2D(u_tex, v_uv);
${filter.body}
gl_FragColor = c;
}`
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const sh = gl.createShader(type)!
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader compile failed: ' + gl.getShaderInfoLog(sh))
}
return sh
}
/** A reusable WebGL image-filter runner. Create once, call `run` per pipeline evaluation. */
export class ImagePipelineRunner {
readonly #canvas: HTMLCanvasElement
readonly #gl: WebGLRenderingContext
readonly #programs = new Map<string, WebGLProgram>() // fragment src → program
readonly #vert: WebGLShader
#w = 0
#h = 0
#fbo: WebGLFramebuffer[] = []
#tex: WebGLTexture[] = []
constructor() {
this.#canvas = document.createElement('canvas')
const gl = this.#canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
if (!gl) throw new Error('WebGL unavailable')
this.#gl = gl
this.#vert = compile(gl, gl.VERTEX_SHADER, VERT)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
}
#program(fragSrc: string): WebGLProgram {
const cached = this.#programs.get(fragSrc)
if (cached) return cached
const gl = this.#gl
const prog = gl.createProgram()!
gl.attachShader(prog, this.#vert)
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fragSrc))
gl.bindAttribLocation(prog, 0, 'a_pos')
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('program link failed: ' + gl.getProgramInfoLog(prog))
}
this.#programs.set(fragSrc, prog)
return prog
}
#resize(w: number, h: number): void {
if (w === this.#w && h === this.#h && this.#tex.length) return
const gl = this.#gl
this.#canvas.width = w
this.#canvas.height = h
for (const t of this.#tex) gl.deleteTexture(t)
for (const f of this.#fbo) gl.deleteFramebuffer(f)
this.#tex = []
this.#fbo = []
for (let i = 0; i < 2; i++) {
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
this.#tex.push(tex)
this.#fbo.push(fbo)
}
this.#w = w
this.#h = h
}
/** Run the chain of filters over `image` and return the result as a PNG data URL. */
run(image: HTMLImageElement, passes: { filter: Filter; state: Record<string, unknown> }[]): string {
const gl = this.#gl
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
this.#resize(w, h)
// Upload the source flipped so it renders upright through the framebuffer chain to the canvas.
const srcTex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, srcTex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.viewport(0, 0, w, h)
const list = passes.length ? passes : [{ filter: null as Filter | null, state: {} }]
let input = srcTex
for (let i = 0; i < list.length; i++) {
const last = i === list.length - 1
const entry = list[i]!
const prog = this.#program(entry.filter ? fragFor(entry.filter) : PASSTHROUGH)
gl.useProgram(prog)
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.#fbo[i % 2]!)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, input)
const texLoc = gl.getUniformLocation(prog, 'u_tex')
if (texLoc) gl.uniform1i(texLoc, 0)
const texelLoc = gl.getUniformLocation(prog, 'u_texel')
if (texelLoc) gl.uniform2f(texelLoc, 1 / w, 1 / h)
if (entry.filter) entry.filter.setUniforms(gl, prog, entry.state)
gl.drawArrays(gl.TRIANGLES, 0, 3)
input = this.#tex[i % 2]!
}
gl.deleteTexture(srcTex)
return this.#canvas.toDataURL('image/png')
}
}
/** Procedurally drawn default source image (no bundled asset) — a colorful test card. */
export function defaultSourceImage(): string {
const w = 480, h = 320
const cv = document.createElement('canvas')
cv.width = w; cv.height = h
const ctx = cv.getContext('2d')!
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#1b2a4a'); g.addColorStop(0.5, '#7a3b9e'); g.addColorStop(1, '#e8a04b')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
const discs: [number, number, number, string][] = [
[120, 110, 70, 'rgba(255, 90, 120, 0.85)'],
[330, 90, 55, 'rgba(80, 220, 200, 0.85)'],
[250, 220, 85, 'rgba(255, 215, 90, 0.8)'],
[400, 250, 45, 'rgba(120, 160, 255, 0.85)'],
]
for (const [x, y, r, fill] of discs) {
ctx.fillStyle = fill
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
}
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 3
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo((w / 6) * i, 0); ctx.lineTo((w / 6) * i, h); ctx.stroke() }
return cv.toDataURL('image/png')
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// ─── Framework-agnostic showcase wiring ─────────────────────────────────────────────────────────
// Reused as-is by every framework demo. The only framework-supplied pieces are the two preview
// widget controllers (Source/Result); the schemas, layout, live re-processing and download are shared.
import type { XenolithEditor, NodeSchema, Node } from '@xenolithengine/graph-editor'
import type { CustomWidgetController } from '@xenolithengine/graph-render-pixi'
import { reachableFrom } from '@xenolithengine/graph-core'
export const OUT_RESULT = 'result'
/** Follow the linear chain from the source node and collect the filter nodes (excluding output). */
function chainFilters(editor: XenolithEditor, srcId: string, outId: string): Node[] {
const edges = [...editor.graphEdges()]
const out: Node[] = []
const seen = new Set<string>()
let cur = srcId
for (;;) {
const edge = edges.find((e) => e.from.node === cur)
if (!edge || edge.to.node === outId || seen.has(edge.to.node)) break
seen.add(edge.to.node)
const n = editor.getNode(edge.to.node)
if (!n) break
out.push(n as Node)
cur = edge.to.node
}
return out
}
export interface ImagePipelineWidgets {
input: CustomWidgetController
output: CustomWidgetController
}
export interface ImagePipelineHandle {
outputId: string
process(): Promise<void>
download(): void
}
/** Register schemas, build the Source → filters → Result chain, wire live re-processing on every
* widget change, and return a handle the UI layer drives. Framework-agnostic. */
export function buildImagePipeline(editor: XenolithEditor, widgets: ImagePipelineWidgets): ImagePipelineHandle {
editor.registerWidget('img-input', widgets.input)
editor.registerWidget('img-output', widgets.output)
editor.registry.register({
type: 'Source', title: 'Source image',
pins: [{ kind: 'data', direction: 'out', type: 'image', label: 'Out' }],
widgets: [{ id: 'src', label: '', type: 'custom', renderer: 'img-input', key: 'src', height: 170 }],
} satisfies NodeSchema)
editor.registry.register({
type: 'Output', title: 'Result',
pins: [{ kind: 'data', direction: 'in', type: 'image', label: 'In' }],
widgets: [{ id: OUT_RESULT, label: '', type: 'custom', renderer: 'img-output', key: OUT_RESULT, height: 170 }],
} satisfies NodeSchema)
const filters = Object.values(FILTERS)
for (const f of filters) {
editor.registry.register({
type: f.type, title: f.title,
pins: [
{ kind: 'data', direction: 'in', type: 'image', label: 'In' },
{ kind: 'data', direction: 'out', type: 'image', label: 'Out' },
],
widgets: f.widgets,
})
}
const source = editor.registry.instantiate('Source', { x: 0, y: 40 })
source.size = { x: 300, y: 230 }
source.state['src'] = defaultSourceImage()
editor.addNode(source)
let prev = source
let prevOut = 0
filters.forEach((f, i) => {
const node = editor.registry.instantiate(f.type, { x: 360 + i * 200, y: 110 })
for (const [k, v] of Object.entries(f.defaults)) node.state[k] = v
editor.addNode(node)
editor.connect(prev, prevOut, node, 0)
prev = node
prevOut = 1
})
const output = editor.registry.instantiate('Output', { x: 360 + filters.length * 200 + 40, y: 40 })
output.size = { x: 300, y: 230 }
output.state[OUT_RESULT] = ''
editor.addNode(output)
editor.connect(prev, prevOut, output, 0)
const runner = new ImagePipelineRunner()
let busy = false
const process = async (): Promise<void> => {
if (busy) return
// If the Output is no longer wired up from the Source (an edge was cut), there is no result to
// show — clear it. Otherwise the Result node would keep displaying a stale image that can't exist.
if (!reachableFrom(editor.readGraph(), source.id).has(output.id)) {
editor.setWidgetValue(output.id, OUT_RESULT, '')
return
}
const src = editor.getNode(source.id)?.state['src']
if (typeof src !== 'string' || !src) return
busy = true
try {
const img = await loadImage(src)
const passes = chainFilters(editor, String(source.id), String(output.id)).map((n) => ({ filter: FILTERS[n.type]!, state: n.state }))
const url = runner.run(img, passes)
editor.setWidgetValue(output.id, OUT_RESULT, url)
} catch { /* decode/GL failure — keep the previous result */ } finally { busy = false }
}
// Re-process on any change that affects the result: a widget edit, OR the graph topology changing
// (connect/disconnect/remove) — rewiring the chain (e.g. bypassing a filter) must update the output
// immediately, not only after the next slider move.
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (): void => { clearTimeout(timer); timer = setTimeout(() => { void process() }, 140) }
editor.on('widget:changed', (e) => { if (e.widgetId !== OUT_RESULT) schedule() })
editor.on('edge:connected', schedule)
editor.on('edge:disconnected', schedule)
editor.on('node:removed', schedule)
editor.on('node:added', schedule) // undo of a delete re-adds the node + its edges → re-process
editor.fitView({ padding: 56, maxZoom: 1 })
void process()
const download = (): void => {
const url = editor.getNode(output.id)?.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}
return { outputId: String(output.id), process, download }
}
/** Trigger a PNG download of the current Result node's image. Standalone helper so a host can
* wire a Download button via just `useEditor()` — no need to thread a build-handle around. */
export function downloadImageResult(editor: XenolithEditor): void {
const output = [...editor.graphNodes()].find((n) => n.type === 'Output')
if (!output) return
const url = output.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
} // Solid has no widget bridge. One DomWidgetController per renderer — these nodes use each renderer once.
import { xenolith } from '@xenolithengine/graph-solid'
import type { DomWidgetController, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildImagePipeline, downloadImageResult } from '@xenolithengine/demo/image-pipeline'
function readFile(file: File | undefined, setValue: (v: unknown) => void): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => setValue(String(reader.result))
reader.readAsDataURL(file)
}
function imageInput(): DomWidgetController {
let img: HTMLImageElement | null = null
let empty: HTMLDivElement | null = null
const paint = (value: unknown): void => {
const src = typeof value === 'string' && value ? value : ''
if (img) { img.src = src; img.style.display = src ? 'block' : 'none' }
if (empty) empty.style.display = src ? 'none' : 'flex'
}
return {
mount(el, c) {
el.style.cssText = 'position:relative;width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);'
img = document.createElement('img')
img.alt = 'source'
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:none;'
empty = document.createElement('div')
empty.textContent = 'Drop an image'
empty.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--xeno-muted);font-size:12px;'
const label = document.createElement('label')
label.textContent = 'Replace'
label.style.cssText = 'position:absolute;bottom:6px;right:6px;font-size:10px;padding:3px 8px;border-radius:6px;background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;border:1px solid var(--xeno-border);'
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.hidden = true
input.addEventListener('change', () => readFile(input.files?.[0], c.setValue))
label.appendChild(input)
el.addEventListener('dragover', (e) => e.preventDefault())
el.addEventListener('drop', (e) => { e.preventDefault(); readFile(e.dataTransfer?.files?.[0], c.setValue) })
el.append(img, empty, label)
paint(c.value)
return () => { img = null; empty = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
function imageOutput(): DomWidgetController {
let img: HTMLImageElement | null = null
let empty: HTMLSpanElement | null = null
const paint = (value: unknown): void => {
const src = typeof value === 'string' && value ? value : ''
if (img) { img.src = src; img.style.display = src ? 'block' : 'none' }
if (empty) empty.style.display = src ? 'none' : 'inline'
}
return {
mount(el, c) {
el.style.cssText = 'width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);display:flex;align-items:center;justify-content:center;'
img = document.createElement('img')
img.alt = 'result'
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:none;'
empty = document.createElement('span')
empty.textContent = 'Rendering…'
empty.style.cssText = 'color:var(--xeno-muted);font-size:12px;'
el.append(img, empty)
paint(c.value)
return () => { img = null; empty = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
export function ImagePipelineDemo() {
let editor: XenolithEditor | null = null
const btn = 'font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:pointer;width:100%;border:1px solid var(--xeno-border);background:var(--xeno-elevated);color:var(--xeno-text);'
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
editor = e.detail
e.detail.chrome.setControls({ position: 'bottom-left' })
buildImagePipeline(e.detail, { input: imageInput(), output: imageOutput() })
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:6px;width:200px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Image pipeline</p>
<button type="button" style={btn} onClick={() => editor && downloadImageResult(editor)}>↓ Download result.png</button>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.45;">
Each node is a live GLSL pass. Drag a slider — the result re-renders. Drop your own image on the Source node.
</span>
</div>
</div>
)
} // A tiny real WebGL image pipeline used by ImagePipelineDemo. Each filter node is one GLSL
// fragment pass; the runner chains them with ping-pong framebuffers (source → pass → pass → … →
// canvas) and hands back a PNG data URL. GLSL ES 1.00, single fullscreen triangle, no deps.
export interface Filter {
type: string
title: string
/** Slider widgets shown on the node; values live in node.state keyed by `key`. */
widgets: { id: string; label: string; type: 'slider'; key: string; min: number; max: number; step: number; freeFloating?: boolean }[]
/** Default state for a freshly-instantiated node. */
defaults: Record<string, number>
/** GLSL declarations (uniforms) injected before main(). */
uniforms: string
/** GLSL body that mutates `vec4 c` (the current pixel, pre-sampled from u_tex at v_uv). */
body: string
/** Push this node's state into the program's uniforms for one pass. */
setUniforms(gl: WebGLRenderingContext, prog: WebGLProgram, state: Record<string, unknown>): void
}
const num = (s: Record<string, unknown>, k: string, d: number): number =>
typeof s[k] === 'number' ? (s[k] as number) : d
function f1(gl: WebGLRenderingContext, prog: WebGLProgram, name: string, v: number): void {
const loc = gl.getUniformLocation(prog, name)
if (loc) gl.uniform1f(loc, v)
}
export const FILTERS: Record<string, Filter> = {
Exposure: {
type: 'Exposure', title: 'Exposure',
widgets: [{ id: 'amt', label: 'EV', type: 'slider', key: 'amt', min: -2, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 0.25 },
uniforms: 'uniform float u_amt;',
body: 'c.rgb *= exp2(u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Saturation: {
type: 'Saturation', title: 'Saturation',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 1.45 },
uniforms: 'uniform float u_amt;',
body: 'float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 1)),
},
Hue: {
type: 'Hue', title: 'Hue rotate',
widgets: [{ id: 'amt', label: 'Degrees', type: 'slider', key: 'amt', min: -180, max: 180, step: 1, freeFloating: true }],
defaults: { amt: 25 },
uniforms: 'uniform float u_amt;',
body: [
'const mat3 toYIQ = mat3(0.299, 0.596, 0.211, 0.587, -0.274, -0.523, 0.114, -0.322, 0.312);',
'const mat3 toRGB = mat3(1.0, 1.0, 1.0, 0.956, -0.272, -1.106, 0.621, -0.647, 1.703);',
'vec3 yiq = toYIQ * c.rgb;',
'float hue = atan(yiq.z, yiq.y) + u_amt;',
'float chroma = length(yiq.yz);',
'yiq.y = chroma * cos(hue); yiq.z = chroma * sin(hue);',
'c.rgb = toRGB * yiq;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', (num(s, 'amt', 0) * Math.PI) / 180),
},
Blur: {
type: 'Blur', title: 'Gaussian blur',
widgets: [{ id: 'amt', label: 'Radius', type: 'slider', key: 'amt', min: 0, max: 5, step: 0.1, freeFloating: true }],
defaults: { amt: 1.2 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 o = u_texel * u_amt;',
'vec4 s = texture2D(u_tex, v_uv) * 4.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(-o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, -o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2( o.x, -o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, -o.y));',
'c = s / 16.0;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Vignette: {
type: 'Vignette', title: 'Vignette',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 1, step: 0.01, freeFloating: true }],
defaults: { amt: 0.55 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 d = v_uv - 0.5;',
'float vig = smoothstep(0.8, 0.25, length(d) * 1.4);',
'c.rgb *= mix(1.0, vig, u_amt);',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
}
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`
const PASSTHROUGH = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
void main() { gl_FragColor = texture2D(u_tex, v_uv); }`
function fragFor(filter: Filter): string {
return `precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
${filter.uniforms}
void main() {
vec4 c = texture2D(u_tex, v_uv);
${filter.body}
gl_FragColor = c;
}`
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const sh = gl.createShader(type)!
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader compile failed: ' + gl.getShaderInfoLog(sh))
}
return sh
}
/** A reusable WebGL image-filter runner. Create once, call `run` per pipeline evaluation. */
export class ImagePipelineRunner {
readonly #canvas: HTMLCanvasElement
readonly #gl: WebGLRenderingContext
readonly #programs = new Map<string, WebGLProgram>() // fragment src → program
readonly #vert: WebGLShader
#w = 0
#h = 0
#fbo: WebGLFramebuffer[] = []
#tex: WebGLTexture[] = []
constructor() {
this.#canvas = document.createElement('canvas')
const gl = this.#canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
if (!gl) throw new Error('WebGL unavailable')
this.#gl = gl
this.#vert = compile(gl, gl.VERTEX_SHADER, VERT)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
}
#program(fragSrc: string): WebGLProgram {
const cached = this.#programs.get(fragSrc)
if (cached) return cached
const gl = this.#gl
const prog = gl.createProgram()!
gl.attachShader(prog, this.#vert)
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fragSrc))
gl.bindAttribLocation(prog, 0, 'a_pos')
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('program link failed: ' + gl.getProgramInfoLog(prog))
}
this.#programs.set(fragSrc, prog)
return prog
}
#resize(w: number, h: number): void {
if (w === this.#w && h === this.#h && this.#tex.length) return
const gl = this.#gl
this.#canvas.width = w
this.#canvas.height = h
for (const t of this.#tex) gl.deleteTexture(t)
for (const f of this.#fbo) gl.deleteFramebuffer(f)
this.#tex = []
this.#fbo = []
for (let i = 0; i < 2; i++) {
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
this.#tex.push(tex)
this.#fbo.push(fbo)
}
this.#w = w
this.#h = h
}
/** Run the chain of filters over `image` and return the result as a PNG data URL. */
run(image: HTMLImageElement, passes: { filter: Filter; state: Record<string, unknown> }[]): string {
const gl = this.#gl
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
this.#resize(w, h)
// Upload the source flipped so it renders upright through the framebuffer chain to the canvas.
const srcTex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, srcTex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.viewport(0, 0, w, h)
const list = passes.length ? passes : [{ filter: null as Filter | null, state: {} }]
let input = srcTex
for (let i = 0; i < list.length; i++) {
const last = i === list.length - 1
const entry = list[i]!
const prog = this.#program(entry.filter ? fragFor(entry.filter) : PASSTHROUGH)
gl.useProgram(prog)
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.#fbo[i % 2]!)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, input)
const texLoc = gl.getUniformLocation(prog, 'u_tex')
if (texLoc) gl.uniform1i(texLoc, 0)
const texelLoc = gl.getUniformLocation(prog, 'u_texel')
if (texelLoc) gl.uniform2f(texelLoc, 1 / w, 1 / h)
if (entry.filter) entry.filter.setUniforms(gl, prog, entry.state)
gl.drawArrays(gl.TRIANGLES, 0, 3)
input = this.#tex[i % 2]!
}
gl.deleteTexture(srcTex)
return this.#canvas.toDataURL('image/png')
}
}
/** Procedurally drawn default source image (no bundled asset) — a colorful test card. */
export function defaultSourceImage(): string {
const w = 480, h = 320
const cv = document.createElement('canvas')
cv.width = w; cv.height = h
const ctx = cv.getContext('2d')!
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#1b2a4a'); g.addColorStop(0.5, '#7a3b9e'); g.addColorStop(1, '#e8a04b')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
const discs: [number, number, number, string][] = [
[120, 110, 70, 'rgba(255, 90, 120, 0.85)'],
[330, 90, 55, 'rgba(80, 220, 200, 0.85)'],
[250, 220, 85, 'rgba(255, 215, 90, 0.8)'],
[400, 250, 45, 'rgba(120, 160, 255, 0.85)'],
]
for (const [x, y, r, fill] of discs) {
ctx.fillStyle = fill
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
}
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 3
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo((w / 6) * i, 0); ctx.lineTo((w / 6) * i, h); ctx.stroke() }
return cv.toDataURL('image/png')
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// ─── Framework-agnostic showcase wiring ─────────────────────────────────────────────────────────
// Reused as-is by every framework demo. The only framework-supplied pieces are the two preview
// widget controllers (Source/Result); the schemas, layout, live re-processing and download are shared.
import type { XenolithEditor, NodeSchema, Node } from '@xenolithengine/graph-editor'
import type { CustomWidgetController } from '@xenolithengine/graph-render-pixi'
import { reachableFrom } from '@xenolithengine/graph-core'
export const OUT_RESULT = 'result'
/** Follow the linear chain from the source node and collect the filter nodes (excluding output). */
function chainFilters(editor: XenolithEditor, srcId: string, outId: string): Node[] {
const edges = [...editor.graphEdges()]
const out: Node[] = []
const seen = new Set<string>()
let cur = srcId
for (;;) {
const edge = edges.find((e) => e.from.node === cur)
if (!edge || edge.to.node === outId || seen.has(edge.to.node)) break
seen.add(edge.to.node)
const n = editor.getNode(edge.to.node)
if (!n) break
out.push(n as Node)
cur = edge.to.node
}
return out
}
export interface ImagePipelineWidgets {
input: CustomWidgetController
output: CustomWidgetController
}
export interface ImagePipelineHandle {
outputId: string
process(): Promise<void>
download(): void
}
/** Register schemas, build the Source → filters → Result chain, wire live re-processing on every
* widget change, and return a handle the UI layer drives. Framework-agnostic. */
export function buildImagePipeline(editor: XenolithEditor, widgets: ImagePipelineWidgets): ImagePipelineHandle {
editor.registerWidget('img-input', widgets.input)
editor.registerWidget('img-output', widgets.output)
editor.registry.register({
type: 'Source', title: 'Source image',
pins: [{ kind: 'data', direction: 'out', type: 'image', label: 'Out' }],
widgets: [{ id: 'src', label: '', type: 'custom', renderer: 'img-input', key: 'src', height: 170 }],
} satisfies NodeSchema)
editor.registry.register({
type: 'Output', title: 'Result',
pins: [{ kind: 'data', direction: 'in', type: 'image', label: 'In' }],
widgets: [{ id: OUT_RESULT, label: '', type: 'custom', renderer: 'img-output', key: OUT_RESULT, height: 170 }],
} satisfies NodeSchema)
const filters = Object.values(FILTERS)
for (const f of filters) {
editor.registry.register({
type: f.type, title: f.title,
pins: [
{ kind: 'data', direction: 'in', type: 'image', label: 'In' },
{ kind: 'data', direction: 'out', type: 'image', label: 'Out' },
],
widgets: f.widgets,
})
}
const source = editor.registry.instantiate('Source', { x: 0, y: 40 })
source.size = { x: 300, y: 230 }
source.state['src'] = defaultSourceImage()
editor.addNode(source)
let prev = source
let prevOut = 0
filters.forEach((f, i) => {
const node = editor.registry.instantiate(f.type, { x: 360 + i * 200, y: 110 })
for (const [k, v] of Object.entries(f.defaults)) node.state[k] = v
editor.addNode(node)
editor.connect(prev, prevOut, node, 0)
prev = node
prevOut = 1
})
const output = editor.registry.instantiate('Output', { x: 360 + filters.length * 200 + 40, y: 40 })
output.size = { x: 300, y: 230 }
output.state[OUT_RESULT] = ''
editor.addNode(output)
editor.connect(prev, prevOut, output, 0)
const runner = new ImagePipelineRunner()
let busy = false
const process = async (): Promise<void> => {
if (busy) return
// If the Output is no longer wired up from the Source (an edge was cut), there is no result to
// show — clear it. Otherwise the Result node would keep displaying a stale image that can't exist.
if (!reachableFrom(editor.readGraph(), source.id).has(output.id)) {
editor.setWidgetValue(output.id, OUT_RESULT, '')
return
}
const src = editor.getNode(source.id)?.state['src']
if (typeof src !== 'string' || !src) return
busy = true
try {
const img = await loadImage(src)
const passes = chainFilters(editor, String(source.id), String(output.id)).map((n) => ({ filter: FILTERS[n.type]!, state: n.state }))
const url = runner.run(img, passes)
editor.setWidgetValue(output.id, OUT_RESULT, url)
} catch { /* decode/GL failure — keep the previous result */ } finally { busy = false }
}
// Re-process on any change that affects the result: a widget edit, OR the graph topology changing
// (connect/disconnect/remove) — rewiring the chain (e.g. bypassing a filter) must update the output
// immediately, not only after the next slider move.
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (): void => { clearTimeout(timer); timer = setTimeout(() => { void process() }, 140) }
editor.on('widget:changed', (e) => { if (e.widgetId !== OUT_RESULT) schedule() })
editor.on('edge:connected', schedule)
editor.on('edge:disconnected', schedule)
editor.on('node:removed', schedule)
editor.on('node:added', schedule) // undo of a delete re-adds the node + its edges → re-process
editor.fitView({ padding: 56, maxZoom: 1 })
void process()
const download = (): void => {
const url = editor.getNode(output.id)?.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}
return { outputId: String(output.id), process, download }
}
/** Trigger a PNG download of the current Result node's image. Standalone helper so a host can
* wire a Download button via just `useEditor()` — no need to thread a build-handle around. */
export function downloadImageResult(editor: XenolithEditor): void {
const output = [...editor.graphNodes()].find((n) => n.type === 'Output')
if (!output) return
const url = output.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
} // Angular has no widget bridge. One DomWidgetController per renderer — these nodes use each renderer once.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { DomWidgetController, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildImagePipeline, downloadImageResult } from '@xenolithengine/demo/image-pipeline'
function readFile(file: File | undefined, setValue: (v: unknown) => void): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => setValue(String(reader.result))
reader.readAsDataURL(file)
}
function imageInput(): DomWidgetController {
let img: HTMLImageElement | null = null
let empty: HTMLDivElement | null = null
const paint = (value: unknown): void => {
const src = typeof value === 'string' && value ? value : ''
if (img) { img.src = src; img.style.display = src ? 'block' : 'none' }
if (empty) empty.style.display = src ? 'none' : 'flex'
}
return {
mount(el, c) {
el.style.cssText = 'position:relative;width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);'
img = document.createElement('img')
img.alt = 'source'
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:none;'
empty = document.createElement('div')
empty.textContent = 'Drop an image'
empty.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--xeno-muted);font-size:12px;'
const label = document.createElement('label')
label.textContent = 'Replace'
label.style.cssText = 'position:absolute;bottom:6px;right:6px;font-size:10px;padding:3px 8px;border-radius:6px;background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;border:1px solid var(--xeno-border);'
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.hidden = true
input.addEventListener('change', () => readFile(input.files?.[0], c.setValue))
label.appendChild(input)
el.addEventListener('dragover', (e) => e.preventDefault())
el.addEventListener('drop', (e) => { e.preventDefault(); readFile(e.dataTransfer?.files?.[0], c.setValue) })
el.append(img, empty, label)
paint(c.value)
return () => { img = null; empty = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
function imageOutput(): DomWidgetController {
let img: HTMLImageElement | null = null
let empty: HTMLSpanElement | null = null
const paint = (value: unknown): void => {
const src = typeof value === 'string' && value ? value : ''
if (img) { img.src = src; img.style.display = src ? 'block' : 'none' }
if (empty) empty.style.display = src ? 'none' : 'inline'
}
return {
mount(el, c) {
el.style.cssText = 'width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px solid var(--xeno-border);display:flex;align-items:center;justify-content:center;'
img = document.createElement('img')
img.alt = 'result'
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:none;'
empty = document.createElement('span')
empty.textContent = 'Rendering…'
empty.style.cssText = 'color:var(--xeno-muted);font-size:12px;'
el.append(img, empty)
paint(c.value)
return () => { img = null; empty = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
@Component({
selector: 'image-pipeline-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<p class="label">Image pipeline</p>
<button type="button" class="btn" (click)="download()">↓ Download result.png</button>
<span class="note">Each node is a live GLSL pass. Drag a slider — the result re-renders. Drop your own image on the Source node.</span>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; display:flex; flex-direction:column;
gap:6px; width:200px; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px;
color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif; }
.label { margin:0; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--xeno-muted,#9a9a9a); }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626); color:var(--xeno-text,#cfcfcf); }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.45; }
`],
})
export class ImagePipelineDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private editor: XenolithEditor | null = null
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.editor = editor
editor.chrome.setControls({ position: 'bottom-left' })
buildImagePipeline(editor, { input: imageInput(), output: imageOutput() })
}
download(): void { if (this.editor) downloadImageResult(this.editor) }
ngOnDestroy(): void { this.graph.destroy() }
} // A tiny real WebGL image pipeline used by ImagePipelineDemo. Each filter node is one GLSL
// fragment pass; the runner chains them with ping-pong framebuffers (source → pass → pass → … →
// canvas) and hands back a PNG data URL. GLSL ES 1.00, single fullscreen triangle, no deps.
export interface Filter {
type: string
title: string
/** Slider widgets shown on the node; values live in node.state keyed by `key`. */
widgets: { id: string; label: string; type: 'slider'; key: string; min: number; max: number; step: number; freeFloating?: boolean }[]
/** Default state for a freshly-instantiated node. */
defaults: Record<string, number>
/** GLSL declarations (uniforms) injected before main(). */
uniforms: string
/** GLSL body that mutates `vec4 c` (the current pixel, pre-sampled from u_tex at v_uv). */
body: string
/** Push this node's state into the program's uniforms for one pass. */
setUniforms(gl: WebGLRenderingContext, prog: WebGLProgram, state: Record<string, unknown>): void
}
const num = (s: Record<string, unknown>, k: string, d: number): number =>
typeof s[k] === 'number' ? (s[k] as number) : d
function f1(gl: WebGLRenderingContext, prog: WebGLProgram, name: string, v: number): void {
const loc = gl.getUniformLocation(prog, name)
if (loc) gl.uniform1f(loc, v)
}
export const FILTERS: Record<string, Filter> = {
Exposure: {
type: 'Exposure', title: 'Exposure',
widgets: [{ id: 'amt', label: 'EV', type: 'slider', key: 'amt', min: -2, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 0.25 },
uniforms: 'uniform float u_amt;',
body: 'c.rgb *= exp2(u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Saturation: {
type: 'Saturation', title: 'Saturation',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 2, step: 0.01, freeFloating: true }],
defaults: { amt: 1.45 },
uniforms: 'uniform float u_amt;',
body: 'float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, u_amt);',
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 1)),
},
Hue: {
type: 'Hue', title: 'Hue rotate',
widgets: [{ id: 'amt', label: 'Degrees', type: 'slider', key: 'amt', min: -180, max: 180, step: 1, freeFloating: true }],
defaults: { amt: 25 },
uniforms: 'uniform float u_amt;',
body: [
'const mat3 toYIQ = mat3(0.299, 0.596, 0.211, 0.587, -0.274, -0.523, 0.114, -0.322, 0.312);',
'const mat3 toRGB = mat3(1.0, 1.0, 1.0, 0.956, -0.272, -1.106, 0.621, -0.647, 1.703);',
'vec3 yiq = toYIQ * c.rgb;',
'float hue = atan(yiq.z, yiq.y) + u_amt;',
'float chroma = length(yiq.yz);',
'yiq.y = chroma * cos(hue); yiq.z = chroma * sin(hue);',
'c.rgb = toRGB * yiq;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', (num(s, 'amt', 0) * Math.PI) / 180),
},
Blur: {
type: 'Blur', title: 'Gaussian blur',
widgets: [{ id: 'amt', label: 'Radius', type: 'slider', key: 'amt', min: 0, max: 5, step: 0.1, freeFloating: true }],
defaults: { amt: 1.2 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 o = u_texel * u_amt;',
'vec4 s = texture2D(u_tex, v_uv) * 4.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(-o.x, 0.0)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2(0.0, -o.y)) * 2.0;',
's += texture2D(u_tex, v_uv + vec2( o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, o.y));',
's += texture2D(u_tex, v_uv + vec2( o.x, -o.y));',
's += texture2D(u_tex, v_uv + vec2(-o.x, -o.y));',
'c = s / 16.0;',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
Vignette: {
type: 'Vignette', title: 'Vignette',
widgets: [{ id: 'amt', label: 'Amount', type: 'slider', key: 'amt', min: 0, max: 1, step: 0.01, freeFloating: true }],
defaults: { amt: 0.55 },
uniforms: 'uniform float u_amt;',
body: [
'vec2 d = v_uv - 0.5;',
'float vig = smoothstep(0.8, 0.25, length(d) * 1.4);',
'c.rgb *= mix(1.0, vig, u_amt);',
].join('\n'),
setUniforms: (gl, p, s) => f1(gl, p, 'u_amt', num(s, 'amt', 0)),
},
}
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`
const PASSTHROUGH = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
void main() { gl_FragColor = texture2D(u_tex, v_uv); }`
function fragFor(filter: Filter): string {
return `precision highp float;
varying vec2 v_uv;
uniform sampler2D u_tex;
uniform vec2 u_texel;
${filter.uniforms}
void main() {
vec4 c = texture2D(u_tex, v_uv);
${filter.body}
gl_FragColor = c;
}`
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const sh = gl.createShader(type)!
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader compile failed: ' + gl.getShaderInfoLog(sh))
}
return sh
}
/** A reusable WebGL image-filter runner. Create once, call `run` per pipeline evaluation. */
export class ImagePipelineRunner {
readonly #canvas: HTMLCanvasElement
readonly #gl: WebGLRenderingContext
readonly #programs = new Map<string, WebGLProgram>() // fragment src → program
readonly #vert: WebGLShader
#w = 0
#h = 0
#fbo: WebGLFramebuffer[] = []
#tex: WebGLTexture[] = []
constructor() {
this.#canvas = document.createElement('canvas')
const gl = this.#canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
if (!gl) throw new Error('WebGL unavailable')
this.#gl = gl
this.#vert = compile(gl, gl.VERTEX_SHADER, VERT)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
}
#program(fragSrc: string): WebGLProgram {
const cached = this.#programs.get(fragSrc)
if (cached) return cached
const gl = this.#gl
const prog = gl.createProgram()!
gl.attachShader(prog, this.#vert)
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, fragSrc))
gl.bindAttribLocation(prog, 0, 'a_pos')
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('program link failed: ' + gl.getProgramInfoLog(prog))
}
this.#programs.set(fragSrc, prog)
return prog
}
#resize(w: number, h: number): void {
if (w === this.#w && h === this.#h && this.#tex.length) return
const gl = this.#gl
this.#canvas.width = w
this.#canvas.height = h
for (const t of this.#tex) gl.deleteTexture(t)
for (const f of this.#fbo) gl.deleteFramebuffer(f)
this.#tex = []
this.#fbo = []
for (let i = 0; i < 2; i++) {
const tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
this.#tex.push(tex)
this.#fbo.push(fbo)
}
this.#w = w
this.#h = h
}
/** Run the chain of filters over `image` and return the result as a PNG data URL. */
run(image: HTMLImageElement, passes: { filter: Filter; state: Record<string, unknown> }[]): string {
const gl = this.#gl
const w = image.naturalWidth || image.width
const h = image.naturalHeight || image.height
this.#resize(w, h)
// Upload the source flipped so it renders upright through the framebuffer chain to the canvas.
const srcTex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, srcTex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.viewport(0, 0, w, h)
const list = passes.length ? passes : [{ filter: null as Filter | null, state: {} }]
let input = srcTex
for (let i = 0; i < list.length; i++) {
const last = i === list.length - 1
const entry = list[i]!
const prog = this.#program(entry.filter ? fragFor(entry.filter) : PASSTHROUGH)
gl.useProgram(prog)
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.#fbo[i % 2]!)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, input)
const texLoc = gl.getUniformLocation(prog, 'u_tex')
if (texLoc) gl.uniform1i(texLoc, 0)
const texelLoc = gl.getUniformLocation(prog, 'u_texel')
if (texelLoc) gl.uniform2f(texelLoc, 1 / w, 1 / h)
if (entry.filter) entry.filter.setUniforms(gl, prog, entry.state)
gl.drawArrays(gl.TRIANGLES, 0, 3)
input = this.#tex[i % 2]!
}
gl.deleteTexture(srcTex)
return this.#canvas.toDataURL('image/png')
}
}
/** Procedurally drawn default source image (no bundled asset) — a colorful test card. */
export function defaultSourceImage(): string {
const w = 480, h = 320
const cv = document.createElement('canvas')
cv.width = w; cv.height = h
const ctx = cv.getContext('2d')!
const g = ctx.createLinearGradient(0, 0, w, h)
g.addColorStop(0, '#1b2a4a'); g.addColorStop(0.5, '#7a3b9e'); g.addColorStop(1, '#e8a04b')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
const discs: [number, number, number, string][] = [
[120, 110, 70, 'rgba(255, 90, 120, 0.85)'],
[330, 90, 55, 'rgba(80, 220, 200, 0.85)'],
[250, 220, 85, 'rgba(255, 215, 90, 0.8)'],
[400, 250, 45, 'rgba(120, 160, 255, 0.85)'],
]
for (const [x, y, r, fill] of discs) {
ctx.fillStyle = fill
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill()
}
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 3
for (let i = 1; i < 6; i++) { ctx.beginPath(); ctx.moveTo((w / 6) * i, 0); ctx.lineTo((w / 6) * i, h); ctx.stroke() }
return cv.toDataURL('image/png')
}
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// ─── Framework-agnostic showcase wiring ─────────────────────────────────────────────────────────
// Reused as-is by every framework demo. The only framework-supplied pieces are the two preview
// widget controllers (Source/Result); the schemas, layout, live re-processing and download are shared.
import type { XenolithEditor, NodeSchema, Node } from '@xenolithengine/graph-editor'
import type { CustomWidgetController } from '@xenolithengine/graph-render-pixi'
import { reachableFrom } from '@xenolithengine/graph-core'
export const OUT_RESULT = 'result'
/** Follow the linear chain from the source node and collect the filter nodes (excluding output). */
function chainFilters(editor: XenolithEditor, srcId: string, outId: string): Node[] {
const edges = [...editor.graphEdges()]
const out: Node[] = []
const seen = new Set<string>()
let cur = srcId
for (;;) {
const edge = edges.find((e) => e.from.node === cur)
if (!edge || edge.to.node === outId || seen.has(edge.to.node)) break
seen.add(edge.to.node)
const n = editor.getNode(edge.to.node)
if (!n) break
out.push(n as Node)
cur = edge.to.node
}
return out
}
export interface ImagePipelineWidgets {
input: CustomWidgetController
output: CustomWidgetController
}
export interface ImagePipelineHandle {
outputId: string
process(): Promise<void>
download(): void
}
/** Register schemas, build the Source → filters → Result chain, wire live re-processing on every
* widget change, and return a handle the UI layer drives. Framework-agnostic. */
export function buildImagePipeline(editor: XenolithEditor, widgets: ImagePipelineWidgets): ImagePipelineHandle {
editor.registerWidget('img-input', widgets.input)
editor.registerWidget('img-output', widgets.output)
editor.registry.register({
type: 'Source', title: 'Source image',
pins: [{ kind: 'data', direction: 'out', type: 'image', label: 'Out' }],
widgets: [{ id: 'src', label: '', type: 'custom', renderer: 'img-input', key: 'src', height: 170 }],
} satisfies NodeSchema)
editor.registry.register({
type: 'Output', title: 'Result',
pins: [{ kind: 'data', direction: 'in', type: 'image', label: 'In' }],
widgets: [{ id: OUT_RESULT, label: '', type: 'custom', renderer: 'img-output', key: OUT_RESULT, height: 170 }],
} satisfies NodeSchema)
const filters = Object.values(FILTERS)
for (const f of filters) {
editor.registry.register({
type: f.type, title: f.title,
pins: [
{ kind: 'data', direction: 'in', type: 'image', label: 'In' },
{ kind: 'data', direction: 'out', type: 'image', label: 'Out' },
],
widgets: f.widgets,
})
}
const source = editor.registry.instantiate('Source', { x: 0, y: 40 })
source.size = { x: 300, y: 230 }
source.state['src'] = defaultSourceImage()
editor.addNode(source)
let prev = source
let prevOut = 0
filters.forEach((f, i) => {
const node = editor.registry.instantiate(f.type, { x: 360 + i * 200, y: 110 })
for (const [k, v] of Object.entries(f.defaults)) node.state[k] = v
editor.addNode(node)
editor.connect(prev, prevOut, node, 0)
prev = node
prevOut = 1
})
const output = editor.registry.instantiate('Output', { x: 360 + filters.length * 200 + 40, y: 40 })
output.size = { x: 300, y: 230 }
output.state[OUT_RESULT] = ''
editor.addNode(output)
editor.connect(prev, prevOut, output, 0)
const runner = new ImagePipelineRunner()
let busy = false
const process = async (): Promise<void> => {
if (busy) return
// If the Output is no longer wired up from the Source (an edge was cut), there is no result to
// show — clear it. Otherwise the Result node would keep displaying a stale image that can't exist.
if (!reachableFrom(editor.readGraph(), source.id).has(output.id)) {
editor.setWidgetValue(output.id, OUT_RESULT, '')
return
}
const src = editor.getNode(source.id)?.state['src']
if (typeof src !== 'string' || !src) return
busy = true
try {
const img = await loadImage(src)
const passes = chainFilters(editor, String(source.id), String(output.id)).map((n) => ({ filter: FILTERS[n.type]!, state: n.state }))
const url = runner.run(img, passes)
editor.setWidgetValue(output.id, OUT_RESULT, url)
} catch { /* decode/GL failure — keep the previous result */ } finally { busy = false }
}
// Re-process on any change that affects the result: a widget edit, OR the graph topology changing
// (connect/disconnect/remove) — rewiring the chain (e.g. bypassing a filter) must update the output
// immediately, not only after the next slider move.
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (): void => { clearTimeout(timer); timer = setTimeout(() => { void process() }, 140) }
editor.on('widget:changed', (e) => { if (e.widgetId !== OUT_RESULT) schedule() })
editor.on('edge:connected', schedule)
editor.on('edge:disconnected', schedule)
editor.on('node:removed', schedule)
editor.on('node:added', schedule) // undo of a delete re-adds the node + its edges → re-process
editor.fitView({ padding: 56, maxZoom: 1 })
void process()
const download = (): void => {
const url = editor.getNode(output.id)?.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}
return { outputId: String(output.id), process, download }
}
/** Trigger a PNG download of the current Result node's image. Standalone helper so a host can
* wire a Download button via just `useEditor()` — no need to thread a build-handle around. */
export function downloadImageResult(editor: XenolithEditor): void {
const output = [...editor.graphNodes()].find((n) => n.type === 'Output')
if (!output) return
const url = output.state[OUT_RESULT]
if (typeof url !== 'string' || !url) return
const a = document.createElement('a')
a.href = url; a.download = 'result.png'
document.body.appendChild(a); a.click(); a.remove()
}