PageSourceSearch

https://sdr2hdrvideo.github.io/js/panel.js

js sdr2hdrvideo.github.io collected 2026-10-03 09:55:22 UTC 12,966 bytes, 305 lines download raw bytes

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.