<!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>