Remove fisheye dewarp feature

Remove the per-camera fisheye dewarp tool: the WebGL dewarp module
(shaders, init/render/toggle, per-channel dewarpState), the Dewarp
button, its CSS, the dewarp branches interleaved into the shared
wheel/dblclick/mousedown/mousemove/mouseup handlers, and the WebGL
context cleanup in newSession(). Normal digital zoom/pan, drag-reorder,
magnifier, and expand are unaffected. Also drop the dewarp mention from
the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 08:39:12 -04:00
parent db754b2bae
commit e7dee4f5db
2 changed files with 3 additions and 333 deletions
+2 -332
View File
@@ -281,29 +281,6 @@
}
.cam-cell .zoom-indicator.visible { display: block; }
/* Dewarp button & canvas */
.dewarp-btn {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.75); border: 1px solid var(--border-default); color: var(--text-secondary);
padding: 4px 12px; border-radius: 4px; font-size: 11px; font-weight: 600;
cursor: pointer; z-index: 5; opacity: 0; transition: opacity 0.15s;
display: flex; align-items: center; gap: 5px; letter-spacing: 0.3px;
pointer-events: none;
}
.cam-cell:hover .dewarp-btn { opacity: 1; pointer-events: auto; }
.dewarp-btn:hover { background: rgba(0,0,0,0.9); color: var(--text-primary); border-color: var(--accent-primary); box-shadow: var(--shadow-focus); }
.dewarp-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); opacity: 1; pointer-events: auto; }
.dewarp-btn svg { width: 14px; height: 14px; fill: currentColor; }
.dewarp-canvas {
position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1;
display: none;
}
.dewarp-canvas.active { display: block; }
.cam-cell.dewarped video { visibility: hidden; }
.cam-cell.dewarped .no-signal { z-index: 0; }
.cam-cell.dewarped { cursor: grab; }
.cam-cell.dewarped.panning { cursor: grabbing; }
/* Magnifier selection rectangle */
.mag-selection {
position: absolute; border: 2px solid var(--accent-primary); background: rgba(0,110,215,0.12);
@@ -1873,26 +1850,11 @@
cell.appendChild(segInfo);
ch._segInfoEl = segInfo;
// Dewarp button
const dewarpBtn = document.createElement('button');
dewarpBtn.className = 'dewarp-btn';
dewarpBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg> Dewarp';
dewarpBtn.title = 'Toggle fisheye dewarp';
dewarpBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDewarp(cell, ch.name);
});
cell.appendChild(dewarpBtn);
setupDragHandle(cell);
cell.addEventListener('click', (e) => {
if (magActive) return;
if (e.target.closest('.drag-handle')) return;
if (e.target.closest('.dewarp-btn')) return;
// Don't expand when dewarped (click is used for pan)
const _ds = dewarpState.get(ch.name);
if (_ds && _ds.active) return;
const z = getZoom(cell);
if (z.scale > 1) return;
const wasExpanded = cell.classList.contains('expanded');
@@ -2138,10 +2100,9 @@
if (isPlaying) seg.videoEl.play();
}
}
// Re-apply zoom to newly visible video (skip if dewarped)
// Re-apply zoom to newly visible video
if (ch.cellEl) {
const _ds = dewarpState.get(ch.name);
if (!_ds || !_ds.active) applyZoom(ch.cellEl);
applyZoom(ch.cellEl);
}
}
@@ -2505,14 +2466,6 @@
if (!cell) return;
e.preventDefault();
// Dewarp zoom: adjust FOV
const ds = getDewarpForCell(cell);
if (ds && ds.active) {
const delta = e.deltaY > 0 ? 0.05 : -0.05;
ds.fov = Math.max(0.2, Math.min(Math.PI, ds.fov + delta));
return;
}
const z = getZoom(cell);
const rect = cell.getBoundingClientRect();
const mx = ((e.clientX - rect.left) / rect.width) * 100;
@@ -2533,15 +2486,6 @@
cameraGrid.addEventListener('dblclick', e => {
const cell = e.target.closest('.cam-cell');
if (!cell) return;
// Dewarp: reset view on double-click
const ds = getDewarpForCell(cell);
if (ds && ds.active) {
e.stopPropagation();
ds.yaw = 0;
ds.pitch = 0;
ds.fov = Math.PI * 0.5;
return;
}
const z = getZoom(cell);
if (z.scale > 1) {
e.stopPropagation();
@@ -2557,24 +2501,9 @@
cameraGrid.addEventListener('mousedown', e => {
if (magActive) return; // magnifier takes priority
if (e.target.closest('.drag-handle')) return; // drag handle takes priority
if (e.target.closest('.dewarp-btn')) return; // dewarp button takes priority
const cell = e.target.closest('.cam-cell');
if (!cell) return;
// Dewarp pan
const ds = getDewarpForCell(cell);
if (ds && ds.active) {
e.preventDefault();
e.stopPropagation();
dewarpPanCell = cell;
dewarpPanStartX = e.clientX;
dewarpPanStartY = e.clientY;
dewarpPanStartYaw = ds.yaw;
dewarpPanStartPitch = ds.pitch;
cell.classList.add('panning');
return;
}
const z = getZoom(cell);
if (z.scale <= 1) return;
@@ -2589,18 +2518,6 @@
});
document.addEventListener('mousemove', e => {
// Dewarp pan
if (dewarpPanCell) {
const ds = getDewarpForCell(dewarpPanCell);
if (ds) {
const rect = dewarpPanCell.getBoundingClientRect();
const sensitivity = ds.fov / rect.width;
ds.yaw = dewarpPanStartYaw - (e.clientX - dewarpPanStartX) * sensitivity;
ds.pitch = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45,
dewarpPanStartPitch + (e.clientY - dewarpPanStartY) * sensitivity));
}
return;
}
if (!panCell) return;
const z = getZoom(panCell);
const rect = panCell.getBoundingClientRect();
@@ -2614,10 +2531,6 @@
});
document.addEventListener('mouseup', () => {
if (dewarpPanCell) {
dewarpPanCell.classList.remove('panning');
dewarpPanCell = null;
}
if (panCell) {
panCell.classList.remove('panning');
panCell = null;
@@ -2709,237 +2622,6 @@
toggleMagnifier();
});
// ─── Fisheye Dewarp (WebGL) ───
const dewarpState = new Map(); // keyed by channel name
function getDewarpState(chName) {
if (!dewarpState.has(chName)) {
dewarpState.set(chName, {
active: false,
canvas: null,
gl: null,
program: null,
texture: null,
animFrameId: null,
// View: yaw/pitch in radians, fov in radians
yaw: 0,
pitch: 0,
fov: Math.PI * 0.5, // 90° default FOV
});
}
return dewarpState.get(chName);
}
const DEWARP_VS = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
`;
// Equidistant fisheye → rectilinear with pan/tilt/zoom
// Maps each output pixel to a direction, then finds the corresponding
// point on the fisheye source image.
const DEWARP_FS = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform float uYaw; // horizontal pan (radians)
uniform float uPitch; // vertical tilt (radians)
uniform float uFov; // field of view (radians)
uniform float uAspect; // video aspect ratio (w/h)
void main() {
// Map UV to normalized device coords centered at 0
vec2 ndc = (vUv - 0.5) * 2.0;
ndc.x *= uAspect;
// Convert screen point to 3D ray direction (rectilinear)
float halfFov = uFov * 0.5;
float d = 1.0 / tan(halfFov);
vec3 ray = normalize(vec3(ndc.x, ndc.y, d));
// Rotate ray by pitch (around X axis)
float cp = cos(uPitch), sp = sin(uPitch);
ray = vec3(ray.x, cp * ray.y - sp * ray.z, sp * ray.y + cp * ray.z);
// Rotate ray by yaw (around Y axis)
float cy = cos(uYaw), sy = sin(uYaw);
ray = vec3(cy * ray.x + sy * ray.z, ray.y, -sy * ray.x + cy * ray.z);
// 3D ray → equidistant fisheye coordinates
// theta = angle from optical axis (Z+), phi = azimuth
float theta = acos(clamp(ray.z, -1.0, 1.0));
float phi = atan(ray.y, ray.x);
// Equidistant projection: r = theta / (pi/2) maps hemisphere to unit circle
// We use pi to support full 360° fisheye
float r = theta / 3.14159265;
// Map to texture coordinates (fisheye circle centered in frame)
vec2 fishUv = vec2(0.5) + r * vec2(cos(phi), sin(phi));
// Account for non-square video: fisheye circle fits the shorter dimension
// If video is wider than tall, squeeze x; if taller, squeeze y
if (uAspect > 1.0) {
fishUv.x = 0.5 + (fishUv.x - 0.5) / uAspect;
} else {
fishUv.y = 0.5 + (fishUv.y - 0.5) * uAspect;
}
// Clamp to valid range and fade edges
float dist = length(fishUv - 0.5) * 2.0;
if (fishUv.x < 0.0 || fishUv.x > 1.0 || fishUv.y < 0.0 || fishUv.y > 1.0 || dist > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Flip Y for WebGL texture coordinates
fishUv.y = 1.0 - fishUv.y;
gl_FragColor = texture2D(uTex, fishUv);
}
`;
function compileShader(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
avpLog('error', 'Shader compile error: ' + gl.getShaderInfoLog(s));
gl.deleteShader(s);
return null;
}
return s;
}
function initDewarpGL(cell) {
const canvas = document.createElement('canvas');
canvas.className = 'dewarp-canvas';
cell.appendChild(canvas);
const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false });
if (!gl) { avpLog('error', 'WebGL not available'); return null; }
const vs = compileShader(gl, gl.VERTEX_SHADER, DEWARP_VS);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, DEWARP_FS);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
avpLog('error', 'Program link error: ' + gl.getProgramInfoLog(prog));
return null;
}
gl.useProgram(prog);
// Full-screen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// Texture
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return { canvas, gl, program: prog, texture: tex };
}
function renderDewarpFrame(chName) {
const ds = dewarpState.get(chName);
if (!ds || !ds.active) return;
const ch = channels.get(chName);
if (!ch || !ch.cellEl) return;
const video = ch.cellEl.querySelector('video:not(.hidden-video)');
if (!video || video.readyState < 2) {
ds.animFrameId = requestAnimationFrame(() => renderDewarpFrame(chName));
return;
}
const { canvas, gl, program, texture } = ds;
// Resize canvas to match cell
const rect = ch.cellEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
// Upload video frame
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
// Set uniforms
gl.useProgram(program);
gl.uniform1f(gl.getUniformLocation(program, 'uYaw'), ds.yaw);
gl.uniform1f(gl.getUniformLocation(program, 'uPitch'), ds.pitch);
gl.uniform1f(gl.getUniformLocation(program, 'uFov'), ds.fov);
const videoAspect = video.videoWidth / (video.videoHeight || 1);
gl.uniform1f(gl.getUniformLocation(program, 'uAspect'), videoAspect);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
ds.animFrameId = requestAnimationFrame(() => renderDewarpFrame(chName));
}
function toggleDewarp(cell, chName) {
const ds = getDewarpState(chName);
ds.active = !ds.active;
const btn = cell.querySelector('.dewarp-btn');
if (btn) btn.classList.toggle('active', ds.active);
if (ds.active) {
// Initialize WebGL if needed
if (!ds.gl) {
const init = initDewarpGL(cell);
if (!init) { ds.active = false; btn?.classList.remove('active'); return; }
Object.assign(ds, init);
}
ds.canvas.classList.add('active');
cell.classList.add('dewarped');
// Reset normal CSS zoom — dewarp has its own zoom
resetZoom(cell);
// Start render loop
renderDewarpFrame(chName);
} else {
// Stop render loop
if (ds.animFrameId) { cancelAnimationFrame(ds.animFrameId); ds.animFrameId = null; }
if (ds.canvas) ds.canvas.classList.remove('active');
cell.classList.remove('dewarped');
// Reset dewarp view
ds.yaw = 0;
ds.pitch = 0;
ds.fov = Math.PI * 0.5;
}
}
// ─── Dewarp zoom (scroll wheel) and pan (click+drag) ───
let dewarpPanCell = null;
let dewarpPanStartX = 0, dewarpPanStartY = 0;
let dewarpPanStartYaw = 0, dewarpPanStartPitch = 0;
function getDewarpForCell(cell) {
const chName = findChForCell(cell);
return chName ? dewarpState.get(chName) : null;
}
// ─── Camera Reorder (drag & drop) ───
let dragSrcCell = null;
@@ -3817,18 +3499,6 @@
tlZoom = 1;
tlViewCenter = 0;
// Cleanup dewarp state
for (const [, ds] of dewarpState) {
if (ds.animFrameId) cancelAnimationFrame(ds.animFrameId);
if (ds.gl) {
const ext = ds.gl.getExtension('WEBGL_lose_context');
if (ext) ext.loseContext(); // force WebGL context release
ds.gl.deleteTexture(ds.texture);
ds.gl.deleteProgram(ds.program);
}
}
dewarpState.clear();
// Reset DOM — destroy any remaining video elements first
cameraGrid.querySelectorAll('video').forEach(v => destroyVideoEl(v));
cameraGrid.innerHTML = '';