shadertoy-cli 2.2.8

Agent-friendly ShaderToy project, rendering, debugging, and live-preview CLI
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ShaderToy Preview</title>
<link rel="icon" href="data:,">
<style>
:root { color-scheme: dark; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body { background: #101114; color: #e8e8ea; height: 100vh; height: 100dvh; display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); overflow: hidden; }
aside { padding: 16px; border-right: 1px solid #2c2e35; overflow: auto; overscroll-behavior: contain; }
main { min-width: 0; display: grid; place-items: center; overflow: hidden; background: #08090b; }
#frame { max-width: 100%; max-height: 100%; user-select: none; outline: none; display: block; touch-action: none; }
h1 { font-size: 16px; margin: 0 0 16px; }
label { display: block; font-size: 12px; color: #aaa; margin-top: 12px; }
select,input,button { width: 100%; margin-top: 5px; background: #1b1d22; color: #eee; border: 1px solid #343741; border-radius: 5px; padding: 7px; min-height: 36px; }
button { touch-action: manipulation; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.control + .control { margin-top: 8px; }
.uniform-entry { margin-top: 8px; }
.uniform-entry > label { margin-top: 0; }
.uniform-vector { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
.uniform-bool { display: flex; align-items: center; gap: 8px; }
.uniform-bool input { width: auto; min-height: 0; margin: 0; }
.meta { margin-top: 14px; font-size: 11px; line-height: 1.65; color: #b7bac3; white-space: pre-line; }
#error { margin-top: 14px; white-space: pre-wrap; overflow-wrap: anywhere; color: #ff8e8e; font-size: 11px; }
@media (max-width: 720px) {
  body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
  aside {
    max-height: 44dvh;
    padding: 10px 12px;
    border-right: 0;
    border-bottom: 1px solid #2c2e35;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 8px 10px;
    align-content: start;
  }
  h1 { grid-column: 1 / -1; margin: 0; }
  .control + .control { margin-top: 0; }
  .control label { margin-top: 0; }
  .meta, #error { grid-column: 1 / -1; margin-top: 0; }
  select, input, button { min-height: 44px; font-size: 16px; }
  main { min-height: 0; }
}
</style>
</head>
<body>
<aside>
  <h1>ShaderToy native preview</h1>
  <div class="control">
    <label>View<select id="view"></select></label>
  </div>
  <div class="control">
    <div class="row">
      <button id="pause">Pause</button>
      <button id="step">Step</button>
    </div>
    <button id="reset">Reset</button>
  </div>
  <div class="control">
    <label>Resolution</label>
    <div class="row">
      <input id="width" aria-label="Preview width" inputmode="numeric" type="number" min="1" max="4096">
      <input id="height" aria-label="Preview height" inputmode="numeric" type="number" min="1" max="4096">
    </div>
    <button id="resolution">Apply resolution</button>
  </div>
  <div class="control">
    <label>Time scale (log2)<input id="scale" type="range" min="-4" max="4" step="0.25" value="0"></label>
  </div>
  <div class="control" id="uniform-section">
    <label>Parameters</label>
    <div id="uniforms"></div>
  </div>
  <div class="control" id="webcam-section" style="display:none">
    <label>Webcam input</label>
    <button id="webcam-toggle">Start webcam</button>
  </div>
  <div class="meta" id="meta"></div>
  <div id="error"></div>
</aside>
<main><canvas id="frame" tabindex="0" aria-label="Shader preview"></canvas></main>
<script>
const query = location.search;
const wsProto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(wsProto + "//" + location.host + "/ws" + query);
ws.binaryType = "arraybuffer";
const frame = document.getElementById("frame");
const frameContext = frame.getContext("2d", {alpha:false});
const view = document.getElementById("view");
const pause = document.getElementById("pause");
const meta = document.getElementById("meta");
const error = document.getElementById("error");
const width = document.getElementById("width");
const height = document.getElementById("height");
const scale = document.getElementById("scale");
const uniforms = document.getElementById("uniforms");
const uniformSection = document.getElementById("uniform-section");
const webcamSection = document.getElementById("webcam-section");
const webcamToggle = document.getElementById("webcam-toggle");
const webcamVideo = document.createElement("video");
const webcamCanvas = document.createElement("canvas");
webcamCanvas.width = 320;
webcamCanvas.height = 240;
const webcamContext = webcamCanvas.getContext("2d", {alpha:false, willReadFrequently:true});
let webcamStream = null;
let webcamTimer = null;
let uniformSignature = "";
let status = null;
let down = false;
let decodingFrame = false;
let pendingFrame = null;

function send(value) {
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value));
}
function samePassOptions(passes) {
  if (view.options.length !== passes.length) return false;
  for (let i = 0; i < passes.length; i++) {
    if (view.options[i].value !== passes[i]) return false;
  }
  return true;
}
function uniformStructureSignature(items) {
  return JSON.stringify((items || []).map(item => ({
    name:item.name, kind:item.kind, min:item.min, max:item.max, step:item.step
  })));
}
function componentValue(value, index) {
  return Array.isArray(value) ? value[index] : value;
}
function makeNumericUniformInput(item, index=null) {
  const input = document.createElement("input");
  const component = index === null ? 0 : index;
  const min = item.min == null ? null : componentValue(item.min, component);
  const max = item.max == null ? null : componentValue(item.max, component);
  input.type = min != null && max != null ? "range" : "number";
  if (min != null) input.min = min;
  if (max != null) input.max = max;
  input.step = item.step || (item.kind === "int" ? 1 : "any");
  input.dataset.uniform = item.name;
  if (index !== null) input.dataset.component = index;
  input.oninput = () => {
    const current = (status.uniforms || []).find(candidate => candidate.name === item.name);
    if (!current) return;
    if (index === null) {
      const value = item.kind === "int" ? Math.trunc(+input.value) : +input.value;
      send({type:"uniform", name:item.name, value:value});
    } else {
      const next = current.value.slice();
      next[index] = +input.value;
      send({type:"uniform", name:item.name, value:next});
    }
  };
  return input;
}
function rebuildUniforms(items) {
  uniforms.replaceChildren();
  uniformSection.style.display = items.length ? "" : "none";
  for (const item of items) {
    const entry = document.createElement("div");
    entry.className = "uniform-entry";
    const label = document.createElement("label");
    label.textContent = item.name;
    entry.appendChild(label);
    if (item.kind === "bool") {
      const wrap = document.createElement("div");
      wrap.className = "uniform-bool";
      const input = document.createElement("input");
      input.type = "checkbox";
      input.dataset.uniform = item.name;
      input.onchange = () => send({type:"uniform", name:item.name, value:input.checked});
      wrap.append(input, document.createTextNode("enabled"));
      entry.appendChild(wrap);
    } else if (item.kind.startsWith("vec")) {
      const count = +item.kind.slice(3);
      const vector = document.createElement("div");
      vector.className = "uniform-vector";
      for (let i = 0; i < count; i++) vector.appendChild(makeNumericUniformInput(item, i));
      entry.appendChild(vector);
    } else {
      entry.appendChild(makeNumericUniformInput(item));
    }
    uniforms.appendChild(entry);
  }
}
function updateUniforms(items) {
  const signature = uniformStructureSignature(items);
  if (signature !== uniformSignature) {
    uniformSignature = signature;
    rebuildUniforms(items);
  }
  uniforms.querySelectorAll("[data-uniform]").forEach(input => {
    const item = items.find(candidate => candidate.name === input.dataset.uniform);
    if (!item || document.activeElement === input) return;
    if (item.kind === "bool") input.checked = !!item.value;
    else {
      const component = input.dataset.component;
      input.value = component == null ? item.value : item.value[+component];
    }
  });
}

function stopWebcam() {
  if (webcamTimer !== null) clearInterval(webcamTimer);
  webcamTimer = null;
  if (webcamStream !== null) {
    for (const track of webcamStream.getTracks()) track.stop();
  }
  webcamStream = null;
  webcamVideo.srcObject = null;
  webcamToggle.textContent = "Start webcam";
}
async function startWebcam() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    throw new Error("getUserMedia is unavailable in this browser/context");
  }
  webcamStream = await navigator.mediaDevices.getUserMedia({
    video: {width:{ideal:320}, height:{ideal:240}},
    audio: false
  });
  webcamVideo.srcObject = webcamStream;
  webcamVideo.muted = true;
  webcamVideo.playsInline = true;
  await webcamVideo.play();
  webcamToggle.textContent = "Stop webcam";
  webcamTimer = setInterval(() => {
    if (ws.readyState !== WebSocket.OPEN || webcamVideo.readyState < 2) return;
    webcamContext.drawImage(webcamVideo, 0, 0, 320, 240);
    const rgba = webcamContext.getImageData(0, 0, 320, 240).data;
    ws.send(rgba.buffer);
  }, 100);
}
webcamToggle.onclick = async () => {
  if (webcamStream !== null) {
    stopWebcam();
    return;
  }
  try {
    await startWebcam();
  } catch (err) {
    error.textContent = "webcam failed: " + err;
    stopWebcam();
  }
};

function update(s) {
  status = s;
  meta.textContent = s.project + "\nframe " + s.frame + "  time " + s.time.toFixed(3) + "s\n" + s.width + "x" + s.height + " @ " + s.fps + " fps";
  error.textContent = s.error || "";
  pause.textContent = s.paused ? "Resume" : "Pause";
  if (document.activeElement !== width) width.value = s.width;
  if (document.activeElement !== height) height.value = s.height;
  if (document.activeElement !== scale) scale.value = s.time_scale;
  updateUniforms(s.uniforms || []);
  webcamSection.style.display = s.webcam ? "" : "none";
  if (!s.webcam && webcamStream !== null) stopWebcam();
  if (!samePassOptions(s.passes)) {
    const selected = view.value;
    view.replaceChildren(...s.passes.map(name => {
      const option = document.createElement("option");
      option.value = name;
      option.textContent = name;
      return option;
    }));
    view.value = s.view || selected || s.final_pass;
  } else if (document.activeElement !== view && view.value !== s.view) {
    view.value = s.view;
  }
}
async function queueFrame(payload) {
  pendingFrame = payload;
  if (decodingFrame) return;
  decodingFrame = true;
  try {
    while (pendingFrame !== null) {
      const next = pendingFrame;
      pendingFrame = null;
      const bitmap = await createImageBitmap(new Blob([next], {type:"image/png"}));
      if (frame.width !== bitmap.width || frame.height !== bitmap.height) {
        frame.width = bitmap.width;
        frame.height = bitmap.height;
      }
      frameContext.drawImage(bitmap, 0, 0);
      bitmap.close();
    }
  } finally {
    decodingFrame = false;
  }
}
ws.onmessage = event => {
  if (typeof event.data === "string") {
    update(JSON.parse(event.data));
  } else {
    queueFrame(event.data).catch(err => { error.textContent = "preview frame decode failed: " + err; });
  }
};
view.onchange = () => send({type:"view", pass:view.value});
pause.onclick = () => send({type: status && status.paused ? "resume" : "pause"});
document.getElementById("step").onclick = () => send({type:"step"});
document.getElementById("reset").onclick = () => send({type:"reset"});
document.getElementById("resolution").onclick = () => send({type:"resolution", width:+width.value, height:+height.value});
scale.oninput = () => send({type:"time-scale", value:+scale.value});

function pointer(event, clicked=false) {
  if (!status || !frame.width || !frame.height) return;
  const rect = frame.getBoundingClientRect();
  const x = (event.clientX - rect.left) / rect.width * status.width;
  const yTop = (event.clientY - rect.top) / rect.height * status.height;
  const y = status.height - yTop;
  send({type:"mouse", x:x, y:y, down:down, clicked:clicked});
}
frame.addEventListener("pointerdown", e => {
  down = true;
  frame.focus();
  frame.setPointerCapture(e.pointerId);
  pointer(e, true);
});
frame.addEventListener("pointerup", e => {
  if (!down) return;
  down = false;
  pointer(e, false);
  if (frame.hasPointerCapture(e.pointerId)) frame.releasePointerCapture(e.pointerId);
});
frame.addEventListener("pointercancel", e => {
  if (!down) return;
  down = false;
  pointer(e, false);
});
frame.addEventListener("pointermove", e => {
  if (e.pointerType === "touch") e.preventDefault();
  pointer(e, false);
});

function keyEvent(e, isDown) {
  if (["INPUT","SELECT","BUTTON"].includes(document.activeElement && document.activeElement.tagName)) return;
  const code = e.keyCode || e.which;
  if (code >= 0 && code <= 255) {
    send({type:"key", code:code, down:isDown, pressed:isDown && !e.repeat});
  }
}
window.addEventListener("keydown", e => keyEvent(e,true));
window.addEventListener("keyup", e => keyEvent(e,false));
window.addEventListener("beforeunload", stopWebcam);
</script>
</body>
</html>