<!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: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--panel: #111821;
--panel-raised: #17212c;
--line: #2b3948;
--text: #edf5fb;
--muted: #92a5b5;
--accent: #68c7ed;
--accent-soft: #173848;
--danger: #ff9c9c;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: #0b1118;
color: var(--text);
height: 100vh;
height: 100dvh;
display: grid;
grid-template-columns: minmax(280px, 316px) minmax(0, 1fr);
overflow: hidden;
}
aside {
padding: 18px;
border-right: 1px solid var(--line);
background: linear-gradient(180deg, #121b25 0%, #0f161e 100%);
overflow: auto;
overscroll-behavior: contain;
}
main {
min-width: 0;
display: grid;
place-items: center;
overflow: hidden;
background:
linear-gradient(45deg, #0a0f15 25%, transparent 25%),
linear-gradient(-45deg, #0a0f15 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #0a0f15 75%),
linear-gradient(-45deg, transparent 75%, #0a0f15 75%),
#080c11;
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
}
#frame {
max-width: 100%;
max-height: 100%;
user-select: none;
outline: none;
display: block;
touch-action: none;
box-shadow: 0 0 0 1px #26323f, 0 20px 70px #0008;
}
.review-header { padding: 2px 2px 14px; border-bottom: 1px solid var(--line); }
.eyebrow {
color: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
}
h1 { font-size: 18px; line-height: 1.2; margin: 6px 0 10px; font-weight: 650; }
.status-strip { display: flex; flex-wrap: wrap; gap: 5px; }
.badge {
background: #0b1219;
border: 1px solid #2c3c4c;
border-radius: 999px;
color: #b8c8d5;
font: 10px/1 var(--mono);
padding: 5px 7px;
}
.badge.accent { background: var(--accent-soft); border-color: #286077; color: #a6e5ff; }
.panel {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--line);
background: #111923c9;
border-radius: 8px;
}
.panel h2 {
margin: 0 0 8px;
color: #cfdae3;
font-family: var(--mono);
font-size: 10px;
letter-spacing: .1em;
text-transform: uppercase;
}
label { display: block; font-size: 11px; color: var(--muted); margin-top: 9px; }
label:first-of-type { margin-top: 0; }
select, input, button {
width: 100%;
margin-top: 5px;
background: var(--panel-raised);
color: var(--text);
border: 1px solid #354657;
border-radius: 5px;
padding: 7px 8px;
min-height: 36px;
font: 12px/1.2 var(--mono);
}
select:focus-visible, input:focus-visible, button:focus-visible, #frame:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
button { cursor: pointer; touch-action: manipulation; }
button:hover { background: #202e3c; border-color: #476078; }
button.primary { background: #163547; border-color: #2e708d; color: #c5ecff; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.resolution-row input { text-align: right; }
.hint {
margin-top: 7px;
color: #7890a3;
font: 10px/1.45 var(--mono);
}
.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; margin-top: 6px; }
.uniform-bool input { width: auto; min-height: 0; margin: 0; }
.meta {
margin-top: 12px;
padding: 10px 11px;
border-left: 2px solid #355267;
background: #0b1219;
font: 10px/1.65 var(--mono);
color: #8fa4b5;
white-space: pre-line;
}
#error {
margin-top: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--danger);
font: 10px/1.5 var(--mono);
}
@media (max-width: 720px) {
body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
aside {
max-height: 48dvh;
padding: 10px 12px;
border-right: 0;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 10px;
align-content: start;
}
.review-header { grid-column: 1 / -1; }
.panel { margin-top: 0; }
.meta, #error { grid-column: 1 / -1; margin-top: 0; }
select, input, button { min-height: 42px; font-size: 16px; }
main { min-height: 0; }
}
</style>
</head>
<body>
<aside>
<header class="review-header">
<div class="eyebrow">Human review</div>
<h1 id="project-title">ShaderToy native preview</h1>
<div class="status-strip">
<span class="badge accent" id="quality-badge">base</span>
<span class="badge" id="resolution-badge">1280×720</span>
<span class="badge" id="fps-badge">— fps</span>
</div>
</header>
<section class="panel" id="preset-section" style="display:none">
<h2>Quality</h2>
<label>Preset<select id="preset"></select></label>
<div class="hint" id="preset-hint">Switch quality tiers without restarting preview.</div>
</section>
<section class="panel">
<h2>Review output</h2>
<label>Resolution
<select id="resolution-mode">
<option value="project">Project output</option>
<option value="640x360">640 × 360</option>
<option value="960x540">960 × 540</option>
<option value="1280x720">1280 × 720</option>
<option value="1600x900">1600 × 900</option>
<option value="1920x1080">1920 × 1080</option>
<option value="2560x1440">2560 × 1440</option>
<option value="custom">Custom…</option>
</select>
</label>
<div class="row resolution-row">
<label>Width<input id="width" aria-label="Preview width" inputmode="numeric" type="number" min="1" max="4096"></label>
<label>Height<input id="height" aria-label="Preview height" inputmode="numeric" type="number" min="1" max="4096"></label>
</div>
<button class="primary" id="resolution">Apply custom size</button>
<div class="hint" id="resolution-hint"></div>
</section>
<section class="panel">
<h2>Playback</h2>
<label>View<select id="view"></select></label>
<div class="row">
<button id="pause">Pause</button>
<button id="step">Step</button>
</div>
<button id="reset">Reset simulation</button>
<label>Time scale (log2)<input id="scale" type="range" min="-4" max="4" step="0.25" value="0"></label>
</section>
<section class="panel" id="uniform-section">
<h2>Parameters</h2>
<div id="uniforms"></div>
</section>
<section class="panel" id="webcam-section" style="display:none">
<h2>Input</h2>
<button id="webcam-toggle">Start webcam</button>
</section>
<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");
let frameBitmapContext = null;
let frameContext = null;
let rawRenderer = null;
function ensureImageRenderer() {
if (frameBitmapContext || frameContext) return;
frameBitmapContext = frame.getContext("bitmaprenderer");
if (!frameBitmapContext) frameContext = frame.getContext("2d", {alpha:false});
if (!frameBitmapContext && !frameContext) throw new Error("canvas image rendering is unavailable");
}
function ensureRawRenderer() {
if (rawRenderer) return rawRenderer;
const gl = frame.getContext("webgl2", {
alpha:false,
antialias:false,
depth:false,
stencil:false,
preserveDrawingBuffer:false
});
if (!gl) {
const context = frame.getContext("2d", {alpha:false});
if (!context) throw new Error("canvas rendering is unavailable");
rawRenderer = {gl:null, context, width:0, height:0};
return rawRenderer;
}
const vertex = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertex,
"#version 300 es\n" +
"precision highp float;\n" +
"const vec2 p[4]=vec2[4](vec2(-1.0,-1.0),vec2(1.0,-1.0),vec2(-1.0,1.0),vec2(1.0,1.0));\n" +
"const vec2 uv[4]=vec2[4](vec2(0.0,0.0),vec2(1.0,0.0),vec2(0.0,1.0),vec2(1.0,1.0));\n" +
"out vec2 vUv;\n" +
"void main(){gl_Position=vec4(p[gl_VertexID],0.0,1.0);vUv=uv[gl_VertexID];}\n"
);
gl.compileShader(vertex);
if (!gl.getShaderParameter(vertex, gl.COMPILE_STATUS)) {
throw new Error("raw preview vertex shader failed: " + gl.getShaderInfoLog(vertex));
}
const fragment = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragment,
"#version 300 es\n" +
"precision highp float;\n" +
"uniform sampler2D uFrame;\n" +
"in vec2 vUv;\n" +
"out vec4 fragColor;\n" +
"void main(){fragColor=texture(uFrame,vUv);}\n"
);
gl.compileShader(fragment);
if (!gl.getShaderParameter(fragment, gl.COMPILE_STATUS)) {
throw new Error("raw preview fragment shader failed: " + gl.getShaderInfoLog(fragment));
}
const program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error("raw preview program failed: " + gl.getProgramInfoLog(program));
}
gl.useProgram(program);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
rawRenderer = {gl, texture, vao, width:0, height:0};
return rawRenderer;
}
const view = document.getElementById("view");
const presetSelect = document.getElementById("preset");
const presetSection = document.getElementById("preset-section");
const resolutionMode = document.getElementById("resolution-mode");
const resolutionHint = document.getElementById("resolution-hint");
const presetHint = document.getElementById("preset-hint");
const projectTitle = document.getElementById("project-title");
const qualityBadge = document.getElementById("quality-badge");
const resolutionBadge = document.getElementById("resolution-badge");
const fpsBadge = document.getElementById("fps-badge");
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;
const displayedFrameTimes = [];
let actualFps = 0;
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 updatePresetOptions(presets, selected) {
const values = ["", ...(presets || [])];
let same = presetSelect.options.length === values.length;
if (same) {
for (let i = 0; i < values.length; i++) {
if (presetSelect.options[i].value !== values[i]) {
same = false;
break;
}
}
}
if (!same) {
presetSelect.replaceChildren(...values.map(name => {
const option = document.createElement("option");
option.value = name;
option.textContent = name || "(base)";
return option;
}));
}
presetSelect.value = selected || "";
presetSection.style.display = (presets || []).length ? "" : "none";
}
const REVIEW_RESOLUTIONS = new Set(["640x360", "960x540", "1280x720", "1600x900", "1920x1080", "2560x1440"]);
function updateResolutionControls(s) {
const key = s.width + "x" + s.height;
const mode = s.custom_resolution ? (REVIEW_RESOLUTIONS.has(key) ? key : "custom") : "project";
if (document.activeElement !== resolutionMode) resolutionMode.value = mode;
if (document.activeElement !== width) width.value = s.width;
if (document.activeElement !== height) height.value = s.height;
const requested = s.preset_width + "×" + s.preset_height;
const actual = s.width + "×" + s.height;
resolutionHint.textContent = s.custom_resolution
? "Review override active · switching quality keeps " + actual + "."
: (requested === actual
? "Project output locked at " + actual + "."
: "Project output locked at " + actual + " · preset final-output scaling to " + requested + " is ignored for fair review.");
presetHint.textContent = "Switch tiers live; final output stays fixed so quality/performance comparisons remain apples-to-apples.";
}
function applyCustomResolution() {
const nextWidth = +width.value;
const nextHeight = +height.value;
resolutionMode.value = "custom";
send({type:"resolution", width:nextWidth, height:nextHeight});
}
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 renderMeta() {
if (!status) return;
const actual = status.paused ? "paused" : actualFps.toFixed(1) + " actual fps";
projectTitle.textContent = status.project || "ShaderToy native preview";
qualityBadge.textContent = status.preset || "base";
resolutionBadge.textContent = status.width + "×" + status.height + (status.custom_resolution ? " review" : " project");
fpsBadge.textContent = status.paused ? "paused" : actualFps.toFixed(1) + " fps";
meta.textContent = "frame " + status.frame + " time " + status.time.toFixed(3) + "s"
+ "\n" + actual + " · target " + status.fps + " fps · " + status.transport + " transport";
}
function refreshActualFps(now=performance.now()) {
const cutoff = now - 1000;
while (displayedFrameTimes.length && displayedFrameTimes[0] < cutoff) {
displayedFrameTimes.shift();
}
if (displayedFrameTimes.length >= 2) {
const elapsed = displayedFrameTimes[displayedFrameTimes.length - 1] - displayedFrameTimes[0];
actualFps = elapsed > 0 ? (displayedFrameTimes.length - 1) * 1000 / elapsed : 0;
} else {
actualFps = 0;
}
renderMeta();
}
function update(s) {
status = s;
if (s.paused) {
displayedFrameTimes.length = 0;
actualFps = 0;
}
renderMeta();
error.textContent = s.error || "";
pause.textContent = s.paused ? "Resume" : "Pause";
updateResolutionControls(s);
if (document.activeElement !== scale) scale.value = s.time_scale;
updateUniforms(s.uniforms || []);
updatePresetOptions(s.presets || [], s.preset);
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;
}
}
function drawRawFrame(next) {
if (next.byteLength < 8) throw new Error("raw RGB frame is truncated");
const header = new DataView(next, 0, 8);
const nextWidth = header.getUint32(0, true);
const nextHeight = header.getUint32(4, true);
const expected = 8 + nextWidth * nextHeight * 3;
if (next.byteLength !== expected) {
throw new Error("raw RGB frame has " + next.byteLength + " bytes, expected " + expected);
}
const renderer = ensureRawRenderer();
const pixels = new Uint8Array(next, 8);
if (!renderer.gl) {
if (frame.width !== nextWidth || frame.height !== nextHeight) {
frame.width = nextWidth;
frame.height = nextHeight;
}
const rgba = new Uint8ClampedArray(nextWidth * nextHeight * 4);
for (let y = 0; y < nextHeight; y++) {
let src = (nextHeight - 1 - y) * nextWidth * 3;
let dst = y * nextWidth * 4;
for (let x = 0; x < nextWidth; x++, src += 3, dst += 4) {
rgba[dst] = pixels[src];
rgba[dst + 1] = pixels[src + 1];
rgba[dst + 2] = pixels[src + 2];
rgba[dst + 3] = 255;
}
}
renderer.context.putImageData(new ImageData(rgba, nextWidth, nextHeight), 0, 0);
return;
}
const gl = renderer.gl;
if (frame.width !== nextWidth || frame.height !== nextHeight) {
frame.width = nextWidth;
frame.height = nextHeight;
gl.viewport(0, 0, nextWidth, nextHeight);
}
gl.bindTexture(gl.TEXTURE_2D, renderer.texture);
if (renderer.width !== nextWidth || renderer.height !== nextHeight) {
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGB8,
nextWidth, nextHeight, 0,
gl.RGB, gl.UNSIGNED_BYTE, pixels
);
renderer.width = nextWidth;
renderer.height = nextHeight;
} else {
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0,
nextWidth, nextHeight,
gl.RGB, gl.UNSIGNED_BYTE, pixels
);
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
async function drawCompressedFrame(next, mime) {
ensureImageRenderer();
const bitmap = await createImageBitmap(new Blob([next], {type:mime}));
if (frame.width !== bitmap.width || frame.height !== bitmap.height) {
frame.width = bitmap.width;
frame.height = bitmap.height;
}
if (frameBitmapContext) {
frameBitmapContext.transferFromImageBitmap(bitmap);
} else {
frameContext.drawImage(bitmap, 0, 0);
bitmap.close();
}
}
async function queueFrame(payload) {
pendingFrame = payload;
if (decodingFrame) return;
decodingFrame = true;
try {
while (pendingFrame !== null) {
const next = pendingFrame;
pendingFrame = null;
const transport = status?.transport || "mjpeg";
if (transport === "raw") {
drawRawFrame(next);
} else {
await drawCompressedFrame(next, transport === "png" ? "image/png" : "image/jpeg");
}
const now = performance.now();
displayedFrameTimes.push(now);
refreshActualFps(now);
}
} 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; });
}
};
presetSelect.onchange = () => send({type:"preset", preset:presetSelect.value || null});
resolutionMode.onchange = () => {
const value = resolutionMode.value;
if (value === "project") {
send({type:"resolution-default"});
} else if (value === "custom") {
width.focus();
width.select();
} else {
const [nextWidth, nextHeight] = value.split("x").map(Number);
send({type:"resolution", width:nextWidth, height:nextHeight});
}
};
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 = applyCustomResolution;
for (const input of [width, height]) {
input.addEventListener("input", () => { resolutionMode.value = "custom"; });
input.addEventListener("keydown", event => {
if (event.key === "Enter") applyCustomResolution();
});
}
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));
setInterval(() => refreshActualFps(), 250);
window.addEventListener("beforeunload", stopWebcam);
</script>
</body>
</html>