diff --git a/README.md b/README.md index 6c5be1b..9e986e6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The app can run in two modes: - Synchronize multiple camera segments on a shared timeline - Scrub, zoom, pan, change playback speed, and frame-step footage - Reorder, hide/show, expand, and manually lay out camera tiles -- Use region zoom, scroll zoom, and fisheye dewarp tools +- Use region zoom and scroll zoom tools - Verify signed exports offline and optionally confirm certificates with Alta's cloud verification endpoint - Preserve sessions across refreshes with IndexedDB diff --git a/templates/index.html b/templates/index.html index 9fb14e0..da666e2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 = ' 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 = '';