1/* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 * panel.js â one viewer panel. 3 * 4 * A panel owns: 5 * * one <canvas> + WebGPU context (configured rgba16float / srgb / extended), 6 * * one render pipeline (keyed by its method's tone mapper), 7 * * one uniform buffer (its own copy, even if it currently shares values 8 * with other panels â this is what lets the per-method tone-mapper 9 * extension work without architectural rework), 10 * * an array of `frameCount` rgba16float textures (one per frame), 11 * * a sampler. 12 * 13 * It does NOT own playback state; the SyncController drives that. 14 * 15 * Lifecycle: 16 * panel = new Panel({ device, container, method }); 17 * await panel.loadSequence(url, fps, frameCount, onProgress); 18 * panel.setUniforms({ exposure: 1, ... }); 19 * panel.drawFrame(0); 20 * panel.unloadSequence(); // before loading a new scene 21 * panel.dispose(); // on tear-down 22 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 23 24window.App = window.App || {}; 25 26window.App.panel = (function () { 27 'use strict'; 28 29 const { getToneMapperForMethod, packUniforms } = window.App.toneMappers; 30 const { 31 configureCanvas, getOrCreatePipeline, buildBindGroup, drawFullscreen, 32 } = window.App.renderer; 33 const { decodeVideoToTextures, destroyTextures } = window.App.videoLoader; 34 35 class Panel { 36 constructor(args) { 37 const { device, container, method, toneMode } = args; 38 this.device = device; 39 this.method = method; 40 this.toneMapper = getToneMapperForMethod(method); 41 this.toneMode = toneMode || 'standard'; 42 43 // ââ DOM ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 44 this.root = document.createElement('div'); 45 this.root.className = 'panel panel-loading'; 46 this.root.dataset.method = method; 47 48 this.canvas = document.createElement('canvas'); 49 this.root.appendChild(this.canvas); 50 51 this.label = document.createElement('div'); 52 this.label.className = 'panel-overlay'; 53 this.label.textContent = method; 54 this.root.appendChild(this.label); 55 56 this.cornerLabel = document.createElement('div'); 57 this.cornerLabel.className = 'panel-overlay-corner'; 58 this.root.appendChild(this.cornerLabel); 59 60 container.appendChild(this.root); 61 62 // ââ GPU resources ââââââââââââââââââââââââââââââââââââââââââââââââ 63 this.ctx = this.canvas.getContext('webgpu'); 64 if (!this.ctx) throw new Error('Failed to get webgpu context for panel'); 65 configureCanvas(this.ctx, device, this.toneMode); 66 67 this.pipeline = getOrCreatePipeline(device, this.toneMapper); 68 this.sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' }); 69 70 // Uniform buffer: 4 floats = 16 bytes (matches gmVideoExp_cc.html `Uni`). 71 // Each panel keeps its own copy. Today they're written with the same 72 // values; this isolation is what enables the future per-method extension. 73 const uniformByteSize = this.toneMapper.uniformSchema.length * 4; 74 this.uniformBuffer = device.createBuffer({ 75 size: Math.max(16, uniformByteSize), // WebGPU minimum 76 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 77 }); 78 this.setUniforms(/* defaults */); 79 80 // ââ Sequence state (filled by loadSequence) ââââââââââââââââââââââ 81 this.textures = null; // GPUTexture[] | null 82 this.bindGroups = null; // GPUBindGroup[] | null (one per frame) 83 this.width = 0; 84 this.height = 0; 85 this.currentFrame = 0; 86 87 // Resize observer keeps canvas pixel size in sync with DOM size + DPR. 88 this._resizeObserver = new ResizeObserver(() => this._syncCanvasSize()); 89 this._resizeObserver.observe(this.root); 90 } 91 92 /** 93 * Load and decode a video into per-frame textures. 94 * 95 * @param source { url: string, dispose: () => void } â opaque source from 96 * config.videoSource(); the dispose() releases blob URLs once we're 97 * done with the video element. 98 */ 99 async loadSequence(source, fps, frameCount, onProgress) { 100 this.unloadSequence(); 101 this.root.classList.add('panel-loading'); 102 this.root.classList.remove('panel-error'); 103 delete this.root.dataset.error; 104 105 if (!source || !source.url) { 106 this.root.classList.remove('panel-loading'); 107 this.root.classList.add('panel-error'); 108 this.root.dataset.error = (source && source.error) || 'No video source'; 109 throw new Error(this.root.dataset.error); 110 } 111
112 try { 113 const { textures, width, height } = await decodeVideoToTextures({ 114 device: this.device, url: source.url, fps, frameCount, onProgress, 115 }); 116 117 this.textures = textures; 118 this.width = width; 119 this.height = height; 120 121 // Pre-build a bind group per frame (textures don't change after load). 122 this.bindGroups = textures.map(tex => buildBindGroup( 123 this.device, this.pipeline, this.sampler, tex.createView(), this.uniformBuffer, 124 )); 125 126 // Resolution badge was previously written here as `${w}x${h}`. We 127 // now keep the DOM node (other code paths still call setCornerInfo) 128 // but leave it empty so the published view has nothing overlaid in 129 // the bottom-right corner of each panel. 130 this.cornerLabel.textContent = ''; 131 this._syncCanvasSize(); 132 133 this.root.classList.remove('panel-loading'); 134 this.drawFrame(0); 135 } catch (err) { 136 this.root.classList.remove('panel-loading'); 137 this.root.classList.add('panel-error'); 138 this.root.dataset.error = err.message ? err.message : String(err); 139 throw err; 140 } finally { 141 // Release the blob URL (if any) â the video element has finished 142 // with it, and decoded frames live in GPU textures from here on. 143 if (source.dispose) source.dispose(); 144 } 145 } 146 147 unloadSequence() { 148 if (this.bindGroups) this.bindGroups = null; 149 if (this.textures) { 150 destroyTextures(this.textures); 151 this.textures = null; 152 } 153 this.cornerLabel.textContent = ''; 154 } 155 156 /** Write a uniform value object into this panel's uniform buffer. 157 Note: this DOES NOT redraw â call redraw() after if you need it. */ 158 setUniforms(values) { 159 this._lastUniforms = Object.assign({}, this._lastUniforms || {}, values || {}); 160 const arr = packUniforms(this.toneMapper, this._lastUniforms); 161 this.device.queue.writeBuffer( 162 this.uniformBuffer, 0, arr.buffer, arr.byteOffset, arr.byteLength, 163 ); 164 } 165 166 /** Draw frame `idx` into the panel's canvas. No-op if not loaded. */ 167 drawFrame(idx) { 168 if (!this.bindGroups || idx < 0 || idx >= this.bindGroups.length) return; 169 this.currentFrame = idx; 170 drawFullscreen(this.device, this.ctx, this.pipeline, this.bindGroups[idx]); 171 } 172 173 /** Re-render the current frame (after a uniform change, resize, etc.) */ 174 redraw() { this.drawFrame(this.currentFrame); } 175 176 /** 177 * Swap the active tone mapper. Rebuilds the render pipeline and bind 178 * groups; reuses the uniform buffer if the new mapper has the same 179 * uniform schema (otherwise reallocates). Triggers a redraw. 180 */ 181 swapToneMapper(mapper) { 182 if (!mapper) throw new Error('swapToneMapper: null mapper'); 183 if (mapper === this.toneMapper) return; 184 185 const oldSize = this.toneMapper.uniformSchema.length * 4; 186 const newSize = mapper.uniformSchema.length * 4; 187 if (newSize !== oldSize) { 188 if (this.uniformBuffer.destroy) this.uniformBuffer.destroy(); 189 this.uniformBuffer = this.device.createBuffer({ 190 size: Math.max(16, newSize), 191 usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 192 }); 193 } 194 195 this.toneMapper = mapper; 196 this.pipeline = getOrCreatePipeline(this.device, mapper); 197 this.setUniforms(this._lastUniforms || {}); 198 199 // Bind groups are tied to the pipeline's bind-group layout, so rebuild. 200 if (this.textures) { 201 this.bindGroups = this.textures.map(tex => buildBindGroup( 202 this.device, this.pipeline, this.sampler, tex.createView(), this.uniformBuffer, 203 )); 204 this.redraw(); 205 } 206 } 207 208 /** 209 * Rebuild the render pipeline and bind groups for the current tone 210 * mapper at the current linearize mode. Call this after globally 211 * changing the linearize mode (window.App.toneMappers.setLinearizeMode). 212 * 213 * The cache key in getOrCreatePipeline includes the linearize mode, 214 * so this either fetches a previously-built pipeline for the new 215 * mode or compiles a fresh shader. Bind groups need to be rebuilt 216 * because they're tied to the pipeline's bind-group layout (which 217 * may differ even if the layout: 'auto' result looks the same). 218 */ 219 refreshPipeline() { 220 this.pipeline = getOrCreatePipeline(this.device, this.toneMapper); 221 if (this.textures) { 222 this.bindGroups = this.textures.map(tex => buildBindGroup( 223 this.device, this.pipeline, this.sampler, tex.createView(), this.uniformBuffer, 224 )); 225 this.redraw(); 226 } 227 } 228 229 /** 230 * Switch the canvas's WebGPU tone-mapping mode. This is what flips 231 * the panel between SDR-clamped output ('standard') and HDR-headroom 232 * pass-through ('extended'). Re-configuring the canvas does NOT
233 * invalidate pipelines or bind groups, so we just reconfigure and 234 * redraw. 235 * 236 * We deliberately reconfigure UNCONDITIONALLY (no early-return on 237 * mode-match): WebGPU canvas state could conceivably drift out of 238 * sync with this.toneMode after a context-loss recovery or after 239 * the user re-picks the videos folder, and reconfiguring is cheap 240 * and idempotent. Skipping the redraw is what was masking earlier 241 * "stuck HDR" reports. 242 */ 243 setToneMode(mode) { 244 configureCanvas(this.ctx, this.device, mode); 245 this.toneMode = mode; 246 this.redraw(); 247 } 248 249 setCornerInfo(text) { 250 if (this.cornerLabel) this.cornerLabel.textContent = text; 251 } 252 253 /** Update the panel's method label overlay. Called when the method select changes. */ 254 setLabel(text) { 255 if (this.label) this.label.textContent = text; 256 } 257 258 setLabelVisible(visible) { 259 if (this.label) this.label.style.display = visible ? '' : 'none'; 260 } 261 262 // ââ internals ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 263 264 /** Match canvas pixel size to its CSS box, preserving aspect ratio of source. */ 265 _syncCanvasSize() { 266 if (!this.root || !this.canvas) return; // Safety: elements not found 267 if (!this.width || !this.height) { 268 const rect = this.root.getBoundingClientRect(); 269 const dpr = Math.min(window.devicePixelRatio || 1, 2); 270 this.canvas.width = Math.max(1, Math.floor(rect.width * dpr)); 271 this.canvas.height = Math.max(1, Math.floor(rect.height * dpr)); 272 return; 273 } 274 275 const aspect = this.width / this.height; 276 const rect = this.root.getBoundingClientRect(); 277 const dpr = Math.min(window.devicePixelRatio || 1, 2); // cap at 2x to spare GPU 278 279 // Width-driven sizing. The panel.root has height:auto and grows to 280 // contain the canvas; the CSS `max-height: 70vh` on .panel canvas 281 // caps runaway sizes for portrait clips. We deliberately do NOT 282 // clamp by rect.height here â that clamp produced a too-small 283 // initial size because the panel started at its min-height before 284 // the canvas pushed it taller. Width-driven sizing matches what 285 // the user sees after toggling Slider â Side. 286 let cssW = rect.width; 287 let cssH = rect.width / aspect; 288 this.canvas.style.width = `${cssW}px`; 289 this.canvas.style.height = `${cssH}px`; 290 this.canvas.width = Math.max(1, Math.floor(cssW * dpr)); 291 this.canvas.height = Math.max(1, Math.floor(cssH * dpr)); 292 293 if (this.bindGroups) this.redraw(); 294 } 295 296 dispose() { 297 this._resizeObserver.disconnect(); 298 this.unloadSequence(); 299 if (this.uniformBuffer.destroy) this.uniformBuffer.destroy(); 300 this.root.remove(); 301 } 302 } 303 304 return { Panel }; 305})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.