pbnj

snippet #7

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PRISM · GLSL Live Editor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Syne:wght@700;900&display=swap');
:root{
  --bg:#06080d;--panel:#0b0e15;--panel2:#0f1219;
  --b1:#18202e;--b2:#1e2840;
  --a1:#ff2a6d;--a2:#05d9e8;--a3:#b537f2;--a4:#ffb800;--ag:#00ff9d;
  --tx:#b8c8e0;--dim:#3a4a60;--bright:#ddeeff;
  --mono:'JetBrains Mono',monospace;--sans:'Syne',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--tx);font-family:var(--mono);font-size:13px;}

/* LAYOUT */
#app{display:grid;grid-template-rows:44px 1fr 24px;height:100vh;}
#main{display:grid;grid-template-columns:1fr 360px;overflow:hidden;}
#left{display:flex;flex-direction:column;overflow:hidden;min-width:0;}
#right{display:flex;flex-direction:column;border-left:1px solid var(--b1);overflow:hidden;background:var(--panel);}

/* TOPBAR */
#topbar{background:var(--panel);border-bottom:1px solid var(--b1);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.logo{font-family:var(--sans);font-weight:900;font-size:15px;letter-spacing:.14em;color:var(--bright);margin-right:4px;}
.logo b{color:var(--a1);}
.sep{width:1px;height:20px;background:var(--b2);margin:0 2px;}
.btn{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:.08em;padding:4px 9px;border-radius:3px;border:1px solid var(--b2);background:transparent;color:var(--tx);cursor:pointer;transition:.12s;text-transform:uppercase;white-space:nowrap;}
.btn:hover{border-color:var(--a2);color:var(--a2);}
.btn.run{background:var(--ag);border-color:var(--ag);color:#000;}
.btn.run:hover{filter:brightness(1.1);}
.btn.save{background:var(--a1);border-color:var(--a1);color:#fff;}
.btn.save:hover{filter:brightness(1.15);}
#st{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:10px;}
.dot{width:7px;height:7px;border-radius:50%;background:var(--dim);flex-shrink:0;}
.dot.ok{background:var(--ag);box-shadow:0 0 8px var(--ag);}
.dot.err{background:var(--a1);box-shadow:0 0 8px var(--a1);}
.dot.busy{background:var(--a4);animation:blink .7s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.2}}
select.btn{padding:3px 6px;}

/* CANVAS */
#cw{flex:1;position:relative;overflow:hidden;background:#000;min-height:0;}
#cv{display:block;max-width:100%;max-height:100%;}
#fps{position:absolute;top:8px;right:10px;font-size:10px;color:var(--dim);background:rgba(0,0,0,.6);padding:2px 6px;border-radius:2px;pointer-events:none;}

/* ERROR PANEL */
#ep{border-top:2px solid var(--a1);background:#080308;display:none;flex-shrink:0;max-height:120px;overflow-y:auto;}
#ep-hd{padding:3px 12px;background:rgba(255,42,109,.1);color:var(--a1);font-size:9px;font-weight:600;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
#ep-body{padding:6px 12px 8px;font-size:10px;line-height:1.8;user-select:text;-webkit-user-select:text;cursor:text;white-space:pre;overflow-x:auto;}
.el{display:block;}.el .loc{color:#ff4060;font-weight:700;margin-right:6px;cursor:pointer;}.el .loc:hover{text-decoration:underline;}
.el .msg{color:#ffaabb;}.el .src{color:#445566;font-size:9px;padding-left:14px;}

/* EDITOR */
#ew{height:260px;border-top:1px solid var(--b1);display:flex;flex-direction:column;flex-shrink:0;}
#etabs{display:flex;background:var(--panel);border-bottom:1px solid var(--b1);padding:0 8px;gap:1px;padding-top:3px;}
.etab{font-size:9px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;padding:5px 10px;cursor:pointer;color:var(--dim);border-radius:3px 3px 0 0;border:1px solid transparent;border-bottom:none;transition:.12s;}
.etab:hover{color:var(--tx);}
.etab.on{background:var(--bg);border-color:var(--b1);color:var(--a2);}
#esnips{margin-left:auto;display:flex;align-items:center;gap:4px;padding-bottom:3px;}
#eb{flex:1;position:relative;overflow:hidden;}
textarea.code{position:absolute;inset:0;width:100%;height:100%;background:var(--bg);color:#9fefdf;font-family:var(--mono);font-size:12px;line-height:1.7;border:none;outline:none;resize:none;padding:10px 12px;tab-size:2;caret-color:var(--a1);display:none;spellcheck:false;}
textarea.code.on{display:block;}
textarea.code::selection{background:rgba(5,217,232,.2);}

/* SIDEBAR SECTIONS */
.sh{padding:8px 12px;font-size:9px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);border-bottom:1px solid var(--b1);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}
.sh span{color:var(--a3);}

/* AUDIO */
#ap{flex-shrink:0;border-bottom:1px solid var(--b1);}
#wc{width:100%;height:44px;display:block;background:#000;}
.abars{display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;padding:6px 12px;}
.abw{display:flex;flex-direction:column;gap:2px;align-items:center;}
.abl{font-size:8px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;}
.ab{width:100%;height:4px;background:var(--b1);border-radius:2px;overflow:hidden;}
.abf{height:100%;width:0%;border-radius:2px;transition:width .05s;}
.fb{background:var(--a1);}.fm{background:var(--a3);}.ft{background:var(--a2);}
.abtns{display:flex;gap:5px;padding:0 12px 7px;}

/* UNIFORMS */
#up{flex-shrink:0;border-bottom:1px solid var(--b1);}
.ur{display:grid;grid-template-columns:70px 1fr 38px;align-items:center;gap:6px;padding:4px 12px;border-bottom:1px solid rgba(24,32,46,.6);}
.un{font-size:9px;color:var(--dim);}
.uv{font-size:9px;color:var(--a2);text-align:right;font-weight:600;}
input[type=range]{-webkit-appearance:none;width:100%;height:3px;background:var(--b2);border-radius:2px;outline:none;cursor:pointer;}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:var(--a2);cursor:pointer;}
input[type=range]:disabled::-webkit-slider-thumb{background:var(--dim);}

/* TEXTURES */
#tp{flex-shrink:0;border-bottom:1px solid var(--b1);}
.tslots{display:flex;gap:5px;padding:7px 12px;}
.ts{width:58px;height:44px;border:1px dashed var(--b2);border-radius:3px;background:var(--bg);cursor:pointer;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:16px;transition:.12s;flex-shrink:0;}
.ts:hover{border-color:var(--a2);}
.ts img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.ts .sn{font-size:8px;color:var(--dim);position:absolute;bottom:2px;right:3px;}

/* SNIPPETS */
#sp{flex-shrink:0;border-bottom:1px solid var(--b1);}
.sg{display:grid;grid-template-columns:1fr 1fr;gap:3px;padding:6px;}
.sb{font-size:9px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;padding:4px 6px;border-radius:2px;border:1px solid var(--b1);background:transparent;color:var(--dim);cursor:pointer;transition:.12s;text-align:left;}
.sb:hover{border-color:var(--a3);color:var(--a3);}

/* PRESETS */
#pp{flex:1;overflow-y:auto;min-height:0;}
.pg{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:8px;}
.pc{background:var(--panel2);border:1px solid var(--b1);border-radius:3px;padding:6px;cursor:pointer;transition:.12s;position:relative;}
.pc:hover{border-color:var(--a2);transform:translateY(-1px);}
.pc canvas{width:100%;height:56px;display:block;border-radius:2px;background:#000;}
.pn{font-size:9px;color:var(--tx);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pa{font-size:8px;color:var(--dim);}
.ptag{font-size:7px;letter-spacing:.05em;padding:1px 4px;border-radius:2px;background:rgba(5,217,232,.1);color:var(--a2);text-transform:uppercase;display:inline-block;margin-top:2px;}
.pdel{position:absolute;top:3px;right:3px;width:15px;height:15px;border-radius:50%;background:rgba(255,42,109,.7);border:none;cursor:pointer;font-size:9px;color:#fff;display:none;align-items:center;justify-content:center;}
.pc:hover .pdel{display:flex;}

/* STATUSBAR */
#sb{background:var(--panel);border-top:1px solid var(--b1);display:flex;align-items:center;padding:0 12px;gap:14px;font-size:9px;color:var(--dim);}
#sb .hi{color:var(--a2);}

/* MODAL */
#mb{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;align-items:center;justify-content:center;z-index:100;}
#mb.open{display:flex;}
.modal{background:var(--panel);border:1px solid var(--b2);border-radius:6px;width:420px;padding:22px;}
.modal h2{font-family:var(--sans);font-size:17px;color:var(--bright);margin-bottom:14px;}
.modal input[type=text]{width:100%;background:var(--bg);border:1px solid var(--b2);color:var(--bright);font-family:var(--mono);font-size:12px;padding:7px 10px;border-radius:3px;outline:none;margin-bottom:8px;}
.modal input:focus{border-color:var(--a2);}
.modal-btns{display:flex;gap:7px;justify-content:flex-end;margin-top:14px;}
.tag-row{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;}
.to{font-size:9px;padding:3px 7px;border-radius:2px;border:1px solid var(--b1);color:var(--dim);cursor:pointer;transition:.12s;}
.to.on{background:rgba(5,217,232,.12);border-color:var(--a2);color:var(--a2);}

::-webkit-scrollbar{width:3px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--b2);border-radius:2px;}
</style>
</head>
<body>
<div id="app">

<!-- TOPBAR -->
<div id="topbar">
  <div class="logo">PRISM<b>·</b>GLSL</div>
  <div class="sep"></div>
  <button class="btn run" onclick="run()">▶ RUN <small style="opacity:.7">Ctrl+↵</small></button>
  <button class="btn" id="playbtn" onclick="togglePlay()">⏸</button>
  <button class="btn" onclick="resetTime()">↺</button>
  <div class="sep"></div>
  <button class="btn save" onclick="openSave()">💾 SAVE</button>
  <button class="btn" onclick="doExport('glsl')">⤓ .GLSL</button>
  <button class="btn" onclick="doExport('prism')">⤓ PRISM</button>
  <div class="sep"></div>
  <select class="btn" id="ressel" onchange="setRes(this.value)">
    <option value=".5">½×</option><option value="1" selected>1×</option>
    <option value="1.5">1.5×</option><option value="2">2×</option>
  </select>
  <div id="st"><div class="dot" id="dot"></div><span id="stxt">READY</span></div>
</div>

<!-- MAIN -->
<div id="main">
<div id="left">
  <div id="cw"><canvas id="cv"></canvas><div id="fps">-- FPS</div></div>
  <div id="ep">
    <div id="ep-hd"><span>⚠ ERRORS</span><button class="btn" style="padding:1px 6px;font-size:8px;" onclick="document.getElementById('ep').style.display='none'">✕</button></div>
    <div id="ep-body"></div>
  </div>
  <div id="ew">
    <div id="etabs">
      <div class="etab on" onclick="tab('frag')">Fragment</div>
      <div class="etab" onclick="tab('toy')">ShaderToy</div>
      <div class="etab" onclick="tab('notes')">Notes</div>
      <div id="esnips">
        <button class="btn" onclick="snip('noise')">+Noise</button>
        <button class="btn" onclick="snip('fbm')">+FBM</button>
        <button class="btn" onclick="snip('rm')">+RayMarch</button>
        <button class="btn" onclick="snip('pal')">+Palette</button>
      </div>
    </div>
    <div id="eb">
      <textarea class="code on" id="frag-ed" spellcheck="false"></textarea>
      <textarea class="code" id="toy-ed" spellcheck="false" placeholder="// ShaderToy mode – paste ShaderToy code directly&#10;// iTime iResolution iChannel0-3 iMouse all available&#10;void mainImage(out vec4 fragColor, in vec2 fragCoord){&#10;  vec2 uv = fragCoord/iResolution.xy;&#10;  fragColor = vec4(uv,sin(iTime)*.5+.5,1.);&#10;}"></textarea>
      <textarea class="code" id="notes-ed" spellcheck="false" placeholder="// Notes / ideas..."></textarea>
    </div>
  </div>
</div>

<div id="right">
  <!-- AUDIO -->
  <div id="ap">
    <div class="sh">🎵 Audio <span id="aml">OFF</span></div>
    <canvas id="wc" height="44"></canvas>
    <div class="abars">
      <div class="abw"><div class="abl">BASS</div><div class="ab"><div class="abf fb" id="bb"></div></div></div>
      <div class="abw"><div class="abl">MID</div><div class="ab"><div class="abf fm" id="bm"></div></div></div>
      <div class="abw"><div class="abl">HI</div><div class="ab"><div class="abf ft" id="bt"></div></div></div>
    </div>
    <div class="abtns">
      <button class="btn" onclick="micOn()">🎤 MIC</button>
      <button class="btn" onclick="fileAudio()">📁 FILE</button>
      <button class="btn" onclick="audioOff()">■ STOP</button>
      <input type="file" id="afile" accept="audio/*" onchange="loadAudio(this)" style="display:none">
    </div>
  </div>

  <!-- UNIFORMS -->
  <div id="up">
    <div class="sh">⚙ Uniforms <span>LIVE</span></div>
    <div class="ur"><div class="un">u_bass</div><input type="range" min="0" max="1" step=".001" id="sl-bass" value="0" oninput="MU.u_bass=+this.value;uv2('bass',this.value)" disabled><div class="uv" id="vbass">0.00</div></div>
    <div class="ur"><div class="un">u_mid</div><input type="range" min="0" max="1" step=".001" id="sl-mid" value="0" oninput="MU.u_mid=+this.value;uv2('mid',this.value)" disabled><div class="uv" id="vmid">0.00</div></div>
    <div class="ur"><div class="un">u_treble</div><input type="range" min="0" max="1" step=".001" id="sl-treble" value="0" oninput="MU.u_treble=+this.value;uv2('treble',this.value)" disabled><div class="uv" id="vtreble">0.00</div></div>
    <div class="ur"><div class="un">u_speed</div><input type="range" min="0" max="4" step=".01" id="sl-speed" value="1" oninput="TS=+this.value;uv2('speed',this.value)"><div class="uv" id="vspeed">1.00</div></div>
    <div class="ur"><div class="un">u_param1</div><input type="range" min="0" max="1" step=".001" id="sl-p1" value="0.5" oninput="MU.u_param1=+this.value;uv2('p1',this.value)"><div class="uv" id="vp1">0.50</div></div>
    <div class="ur"><div class="un">u_param2</div><input type="range" min="0" max="1" step=".001" id="sl-p2" value="0.5" oninput="MU.u_param2=+this.value;uv2('p2',this.value)"><div class="uv" id="vp2">0.50</div></div>
    <div class="ur"><div class="un">u_param3</div><input type="range" min="-3.14" max="3.14" step=".01" id="sl-p3" value="0" oninput="MU.u_param3=+this.value;uv2('p3',this.value)"><div class="uv" id="vp3">0.00</div></div>
  </div>

  <!-- TEXTURES -->
  <div id="tp">
    <div class="sh">🖼 Textures <span>iChannel0–3 / u_tex0–3</span></div>
    <div class="tslots">
      <div class="ts" onclick="pickImg(0)" id="ts0">+<span class="sn">0</span></div>
      <div class="ts" onclick="pickImg(1)" id="ts1">+<span class="sn">1</span></div>
      <div class="ts" onclick="pickImg(2)" id="ts2">+<span class="sn">2</span></div>
      <div class="ts" onclick="pickImg(3)" id="ts3">+<span class="sn">3</span></div>
      <input type="file" id="ifile" accept="image/*" style="display:none" onchange="loadImg(this)">
    </div>
  </div>

  <!-- SNIPPETS -->
  <div id="sp">
    <div class="sh">🧮 Snippets</div>
    <div class="sg">
      <button class="sb" onclick="snip('voronoi')">voronoi</button>
      <button class="sb" onclick="snip('sdf2')">SDF 2D</button>
      <button class="sb" onclick="snip('sdf3')">SDF 3D</button>
      <button class="sb" onclick="snip('polar')">polar</button>
      <button class="sb" onclick="snip('domain')">domain warp</button>
      <button class="sb" onclick="snip('plasma')">plasma</button>
      <button class="sb" onclick="snip('spectrum')">spectrum</button>
      <button class="sb" onclick="snip('beat')">beat pulse</button>
      <button class="sb" onclick="snip('kaleid')">kaleid</button>
      <button class="sb" onclick="snip('hue')">hue shift</button>
    </div>
  </div>

  <!-- PRESETS -->
  <div id="pp">
    <div class="sh">📁 Library <span id="pcount">0</span></div>
    <div class="pg" id="pg"></div>
  </div>
</div>
</div>

<!-- STATUSBAR -->
<div id="sb">
  <span id="sbres">--</span><span>·</span>
  <span class="hi" id="sbtime">t=0.00</span><span>·</span>
  <span id="sbmode">FRAGMENT</span><span>·</span>
  <span style="color:var(--dim)">uniforms: u_time u_resolution u_bass u_mid u_treble u_fft b u_tex0–3 u_param1–3 u_beat u_bpm</span>
</div>
</div>

<!-- MODAL -->
<div id="mb">
  <div class="modal">
    <h2>Save Preset</h2>
    <input type="text" id="pname" placeholder="Shader name..." maxlength="40">
    <input type="text" id="pauth" placeholder="Author (optional)" maxlength="30">
    <div class="tag-row" id="tagrow"></div>
    <div class="modal-btns">
      <button class="btn" onclick="closeSave()">Cancel</button>
      <button class="btn save" onclick="confirmSave()">Save</button>
    </div>
  </div>
</div>

<script>
'use strict';
// ═══════════════════════════════════════════════════════════════════════════
//  PRISM GLSL LIVE EDITOR  ·  Final Edition
//  Handles: golf/twigl, ShaderToy, raw GLSL ES 1.00/3.00, full-main shaders
// ═══════════════════════════════════════════════════════════════════════════

// ── WebGL context ─────────────────────────────────────────────────────────
const canvas = document.getElementById('cv');
const wrap   = document.getElementById('cw');
const gl = canvas.getContext('webgl2',{antialias:false,preserveDrawingBuffer:true})
        || canvas.getContext('webgl', {antialias:false,preserveDrawingBuffer:true});
const GL2 = !!(canvas.getContext('webgl2'));

// ── Global state ──────────────────────────────────────────────────────────
let prog = null, animId = null;
let startT = performance.now(), pausedAt = 0, paused = false, TS = 1;
let pxRatio = 1, frames = 0, lastFps = 0;
let curTab = 'frag';
let presets = [], selTags = [], saveCode = '', saveToy = false;
let audioCtx, analyser, srcNode, micStr, amode = 'off';
let fftBuf, waveBuf, fftTexData, fftTex;
let userGLTex = [null,null,null,null], curImgSlot = 0;
let fbFBO = null, fbTex = null, fbSize = [0,0];
let smoothA = {bass:0,mid:0,treble:0};
let beatState = 0, lastBeat = 0;
let MU = {u_bass:0,u_mid:0,u_treble:0,u_param1:.5,u_param2:.5,u_param3:0};
let headerLines = 0;

// ── Canvas resize ─────────────────────────────────────────────────────────
function resizeCanvas(){
  const W=wrap.clientWidth, H=wrap.clientHeight;
  canvas.width=Math.max(1,Math.floor(W*pxRatio));
  canvas.height=Math.max(1,Math.floor(H*pxRatio));
  canvas.style.width=W+'px'; canvas.style.height=H+'px';
  gl.viewport(0,0,canvas.width,canvas.height);
  document.getElementById('sbres').textContent=canvas.width+'×'+canvas.height;
  if(fbSize[0]!==canvas.width||fbSize[1]!==canvas.height) initFBO();
}
new ResizeObserver(resizeCanvas).observe(wrap);
function setRes(v){ pxRatio=parseFloat(v); resizeCanvas(); }

// ── Feedback FBO ──────────────────────────────────────────────────────────
function initFBO(){
  if(fbTex) gl.deleteTexture(fbTex);
  if(fbFBO) gl.deleteFramebuffer(fbFBO);
  fbTex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D,fbTex);
  const ifmt = GL2 ? gl.RGBA8 : gl.RGBA;
  gl.texImage2D(gl.TEXTURE_2D,0,ifmt,canvas.width,canvas.height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
  fbFBO = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER,fbFBO);
  gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,fbTex,0);
  gl.bindFramebuffer(gl.FRAMEBUFFER,null);
  fbSize=[canvas.width,canvas.height];
}

// ── FFT texture ───────────────────────────────────────────────────────────
fftTexData = new Uint8Array(512*4);
fftTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,fftTex);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,512,1,0,gl.RGBA,gl.UNSIGNED_BYTE,fftTexData);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);

// ── Full-screen quad ──────────────────────────────────────────────────────
const qbuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,qbuf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);

// ════════════════════════════════════════════════════════════════════════════
//  SHADER PREPROCESSOR
//  Detects dialect and transforms to valid GLSL ES 3.00 for WebGL2
//  Handles:
//    • Full raw GLSL (has #version or void main)
//    • ShaderToy (has mainImage)
//    • Golf/compact body (no main — wraps in main with `o` output)
//    • twigl geek mode (tiny one-liner)
//    • GLSL ES 1.00 (texture2D, gl_FragColor → patched automatically)
// ════════════════════════════════════════════════════════════════════════════

const VERT_GL2 = `#version 300 es
in vec2 a_pos;
void main(){ gl_Position=vec4(a_pos,0.,1.); }`;
const VERT_GL1 = `attribute vec2 a_pos;
void main(){ gl_Position=vec4(a_pos,0.,1.); }`;
const vertSrc = () => GL2 ? VERT_GL2 : VERT_GL1;

// Injected helpers – available in every shader, never redeclare needed
const HELPERS = `
// ── Constants ──────────────────────────────────────────────────
#define PI   3.14159265359
#define PI2  6.28318530718
#define TAU  6.28318530718
#define PHI  1.61803398875
#define E    2.71828182845

// ── Rotation ───────────────────────────────────────────────────
mat2 rotate2D(float a){float c=cos(a),s=sin(a);return mat2(c,-s,s,c);}
mat2 rot(float a){return rotate2D(a);}
mat3 rotX(float a){float c=cos(a),s=sin(a);return mat3(1,0,0,0,c,-s,0,s,c);}
mat3 rotY(float a){float c=cos(a),s=sin(a);return mat3(c,0,s,0,1,0,-s,0,c);}
mat3 rotZ(float a){float c=cos(a),s=sin(a);return mat3(c,-s,0,s,c,0,0,0,1);}

// ── Hash / noise ───────────────────────────────────────────────
float hash(float p){return fract(sin(p)*43758.5453);}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2  hash2(vec2 p){return fract(sin(vec2(dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3))))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<6;i++,p*=2.,a*=.5)v+=a*noise(p);return v;}

// ── Color ──────────────────────────────────────────────────────
vec3 palette(float t,vec3 a,vec3 b,vec3 c,vec3 d){return a+b*cos(6.28318*(c*t+d));}
vec3 hsv(float h,float s,float v){vec4 K=vec4(1.,2./3.,1./3.,3.);vec3 p=abs(fract(vec3(h)+K.xyz)*6.-K.www);return v*mix(K.xxx,clamp(p-K.xxx,0.,1.),s);}

// ── SDF ────────────────────────────────────────────────────────
float sdCircle(vec2 p,float r){return length(p)-r;}
float sdBox(vec2 p,vec2 b){vec2 d=abs(p)-b;return length(max(d,0.))+min(max(d.x,d.y),0.);}
float sdBox(vec3 p,vec3 b){vec3 d=abs(p)-b;return length(max(d,0.))+min(max(d.x,max(d.y,d.z)),0.);}
float sdSphere(vec3 p,float r){return length(p)-r;}
float sdCylinder(vec3 p,float r,float h){return max(length(p.xz)-r,abs(p.y)-h);}

// ── Misc helpers ───────────────────────────────────────────────
float luma(vec3 c){return dot(c,vec3(.299,.587,.114));}
vec2  toPolar(vec2 p){return vec2(length(p),atan(p.y,p.x));}
vec2  fromPolar(vec2 p){return vec2(cos(p.y),sin(p.y))*p.x;}
float remap(float v,float a,float b,float c,float d){return c+(v-a)/(b-a)*(d-c);}

// ── Default s (used as start value in golfed loops) ────────────
float s=2.;
`;

function preprocessCode(raw){
  // 1) Normalise line endings
  let code = raw.replace(/\r\n/g,'\n').replace(/\r/g,'\n').trim();

  // 2) Detect dialect
  const hasVersion  = /^\s*#\s*version\b/.test(code);
  const hasMain     = /void\s+main\s*\(\s*\)/.test(code);
  const hasMainImg  = /void\s+mainImage\s*\(/.test(code);

  // 3) If it already has #version → treat as raw, only fix legacy calls
  if(hasVersion){
    code = fixLegacyCalls(code);
    headerLines = 0;
    return { src: code, raw: true };
  }

  // 4) Fix ES1 → ES3 API calls in all user code
  code = fixLegacyCalls(code);

  // 5) Build uniform header
  const ver    = GL2 ? '#version 300 es\n' : '';
  const outVar = GL2 ? 'out highp vec4 _o;\n' : '';
  const fcDef  = GL2 ? '#define gl_FragColor _o\n' : '';

  const header = ver
    + 'precision highp float;\nprecision highp int;\n'
    + outVar
    + `uniform float u_time;
uniform float u_speed;
uniform vec2  u_resolution;
uniform float u_bass;
uniform float u_mid;
uniform float u_treble;
uniform float u_param1;
uniform float u_param2;
uniform float u_param3;
uniform float u_bpm;
uniform int   u_beat;
uniform sampler2D u_fft;
uniform sampler2D u_tex0;
uniform sampler2D u_tex1;
uniform sampler2D u_tex2;
uniform sampler2D u_tex3;
uniform sampler2D b;
uniform sampler2D iChannel4;
`
    + fcDef
    // ShaderToy aliases
    + `#define iTime       u_time
#define iResolution vec3(u_resolution,1.)
#define iChannel0   u_tex0
#define iChannel1   u_tex1
#define iChannel2   u_tex2
#define iChannel3   u_tex3
#define iMouse      vec4(0.)
// golf shorthands
#define FC gl_FragCoord
#define r  u_resolution
#define t  u_time
`
    + HELPERS;

  headerLines = header.split('\n').length;

  // 6) ShaderToy mode (has mainImage)
  if(hasMainImg){
    const body = header + '\n' + code + `
void main(){
  vec4 fragColor=vec4(0.);
  mainImage(fragColor,gl_FragCoord.xy);
  gl_FragColor=fragColor;
}`;
    return { src: body, raw: false };
  }

  // 7) Full main present → just prepend header
  if(hasMain){
    return { src: header + '\n' + code, raw: false };
  }

  // 8) Golf/compact body → wrap in main with `o` shorthand
  return {
    src: header + `\nvoid main(){\nvec4 o=vec4(0.);\n${code}\ngl_FragColor=o;\n}`,
    raw: false
  };
}

// Fix GLSL ES 1.00 API → 3.00
function fixLegacyCalls(code){
  return code
    .replace(/\btexture2D\s*\(/g,   'texture(')
    .replace(/\btexture2DLod\s*\(/g,'textureLod(')
    .replace(/\btextureCube\s*\(/g, 'texture(')
    .replace(/\bvarying\b/g,        'in')
    .replace(/\battribute\b/g,      'in');
}

// ── Compile helpers ───────────────────────────────────────────────────────
function mkShader(src, type){
  const sh = gl.createShader(type);
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){
    const e = gl.getShaderInfoLog(sh);
    gl.deleteShader(sh);
    throw e;
  }
  return sh;
}
function mkProg(vs, fs){
  const p = gl.createProgram();
  gl.attachShader(p,vs); gl.attachShader(p,fs);
  gl.linkProgram(p);
  if(!gl.getProgramParameter(p,gl.LINK_STATUS)) throw gl.getProgramInfoLog(p);
  return p;
}

// ── Status ────────────────────────────────────────────────────────────────
function setSt(state, msg){
  document.getElementById('dot').className='dot '+state;
  document.getElementById('stxt').textContent=msg;
}

// ── Compile & Run ─────────────────────────────────────────────────────────
function run(){
  setSt('busy','COMPILING…');
  const isToy = curTab==='toy';
  const raw = (isToy ? document.getElementById('toy-ed') : document.getElementById('frag-ed')).value;

  let result;
  try { result = preprocessCode(raw); }
  catch(e){ setSt('err','PREPROCESS ERROR'); showErr(String(e),raw); return; }

  try {
    const vs = mkShader(vertSrc(), gl.VERTEX_SHADER);
    const fs = mkShader(result.src, gl.FRAGMENT_SHADER);
    const np = mkProg(vs, fs);
    if(prog) gl.deleteProgram(prog);
    prog = np;
    document.getElementById('ep').style.display='none';
    setSt('ok','✓ OK');
    document.getElementById('sbmode').textContent = isToy?'SHADERTOY':result.raw?'RAW GLSL':'FRAGMENT';
  } catch(rawErr){
    setSt('err','ERROR');
    showErr(String(rawErr), raw);
  }
}

// ── Error display ─────────────────────────────────────────────────────────
function showErr(rawErr, userSrc){
  const ep   = document.getElementById('ep');
  const body = document.getElementById('ep-body');
  ep.style.display='block';
  body.innerHTML='';

  const userLines = userSrc.split('\n');
  let hadParsed = false;

  rawErr.split('\n').forEach(line=>{
    if(!line.trim()) return;
    const span = document.createElement('span');
    span.className='el';

    // ERROR: 0:LINE: message  (WebGL standard)
    const m = line.match(/ERROR\s*:\s*\d+\s*:\s*(\d+)\s*:\s*(.*)/);
    if(m){
      hadParsed=true;
      const glLine  = parseInt(m[1]);
      const userLine= Math.max(1, glLine - headerLines);
      const msg     = m[2].trim();
      const srcLine = (userLines[userLine-1]||'').trim();

      const loc = document.createElement('span');
      loc.className='loc'; loc.textContent='line '+userLine+':';
      loc.onclick=()=>jumpTo(userLine);

      const ms = document.createElement('span');
      ms.className='msg'; ms.textContent=msg;

      const sl = document.createElement('span');
      sl.className='src'; sl.textContent=srcLine;

      span.appendChild(loc); span.appendChild(ms);
      span.appendChild(document.createElement('br'));
      span.appendChild(sl);
    } else {
      span.innerHTML=`<span class="msg">${esc(line)}</span>`;
    }
    body.appendChild(span);
  });
  if(!hadParsed) body.textContent = rawErr;
}

function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

function jumpTo(n){
  const ta = curTab==='toy' ? document.getElementById('toy-ed') : document.getElementById('frag-ed');
  ta.focus();
  const lines = ta.value.split('\n');
  let pos=0;
  for(let i=0;i<Math.min(n-1,lines.length);i++) pos+=lines[i].length+1;
  ta.setSelectionRange(pos, pos+(lines[n-1]||'').length);
  ta.scrollTop = Math.max(0,(n-4)*(ta.scrollHeight/lines.length));
}

// ── Render loop ───────────────────────────────────────────────────────────
function uLoc(n){ return gl.getUniformLocation(prog,n); }
function sf(n,v){ const l=uLoc(n); if(l!=null)gl.uniform1f(l,v); }
function si(n,v){ const l=uLoc(n); if(l!=null)gl.uniform1i(l,v); }
function s2(n,x,y){ const l=uLoc(n); if(l!=null)gl.uniform2f(l,x,y); }

function renderFrame(){
  animId=requestAnimationFrame(renderFrame);
  if(!prog) return;

  if(analyser) tickAudio();

  const now   = performance.now();
  const elapsed = paused ? pausedAt : (now-startT)/1000*TS;

  gl.useProgram(prog);
  gl.viewport(0,0,canvas.width,canvas.height);

  // Bind quad
  const posLoc = gl.getAttribLocation(prog,'a_pos');
  gl.bindBuffer(gl.ARRAY_BUFFER,qbuf);
  gl.enableVertexAttribArray(posLoc);
  gl.vertexAttribPointer(posLoc,2,gl.FLOAT,false,0,0);

  // Core
  sf('u_time', elapsed);
  sf('u_speed', TS);
  s2('u_resolution', canvas.width, canvas.height);
  sf('u_bass',   amode!=='off' ? smoothA.bass   : MU.u_bass);
  sf('u_mid',    amode!=='off' ? smoothA.mid    : MU.u_mid);
  sf('u_treble', amode!=='off' ? smoothA.treble : MU.u_treble);
  sf('u_param1', MU.u_param1);
  sf('u_param2', MU.u_param2);
  sf('u_param3', MU.u_param3);
  sf('u_bpm', 120);
  si('u_beat', beatState);

  // FFT tex → slot 0
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D,fftTex);
  si('u_fft',0);

  // User textures → slots 1-4
  for(let i=0;i<4;i++){
    gl.activeTexture(gl.TEXTURE1+i);
    gl.bindTexture(gl.TEXTURE_2D, userGLTex[i]||fftTex);
    si('u_tex'+i, 1+i);
    si('iChannel'+i, 1+i);
  }

  // Feedback buffer `b` → slot 5
  gl.activeTexture(gl.TEXTURE5);
  gl.bindTexture(gl.TEXTURE_2D, fbTex||fftTex);
  si('b',5); si('iChannel4',5);

  gl.drawArrays(gl.TRIANGLES,0,6);

  // Copy frame into feedback texture
  if(fbTex){
    gl.activeTexture(gl.TEXTURE5);
    gl.bindTexture(gl.TEXTURE_2D,fbTex);
    gl.copyTexSubImage2D(gl.TEXTURE_2D,0,0,0,0,0,canvas.width,canvas.height);
  }

  // FPS
  frames++;
  if(now-lastFps>500){
    document.getElementById('fps').textContent=Math.round(frames/((now-lastFps)/1000))+' FPS';
    document.getElementById('sbtime').textContent='t='+elapsed.toFixed(2);
    frames=0; lastFps=now;
  }
}

function togglePlay(){
  paused=!paused;
  if(paused) pausedAt=(performance.now()-startT)/1000*TS;
  else startT=performance.now()-pausedAt/TS*1000;
  document.getElementById('playbtn').textContent=paused?'▶':'⏸';
}
function resetTime(){ startT=performance.now(); pausedAt=0; }

// ── Tabs ──────────────────────────────────────────────────────────────────
function tab(name){
  curTab=name;
  document.querySelectorAll('.etab').forEach((el,i)=>{
    el.classList.toggle('on',['frag','toy','notes'][i]===name);
  });
  document.querySelectorAll('.code').forEach(el=>el.classList.remove('on'));
  document.getElementById(name+'-ed').classList.add('on');
}

// ── Keyboard ─────────────────────────────────────────────────────────────
document.addEventListener('keydown',e=>{
  if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){ e.preventDefault(); run(); }
});

// ── Snippets ─────────────────────────────────────────────────────────────
const SNIPS = {
  noise: `// Value noise (hash+noise already in header)
float n = noise(uv*4.+t*.1);`,
  fbm: `// FBM (also in header as fbm(p))
float f = fbm(uv*3.+t*.05);`,
  voronoi: `// Voronoi
vec2 voronoi(vec2 x){
  vec2 n=floor(x),f=fract(x),mr; float md=8.;
  for(int j=-1;j<=1;j++) for(int i=-1;i<=1;i++){
    vec2 g=vec2(i,j),o=hash2(n+g),r=g+o-f;
    float d=dot(r,r); if(d<md){md=d;mr=r;}
  }
  return vec2(sqrt(md),hash(n+mr+.5));
}`,
  sdf2: `// 2D SDF shapes (sdCircle/sdBox already in header)
float d1 = sdCircle(uv-vec2(.5), .3);
float d2 = sdBox(uv-vec2(.5), vec2(.2,.3));
float sdf = min(d1,d2);`,
  sdf3: `// 3D SDF (sdSphere/sdBox/sdCylinder in header)
float map(vec3 p){
  float d=sdSphere(p,1.0);
  d=min(d, sdBox(p-vec3(0,-1.5,0),vec3(3.,.1,3.)));
  return d;
}`,
  polar: `// Polar coords (toPolar/fromPolar in header)
vec2 pol = toPolar(uv-vec2(.5,.5));
// pol.x = radius  pol.y = angle`,
  rm: `// Ray marcher boilerplate
vec3 nm(vec3 p){vec2 e=vec2(.001,0.);return normalize(vec3(map(p+e.xyy)-map(p-e.xyy),map(p+e.yxy)-map(p-e.yxy),map(p+e.yyx)-map(p-e.yyx)));}
void main(){
  vec2 uv=(FC.xy*2.-r)/r.y;
  vec3 ro=vec3(0,0,3),rd=normalize(vec3(uv,-1));
  float d=0.; vec4 col=vec4(.05,.06,.1,1.);
  for(int i=0;i<96;i++){
    vec3 p=ro+rd*d; float h=map(p);
    if(h<.001){col=vec4(nm(p)*.5+.5,1.);break;}
    d+=h; if(d>20.)break;
  }
  gl_FragColor=col;
}`,
  pal: `// Cosine palette (also palette() in header)
vec3 col = palette(f, vec3(.5), vec3(.5), vec3(1.), vec3(0.,.33,.67));`,
  domain: `// Domain warping
vec2 q=vec2(fbm(uv+vec2(0.,0.)),fbm(uv+vec2(5.2,1.3)));
vec2 warp=uv+.7*q;
float f=fbm(warp);`,
  plasma: `// Audio plasma
vec2 uv=(FC.xy*2.-r)/r.y;
float v=sin(uv.x*3.+t)+sin(uv.y*3.*(u_mid*2.+.5)+t)+sin(length(uv)*5.*u_bass-t*2.);
gl_FragColor=vec4(.5+.5*cos(v*3.+vec3(0,2.1,4.2)),1.);`,
  spectrum: `// Spectrum bars from FFT
vec2 uv=FC.xy/r;
float bin=floor(uv.x*64.)/64.;
float fv=texture(u_fft,vec2(bin+.5/64.,.5)).r;
float bar=step(uv.y,fv*.9)*(1.-step(uv.y,.015));
vec3 col=mix(vec3(1.,.1,.4),vec3(.05,.9,1.),uv.x)*bar;
gl_FragColor=vec4(col,1.);`,
  beat: `// Beat reactive
float pulse = float(u_beat)*exp(-mod(t,1.)*6.); // decay per beat
float b2 = u_bass*u_bass; // squared for sharper response`,
  kaleid: `// Kaleidoscope
vec2 kal(vec2 p,float n){
  float a=atan(p.y,p.x),r=length(p);
  float seg=TAU/n; a=mod(a,seg);
  if(a>seg*.5) a=seg-a;
  return vec2(cos(a),sin(a))*r;
}`,
  hue: `// Hue shift (hsv() in header)
vec3 col = hsv(u_time*.1 + luma(baseCol), .8, .9);`,
};
function snip(k){
  const s=SNIPS[k]; if(!s) return;
  const ta=curTab==='toy'?document.getElementById('toy-ed'):document.getElementById('frag-ed');
  const p=ta.selectionStart;
  ta.value=ta.value.slice(0,p)+'\n'+s+'\n'+ta.value.slice(ta.selectionEnd);
  ta.selectionStart=ta.selectionEnd=p+s.length+2;
  ta.focus();
}

// ── Audio ─────────────────────────────────────────────────────────────────
function initACtx(){
  if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)();
  if(audioCtx.state==='suspended') audioCtx.resume();
  analyser=audioCtx.createAnalyser();
  analyser.fftSize=1024;
  analyser.smoothingTimeConstant=0.75;
  fftBuf=new Uint8Array(analyser.frequencyBinCount);
  waveBuf=new Uint8Array(analyser.frequencyBinCount);
}

async function micOn(){
  try{
    initACtx();
    if(micStr) micStr.getTracks().forEach(t=>t.stop());
    micStr=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
    if(srcNode){try{srcNode.disconnect();}catch(e){}}
    srcNode=audioCtx.createMediaStreamSource(micStr);
    srcNode.connect(analyser);
    setAMode('mic','🎤 MIC');
  }catch(e){alert('Mic error: '+e.message);}
}
function fileAudio(){ document.getElementById('afile').click(); }
function loadAudio(inp){
  const f=inp.files[0]; if(!f) return;
  initACtx();
  if(srcNode){try{srcNode.disconnect();}catch(e){}}
  const url=URL.createObjectURL(f);
  const aud=new Audio(url); aud.crossOrigin='anonymous';
  srcNode=audioCtx.createMediaElementSource(aud);
  srcNode.connect(analyser); analyser.connect(audioCtx.destination);
  aud.play();
  setAMode('file','📁 '+f.name.slice(0,14));
  inp.value='';
}
function audioOff(){
  if(srcNode){try{srcNode.disconnect();}catch(e){} srcNode=null;}
  if(micStr){micStr.getTracks().forEach(t=>t.stop());micStr=null;}
  analyser=null; setAMode('off','OFF');
  ['bass','mid','treble'].forEach(k=>document.getElementById('b'+k[0]).style.width='0%');
}
function setAMode(mode, label){
  amode=mode;
  const el=document.getElementById('aml');
  el.textContent=label;
  el.style.color=mode==='off'?'var(--dim)':mode==='mic'?'var(--a1)':'var(--a2)';
  ['bass','mid','treble'].forEach(k=>{ document.getElementById('sl-'+k).disabled=mode!=='off'; });
}

function tickAudio(){
  analyser.getByteFrequencyData(fftBuf);
  analyser.getByteTimeDomainData(waveBuf);

  // Upload FFT texture (full 512 bins: R=spectrum, G=waveform)
  for(let i=0;i<512;i++){
    fftTexData[i*4]   = fftBuf[i]||0;
    fftTexData[i*4+1] = i<waveBuf.length?waveBuf[i]:128;
    fftTexData[i*4+2] = 0;
    fftTexData[i*4+3] = 255;
  }
  gl.bindTexture(gl.TEXTURE_2D,fftTex);
  gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,512,1,gl.RGBA,gl.UNSIGNED_BYTE,fftTexData);

  // Band averages
  let bass=0,mid=0,tre=0;
  for(let i=1;i<6;i++)  bass+=fftBuf[i];
  for(let i=6;i<60;i++) mid+=fftBuf[i];
  for(let i=60;i<128;i++)tre+=fftBuf[i];
  bass/=5*255; mid/=54*255; tre/=68*255;

  const att=.3,dec=.07;
  const sm=(cur,v)=>v>cur?cur*(1-att)+v*att:cur*(1-dec)+v*dec;
  smoothA.bass  =sm(smoothA.bass,  bass);
  smoothA.mid   =sm(smoothA.mid,   mid);
  smoothA.treble=sm(smoothA.treble,tre);

  // Beat detect
  const now=performance.now();
  if(smoothA.bass>.55&&now-lastBeat>220){beatState=1;lastBeat=now;}
  else if(now-lastBeat>110) beatState=0;

  // UI bars
  document.getElementById('bb').style.width=(smoothA.bass  *100)+'%';
  document.getElementById('bm').style.width=(smoothA.mid   *100)+'%';
  document.getElementById('bt').style.width=(smoothA.treble*100)+'%';

  if(amode!=='off'){
    ['bass','mid','treble'].forEach(k=>{
      const v=smoothA[k];
      document.getElementById('sl-'+k).value=v;
      uv2(k,v.toFixed(3));
    });
  }
  drawWave();
}

function drawWave(){
  const wc=document.getElementById('wc');
  const c=wc.getContext('2d');
  c.fillStyle='#000'; c.fillRect(0,0,wc.width,wc.height);
  // Spectrum
  c.fillStyle='rgba(255,42,109,.3)';
  const bw=wc.width/64;
  for(let i=0;i<64;i++){
    const h=(fftBuf[i*2]/255)*wc.height;
    c.fillRect(i*bw,wc.height-h,bw-1,h);
  }
  // Waveform
  c.strokeStyle='#05d9e8'; c.lineWidth=1.5; c.beginPath();
  for(let i=0;i<waveBuf.length;i++){
    const x=i/waveBuf.length*wc.width;
    const y=((waveBuf[i]/128)-1)*wc.height*.45+wc.height/2;
    i?c.lineTo(x,y):c.moveTo(x,y);
  }
  c.stroke();
}

function uv2(id,v){ document.getElementById('v'+id).textContent=parseFloat(v).toFixed(2); }

// ── Textures ──────────────────────────────────────────────────────────────
function pickImg(slot){ curImgSlot=slot; document.getElementById('ifile').click(); }
document.getElementById('ifile').addEventListener('change',function(){
  const f=this.files[0]; if(!f) return;
  const img=new Image();
  img.onload=()=>{
    if(userGLTex[curImgSlot]) gl.deleteTexture(userGLTex[curImgSlot]);
    userGLTex[curImgSlot]=gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D,userGLTex[curImgSlot]);
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,img);
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.REPEAT);
    const sl=document.getElementById('ts'+curImgSlot);
    sl.innerHTML=''; const im=document.createElement('img'); im.src=img.src;
    const sn=document.createElement('span'); sn.className='sn'; sn.textContent=curImgSlot;
    sl.appendChild(im); sl.appendChild(sn);
  };
  img.src=URL.createObjectURL(f); this.value='';
});

// ── Export ────────────────────────────────────────────────────────────────
function doExport(type){
  const isToy=curTab==='toy';
  const raw=(isToy?document.getElementById('toy-ed'):document.getElementById('frag-ed')).value;
  if(type==='glsl'){
    dl('shader.glsl', raw);
  } else {
    dl('prism_shader.frag',
`#version 460 core
// ── Prism Engine export ────────────────────────────────
in  vec2 v_uv;
out vec4 FragColor;
uniform float     u_time;
uniform vec2      u_resolution;
uniform float     u_bass,u_mid,u_treble,u_bpm;
uniform int       u_isBeat;
uniform sampler2D u_audioTexture,u_tex1,u_tex2,u_tex3;

${raw}
`);
  }
}
function dl(name,content){
  const a=document.createElement('a');
  a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(content);
  a.download=name; a.click();
}

// ── Save modal ────────────────────────────────────────────────────────────
const TAGS=['2D','3D','raymarching','audio','noise','fractal','feedback','tunnel','pattern','glow','dark','abstract'];
function openSave(){
  selTags=[];
  saveToy=curTab==='toy';
  saveCode=(saveToy?document.getElementById('toy-ed'):document.getElementById('frag-ed')).value;
  const row=document.getElementById('tagrow'); row.innerHTML='';
  TAGS.forEach(tg=>{
    const b=document.createElement('button'); b.className='to btn'; b.textContent=tg;
    b.onclick=()=>{ b.classList.toggle('on'); selTags=Array.from(document.querySelectorAll('#tagrow .btn.on')).map(x=>x.textContent); };
    row.appendChild(b);
  });
  document.getElementById('mb').classList.add('open');
  document.getElementById('pname').focus();
}
function closeSave(){ document.getElementById('mb').classList.remove('open'); }
function confirmSave(){
  const name=document.getElementById('pname').value.trim()||'Untitled '+Date.now();
  const auth=document.getElementById('pauth').value.trim();
  const notes=document.getElementById('notes-ed').value;
  const p={id:Date.now(),name,auth,tags:selTags,code:saveCode,isToy:saveToy,notes,created:new Date().toISOString()};
  presets.push(p);
  storeSave(); renderPresets(); closeSave();
}
function storeSave(){ try{localStorage.setItem('prism_glsl_v2',JSON.stringify(presets));}catch(e){} }
function storeLoad(){ try{const r=localStorage.getItem('prism_glsl_v2'); if(r)presets=JSON.parse(r);}catch(e){presets=[];} }
function delPreset(id){ presets=presets.filter(p=>p.id!==id); storeSave(); renderPresets(); }
function loadPreset(p){
  const ta=p.isToy?document.getElementById('toy-ed'):document.getElementById('frag-ed');
  ta.value=p.code; tab(p.isToy?'toy':'frag');
  if(p.notes) document.getElementById('notes-ed').value=p.notes;
  run();
}

// ── Preset rendering ──────────────────────────────────────────────────────
function renderPresets(){
  const all=[...BUILTINS,...presets];
  const grid=document.getElementById('pg');
  document.getElementById('pcount').textContent=all.length;
  grid.innerHTML='';
  all.forEach((p,idx)=>{
    const card=document.createElement('div'); card.className='pc';
    const cv=document.createElement('canvas'); cv.width=120; cv.height=56; cv.id='thumb'+p.id;
    const nm=document.createElement('div'); nm.className='pn'; nm.textContent=p.name;
    card.appendChild(cv); card.appendChild(nm);
    if(p.auth){ const au=document.createElement('div'); au.className='pa'; au.textContent='by '+p.auth; card.appendChild(au); }
    (p.tags||[]).forEach(tg=>{ const s=document.createElement('span'); s.className='ptag'; s.textContent=tg; card.appendChild(s); });
    if(p.id>0){
      const db=document.createElement('button'); db.className='pdel'; db.textContent='×';
      db.onclick=e=>{ e.stopPropagation(); delPreset(p.id); }; card.appendChild(db);
    }
    card.onclick=()=>loadPreset(p);
    grid.appendChild(card);
    setTimeout(()=>thumbRender(p, document.getElementById('thumb'+p.id)), idx*40);
  });
}

function thumbRender(preset, tc){
  if(!tc) return;
  const tgl=tc.getContext('webgl2')||tc.getContext('webgl'); if(!tgl) return;
  try{
    const r=preprocessCode(preset.code, preset.isToy);
    const tv=tgl.createShader(tgl.VERTEX_SHADER);
    const vsrc=GL2?VERT_GL2:VERT_GL1;
    tgl.shaderSource(tv,vsrc); tgl.compileShader(tv);
    const tf=tgl.createShader(tgl.FRAGMENT_SHADER);
    tgl.shaderSource(tf,r.src); tgl.compileShader(tf);
    if(!tgl.getShaderParameter(tf,tgl.COMPILE_STATUS)) return;
    const tp2=tgl.createProgram();
    tgl.attachShader(tp2,tv); tgl.attachShader(tp2,tf); tgl.linkProgram(tp2);
    if(!tgl.getProgramParameter(tp2,tgl.LINK_STATUS)) return;
    const buf=tgl.createBuffer();
    tgl.bindBuffer(tgl.ARRAY_BUFFER,buf);
    tgl.bufferData(tgl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),tgl.STATIC_DRAW);
    tgl.useProgram(tp2);
    const pl=tgl.getAttribLocation(tp2,'a_pos');
    tgl.enableVertexAttribArray(pl);
    tgl.vertexAttribPointer(pl,2,tgl.FLOAT,false,0,0);
    const sf2=(n,v)=>{ const l=tgl.getUniformLocation(tp2,n); if(l!=null)tgl.uniform1f(l,v); };
    const s22=(n,x,y)=>{ const l=tgl.getUniformLocation(tp2,n); if(l!=null)tgl.uniform2f(l,x,y); };
    sf2('u_time',3.14); s22('u_resolution',120,56);
    tgl.drawArrays(tgl.TRIANGLES,0,6);
  }catch(e){}
}

// ── Built-in presets ──────────────────────────────────────────────────────
const BUILTINS=[
  {id:-1,name:'Neon Tunnel',auth:'prism',tags:['3D','tunnel','glow'],isToy:false,notes:'',
   code:`vec3 p,v;
for(float i,z,d,l;i++<1e2;o+=(sin(p.y+vec4(0,2,5,4))+1.)/d)
  p=z*normalize(FC.rgb*2.-r.xyy),p.yz-=t,
  z+=d=(length(max(v=cos(p+cos(p/.2)),v.yzx*.1)))/6.;
o=tanh(o/1e4);`},
  {id:-2,name:'Neon Petals',auth:'prism',tags:['2D','glow','audio'],isToy:false,notes:'',
   code:`vec2 p=(FC.xy*2.-r)/r.x*3.;float v=.1;
for(float i=0.;i<11.;i++){
  vec2 c=sin(vec2(i,i*1.4)+t*.5);
  vec2 q=(p-c)*.9;q.y-=sqrt(abs(q.x))*.5;
  float d=length(q)-.3+sin(q.x*5.+t)*.05;
  v+=.015/abs(d);
}
o=vec4(1.,.15,.4,1.)*v*(1.+u_bass*.5);`},
  {id:-3,name:'Liquid Tech',auth:'prism',tags:['3D','raymarching','glow'],isToy:false,notes:'',
   code:`for(float i,d,s;++i<1e2;){
  vec3 p=vec3((FC.xy*2.-r.xy)/r.y*d*rotate2D(t/2.),d-8.);
  p.xz*=rotate2D(t/2.);
  d+=s=.012+.08*abs(max(sin(dot(p.yzx,p)/.7),length(p)-4.)-i/1e2);
  o+=max(1.3*sin(vec4(3,2,1,1)+i*.3)/s,-length(p*p));
}
o=tanh(o*o/8e5);`},
  {id:-4,name:'Mandelbulb',auth:'prism',tags:['3D','fractal'],isToy:false,notes:'',
   code:`for(float R,P,e,d,i,j,g;i++<1e2;o+=.005/exp(e*e*1e8-sin(R/vec4(7,6,5,1)))){
  vec3 z,p=vec3((FC.xy-.5*r)/r.y*g,g+g)-i/3e4;
  d=P=12.;p.yz*=rotate2D(t/12.);p.x+=t/PI2;
  z=p=mod(p,2.)-1.;
  for(j=s;R=length(z),j++<5.&&R<1.5;z=p+sin(asin(z/R)*P)*pow(R,P))
    d=pow(R,P-2.)*P*d;
  g+=e=log(R)*R/++d;
}`},
  {id:-5,name:'Feedback Fluid',auth:'prism',tags:['2D','feedback'],isToy:false,notes:'Uses backbuffer b',
   code:`vec2 p=(FC.xy*2.-r)/r.y/.3,v;
for(float i,l,f;i++<9.;o+=.1/abs(l=dot(p,p)-5.-2./v.y)*(cos(i/3.+.1/l+vec4(1,2,3,4))+1.))
  for(v=p,f=0.;f++<9.;v+=sin(ceil(v.yx*f+i*.3)+r-t/2.)/f);
o=max(tanh(o+(o=texture(b,(FC.xy+r.y*.04*sin(FC.xy+FC.yx/.6))/r))*o),.0);`},
  {id:-6,name:'Audio Spectrum',auth:'prism',tags:['2D','audio','music'],isToy:false,notes:'',
   code:`void main(){
  vec2 uv=FC.xy/r;
  float bin=floor(uv.x*64.)/64.;
  float fv=texture(u_fft,vec2(bin+.5/64.,.5)).r;
  float bar=step(uv.y,fv*.88)*(1.-step(uv.y,.015));
  vec3 bc=mix(vec3(1.,.1,.4),vec3(.05,.9,1.),pow(uv.x,.7))*mix(1.,1.8,fv);
  float wave=texture(u_fft,vec2(uv.x,.5)).g/255.;
  float wl=smoothstep(.012,.0,abs(uv.y-wave));
  vec3 col=vec3(.02,.03,.06)+bc*bar+vec3(0.,.8,.6)*wl;
  col+=float(u_beat)*vec3(.08,.04,.02)*smoothstep(1.,.7,uv.y);
  gl_FragColor=vec4(col,1.);
}`},
  {id:-7,name:'Plasma Wave',auth:'prism',tags:['2D','audio','pattern'],isToy:false,notes:'',
   code:`void main(){
  vec2 uv=(FC.xy*2.-r)/r.y;
  float v=sin(uv.x*3.*(u_bass*2.+.5)+t)+sin(uv.y*3.*(u_mid*1.5+.3)+t*1.3)
         +sin((uv.x+uv.y)*2.+t*.7)+sin(length(uv)*5.*(u_bass+.5)-t*2.);
  gl_FragColor=vec4(.5+.5*cos(v*3.+vec3(0.,2.1,4.2))*(1.+u_treble*.4),1.);
}`},
  {id:-8,name:'Raymarched SDF',auth:'prism',tags:['3D','raymarching'],isToy:false,notes:'',
   code:`float map(vec3 p){
  p.y+=sin(p.x*1.5+t)*.3*(1.+u_bass);
  p.xz*=rotate2D(t*.3);
  return max(sdBox(p,vec3(.8))-.05,-sdSphere(p,.9));
}
void main(){
  vec2 uv=(FC.xy*2.-r)/r.y;
  vec3 ro=vec3(0.,0.,2.8),rd=normalize(vec3(uv,-1.));
  float d=0.; vec4 col=vec4(.05,.06,.12,1.);
  for(int i=0;i<80;i++){
    vec3 p=ro+rd*d; float h=map(p);
    if(h<.001){
      vec3 n=vec3(map(p+vec3(.001,0,0))-map(p-vec3(.001,0,0)),
                  map(p+vec3(0,.001,0))-map(p-vec3(0,.001,0)),
                  map(p+vec3(0,0,.001))-map(p-vec3(0,0,.001)));
      n=normalize(n);
      float diff=max(dot(n,normalize(vec3(1.,2.,2.))),0.);
      vec3 c=mix(vec3(.05,.4,1.),vec3(1.,.2,.5),diff)*(diff*.7+.3);
      col=vec4(c*(1.+u_mid*.5),1.); break;
    }
    d+=h; if(d>12.)break;
  }
  gl_FragColor=col;
}`},
  {id:-9,name:'Kaleidoscope FBM',auth:'prism',tags:['2D','fractal','pattern'],isToy:false,notes:'',
   code:`void main(){
  vec2 uv=(FC.xy*2.-r)/r.y;
  float a=atan(uv.y,uv.x),ra=length(uv);
  float seg=PI/4.;
  a=mod(a,seg*2.); if(a>seg) a=seg*2.-a;
  uv=vec2(cos(a+t*.2),sin(a+t*.2))*ra;
  float f=fbm(uv*3.+t*.1), f2=fbm(uv*6.-t*.15+f);
  vec3 col=.5+.5*cos(f2*6.+vec3(0.,2.1,4.2)+u_bass*2.);
  gl_FragColor=vec4(col,1.);
}`},
];

// ── Init ──────────────────────────────────────────────────────────────────
document.getElementById('mb').addEventListener('click',function(e){ if(e.target===this)closeSave(); });
document.getElementById('pname').addEventListener('keydown',e=>{ if(e.key==='Enter')confirmSave(); });

storeLoad();
renderPresets();
document.getElementById('frag-ed').value=BUILTINS[0].code;
resizeCanvas();
initFBO();
run();
renderFrame();
</script>
</body>
</html>