Skip to content

Instantly share code, notes, and snippets.

@cprima
Last active October 1, 2025 20:31
Show Gist options
  • Select an option

  • Save cprima/6463818df62a281db0cd5767cff4b616 to your computer and use it in GitHub Desktop.

Select an option

Save cprima/6463818df62a281db0cd5767cff4b616 to your computer and use it in GitHub Desktop.
shadertoy-local-lite

Shadertoy Local Lite

A minimal, browser-based WebGL shader editor with live preview. Edit GLSL fragment shaders locally with instant visual feedback in a clean 16:9 canvas.

Features

  • Live shader editing with instant compilation feedback
  • 16:9 aspect ratio canvas that scales perfectly with flexbox layout
  • Popup viewers for clean OBS recording (sync via BroadcastChannel)
  • Three-state safeguard: Disk → Editor → Viewer (broken shaders never reach viewer)
  • Multiple shader files with dropdown switching
  • Solarized orange branded interface with light theme
  • Resizable split pane between editor and canvas
  • Layout modes: Split (default), Canvas-only, Editor-only
  • Keyboard shortcuts for quick control

Quick Start

Requirements

  • Python 3.9+
  • uv package manager

Run

make dev

Server starts at http://localhost:5678

Alternative (without make)

uv run python dev.py

Usage

Editor Controls

  • Tab: Insert 2 spaces
  • Shader dropdown: Switch between .frag files
  • Save: Ctrl+S or Save button (writes to disk)
  • New: Create new shader file
  • Delete: Remove current shader

Viewer Controls

  • Canvas/Editor/Split: Toggle layout modes
  • Sizes: Open viewer in preset window sizes (720p, 1080p, 1440p, 4K)
  • Pause/Resume: Space bar (in viewer)
  • Restart: R key (resets iTime)
  • Toggle HUD: H key (fps/resolution/time overlay)
  • Screenshot: S key (saves PNG with frame number)

Shader Format

Write standard Shadertoy-style shaders:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    fragColor = vec4(uv, 0.5 + 0.5 * sin(iTime), 1.0);
}

Or use main() directly:

void main() {
    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    outColor = vec4(uv, 0.5 + 0.5 * sin(iTime), 1.0);
}

Available Uniforms

  • vec3 iResolution - viewport resolution (width, height, aspect)
  • float iTime - elapsed time in seconds
  • float iTimeDelta - time since last frame
  • int iFrame - frame counter
  • float iFrameRate - current fps
  • vec4 iMouse - mouse position (x, y, click_x, click_y)
  • vec4 iDate - (year, month, day, time_in_seconds)

Architecture

Three-State Safeguard

  1. Disk State: Files in current directory (.frag)
  2. Editor State: May contain broken/incomplete code
  3. Viewer State: Only receives successfully compiled shaders

The viewer never displays broken shaders. If compilation fails, it continues showing the last valid shader.

Files

  • index.html - Main editor interface
  • app.js - Application logic, state management, file operations
  • editor.js - Simple textarea editor (CodeMirror temporarily removed)
  • viewer.html - Standalone viewer for OBS recording
  • viewer.js - Viewer with WebGL rendering and BroadcastChannel sync
  • server.py - Flask API for file operations (list/save/create/delete)
  • state.js - State management with safeguard system
  • style.css - Solarized theme with orange branding
  • shader.frag - Example shader (or your own .frag files)

Development Notes

  • Uses flexbox layout for responsive editor/canvas split
  • Canvas maintains 16:9 aspect ratio via CSS
  • Popup viewers sync automatically with main window
  • Status bar updates every second to show shader age
  • No external dependencies for frontend (vanilla JS)
  • Flat file structure suitable for Gist distribution

Browser Compatibility

Requires WebGL2 support. Tested on:

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 15+

License

Public domain / MIT-0. Do whatever you want with it.

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
# uv (Python package manager)
.venv/
uv.lock
.python-version
# Flask
instance/
.webassets-cache
# Editor/IDE
.vscode/
.idea/
*.swp
*.swo
*~
.project
.classpath
.settings/
.vs/
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Logs
*.log
server.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Backup files
*.bak
*.tmp
*~
# Local configuration (if added later)
.env
.env.local
config.local.json
# Node modules (if npm/yarn used in future)
node_modules/
# Coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Documentation builds
docs/_build/
# Keep shader files - these are user content!
# (This is a comment, *.frag files are NOT ignored)
# Ignore ALL subdirectories (flat structure for Gist compatibility)
# Only files in root directory should be tracked
*/
// Pure Solarized terrain (flat colors) + meandering valley
// Sun↔Moon cycle with twilight blend, deeper rise/set, no flicker
#define speed 2.
#define disable_sound_texture_sampling
#define audio_vibration_amplitude .125
// ---- breathing controls
const float BREATH = 0.6;
const float OMEGA = 0.25;
// ---- valley meander controls
const float VALLEY_AMP = 1.5;
const float VALLEY_FREQ = 0.05;
const float VALLEY_SPEED = 0.2;
// ---- Solarized (exact)
const vec3 base03=vec3(0x00,0x2b,0x36)/255.;
const vec3 base02=vec3(0x07,0x36,0x42)/255.;
const vec3 base01=vec3(0x58,0x6e,0x75)/255.;
const vec3 base00=vec3(0x65,0x7b,0x83)/255.;
const vec3 base0 =vec3(0x83,0x94,0x96)/255.;
const vec3 base1 =vec3(0x93,0xa1,0xa1)/255.;
const vec3 base2 =vec3(0xee,0xe8,0xd5)/255.;
const vec3 base3 =vec3(0xfd,0xf6,0xe3)/255.;
const vec3 yellow=vec3(0xb5,0x89,0x00)/255.;
const vec3 orange=vec3(0xcb,0x4b,0x16)/255.;
const vec3 red =vec3(0xdc,0x32,0x2f)/255.;
const vec3 magenta=vec3(0xd3,0x36,0x82)/255.;
const vec3 violet=vec3(0x6c,0x71,0xc4)/255.;
const vec3 blue =vec3(0x26,0x8b,0xd2)/255.;
const vec3 cyan =vec3(0x2a,0x1f,0x98)/255.;
const vec3 green =vec3(0x85,0x99,0x00)/255.;
float jTime;
#ifdef disable_sound_texture_sampling
#define textureMirror(a,b) vec4(0)
#else
vec4 textureMirror(sampler2D tex, vec2 c){
vec2 cf = fract(c);
return texture(tex, mix(cf, 1.-cf, mod(floor(c),2.)));
}
#endif
float amp(vec2 p){ return smoothstep(1.,8.,abs(p.x)); }
float pow1d5(float a){ return a*sqrt(a); }
float hash21(vec2 co){ return fract(sin(dot(co.xy,vec2(1.9898,7.233)))*45758.5433); }
float hash(vec2 uv){
float a = amp(uv);
float w = 1.0;
return (a>0. ? a*pow1d5(hash21(uv))*w : 0.0)
-(textureMirror(iChannel0, vec2((uv.x*29.+uv.y)*.03125,1.)).x)*audio_vibration_amplitude;
}
float edgeMin(float dx, vec2 da, vec2 db, vec2 uv){
uv.x += 5.;
return min(min((1.-dx)*db.y, da.x), da.y);
}
vec2 trinoise(vec2 uv){
const float sq = sqrt(3./2.);
uv.x *= sq; uv.y -= .5*uv.x;
vec2 d = fract(uv); uv -= d;
bool c = dot(d, vec2(1)) > 1.;
vec2 dd = 1.-d;
vec2 da = c?dd:d, db = c?d:dd;
float nn = hash(uv+float(c));
float n2 = hash(uv+vec2(1,0));
float n3 = hash(uv+vec2(0,1));
float nmid = mix(n2, n3, d.y);
float ns = mix(nn, c?n2:n3, da.y);
float dx = da.x/db.y;
return vec2(mix(ns, nmid, dx), edgeMin(dx, da, db, uv+d));
}
// valley offset
vec2 valleyOffset(float z){
float fx = VALLEY_AMP * sin(VALLEY_FREQ * z + VALLEY_SPEED * jTime);
return vec2(fx, 0.0);
}
// map (height, edge)
vec2 map(vec3 p){
vec2 shift = valleyOffset(p.z);
vec2 n = trinoise(p.xz + shift);
float b = 1.0 + BREATH * sin(OMEGA * jTime);
float height = p.y - (2.0 * b) * n.x;
return vec2(height, n.y);
}
vec3 grad(vec3 p){
const vec2 e = vec2(.005,0);
float a = map(p).x;
return vec3(map(p+e.xyy).x-a,
map(p+e.yxy).x-a,
map(p+e.yyx).x-a)/e.x;
}
vec2 intersect(vec3 ro, vec3 rd){
float d=0., h=0.;
for(int i=0;i<500;i++){
vec3 p = ro + d*rd;
vec2 s = map(p);
h = s.x;
d += h*.5;
if(abs(h) < .003*d) return vec2(d, s.y);
if(d > 150.) break;
}
return vec2(-1);
}
// ---------- sky & alternating sun/moon ----------
#define TAU 6.28318530718
const float DAY_NIGHT_CYCLE = 24.0; // sec per full cycle
const float DISC_RAD = 0.045;
const float HALO = 0.018;
const float EL_PEAK = 0.8; // ~30° above horizon
const float EL_SUB = 0.22; // depth below horizon at rise/set
const float AZ_MAX = 0.90; // left↔right sweep
// base gradients
vec3 daySkyBase(vec3 rd){
float v = smoothstep(-0.25, 0.9, rd.y);
return mix(base3, base2, v);
}
vec3 nightSkyBase(vec3 rd){
float v = smoothstep(-0.2, 0.6, rd.y);
return mix(mix(base02, base03, 0.35), mix(base01, base02, 0.25), v);
}
// az/el → dir
vec3 dirFromAzEl(float az, float el){
float ce = cos(el), se = sin(el);
float sa = sin(az), ca = cos(az);
return normalize(vec3(ce*sa, se, ce*ca));
}
// overlay core + halo
void addCelestial(in vec3 rd, in vec3 ld, in vec3 color,
float coreEdge, float glowInner, float glowOuter,
inout vec3 skyCol)
{
float dp = clamp(dot(rd, ld), -1.0, 1.0);
float ang = acos(dp);
float coreA = 1.0 - smoothstep(DISC_RAD, DISC_RAD + coreEdge, ang);
skyCol = mix(skyCol, color, coreA);
float g = smoothstep(glowOuter, glowInner, ang);
skyCol += color * (0.35 * g);
}
vec3 gsky(vec3 rd, bool mask){
float cyclePhase = (jTime / DAY_NIGHT_CYCLE) * TAU;
float bodyPhase = mod(cyclePhase, TAU);
bool isSun = bodyPhase < 3.14159265;
// normalized phase 0..π for each body
float halfPhase = isSun ? bodyPhase : bodyPhase - 3.14159265;
float t = halfPhase / 3.14159265;
float az = mix(-AZ_MAX, +AZ_MAX, t);
float el = sin(t*3.14159265) * EL_PEAK - EL_SUB;
vec3 ld = dirFromAzEl(az, el);
// twilight blend
float wDay = clamp(0.5 + 0.5*sin(bodyPhase), 0.0, 1.0);
vec3 skyCol = mix(nightSkyBase(rd), daySkyBase(rd), wDay);
if(mask){
if(isSun){
addCelestial(rd, ld, orange, HALO*0.95, 1.2*DISC_RAD, 2.0*DISC_RAD, skyCol);
}else{
addCelestial(rd, ld, yellow, HALO*0.8, 1.4*DISC_RAD, 2.6*DISC_RAD, skyCol);
}
}
return skyCol;
}
// --- per-triangle ID matching trinoise skew
void triCellInfo(in vec2 uv, out vec2 cell, out bool up){
const float sq = sqrt(3.0/2.0);
vec2 q = uv; q.x *= sq; q.y -= 0.5*q.x;
vec2 f = fract(q);
cell = floor(q);
up = dot(f, vec2(1.0)) > 1.0;
}
vec3 triSolarizedFromHash(float h){
if(h < 0.70) return base2;
float r = fract(h * 7.123);
if(r < 1.0/7.0) return cyan;
if(r < 2.0/7.0) return blue;
if(r < 3.0/7.0) return violet;
if(r < 4.0/7.0) return yellow;
if(r < 5.0/7.0) return green;
if(r < 6.0/7.0) return magenta;
return red;
}
vec3 triColorAt(vec2 uv){
vec2 cell; bool up;
triCellInfo(uv, cell, up);
float h = hash21(cell + (up ? vec2(37.2,91.7) : vec2(0.0)));
h = fract(h + 0.123*hash21(cell + vec2(19.7,7.3)));
return triSolarizedFromHash(h);
}
float outlineMask(vec3 p, float hitY) {
const float valleyMin = 0.5;
const float valleyMax = 3.0;
float t = clamp((p.y - valleyMin) / (valleyMax - valleyMin), 0.0, 1.0);
float thickness = mix(0.04, 0.02, t);
return smoothstep(thickness, 0.0, hitY);
}
// ---------- tunnel params ----------
const float SPACING = 10.0;
const int AHEAD = 200;
const vec2 BASE_HALF = vec2(30.0, 18.0);
const float PERSK = 0.0016;
const float RECT_YCTR = 1.0;
const float ZFAR_G = SPACING*float(AHEAD);
const float TERRAIN_Y_MIN = 0.5;
const float TERRAIN_Y_MAX = 3.0;
float rectBorderDist(vec2 p, vec2 halfSize){
vec2 d = abs(p) - halfSize;
float outside = length(max(d, 0.0));
float inside = min(max(d.x, d.y), 0.0);
return outside + inside;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord){
jTime = iTime;
vec2 uv = (2.0*fragCoord - iResolution.xy)/iResolution.y;
vec3 ro = vec3(0.0, 1.0, -20000.0 + jTime*speed);
vec3 rd = normalize(vec3(uv, 4.0/3.0));
vec2 hit = intersect(ro, rd);
float dHit = hit.x;
vec3 col = gsky(rd, dHit < 0.0);
if(dHit > 0.0){
vec3 p = ro + dHit*rd;
vec2 shift = valleyOffset(p.z);
vec3 triCol = triColorAt(p.xz + shift);
col = triCol;
float edge = outlineMask(p, hit.y);
col = mix(col, orange, edge);
}
float firstIdx = floor(ro.z / SPACING) + 1.0;
for(int i=0; i<AHEAD; i++){
float m = firstIdx + float(i);
float z0 = m*SPACING;
float tRect = (z0 - ro.z) / rd.z;
if(tRect <= 0.0) continue;
if(dHit > 0.0 && tRect >= dHit) continue;
vec3 pRect = ro + tRect*rd;
float cx = valleyOffset(z0).x;
vec2 local = pRect.xy - vec2(cx, RECT_YCTR);
float dist = z0 - ro.z;
float s = 1.0 / (1.0 + PERSK*max(dist, 0.0));
vec2 halfSize = BASE_HALF * s;
halfSize.x = min(halfSize.x, 0.8*VALLEY_AMP);
float ySpan = TERRAIN_Y_MAX - TERRAIN_Y_MIN;
halfSize.y = min(halfSize.y, 0.7*(0.5*ySpan));
float thick = mix(0.05, 0.012, clamp(dist/ZFAR_G, 0.0, 1.0));
float dB = rectBorderDist(local, halfSize);
float edge = 1.0 - smoothstep(thick, thick + fwidth(dB), abs(dB));
col = mix(col, orange, edge);
}
fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
/**
* app.js - Main application logic
* Enhanced Shadertoy Local Lite with file management, safeguards, and recording features
*/
// =============================================================================
// DOM Elements
// =============================================================================
const canvas = document.getElementById('glcanvas');
const editorContainer = document.getElementById('editor');
const errorsEl = document.getElementById('errors');
const fpsEl = document.getElementById('fps');
const resEl = document.getElementById('res');
const timeEl = document.getElementById('time');
const hudEl = document.getElementById('hud');
const toastEl = document.getElementById('toast');
// File controls
const shaderSelect = document.getElementById('shaderSelect');
const newShaderBtn = document.getElementById('newShaderBtn');
const deleteShaderBtn = document.getElementById('deleteShaderBtn');
// Action buttons
const runBtn = document.getElementById('runBtn');
const saveBtn = document.getElementById('saveBtn');
const reloadBtn = document.getElementById('reloadBtn');
const pauseBtn = document.getElementById('pauseBtn');
const restartBtn = document.getElementById('restartBtn');
const hudBtn = document.getElementById('hudBtn');
// Viewer controls
const openViewerBtn = document.getElementById('openViewerBtn');
const viewerMenu = document.getElementById('viewerMenu');
// Layout controls
const layoutSplit = document.getElementById('layoutSplit');
const layoutCanvas = document.getElementById('layoutCanvas');
const layoutEditor = document.getElementById('layoutEditor');
const mainEl = document.querySelector('main');
const resizer = document.getElementById('resizer');
// Status
const editorStatusEl = document.getElementById('editorStatus');
const viewerStatusEl = document.getElementById('viewerStatus');
const editorFilenameEl = document.getElementById('editorFilename');
// Modals
const newShaderModal = document.getElementById('newShaderModal');
const newShaderInput = document.getElementById('newShaderInput');
const newShaderCreate = document.getElementById('newShaderCreate');
const newShaderCancel = document.getElementById('newShaderCancel');
// =============================================================================
// WebGL Setup
// =============================================================================
const gl = canvas.getContext('webgl2', {
antialias: true,
preserveDrawingBuffer: true,
alpha: false
});
if (!gl) {
showError('WebGL2 not available');
throw new Error('WebGL2 required');
}
// =============================================================================
// State
// =============================================================================
const state = new ShaderState();
let program = null;
let raf = 0;
let paused = false;
let startTime = performance.now() / 1000;
let prevTime = startTime;
let frame = 0;
let frameStart = startTime;
const mouse = [0, 0, 0, 0];
const DPR = Math.min(window.devicePixelRatio || 1, 2);
let hudVisible = true;
let viewerWindows = [];
// BroadcastChannel for syncing with popup viewers
let broadcastChannel = null;
try {
broadcastChannel = new BroadcastChannel('shadertoy-sync');
} catch (e) {
console.warn('BroadcastChannel not available:', e);
}
// =============================================================================
// WebGL Helper Functions
// =============================================================================
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) || '';
gl.deleteShader(sh);
throw new Error(log);
}
return sh;
}
function link(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(p) || '';
gl.deleteProgram(p);
throw new Error(log);
}
return p;
}
const vsSrc = `#version 300 es
precision highp float;
const vec2 verts[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));
void main(){ gl_Position = vec4(verts[gl_VertexID], 0., 1.); }
`;
function buildFrag(user) {
const hasMain = /\bvoid\s+main\s*\(/.test(user);
const header = `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec3 iResolution;
uniform float iTime;
uniform float iTimeDelta;
uniform int iFrame;
uniform float iFrameRate;
uniform vec4 iMouse;
uniform vec4 iDate;
`;
if (hasMain) {
return header + '\n' + user.replace('gl_FragColor', 'outColor');
}
return header + `
${user}
void main(){
vec4 c = vec4(0.0);
mainImage(c, gl_FragCoord.xy);
outColor = c;
}`;
}
function createProgramFromEditor() {
const fsSrc = buildFrag(getEditorContent());
const vs = compile(gl.VERTEX_SHADER, vsSrc);
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
const p = link(vs, fs);
gl.deleteShader(vs);
gl.deleteShader(fs);
return p;
}
let u = {};
function bindUniforms() {
u.res = gl.getUniformLocation(program, 'iResolution');
u.time = gl.getUniformLocation(program, 'iTime');
u.dt = gl.getUniformLocation(program, 'iTimeDelta');
u.ifr = gl.getUniformLocation(program, 'iFrame');
u.fps = gl.getUniformLocation(program, 'iFrameRate');
u.mouse = gl.getUniformLocation(program, 'iMouse');
u.date = gl.getUniformLocation(program, 'iDate');
}
function setDateUniform(t) {
const d = new Date();
const secs = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() / 1000;
gl.uniform4f(u.date, d.getFullYear(), d.getMonth() + 1, d.getDate(), secs);
}
// =============================================================================
// Rendering
// =============================================================================
function resize() {
const w = Math.floor(canvas.clientWidth * DPR);
const h = Math.floor(canvas.clientHeight * DPR);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
resEl.textContent = `${w}×${h}`;
}
}
function draw(now) {
const t = now / 1000;
const dt = Math.max(0.00001, t - prevTime);
gl.uniform3f(u.res, canvas.width, canvas.height, 1.0);
gl.uniform1f(u.time, t - startTime);
gl.uniform1f(u.dt, dt);
gl.uniform1i(u.ifr, frame++);
gl.uniform1f(u.fps, 1.0 / dt);
gl.uniform4f(u.mouse, mouse[0], mouse[1], mouse[2], mouse[3]);
setDateUniform(t);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// Update HUD
if (t - frameStart > 0.25) {
fpsEl.textContent = `${(1 / dt).toFixed(1)} fps`;
timeEl.textContent = `T: ${(t - startTime).toFixed(1)}s`;
frameStart = t;
}
prevTime = t;
}
function tick() {
if (paused) return;
resize();
try {
gl.useProgram(program);
draw(performance.now());
errorsEl.textContent = '';
} catch (e) {
errorsEl.textContent = e.message;
}
raf = requestAnimationFrame(tick);
}
// =============================================================================
// Shader Compilation with Safeguards
// =============================================================================
function compileShader() {
try {
const content = getEditorContent();
// Don't compile if editor is empty (not yet initialized)
if (!content || content.trim() === '') {
console.warn('Editor content is empty, skipping compilation');
return false;
}
const newProgram = createProgramFromEditor();
// Success! Update viewer
if (program) gl.deleteProgram(program);
program = newProgram;
bindUniforms();
// Mark as successful in state
state.setCompileSuccess(content);
// Sync to viewer windows
if (broadcastChannel) {
broadcastChannel.postMessage({
type: 'shader-update',
code: content
});
}
return true;
} catch (e) {
// Compilation failed - viewer keeps old shader
state.setCompileError(e.message);
errorsEl.textContent = e.message;
// Highlight error line if possible
if (state.errorLine) {
highlightErrorLine(state.errorLine);
}
return false;
}
}
function runShader() {
cancelAnimationFrame(raf);
if (compileShader()) {
clearErrorLine();
prevTime = performance.now() / 1000;
startTime = prevTime;
frame = 0;
paused = false;
pauseBtn.classList.remove('active');
pauseBtn.textContent = '⏸ Pause';
tick();
updateStatus();
showToast('Shader compiled successfully', 'success');
} else {
showToast('Compilation failed', 'error');
}
}
async function saveShader() {
try {
const success = await state.saveToDisk();
if (success) {
showToast('Saved to disk', 'success');
updateStatus();
}
} catch (e) {
showToast(e.message, 'error');
}
}
async function reloadShader() {
try {
const content = await state.loadFromDisk(state.currentFile);
setEditorContent(content);
updateStatus();
// Try to compile loaded shader
if (compileShader()) {
clearErrorLine();
showToast('Reloaded from disk', 'success');
} else {
showToast('Reloaded, but has errors', 'warning');
}
} catch (e) {
showToast(e.message, 'error');
}
}
// =============================================================================
// File Management
// =============================================================================
async function fetchShaderList() {
try {
const res = await fetch('/api/shaders');
const data = await res.json();
return data.shaders || [];
} catch (e) {
console.error('Failed to fetch shader list:', e);
return [];
}
}
async function populateDropdown() {
const shaders = await fetchShaderList();
shaderSelect.innerHTML = '';
if (shaders.length === 0) {
shaderSelect.innerHTML = '<option>No shaders found</option>';
return;
}
shaders.forEach(filename => {
const option = document.createElement('option');
option.value = filename;
option.textContent = filename;
if (filename === state.currentFile) {
option.selected = true;
}
shaderSelect.appendChild(option);
});
}
async function switchShader(filename) {
if (filename === state.currentFile) return;
// Check if safe to switch
if (!state.canSwitchFile()) return;
try {
const content = await state.loadFromDisk(filename);
setEditorContent(content);
editorFilenameEl.textContent = filename;
updateStatus();
// Try to compile
if (compileShader()) {
clearErrorLine();
}
// Update URL
const url = new URL(window.location);
url.searchParams.set('shader', filename);
window.history.pushState({}, '', url);
} catch (e) {
showToast(e.message, 'error');
}
}
function showNewShaderModal() {
newShaderModal.classList.add('show');
newShaderInput.value = '';
newShaderInput.focus();
}
function hideNewShaderModal() {
newShaderModal.classList.remove('show');
}
async function createNewShader() {
let filename = newShaderInput.value.trim();
if (!filename) {
showToast('Enter a filename', 'error');
return;
}
// Add .frag extension if missing
if (!filename.endsWith('.frag')) {
filename += '.frag';
}
// Validate filename
if (!/^[\w\-\.]+\.frag$/.test(filename)) {
showToast('Invalid filename', 'error');
return;
}
try {
const res = await fetch('/api/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Create failed');
}
hideNewShaderModal();
await populateDropdown();
// Switch to new shader
shaderSelect.value = filename;
await switchShader(filename);
showToast(`Created ${filename}`, 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteCurrentShader() {
const filename = state.currentFile;
if (!confirm(`Delete ${filename}?\n\nThis cannot be undone.`)) {
return;
}
try {
const res = await fetch('/api/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Delete failed');
}
// Refresh list
const shaders = await fetchShaderList();
await populateDropdown();
// Switch to first available shader
if (shaders.length > 0) {
const nextShader = shaders[0];
shaderSelect.value = nextShader;
await switchShader(nextShader);
}
showToast(`Deleted ${filename}`, 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
// =============================================================================
// Viewer Window Management
// =============================================================================
function openViewerWindow(width, height) {
const url = `viewer.html?shader=${state.currentFile}&w=${width}&h=${height}`;
const viewer = window.open(
url,
`viewer_${Date.now()}`,
`width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no`
);
if (viewer) {
viewerWindows.push(viewer);
showToast(`Opened ${width}×${height} viewer`, 'success');
} else {
showToast('Failed to open viewer (popup blocked?)', 'error');
}
}
// =============================================================================
// Layout Management
// =============================================================================
function setLayout(mode) {
mainEl.className = `layout-${mode}`;
layoutSplit.classList.toggle('active', mode === 'split');
layoutCanvas.classList.toggle('active', mode === 'canvas');
layoutEditor.classList.toggle('active', mode === 'editor');
// Resize canvas after layout change
setTimeout(resize, 100);
}
// Resizable splitter
let isResizing = false;
let startX = 0;
let startWidth = 0;
function initResizer() {
resizer.addEventListener('mousedown', e => {
isResizing = true;
startX = e.clientX;
const editorPane = document.querySelector('.editor-pane');
startWidth = editorPane.offsetWidth;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!isResizing) return;
const editorPane = document.querySelector('.editor-pane');
const delta = e.clientX - startX;
const newWidth = startWidth + delta;
const minWidth = 300;
const maxWidth = window.innerWidth - 400;
if (newWidth >= minWidth && newWidth <= maxWidth) {
const percentage = (newWidth / window.innerWidth) * 100;
mainEl.style.gridTemplateColumns = `${percentage}% auto 1fr`;
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
}
});
}
// =============================================================================
// UI Functions
// =============================================================================
function updateStatus() {
const status = state.getStatusText();
editorStatusEl.textContent = status.editorStatus;
viewerStatusEl.textContent = status.viewerStatus;
}
function showToast(message, type = 'info') {
toastEl.textContent = message;
toastEl.className = `toast ${type} show`;
setTimeout(() => {
toastEl.classList.remove('show');
}, 3000);
}
function showError(message) {
errorsEl.textContent = message;
}
function togglePause() {
paused = !paused;
pauseBtn.classList.toggle('active', paused);
pauseBtn.textContent = paused ? '▶ Resume' : '⏸ Pause';
if (!paused) tick();
}
function restartTime() {
startTime = performance.now() / 1000;
prevTime = startTime;
frame = 0;
frameStart = startTime;
showToast('Time restarted', 'success');
}
function toggleHud() {
hudVisible = !hudVisible;
hudEl.classList.toggle('hidden', !hudVisible);
hudBtn.classList.toggle('active', !hudVisible);
}
// =============================================================================
// Event Listeners
// =============================================================================
// File controls
shaderSelect.addEventListener('change', () => {
switchShader(shaderSelect.value);
});
newShaderBtn.addEventListener('click', showNewShaderModal);
deleteShaderBtn.addEventListener('click', deleteCurrentShader);
// New shader modal
newShaderCreate.addEventListener('click', createNewShader);
newShaderCancel.addEventListener('click', hideNewShaderModal);
newShaderInput.addEventListener('keydown', e => {
if (e.key === 'Enter') createNewShader();
if (e.key === 'Escape') hideNewShaderModal();
});
// Click outside modal to close
newShaderModal.addEventListener('click', e => {
if (e.target === newShaderModal) hideNewShaderModal();
});
// Action buttons
runBtn.addEventListener('click', runShader);
saveBtn.addEventListener('click', saveShader);
reloadBtn.addEventListener('click', reloadShader);
pauseBtn.addEventListener('click', togglePause);
restartBtn.addEventListener('click', restartTime);
hudBtn.addEventListener('click', toggleHud);
// Viewer button dropdown
openViewerBtn.addEventListener('click', e => {
e.stopPropagation();
viewerMenu.classList.toggle('show');
});
viewerMenu.addEventListener('click', e => {
const btn = e.target.closest('button[data-size]');
if (btn) {
const [w, h] = btn.dataset.size.split(',').map(Number);
openViewerWindow(w, h);
viewerMenu.classList.remove('show');
}
});
// Close dropdown when clicking outside
document.addEventListener('click', () => {
viewerMenu.classList.remove('show');
});
// Layout buttons
layoutSplit.addEventListener('click', () => setLayout('split'));
layoutCanvas.addEventListener('click', () => setLayout('canvas'));
layoutEditor.addEventListener('click', () => setLayout('editor'));
// Canvas mouse events
window.addEventListener('resize', resize);
['mousemove', 'mousedown', 'mouseup'].forEach(evt => {
canvas.addEventListener(evt, e => {
const r = canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * DPR;
const y = (r.height - (e.clientY - r.top)) * DPR;
if (evt === 'mousedown') { mouse[2] = x; mouse[3] = y; }
if (evt !== 'mouseup') { mouse[0] = x; mouse[1] = y; }
});
});
// =============================================================================
// Keyboard Shortcuts
// =============================================================================
document.addEventListener('keydown', e => {
const ctrl = e.ctrlKey || e.metaKey;
// Ctrl+Enter - Run shader
if (ctrl && e.key === 'Enter') {
e.preventDefault();
runShader();
}
// Ctrl+S - Save
if (ctrl && e.key === 's') {
e.preventDefault();
saveShader();
}
// Ctrl+R - Reload
if (ctrl && e.key === 'r') {
e.preventDefault();
reloadShader();
}
// Ctrl+P - Open viewer
if (ctrl && e.key === 'p') {
e.preventDefault();
openViewerWindow(1920, 1080);
}
// Ctrl+H - Toggle HUD
if (ctrl && e.key === 'h') {
e.preventDefault();
toggleHud();
}
// Ctrl+T - Restart time
if (ctrl && e.key === 't') {
e.preventDefault();
restartTime();
}
// Ctrl+1/2/3 - Layout modes
if (ctrl && e.key === '1') {
e.preventDefault();
setLayout('split');
}
if (ctrl && e.key === '2') {
e.preventDefault();
setLayout('canvas');
}
if (ctrl && e.key === '3') {
e.preventDefault();
setLayout('editor');
}
});
// =============================================================================
// Initialization
// =============================================================================
async function init() {
// Parse URL for initial shader
const urlParams = new URLSearchParams(window.location.search);
const shaderParam = urlParams.get('shader');
if (shaderParam) {
state.currentFile = shaderParam;
}
// Populate shader dropdown
await populateDropdown();
// Initialize CodeMirror editor
await initializeEditor(editorContainer, content => {
state.setEditorContent(content);
updateStatus();
});
// Load initial shader
try {
const content = await state.loadFromDisk(state.currentFile);
setEditorContent(content);
editorFilenameEl.textContent = state.currentFile;
// Ensure state has the content before compiling
state.setEditorContent(content);
} catch (e) {
showToast(`Failed to load ${state.currentFile}`, 'error');
}
// Initialize resizer
initResizer();
// Setup canvas resize observer
requestAnimationFrame(() => {
const vp = document.querySelector('.viewer-pane');
const ro = new ResizeObserver(() => resize());
ro.observe(vp);
resize();
});
// Check for recovery backup
const backup = state.hasRecoveryBackup();
if (backup) {
const age = Math.floor((Date.now() - backup.timestamp) / 1000);
if (confirm(`Found unsaved changes from ${age}s ago.\n\nRestore backup?`)) {
state.restoreFromBackup(backup);
setEditorContent(state.editorContent);
updateStatus();
showToast('Backup restored', 'success');
} else {
state.clearBackup();
}
}
// Start auto-backup
state.startAutoBackup(30000);
// Compile and run initial shader
runShader();
// Update status initially and every second to show age
updateStatus();
setInterval(updateStatus, 1000);
// Warn before unload if dirty
window.addEventListener('beforeunload', e => {
if (state.isDirty) {
e.preventDefault();
e.returnValue = 'You have unsaved changes. Leave anyway?';
}
});
console.log('Shadertoy Local Lite initialized');
}
// Start when DOM and CodeMirror are ready
async function waitForCodeMirror() {
// Wait for CodeMirror to be loaded from CDN
let attempts = 0;
while (typeof CodeMirror === 'undefined' && attempts < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (typeof CodeMirror === 'undefined') {
console.error('CodeMirror failed to load after 5 seconds');
showError('CodeMirror editor failed to load. Please refresh the page.');
return false;
}
return true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', async () => {
if (await waitForCodeMirror()) {
init();
}
});
} else {
waitForCodeMirror().then(ready => {
if (ready) init();
});
}

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Running the Application

This is an enhanced WebGL2 shader editor with file management capabilities. It requires a Flask server for file operations.

Requirements: uv - Fast Python package manager

# Recommended: Start Flask API server (single terminal)
make dev        # Press Ctrl+C to stop

# Alternative: Foreground mode
make serve-api  # Press Ctrl+C to stop

# Stop via PID file (if needed)
make stop

# Basic HTTP server (no file operations, no uv needed)
make serve

Then open http://127.0.0.1:8007 in a browser.

Important: Use make dev or make serve-api for full functionality (create/save/delete shaders).

Architecture Overview

Shadertoy Local Lite is a full-featured, browser-based shader editor with:

  • Multi-file management (dropdown selector for *.frag files)
  • Three-state safeguard system (disk/editor/viewer separation)
  • 16:9 aspect ratio canvas with responsive flexbox layout
  • Popup viewer windows for OBS recording (720p/900p/1080p/1440p/4K)
  • Simple textarea editor (CodeMirror temporarily removed due to CDN conflicts)
  • Recording features (HUD toggle, time controls, screenshot)
  • Layout flexibility (resizable splitter, 3 layout modes)
  • Keyboard shortcuts for all major actions
  • Solarized orange branded header with light theme

Constraint: Flat file structure (no subdirectories) - suitable for GitHub Gist hosting.

File Structure

shadertoy-local-lite/
├── index.html        # Main UI (editor + canvas split view)
├── viewer.html       # Popup viewer for recording
├── app.js           # Main application logic (~800 lines)
├── viewer.js        # Viewer-specific logic
├── editor.js        # CodeMirror 6 integration
├── state.js         # ShaderState class (safeguard system)
├── style.css        # All styles (Solarized Light/Dark)
├── server.py        # Flask API server for file operations
├── Makefile         # Build commands
├── CLAUDE.md        # This file
├── PLAN.md          # Detailed implementation plan
├── shader.frag      # Example shader
└── *.frag          # User-created shader files

Core Components

state.js - Three-State Safeguard System

Critical Architecture: Prevents broken shaders from affecting the viewer.

┌──────────────┐
│  Disk State  │  ← What's saved in *.frag file
└──────┬───────┘
       │
       ↓ Load/Reload
┌──────────────┐
│ Editor State │  ← Current text in CodeMirror (may be broken)
└──────┬───────┘
       │
       ↓ Compile (only on success)
┌──────────────┐
│ Viewer State │  ← Last successfully compiled shader (always valid)
└──────────────┘

Rules:

  1. Run/Compile (runShader() in app.js:274) - Try compile editor → Update viewer only on success
  2. Save (saveShader() in app.js:293) - Write editor to disk (warn if broken via state.shouldSaveBrokenCode())
  3. Reload (reloadShader() in app.js:305) - Load disk → Try compile → Update viewer only on success
  4. Viewer never shows broken shader - Old shader keeps running if compilation fails

The ShaderState class tracks:

  • currentFile - Selected shader filename
  • editorContent - Current editor text (may have errors)
  • diskContent - Last known disk state
  • viewerShader - Last successful compiled shader
  • isDirty - Editor differs from disk (shows ● indicator)
  • hasErrors - Current compilation status
  • errorLine - Parsed error line number for highlighting

server.py - Flask API Backend

Provides file management endpoints:

GET  /api/shaders          # List all *.frag files
POST /api/save             # Save file content
POST /api/create           # Create new shader with template
POST /api/delete           # Delete shader file
POST /api/rename           # Rename shader file (implemented but unused)

All endpoints have input validation to prevent path traversal attacks. Filenames must:

  • End with .frag
  • Match pattern ^[\w\-\.]+\.frag$
  • Contain no / or \ or ..

app.js - Main Application Logic

Key Functions:

Compilation (app.js:239-272):

  • compileShader() - Try compile, update viewer only on success, sync to popup viewers via BroadcastChannel
  • runShader() - Compile and start render loop
  • saveShader() - Save to disk with broken code warning
  • reloadShader() - Reload from disk

File Management (app.js:327-472):

  • fetchShaderList() - GET /api/shaders
  • populateDropdown() - Fill select element
  • switchShader() - Handle unsaved changes, load new file
  • createNewShader() - POST /api/create with template
  • deleteCurrentShader() - POST /api/delete with confirmation

Viewer Windows (app.js:478-493):

  • openViewerWindow(width, height) - Opens popup with window.open() and size presets
  • Uses BroadcastChannel 'shadertoy-sync' to push shader updates to all open viewers
  • Viewer URLs: viewer.html?shader=file.frag&w=1920&h=1080

Layout Management (app.js:499-546):

  • setLayout(mode) - Switch between 'split', 'canvas', 'editor' modes
  • initResizer() - Drag handler for resizable splitter

editor.js - Simple Textarea Editor

Note: CodeMirror 6 was temporarily removed due to CDN module loading conflicts. Current implementation uses a styled textarea.

Setup (editor.js:12-53):

  • Creates textarea element with monospace font
  • Handles Tab key for 2-space indentation
  • Uses CSS variables for Solarized theme colors
  • Fills container without creating scrollbars on parent

API:

  • initializeEditor(container, onChange) - Setup textarea instance
  • setEditorContent(content) - Programmatically set text
  • getEditorContent() - Get current text
  • highlightErrorLine(lineNum) - Select error line (simplified)
  • clearErrorLine() - No-op for textarea
  • focusEditor() - Focus the textarea

TODO: Restore CodeMirror 6 with proper module bundler or use CodeMirror 5 for simpler CDN loading.

viewer.js - Popup Viewer Logic

Standalone shader viewer for OBS recording. Features:

  • Minimal UI (canvas + optional HUD + controls)
  • Listens to BroadcastChannel for shader updates from main window
  • URL params: ?shader=file.frag&w=1920&h=1080 to set window size
  • Controls: Pause, Restart, HUD toggle, Screenshot, Recording mode
  • Keyboard: Space (pause), R (restart), H (HUD), S (screenshot), Esc (exit recording mode)

Canvas & Rendering (app.js:185-233)

16:9 Aspect Ratio: Enforced via CSS aspect-ratio: 16/9 on #glcanvas. Canvas is centered in viewer pane with black letterbox background.

Flexbox Layout: Main uses display: flex with:

  • Editor pane: flex-basis: 480px; min-width: 360px; max-width: 50%
  • Viewer pane: flex: 1; min-width: 0 (takes remaining space)
  • Resizer: flex-shrink: 0; width: 4px
  • All overflow hidden to prevent scrollbars except within textarea

WebGL2 Pipeline:

  1. Vertex shader (app.js:122-126) - Fullscreen triangle using gl_VertexID
  2. Fragment shader wrapping (app.js:128-152) - buildFrag(user) detects void main() vs mainImage() style and injects Shadertoy uniforms
  3. Render loop (app.js:220-233) - requestAnimationFrame updates uniforms and draws

Available Uniforms:

uniform vec3  iResolution;  // Canvas width, height, pixel aspect
uniform float iTime;        // Time in seconds since start
uniform float iTimeDelta;   // Time since last frame
uniform int   iFrame;       // Frame counter
uniform float iFrameRate;   // Instantaneous FPS
uniform vec4  iMouse;       // xy=current, zw=click position
uniform vec4  iDate;        // (year, month, day, time in seconds)

Note: iChannel0-3 textures are NOT implemented.

Keyboard Shortcuts (app.js:667-719)

Ctrl/Cmd+Enter  → Compile and run shader
Ctrl/Cmd+S      → Save to disk
Ctrl/Cmd+R      → Reload from disk
Ctrl/Cmd+P      → Open 1080p viewer window
Ctrl/Cmd+H      → Toggle HUD visibility
Ctrl/Cmd+T      → Restart time to 0
Ctrl/Cmd+1      → Split layout
Ctrl/Cmd+2      → Canvas-only layout
Ctrl/Cmd+3      → Editor-only layout

UI Status Indicators (app.js:562-564)

Header: Solarized orange background (var(--orange)) with light text and semi-transparent white buttons.

Status Bar (index.html:63-66):

  • Editor status: Shows filename ● if unsaved changes exist (updates dynamically on file switch)
  • Viewer status: Shows what's rendering (updates every 1 second via setInterval):
    • ✓ Running: shader.frag - Up to date
    • ⚠️ Running: shader.frag (15s old) - Editor has changes not yet compiled
    • Age counter increments automatically to show staleness

File Management Workflow

Creating New Shader:

  1. Click ➕ button → Modal prompts for filename
  2. Automatically adds .frag extension if missing
  3. POST /api/create with template
  4. Switches to new shader and opens in editor

Switching Shaders:

  1. Select from dropdown
  2. If unsaved changes: confirmation prompt
  3. Load from disk → Try compile → Update viewer if successful
  4. URL updates to ?shader=newfile.frag

Deleting Shader:

  1. Click 🗑️ button → Confirmation prompt
  2. POST /api/delete
  3. Switches to first available shader

Recording Workflow

For OBS Screen Capture:

  1. Open viewer window: Click "⧉ Viewer" dropdown → Select size (e.g., 1080p)
  2. Viewer window opens at exact pixel size (1920×1080)
  3. Changes in main window editor automatically sync to viewer via BroadcastChannel
  4. Viewer controls:
    • 👁️ HUD - Toggle FPS/resolution display
    • ⏮ Restart - Reset time to 0 for repeatable recordings
    • 📸 - Screenshot current frame as PNG
    • 🎥 - Recording mode (hides all UI except canvas)

Tips:

  • Use Recording mode (🎥) for clean canvas capture
  • Restart time before recording for consistent start point
  • Multiple viewers can be open simultaneously, all stay synced

Auto-Backup & Recovery (state.js:166-218)

Auto-Backup:

  • Saves editor content to localStorage every 30 seconds (if dirty)
  • Key: 'shader_backup'

Recovery:

  • On page load, checks if backup is newer than last compile
  • Prompts: "Found unsaved changes from 45s ago. Restore backup?"
  • User can restore or discard

Before Unload:

  • If isDirty, browser shows "You have unsaved changes" warning

Layout Modes (app.js:499-508)

Three layout modes via CSS classes on <main>:

  1. Split (default) - layout-split - Editor left, canvas right, resizable splitter (flexbox row)
  2. Canvas - layout-canvas - Canvas only (hide editor and resizer)
  3. Editor - layout-editor - Editor only (hide canvas and resizer)

Buttons in header toggle between modes. Shortcuts: Ctrl+1/2/3

Resizable Splitter (app.js:525-546)

Drag the 4px divider between editor and canvas to adjust width ratio.

  • Min editor width: 360px
  • Max editor width: 50% of window
  • Changes editor pane width dynamically via inline styles
  • Responsive: On mobile (<900px), switches to vertical flexbox (column) with horizontal resize bar

Toast Notifications (app.js:558-565)

Show temporary messages (3 seconds) in bottom-right:

showToast('Shader compiled successfully', 'success');  // Green
showToast('Compilation failed', 'error');              // Red
showToast('Reloaded, but has errors', 'warning');      // Orange

Styled in style.css:351-375

Error Handling

Compilation Errors (app.js:260-271):

  • Parse GLSL error: ERROR: 0:23: 'foo' : undeclared identifier
  • Extract line number → state.errorLine = 23
  • Highlight line in editor with red background (via CodeMirror decoration)
  • Display full error in #errors panel below editor

File Operation Errors:

  • Fetch failures → Toast notification
  • Invalid filenames → Client-side validation + server rejection
  • Missing files → Error message in errors panel

BroadcastChannel Sync (app.js:86-91, 252-257)

Main window → Viewer sync:

// Main window (app.js:252-257)
broadcastChannel.postMessage({
    type: 'shader-update',
    code: getEditorContent()
});

// Viewer (viewer.js:20-24)
broadcastChannel.onmessage = (e) => {
    if (e.data.type === 'shader-update') {
        loadShaderCode(e.data.code);
    }
};

All open viewer windows receive updates when main window compiles successfully.

Development Tips

Adding New Features:

  1. UI changes → Update index.html and style.css
  2. State changes → Modify ShaderState class in state.js
  3. Core logic → Update app.js (well-organized with section comments)
  4. New API endpoints → Add to server.py with validation

Debugging:

  • Check browser console for WebGL/BroadcastChannel errors
  • Flask server logs show API requests
  • Use browser DevTools → Application → Local Storage to inspect backups

Common Issues:

  • "Popup blocked" → User needs to allow popups for this site
  • "WebGL2 not available" → Browser/GPU limitation
  • Textarea editor has no syntax highlighting → CodeMirror temporarily removed, restore with bundler or use CM5
  • File operations fail → Make sure Flask server is running (make dev or make serve-api)
  • Three scrollbars → Fixed with flexbox layout and overflow:hidden

Keyboard Shortcuts Reference (Quick List)

Shortcut Action
Ctrl/Cmd+Enter Compile and run
Ctrl/Cmd+S Save to disk
Ctrl/Cmd+R Reload from disk
Ctrl/Cmd+P Open 1080p viewer
Ctrl/Cmd+H Toggle HUD
Ctrl/Cmd+T Restart time
Ctrl/Cmd+1 Split layout
Ctrl/Cmd+2 Canvas only
Ctrl/Cmd+3 Editor only

Future Enhancements (Not Yet Implemented)

See PLAN.md Phase 9+ for optional features:

  • Theme toggle (Solarized Dark CSS exists but no button)
  • Settings modal for canvas DPR, colors, etc.
  • Fullscreen API
  • Screenshot via Ctrl+Shift+S
  • Keyboard shortcuts help modal
  • Time scrubber slider
  • File watcher for external editor changes
  • Shader library/presets
  • URL hash sharing (compressed shader code)

Testing Checklist

When modifying code, verify:

  • Compile success updates viewer
  • Compile failure keeps old viewer running
  • Broken code warning on save
  • Unsaved changes warning on switch
  • File dropdown populates correctly
  • Create/delete shader works
  • 16:9 aspect ratio enforced
  • Popup viewer opens at correct size
  • BroadcastChannel syncs to viewers
  • CodeMirror syntax highlighting works
  • Error lines highlighted in editor
  • HUD toggle works
  • Time restart works
  • All keyboard shortcuts work
  • Layout modes switch correctly
  • Resizable splitter works
  • Auto-backup/recovery works
  • Before-unload warning if dirty
  • Toast notifications appear

Architecture Decisions

Why Flask instead of pure HTTP server?

  • Need POST endpoints for file create/save/delete operations
  • Simple to set up, no build process required
  • CORS handling built-in

Why BroadcastChannel instead of postMessage?

  • Simpler API for one-to-many communication
  • Works across all same-origin tabs/windows automatically
  • Fallback: Works fine without it (viewers just won't auto-update)

Why CodeMirror 6 instead of Monaco?

  • Lighter weight (~100KB vs ~3MB)
  • Better mobile support
  • Easier to theme with CSS variables
  • ESM modules work well with CDN

Why three-state safeguard system?

  • Critical for live shader development: Broken code in editor shouldn't crash viewer
  • Allows saving work-in-progress without affecting running shader
  • Provides clear feedback via status indicators
  • Prevents frustrating "oops, I broke it and lost the working version" moments

Why flat file structure (no folders)?

  • GitHub Gist compatibility (gists don't support folders)
  • Simpler mental model for single-project workflow
  • Easier file listing via glob (no recursive search needed)
  • API endpoints simpler without path handling

Security Notes

  • Path traversal protection: All file operations validate filenames (no .., /, \)
  • Filename whitelist: Only ^[\w\-\.]+\.frag$ allowed
  • CORS enabled: Required for localhost development
  • No authentication: This is a local development tool, not production-ready
  • XSS safe: User shader code runs in WebGL (not eval'd as JS)
#!/usr/bin/env python3
"""Development server launcher - handles Ctrl+C cleanly on all platforms"""
import subprocess
import sys
import logging
logging.basicConfig(
level=logging.INFO,
format='%(message)s'
)
def main():
logging.info("=" * 60)
logging.info("Shadertoy Local Lite - Development Server")
logging.info("=" * 60)
logging.info("Starting on http://127.0.0.1:8007")
logging.info("Press Ctrl+C to stop")
logging.info("=" * 60)
logging.info("")
try:
subprocess.run([sys.executable, "server.py"])
except KeyboardInterrupt:
logging.info("\n\nShutting down server...")
sys.exit(0)
if __name__ == "__main__":
main()
/**
* Simple textarea editor (temporary replacement for CodeMirror)
* TODO: Fix CodeMirror 6 CDN loading issues and restore syntax highlighting
*/
// Editor element
let editorView = null;
/**
* Initialize simple textarea editor
*/
async function initializeEditor(container, onChange) {
// Create textarea element
const textarea = document.createElement('textarea');
textarea.style.cssText = `
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: 10px;
box-sizing: border-box;
font-family: ui-monospace, SFMono-Regular, Consolas, Menlo, monospace;
font-size: 15px;
line-height: 1.5;
border: none;
resize: none;
overflow: auto;
background: var(--base3);
color: var(--base00);
tab-size: 2;
outline: none;
`;
textarea.addEventListener('input', () => {
if (onChange) {
onChange(textarea.value);
}
});
// Handle Tab key
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
if (onChange) {
onChange(textarea.value);
}
}
});
container.appendChild(textarea);
editorView = { textarea };
return editorView;
}
/**
* Set editor content programmatically
*/
function setEditorContent(content) {
if (!editorView || !editorView.textarea) return;
editorView.textarea.value = content;
}
/**
* Get current editor content
*/
function getEditorContent() {
if (!editorView || !editorView.textarea) return '';
return editorView.textarea.value;
}
/**
* Highlight error line in editor (simplified - just scroll to line)
*/
function highlightErrorLine(lineNumber) {
if (!editorView || !editorView.textarea || !lineNumber) return;
const textarea = editorView.textarea;
const lines = textarea.value.split('\n');
// Calculate character position of error line
let charPos = 0;
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
charPos += lines[i].length + 1; // +1 for newline
}
// Select the error line
textarea.focus();
textarea.setSelectionRange(charPos, charPos + (lines[lineNumber - 1]?.length || 0));
}
/**
* Clear error line marker
*/
function clearErrorLine() {
if (!editorView || !editorView.textarea) return;
// No-op for textarea
}
/**
* Focus the editor
*/
function focusEditor() {
if (editorView && editorView.textarea) {
editorView.textarea.focus();
}
}
/**
* Switch to dark theme (handled by CSS)
*/
function setEditorTheme(isDark) {
// No-op - CSS variables handle this
}
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shadertoy Live Runner — Solarized Light</title>
<link rel="stylesheet" href="style.css" />
<!-- Temporarily use textarea instead of CodeMirror -->
<script>
// Minimal editor API using textarea
window.CodeMirror = {
EditorState: {},
EditorView: {},
basicSetup: [],
cpp: () => [],
keymap: { of: () => [] },
Decoration: {},
StateField: {
define: (opts) => ({ init: opts.create })
},
StateEffect: {}
};
</script>
<body>
<header>
<h1>Shadertoy Live</h1>
<div class="file-controls">
<select id="shaderSelect" title="Select shader file">
<option>Loading...</option>
</select>
<button id="newShaderBtn" title="Create new shader"></button>
<button id="deleteShaderBtn" title="Delete current shader">🗑️</button>
</div>
<div class="controls">
<button id="runBtn" title="Compile and run (Ctrl/Cmd+Enter)">▶ Run</button>
<button id="saveBtn" title="Save to disk (Ctrl/Cmd+S)">💾 Save</button>
<button id="reloadBtn" title="Reload from disk (Ctrl/Cmd+R)">🔄 Reload</button>
<button id="pauseBtn" title="Pause animation">⏸ Pause</button>
<button id="restartBtn" title="Restart time (Ctrl/Cmd+T)">⏮ Restart</button>
<button id="hudBtn" title="Toggle HUD (Ctrl/Cmd+H)">👁️ HUD</button>
</div>
<div class="viewer-controls">
<div class="dropdown-container">
<button id="openViewerBtn" class="dropdown-btn" title="Open popup viewer (Ctrl/Cmd+P)">⧉ Viewer</button>
<div class="dropdown-menu" id="viewerMenu">
<button data-size="1280,720">720p (1280×720)</button>
<button data-size="1600,900">900p (1600×900)</button>
<button data-size="1920,1080">1080p (1920×1080)</button>
<button data-size="2560,1440">1440p (2560×1440)</button>
<button data-size="3840,2160">4K (3840×2160)</button>
</div>
</div>
</div>
<div class="layout-controls">
<button id="layoutSplit" class="layout-btn active" title="Split view (Ctrl/Cmd+1)"></button>
<button id="layoutCanvas" class="layout-btn" title="Canvas only (Ctrl/Cmd+2)"></button>
<button id="layoutEditor" class="layout-btn" title="Editor only (Ctrl/Cmd+3)"></button>
</div>
</header>
<div class="status-bar">
<span id="editorStatus">shader.frag</span>
<span id="viewerStatus">No shader running</span>
</div>
<main class="layout-split">
<section class="editor-pane">
<div class="editor-header">
<span id="editorFilename">shader.frag</span>
<span class="hint">Edit here or in your editor → "Reload"</span>
</div>
<!-- CodeMirror will be inserted here -->
<div id="editor"></div>
<pre id="errors" aria-live="polite"></pre>
</section>
<div id="resizer" title="Drag to resize"></div>
<section class="viewer-pane">
<canvas id="glcanvas"></canvas>
<div id="hud">
<span id="fps">— fps</span>
<span id="res">—×—</span>
<span id="time">T: 0.0s</span>
</div>
</section>
</main>
<!-- Modals -->
<div id="newShaderModal" class="modal">
<div class="modal-content">
<h2>Create New Shader</h2>
<p>Enter filename for new shader:</p>
<input type="text" id="newShaderInput" placeholder="myshader.frag" />
<div class="modal-buttons">
<button id="newShaderCreate">Create</button>
<button id="newShaderCancel">Cancel</button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<!-- Scripts in order -->
<script src="state.js"></script>
<script src="editor.js"></script>
<script src="app.js"></script>
</body>
</html>
.PHONY: serve serve-api dev help
help:
@echo "Available targets:"
@echo " serve - Start basic HTTP server on port 8007 (no file operations)"
@echo " serve-api - Start Flask API server (Ctrl+C to stop)"
@echo " dev - Start Flask API server (same as serve-api)"
@echo ""
@echo "Requirements:"
@echo " uv - Install from https://docs.astral.sh/uv/"
serve:
python -m http.server 8007
serve-api:
@echo "Installing dependencies with uv..."
@uv sync --no-dev
@echo ""
@echo "Starting API server on http://127.0.0.1:8007"
@echo "Use Ctrl+C to stop"
@echo ""
@uv run python server.py
dev:
@uv sync --no-dev
@uv run python dev.py

Implementation Plan: Enhanced Shadertoy Local Lite

Todo List

Phase 1: Server Infrastructure ✅

  • Create server.py with Flask
    • /api/shaders endpoint (list *.frag files)
    • /api/save endpoint (save file)
    • /api/create endpoint (new file)
    • /api/delete endpoint (delete file)
    • /api/rename endpoint (rename file)
    • Input validation (no path traversal)
    • CORS configuration
  • Update Makefile with serve-api target
  • Test all endpoints

Phase 2: State Management & Safeguards ✅

  • Create state.js
    • ShaderState class implementation
    • Three-state tracking (disk/editor/viewer)
    • Dirty state detection
    • Error state tracking
  • Refactor app.js compilation logic
    • Separate compileShader() from runShader()
    • Add saveShader() with broken code warning
    • Add reloadShader() logic
    • Confirmation modals
  • Add status indicators to UI

Phase 3: File Management UI ✅

  • Update index.html
    • Add shader dropdown selector
    • Add new/delete buttons
    • Add confirmation modals
  • Update app.js
    • Implement fetchShaderList()
    • Implement populateDropdown()
    • Handle shader switching with safeguards
    • Handle URL param ?shader=filename.frag
  • Style dropdown in style.css

Phase 4: 16:9 Canvas & Layout ✅

  • Modify style.css
    • Force 16:9 aspect ratio on canvas
    • Letterbox background styling
    • Resizable splitter styles
  • Add resizable splitter to index.html
  • Implement drag handler in app.js
  • Add layout mode buttons
  • Implement layout modes (split/canvas/editor only)
  • Save layout preferences to localStorage

Phase 5: CodeMirror 6 Integration ✅

  • Create editor.js
    • Import CodeMirror 6 via CDN
    • Setup EditorView with extensions
    • GLSL syntax highlighting (cpp language)
    • Error line markers
    • onChange handler
  • Update index.html with CodeMirror container
  • Create custom Solarized theme
  • Wire up to app.js

Phase 6: Popup Viewer Window ✅

  • Create viewer.html
    • Canvas element
    • HUD overlay
    • Minimal controls
  • Create viewer.js
    • WebGL rendering logic
    • BroadcastChannel listener
    • URL param handling
    • Screenshot functionality
  • Update app.js
    • Add "Open Viewer" button with presets
    • Implement openViewerWindow()
    • Setup BroadcastChannel for sync
  • Style viewer in style.css

Phase 7: Recording Features ✅

  • HUD toggle functionality
  • Time restart button
  • Screenshot button
  • Time scrubber slider (optional enhancement)
  • Recording mode (hide all UI)
  • Add controls to both main and viewer

Phase 8: Keyboard Shortcuts ✅

  • Implement keyboard handler in app.js
    • Ctrl+Enter → Run
    • Ctrl+S → Save
    • Ctrl+R → Reload
    • Ctrl+P → Open viewer
    • Ctrl+H → Toggle HUD
    • Ctrl+F → Fullscreen (optional enhancement)
    • Ctrl+T → Restart time
    • Ctrl+Shift+S → Screenshot (optional enhancement)
    • Ctrl+1/2/3 → Layout modes
  • Add keyboard shortcuts help modal (optional enhancement)

Phase 9: Theme Toggle 🔜

  • Add Solarized Dark to style.css
  • Add theme toggle button (not yet implemented)
  • Implement theme switching logic (not yet implemented)
  • Update CodeMirror theme on switch (not yet implemented)
  • Save theme preference to localStorage (not yet implemented)

Phase 10: Settings & Persistence ✅

  • Create settings modal in index.html (optional enhancement)
  • Implement settings logic
    • Canvas pixel density control (optional)
    • Letterbox color picker (optional)
    • Default popup size (optional)
    • Auto-reload interval (optional)
  • Auto-backup to localStorage
  • Recovery prompt on page load
  • Before-unload warning for unsaved changes

Phase 11: Polish & UX ✅

  • Add loading states and spinners (optional)
  • Implement toast notifications
  • Improve error parsing and display
  • Add fullscreen API support (optional)
  • Improve accessibility (ARIA labels) (optional)

Phase 12: Documentation ⏳

  • Update CLAUDE.md (in progress)
    • New architecture
    • API endpoints
    • Keyboard shortcuts
    • Safeguard system
  • Add comments to code
  • Create inline documentation

Overview

Transform the minimal shader editor into a full-featured development environment with:

  • Multi-file management - Dropdown selector for *.frag files
  • Safeguarded workflow - Broken code doesn't break viewer
  • 16:9 aspect ratio canvas - With letterboxing
  • Popup viewer - For OBS recording (720p/900p/1080p/1440p/4K presets)
  • CodeMirror 6 editor - GLSL syntax highlighting
  • Recording features - HUD toggle, time controls, screenshot
  • Layout flexibility - Resizable splitter, detachable editor
  • Theme toggle - Solarized Light/Dark
  • Keyboard shortcuts - Full hotkey system

Constraint: Flat file structure (no subdirectories) - suitable for GitHub Gist


File Structure (Final)

shadertoy-local-lite/
├── index.html        # Main UI (editor + canvas)
├── viewer.html       # Popup viewer
├── app.js           # Main app logic
├── viewer.js        # Viewer logic
├── editor.js        # CodeMirror integration
├── state.js         # State management
├── style.css        # All styles
├── server.py        # Flask API server
├── Makefile         # Build commands
├── CLAUDE.md        # Documentation
├── PLAN.md          # This file
├── shader.frag      # Example shader
└── *.frag          # User-created shaders

Total: ~10-12 files in root directory (no folders)


Architecture

Three-State Safeguard System

┌──────────────┐
│  Disk State  │  ← What's saved in *.frag file
└──────┬───────┘
       │
       ↓ Load/Reload
┌──────────────┐
│ Editor State │  ← Current text in CodeMirror (may be broken)
└──────┬───────┘
       │
       ↓ Compile (only on success)
┌──────────────┐
│ Viewer State │  ← Last successfully compiled shader (always valid)
└──────────────┘

Rules:

  1. Run/Compile - Try to compile editor → Update viewer only on success
  2. Save - Write editor to disk (warn if broken)
  3. Reload - Load disk → Try compile → Update viewer only on success
  4. Viewer never shows broken shader

Component Responsibilities

state.js

  • ShaderState class tracks all three states
  • Manages dirty flags, error states
  • Provides safeguard logic

app.js

  • Main application logic
  • File management (create/delete/switch)
  • Compilation pipeline
  • Viewer window management
  • Keyboard shortcuts
  • Settings persistence

editor.js

  • CodeMirror 6 setup and configuration
  • GLSL syntax highlighting
  • Error line markers
  • Theme management

viewer.js

  • Standalone viewer logic
  • BroadcastChannel listener for shader updates
  • Screenshot/recording features
  • HUD display

server.py

  • Flask API for file operations
  • List/create/save/delete/rename endpoints
  • Input validation

Implementation Details

Phase 1: Server Infrastructure

server.py

from flask import Flask, send_from_directory, jsonify, request
from flask_cors import CORS
import glob
import os
import re

app = Flask(__name__)
CORS(app)

def validate_filename(filename):
    """Ensure filename is safe and ends with .frag"""
    if not filename.endswith('.frag'):
        return False
    if '/' in filename or '\\' in filename or '..' in filename:
        return False
    return re.match(r'^[\w\-\.]+\.frag$', filename) is not None

@app.route('/api/shaders')
def list_shaders():
    """List all *.frag files in current directory"""
    files = sorted(glob.glob('*.frag'))
    return jsonify({'shaders': files})

@app.route('/api/save', methods=['POST'])
def save_shader():
    """Save shader content to file"""
    data = request.json
    filename = data.get('filename')
    content = data.get('content')

    if not validate_filename(filename):
        return jsonify({'error': 'Invalid filename'}), 400

    with open(filename, 'w', encoding='utf-8') as f:
        f.write(content)

    return jsonify({'success': True, 'filename': filename})

@app.route('/api/create', methods=['POST'])
def create_shader():
    """Create new shader file with template"""
    data = request.json
    filename = data.get('filename')

    if not validate_filename(filename):
        return jsonify({'error': 'Invalid filename'}), 400

    if os.path.exists(filename):
        return jsonify({'error': 'File already exists'}), 400

    template = data.get('content', '''void mainImage(out vec4 fragColor, in vec2 fragCoord){
    vec2 uv = fragCoord / iResolution.xy;
    fragColor = vec4(uv, 0.5, 1.0);
}''')

    with open(filename, 'w', encoding='utf-8') as f:
        f.write(template)

    return jsonify({'success': True, 'filename': filename})

@app.route('/api/delete', methods=['POST'])
def delete_shader():
    """Delete shader file"""
    filename = request.json.get('filename')

    if not validate_filename(filename):
        return jsonify({'error': 'Invalid filename'}), 400

    if not os.path.exists(filename):
        return jsonify({'error': 'File not found'}), 404

    os.remove(filename)
    return jsonify({'success': True})

@app.route('/api/rename', methods=['POST'])
def rename_shader():
    """Rename shader file"""
    old_name = request.json.get('oldName')
    new_name = request.json.get('newName')

    if not (validate_filename(old_name) and validate_filename(new_name)):
        return jsonify({'error': 'Invalid filename'}), 400

    if not os.path.exists(old_name):
        return jsonify({'error': 'File not found'}), 404

    if os.path.exists(new_name):
        return jsonify({'error': 'File already exists'}), 400

    os.rename(old_name, new_name)
    return jsonify({'success': True, 'filename': new_name})

@app.route('/<path:path>')
def serve_file(path):
    """Serve static files"""
    return send_from_directory('.', path)

@app.route('/')
def index():
    """Serve index.html"""
    return send_from_directory('.', 'index.html')

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8007, debug=True)

Makefile

.PHONY: serve serve-api help

help:
	@echo "Available targets:"
	@echo "  serve      - Start basic HTTP server on port 8007"
	@echo "  serve-api  - Start Flask API server on port 8007 (required for file operations)"

serve:
	python -m http.server 8007

serve-api:
	@echo "Installing Flask if needed..."
	@pip install flask flask-cors 2>/dev/null || pip3 install flask flask-cors 2>/dev/null
	@echo "Starting API server on http://127.0.0.1:8007"
	python server.py

Phase 2: State Management

state.js

class ShaderState {
    constructor() {
        this.currentFile = 'shader.frag';
        this.editorContent = '';
        this.diskContent = '';
        this.viewerShader = '';
        this.viewerFilename = '';
        this.isDirty = false;
        this.hasErrors = false;
        this.errorMessage = '';
        this.lastCompileSuccess = null;
    }

    setEditorContent(content) {
        this.editorContent = content;
        this.updateDirtyState();
    }

    setDiskContent(content) {
        this.diskContent = content;
        this.updateDirtyState();
    }

    updateDirtyState() {
        this.isDirty = this.editorContent !== this.diskContent;
    }

    setCompileSuccess(shaderCode) {
        this.hasErrors = false;
        this.errorMessage = '';
        this.viewerShader = shaderCode;
        this.viewerFilename = this.currentFile;
        this.lastCompileSuccess = Date.now();
    }

    setCompileError(error) {
        this.hasErrors = true;
        this.errorMessage = error;
    }

    getStatusText() {
        let status = this.currentFile;
        if (this.isDirty) status += ' ●';

        let viewerStatus = '';
        if (this.viewerFilename) {
            if (this.viewerFilename === this.currentFile && !this.isDirty && !this.hasErrors) {
                viewerStatus = `✓ Running: ${this.viewerFilename}`;
            } else {
                const age = Math.floor((Date.now() - this.lastCompileSuccess) / 1000);
                viewerStatus = `⚠️ Running: ${this.viewerFilename} (${age}s old)`;
            }
        }

        return { editorStatus: status, viewerStatus };
    }

    canSwitchFile() {
        if (!this.isDirty) return true;
        return confirm(`You have unsaved changes in ${this.currentFile}.\n\nSwitch anyway?`);
    }

    shouldSaveBrokenCode() {
        if (!this.hasErrors) return true;
        return confirm(`⚠️ Shader has compilation errors:\n\n${this.errorMessage}\n\nSave broken code anyway?`);
    }

    async loadFromDisk(filename) {
        try {
            const res = await fetch(filename, { cache: 'no-store' });
            const content = await res.text();
            this.currentFile = filename;
            this.diskContent = content;
            this.editorContent = content;
            this.isDirty = false;
            return content;
        } catch (e) {
            throw new Error(`Failed to load ${filename}: ${e.message}`);
        }
    }

    async saveToDisk() {
        if (this.hasErrors && !this.shouldSaveBrokenCode()) {
            return false;
        }

        try {
            const res = await fetch('/api/save', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    filename: this.currentFile,
                    content: this.editorContent
                })
            });

            if (!res.ok) throw new Error('Save failed');

            this.diskContent = this.editorContent;
            this.isDirty = false;
            return true;
        } catch (e) {
            throw new Error(`Failed to save: ${e.message}`);
        }
    }
}

Phase 3-12: Detailed Implementation

(Continue with detailed code for each phase...)


Testing Checklist

Server API

  • /api/shaders returns list of .frag files
  • /api/save writes file correctly
  • /api/create creates new file with template
  • /api/delete removes file
  • /api/rename renames file
  • Validation rejects invalid filenames
  • Path traversal attempts blocked

File Management

  • Dropdown populates with all .frag files
  • Switch between shaders works
  • Create new shader prompts for filename
  • Delete confirms and removes file
  • Unsaved changes warning on switch

Safeguard System

  • Compile success updates viewer
  • Compile failure keeps old viewer running
  • Broken code warning on save
  • Can save broken code after confirmation
  • Status indicators show correct state
  • Dirty flag (●) appears when edited

Canvas & Layout

  • Canvas enforces 16:9 aspect ratio
  • Letterboxing appears correctly
  • Resizable splitter works
  • Layout modes switch correctly
  • Layout preferences persist

Editor

  • CodeMirror syntax highlighting works
  • Error lines highlighted in red
  • Line numbers visible
  • Ctrl+Enter compiles from editor
  • Editor content syncs with state

Popup Viewer

  • Opens at correct preset size
  • Receives shader updates via BroadcastChannel
  • URL params set size correctly
  • HUD displays FPS and resolution
  • Screenshot downloads PNG

Recording Features

  • HUD toggle shows/hides overlay
  • Time restart resets to t=0
  • Screenshot captures current frame
  • Recording mode hides all UI
  • Escape exits recording mode

Keyboard Shortcuts

  • Ctrl+Enter → Run
  • Ctrl+S → Save
  • Ctrl+R → Reload
  • Ctrl+P → Open viewer
  • Ctrl+H → Toggle HUD
  • Ctrl+F → Fullscreen
  • Ctrl+T → Restart time
  • Ctrl+Shift+S → Screenshot
  • Ctrl+1/2/3 → Layout modes

Theme & Settings

  • Theme toggle switches light/dark
  • Theme persists across reload
  • Settings modal opens
  • Settings save to localStorage
  • Settings apply correctly

Persistence

  • Auto-backup saves every 30s
  • Recovery prompt on page load
  • Unsaved changes warning on unload
  • Layout/theme/settings persist

Known Limitations

  1. No folder support - Flat structure only (gist constraint)
  2. No iChannel textures - Not implemented in minimal runner
  3. Browser limitations - File operations require server
  4. Single instance sync - BroadcastChannel works same-origin only

Future Enhancements

  • Shader library/presets gallery
  • URL hash sharing (compressed shader code)
  • GIF export via gif.js
  • WebM recording via MediaRecorder
  • Image sequence export
  • File watcher for external editor changes
  • GLSL autocomplete
  • Multiple shader tabs
  • Shader debugging tools
[project]
name = "shadertoy-local-lite"
version = "1.0.0"
description = "Enhanced WebGL2 shader editor with file management, safeguards, and recording features"
readme = "PLAN.md"
requires-python = ">=3.8"
dependencies = [
"flask>=3.0.0",
"flask-cors>=4.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
]
#!/usr/bin/env python3
"""
Flask API server for Shadertoy Local Lite
Provides file management endpoints for *.frag shader files
"""
from flask import Flask, send_from_directory, jsonify, request
from flask_cors import CORS
import glob
import os
import re
import logging
import sys
# Configure logging to both file and stdout
logger = logging.getLogger()
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
# File handler
file_handler = logging.FileHandler('server.log', mode='a', encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
app = Flask(__name__)
CORS(app)
def validate_filename(filename):
"""
Ensure filename is safe and ends with .frag
Prevents path traversal and other security issues
"""
if not filename:
return False
if not filename.endswith('.frag'):
return False
if '/' in filename or '\\' in filename or '..' in filename:
return False
# Only allow alphanumeric, dash, underscore, and period
return re.match(r'^[\w\-\.]+\.frag$', filename) is not None
@app.route('/api/shaders')
def list_shaders():
"""List all *.frag files in current directory"""
try:
files = sorted(glob.glob('*.frag'))
logging.info(f"/api/shaders → {len(files)} files: {files}")
return jsonify({'shaders': files})
except Exception as e:
logging.error(f"/api/shaders failed: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/save', methods=['POST'])
def save_shader():
"""Save shader content to file"""
try:
data = request.json
filename = data.get('filename')
content = data.get('content')
if not filename or content is None:
return jsonify({'error': 'Missing filename or content'}), 400
if not validate_filename(filename):
return jsonify({'error': 'Invalid filename'}), 400
with open(filename, 'w', encoding='utf-8') as f:
f.write(content)
logging.info(f"Saved {filename} ({len(content)} bytes)")
return jsonify({'success': True, 'filename': filename})
except Exception as e:
logging.error(f"Save failed for {filename}: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/create', methods=['POST'])
def create_shader():
"""Create new shader file with template"""
try:
data = request.json
filename = data.get('filename')
if not filename:
return jsonify({'error': 'Missing filename'}), 400
if not validate_filename(filename):
return jsonify({'error': 'Invalid filename'}), 400
if os.path.exists(filename):
return jsonify({'error': 'File already exists'}), 400
template = data.get('content', '''// New shader
void mainImage(out vec4 fragColor, in vec2 fragCoord){
vec2 uv = fragCoord / iResolution.xy;
fragColor = vec4(uv, 0.5, 1.0);
}''')
with open(filename, 'w', encoding='utf-8') as f:
f.write(template)
logging.info(f"Created {filename}")
return jsonify({'success': True, 'filename': filename})
except Exception as e:
logging.error(f"Create failed for {filename}: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/delete', methods=['POST'])
def delete_shader():
"""Delete shader file"""
try:
filename = request.json.get('filename')
if not filename:
return jsonify({'error': 'Missing filename'}), 400
if not validate_filename(filename):
return jsonify({'error': 'Invalid filename'}), 400
if not os.path.exists(filename):
return jsonify({'error': 'File not found'}), 404
os.remove(filename)
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/rename', methods=['POST'])
def rename_shader():
"""Rename shader file"""
try:
old_name = request.json.get('oldName')
new_name = request.json.get('newName')
if not (old_name and new_name):
return jsonify({'error': 'Missing oldName or newName'}), 400
if not (validate_filename(old_name) and validate_filename(new_name)):
return jsonify({'error': 'Invalid filename'}), 400
if not os.path.exists(old_name):
return jsonify({'error': 'File not found'}), 404
if os.path.exists(new_name):
return jsonify({'error': 'File already exists'}), 400
os.rename(old_name, new_name)
return jsonify({'success': True, 'filename': new_name})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/<path:path>')
def serve_file(path):
"""Serve static files"""
try:
return send_from_directory('.', path)
except Exception as e:
return jsonify({'error': 'File not found'}), 404
@app.route('/')
def index():
"""Serve index.html"""
return send_from_directory('.', 'index.html')
if __name__ == '__main__':
logging.info("=" * 60)
logging.info("Shadertoy Local Lite - API Server")
logging.info("=" * 60)
logging.info("Server starting on http://127.0.0.1:8007")
logging.info("Press Ctrl+C to stop")
logging.info("=" * 60)
app.run(host='127.0.0.1', port=8007, debug=True)
// Shadertoy-style: expects mainImage(out vec4 fragColor, in vec2 fragCoord)
void mainImage(out vec4 fragColor, in vec2 fragCoord){
vec2 uv = fragCoord / iResolution.xy;
float t = iTime;
vec3 col = 0.55 + 0.45*cos(t + vec3(0.0,2.0,4.0) + uv.xyx*3.14159);
// simple vignette
float v = smoothstep(1.2, 0.0, length(uv*2. - 1.));
fragColor = vec4(col * v, 1.0);
}
/**
* ShaderState - Three-state safeguard system
* Tracks: Disk state, Editor state, Viewer state
*
* Rules:
* - Run/Compile: Try compile editor → Update viewer only on success
* - Save: Write editor to disk (warn if broken)
* - Reload: Load disk → Try compile → Update viewer only on success
* - Viewer never shows broken shader
*/
class ShaderState {
constructor() {
this.currentFile = 'shader.frag'; // Currently selected file
this.editorContent = ''; // Current editor text (may be broken)
this.diskContent = ''; // Last known disk content
this.viewerShader = ''; // Last successful compiled shader
this.viewerFilename = ''; // Which file is rendering
this.isDirty = false; // Editor differs from disk
this.hasErrors = false; // Current compilation status
this.errorMessage = ''; // Compilation error details
this.errorLine = null; // Error line number (if parsed)
this.lastCompileSuccess = null; // Timestamp of last successful compile
this.autoBackupInterval = null; // Auto-backup timer
}
/**
* Update editor content and check dirty state
*/
setEditorContent(content) {
this.editorContent = content;
this.updateDirtyState();
}
/**
* Update disk content (after load/save) and check dirty state
*/
setDiskContent(content) {
this.diskContent = content;
this.updateDirtyState();
}
/**
* Check if editor content differs from disk
*/
updateDirtyState() {
this.isDirty = this.editorContent !== this.diskContent;
}
/**
* Mark compilation as successful and update viewer shader
*/
setCompileSuccess(shaderCode) {
this.hasErrors = false;
this.errorMessage = '';
this.errorLine = null;
this.viewerShader = shaderCode;
this.viewerFilename = this.currentFile;
this.lastCompileSuccess = Date.now();
}
/**
* Mark compilation as failed with error details
*/
setCompileError(error) {
this.hasErrors = true;
this.errorMessage = error;
this.errorLine = this.parseErrorLine(error);
}
/**
* Parse GLSL error message to extract line number
* Example: "ERROR: 0:23: 'foo' : undeclared identifier"
*/
parseErrorLine(error) {
const match = error.match(/ERROR:\s*\d+:(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Get status text for UI display
*/
getStatusText() {
// Editor status: filename with dirty indicator
let editorStatus = this.currentFile;
if (this.isDirty) {
editorStatus += ' ●';
}
// Viewer status: what's currently rendering
let viewerStatus = '';
if (this.viewerFilename) {
if (this.viewerFilename === this.currentFile && !this.isDirty && !this.hasErrors) {
viewerStatus = `✓ Running: ${this.viewerFilename}`;
} else {
const age = Math.floor((Date.now() - (this.lastCompileSuccess || 0)) / 1000);
if (age > 0) {
viewerStatus = `⚠️ Running: ${this.viewerFilename} (${age}s old)`;
} else {
viewerStatus = `⚠️ Running: ${this.viewerFilename}`;
}
}
} else {
viewerStatus = 'No shader running';
}
return { editorStatus, viewerStatus };
}
/**
* Check if it's safe to switch files (handles unsaved changes)
*/
canSwitchFile() {
if (!this.isDirty) return true;
return confirm(
`You have unsaved changes in ${this.currentFile}.\n\n` +
`Switch anyway? (Changes will be lost)`
);
}
/**
* Check if user wants to save broken code
*/
shouldSaveBrokenCode() {
if (!this.hasErrors) return true;
return confirm(
`⚠️ Shader has compilation errors:\n\n` +
`${this.errorMessage}\n\n` +
`Save broken code anyway?`
);
}
/**
* Load shader from disk
*/
async loadFromDisk(filename) {
try {
const res = await fetch(filename, { cache: 'no-store' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const content = await res.text();
this.currentFile = filename;
this.diskContent = content;
this.editorContent = content;
this.isDirty = false;
return content;
} catch (e) {
throw new Error(`Failed to load ${filename}: ${e.message}`);
}
}
/**
* Save current editor content to disk via API
*/
async saveToDisk() {
// Check if user wants to save broken code
if (this.hasErrors && !this.shouldSaveBrokenCode()) {
return false;
}
try {
const res = await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: this.currentFile,
content: this.editorContent
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Save failed');
}
// Update disk content to match editor
this.diskContent = this.editorContent;
this.isDirty = false;
return true;
} catch (e) {
throw new Error(`Failed to save: ${e.message}`);
}
}
/**
* Start auto-backup to localStorage
*/
startAutoBackup(interval = 30000) {
this.stopAutoBackup();
this.autoBackupInterval = setInterval(() => {
if (this.isDirty) {
this.backupToLocalStorage();
}
}, interval);
}
/**
* Stop auto-backup
*/
stopAutoBackup() {
if (this.autoBackupInterval) {
clearInterval(this.autoBackupInterval);
this.autoBackupInterval = null;
}
}
/**
* Backup current editor content to localStorage
*/
backupToLocalStorage() {
try {
const backup = {
filename: this.currentFile,
content: this.editorContent,
timestamp: Date.now()
};
localStorage.setItem('shader_backup', JSON.stringify(backup));
} catch (e) {
console.error('Failed to backup to localStorage:', e);
}
}
/**
* Check if there's a recovery backup available
*/
hasRecoveryBackup() {
try {
const backup = localStorage.getItem('shader_backup');
if (!backup) return null;
const data = JSON.parse(backup);
// Check if backup is newer than last compile success
if (data.timestamp > (this.lastCompileSuccess || 0)) {
return data;
}
return null;
} catch (e) {
return null;
}
}
/**
* Restore from localStorage backup
*/
restoreFromBackup(backup) {
this.currentFile = backup.filename;
this.editorContent = backup.content;
this.diskContent = ''; // Unknown disk state
this.isDirty = true; // Assume dirty since restored
}
/**
* Clear localStorage backup
*/
clearBackup() {
try {
localStorage.removeItem('shader_backup');
} catch (e) {
console.error('Failed to clear backup:', e);
}
}
/**
* Get settings from localStorage
*/
static loadSettings() {
try {
const settings = localStorage.getItem('shader_settings');
return settings ? JSON.parse(settings) : null;
} catch (e) {
return null;
}
}
/**
* Save settings to localStorage
*/
static saveSettings(settings) {
try {
localStorage.setItem('shader_settings', JSON.stringify(settings));
return true;
} catch (e) {
console.error('Failed to save settings:', e);
return false;
}
}
}
/* Solarized Light/Dark palette */
:root{
--base03:#002b36; --base02:#073642; --base01:#586e75; --base00:#657b83;
--base0:#839496; --base1:#93a1a1; --base2:#eee8d5; --base3:#fdf6e3;
--yellow:#b58900; --orange:#cb4b16; --red:#dc322f; --magenta:#d33682;
--violet:#6c71c4; --blue:#268bd2; --cyan:#2aa198; --green:#859900;
}
/* Dark theme colors */
body.theme-dark {
--base03:#fdf6e3; --base02:#eee8d5; --base01:#93a1a1; --base00:#839496;
--base0:#657b83; --base1:#586e75; --base2:#073642; --base3:#002b36;
}
*{box-sizing:border-box}
html,body{
height:100vh;
margin:0;
overflow:hidden;
background:var(--base3);
color:var(--base00);
font:15px/1.4 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;
}
/* === Header === */
header{
display:flex;
align-items:center;
gap:1rem;
padding:.6rem .9rem;
border-bottom:2px solid var(--orange);
background:var(--orange);
color:var(--base3);
z-index:100;
flex-wrap:wrap;
flex-shrink:0;
}
h1{
margin:0;
font-size:1rem;
color:var(--base3);
letter-spacing:.02em;
font-weight:600;
}
/* File controls */
.file-controls{
display:flex;
gap:.3rem;
align-items:center;
}
#shaderSelect{
appearance:none;
border:1px solid rgba(255,255,255,.3);
background:rgba(255,255,255,.2);
color:var(--base3);
padding:.35rem .6rem;
border-radius:.4rem;
cursor:pointer;
font-family:inherit;
font-size:14px;
min-width:150px;
}
#shaderSelect:hover{background:rgba(255,255,255,.3)}
#shaderSelect:focus{outline:2px solid var(--base3);outline-offset:2px}
/* Control buttons */
.controls, .viewer-controls, .layout-controls{
display:flex;
gap:.3rem;
align-items:center;
}
button{
appearance:none;
border:1px solid rgba(255,255,255,.3);
background:rgba(255,255,255,.2);
color:var(--base3);
padding:.35rem .6rem;
border-radius:.4rem;
cursor:pointer;
font-family:inherit;
font-size:13px;
white-space:nowrap;
}
button:hover{background:rgba(255,255,255,.3)}
button:active{background:rgba(255,255,255,.4)}
button:disabled{opacity:0.5;cursor:not-allowed}
button.active{background:rgba(255,255,255,.4);border-color:var(--base3)}
.layout-btn{
width:2rem;
padding:.35rem .3rem;
text-align:center;
}
.layout-btn.active{
background:rgba(255,255,255,.5);
border-color:var(--base3);
color:var(--base3);
font-weight:600;
}
/* Dropdown menu for viewer sizes */
.dropdown-container{
position:relative;
}
.dropdown-btn::after{
content:'▾';
margin-left:.3rem;
font-size:10px;
}
.dropdown-menu{
display:none;
position:absolute;
top:100%;
right:0;
margin-top:.2rem;
background:var(--base3);
border:1px solid var(--base2);
border-radius:.4rem;
box-shadow:0 2px 8px rgba(0,0,0,.15);
min-width:150px;
z-index:1000;
}
.dropdown-menu.show{display:block}
.dropdown-menu button{
display:block;
width:100%;
text-align:left;
border:none;
border-radius:0;
padding:.4rem .6rem;
}
.dropdown-menu button:first-child{border-radius:.4rem .4rem 0 0}
.dropdown-menu button:last-child{border-radius:0 0 .4rem .4rem}
.dropdown-menu button:hover{background:var(--base2)}
/* Status bar */
.status-bar{
display:flex;
justify-content:space-between;
padding:.3rem .9rem;
background:var(--base2);
border-bottom:1px solid var(--base2);
font-size:13px;
color:var(--base01);
flex-shrink:0;
}
#editorStatus{font-weight:500}
#viewerStatus{color:var(--base1)}
/* === Main Layout === */
body{
display:flex;
flex-direction:column;
}
main{
display:flex;
flex-direction:row;
flex:1;
min-height:0;
overflow:hidden;
}
/* Layout modes */
main.layout-canvas .editor-pane,
main.layout-canvas #resizer{
display:none;
}
main.layout-editor .viewer-pane,
main.layout-editor #resizer{
display:none;
}
/* === Editor Pane === */
.editor-pane{
display:flex;
flex-direction:column;
flex-basis:480px;
min-width:360px;
max-width:50%;
border-right:1px solid var(--base2);
overflow:hidden;
}
.editor-header{
display:flex;
justify-content:space-between;
padding:.4rem .6rem;
background:var(--base2);
color:var(--base01);
border-bottom:1px solid var(--base2);
font-size:13px;
}
.editor-header .hint{
color:var(--base1);
font-size:.85em;
}
#editor{
flex:1;
min-height:0;
overflow:hidden;
background:var(--base3);
}
/* CodeMirror editor styling handled in editor.js */
#errors{
margin:0;
padding:.4rem .6rem;
border-top:1px solid var(--base2);
background:#fff8f8;
color:var(--red);
white-space:pre-wrap;
font-size:13px;
line-height:1.4;
overflow:hidden;
text-overflow:ellipsis;
}
#errors:empty{
background:transparent;
}
/* === Resizer === */
#resizer{
width:4px;
flex-shrink:0;
background:var(--base2);
cursor:col-resize;
position:relative;
}
#resizer:hover{
background:var(--base1);
}
#resizer:active{
background:var(--blue);
}
/* === Viewer Pane === */
.viewer-pane{
position:relative;
flex:1;
min-width:0;
background:#000;
display:flex;
align-items:center;
justify-content:center;
overflow:hidden;
}
#glcanvas{
display:block;
aspect-ratio:16/9;
max-width:100%;
max-height:100%;
margin:auto;
}
#hud{
position:absolute;
bottom:.6rem;
left:.6rem;
background:rgba(253,246,227,.9);
border:1px solid var(--base2);
border-radius:.35rem;
padding:.3rem .5rem;
color:var(--base01);
display:flex;
gap:.6rem;
font-size:13px;
z-index:10;
}
#hud.hidden{
display:none;
}
/* === Modals === */
.modal{
display:none;
position:fixed;
top:0;
left:0;
right:0;
bottom:0;
background:rgba(0,0,0,.5);
z-index:1000;
align-items:center;
justify-content:center;
}
.modal.show{
display:flex;
}
.modal-content{
background:var(--base3);
border:1px solid var(--base2);
border-radius:.5rem;
padding:1.5rem;
max-width:400px;
width:90%;
}
.modal-content h2{
margin:0 0 1rem;
font-size:1.2rem;
color:var(--base01);
}
.modal-content p{
margin:0 0 .5rem;
color:var(--base00);
}
.modal-content input{
width:100%;
padding:.5rem;
border:1px solid var(--base2);
border-radius:.3rem;
background:var(--base3);
color:var(--base00);
font-family:inherit;
font-size:14px;
margin-bottom:1rem;
}
.modal-content input:focus{
outline:2px solid var(--blue);
outline-offset:2px;
}
.modal-buttons{
display:flex;
gap:.5rem;
justify-content:flex-end;
}
/* === Toast Notifications === */
.toast{
position:fixed;
bottom:2rem;
right:2rem;
background:var(--base01);
color:var(--base3);
padding:.75rem 1rem;
border-radius:.4rem;
font-size:14px;
box-shadow:0 2px 12px rgba(0,0,0,.3);
z-index:2000;
opacity:0;
transform:translateY(1rem);
transition:opacity .3s, transform .3s;
pointer-events:none;
}
.toast.show{
opacity:1;
transform:translateY(0);
}
.toast.success{background:var(--green)}
.toast.error{background:var(--red)}
.toast.warning{background:var(--orange)}
/* === Responsive === */
@media (max-width: 900px){
header{
font-size:13px;
}
h1{font-size:.9rem}
button{
padding:.3rem .5rem;
font-size:12px;
}
main.layout-split{
flex-direction:column;
}
.editor-pane{
flex-basis:45%;
min-width:0;
max-width:100%;
border-right:0;
border-bottom:1px solid var(--base2);
}
.viewer-pane{
flex-basis:55%;
}
#resizer{
width:100%;
height:4px;
cursor:row-resize;
}
}
@media (max-width: 600px){
.controls{
flex-wrap:wrap;
}
.hint{
display:none;
}
}
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shadertoy Viewer</title>
<style>
/* Solarized Light palette */
:root{
--base03:#002b36; --base02:#073642; --base01:#586e75; --base00:#657b83;
--base0:#839496; --base1:#93a1a1; --base2:#eee8d5; --base3:#fdf6e3;
--yellow:#b58900; --orange:#cb4b16; --red:#dc322f; --magenta:#d33682;
--violet:#6c71c4; --blue:#268bd2; --cyan:#2aa198; --green:#859900;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#000;overflow:hidden}
#glcanvas{
width:100%;height:100%;display:block;
aspect-ratio:16/9;
max-width:100vw;max-height:100vh;
margin:auto;
}
#hud{
position:absolute;bottom:.6rem;left:.6rem;
background:rgba(253,246,227,.9);
border:1px solid var(--base2);border-radius:.35rem;
padding:.3rem .5rem;color:var(--base01);
display:none;gap:.6rem;font:13px/1.4 ui-monospace,Consolas,monospace;
z-index:100;
}
#hud.visible{display:flex}
#controls{
position:absolute;top:.6rem;right:.6rem;
background:rgba(253,246,227,.9);
border:1px solid var(--base2);border-radius:.35rem;
padding:.3rem;
display:none;gap:.3rem;
font:13px/1.4 ui-monospace,Consolas,monospace;
z-index:100;
}
#controls.visible{display:flex}
#controls button{
appearance:none;border:1px solid var(--base2);
background:var(--base3);color:var(--base00);
padding:.25rem .4rem;border-radius:.3rem;
cursor:pointer;font-size:12px;
}
#controls button:hover{border-color:var(--base1);background:var(--base2)}
#controls button:active{background:var(--base1)}
#controls button.active{color:var(--red);border-color:var(--red)}
#error{
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
background:rgba(220,50,47,.95);color:#fff;
padding:1rem;border-radius:.5rem;
max-width:80%;text-align:center;
font:14px/1.4 ui-monospace,Consolas,monospace;
display:none;
z-index:200;
}
#error.show{display:block}
</style>
<body>
<canvas id="glcanvas"></canvas>
<div id="hud">
<span id="fps">— fps</span>
<span id="res">—×—</span>
<span id="time">T: 0.0s</span>
</div>
<div id="controls">
<button id="pauseBtn" title="Pause/Resume (Space)">⏸ Pause</button>
<button id="restartBtn" title="Restart time (R)">⏮ Restart</button>
<button id="hudBtn" title="Toggle Overlays (H)">👁️ Show</button>
<button id="screenshotBtn" title="Screenshot (S)">📸</button>
</div>
<div id="error"></div>
<script src="viewer.js"></script>
</body>
</html>
/**
* Viewer.js - Standalone shader viewer for OBS recording
* Minimal UI, clean canvas, BroadcastChannel sync with main window
*/
// DOM elements
const canvas = document.getElementById('glcanvas');
const fpsEl = document.getElementById('fps');
const resEl = document.getElementById('res');
const timeEl = document.getElementById('time');
const hudEl = document.getElementById('hud');
const controlsEl = document.getElementById('controls');
const errorEl = document.getElementById('error');
const pauseBtn = document.getElementById('pauseBtn');
const restartBtn = document.getElementById('restartBtn');
const hudBtn = document.getElementById('hudBtn');
const screenshotBtn = document.getElementById('screenshotBtn');
// WebGL setup
const gl = canvas.getContext('webgl2', {
antialias: true,
preserveDrawingBuffer: true,
alpha: false
});
if (!gl) {
showError('WebGL2 not available');
throw new Error('WebGL2 required');
}
// State
let program = null;
let raf = 0;
let paused = false;
let startTime = performance.now() / 1000;
let prevTime = startTime;
let frame = 0;
let frameStart = startTime;
const mouse = [0, 0, 0, 0];
const DPR = Math.min(window.devicePixelRatio || 1, 2);
// Settings (clean mode by default for OBS)
let hudVisible = false;
let currentShaderCode = '';
// BroadcastChannel for sync with main window
let broadcastChannel = null;
try {
broadcastChannel = new BroadcastChannel('shadertoy-sync');
broadcastChannel.onmessage = (e) => {
if (e.data.type === 'shader-update') {
loadShaderCode(e.data.code);
}
};
} catch (e) {
console.warn('BroadcastChannel not available:', e);
}
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search);
const shaderFile = urlParams.get('shader') || 'shader.frag';
const targetWidth = parseInt(urlParams.get('w')) || 0;
const targetHeight = parseInt(urlParams.get('h')) || 0;
// Set window size if specified
if (targetWidth && targetHeight) {
window.resizeTo(targetWidth, targetHeight);
}
// GL helper functions
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) || '';
gl.deleteShader(sh);
throw new Error(log);
}
return sh;
}
function link(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(p) || '';
gl.deleteProgram(p);
throw new Error(log);
}
return p;
}
// Vertex shader (fullscreen triangle)
const vsSrc = `#version 300 es
precision highp float;
const vec2 verts[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));
void main(){ gl_Position = vec4(verts[gl_VertexID], 0., 1.); }
`;
// Build fragment shader with Shadertoy wrapper
function buildFrag(user) {
const hasMain = /\bvoid\s+main\s*\(/.test(user);
const header = `#version 300 es
precision highp float;
out vec4 outColor;
uniform vec3 iResolution;
uniform float iTime;
uniform float iTimeDelta;
uniform int iFrame;
uniform float iFrameRate;
uniform vec4 iMouse;
uniform vec4 iDate;
// (No iChannel* in this minimal runner)
`;
if (hasMain) {
return header + '\n' + user.replace('gl_FragColor', 'outColor');
}
// Assume mainImage style
return header + `
${user}
void main(){
vec4 c = vec4(0.0);
mainImage(c, gl_FragCoord.xy);
outColor = c;
}`;
}
// Create WebGL program from shader code
function createProgram(shaderCode) {
const fsSrc = buildFrag(shaderCode);
const vs = compile(gl.VERTEX_SHADER, vsSrc);
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
const p = link(vs, fs);
gl.deleteShader(vs);
gl.deleteShader(fs);
return p;
}
// Uniform locations
let u = {};
function bindUniforms() {
u.res = gl.getUniformLocation(program, 'iResolution');
u.time = gl.getUniformLocation(program, 'iTime');
u.dt = gl.getUniformLocation(program, 'iTimeDelta');
u.ifr = gl.getUniformLocation(program, 'iFrame');
u.fps = gl.getUniformLocation(program, 'iFrameRate');
u.mouse = gl.getUniformLocation(program, 'iMouse');
u.date = gl.getUniformLocation(program, 'iDate');
}
function setDateUniform(t) {
const d = new Date();
const secs = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() / 1000;
gl.uniform4f(u.date, d.getFullYear(), d.getMonth() + 1, d.getDate(), secs);
}
// Resize canvas
function resize() {
const w = Math.floor(canvas.clientWidth * DPR);
const h = Math.floor(canvas.clientHeight * DPR);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
resEl.textContent = `${w}×${h}`;
}
}
// Draw frame
function draw(now) {
const t = now / 1000;
const dt = Math.max(0.00001, t - prevTime);
gl.uniform3f(u.res, canvas.width, canvas.height, 1.0);
gl.uniform1f(u.time, t - startTime);
gl.uniform1f(u.dt, dt);
gl.uniform1i(u.ifr, frame++);
gl.uniform1f(u.fps, 1.0 / dt);
gl.uniform4f(u.mouse, mouse[0], mouse[1], mouse[2], mouse[3]);
setDateUniform(t);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// Update HUD
if (hudVisible && t - frameStart > 0.25) {
fpsEl.textContent = `${(1 / dt).toFixed(1)} fps`;
timeEl.textContent = `T: ${(t - startTime).toFixed(1)}s`;
frameStart = t;
}
prevTime = t;
}
// Render loop
function tick() {
if (paused) return;
resize();
try {
gl.useProgram(program);
draw(performance.now());
hideError();
} catch (e) {
showError(e.message);
}
raf = requestAnimationFrame(tick);
}
// Start rendering with shader code
function run() {
cancelAnimationFrame(raf);
try {
program = createProgram(currentShaderCode);
bindUniforms();
hideError();
prevTime = performance.now() / 1000;
startTime = prevTime;
frame = 0;
paused = false;
pauseBtn.classList.remove('active');
pauseBtn.textContent = '⏸ Pause';
tick();
} catch (e) {
showError('Compilation error:\n' + e.message);
}
}
// Load shader from code
function loadShaderCode(code) {
currentShaderCode = code;
run();
}
// Load shader from file
async function loadShaderFile(filename) {
try {
const res = await fetch(filename, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const code = await res.text();
loadShaderCode(code);
} catch (e) {
showError(`Failed to load ${filename}:\n${e.message}`);
}
}
// UI functions
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.add('show');
}
function hideError() {
errorEl.classList.remove('show');
}
function togglePause() {
paused = !paused;
pauseBtn.classList.toggle('active', paused);
pauseBtn.textContent = paused ? '▶ Resume' : '⏸ Pause';
if (!paused) tick();
}
function restartTime() {
startTime = performance.now() / 1000;
prevTime = startTime;
frame = 0;
frameStart = startTime;
}
function toggleHud() {
hudVisible = !hudVisible;
hudEl.classList.toggle('visible', hudVisible);
controlsEl.classList.toggle('visible', hudVisible);
hudBtn.classList.toggle('active', !hudVisible);
}
function takeScreenshot() {
try {
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `shader-${shaderFile}-frame-${frame.toString().padStart(6, '0')}.png`;
a.click();
URL.revokeObjectURL(url);
});
} catch (e) {
showError('Screenshot failed: ' + e.message);
}
}
// Event listeners
window.addEventListener('resize', resize);
['mousemove', 'mousedown', 'mouseup'].forEach(evt => {
canvas.addEventListener(evt, e => {
const r = canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * DPR;
const y = (r.height - (e.clientY - r.top)) * DPR;
if (evt === 'mousedown') { mouse[2] = x; mouse[3] = y; }
if (evt !== 'mouseup') { mouse[0] = x; mouse[1] = y; }
});
});
document.addEventListener('keydown', e => {
if (e.key === ' ') { e.preventDefault(); togglePause(); }
if (e.key === 'r' || e.key === 'R') { restartTime(); }
if (e.key === 'h' || e.key === 'H') { toggleHud(); }
if (e.key === 's' || e.key === 'S') { takeScreenshot(); }
});
pauseBtn.onclick = togglePause;
restartBtn.onclick = restartTime;
hudBtn.onclick = toggleHud;
screenshotBtn.onclick = takeScreenshot;
// Initialize
function init() {
requestAnimationFrame(() => {
const ro = new ResizeObserver(() => resize());
ro.observe(canvas);
resize();
});
loadShaderFile(shaderFile);
}
init();
// Notify parent we're ready
if (window.opener) {
window.opener.postMessage({ type: 'viewer-ready' }, '*');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment