Nicholas Celestin commited on
Commit
3f22414
·
1 Parent(s): e6167a7

Build update — 2026-05-22T18:34:00.912Z

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +12 -33
  2. README.md +22 -6
  3. assets/favicon-180x180.png +3 -0
  4. assets/favicon-32x32.png +3 -0
  5. assets/favicon.ico +3 -0
  6. assets/favicon.svg +42 -0
  7. assets/shared.css +33 -0
  8. components/compare-slider.js +469 -0
  9. components/image-cropper.js +243 -0
  10. components/image-drop-zone.js +90 -0
  11. components/status-bar.js +179 -0
  12. components/view-mode-controls.js +97 -0
  13. features/bg-removal/bg-removal-app.js +564 -0
  14. features/bg-removal/bg-removal-engine.js +230 -0
  15. features/upscaler/custom-models/custom-model-inspector.js +336 -0
  16. features/upscaler/custom-models/custom-model-store.js +205 -0
  17. features/upscaler/custom-models/custom-model-upload-dialog.js +460 -0
  18. features/upscaler/engine/face-detector-engine.js +368 -0
  19. features/upscaler/engine/gpu-frame-extractor.js +201 -0
  20. features/upscaler/engine/gpu-tile-renderer.js +251 -0
  21. features/upscaler/engine/tiling.js +168 -0
  22. features/upscaler/engine/upscaler-engine.js +924 -0
  23. features/upscaler/model-registry.js +135 -0
  24. features/upscaler/ui/perf-monitor.js +305 -0
  25. features/upscaler/ui/upscale-preview.js +117 -0
  26. features/upscaler/ui/upscaler-canvas-area.js +146 -0
  27. features/upscaler/ui/upscaler-controls.js +874 -0
  28. features/upscaler/ui/upscaler-toolbar.js +247 -0
  29. features/upscaler/upscale-pipeline.js +536 -0
  30. features/upscaler/upscaler-app.js +558 -0
  31. index.html +87 -18
  32. lib/backend-events.js +134 -0
  33. lib/backend.js +207 -0
  34. lib/canvas.js +175 -0
  35. lib/fetch-progress.js +121 -0
  36. lib/morph.js +32 -0
  37. lib/onnx-meta.js +18 -0
  38. models/4x-ClearRealityV1.onnx +3 -0
  39. models/4x-UltraSharpV2_Lite.onnx +3 -0
  40. models/4x-UpdraftSmall.onnx +3 -0
  41. models/DAT_light_x4_dyn_OTF_4.onnx +3 -0
  42. style.css +0 -28
  43. vendor/fflate/index.mjs +2665 -0
  44. vendor/font-awesome/css/all.min.css +0 -0
  45. vendor/font-awesome/webfonts/fa-brands-400.ttf +3 -0
  46. vendor/font-awesome/webfonts/fa-brands-400.woff2 +3 -0
  47. vendor/font-awesome/webfonts/fa-regular-400.ttf +3 -0
  48. vendor/font-awesome/webfonts/fa-regular-400.woff2 +3 -0
  49. vendor/font-awesome/webfonts/fa-solid-900.ttf +3 -0
  50. vendor/font-awesome/webfonts/fa-solid-900.woff2 +3 -0
.gitattributes CHANGED
@@ -1,35 +1,14 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
  *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
  *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.onnx filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.wasm filter=lfs diff=lfs merge=lfs -text
3
+ *.ttf filter=lfs diff=lfs merge=lfs -text
4
+ *.woff filter=lfs diff=lfs merge=lfs -text
5
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
6
+ *.eot filter=lfs diff=lfs merge=lfs -text
7
+ *.otf filter=lfs diff=lfs merge=lfs -text
8
+ *.png filter=lfs diff=lfs merge=lfs -text
9
+ *.jpg filter=lfs diff=lfs merge=lfs -text
10
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
11
+ *.gif filter=lfs diff=lfs merge=lfs -text
12
+ *.ico filter=lfs diff=lfs merge=lfs -text
13
+ *.webp filter=lfs diff=lfs merge=lfs -text
14
+ *.bin filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,12 +1,28 @@
1
  ---
2
- title: Updraft
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: static
7
  pinned: false
8
  license: mit
9
- short_description: Updraft Local In-Browser Upscaling
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Updraft Upscaler
3
+ colorFrom: blue
4
+ colorTo: indigo
 
5
  sdk: static
6
  pinned: false
7
  license: mit
8
+ short_description: In-browser image upscaler — no upload, no install
9
+ custom_headers:
10
+ cross-origin-embedder-policy: require-corp
11
+ cross-origin-opener-policy: same-origin
12
+ cross-origin-resource-policy: cross-origin
13
  ---
14
 
15
+ # Updraft
16
+
17
+ In-browser image upscaler running ONNX models client-side. Your images never leave your device.
18
+
19
+ This Space ships a curated subset of models. For the full model lineup (including the larger DAT and TinySR diffusion-refiner models) plus video upscaling, use the main site:
20
+
21
+ → **https://nickcelestin.com/applications/aitools/index.html**
22
+
23
+ ## Models in this Space
24
+
25
+ - `models/4x-UpdraftSmall.onnx`
26
+ - `models/4x-ClearRealityV1.onnx`
27
+ - `models/DAT_light_x4_dyn_OTF_4.onnx`
28
+ - `models/4x-UltraSharpV2_Lite.onnx`
assets/favicon-180x180.png ADDED

Git LFS Details

  • SHA256: 77c0f06811ab7652e4c21745bf07ef502193cd7db606695c57c29b8aa0aee813
  • Pointer size: 130 Bytes
  • Size of remote file: 10 kB
assets/favicon-32x32.png ADDED

Git LFS Details

  • SHA256: a473be4cc7477c3cd297e8bebe6b1fa8e6b87c0874fcbdc54e48d4752ded9388
  • Pointer size: 129 Bytes
  • Size of remote file: 1.18 kB
assets/favicon.ico ADDED

Git LFS Details

  • SHA256: 1ed7c4bab8e8e7f9ef078a875a7c200f1985f1020112b44d6c0c6996064cebe3
  • Pointer size: 128 Bytes
  • Size of remote file: 562 Bytes
assets/favicon.svg ADDED
assets/shared.css ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* === AI Tools — Global Overrides === */
2
+
3
+ :root {
4
+ --pico-font-size: 100%;
5
+ scrollbar-width: thin;
6
+ scrollbar-color: var(--pico-secondary-border) var(--pico-background-color);
7
+ }
8
+
9
+ /* Nav brand */
10
+ nav .brand {
11
+ color: #6366f1;
12
+ }
13
+ nav .brand-icon {
14
+ height: 2.25em;
15
+ vertical-align: -0.35em;
16
+ margin: 0 -0.5em;
17
+ }
18
+
19
+ /* Compact form controls inside tool UIs */
20
+ .controls select,
21
+ .controls input {
22
+ margin-bottom: 0;
23
+ }
24
+
25
+ /* Feature sections */
26
+ [id^="feature-"] {
27
+ animation: fadeIn 0.15s ease-in;
28
+ }
29
+
30
+ @keyframes fadeIn {
31
+ from { opacity: 0; }
32
+ to { opacity: 1; }
33
+ }
components/compare-slider.js ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <compare-slider> — before/after image comparison slider.
3
+ *
4
+ * The slider is always on. Left-click / drag positions the divider. Right-click
5
+ * snaps the divider to whichever extreme it isn't currently nearer to (left
6
+ * or right edge), so a single right-click flips between the before-only and
7
+ * after-only views.
8
+ */
9
+
10
+ import { morph } from 'lib/morph';
11
+ import { canvasToBlobUrl } from 'lib/canvas';
12
+
13
+ class CompareSlider extends HTMLElement {
14
+ #dragging = false;
15
+ #beforeSrc = '';
16
+ #afterSrc = '';
17
+ #positionFrac = 0.5;
18
+ #downloadSrc = '';
19
+ #downloadName = '';
20
+ #beforeCanvas = null;
21
+ #afterCanvas = null;
22
+ #lazyBlobURL = '';
23
+
24
+ #onWindowMouseMove = (e) => { if (this.#dragging) this.#setPosition(this.#getFrac(e)); };
25
+ #onWindowTouchMove = (e) => { if (this.#dragging) this.#setPosition(this.#getFrac(e)); };
26
+ #onWindowMouseUp = () => {
27
+ this.#dragging = false;
28
+ this.classList.remove('dragging');
29
+ };
30
+ #onWindowTouchEnd = () => {
31
+ this.#dragging = false;
32
+ this.classList.remove('dragging');
33
+ };
34
+
35
+ #knobScheduled = false;
36
+ #knobFullWidth = 0;
37
+ #resizeObserver = null;
38
+ #scheduleKnobUpdate = () => {
39
+ if (this.#knobScheduled) return;
40
+ this.#knobScheduled = true;
41
+ requestAnimationFrame(() => {
42
+ this.#knobScheduled = false;
43
+ this.#updateKnobPosition();
44
+ });
45
+ };
46
+ #onResize = () => {
47
+ // Knob's measured full width may change with font-metric / DPR shifts.
48
+ this.#knobFullWidth = 0;
49
+ this.#scheduleKnobUpdate();
50
+ };
51
+
52
+ connectedCallback() {
53
+ this.classList.add('compare');
54
+ // Belt-and-suspenders: also set display:none inline so the slider is
55
+ // definitely hidden before show() has been called. The class CSS rule
56
+ // does this too, but it lives inside our own <style> block — there's a
57
+ // brief window between when the host is added to the DOM (with the
58
+ // class but no inner content) and when #render() lands the style tag,
59
+ // during which the absolutely-positioned handle would render.
60
+ this.style.display = 'none';
61
+ this.#render();
62
+
63
+ this.addEventListener('mousedown', e => {
64
+ if (e.button !== 0) return; // left-button only — right-click handled below
65
+ e.preventDefault(); this.#dragging = true; this.classList.add('dragging');
66
+ this.#setPosition(this.#getFrac(e));
67
+ });
68
+ this.addEventListener('touchstart', e => {
69
+ this.#dragging = true; this.classList.add('dragging');
70
+ this.#setPosition(this.#getFrac(e));
71
+ }, { passive: true });
72
+
73
+ this.addEventListener('contextmenu', (e) => {
74
+ // Right-click flips the divider to whichever extreme it's currently
75
+ // farther from — single click toggles between full-before and full-after.
76
+ e.preventDefault();
77
+ this.#setPosition(this.#positionFrac < 0.5 ? 1 : 0);
78
+ });
79
+
80
+ window.addEventListener('mousemove', this.#onWindowMouseMove);
81
+ window.addEventListener('touchmove', this.#onWindowTouchMove, { passive: true });
82
+ window.addEventListener('mouseup', this.#onWindowMouseUp);
83
+ window.addEventListener('touchend', this.#onWindowTouchEnd);
84
+ // Keep the (fixed-position) knob aligned as the user scrolls / resizes.
85
+ window.addEventListener('scroll', this.#scheduleKnobUpdate, { passive: true });
86
+ window.addEventListener('resize', this.#onResize);
87
+ // native-size mode scrolls inside the slider itself, not the page.
88
+ this.addEventListener('scroll', this.#scheduleKnobUpdate, { passive: true });
89
+ // Catch view-mode swaps (e.g. .expanded ↔ .native-size) where the host
90
+ // class flips but no scroll/resize fires — without this the knob keeps
91
+ // its previous logical position until the next drag.
92
+ if (typeof ResizeObserver !== 'undefined') {
93
+ this.#resizeObserver = new ResizeObserver(this.#scheduleKnobUpdate);
94
+ this.#resizeObserver.observe(this);
95
+ }
96
+ }
97
+
98
+ disconnectedCallback() {
99
+ window.removeEventListener('mousemove', this.#onWindowMouseMove);
100
+ window.removeEventListener('touchmove', this.#onWindowTouchMove);
101
+ window.removeEventListener('mouseup', this.#onWindowMouseUp);
102
+ window.removeEventListener('touchend', this.#onWindowTouchEnd);
103
+ window.removeEventListener('scroll', this.#scheduleKnobUpdate);
104
+ window.removeEventListener('resize', this.#onResize);
105
+ this.#resizeObserver?.disconnect();
106
+ this.#resizeObserver = null;
107
+ }
108
+
109
+ /**
110
+ * @param {string|HTMLCanvasElement} beforeSrc
111
+ * @param {string|HTMLCanvasElement} afterSrc
112
+ * @param {{ downloadSrc?: string, downloadName?: string }} [opts]
113
+ */
114
+ async show(beforeSrc, afterSrc, opts = {}) {
115
+ const canvasMode = beforeSrc instanceof HTMLCanvasElement;
116
+ if (canvasMode) {
117
+ this.#beforeCanvas = beforeSrc;
118
+ this.#afterCanvas = afterSrc;
119
+ this.#beforeSrc = '';
120
+ this.#afterSrc = '';
121
+ this.#downloadSrc = '';
122
+ } else {
123
+ this.#beforeCanvas = null;
124
+ this.#afterCanvas = null;
125
+ this.#beforeSrc = beforeSrc;
126
+ this.#afterSrc = afterSrc;
127
+ this.#downloadSrc = opts.downloadSrc || afterSrc;
128
+ }
129
+ this.#downloadName = opts.downloadName || 'download.png';
130
+ this.#render();
131
+
132
+ if (!canvasMode) {
133
+ const imgs = this.querySelectorAll('.compare-after, .compare-before-wrap img');
134
+ await Promise.all([...imgs].map(img =>
135
+ img.complete ? Promise.resolve() : new Promise(r => { img.onload = r; })
136
+ ));
137
+ }
138
+
139
+ const afterEl = this.querySelector('.compare-after');
140
+ const natW = canvasMode
141
+ ? (this.#afterCanvas?.width || 0)
142
+ : (afterEl?.naturalWidth || 0);
143
+ const natH = canvasMode
144
+ ? (this.#afterCanvas?.height || 0)
145
+ : (afterEl?.naturalHeight || 0);
146
+ if (natW && natH) {
147
+ this.style.setProperty('--ar', `${natW} / ${natH}`);
148
+ // --ar-num is a plain number (w/h) used by calc() to derive the host
149
+ // width in fit-height mode. Block elements with width:auto +
150
+ // aspect-ratio don't reliably derive width from the aspect — the
151
+ // "stretch to containing block" default often wins. Computing the
152
+ // width explicitly via calc avoids that ambiguity.
153
+ this.style.setProperty('--ar-num', `${natW / natH}`);
154
+ this.style.setProperty('--natural-w', `${natW}px`);
155
+ }
156
+
157
+ this.style.display = 'block';
158
+ this.#setPosition(this.#positionFrac);
159
+ this.#updateKnobPosition();
160
+ if (canvasMode) this.#prepareLazyDownload();
161
+ }
162
+
163
+ hide() {
164
+ this.style.display = 'none';
165
+ const knob = this.querySelector('.handle-knob');
166
+ if (knob) knob.style.visibility = 'hidden';
167
+ this.style.removeProperty('--ar');
168
+ this.style.removeProperty('--ar-num');
169
+ this.style.removeProperty('--natural-w');
170
+ this.#beforeCanvas = null;
171
+ this.#afterCanvas = null;
172
+ if (this.#lazyBlobURL) {
173
+ URL.revokeObjectURL(this.#lazyBlobURL);
174
+ this.#lazyBlobURL = '';
175
+ this.#downloadSrc = '';
176
+ }
177
+ }
178
+
179
+ async openInTab() {
180
+ const url = this.#downloadSrc || await this.#ensureDownloadURL();
181
+ if (url) window.open(url, '_blank');
182
+ }
183
+
184
+ async download() {
185
+ const url = this.#downloadSrc || await this.#ensureDownloadURL();
186
+ if (!url) return;
187
+ const a = document.createElement('a');
188
+ a.download = this.#downloadName || 'download.png';
189
+ a.href = url;
190
+ a.click();
191
+ }
192
+
193
+ #getFrac(e) {
194
+ // Use the stage rect (the natural-size box that wraps the after canvas
195
+ // and the before-wrap clip) instead of the host. In normal modes the
196
+ // stage fills the host so this is equivalent, but in native-size mode
197
+ // the host is a scrollable container while the stage is at the canvas's
198
+ // natural pixel size — only the stage rect tracks the actual image
199
+ // coordinates the slider divides.
200
+ const stage = this.querySelector('.compare-stage');
201
+ const rect = (stage || this).getBoundingClientRect();
202
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
203
+ return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
204
+ }
205
+
206
+ #setPosition(frac) {
207
+ const clamped = Math.max(0, Math.min(1, frac));
208
+ this.#positionFrac = clamped;
209
+ const pct = (clamped * 100).toFixed(2) + '%';
210
+ const wrap = this.querySelector('.compare-before-wrap');
211
+ const handle = this.querySelector('.compare-handle');
212
+ if (wrap) wrap.style.width = pct;
213
+ if (handle) handle.style.left = pct;
214
+ this.#updateKnobPosition();
215
+ }
216
+
217
+ /**
218
+ * Position the (fixed) knob inside the visible portion of the slider's
219
+ * stage — i.e. the image area, which is the "workspace" the divider
220
+ * actually lives in. The knob follows the user vertically (target =
221
+ * viewport center) but is clamped to the stage's visible rect on both
222
+ * axes. When clamped to a horizontal edge, only the icon for the side
223
+ * currently in view is shown, matching the "icon-clipped-by-canvas"
224
+ * behavior the slider had at extreme drag positions when the image fit
225
+ * the workspace.
226
+ */
227
+ #updateKnobPosition() {
228
+ const knob = this.querySelector('.handle-knob');
229
+ if (!knob) return;
230
+ if (getComputedStyle(this).display === 'none') {
231
+ knob.style.visibility = 'hidden';
232
+ return;
233
+ }
234
+ const stage = this.querySelector('.compare-stage');
235
+ if (!stage) return;
236
+ const stageRect = stage.getBoundingClientRect();
237
+ const vw = window.innerWidth;
238
+ const vh = window.innerHeight;
239
+ // Visible stage = intersection of stage bounds and viewport.
240
+ const visLeft = Math.max(stageRect.left, 0);
241
+ const visRight = Math.min(stageRect.right, vw);
242
+ const visTop = Math.max(stageRect.top, 0);
243
+ const visBottom = Math.min(stageRect.bottom, vh);
244
+ if (visRight <= visLeft || visBottom <= visTop ||
245
+ stageRect.width === 0 || stageRect.height === 0) {
246
+ knob.style.visibility = 'hidden';
247
+ return;
248
+ }
249
+ knob.style.visibility = '';
250
+
251
+ // Cache the knob's full (unclipped) width. The clip classes hide one
252
+ // half of the knob, which would otherwise shrink offsetWidth and cause
253
+ // the would-clip threshold to oscillate as the user drags near an edge.
254
+ if (this.#knobFullWidth === 0) {
255
+ const hadClipLeft = knob.classList.contains('clip-at-left');
256
+ const hadClipRight = knob.classList.contains('clip-at-right');
257
+ if (hadClipLeft) knob.classList.remove('clip-at-left');
258
+ if (hadClipRight) knob.classList.remove('clip-at-right');
259
+ this.#knobFullWidth = knob.offsetWidth;
260
+ if (hadClipLeft) knob.classList.add('clip-at-left');
261
+ if (hadClipRight) knob.classList.add('clip-at-right');
262
+ }
263
+ const halfKnob = this.#knobFullWidth / 2;
264
+
265
+ const logicalX = stageRect.left + this.#positionFrac * stageRect.width;
266
+ // The knob clips when its bounding box (not just its centerline) would
267
+ // extend past the visible stage. Before the knob was position:fixed it
268
+ // got this for free from the slider's overflow:hidden; now we have to
269
+ // detect it ourselves.
270
+ const wouldClipLeft = logicalX - halfKnob < visLeft;
271
+ const wouldClipRight = logicalX + halfKnob > visRight && !wouldClipLeft;
272
+ knob.classList.toggle('clip-at-left', wouldClipLeft);
273
+ knob.classList.toggle('clip-at-right', wouldClipRight);
274
+
275
+ let leftPx;
276
+ if (wouldClipLeft) leftPx = visLeft;
277
+ else if (wouldClipRight) leftPx = visRight;
278
+ else leftPx = logicalX;
279
+ const targetY = vh / 2;
280
+ const clampedY = Math.max(visTop, Math.min(visBottom, targetY));
281
+ knob.style.left = leftPx + 'px';
282
+ knob.style.top = clampedY + 'px';
283
+ }
284
+
285
+ #drawCanvasSources() {
286
+ const afterEl = this.querySelector('canvas.compare-after');
287
+ if (afterEl && this.#afterCanvas) {
288
+ afterEl.getContext('2d').drawImage(this.#afterCanvas, 0, 0);
289
+ }
290
+ const beforeEl = this.querySelector('.compare-before-wrap canvas');
291
+ if (beforeEl && this.#beforeCanvas) {
292
+ beforeEl.getContext('2d').drawImage(this.#beforeCanvas, 0, 0);
293
+ }
294
+ }
295
+
296
+ async #ensureDownloadURL() {
297
+ if (this.#downloadSrc) return this.#downloadSrc;
298
+ if (!this.#afterCanvas) return '';
299
+ this.#lazyBlobURL = await canvasToBlobUrl(this.#afterCanvas);
300
+ this.#downloadSrc = this.#lazyBlobURL;
301
+ return this.#downloadSrc;
302
+ }
303
+
304
+ async #prepareLazyDownload() {
305
+ const canvas = this.#afterCanvas;
306
+ if (!canvas) return;
307
+ const url = await canvasToBlobUrl(canvas);
308
+ if (this.#afterCanvas !== canvas) {
309
+ URL.revokeObjectURL(url);
310
+ return;
311
+ }
312
+ this.#lazyBlobURL = url;
313
+ this.#downloadSrc = this.#lazyBlobURL;
314
+ }
315
+
316
+ #render() {
317
+ const cm = !!this.#afterCanvas;
318
+ const afterTag = cm
319
+ ? `<canvas class="compare-after" width="${this.#afterCanvas.width}" height="${this.#afterCanvas.height}"></canvas>`
320
+ : `<img class="compare-after" src="${this.#afterSrc}">`;
321
+ const beforeTag = cm
322
+ ? `<canvas width="${this.#beforeCanvas.width}" height="${this.#beforeCanvas.height}"></canvas>`
323
+ : `<img src="${this.#beforeSrc}">`;
324
+ morph(this, `
325
+ <style>
326
+ .compare {
327
+ display: none; position: relative; overflow: hidden;
328
+ border: 1px solid var(--pico-muted-border-color, #333);
329
+ border-radius: var(--pico-border-radius, 4px);
330
+ cursor: col-resize; user-select: none; max-width: 100%;
331
+ }
332
+ .compare:not(.expanded):not(.native-size) {
333
+ width: 100%;
334
+ max-width: 100%;
335
+ aspect-ratio: var(--ar, auto);
336
+ margin-inline: auto;
337
+ }
338
+ .compare.expanded {
339
+ height: calc(100vh - 1rem);
340
+ width: calc((100vh - 1rem) * var(--ar-num, 1));
341
+ max-width: none;
342
+ margin-inline: auto;
343
+ }
344
+ .compare img, .compare canvas { display: block; width: 100%; height: auto; pointer-events: none; }
345
+ /* compare-stage is the natural-size containing block for the after
346
+ canvas, the before-wrap clip and the slider handle. In normal modes
347
+ it just fills the host (width: 100%); in native-size it shrinks to
348
+ the canvas's natural dimensions so before-wrap's percentage width
349
+ / handle's percentage left line up with the same native pixels the
350
+ after canvas is showing. */
351
+ .compare .compare-stage {
352
+ position: relative;
353
+ display: block;
354
+ width: 100%;
355
+ }
356
+ .compare .compare-before-wrap {
357
+ position: absolute; top: 0; left: 0; height: 100%; overflow: hidden;
358
+ width: 50%; border-right: 2px solid rgba(255,255,255,0.35);
359
+ transition: border-color 0.2s ease;
360
+ }
361
+ .compare.dragging .compare-before-wrap {
362
+ border-right-color: #fff;
363
+ }
364
+ .compare .compare-before-wrap img,
365
+ .compare .compare-before-wrap canvas { width: auto; height: 100%; max-width: none; }
366
+ .compare .compare-handle {
367
+ position: absolute; top: 0; bottom: 0; width: 2px; background: #fff;
368
+ left: 50%; margin-left: -1px; z-index: 2; pointer-events: none;
369
+ opacity: 0.35;
370
+ transition: opacity 0.2s ease;
371
+ }
372
+ .compare.dragging .compare-handle {
373
+ opacity: 1;
374
+ }
375
+ /* The knob is positioned in viewport coordinates (position: fixed)
376
+ and re-placed on scroll/resize/drag by #updateKnobPosition. JS
377
+ sets its top/left; transform anchors the box around that point
378
+ (centered by default, left-anchored when clamped to the viewport's
379
+ left edge, right-anchored when clamped to the right edge). */
380
+ .compare .compare-handle .handle-knob {
381
+ position: fixed; top: 50%; left: 50%;
382
+ transform: translate(-50%, -50%);
383
+ display: inline-flex; align-items: center; gap: 8px;
384
+ padding: 4px 8px;
385
+ border-radius: 999px;
386
+ background: rgba(255,255,255,0.92);
387
+ border: 2px solid #333;
388
+ box-shadow: 0 0 6px rgba(0,0,0,0.5);
389
+ color: #333;
390
+ white-space: nowrap;
391
+ z-index: 3;
392
+ pointer-events: none;
393
+ }
394
+ .compare .compare-handle .handle-knob.clip-at-left {
395
+ transform: translate(0, -50%);
396
+ }
397
+ .compare .compare-handle .handle-knob.clip-at-right {
398
+ transform: translate(-100%, -50%);
399
+ }
400
+ /* When clamped to a viewport edge, drop the icon for the side the
401
+ user *isn't* looking at so the knob unambiguously labels what's
402
+ currently in view. */
403
+ .compare .compare-handle .handle-knob.clip-at-left .handle-side-before {
404
+ display: none;
405
+ }
406
+ .compare .compare-handle .handle-knob.clip-at-right .handle-side-after {
407
+ display: none;
408
+ }
409
+ .compare .compare-handle .handle-side {
410
+ display: inline-flex; align-items: center; gap: 3px;
411
+ }
412
+ .compare .compare-handle .handle-side .fas {
413
+ font-size: 11px;
414
+ }
415
+ .compare .compare-handle .handle-arrow {
416
+ font-size: 9px; line-height: 1;
417
+ }
418
+ /* native-size renders content at native pixel dimensions inline.
419
+ The host stays in normal flow but scrolls internally when content
420
+ exceeds its bounds. Flex + margin:auto centers the stage when the
421
+ image is smaller than the host and keeps it scrollable when
422
+ larger. */
423
+ .compare.native-size {
424
+ width: 100%;
425
+ max-width: 100%;
426
+ height: calc(100vh - 1rem);
427
+ max-height: calc(100vh - 1rem);
428
+ aspect-ratio: auto;
429
+ overflow: auto;
430
+ cursor: default;
431
+ display: flex;
432
+ }
433
+ .compare.native-size .compare-stage {
434
+ margin: auto;
435
+ width: max-content;
436
+ height: max-content;
437
+ flex: 0 0 auto;
438
+ }
439
+ .compare.native-size .compare-after {
440
+ width: auto;
441
+ max-width: none;
442
+ height: auto;
443
+ display: block;
444
+ }
445
+ </style>
446
+ <div class="compare-stage">
447
+ ${afterTag}
448
+ <div class="compare-before-wrap">
449
+ ${beforeTag}
450
+ </div>
451
+ <div class="compare-handle" aria-hidden="true">
452
+ <div class="handle-knob">
453
+ <span class="handle-side handle-side-before" aria-label="Original">
454
+ <i class="fas fa-eye-low-vision"></i>
455
+ <span class="handle-arrow">◀</span>
456
+ </span>
457
+ <span class="handle-side handle-side-after" aria-label="Enhanced">
458
+ <span class="handle-arrow">▶</span>
459
+ <i class="fas fa-eye"></i>
460
+ </span>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ `);
465
+ if (cm) this.#drawCanvasSources();
466
+ }
467
+ }
468
+
469
+ customElements.define('compare-slider', CompareSlider);
components/image-cropper.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <image-cropper> — optional bounding-box selection on the source image.
3
+ *
4
+ * Events:
5
+ * crop-changed — detail: { crop: { x, y, w, h } | null }
6
+ */
7
+
8
+ import { morph } from 'lib/morph';
9
+ import { cropToCanvas } from 'lib/canvas';
10
+
11
+ class ImageCropper extends HTMLElement {
12
+ #image = null;
13
+ #crop = null;
14
+ #dragging = false;
15
+ #dragStart = null;
16
+ #dragCurrent = null;
17
+
18
+ #onWindowMouseMove = (e) => { if (this.#dragging) { this.#dragCurrent = this.#eventToElement(e); this.#drawOverlay(); } };
19
+ #onWindowTouchMove = (e) => { if (this.#dragging) { this.#dragCurrent = this.#eventToElement(e); this.#drawOverlay(); } };
20
+ #onWindowMouseUp = () => { if (this.#dragging) this.#finishDrag(); };
21
+ #onWindowTouchEnd = () => { if (this.#dragging) this.#finishDrag(); };
22
+
23
+ connectedCallback() {
24
+ this.classList.add('image-cropper');
25
+ this.#render();
26
+
27
+ this.addEventListener('mousedown', e => {
28
+ const canvas = e.target.closest('canvas');
29
+ if (!canvas) return;
30
+ this.#dragging = true;
31
+ this.#dragStart = this.#eventToElement(e);
32
+ this.#dragCurrent = this.#dragStart;
33
+ this.#crop = null;
34
+ this.#drawOverlay();
35
+ });
36
+ this.addEventListener('touchstart', e => {
37
+ const canvas = e.target.closest('canvas');
38
+ if (!canvas) return;
39
+ this.#dragging = true;
40
+ this.#dragStart = this.#eventToElement(e);
41
+ this.#dragCurrent = this.#dragStart;
42
+ this.#crop = null;
43
+ this.#drawOverlay();
44
+ }, { passive: true });
45
+
46
+ window.addEventListener('mousemove', this.#onWindowMouseMove);
47
+ window.addEventListener('touchmove', this.#onWindowTouchMove, { passive: true });
48
+ window.addEventListener('mouseup', this.#onWindowMouseUp);
49
+ window.addEventListener('touchend', this.#onWindowTouchEnd);
50
+ }
51
+
52
+ disconnectedCallback() {
53
+ window.removeEventListener('mousemove', this.#onWindowMouseMove);
54
+ window.removeEventListener('touchmove', this.#onWindowTouchMove);
55
+ window.removeEventListener('mouseup', this.#onWindowMouseUp);
56
+ window.removeEventListener('touchend', this.#onWindowTouchEnd);
57
+ }
58
+
59
+ show(image) {
60
+ // Preserve the existing crop selection when re-shown with the same image
61
+ // reference (e.g. when the upscaler navigates back to crop mode from the
62
+ // compare view). A genuinely new image still resets the crop.
63
+ const sameImage = image === this.#image;
64
+ this.#image = image;
65
+ if (!sameImage) this.#crop = null;
66
+ this.style.setProperty('--ar', `${image.width} / ${image.height}`);
67
+ this.style.setProperty('--ar-num', `${image.width / image.height}`);
68
+ this.style.setProperty('--natural-w', `${image.width}px`);
69
+ this.#render();
70
+ this.style.display = 'block';
71
+ this.#resizeCanvas();
72
+ this.#drawOverlay();
73
+ }
74
+
75
+ hide() {
76
+ this.style.display = 'none';
77
+ this.style.removeProperty('--ar');
78
+ this.style.removeProperty('--ar-num');
79
+ this.style.removeProperty('--natural-w');
80
+ this.#image = null;
81
+ this.#crop = null;
82
+ const canvas = this.querySelector('canvas');
83
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
84
+ }
85
+
86
+ clearCrop() {
87
+ this.#crop = null;
88
+ this.#render();
89
+ this.#resizeCanvas();
90
+ this.#drawOverlay();
91
+ this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: null } }));
92
+ }
93
+
94
+ get crop() { return this.#crop; }
95
+
96
+ extractImage() {
97
+ const img = this.#image;
98
+ if (!img) throw new Error('No image loaded');
99
+ if (!this.#crop) return img;
100
+ return cropToCanvas(img, this.#crop);
101
+ }
102
+
103
+ #resizeCanvas() {
104
+ if (!this.#image) return;
105
+ const canvas = this.querySelector('canvas');
106
+ if (!canvas) return;
107
+ canvas.width = this.#image.width;
108
+ canvas.height = this.#image.height;
109
+ }
110
+
111
+ #eventToElement(e) {
112
+ const canvas = this.querySelector('canvas');
113
+ if (!canvas) return { ex: 0, ey: 0 };
114
+ const rect = canvas.getBoundingClientRect();
115
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
116
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
117
+ return {
118
+ ex: Math.max(0, Math.min(rect.width, clientX - rect.left)),
119
+ ey: Math.max(0, Math.min(rect.height, clientY - rect.top)),
120
+ };
121
+ }
122
+
123
+ #finishDrag() {
124
+ this.#dragging = false;
125
+ if (!this.#dragStart || !this.#dragCurrent || !this.#image) return;
126
+
127
+ const canvas = this.querySelector('canvas');
128
+ const scaleX = this.#image.width / canvas.clientWidth;
129
+ const scaleY = this.#image.height / canvas.clientHeight;
130
+
131
+ const x1 = Math.min(this.#dragStart.ex, this.#dragCurrent.ex);
132
+ const y1 = Math.min(this.#dragStart.ey, this.#dragCurrent.ey);
133
+ const x2 = Math.max(this.#dragStart.ex, this.#dragCurrent.ex);
134
+ const y2 = Math.max(this.#dragStart.ey, this.#dragCurrent.ey);
135
+
136
+ const ix = Math.round(x1 * scaleX);
137
+ const iy = Math.round(y1 * scaleY);
138
+ const iw = Math.round((x2 - x1) * scaleX);
139
+ const ih = Math.round((y2 - y1) * scaleY);
140
+
141
+ if (iw < 16 || ih < 16) {
142
+ this.#crop = null;
143
+ this.#render();
144
+ this.#resizeCanvas();
145
+ this.#drawOverlay();
146
+ this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: null } }));
147
+ return;
148
+ }
149
+
150
+ this.#crop = { x: ix, y: iy, w: iw, h: ih };
151
+ this.#render();
152
+ this.#resizeCanvas();
153
+ this.#drawOverlay();
154
+ this.dispatchEvent(new CustomEvent('crop-changed', { bubbles: true, detail: { crop: this.#crop } }));
155
+ }
156
+
157
+ #drawOverlay() {
158
+ const canvas = this.querySelector('canvas');
159
+ if (!canvas || !this.#image) return;
160
+ const ctx = canvas.getContext('2d');
161
+ const cw = canvas.width;
162
+ const ch = canvas.height;
163
+
164
+ ctx.drawImage(this.#image, 0, 0, cw, ch);
165
+
166
+ let sx, sy, sw, sh;
167
+ if (this.#dragging && this.#dragStart && this.#dragCurrent) {
168
+ const scaleX = cw / canvas.clientWidth;
169
+ const scaleY = ch / canvas.clientHeight;
170
+ sx = Math.min(this.#dragStart.ex, this.#dragCurrent.ex) * scaleX;
171
+ sy = Math.min(this.#dragStart.ey, this.#dragCurrent.ey) * scaleY;
172
+ sw = Math.abs(this.#dragCurrent.ex - this.#dragStart.ex) * scaleX;
173
+ sh = Math.abs(this.#dragCurrent.ey - this.#dragStart.ey) * scaleY;
174
+ } else if (this.#crop) {
175
+ sx = this.#crop.x;
176
+ sy = this.#crop.y;
177
+ sw = this.#crop.w;
178
+ sh = this.#crop.h;
179
+ } else {
180
+ return;
181
+ }
182
+
183
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
184
+ ctx.fillRect(0, 0, cw, sy);
185
+ ctx.fillRect(0, sy, sx, sh);
186
+ ctx.fillRect(sx + sw, sy, cw - sx - sw, sh);
187
+ ctx.fillRect(0, sy + sh, cw, ch - sy - sh);
188
+
189
+ ctx.strokeStyle = 'var(--pico-primary, #4c8)';
190
+ ctx.lineWidth = 2;
191
+ ctx.strokeRect(sx, sy, sw, sh);
192
+ }
193
+
194
+ #render() {
195
+ morph(this, `
196
+ <style>
197
+ .image-cropper { display: none; }
198
+ .image-cropper:not(.expanded) {
199
+ width: 100%;
200
+ max-width: 100%;
201
+ aspect-ratio: var(--ar, auto);
202
+ margin-inline: auto;
203
+ }
204
+ .image-cropper.expanded {
205
+ height: calc(100vh - 1rem);
206
+ width: calc((100vh - 1rem) * var(--ar-num, 1));
207
+ max-width: none;
208
+ margin-inline: auto;
209
+ }
210
+ /* native-size: canvas at its natural pixel dimensions, centered in
211
+ a workspace-sized scroll container. Mirrors the compare-slider. */
212
+ .image-cropper.native-size {
213
+ width: 100%;
214
+ max-width: 100%;
215
+ height: calc(100vh - 1rem);
216
+ max-height: calc(100vh - 1rem);
217
+ overflow: auto;
218
+ display: flex;
219
+ margin-inline: auto;
220
+ }
221
+ .image-cropper.native-size canvas {
222
+ margin: auto;
223
+ width: auto;
224
+ height: auto;
225
+ max-width: none;
226
+ flex: 0 0 auto;
227
+ }
228
+ .image-cropper canvas {
229
+ display: block;
230
+ width: 100%;
231
+ height: auto;
232
+ max-width: 100%;
233
+ border: 1px solid var(--pico-muted-border-color, #333);
234
+ border-radius: var(--pico-border-radius, 4px);
235
+ cursor: crosshair;
236
+ }
237
+ </style>
238
+ <canvas></canvas>
239
+ `);
240
+ }
241
+ }
242
+
243
+ customElements.define('image-cropper', ImageCropper);
components/image-drop-zone.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <image-drop-zone> — file picker + drag-and-drop image loader.
3
+ *
4
+ * Events:
5
+ * image-loaded — detail: { image: HTMLImageElement }
6
+ */
7
+
8
+ import { morph } from 'lib/morph';
9
+
10
+ class ImageDropZone extends HTMLElement {
11
+ connectedCallback() {
12
+ this.classList.add('drop-zone');
13
+ this.#render();
14
+
15
+ this.addEventListener('click', e => {
16
+ if (e.target.closest('.drop-zone-area')) this.querySelector('input[type="file"]').click();
17
+ });
18
+ this.addEventListener('dragover', e => {
19
+ if (e.target.closest('.drop-zone-area')) { e.preventDefault(); e.target.closest('.drop-zone-area').classList.add('dragover'); }
20
+ });
21
+ this.addEventListener('dragleave', e => {
22
+ if (e.target.closest('.drop-zone-area')) e.target.closest('.drop-zone-area').classList.remove('dragover');
23
+ });
24
+ this.addEventListener('drop', e => {
25
+ const area = e.target.closest('.drop-zone-area');
26
+ if (!area) return;
27
+ e.preventDefault();
28
+ area.classList.remove('dragover');
29
+ if (e.dataTransfer.files.length) this.#handleFile(e.dataTransfer.files[0]);
30
+ });
31
+ this.addEventListener('change', e => {
32
+ if (e.target.matches('input[type="file"]') && e.target.files.length) this.#handleFile(e.target.files[0]);
33
+ });
34
+ document.addEventListener('paste', e => {
35
+ const items = e.clipboardData?.items;
36
+ if (!items) return;
37
+ for (const item of items) {
38
+ if (item.type.startsWith('image/')) {
39
+ e.preventDefault();
40
+ this.#handleFile(item.getAsFile());
41
+ return;
42
+ }
43
+ }
44
+ });
45
+ }
46
+
47
+ #handleFile(file) {
48
+ if (!file.type.startsWith('image/')) return;
49
+ const reader = new FileReader();
50
+ reader.onload = () => {
51
+ const img = new Image();
52
+ img.onload = () => {
53
+ this.dispatchEvent(new CustomEvent('image-loaded', { bubbles: true, detail: { image: img } }));
54
+ };
55
+ img.src = reader.result;
56
+ };
57
+ reader.readAsDataURL(file);
58
+ }
59
+
60
+ show() {
61
+ this.style.display = '';
62
+ const input = this.querySelector('input[type="file"]');
63
+ if (input) input.value = '';
64
+ }
65
+ hide() { this.style.display = 'none'; }
66
+
67
+ #render() {
68
+ morph(this, `
69
+ <style>
70
+ .drop-zone .drop-zone-area {
71
+ border: 2px dashed var(--pico-muted-border-color, #444);
72
+ border-radius: 8px; padding: 3rem; text-align: center;
73
+ color: var(--pico-muted-color, #666); font-size: 0.9rem;
74
+ cursor: pointer; transition: border-color 0.2s;
75
+ }
76
+ .drop-zone .drop-zone-area.dragover {
77
+ border-color: var(--pico-primary, #4c8);
78
+ color: var(--pico-primary, #4c8);
79
+ }
80
+ </style>
81
+ <input type="file" accept="image/*" hidden>
82
+ <div class="drop-zone-area">
83
+ <i class="fas fa-cloud-upload-alt" style="font-size:1.5rem; display:block; margin-bottom:0.5rem"></i>
84
+ Drop an image here, paste from clipboard, or click to browse
85
+ </div>
86
+ `);
87
+ }
88
+ }
89
+
90
+ customElements.define('image-drop-zone', ImageDropZone);
components/status-bar.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <status-bar> — colored state icon + title + details (tooltip) + progress row.
3
+ *
4
+ * State icon: filled circle (FA fa-circle) colored by state:
5
+ * idle — grey
6
+ * running — blue (subtle pulse)
7
+ * success — green
8
+ * warning — orange (success with a non-fatal fallback/skip)
9
+ * error — red
10
+ *
11
+ * Title is the brief "what's happening right now" line. Details live in a
12
+ * hover tooltip on the icon — used for the longer narrative (which EP,
13
+ * what error, etc.). Progress row shows a fractional bar with an optional
14
+ * tile count to the right.
15
+ *
16
+ * Primary API:
17
+ * sb.set({ title, state, details, progress, tileCount })
18
+ * - any subset; unspecified fields are left as-is
19
+ * - state: 'idle' | 'running' | 'success' | 'warning' | 'error'
20
+ * - progress: 0..1, -1 to hide, -2 indeterminate
21
+ * - tileCount: { done, total } or null
22
+ *
23
+ * Convenience aliases preserved for incremental callers:
24
+ * sb.message = '...' ≡ sb.set({ title })
25
+ * sb.showProgress(frac) ≡ sb.set({ progress: frac })
26
+ * sb.hideProgress() ≡ sb.set({ progress: -1, tileCount: null })
27
+ * sb.showIndeterminate() ≡ sb.set({ progress: -2 })
28
+ */
29
+
30
+ import { morph } from 'lib/morph';
31
+
32
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
33
+ const STATES = ['idle', 'running', 'success', 'warning', 'error'];
34
+
35
+ class StatusBar extends HTMLElement {
36
+ #title = '';
37
+ #state = 'idle';
38
+ #details = '';
39
+ #progress = -1;
40
+ #tileCount = null;
41
+
42
+ connectedCallback() {
43
+ this.classList.add('status-bar');
44
+ this.#render();
45
+ }
46
+
47
+ set(fields) {
48
+ if (fields.title !== undefined) this.#title = fields.title;
49
+ if (fields.state !== undefined && STATES.includes(fields.state)) this.#state = fields.state;
50
+ if (fields.details !== undefined) this.#details = fields.details;
51
+ if (fields.progress !== undefined) this.#progress = fields.progress;
52
+ if (fields.tileCount !== undefined) this.#tileCount = fields.tileCount;
53
+ this.#render();
54
+ }
55
+
56
+ set message(msg) { this.set({ title: msg }); }
57
+ showProgress(frac) { this.set({ progress: frac }); }
58
+ hideProgress() { this.set({ progress: -1, tileCount: null }); }
59
+ showIndeterminate() { this.set({ progress: -2 }); }
60
+
61
+ #render() {
62
+ const showProgress = this.#progress !== -1;
63
+ const indeterminate = this.#progress === -2;
64
+ const fillWidth = indeterminate ? 100 : Math.max(0, Math.min(1, this.#progress)) * 100;
65
+ const fillAnim = indeterminate ? 'animation: status-indeterminate 1.5s ease-in-out infinite;' : '';
66
+ const tc = this.#tileCount;
67
+ const hasTileCount = tc && Number.isFinite(tc.done) && Number.isFinite(tc.total);
68
+ const tileText = hasTileCount ? `${tc.done} / ${tc.total}` : '';
69
+ const ariaLabel = this.#details ? `${this.#title} — ${this.#details}` : this.#title;
70
+
71
+ morph(this, `
72
+ <style>
73
+ .status-bar .status-row {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.4rem;
77
+ min-width: 0;
78
+ width: 100%;
79
+ }
80
+ .status-bar .status-icon-wrap {
81
+ position: relative;
82
+ display: inline-flex;
83
+ align-items: center;
84
+ flex-shrink: 0;
85
+ outline: none;
86
+ }
87
+ .status-bar .status-icon {
88
+ font-size: 0.7em;
89
+ line-height: 1;
90
+ }
91
+ .status-bar .status-icon.idle { color: var(--pico-muted-color, #888); }
92
+ .status-bar .status-icon.running { color: #3b82f6; animation: status-pulse 1.4s ease-in-out infinite; }
93
+ .status-bar .status-icon.success { color: #16a34a; }
94
+ .status-bar .status-icon.warning { color: #d97706; }
95
+ .status-bar .status-icon.error { color: var(--pico-del-color, #c62828); }
96
+ @keyframes status-pulse {
97
+ 0%, 100% { opacity: 0.55; }
98
+ 50% { opacity: 1; }
99
+ }
100
+ .status-bar .status-tooltip {
101
+ display: none;
102
+ position: absolute;
103
+ bottom: calc(100% + 0.45rem);
104
+ left: 0;
105
+ background: var(--pico-card-background-color, #1e1e2e);
106
+ color: var(--pico-color, #cdd6f4);
107
+ border: 1px solid var(--pico-muted-border-color);
108
+ border-radius: var(--pico-border-radius);
109
+ padding: 0.4rem 0.55rem;
110
+ font-size: 0.75rem;
111
+ line-height: 1.4;
112
+ white-space: pre-wrap;
113
+ width: max-content;
114
+ max-width: 22rem;
115
+ z-index: 100;
116
+ pointer-events: none;
117
+ box-shadow: 0 2px 8px rgba(0,0,0,.25);
118
+ mix-blend-mode: normal;
119
+ }
120
+ .status-bar .status-icon-wrap:hover .status-tooltip,
121
+ .status-bar .status-icon-wrap:focus-within .status-tooltip {
122
+ display: block;
123
+ }
124
+ .status-bar .status-text {
125
+ font-size: 0.85rem;
126
+ color: var(--pico-muted-color, #aaa);
127
+ margin-bottom: 0;
128
+ overflow: hidden;
129
+ text-overflow: ellipsis;
130
+ white-space: nowrap;
131
+ flex: 0 1 auto;
132
+ min-width: 0;
133
+ }
134
+ .status-bar .progress-track {
135
+ flex: 1 1 auto;
136
+ height: 6px;
137
+ background: var(--pico-muted-border-color, #333);
138
+ border-radius: 3px;
139
+ overflow: hidden;
140
+ min-width: 0;
141
+ }
142
+ .status-bar .progress-fill {
143
+ height: 100%;
144
+ background: var(--pico-primary, #4c8);
145
+ width: 0%;
146
+ transition: width 0.2s;
147
+ }
148
+ .status-bar .progress-count {
149
+ font-size: 0.72rem;
150
+ color: var(--pico-muted-color, #aaa);
151
+ font-variant-numeric: tabular-nums;
152
+ white-space: nowrap;
153
+ flex-shrink: 0;
154
+ }
155
+ @keyframes status-indeterminate {
156
+ 0% { width: 20%; margin-left: 0; }
157
+ 50% { width: 40%; margin-left: 30%; }
158
+ 100% { width: 20%; margin-left: 80%; }
159
+ }
160
+ </style>
161
+
162
+ <div class="status-row">
163
+ <span class="status-icon-wrap" ${this.#details ? 'tabindex="0"' : ''} aria-label="${esc(ariaLabel)}">
164
+ <i class="fas fa-circle status-icon ${this.#state}" aria-hidden="true"></i>
165
+ ${this.#details ? `<span class="status-tooltip" role="tooltip">${esc(this.#details)}</span>` : ''}
166
+ </span>
167
+ <div class="status-text">${esc(this.#title)}</div>
168
+ ${showProgress ? `
169
+ <div class="progress-track" aria-hidden="true">
170
+ <div class="progress-fill" style="width:${fillWidth}%;${fillAnim}"></div>
171
+ </div>
172
+ ${hasTileCount ? `<span class="progress-count">${esc(tileText)}</span>` : ''}
173
+ ` : ''}
174
+ </div>
175
+ `);
176
+ }
177
+ }
178
+
179
+ customElements.define('status-bar', StatusBar);
components/view-mode-controls.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <view-mode-controls> — segmented icon-button radio for the canvas view mode.
3
+ *
4
+ * Renders one button per mode; the currently-selected mode is marked with
5
+ * `aria-pressed="true"`. Clicking a different button switches and emits
6
+ * `mode-change` { detail: { mode } }.
7
+ *
8
+ * Mode keys: 'fit-width' | 'fit-height' | 'one-to-one'.
9
+ */
10
+
11
+ const VIEW_MODES = [
12
+ { key: 'fit-width', label: 'Fit Width', icon: 'fa-arrows-left-right' },
13
+ { key: 'fit-height', label: 'Fit Height', icon: 'fa-arrows-up-down' },
14
+ { key: 'one-to-one', label: '1:1', icon: 'fa-vector-square' },
15
+ ];
16
+
17
+ class ViewModeControls extends HTMLElement {
18
+ #mode = 'fit-width';
19
+
20
+ connectedCallback() {
21
+ this.#render();
22
+ this.addEventListener('click', this.#onClick);
23
+ }
24
+
25
+ get mode() { return this.#mode; }
26
+ set mode(value) {
27
+ if (!VIEW_MODES.find(m => m.key === value)) return;
28
+ if (this.#mode === value) return;
29
+ this.#mode = value;
30
+ this.#render();
31
+ }
32
+
33
+ #onClick = (e) => {
34
+ const btn = e.target.closest('button[data-mode]');
35
+ if (!btn) return;
36
+ e.stopPropagation();
37
+ if (btn.dataset.mode === this.#mode) return;
38
+ this.#mode = btn.dataset.mode;
39
+ this.#render();
40
+ this.dispatchEvent(new CustomEvent('mode-change', { detail: { mode: this.#mode } }));
41
+ };
42
+
43
+ #render() {
44
+ const buttons = VIEW_MODES.map(m => `
45
+ <button type="button" class="secondary outline" data-mode="${m.key}"
46
+ aria-pressed="${m.key === this.#mode}"
47
+ title="${m.label}" aria-label="${m.label}">
48
+ <i class="fas ${m.icon}"></i>
49
+ </button>
50
+ `).join('');
51
+ this.innerHTML = `
52
+ <style>
53
+ view-mode-controls {
54
+ display: inline-flex;
55
+ vertical-align: middle;
56
+ }
57
+ view-mode-controls .vm-row {
58
+ display: inline-flex;
59
+ gap: 0;
60
+ }
61
+ view-mode-controls .vm-row > button {
62
+ border-radius: 0;
63
+ }
64
+ view-mode-controls .vm-row > button:first-child {
65
+ border-top-left-radius: var(--pico-border-radius);
66
+ border-bottom-left-radius: var(--pico-border-radius);
67
+ }
68
+ view-mode-controls .vm-row > button:last-child {
69
+ border-top-right-radius: var(--pico-border-radius);
70
+ border-bottom-right-radius: var(--pico-border-radius);
71
+ }
72
+ view-mode-controls .vm-row > button:not(:first-child) {
73
+ margin-left: -1px;
74
+ }
75
+ view-mode-controls button .fas {
76
+ margin-right: 0 !important;
77
+ }
78
+ /* Pressed state: disable the host toolbar's mix-blend-mode trick and
79
+ paint a solid filled background so the active mode is unambiguous
80
+ against the surrounding outline buttons. */
81
+ view-mode-controls button[aria-pressed="true"] {
82
+ mix-blend-mode: normal !important;
83
+ background: rgba(255, 255, 255, 0.22) !important;
84
+ opacity: 1 !important;
85
+ border-color: #fff !important;
86
+ color: #fff !important;
87
+ z-index: 1;
88
+ }
89
+ </style>
90
+ <div class="vm-row" role="radiogroup" aria-label="View mode">
91
+ ${buttons}
92
+ </div>
93
+ `;
94
+ }
95
+ }
96
+
97
+ customElements.define('view-mode-controls', ViewModeControls);
features/bg-removal/bg-removal-app.js ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <bg-removal-app> — orchestrates the background removal feature.
3
+ */
4
+
5
+ import { morph } from 'lib/morph';
6
+ import { canvasToBlobUrl, imageToBlobUrl } from 'lib/canvas';
7
+ import { trackBackendEvents, friendlyBackend, realizedIsGpu } from 'lib/backend-events';
8
+ import 'components/image-drop-zone';
9
+ import 'components/status-bar';
10
+ import 'components/compare-slider';
11
+ import 'components/image-cropper';
12
+ import 'components/view-mode-controls';
13
+ import { BgRemovalEngine } from './bg-removal-engine.js';
14
+
15
+ /**
16
+ * Draw a checkerboard pattern + transparent image onto a canvas, return as blob URL.
17
+ */
18
+ function checkerboardComposite(transparentCanvas) {
19
+ const w = transparentCanvas.width;
20
+ const h = transparentCanvas.height;
21
+ const c = document.createElement('canvas');
22
+ c.width = w;
23
+ c.height = h;
24
+ const ctx = c.getContext('2d');
25
+
26
+ const size = 16;
27
+ for (let y = 0; y < h; y += size) {
28
+ for (let x = 0; x < w; x += size) {
29
+ ctx.fillStyle = ((x / size + y / size) & 1) ? '#ccc' : '#fff';
30
+ ctx.fillRect(x, y, size, size);
31
+ }
32
+ }
33
+
34
+ ctx.drawImage(transparentCanvas, 0, 0);
35
+ return canvasToBlobUrl(c);
36
+ }
37
+
38
+ class BgRemovalApp extends HTMLElement {
39
+ static BRIA_MODEL_KEY = 'rmbg-1.4';
40
+
41
+ #loadedImage = null;
42
+ #running = false;
43
+ #engine = new BgRemovalEngine();
44
+ #abortController = null;
45
+ #resultBlobUrl = null;
46
+ #checkerBlobUrl = null;
47
+ #beforeBlobUrl = null;
48
+ #transparentBlobUrl = null;
49
+ #viewState = { mode: 'fit-width' };
50
+ static #VIEW_MODES = ['fit-width', 'fit-height', 'one-to-one'];
51
+
52
+ connectedCallback() {
53
+ this.#render();
54
+ this.#setupEvents();
55
+ this.#restoreViewState();
56
+ }
57
+
58
+ #q(sel) { return this.querySelector(sel); }
59
+
60
+ #cleanup() {
61
+ if (this.#resultBlobUrl) { URL.revokeObjectURL(this.#resultBlobUrl); this.#resultBlobUrl = null; }
62
+ if (this.#checkerBlobUrl) { URL.revokeObjectURL(this.#checkerBlobUrl); this.#checkerBlobUrl = null; }
63
+ if (this.#beforeBlobUrl) { URL.revokeObjectURL(this.#beforeBlobUrl); this.#beforeBlobUrl = null; }
64
+ if (this.#transparentBlobUrl) { URL.revokeObjectURL(this.#transparentBlobUrl); this.#transparentBlobUrl = null; }
65
+ }
66
+
67
+ #applyViewState() {
68
+ const mode = this.#viewState.mode;
69
+ const isFitHeight = mode === 'fit-height';
70
+ const isOneToOne = mode === 'one-to-one';
71
+ for (const sel of ['image-cropper', 'compare-slider']) {
72
+ const el = this.#q(sel);
73
+ if (!el) continue;
74
+ el.classList.toggle('expanded', isFitHeight);
75
+ el.classList.toggle('native-size', isOneToOne);
76
+ }
77
+ }
78
+
79
+ #persistViewState() {
80
+ localStorage.setItem('bgremoval_view_mode', this.#viewState.mode);
81
+ }
82
+
83
+ #setMode(mode) {
84
+ if (!BgRemovalApp.#VIEW_MODES.includes(mode)) return;
85
+ if (this.#viewState.mode === mode) return;
86
+ this.#viewState.mode = mode;
87
+ const vmc = this.#q('view-mode-controls');
88
+ if (vmc) vmc.mode = mode;
89
+ this.#applyViewState();
90
+ this.#persistViewState();
91
+ }
92
+
93
+ #defaultModeForImage(image) {
94
+ const vw = window.innerWidth || 1;
95
+ const vh = window.innerHeight || 1;
96
+ const imgRatio = image.width / image.height;
97
+ const vpRatio = vw / vh;
98
+ return imgRatio >= vpRatio ? 'fit-width' : 'fit-height';
99
+ }
100
+
101
+ #restoreViewState() {
102
+ const savedMode = localStorage.getItem('bgremoval_view_mode');
103
+ if (BgRemovalApp.#VIEW_MODES.includes(savedMode)) {
104
+ this.#viewState.mode = savedMode;
105
+ }
106
+ this.#q('view-mode-controls').mode = this.#viewState.mode;
107
+ this.#applyViewState();
108
+ }
109
+
110
+ #getVisibleCanvasElement() {
111
+ for (const sel of ['compare-slider', 'image-cropper', 'image-drop-zone']) {
112
+ const el = this.#q(sel);
113
+ if (el && el.offsetParent !== null) return el;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ #snapCenterVisibleCanvas() {
119
+ const el = this.#getVisibleCanvasElement();
120
+ if (!el) return;
121
+ requestAnimationFrame(() => {
122
+ const rect = el.getBoundingClientRect();
123
+ const vh = window.innerHeight;
124
+ const fullyVisible = rect.top >= 0 && rect.bottom <= vh;
125
+ if (fullyVisible) return;
126
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
127
+ });
128
+ }
129
+
130
+ #setupEvents() {
131
+ const backendEl = this.#q('.backend-select');
132
+ const removeBtn = this.#q('.remove-btn');
133
+ const stopBtn = this.#q('.stop-btn');
134
+ const startOverBtn = this.#q('.startover-btn');
135
+ const backToCropBtn = this.#q('.back-to-crop-btn');
136
+ const clearCropBtn = this.#q('.clear-crop-btn');
137
+ const viewModeControls = this.#q('view-mode-controls');
138
+ const openInTabBtn = this.#q('.open-in-tab-btn');
139
+ const downloadBtn = this.#q('.download-btn');
140
+ const toolbarLeft = this.#q('.canvas-toolbar-left');
141
+ const toolbarRight = this.#q('.canvas-toolbar-right');
142
+
143
+ const statusBar = this.#q('status-bar');
144
+ const dropZone = this.#q('image-drop-zone');
145
+ const cropper = this.#q('image-cropper');
146
+ const compareSlider = this.#q('compare-slider');
147
+
148
+ const showCompareControls = () => {
149
+ backToCropBtn.style.display = 'inline-block';
150
+ toolbarRight.hidden = false;
151
+ };
152
+ const hideCompareControls = () => {
153
+ backToCropBtn.style.display = 'none';
154
+ toolbarRight.hidden = true;
155
+ };
156
+
157
+ statusBar.set({ title: '', state: 'idle', details: '', progress: -1, tileCount: null });
158
+
159
+ const idleDetails = (img, crop) => crop
160
+ ? `${img.width}\u00d7${img.height}, cropped to ${crop.w}\u00d7${crop.h}. Click Remove Background.`
161
+ : `${img.width}\u00d7${img.height}. Drag to crop (optional), then click Remove Background.`;
162
+
163
+ const resetToStart = () => {
164
+ this.#loadedImage = null;
165
+ this.#running = false;
166
+ this.#abortController = null;
167
+ this.#cleanup();
168
+ removeBtn.disabled = true;
169
+ stopBtn.style.display = 'none';
170
+ startOverBtn.style.display = 'none';
171
+ clearCropBtn.style.display = 'none';
172
+ hideCompareControls();
173
+ toolbarLeft.hidden = true;
174
+ cropper.hide();
175
+ compareSlider.hide();
176
+ dropZone.show();
177
+ statusBar.set({ title: '', state: 'idle', details: '', progress: -1, tileCount: null });
178
+ };
179
+
180
+ const showReady = () => {
181
+ removeBtn.disabled = false;
182
+ startOverBtn.style.display = 'inline-block';
183
+ hideCompareControls();
184
+ toolbarLeft.hidden = false;
185
+ compareSlider.hide();
186
+ dropZone.hide();
187
+ cropper.show(this.#loadedImage);
188
+ const existingCrop = cropper.crop;
189
+ clearCropBtn.style.display = existingCrop ? 'inline-block' : 'none';
190
+ statusBar.set({
191
+ title: existingCrop ? 'Crop selected' : 'Image loaded',
192
+ state: 'idle',
193
+ details: idleDetails(this.#loadedImage, existingCrop),
194
+ progress: -1,
195
+ tileCount: null,
196
+ });
197
+ };
198
+
199
+ dropZone.addEventListener('image-loaded', (e) => {
200
+ if (this.#running) {
201
+ this.#abortController?.abort();
202
+ this.#running = false;
203
+ this.#abortController = null;
204
+ stopBtn.style.display = 'none';
205
+ }
206
+ this.#loadedImage = e.detail.image;
207
+ this.#setMode(this.#defaultModeForImage(this.#loadedImage));
208
+ showReady();
209
+ });
210
+
211
+ cropper.addEventListener('crop-changed', (e) => {
212
+ const crop = e.detail.crop;
213
+ clearCropBtn.style.display = crop ? 'inline-block' : 'none';
214
+ statusBar.set({
215
+ title: crop ? 'Crop selected' : 'Image loaded',
216
+ state: 'idle',
217
+ details: idleDetails(this.#loadedImage, crop),
218
+ });
219
+ });
220
+
221
+ clearCropBtn.addEventListener('click', () => {
222
+ cropper.clearCrop();
223
+ });
224
+
225
+ backToCropBtn.addEventListener('click', () => {
226
+ if (this.#running || !this.#loadedImage) return;
227
+ showReady();
228
+ });
229
+
230
+ viewModeControls.addEventListener('mode-change', (e) => {
231
+ this.#viewState.mode = e.detail.mode;
232
+ this.#applyViewState();
233
+ this.#persistViewState();
234
+ this.#snapCenterVisibleCanvas();
235
+ });
236
+
237
+ openInTabBtn.addEventListener('click', () => {
238
+ compareSlider.openInTab();
239
+ });
240
+ downloadBtn.addEventListener('click', () => {
241
+ compareSlider.download();
242
+ });
243
+
244
+ removeBtn.addEventListener('click', async () => {
245
+ if (this.#running || !this.#loadedImage) return;
246
+ this.#running = true;
247
+ this.#cleanup();
248
+ removeBtn.disabled = true;
249
+ stopBtn.style.display = 'inline-block';
250
+ startOverBtn.style.display = 'none';
251
+ clearCropBtn.style.display = 'none';
252
+ hideCompareControls();
253
+ compareSlider.hide();
254
+
255
+ this.#abortController = new AbortController();
256
+ const { signal } = this.#abortController;
257
+ const inputImage = cropper.extractImage();
258
+ cropper.style.display = 'none';
259
+
260
+ // runState is monotonic: 'running' \u2192 'warning' (locked once warned).
261
+ let runState = 'running';
262
+ const tracker = trackBackendEvents((ev) => {
263
+ if (ev.kind === 'attempt') {
264
+ statusBar.set({ title: `Loading on ${friendlyBackend(ev.backend)}`, state: runState });
265
+ } else if (ev.kind === 'success') {
266
+ statusBar.set({ title: `Running on ${friendlyBackend(ev.backend)}`, state: runState });
267
+ } else if (ev.kind === 'fallback') {
268
+ runState = 'warning';
269
+ statusBar.set({ title: `Fallback from ${friendlyBackend(ev.backend)}`, state: runState });
270
+ } else if (ev.kind === 'skipped') {
271
+ runState = 'warning';
272
+ statusBar.set({ title: `Skipping ${friendlyBackend(ev.backend)}`, state: runState });
273
+ }
274
+ });
275
+
276
+ try {
277
+ statusBar.set({
278
+ title: 'Loading model',
279
+ state: 'running',
280
+ details: '',
281
+ progress: 0,
282
+ tileCount: null,
283
+ });
284
+ await this.#engine.loadModel(BgRemovalApp.BRIA_MODEL_KEY, backendEl.value, (frac, msg) => {
285
+ statusBar.set({ progress: frac, details: msg });
286
+ });
287
+
288
+ statusBar.set({
289
+ title: 'Removing background',
290
+ state: runState,
291
+ details: 'Running inference\u2026',
292
+ progress: -2,
293
+ });
294
+
295
+ const resultCanvas = await this.#engine.removeBackground(inputImage, signal);
296
+
297
+ const w = inputImage.width;
298
+ const h = inputImage.height;
299
+ const summary = tracker.summary();
300
+ const userWantsGpu = backendEl.value === 'gpu';
301
+ const ranOnCpuDespiteIntent = userWantsGpu && summary.activeBackend && !realizedIsGpu(summary.activeBackend);
302
+ const finalState = (summary.hadFallback || summary.hadSkip || ranOnCpuDespiteIntent) ? 'warning' : 'success';
303
+ const via = summary.activeBackend ? ` via ${friendlyBackend(summary.activeBackend)}` : '';
304
+ const detailsLines = [`${w}\u00d7${h}${via}`];
305
+ if (ranOnCpuDespiteIntent && !summary.hadFallback) {
306
+ detailsLines.push(`Requested GPU but running on ${friendlyBackend(summary.activeBackend)} (prior fallback this session \u2014 reload to retry GPU).`);
307
+ }
308
+ if (summary.lines.length) detailsLines.push(...summary.lines);
309
+ statusBar.set({
310
+ title: 'Done',
311
+ state: finalState,
312
+ details: detailsLines.join('\n'),
313
+ progress: -1,
314
+ tileCount: null,
315
+ });
316
+
317
+ this.#transparentBlobUrl = await canvasToBlobUrl(resultCanvas);
318
+ this.#checkerBlobUrl = await checkerboardComposite(resultCanvas);
319
+ this.#beforeBlobUrl = await imageToBlobUrl(inputImage);
320
+
321
+ await compareSlider.show(this.#beforeBlobUrl, this.#checkerBlobUrl, {
322
+ downloadSrc: this.#transparentBlobUrl,
323
+ downloadName: 'bg_removed.png',
324
+ });
325
+ this.#applyViewState();
326
+ showCompareControls();
327
+
328
+ } catch (e) {
329
+ if (e.name === 'AbortError') {
330
+ statusBar.set({
331
+ title: 'Cancelled',
332
+ state: 'idle',
333
+ details: 'You stopped this run.',
334
+ progress: -1,
335
+ tileCount: null,
336
+ });
337
+ } else {
338
+ console.error(e);
339
+ statusBar.set({
340
+ title: 'Error',
341
+ state: 'error',
342
+ details: e.message || String(e),
343
+ progress: -1,
344
+ tileCount: null,
345
+ });
346
+ }
347
+ } finally {
348
+ tracker.stop();
349
+ }
350
+
351
+ this.#running = false;
352
+ this.#abortController = null;
353
+ stopBtn.style.display = 'none';
354
+ startOverBtn.style.display = 'inline-block';
355
+ removeBtn.disabled = false;
356
+ });
357
+
358
+ stopBtn.addEventListener('click', () => {
359
+ this.#abortController?.abort();
360
+ });
361
+
362
+ startOverBtn.addEventListener('click', () => {
363
+ if (this.#running) this.#abortController?.abort();
364
+ resetToStart();
365
+ });
366
+
367
+ }
368
+
369
+ #render() {
370
+ morph(this, `
371
+ <style>
372
+ bg-removal-app .controls {
373
+ display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem;
374
+ align-items: center; margin-bottom: 1rem;
375
+ }
376
+ bg-removal-app .controls label {
377
+ display: inline-flex; align-items: center; gap: 0.35rem;
378
+ font-size: 0.85rem; margin-bottom: 0; white-space: nowrap;
379
+ }
380
+ bg-removal-app .controls select {
381
+ margin-bottom: 0; padding: 0.3rem 0.5rem;
382
+ font-size: 0.85rem; width: auto;
383
+ }
384
+ bg-removal-app .canvas-stack {
385
+ position: relative;
386
+ background: rgba(0, 0, 0, 0.4);
387
+ border-radius: var(--pico-border-radius);
388
+ padding: 0.5rem;
389
+ }
390
+ bg-removal-app .canvas-toolbar-rail {
391
+ position: sticky;
392
+ top: 0.75rem;
393
+ height: 0;
394
+ z-index: 10;
395
+ pointer-events: none;
396
+ }
397
+ bg-removal-app .canvas-toolbar {
398
+ position: absolute;
399
+ top: 0;
400
+ display: inline-flex;
401
+ gap: 0.25rem;
402
+ align-items: center;
403
+ padding: 0.25rem 0.3rem;
404
+ background: color-mix(in oklab, var(--pico-card-background-color, #1e1e2e) 32%, transparent);
405
+ border: 1px solid color-mix(in oklab, var(--pico-muted-border-color) 45%, transparent);
406
+ border-radius: var(--pico-border-radius);
407
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
408
+ backdrop-filter: blur(10px) saturate(1.1);
409
+ -webkit-backdrop-filter: blur(10px) saturate(1.1);
410
+ pointer-events: auto;
411
+ max-width: calc(100% - 1.5rem);
412
+ }
413
+ bg-removal-app .canvas-toolbar-left {
414
+ left: 0.75rem;
415
+ }
416
+ bg-removal-app .canvas-toolbar-right {
417
+ right: 0.75rem;
418
+ }
419
+ bg-removal-app .canvas-toolbar[hidden] {
420
+ display: none;
421
+ }
422
+ bg-removal-app .canvas-toolbar-stack-left {
423
+ position: absolute;
424
+ top: 0;
425
+ left: 0.75rem;
426
+ display: flex;
427
+ flex-direction: column;
428
+ align-items: flex-start;
429
+ gap: 0.25rem;
430
+ max-width: calc(100% - 1.5rem);
431
+ pointer-events: none;
432
+ }
433
+ bg-removal-app .canvas-toolbar-stack-left > * {
434
+ pointer-events: auto;
435
+ }
436
+ bg-removal-app .canvas-toolbar-stack-left > .canvas-toolbar {
437
+ position: static;
438
+ left: auto;
439
+ top: auto;
440
+ max-width: 100%;
441
+ }
442
+ bg-removal-app .canvas-toolbar button {
443
+ margin-bottom: 0;
444
+ padding: 0.25rem 0.5rem;
445
+ font-size: 0.72rem;
446
+ line-height: 1.2;
447
+ width: auto;
448
+ white-space: nowrap;
449
+ }
450
+ bg-removal-app .canvas-toolbar button.secondary,
451
+ bg-removal-app .canvas-toolbar button.outline {
452
+ opacity: 0.78;
453
+ transition: opacity 0.15s ease;
454
+ background: transparent;
455
+ border-color: currentColor;
456
+ color: #fff;
457
+ mix-blend-mode: difference;
458
+ }
459
+ bg-removal-app .canvas-toolbar button.secondary:hover,
460
+ bg-removal-app .canvas-toolbar button.outline:hover,
461
+ bg-removal-app .canvas-toolbar button.secondary:focus-visible,
462
+ bg-removal-app .canvas-toolbar button.outline:focus-visible {
463
+ opacity: 1;
464
+ background: transparent;
465
+ border-color: currentColor;
466
+ color: #fff;
467
+ }
468
+ bg-removal-app .canvas-toolbar button .fas {
469
+ font-size: 0.78em;
470
+ margin-right: 0.15rem;
471
+ }
472
+ bg-removal-app .canvas-toolbar button .btn-label {
473
+ display: inline;
474
+ }
475
+ @media (max-width: 768px) {
476
+ bg-removal-app .canvas-toolbar button .btn-label {
477
+ display: none;
478
+ }
479
+ bg-removal-app .canvas-toolbar button .fas {
480
+ margin-right: 0;
481
+ }
482
+ }
483
+ bg-removal-app .canvas-toolbar status-bar {
484
+ display: inline-flex;
485
+ flex-direction: column;
486
+ align-items: stretch;
487
+ justify-content: center;
488
+ gap: 0.15rem;
489
+ margin-left: 0.3rem;
490
+ min-width: 0;
491
+ max-width: 20rem;
492
+ }
493
+ bg-removal-app .canvas-toolbar status-bar .status-text {
494
+ font-size: 0.68rem;
495
+ line-height: 1.25;
496
+ min-height: 0;
497
+ margin-bottom: 0;
498
+ color: #fff;
499
+ mix-blend-mode: difference;
500
+ }
501
+ bg-removal-app .canvas-toolbar status-bar .progress-track {
502
+ width: 100%;
503
+ max-width: 180px;
504
+ height: 3px;
505
+ margin-bottom: 0;
506
+ mix-blend-mode: difference;
507
+ }
508
+ </style>
509
+
510
+ <h2>
511
+ <i class="fas fa-eraser"></i> Background Removal
512
+ <span style="font-size:0.7rem; color:var(--pico-muted-color)">(in-browser, ONNX Runtime)</span>
513
+ </h2>
514
+
515
+ <div class="controls">
516
+ <label>Backend:
517
+ <select class="backend-select">
518
+ <option value="gpu">GPU</option>
519
+ <option value="cpu">CPU</option>
520
+ </select>
521
+ </label>
522
+ </div>
523
+
524
+ <div class="canvas-stack">
525
+ <div class="canvas-toolbar-rail" aria-hidden="true">
526
+ <div class="canvas-toolbar-stack-left">
527
+ <div class="canvas-toolbar canvas-toolbar-left" hidden>
528
+ <button class="back-to-crop-btn secondary outline" style="display:none" type="button" title="Back to crop / change selection">
529
+ <i class="fas fa-arrow-left"></i><i class="fas fa-crop-simple"></i> <span class="btn-label">Edit Crop</span>
530
+ </button>
531
+ <button class="remove-btn" disabled title="Remove background">
532
+ <i class="fas fa-eraser"></i> <span class="btn-label">Remove Background</span>
533
+ </button>
534
+ <button class="stop-btn secondary" style="display:none" title="Stop background removal">
535
+ <i class="fas fa-stop"></i> <span class="btn-label">Stop</span>
536
+ </button>
537
+ <view-mode-controls></view-mode-controls>
538
+ <button class="clear-crop-btn secondary outline" style="display:none" type="button" title="Clear the selected crop region">
539
+ <i class="fas fa-eraser"></i> <span class="btn-label">Clear Selection</span>
540
+ </button>
541
+ <button class="startover-btn secondary outline" style="display:none" title="Clear and start over with a new image">
542
+ <i class="fas fa-xmark"></i> <span class="btn-label">Clear</span>
543
+ </button>
544
+ <status-bar></status-bar>
545
+ </div>
546
+ </div>
547
+ <div class="canvas-toolbar canvas-toolbar-right" hidden>
548
+ <button class="open-in-tab-btn secondary outline" type="button" title="Open the result image in a new tab">
549
+ <i class="fas fa-up-right-from-square"></i> <span class="btn-label">Open in Tab</span>
550
+ </button>
551
+ <button class="download-btn secondary outline" type="button" title="Download the transparent PNG">
552
+ <i class="fas fa-download"></i> <span class="btn-label">Download</span>
553
+ </button>
554
+ </div>
555
+ </div>
556
+ <image-drop-zone></image-drop-zone>
557
+ <image-cropper></image-cropper>
558
+ <compare-slider after-label="BG Removed"></compare-slider>
559
+ </div>
560
+ `);
561
+ }
562
+ }
563
+
564
+ customElements.define('bg-removal-app', BgRemovalApp);
features/bg-removal/bg-removal-engine.js ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * BgRemovalEngine — pure inference logic, zero DOM dependency.
3
+ * Downloads an ONNX segmentation model, creates a session,
4
+ * and runs background removal on an image.
5
+ */
6
+
7
+ import { fetchWithProgress } from 'lib/fetch-progress';
8
+ import { dispatchBackendEvent } from 'lib/backend-events';
9
+ import { loadSession } from 'lib/backend';
10
+
11
+ const MODELS = {
12
+ 'isnet': {
13
+ url: 'https://huggingface.co/onnx-community/ISNet-ONNX/resolve/main/onnx/model_quantized.onnx',
14
+ inputSize: 1024,
15
+ label: 'IS-Net (General Use, ~42 MB)',
16
+ // preprocessor_config: do_rescale=false, mean=[128,128,128], std=[256,256,256]
17
+ preprocess(pixel) { return (pixel - 128) / 256; },
18
+ },
19
+ 'rmbg-1.4': {
20
+ url: 'https://huggingface.co/briaai/RMBG-1.4/resolve/main/onnx/model_quantized.onnx',
21
+ inputSize: 1024,
22
+ label: 'BRIA RMBG-1.4 (High Quality, ~44 MB)',
23
+ // preprocessor_config: do_rescale=true (1/255), mean=[0.5,0.5,0.5], std=[1,1,1]
24
+ preprocess(pixel) { return pixel / 255 - 0.5; },
25
+ },
26
+ };
27
+
28
+ export { MODELS };
29
+
30
+ export class BgRemovalEngine {
31
+ #session = null;
32
+ #modelBuffer = null;
33
+ #currentModelKey = null;
34
+ #intent = null;
35
+ // Set by loadSession; kept current by #backendListener so a runtime EP
36
+ // fallback (worker drops from CoreML to CPU between calls) doesn't leave
37
+ // a stale label that the loadModel early-return re-announces later.
38
+ #realizedBackend = null;
39
+ #backendListener = null;
40
+
41
+ get isLoaded() { return this.#session !== null; }
42
+ get currentModel() { return this.#currentModelKey; }
43
+ get realizedBackend() { return this.#realizedBackend; }
44
+ get intent() { return this.#intent; }
45
+
46
+ #trackRealizedBackend() {
47
+ if (this.#backendListener) return;
48
+ this.#backendListener = (e) => {
49
+ const d = e?.detail;
50
+ if (d && d.kind === 'success' && typeof d.backend === 'string') {
51
+ this.#realizedBackend = d.backend;
52
+ }
53
+ };
54
+ document.addEventListener('aitools:backend-event', this.#backendListener);
55
+ }
56
+ #untrackRealizedBackend() {
57
+ if (!this.#backendListener) return;
58
+ document.removeEventListener('aitools:backend-event', this.#backendListener);
59
+ this.#backendListener = null;
60
+ }
61
+
62
+ async loadModel(modelKey, intent = 'cpu', onProgress) {
63
+ if (onProgress != null && typeof onProgress !== 'function') {
64
+ console.warn('[BgRemovalEngine] Ignoring non-function onProgress callback.', {
65
+ type: typeof onProgress,
66
+ value: onProgress,
67
+ modelKey,
68
+ intent,
69
+ });
70
+ }
71
+ intent = normalizeIntent(intent);
72
+ const report = typeof onProgress === 'function' ? onProgress : null;
73
+ if (this.#session && this.#currentModelKey === modelKey && this.#intent === intent) {
74
+ // Same model + same intent \u2014 re-announce so per-run tracker captures
75
+ // the active backend label.
76
+ if (this.#realizedBackend) {
77
+ dispatchBackendEvent({ kind: 'success', backend: this.#realizedBackend });
78
+ }
79
+ return;
80
+ }
81
+
82
+ const cfg = MODELS[modelKey];
83
+ if (!cfg) throw new Error(`Unknown model: ${modelKey}`);
84
+
85
+ // Release old session if switching models or intent.
86
+ if (this.#session) {
87
+ this.#session.release();
88
+ this.#session = null;
89
+ }
90
+ if (this.#currentModelKey !== modelKey) {
91
+ this.#modelBuffer = null;
92
+ }
93
+
94
+ if (!this.#modelBuffer) {
95
+ this.#modelBuffer = await fetchWithProgress(cfg.url, report);
96
+ }
97
+
98
+ report?.(1, 'Loading model into runtime\u2026');
99
+
100
+ const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent);
101
+ this.#session = session;
102
+ this.#intent = intent;
103
+ this.#realizedBackend = realizedBackend;
104
+ this.#currentModelKey = modelKey;
105
+ this.#trackRealizedBackend();
106
+ report?.(1, 'Model loaded.');
107
+ }
108
+
109
+ async removeBackground(image, signal) {
110
+ if (!this.#session) throw new Error('Model not loaded — call loadModel() first');
111
+ if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
112
+
113
+ const cfg = MODELS[this.#currentModelKey];
114
+ const inputSize = cfg.inputSize;
115
+ const origW = image.width;
116
+ const origH = image.height;
117
+
118
+ // --- Resize input to model dimensions ---
119
+ const tmpCanvas = document.createElement('canvas');
120
+ tmpCanvas.width = inputSize;
121
+ tmpCanvas.height = inputSize;
122
+ const tmpCtx = tmpCanvas.getContext('2d');
123
+ tmpCtx.drawImage(image, 0, 0, inputSize, inputSize);
124
+ const imageData = tmpCtx.getImageData(0, 0, inputSize, inputSize);
125
+
126
+ // --- Convert to CHW Float32 with model-specific normalization ---
127
+ const planeSize = inputSize * inputSize;
128
+ const float32 = new Float32Array(3 * planeSize);
129
+ const px = imageData.data;
130
+ const preprocess = cfg.preprocess;
131
+
132
+ for (let i = 0; i < planeSize; i++) {
133
+ const si = i * 4;
134
+ float32[i] = preprocess(px[si]);
135
+ float32[planeSize + i] = preprocess(px[si + 1]);
136
+ float32[2 * planeSize + i] = preprocess(px[si + 2]);
137
+ }
138
+
139
+ // Free temp canvas memory
140
+ tmpCanvas.width = 0;
141
+ tmpCanvas.height = 0;
142
+
143
+ if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
144
+
145
+ // --- Run inference ---
146
+ const tensor = new ort.Tensor('float32', float32, [1, 3, inputSize, inputSize]);
147
+ const inputName = this.#session.inputNames[0];
148
+ const outputName = this.#session.outputNames[0];
149
+
150
+ const results = await this.#session.run({ [inputName]: tensor });
151
+ const rawMask = results[outputName].data;
152
+
153
+ tensor.dispose();
154
+
155
+ // --- Sigmoid + clamp to 0-1 ---
156
+ const maskSize = inputSize * inputSize;
157
+ const mask = new Float32Array(maskSize);
158
+ for (let i = 0; i < maskSize; i++) {
159
+ const v = rawMask[i];
160
+ // Apply sigmoid if value is outside 0-1 (raw logits)
161
+ mask[i] = (v < 0 || v > 1) ? 1 / (1 + Math.exp(-v)) : v;
162
+ }
163
+
164
+ results[outputName].dispose();
165
+
166
+ // --- Write mask to canvas, resize to original dimensions ---
167
+ const maskCanvas = document.createElement('canvas');
168
+ maskCanvas.width = inputSize;
169
+ maskCanvas.height = inputSize;
170
+ const maskCtx = maskCanvas.getContext('2d');
171
+ const maskImgData = maskCtx.createImageData(inputSize, inputSize);
172
+ for (let i = 0; i < maskSize; i++) {
173
+ const v = Math.round(mask[i] * 255);
174
+ maskImgData.data[i * 4] = v;
175
+ maskImgData.data[i * 4 + 1] = v;
176
+ maskImgData.data[i * 4 + 2] = v;
177
+ maskImgData.data[i * 4 + 3] = 255;
178
+ }
179
+ maskCtx.putImageData(maskImgData, 0, 0);
180
+
181
+ // Resize mask to original image dimensions (bilinear via drawImage)
182
+ const fullMaskCanvas = document.createElement('canvas');
183
+ fullMaskCanvas.width = origW;
184
+ fullMaskCanvas.height = origH;
185
+ const fullMaskCtx = fullMaskCanvas.getContext('2d');
186
+ fullMaskCtx.drawImage(maskCanvas, 0, 0, origW, origH);
187
+ const fullMaskData = fullMaskCtx.getImageData(0, 0, origW, origH);
188
+
189
+ // Free small mask canvas
190
+ maskCanvas.width = 0;
191
+ maskCanvas.height = 0;
192
+
193
+ // --- Apply mask as alpha channel to original image ---
194
+ const resultCanvas = document.createElement('canvas');
195
+ resultCanvas.width = origW;
196
+ resultCanvas.height = origH;
197
+ const resultCtx = resultCanvas.getContext('2d');
198
+ resultCtx.drawImage(image, 0, 0);
199
+ const resultData = resultCtx.getImageData(0, 0, origW, origH);
200
+
201
+ for (let i = 0; i < origW * origH; i++) {
202
+ resultData.data[i * 4 + 3] = fullMaskData.data[i * 4]; // R channel = mask
203
+ }
204
+ resultCtx.putImageData(resultData, 0, 0);
205
+
206
+ // Free full mask canvas
207
+ fullMaskCanvas.width = 0;
208
+ fullMaskCanvas.height = 0;
209
+
210
+ return resultCanvas;
211
+ }
212
+
213
+ release() {
214
+ this.#untrackRealizedBackend();
215
+ if (this.#session) {
216
+ this.#session.release();
217
+ this.#session = null;
218
+ }
219
+ this.#modelBuffer = null;
220
+ this.#currentModelKey = null;
221
+ this.#intent = null;
222
+ this.#realizedBackend = null;
223
+ }
224
+ }
225
+
226
+ function normalizeIntent(value) {
227
+ if (value === 'webgpu' || value === 'gpu') return 'gpu';
228
+ if (value === 'wasm' || value === 'cpu') return 'cpu';
229
+ return 'cpu';
230
+ }
features/upscaler/custom-models/custom-model-inspector.js ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readMetaEntry, isFp16InputType } from 'lib/onnx-meta';
2
+
3
+ function normalizeDims(dims) {
4
+ if (!Array.isArray(dims)) return [];
5
+ return dims.map((d) => (typeof d === 'number' ? d : Number.NaN));
6
+ }
7
+
8
+ /**
9
+ * The ValueMetadata shape array is `shape` in ORT-Web 1.20+; older
10
+ * versions called the same field `dimensions`. Read either and let the
11
+ * caller handle the array of (number | string) entries.
12
+ */
13
+ function readMetaShape(meta) {
14
+ return meta?.shape ?? meta?.dimensions ?? null;
15
+ }
16
+
17
+ function detectLayout(dims) {
18
+ if (dims.length !== 4) return 'unknown';
19
+ const cAt1 = dims[1];
20
+ const cAt3 = dims[3];
21
+ if (cAt1 === 3 || cAt1 === 1) return 'nchw';
22
+ if (cAt3 === 3 || cAt3 === 1) return 'nhwc';
23
+ return 'unknown';
24
+ }
25
+
26
+ function inferScaleFromStaticDims(inDims, outDims, layout) {
27
+ if (inDims.length !== 4 || outDims.length !== 4) return null;
28
+ let inH, inW, outH, outW;
29
+ if (layout === 'nhwc') {
30
+ inH = inDims[1];
31
+ inW = inDims[2];
32
+ outH = outDims[1];
33
+ outW = outDims[2];
34
+ } else {
35
+ inH = inDims[2];
36
+ inW = inDims[3];
37
+ outH = outDims[2];
38
+ outW = outDims[3];
39
+ }
40
+ if (![inH, inW, outH, outW].every(Number.isFinite)) return null;
41
+ if (inH <= 0 || inW <= 0) return null;
42
+ const sx = outW / inW;
43
+ const sy = outH / inH;
44
+ if (!Number.isFinite(sx) || !Number.isFinite(sy) || sx !== sy) return null;
45
+ if (sx < 1 || sx > 16) return null;
46
+ return Math.round(sx);
47
+ }
48
+
49
+ function rangeFromInputType(inputType) {
50
+ if (typeof inputType !== 'string') return 1;
51
+ if (inputType.includes('uint8') || inputType.includes('int8')) return 255;
52
+ return 1;
53
+ }
54
+
55
+ function createProbeTensor(ort, inputType, layout, size, range) {
56
+ const dims = layout === 'nhwc' ? [1, size, size, 3] : [1, 3, size, size];
57
+ const count = dims.reduce((acc, v) => acc * v, 1);
58
+ const type = String(inputType || 'float32').toLowerCase();
59
+ if (type.includes('uint8')) {
60
+ const data = new Uint8Array(count);
61
+ data.fill(range === 255 ? 128 : 1);
62
+ return new ort.Tensor('uint8', data, dims);
63
+ }
64
+ if (type.includes('int8')) {
65
+ const data = new Int8Array(count);
66
+ data.fill(0);
67
+ return new ort.Tensor('int8', data, dims);
68
+ }
69
+ if (type.includes('float16')) {
70
+ const data = new Uint16Array(count);
71
+ return new ort.Tensor('float16', data, dims);
72
+ }
73
+ const data = new Float32Array(count);
74
+ data.fill(range === 255 ? 128 : 0.5);
75
+ return new ort.Tensor('float32', data, dims);
76
+ }
77
+
78
+ function getPrimaryOutput(results, outputName) {
79
+ if (!results || typeof results !== 'object') return null;
80
+ if (outputName && results[outputName]) return results[outputName];
81
+ const first = Object.values(results)[0];
82
+ return first || null;
83
+ }
84
+
85
+ function inferScaleFromProbeOutput(layout, inputSize, outDims) {
86
+ if (!Array.isArray(outDims) || outDims.length !== 4) return null;
87
+ const dims = normalizeDims(outDims);
88
+ const outH = layout === 'nhwc' ? dims[1] : dims[2];
89
+ const outW = layout === 'nhwc' ? dims[2] : dims[3];
90
+ if (![outH, outW].every(Number.isFinite) || inputSize <= 0) return null;
91
+ const sx = outW / inputSize;
92
+ const sy = outH / inputSize;
93
+ if (!Number.isFinite(sx) || !Number.isFinite(sy) || sx !== sy) return null;
94
+ if (sx < 1 || sx > 16) return null;
95
+ return Math.round(sx);
96
+ }
97
+
98
+ function inferMultipleFromReshapeError(rawError) {
99
+ const raw = String(rawError || '');
100
+ const match = raw.match(/requested shape:\{([^}]*)\}/i);
101
+ if (!match) return null;
102
+ const requested = match[1]
103
+ .split(',')
104
+ .map((v) => parseInt(v.trim(), 10))
105
+ .filter(Number.isFinite);
106
+ const pow2 = requested.filter((d) => d > 1 && d <= 256 && (d & (d - 1)) === 0);
107
+ if (!pow2.length) return null;
108
+ return Math.max(...pow2);
109
+ }
110
+
111
+ /**
112
+ * Detect a hard upper bound on input size by probing at increasing sizes.
113
+ * Some ONNX exports (notably PyTorch traces of models with shape-dependent
114
+ * conditionals — e.g. DAT-style window attention) bake in window counts as
115
+ * Constants and only accept inputs near the trace size. The auto-detect
116
+ * sweet-spot probe at 64 alone can't catch this, so we sweep a few common
117
+ * tile sizes upward and report the largest size that still runs.
118
+ *
119
+ * Returns `null` if the model worked at every probed size (i.e. no upper
120
+ * bound was found within the tested range).
121
+ */
122
+ async function probeMaxInputSize(session, ort, baseProbeArgs, knownWorkingSize, report) {
123
+ const candidates = [128, 256];
124
+ let lastWorking = knownWorkingSize;
125
+ let foundUpperBound = false;
126
+ for (const size of candidates) {
127
+ if (size <= lastWorking) continue;
128
+ report?.(`testing maximum tile size at ${size}\u00d7${size}…`);
129
+ const probe = await runProbe(session, ort, { ...baseProbeArgs, size });
130
+ if (probe.ok) {
131
+ lastWorking = size;
132
+ } else {
133
+ foundUpperBound = true;
134
+ break;
135
+ }
136
+ }
137
+ return foundUpperBound ? lastWorking : null;
138
+ }
139
+
140
+ async function runProbe(session, ort, { inputName, outputName, inputType, layout, size, range }) {
141
+ const inputTensor = createProbeTensor(ort, inputType, layout, size, range);
142
+ try {
143
+ const results = await session.run({ [inputName]: inputTensor });
144
+ const output = getPrimaryOutput(results, outputName);
145
+ const outDims = output?.dims || [];
146
+ for (const tensor of Object.values(results)) {
147
+ try { tensor?.dispose?.(); } catch {}
148
+ }
149
+ return { ok: true, outDims };
150
+ } catch (error) {
151
+ return { ok: false, error };
152
+ } finally {
153
+ try { inputTensor.dispose?.(); } catch {}
154
+ }
155
+ }
156
+
157
+ export class CustomModelInspector {
158
+ async inspectFile(file, { onProgress } = {}) {
159
+ if (!(file instanceof File)) {
160
+ throw new Error('Expected an ONNX file.');
161
+ }
162
+ const report = typeof onProgress === 'function' ? onProgress : null;
163
+ const ort = globalThis.ort;
164
+ if (!ort?.InferenceSession) {
165
+ return {
166
+ scale: 4,
167
+ range: 1,
168
+ layout: 'nchw',
169
+ multipleOf: 1,
170
+ maxTileSize: null,
171
+ inputType: 'float32',
172
+ precision: 'fp32',
173
+ scaleSource: 'default',
174
+ multipleOfSource: 'default',
175
+ notes: ['ONNX Runtime not loaded yet; using defaults (4x, range 1).'],
176
+ };
177
+ }
178
+
179
+ report?.('reading ONNX metadata and loading session…');
180
+ const bytes = await file.arrayBuffer();
181
+ // Probe on WebGPU first when available — fp16 models will only run there,
182
+ // and fp32 models work fine on either EP, so WebGPU is the right default.
183
+ // Fall back to WASM when WebGPU is unavailable or session creation fails.
184
+ const hasWebGpu = !!(navigator.gpu && ort.env?.webgpu);
185
+ let session = null;
186
+ let probeBackend = null;
187
+ if (hasWebGpu) {
188
+ try {
189
+ report?.('loading session on WebGPU…');
190
+ session = await ort.InferenceSession.create(bytes, {
191
+ executionProviders: [{ name: 'webgpu', preferredLayout: 'NCHW' }],
192
+ graphOptimizationLevel: 'all',
193
+ });
194
+ probeBackend = 'webgpu';
195
+ } catch (err) {
196
+ console.warn('[CustomModelInspector] WebGPU probe session failed, falling back to WASM:', err);
197
+ }
198
+ }
199
+ if (!session) {
200
+ report?.('loading session on CPU/WASM…');
201
+ session = await ort.InferenceSession.create(bytes, {
202
+ executionProviders: ['wasm'],
203
+ graphOptimizationLevel: 'all',
204
+ });
205
+ probeBackend = 'wasm';
206
+ }
207
+ try {
208
+ const inputName = session.inputNames?.[0];
209
+ const outputName = session.outputNames?.[0];
210
+ if (!inputName) {
211
+ return {
212
+ scale: 4,
213
+ range: 1,
214
+ layout: 'nchw',
215
+ multipleOf: 1,
216
+ maxTileSize: null,
217
+ inputType: 'float32',
218
+ precision: 'fp32',
219
+ scaleSource: 'default',
220
+ multipleOfSource: 'default',
221
+ notes: ['Model has no detectable input tensor; using defaults.'],
222
+ };
223
+ }
224
+ const inMeta = readMetaEntry(session.inputMetadata, inputName, 0);
225
+ const outMeta = readMetaEntry(session.outputMetadata, outputName, 0);
226
+ const inDims = normalizeDims(readMetaShape(inMeta));
227
+ const outDims = normalizeDims(readMetaShape(outMeta));
228
+ const layout = detectLayout(inDims);
229
+ const inputType = inMeta?.type || 'float32';
230
+ const range = rangeFromInputType(inputType);
231
+ const precision = isFp16InputType(inputType) ? 'fp16' : 'fp32';
232
+ const notes = [];
233
+ if (precision === 'fp16' && probeBackend !== 'webgpu') {
234
+ notes.push('This model is fp16 but probing fell back to CPU/WASM, which has limited fp16 op coverage. Probe results may be unreliable; the model itself will require WebGPU at run time.');
235
+ }
236
+ const layoutCandidates = layout === 'unknown'
237
+ ? ['nchw', 'nhwc']
238
+ : [layout, layout === 'nchw' ? 'nhwc' : 'nchw'];
239
+
240
+ let chosenLayout = layout === 'nhwc' ? 'nhwc' : 'nchw';
241
+ let probeScale = null;
242
+ let probeWorked = false;
243
+ for (const candidateLayout of layoutCandidates) {
244
+ report?.(`testing ${candidateLayout.toUpperCase()} layout at 64\u00d764…`);
245
+ const probe = await runProbe(session, ort, {
246
+ inputName,
247
+ outputName,
248
+ inputType,
249
+ layout: candidateLayout,
250
+ size: 64,
251
+ range,
252
+ });
253
+ if (!probe.ok) continue;
254
+ probeWorked = true;
255
+ chosenLayout = candidateLayout;
256
+ probeScale = inferScaleFromProbeOutput(candidateLayout, 64, probe.outDims);
257
+ break;
258
+ }
259
+ if (!probeWorked) {
260
+ notes.push('Layout probe failed for both NCHW and NHWC; using defaults.');
261
+ } else if (chosenLayout === 'nhwc') {
262
+ notes.push('Detected NHWC layout from probe; this can run, but may be slower than NCHW.');
263
+ }
264
+
265
+ const staticScale = inferScaleFromStaticDims(inDims, outDims, chosenLayout);
266
+ const scale = probeScale || staticScale || 4;
267
+ const scaleSource = probeScale ? 'probe' : staticScale ? 'metadata' : 'default';
268
+
269
+ let multipleOf = 1;
270
+ let multipleOfSource = 'default';
271
+ if (probeWorked) {
272
+ report?.('checking window-size alignment at 60\u00d760…');
273
+ const mismatchProbe = await runProbe(session, ort, {
274
+ inputName,
275
+ outputName,
276
+ inputType,
277
+ layout: chosenLayout,
278
+ size: 60,
279
+ range,
280
+ });
281
+ if (!mismatchProbe.ok) {
282
+ const inferred = inferMultipleFromReshapeError(mismatchProbe.error?.message);
283
+ if (Number.isFinite(inferred) && inferred > 1) {
284
+ multipleOf = inferred;
285
+ multipleOfSource = 'probe';
286
+ }
287
+ }
288
+ }
289
+
290
+ let maxTileSize = null;
291
+ if (probeWorked) {
292
+ maxTileSize = await probeMaxInputSize(
293
+ session,
294
+ ort,
295
+ { inputName, outputName, inputType, layout: chosenLayout, range },
296
+ 64,
297
+ report,
298
+ );
299
+ if (Number.isFinite(maxTileSize)) {
300
+ // Some exports (e.g. DAT with window-count constants baked in) only
301
+ // accept inputs in a narrow band around the trace size. To keep
302
+ // every padded edge tile inside that band, force the alignment to
303
+ // equal the discovered max — combined with a tile-size cap in the
304
+ // engine, every inference then runs at exactly maxTileSize.
305
+ if (maxTileSize > multipleOf) {
306
+ multipleOf = maxTileSize;
307
+ multipleOfSource = 'probe';
308
+ }
309
+ notes.push(
310
+ `Model rejected inputs larger than ${maxTileSize}\u00d7${maxTileSize}; tile size will be capped at ${maxTileSize}.`,
311
+ );
312
+ }
313
+ }
314
+
315
+ return {
316
+ scale,
317
+ range,
318
+ layout: chosenLayout,
319
+ multipleOf,
320
+ maxTileSize,
321
+ inputType,
322
+ precision,
323
+ scaleSource,
324
+ multipleOfSource,
325
+ notes,
326
+ };
327
+ } finally {
328
+ try { session.release?.(); } catch {}
329
+ }
330
+ }
331
+ }
332
+
333
+ export async function inspectCustomModelFile(file, opts) {
334
+ const inspector = new CustomModelInspector();
335
+ return inspector.inspectFile(file, opts);
336
+ }
features/upscaler/custom-models/custom-model-store.js ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CUSTOM_MODEL_URL_PREFIX, getModelCache } from 'lib/fetch-progress';
2
+
3
+ const CUSTOM_MODELS_KEY = 'upscaler_custom_models';
4
+
5
+ function sanitizeScale(scale) {
6
+ const parsed = parseInt(scale, 10);
7
+ if (!Number.isFinite(parsed) || parsed < 1) return 1;
8
+ if (parsed > 16) return 16;
9
+ return parsed;
10
+ }
11
+
12
+ function sanitizeRange(range) {
13
+ const parsed = parseInt(range, 10);
14
+ if (parsed === 255) return 255;
15
+ return 1;
16
+ }
17
+
18
+ function sanitizeLayout(layout) {
19
+ const normalized = String(layout || '').toLowerCase();
20
+ if (normalized === 'nhwc') return 'nhwc';
21
+ return 'nchw';
22
+ }
23
+
24
+ function sanitizeMultipleOf(multipleOf) {
25
+ const parsed = parseInt(multipleOf, 10);
26
+ if (!Number.isFinite(parsed) || parsed < 1) return 1;
27
+ if (parsed > 256) return 256;
28
+ return parsed;
29
+ }
30
+
31
+ function sanitizeMaxTileSize(maxTileSize) {
32
+ if (maxTileSize == null || maxTileSize === '') return null;
33
+ const parsed = parseInt(maxTileSize, 10);
34
+ if (!Number.isFinite(parsed) || parsed < 1) return null;
35
+ if (parsed > 4096) return 4096;
36
+ return parsed;
37
+ }
38
+
39
+ function sanitizePrecision(precision) {
40
+ return String(precision || '').toLowerCase() === 'fp16' ? 'fp16' : 'fp32';
41
+ }
42
+
43
+ function sanitizeUpscaleBefore(value) {
44
+ if (value === true || value === 'true' || value === 1 || value === '1') return true;
45
+ return false;
46
+ }
47
+
48
+ function sanitizeTileBlend(value) {
49
+ return value === 'gaussian' ? 'gaussian' : 'overlapCrop';
50
+ }
51
+
52
+ function normalizeLabel(label) {
53
+ const trimmed = (label || '').trim();
54
+ return trimmed || 'Custom ONNX';
55
+ }
56
+
57
+ function toModelRecord(raw = {}) {
58
+ const id = String(raw.id || '');
59
+ if (!id) return null;
60
+ const scale = sanitizeScale(raw.scale);
61
+ const range = sanitizeRange(raw.range);
62
+ const layout = sanitizeLayout(raw.layout);
63
+ const multipleOf = sanitizeMultipleOf(raw.multipleOf);
64
+ const maxTileSize = sanitizeMaxTileSize(raw.maxTileSize);
65
+ const precision = sanitizePrecision(raw.precision);
66
+ const upscaleBefore = sanitizeUpscaleBefore(raw.upscaleBefore);
67
+ const tileBlend = sanitizeTileBlend(raw.tileBlend);
68
+ const sizeBytes = Number.isFinite(raw.sizeBytes) ? Math.max(0, raw.sizeBytes) : 0;
69
+ const sizeMB = Number((sizeBytes / (1024 * 1024)).toFixed(1));
70
+ return {
71
+ id,
72
+ url: `${CUSTOM_MODEL_URL_PREFIX}${id}`,
73
+ label: normalizeLabel(raw.label),
74
+ scale,
75
+ range,
76
+ layout,
77
+ multipleOf,
78
+ maxTileSize,
79
+ precision,
80
+ upscaleBefore,
81
+ tileBlend,
82
+ sizeBytes,
83
+ sizeMB,
84
+ custom: true,
85
+ };
86
+ }
87
+
88
+ function readStoredRecords() {
89
+ try {
90
+ const json = localStorage.getItem(CUSTOM_MODELS_KEY);
91
+ if (!json) return [];
92
+ const parsed = JSON.parse(json);
93
+ if (!Array.isArray(parsed)) return [];
94
+ return parsed
95
+ .map(toModelRecord)
96
+ .filter(Boolean);
97
+ } catch {
98
+ return [];
99
+ }
100
+ }
101
+
102
+ function persistRecords(records) {
103
+ const payload = records.map(({ id, label, scale, range, layout, multipleOf, maxTileSize, precision, upscaleBefore, tileBlend, sizeBytes }) => ({
104
+ id,
105
+ label,
106
+ scale,
107
+ range,
108
+ layout,
109
+ multipleOf,
110
+ maxTileSize,
111
+ precision,
112
+ upscaleBefore,
113
+ tileBlend,
114
+ sizeBytes,
115
+ }));
116
+ localStorage.setItem(CUSTOM_MODELS_KEY, JSON.stringify(payload));
117
+ }
118
+
119
+ export function listCustomModels() {
120
+ return readStoredRecords();
121
+ }
122
+
123
+ export function getUploadCustomOptionHTML() {
124
+ return '<option value="__upload_custom__">Upload custom…</option>';
125
+ }
126
+
127
+ export function getCustomModelByUrl(url) {
128
+ if (!url || typeof url !== 'string') return null;
129
+ return readStoredRecords().find((model) => model.url === url) || null;
130
+ }
131
+
132
+ export async function saveCustomModel({ file, label, scale, range, layout, multipleOf, maxTileSize, precision, upscaleBefore, tileBlend }) {
133
+ if (!(file instanceof File)) {
134
+ throw new Error('Missing ONNX file for custom model upload.');
135
+ }
136
+ const idSeed = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
137
+ const id = `custom-${idSeed}`;
138
+ const url = `${CUSTOM_MODEL_URL_PREFIX}${id}`;
139
+ const bytes = await file.arrayBuffer();
140
+
141
+ const cache = await getModelCache();
142
+ if (!cache) {
143
+ throw new Error('Browser Cache API is unavailable, cannot store custom model.');
144
+ }
145
+ await cache.put(url, new Response(bytes, {
146
+ headers: {
147
+ 'content-type': 'application/octet-stream',
148
+ 'content-length': String(bytes.byteLength),
149
+ },
150
+ }));
151
+
152
+ const records = readStoredRecords();
153
+ const model = toModelRecord({
154
+ id, label, scale, range, layout, multipleOf, maxTileSize,
155
+ precision, upscaleBefore, tileBlend,
156
+ sizeBytes: bytes.byteLength,
157
+ });
158
+ records.unshift(model);
159
+ persistRecords(records);
160
+ return model;
161
+ }
162
+
163
+ /**
164
+ * Update an existing custom model's metadata (label / scale / range / layout /
165
+ * multipleOf). The cached ONNX file and id/url are preserved. Unspecified
166
+ * fields are left unchanged. Returns the updated model record, or `null` if
167
+ * no model matches `url`.
168
+ */
169
+ export function updateCustomModelByUrl(url, updates = {}) {
170
+ if (!url || typeof url !== 'string') return null;
171
+ const records = readStoredRecords();
172
+ const index = records.findIndex((entry) => entry.url === url);
173
+ if (index === -1) return null;
174
+
175
+ const current = records[index];
176
+ const merged = toModelRecord({
177
+ id: current.id,
178
+ label: 'label' in updates ? updates.label : current.label,
179
+ scale: 'scale' in updates ? updates.scale : current.scale,
180
+ range: 'range' in updates ? updates.range : current.range,
181
+ layout: 'layout' in updates ? updates.layout : current.layout,
182
+ multipleOf: 'multipleOf' in updates ? updates.multipleOf : current.multipleOf,
183
+ maxTileSize: 'maxTileSize' in updates ? updates.maxTileSize : current.maxTileSize,
184
+ precision: 'precision' in updates ? updates.precision : current.precision,
185
+ upscaleBefore: 'upscaleBefore' in updates ? updates.upscaleBefore : current.upscaleBefore,
186
+ tileBlend: 'tileBlend' in updates ? updates.tileBlend : current.tileBlend,
187
+ sizeBytes: current.sizeBytes,
188
+ });
189
+ records[index] = merged;
190
+ persistRecords(records);
191
+ return merged;
192
+ }
193
+
194
+ export async function deleteCustomModelByUrl(url) {
195
+ if (!url || typeof url !== 'string') return false;
196
+ const records = readStoredRecords();
197
+ const model = records.find((entry) => entry.url === url);
198
+ if (!model) return false;
199
+
200
+ const cache = await getModelCache();
201
+ await cache?.delete(url);
202
+
203
+ persistRecords(records.filter((entry) => entry.id !== model.id));
204
+ return true;
205
+ }
features/upscaler/custom-models/custom-model-upload-dialog.js ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <custom-model-upload-dialog> — modal for uploading a custom ONNX model.
3
+ *
4
+ * Self-contained UX: file selection, automatic ONNX inspection, manual
5
+ * overrides for scale / range / layout / multiple-of, validation, and save
6
+ * via the custom-model store.
7
+ *
8
+ * Usage:
9
+ * const dialog = document.querySelector('custom-model-upload-dialog');
10
+ * const model = await dialog.open({ defaultScale: 4 });
11
+ * if (model) { ... } else { user cancelled }
12
+ */
13
+
14
+ import { morph } from 'lib/morph';
15
+ import { saveCustomModel, updateCustomModelByUrl } from './custom-model-store.js';
16
+ import { inspectCustomModelFile } from './custom-model-inspector.js';
17
+
18
+ class CustomModelUploadDialog extends HTMLElement {
19
+ connectedCallback() {
20
+ this.classList.add('custom-model-upload-dialog');
21
+ this.#render();
22
+ }
23
+
24
+ /**
25
+ * Show the modal and resolve with the saved CustomModel — or null if the
26
+ * user cancelled or closed the dialog.
27
+ *
28
+ * When `editModel` is provided, the dialog opens in edit mode: the file
29
+ * picker is hidden, fields are pre-filled, auto-detection is skipped, and
30
+ * Save updates the existing record's metadata in place.
31
+ *
32
+ * @param {{ defaultScale?: number, editModel?: import('./custom-model-store.js').CustomModel }} [opts]
33
+ */
34
+ open({ defaultScale = 4, editModel = null } = {}) {
35
+ const dialog = this.querySelector('dialog');
36
+ const form = this.querySelector('.custom-model-form');
37
+ const titleEl = this.querySelector('.custom-model-title');
38
+ const fileLabel = this.querySelector('.custom-model-file-label');
39
+ const fileInput = this.querySelector('.custom-model-file');
40
+ const analyzeRow = this.querySelector('.custom-model-analyze-row');
41
+ const analyzeBtn = this.querySelector('.custom-model-analyze-btn');
42
+ const labelInput = this.querySelector('.custom-model-label');
43
+ const scaleInput = this.querySelector('.custom-model-scale');
44
+ const rangeInput = this.querySelector('.custom-model-range');
45
+ const layoutInput = this.querySelector('.custom-model-layout');
46
+ const multipleInput = this.querySelector('.custom-model-multiple');
47
+ const maxTileInput = this.querySelector('.custom-model-maxtile');
48
+ const precisionInput = this.querySelector('.custom-model-precision');
49
+ const upscaleBeforeInput = this.querySelector('.custom-model-upscalebefore');
50
+ const tileBlendInput = this.querySelector('.custom-model-tileblend');
51
+ const sizeLabel = this.querySelector('.custom-model-size');
52
+ const detectLabel = this.querySelector('.custom-model-detected');
53
+ const errorLabel = this.querySelector('.custom-model-error');
54
+ const saveBtn = this.querySelector('.custom-model-save-btn');
55
+ const cancelBtn = this.querySelector('.custom-model-cancel-btn');
56
+
57
+ const isEdit = !!editModel;
58
+ const ANALYZE_BTN_HTML = '<i class="fas fa-flask"></i> Analyze model';
59
+ titleEl.textContent = isEdit ? 'Edit custom ONNX model' : 'Upload custom ONNX model';
60
+ fileLabel.hidden = isEdit;
61
+ fileInput.required = !isEdit;
62
+ analyzeRow.hidden = isEdit;
63
+ detectLabel.hidden = isEdit;
64
+ analyzeBtn.innerHTML = ANALYZE_BTN_HTML;
65
+ analyzeBtn.disabled = true;
66
+ saveBtn.textContent = isEdit ? 'Save changes' : 'Save model';
67
+
68
+ fileInput.value = '';
69
+ if (isEdit) {
70
+ labelInput.value = editModel.label || '';
71
+ scaleInput.value = String(editModel.scale ?? defaultScale);
72
+ rangeInput.value = String(editModel.range === 255 ? 255 : 1);
73
+ layoutInput.value = editModel.layout === 'nhwc' ? 'nhwc' : 'nchw';
74
+ multipleInput.value = String(Math.max(1, editModel.multipleOf || 1));
75
+ maxTileInput.value = editModel.maxTileSize != null ? String(editModel.maxTileSize) : '';
76
+ precisionInput.value = editModel.precision === 'fp16' ? 'fp16' : 'fp32';
77
+ upscaleBeforeInput.value = editModel.upscaleBefore ? 'true' : 'false';
78
+ tileBlendInput.value = editModel.tileBlend === 'gaussian' ? 'gaussian' : 'overlapCrop';
79
+ sizeLabel.textContent = editModel.sizeMB != null
80
+ ? `Model size: ~${editModel.sizeMB} MB`
81
+ : 'Model size: -';
82
+ } else {
83
+ labelInput.value = '';
84
+ scaleInput.value = String(defaultScale);
85
+ rangeInput.value = '1';
86
+ layoutInput.value = 'nchw';
87
+ multipleInput.value = '1';
88
+ maxTileInput.value = '';
89
+ precisionInput.value = 'fp32';
90
+ upscaleBeforeInput.value = 'false';
91
+ tileBlendInput.value = 'overlapCrop';
92
+ sizeLabel.textContent = 'Model size: -';
93
+ }
94
+ errorLabel.textContent = '';
95
+ detectLabel.innerHTML = '';
96
+ saveBtn.disabled = false;
97
+
98
+ return new Promise((resolve) => {
99
+ let settled = false;
100
+ let inspectSeq = 0;
101
+ const cleanup = () => {
102
+ form.removeEventListener('submit', onSubmit);
103
+ fileInput.removeEventListener('change', onFileChange);
104
+ analyzeBtn.removeEventListener('click', onAnalyzeClick);
105
+ cancelBtn.removeEventListener('click', onCancel);
106
+ dialog.removeEventListener('cancel', onCancel);
107
+ dialog.removeEventListener('close', onClose);
108
+ };
109
+ const finish = (result) => {
110
+ if (settled) return;
111
+ settled = true;
112
+ cleanup();
113
+ resolve(result);
114
+ };
115
+ const onFileChange = () => {
116
+ // Bumping inspectSeq cancels any in-flight analysis whose result is
117
+ // about to come back: we don't want stale probe data overwriting
118
+ // form values for the file the user just switched to.
119
+ ++inspectSeq;
120
+ const file = fileInput.files?.[0];
121
+ errorLabel.textContent = '';
122
+ if (!file) {
123
+ sizeLabel.textContent = 'Model size: -';
124
+ detectLabel.innerHTML = '';
125
+ analyzeBtn.disabled = true;
126
+ analyzeBtn.innerHTML = ANALYZE_BTN_HTML;
127
+ return;
128
+ }
129
+ const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
130
+ sizeLabel.textContent = `Model size: ~${sizeMB} MB`;
131
+ if (!labelInput.value.trim()) {
132
+ labelInput.value = file.name.replace(/\.onnx$/i, '');
133
+ }
134
+ const sizeHint = parseFloat(sizeMB) > 50
135
+ ? ' (this model is large — probing may take 30s+)'
136
+ : ' (probing typically takes 5–20s)';
137
+ detectLabel.innerHTML = `Auto-detect: <em>ready — click <strong>Analyze model</strong> to probe the file${sizeHint}.</em>`;
138
+ analyzeBtn.disabled = false;
139
+ analyzeBtn.innerHTML = ANALYZE_BTN_HTML;
140
+ };
141
+ const onAnalyzeClick = () => {
142
+ const file = fileInput.files?.[0];
143
+ if (!file) return;
144
+ const seq = ++inspectSeq;
145
+ errorLabel.textContent = '';
146
+ analyzeBtn.disabled = true;
147
+ analyzeBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Analyzing…';
148
+ saveBtn.disabled = true;
149
+ detectLabel.textContent = 'Auto-detect: starting — running inference probes (WebGPU when available, otherwise CPU/WASM); this can take a while on large models.';
150
+ inspectCustomModelFile(file, {
151
+ onProgress: (message) => {
152
+ if (seq !== inspectSeq || settled) return;
153
+ detectLabel.textContent = `Auto-detect: ${message}`;
154
+ },
155
+ }).then((result) => {
156
+ if (seq !== inspectSeq || settled) return;
157
+ if (Number.isFinite(result?.scale)) {
158
+ scaleInput.value = String(result.scale);
159
+ }
160
+ rangeInput.value = String(result?.range === 255 ? 255 : 1);
161
+ layoutInput.value = result?.layout === 'nhwc' ? 'nhwc' : 'nchw';
162
+ if (Number.isFinite(result?.multipleOf)) {
163
+ multipleInput.value = String(Math.max(1, result.multipleOf));
164
+ }
165
+ maxTileInput.value = Number.isFinite(result?.maxTileSize)
166
+ ? String(result.maxTileSize)
167
+ : '';
168
+ precisionInput.value = result?.precision === 'fp16' ? 'fp16' : 'fp32';
169
+ const parts = [];
170
+ if (result?.layout) parts.push(`layout ${result.layout.toUpperCase()}`);
171
+ if (Number.isFinite(result?.multipleOf)) {
172
+ const suffix = result?.multipleOfSource === 'probe' ? ' (probed)' : '';
173
+ parts.push(`multiple ${result.multipleOf}${suffix}`);
174
+ }
175
+ if (Number.isFinite(result?.maxTileSize)) {
176
+ parts.push(`max tile ${result.maxTileSize}\u00d7${result.maxTileSize} (probed)`);
177
+ }
178
+ if (result?.inputType) parts.push(`input ${result.inputType}`);
179
+ if (Number.isFinite(result?.scale)) {
180
+ const suffix = result?.scaleSource === 'probe' ? ' (probed)' : result?.scaleSource === 'metadata' ? ' (metadata)' : ' (default)';
181
+ parts.push(`scale ${result.scale}x${suffix}`);
182
+ }
183
+ detectLabel.textContent = parts.length
184
+ ? `Auto-detected: ${parts.join(', ')}.`
185
+ : 'Auto-detect finished with defaults.';
186
+ if (Array.isArray(result?.notes) && result.notes.length) {
187
+ errorLabel.textContent = result.notes[0];
188
+ }
189
+ analyzeBtn.disabled = false;
190
+ analyzeBtn.innerHTML = '<i class="fas fa-rotate"></i> Re-analyze';
191
+ saveBtn.disabled = false;
192
+ }).catch((err) => {
193
+ if (seq !== inspectSeq || settled) return;
194
+ detectLabel.textContent = 'Auto-detect failed; you can adjust the fields manually and save anyway.';
195
+ errorLabel.textContent = err?.message || 'Could not inspect model metadata.';
196
+ analyzeBtn.disabled = false;
197
+ analyzeBtn.innerHTML = ANALYZE_BTN_HTML;
198
+ saveBtn.disabled = false;
199
+ });
200
+ };
201
+ const onCancel = (e) => {
202
+ e?.preventDefault?.();
203
+ if (dialog.open) dialog.close();
204
+ finish(null);
205
+ };
206
+ const onClose = () => finish(null);
207
+ const onSubmit = async (e) => {
208
+ e.preventDefault();
209
+ errorLabel.textContent = '';
210
+ if (isEdit) {
211
+ saveBtn.disabled = true;
212
+ try {
213
+ const model = updateCustomModelByUrl(editModel.url, {
214
+ label: labelInput.value,
215
+ scale: scaleInput.value,
216
+ range: rangeInput.value,
217
+ layout: layoutInput.value,
218
+ multipleOf: multipleInput.value,
219
+ maxTileSize: maxTileInput.value,
220
+ precision: precisionInput.value,
221
+ upscaleBefore: upscaleBeforeInput.value === 'true',
222
+ tileBlend: tileBlendInput.value,
223
+ });
224
+ if (!model) {
225
+ errorLabel.textContent = 'Model not found; it may have been removed.';
226
+ saveBtn.disabled = false;
227
+ return;
228
+ }
229
+ if (dialog.open) dialog.close();
230
+ finish(model);
231
+ } catch (err) {
232
+ errorLabel.textContent = err?.message || 'Failed to update model.';
233
+ saveBtn.disabled = false;
234
+ }
235
+ return;
236
+ }
237
+ const file = fileInput.files?.[0];
238
+ if (!file) {
239
+ errorLabel.textContent = 'Choose an ONNX model file first.';
240
+ return;
241
+ }
242
+ if (!/\.onnx$/i.test(file.name)) {
243
+ errorLabel.textContent = 'Only .onnx files are supported.';
244
+ return;
245
+ }
246
+ saveBtn.disabled = true;
247
+ try {
248
+ const model = await saveCustomModel({
249
+ file,
250
+ label: labelInput.value,
251
+ scale: scaleInput.value,
252
+ range: rangeInput.value,
253
+ layout: layoutInput.value,
254
+ multipleOf: multipleInput.value,
255
+ maxTileSize: maxTileInput.value,
256
+ precision: precisionInput.value,
257
+ upscaleBefore: upscaleBeforeInput.value === 'true',
258
+ tileBlend: tileBlendInput.value,
259
+ });
260
+ if (dialog.open) dialog.close();
261
+ finish(model);
262
+ } catch (err) {
263
+ errorLabel.textContent = err?.message || 'Failed to save model.';
264
+ saveBtn.disabled = false;
265
+ }
266
+ };
267
+
268
+ form.addEventListener('submit', onSubmit);
269
+ if (!isEdit) {
270
+ fileInput.addEventListener('change', onFileChange);
271
+ analyzeBtn.addEventListener('click', onAnalyzeClick);
272
+ }
273
+ cancelBtn.addEventListener('click', onCancel);
274
+ dialog.addEventListener('cancel', onCancel);
275
+ dialog.addEventListener('close', onClose);
276
+ dialog.showModal();
277
+ });
278
+ }
279
+
280
+ #render() {
281
+ morph(this, `
282
+ <style>
283
+ .custom-model-upload-dialog dialog {
284
+ width: min(34rem, calc(100vw - 2rem));
285
+ }
286
+ .custom-model-upload-dialog .custom-model-form {
287
+ display: grid;
288
+ gap: 0.6rem;
289
+ margin: 0;
290
+ }
291
+ .custom-model-upload-dialog .custom-model-form label {
292
+ display: grid;
293
+ gap: 0.25rem;
294
+ margin: 0;
295
+ font-size: 0.85rem;
296
+ }
297
+ .custom-model-upload-dialog .custom-model-row {
298
+ display: grid;
299
+ gap: 0.5rem;
300
+ grid-template-columns: minmax(0, 1fr) auto auto auto auto auto auto;
301
+ align-items: end;
302
+ }
303
+ .custom-model-upload-dialog .custom-model-scale { width: 8ch; }
304
+ .custom-model-upload-dialog .custom-model-range { width: 9ch; }
305
+ .custom-model-upload-dialog .custom-model-layout { width: 9ch; }
306
+ .custom-model-upload-dialog .custom-model-multiple { width: 9ch; }
307
+ .custom-model-upload-dialog .custom-model-maxtile { width: 9ch; }
308
+ .custom-model-upload-dialog .custom-model-precision { width: 9ch; }
309
+ @media (max-width: 900px) {
310
+ .custom-model-upload-dialog .custom-model-row {
311
+ grid-template-columns: minmax(0, 1fr) auto auto;
312
+ }
313
+ }
314
+ .custom-model-upload-dialog .custom-model-meta {
315
+ display: flex;
316
+ justify-content: space-between;
317
+ align-items: center;
318
+ gap: 0.5rem;
319
+ font-size: 0.8rem;
320
+ color: var(--pico-muted-color);
321
+ }
322
+ .custom-model-upload-dialog .custom-model-detected {
323
+ font-size: 0.8rem;
324
+ color: var(--pico-muted-color);
325
+ }
326
+ .custom-model-upload-dialog .custom-model-detected em {
327
+ font-style: normal;
328
+ opacity: 0.85;
329
+ }
330
+ .custom-model-upload-dialog .custom-model-file-header {
331
+ display: flex;
332
+ justify-content: space-between;
333
+ align-items: baseline;
334
+ gap: 0.5rem;
335
+ }
336
+ .custom-model-upload-dialog .custom-model-download-link {
337
+ font-size: 0.78rem;
338
+ font-weight: normal;
339
+ }
340
+ .custom-model-upload-dialog .custom-model-analyze-row {
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 0.6rem;
344
+ flex-wrap: wrap;
345
+ margin: -0.3rem 0 0.1rem;
346
+ }
347
+ .custom-model-upload-dialog .custom-model-analyze-btn {
348
+ margin: 0;
349
+ padding: 0.35rem 0.75rem;
350
+ font-size: 0.85rem;
351
+ width: auto;
352
+ flex: 0 0 auto;
353
+ }
354
+ .custom-model-upload-dialog .custom-model-analyze-hint {
355
+ font-size: 0.75rem;
356
+ color: var(--pico-muted-color);
357
+ flex: 1 1 18ch;
358
+ min-width: 0;
359
+ }
360
+ .custom-model-upload-dialog .custom-model-error {
361
+ color: var(--pico-del-color, #c62828);
362
+ min-height: 1.1rem;
363
+ font-size: 0.8rem;
364
+ }
365
+ .custom-model-upload-dialog .custom-model-actions {
366
+ display: flex;
367
+ justify-content: flex-end;
368
+ gap: 0.5rem;
369
+ margin-top: 0.4rem;
370
+ }
371
+ </style>
372
+ <dialog>
373
+ <form class="custom-model-form" method="dialog">
374
+ <h3 class="custom-model-title" style="margin:0">Upload custom ONNX model</h3>
375
+ <label class="custom-model-file-label">
376
+ <span class="custom-model-file-header">
377
+ Model file
378
+ <a class="custom-model-download-link" href="https://huggingface.co/notaneimu/onnx-image-models/tree/main" target="_blank" rel="noopener noreferrer">
379
+ <i class="fas fa-arrow-up-right-from-square"></i> Download More Models
380
+ </a>
381
+ </span>
382
+ <input class="custom-model-file" type="file" accept=".onnx,application/octet-stream" required>
383
+ </label>
384
+ <div class="custom-model-analyze-row">
385
+ <button type="button" class="secondary custom-model-analyze-btn" disabled>
386
+ <i class="fas fa-flask"></i> Analyze model
387
+ </button>
388
+ <span class="custom-model-analyze-hint">
389
+ Probe model to auto-fill values.
390
+ </span>
391
+ </div>
392
+ <div class="custom-model-row">
393
+ <label>
394
+ Label
395
+ <input class="custom-model-label" type="text" maxlength="80" placeholder="My custom model">
396
+ </label>
397
+ <label>
398
+ Scale
399
+ <input class="custom-model-scale" type="number" min="1" max="16" step="1" value="4" required>
400
+ </label>
401
+ <label>
402
+ Range
403
+ <select class="custom-model-range">
404
+ <option value="1">1</option>
405
+ <option value="255">255</option>
406
+ </select>
407
+ </label>
408
+ <label>
409
+ Layout
410
+ <select class="custom-model-layout">
411
+ <option value="nchw">NCHW</option>
412
+ <option value="nhwc">NHWC</option>
413
+ </select>
414
+ </label>
415
+ <label>
416
+ Multiple-of
417
+ <input class="custom-model-multiple" type="number" min="1" max="256" step="1" value="1">
418
+ </label>
419
+ <label title="Hard upper bound on input tile size accepted by the model. Leave blank if the model is fully dynamic.">
420
+ Max tile
421
+ <input class="custom-model-maxtile" type="number" min="1" max="4096" step="1" placeholder="auto">
422
+ </label>
423
+ <label title="Tensor element precision. fp16 models require WebGPU; CPU/WASM has very limited fp16 op coverage and will usually fail.">
424
+ Precision
425
+ <select class="custom-model-precision">
426
+ <option value="fp32">fp32</option>
427
+ <option value="fp16">fp16</option>
428
+ </select>
429
+ </label>
430
+ <label title="HR-space refiner — the engine bicubic-upsamples LR to HR before feeding tiles to the model. Multiple-of and Max tile stay in LR-pixel units.">
431
+ Upscale before
432
+ <select class="custom-model-upscalebefore">
433
+ <option value="false">No</option>
434
+ <option value="true">Yes</option>
435
+ </select>
436
+ </label>
437
+ <label title="Tile blend: 'overlap' is a half-overlap hard crop (fast). 'gaussian' is float32 weighted accumulation that hides seams on diffusion-style models — forces CPU readback path.">
438
+ Tile blend
439
+ <select class="custom-model-tileblend">
440
+ <option value="overlapCrop">overlap</option>
441
+ <option value="gaussian">gaussian</option>
442
+ </select>
443
+ </label>
444
+ </div>
445
+ <div class="custom-model-detected">Auto-detect: waiting for model file…</div>
446
+ <div class="custom-model-meta">
447
+ <span class="custom-model-size">Model size: -</span>
448
+ </div>
449
+ <div class="custom-model-error"></div>
450
+ <div class="custom-model-actions">
451
+ <button type="button" class="secondary custom-model-cancel-btn">Cancel</button>
452
+ <button type="submit" class="custom-model-save-btn">Save model</button>
453
+ </div>
454
+ </form>
455
+ </dialog>
456
+ `);
457
+ }
458
+ }
459
+
460
+ customElements.define('custom-model-upload-dialog', CustomModelUploadDialog);
features/upscaler/engine/face-detector-engine.js ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fetchWithProgress } from 'lib/fetch-progress';
2
+ import { loadSession } from 'lib/backend';
3
+ import { dispatchBackendEvent } from 'lib/backend-events';
4
+
5
+ const DETECTORS = {
6
+ 'face-yunet': {
7
+ label: 'Face (YuNet)',
8
+ url: 'https://huggingface.co/opencv/face_detection_yunet/resolve/main/face_detection_yunet_2023mar.onnx',
9
+ inputWidth: 640,
10
+ inputHeight: 640,
11
+ scoreThreshold: 0.7,
12
+ iouThreshold: 0.3,
13
+ topK: 20,
14
+ },
15
+ };
16
+
17
+ export { DETECTORS };
18
+
19
+ function clamp(v, min, max) {
20
+ return v < min ? min : v > max ? max : v;
21
+ }
22
+
23
+
24
+ function tensorToRows(tensor) {
25
+ const dims = tensor.dims || [];
26
+ const data = tensor.data;
27
+ if (!data || !dims.length) return null;
28
+
29
+ // [N, C]
30
+ if (dims.length === 2) {
31
+ return {
32
+ rows: dims[0],
33
+ cols: dims[1],
34
+ at: (row, col) => data[row * dims[1] + col],
35
+ };
36
+ }
37
+
38
+ // [1, N, C]
39
+ if (dims.length === 3 && dims[0] === 1) {
40
+ return {
41
+ rows: dims[1],
42
+ cols: dims[2],
43
+ at: (row, col) => data[row * dims[2] + col],
44
+ };
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ function nms(boxes, iouThreshold, topK) {
51
+ const sorted = [...boxes].sort((a, b) => b.score - a.score);
52
+ const kept = [];
53
+
54
+ function iou(a, b) {
55
+ const x1 = Math.max(a.x, b.x);
56
+ const y1 = Math.max(a.y, b.y);
57
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
58
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
59
+ const iw = Math.max(0, x2 - x1);
60
+ const ih = Math.max(0, y2 - y1);
61
+ const inter = iw * ih;
62
+ const union = a.w * a.h + b.w * b.h - inter;
63
+ return union <= 0 ? 0 : inter / union;
64
+ }
65
+
66
+ for (const cand of sorted) {
67
+ if (kept.length >= topK) break;
68
+ let suppressed = false;
69
+ for (const k of kept) {
70
+ if (iou(cand, k) > iouThreshold) {
71
+ suppressed = true;
72
+ break;
73
+ }
74
+ }
75
+ if (!suppressed) kept.push(cand);
76
+ }
77
+
78
+ return kept;
79
+ }
80
+
81
+ function parseDecodedDetections(outputTensor, scoreThreshold, srcW, srcH, inW, inH) {
82
+ const rows = tensorToRows(outputTensor);
83
+ if (!rows || rows.cols < 15) return [];
84
+ const sx = srcW / inW;
85
+ const sy = srcH / inH;
86
+ const faces = [];
87
+
88
+ for (let i = 0; i < rows.rows; i++) {
89
+ const score = rows.at(i, 14);
90
+ if (score < scoreThreshold) continue;
91
+
92
+ const x = rows.at(i, 0) * sx;
93
+ const y = rows.at(i, 1) * sy;
94
+ const w = rows.at(i, 2) * sx;
95
+ const h = rows.at(i, 3) * sy;
96
+ if (w <= 1 || h <= 1) continue;
97
+
98
+ faces.push({ x, y, w, h, score });
99
+ }
100
+
101
+ return faces;
102
+ }
103
+
104
+ function readFeatureVector(tensor, anchorIndex, featureCount) {
105
+ const dims = tensor.dims || [];
106
+ const data = tensor.data;
107
+ if (!data) return null;
108
+
109
+ // [1, A, F]
110
+ if (dims.length === 3 && dims[0] === 1 && dims[2] >= featureCount) {
111
+ const off = anchorIndex * dims[2];
112
+ const out = new Array(featureCount);
113
+ for (let i = 0; i < featureCount; i++) out[i] = data[off + i];
114
+ return out;
115
+ }
116
+
117
+ // [1, F, H, W]
118
+ if (dims.length === 4 && dims[0] === 1 && dims[1] >= featureCount) {
119
+ const anchors = dims[2] * dims[3];
120
+ if (anchorIndex >= anchors) return null;
121
+ const out = new Array(featureCount);
122
+ for (let i = 0; i < featureCount; i++) out[i] = data[i * anchors + anchorIndex];
123
+ return out;
124
+ }
125
+
126
+ // [1, H, W, F]
127
+ if (dims.length === 4 && dims[0] === 1 && dims[3] >= featureCount) {
128
+ const off = anchorIndex * dims[3];
129
+ const out = new Array(featureCount);
130
+ for (let i = 0; i < featureCount; i++) out[i] = data[off + i];
131
+ return out;
132
+ }
133
+
134
+ const off = anchorIndex * featureCount;
135
+ if (off + featureCount - 1 >= data.length) return null;
136
+ const out = new Array(featureCount);
137
+ for (let i = 0; i < featureCount; i++) out[i] = data[off + i];
138
+ return out;
139
+ }
140
+
141
+ function decodeRawYunet(results, scoreThreshold, srcW, srcH, padW, padH) {
142
+ const sx = srcW / padW;
143
+ const sy = srcH / padH;
144
+ const outByName = new Map(Object.entries(results));
145
+ const faces = [];
146
+ const strides = [8, 16, 32];
147
+
148
+ for (const stride of strides) {
149
+ const cls = outByName.get(`cls_${stride}`);
150
+ const obj = outByName.get(`obj_${stride}`);
151
+ const bbox = outByName.get(`bbox_${stride}`);
152
+ if (!cls || !obj || !bbox) continue;
153
+
154
+ const fmW = Math.floor(padW / stride);
155
+ const fmH = Math.floor(padH / stride);
156
+ const anchorCount = fmW * fmH;
157
+
158
+ for (let i = 0; i < anchorCount; i++) {
159
+ const clsVec = readFeatureVector(cls, i, 1);
160
+ const objVec = readFeatureVector(obj, i, 1);
161
+ if (!clsVec || !objVec) continue;
162
+ const clsScore = clamp(clsVec[0], 0, 1);
163
+ const objScore = clamp(objVec[0], 0, 1);
164
+ const score = Math.sqrt(clsScore * objScore);
165
+ if (score < scoreThreshold) continue;
166
+
167
+ const bb = readFeatureVector(bbox, i, 4);
168
+ if (!bb) continue;
169
+ const [dx, dy, dw, dh] = bb;
170
+
171
+ const c = i % fmW;
172
+ const r = Math.floor(i / fmW);
173
+ const cx = (c + dx) * stride;
174
+ const cy = (r + dy) * stride;
175
+ const w = Math.exp(dw) * stride;
176
+ const h = Math.exp(dh) * stride;
177
+ const x1 = cx - w / 2;
178
+ const y1 = cy - h / 2;
179
+ if (w <= 1 || h <= 1) continue;
180
+
181
+ faces.push({
182
+ x: clamp(x1 * sx, 0, srcW - 1),
183
+ y: clamp(y1 * sy, 0, srcH - 1),
184
+ w: Math.min(w * sx, srcW),
185
+ h: Math.min(h * sy, srcH),
186
+ score,
187
+ });
188
+ }
189
+ }
190
+
191
+ return faces;
192
+ }
193
+
194
+ export class FaceDetectorEngine {
195
+ #session = null;
196
+ #modelBuffer = null;
197
+ #currentDetectorKey = null;
198
+ #intent = null;
199
+ // Set by loadSession; kept current by #backendListener so a runtime EP
200
+ // fallback doesn't leave a stale label that the loadModel early-return
201
+ // re-announces later.
202
+ #realizedBackend = null;
203
+ #backendListener = null;
204
+ get isLoaded() { return this.#session !== null; }
205
+ get realizedBackend() { return this.#realizedBackend; }
206
+ get intent() { return this.#intent; }
207
+ get currentDetector() { return this.#currentDetectorKey; }
208
+
209
+ #trackRealizedBackend() {
210
+ if (this.#backendListener) return;
211
+ this.#backendListener = (e) => {
212
+ const d = e?.detail;
213
+ if (d && d.kind === 'success' && typeof d.backend === 'string') {
214
+ this.#realizedBackend = d.backend;
215
+ }
216
+ };
217
+ document.addEventListener('aitools:backend-event', this.#backendListener);
218
+ }
219
+ #untrackRealizedBackend() {
220
+ if (!this.#backendListener) return;
221
+ document.removeEventListener('aitools:backend-event', this.#backendListener);
222
+ this.#backendListener = null;
223
+ }
224
+
225
+ async loadModel(detectorKey = 'face-yunet', intent = 'cpu', onProgress) {
226
+ if (onProgress != null && typeof onProgress !== 'function') {
227
+ console.warn('[FaceDetectorEngine] Ignoring non-function onProgress callback.', {
228
+ type: typeof onProgress,
229
+ value: onProgress,
230
+ detectorKey,
231
+ intent,
232
+ });
233
+ }
234
+ intent = normalizeIntent(intent);
235
+ const report = typeof onProgress === 'function' ? onProgress : null;
236
+ if (this.#session && this.#currentDetectorKey === detectorKey && this.#intent === intent) {
237
+ if (this.#realizedBackend) {
238
+ dispatchBackendEvent({ kind: 'success', backend: this.#realizedBackend });
239
+ }
240
+ return;
241
+ }
242
+
243
+ const cfg = DETECTORS[detectorKey];
244
+ if (!cfg) throw new Error(`Unknown detector: ${detectorKey}`);
245
+
246
+ if (this.#session) {
247
+ try { this.#session.release(); } catch {}
248
+ this.#session = null;
249
+ }
250
+ if (this.#currentDetectorKey !== detectorKey) {
251
+ this.#modelBuffer = null;
252
+ }
253
+
254
+ if (!this.#modelBuffer) {
255
+ this.#modelBuffer = await fetchWithProgress(cfg.url, report);
256
+ }
257
+
258
+ report?.(1, 'Loading detector into runtime...');
259
+ console.info(`[FaceDetectorEngine] Loading detector "${detectorKey}" with intent "${intent}"`);
260
+ const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent);
261
+ this.#session = session;
262
+ this.#intent = intent;
263
+ this.#realizedBackend = realizedBackend;
264
+ this.#currentDetectorKey = detectorKey;
265
+ this.#trackRealizedBackend();
266
+ console.info(`[FaceDetectorEngine] Detector ready on ${realizedBackend}`);
267
+ report?.(1, 'Detector loaded.');
268
+ }
269
+
270
+ async detectFaces(image, {
271
+ detectorKey = 'face-yunet',
272
+ scoreThreshold,
273
+ iouThreshold,
274
+ topK,
275
+ signal,
276
+ } = {}) {
277
+ if (!this.#session || this.#currentDetectorKey !== detectorKey) {
278
+ throw new Error('Detector not loaded — call loadModel() first');
279
+ }
280
+ if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
281
+
282
+ const cfg = DETECTORS[detectorKey];
283
+ const minScore = Number.isFinite(scoreThreshold) ? scoreThreshold : cfg.scoreThreshold;
284
+ const maxIou = Number.isFinite(iouThreshold) ? iouThreshold : cfg.iouThreshold;
285
+ const maxKeep = Number.isFinite(topK) ? topK : cfg.topK;
286
+ const srcW = image.width;
287
+ const srcH = image.height;
288
+ const inW = cfg.inputWidth;
289
+ const inH = cfg.inputHeight;
290
+
291
+ const prepCanvas = document.createElement('canvas');
292
+ prepCanvas.width = inW;
293
+ prepCanvas.height = inH;
294
+ const prepCtx = prepCanvas.getContext('2d');
295
+ prepCtx.drawImage(image, 0, 0, inW, inH);
296
+ const imageData = prepCtx.getImageData(0, 0, inW, inH);
297
+ const px = imageData.data;
298
+
299
+ const planeSize = inW * inH;
300
+ const input = new Float32Array(3 * planeSize);
301
+ for (let i = 0; i < planeSize; i++) {
302
+ const si = i * 4;
303
+ // Match OpenCV DNN blobFromImage defaults used by FaceDetectorYN:
304
+ // BGR order, no scale, zero mean.
305
+ input[i] = px[si + 2];
306
+ input[planeSize + i] = px[si + 1];
307
+ input[2 * planeSize + i] = px[si];
308
+ }
309
+
310
+ prepCanvas.width = 0;
311
+ prepCanvas.height = 0;
312
+
313
+ if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
314
+
315
+ const ort = globalThis.ort;
316
+ const tensor = new ort.Tensor('float32', input, [1, 3, inH, inW]);
317
+ const inputName = this.#session.inputNames[0];
318
+ const results = await this.#session.run({ [inputName]: tensor });
319
+ tensor.dispose();
320
+
321
+ let candidates = [];
322
+ const outputNames = this.#session.outputNames || [];
323
+ if (outputNames.length === 1) {
324
+ const raw = results[outputNames[0]];
325
+ candidates = parseDecodedDetections(raw, minScore, srcW, srcH, inW, inH);
326
+ }
327
+
328
+ if (!candidates.length) {
329
+ candidates = decodeRawYunet(results, minScore, srcW, srcH, inW, inH);
330
+ }
331
+
332
+ for (const name of outputNames) {
333
+ try { results[name]?.dispose?.(); } catch {}
334
+ }
335
+
336
+ const filtered = nms(
337
+ candidates,
338
+ maxIou,
339
+ maxKeep,
340
+ );
341
+
342
+ return filtered.map(face => ({
343
+ ...face,
344
+ x: clamp(face.x, 0, srcW - 1),
345
+ y: clamp(face.y, 0, srcH - 1),
346
+ w: clamp(face.w, 1, srcW),
347
+ h: clamp(face.h, 1, srcH),
348
+ }));
349
+ }
350
+
351
+ release() {
352
+ this.#untrackRealizedBackend();
353
+ if (this.#session) {
354
+ try { this.#session.release(); } catch {}
355
+ this.#session = null;
356
+ }
357
+ this.#modelBuffer = null;
358
+ this.#currentDetectorKey = null;
359
+ this.#intent = null;
360
+ this.#realizedBackend = null;
361
+ }
362
+ }
363
+
364
+ function normalizeIntent(value) {
365
+ if (value === 'webgpu' || value === 'gpu') return 'gpu';
366
+ if (value === 'wasm' || value === 'cpu') return 'cpu';
367
+ return 'cpu';
368
+ }
features/upscaler/engine/gpu-frame-extractor.js ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * GpuFrameExtractor — uploads a video/image frame to a GPU texture and
3
+ * extracts CHW float32 tiles via a compute shader, avoiding CPU-side
4
+ * getImageData() + extractTileCHW() entirely.
5
+ *
6
+ * Usage:
7
+ * const extractor = new GpuFrameExtractor(device);
8
+ * extractor.uploadFrame(videoElement, width, height);
9
+ * const gpuBuffer = extractor.extractTile(tx, ty, tw, th, modelValueRange);
10
+ * // gpuBuffer contains CHW float32 data for the tile
11
+ * extractor.destroy();
12
+ */
13
+
14
+ const SHADER = /* wgsl */ `
15
+ struct Params {
16
+ tileX: u32,
17
+ tileY: u32,
18
+ tileW: u32,
19
+ tileH: u32,
20
+ scale: f32,
21
+ }
22
+
23
+ @group(0) @binding(0) var src: texture_2d<f32>;
24
+ @group(0) @binding(1) var<storage, read_write> out: array<f32>;
25
+ @group(0) @binding(2) var<uniform> params: Params;
26
+
27
+ @compute @workgroup_size(16, 16)
28
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
29
+ let col = gid.x;
30
+ let row = gid.y;
31
+ if (col >= params.tileW || row >= params.tileH) { return; }
32
+
33
+ let pixel = textureLoad(src, vec2u(params.tileX + col, params.tileY + row), 0);
34
+ let plane = params.tileW * params.tileH;
35
+ let idx = row * params.tileW + col;
36
+
37
+ // Texture values are [0,1]; scale converts to model's expected range
38
+ // (1.0 keeps [0,1], 255.0 produces [0,255]).
39
+ out[idx] = pixel.r * params.scale;
40
+ out[plane + idx] = pixel.g * params.scale;
41
+ out[2u * plane + idx] = pixel.b * params.scale;
42
+ }
43
+ `;
44
+
45
+ const PARAMS_SIZE = 5 * 4;
46
+ const PARAMS_BUFFER_SIZE = Math.ceil(PARAMS_SIZE / 16) * 16;
47
+
48
+ export class GpuFrameExtractor {
49
+ #device;
50
+ #pipeline;
51
+ #bindGroupLayout;
52
+ #paramsBuffer;
53
+ #frameTexture = null;
54
+ #tileBuffer = null;
55
+ #tileBufferSize = 0;
56
+ #lost = false;
57
+
58
+ constructor(device) {
59
+ this.#device = device;
60
+ this.#initPipeline();
61
+ this.#device.lost.then((info) => {
62
+ this.#lost = true;
63
+ console.warn('[GpuFrameExtractor] GPU device lost:', info.message);
64
+ });
65
+ }
66
+
67
+ get lost() { return this.#lost; }
68
+
69
+ /**
70
+ * Upload a frame source to the internal GPU texture.
71
+ * Accepts HTMLVideoElement, HTMLImageElement, HTMLCanvasElement,
72
+ * ImageBitmap, VideoFrame, OffscreenCanvas — anything valid for
73
+ * copyExternalImageToTexture().
74
+ */
75
+ uploadFrame(source, width, height) {
76
+ if (this.#frameTexture &&
77
+ (this.#frameTexture.width !== width || this.#frameTexture.height !== height)) {
78
+ this.#frameTexture.destroy();
79
+ this.#frameTexture = null;
80
+ }
81
+
82
+ if (!this.#frameTexture) {
83
+ this.#frameTexture = this.#device.createTexture({
84
+ size: [width, height],
85
+ format: 'rgba8unorm',
86
+ usage:
87
+ GPUTextureUsage.TEXTURE_BINDING |
88
+ GPUTextureUsage.COPY_DST |
89
+ GPUTextureUsage.RENDER_ATTACHMENT,
90
+ });
91
+ }
92
+
93
+ this.#device.queue.copyExternalImageToTexture(
94
+ { source },
95
+ { texture: this.#frameTexture },
96
+ [width, height],
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Run the compute shader to extract a tile as CHW float32 into a
102
+ * reusable GPU storage buffer.
103
+ *
104
+ * @param {number} tx - source tile X
105
+ * @param {number} ty - source tile Y
106
+ * @param {number} tw - tile width
107
+ * @param {number} th - tile height
108
+ * @param {number} modelValueRange - upper bound of the model's expected value range (1 or 255);
109
+ * texture values are [0,1] so this acts as a multiplier.
110
+ * @returns {GPUBuffer} containing 3×tw×th float32 values in CHW order
111
+ */
112
+ extractTile(tx, ty, tw, th, modelValueRange) {
113
+ const byteSize = 3 * tw * th * 4;
114
+
115
+ if (this.#tileBufferSize < byteSize) {
116
+ this.#tileBuffer?.destroy();
117
+ this.#tileBuffer = this.#device.createBuffer({
118
+ size: byteSize,
119
+ usage:
120
+ GPUBufferUsage.STORAGE |
121
+ GPUBufferUsage.COPY_SRC |
122
+ GPUBufferUsage.COPY_DST,
123
+ });
124
+ this.#tileBufferSize = byteSize;
125
+ }
126
+
127
+ const paramsData = new ArrayBuffer(PARAMS_BUFFER_SIZE);
128
+ const u32 = new Uint32Array(paramsData);
129
+ const f32 = new Float32Array(paramsData);
130
+ u32[0] = tx;
131
+ u32[1] = ty;
132
+ u32[2] = tw;
133
+ u32[3] = th;
134
+ f32[4] = modelValueRange;
135
+ this.#device.queue.writeBuffer(this.#paramsBuffer, 0, paramsData);
136
+
137
+ const bindGroup = this.#device.createBindGroup({
138
+ layout: this.#bindGroupLayout,
139
+ entries: [
140
+ { binding: 0, resource: this.#frameTexture.createView() },
141
+ { binding: 1, resource: { buffer: this.#tileBuffer, size: byteSize } },
142
+ { binding: 2, resource: { buffer: this.#paramsBuffer } },
143
+ ],
144
+ });
145
+
146
+ const encoder = this.#device.createCommandEncoder();
147
+ const pass = encoder.beginComputePass();
148
+ pass.setPipeline(this.#pipeline);
149
+ pass.setBindGroup(0, bindGroup);
150
+ pass.dispatchWorkgroups(Math.ceil(tw / 16), Math.ceil(th / 16));
151
+ pass.end();
152
+ this.#device.queue.submit([encoder.finish()]);
153
+
154
+ return this.#tileBuffer;
155
+ }
156
+
157
+ destroy() {
158
+ this.#frameTexture?.destroy();
159
+ this.#frameTexture = null;
160
+ this.#tileBuffer?.destroy();
161
+ this.#tileBuffer = null;
162
+ this.#paramsBuffer?.destroy();
163
+ this.#paramsBuffer = null;
164
+ }
165
+
166
+ #initPipeline() {
167
+ const module = this.#device.createShaderModule({ code: SHADER });
168
+
169
+ this.#bindGroupLayout = this.#device.createBindGroupLayout({
170
+ entries: [
171
+ {
172
+ binding: 0,
173
+ visibility: GPUShaderStage.COMPUTE,
174
+ texture: { sampleType: 'float' },
175
+ },
176
+ {
177
+ binding: 1,
178
+ visibility: GPUShaderStage.COMPUTE,
179
+ buffer: { type: 'storage' },
180
+ },
181
+ {
182
+ binding: 2,
183
+ visibility: GPUShaderStage.COMPUTE,
184
+ buffer: { type: 'uniform' },
185
+ },
186
+ ],
187
+ });
188
+
189
+ this.#pipeline = this.#device.createComputePipeline({
190
+ layout: this.#device.createPipelineLayout({
191
+ bindGroupLayouts: [this.#bindGroupLayout],
192
+ }),
193
+ compute: { module, entryPoint: 'main' },
194
+ });
195
+
196
+ this.#paramsBuffer = this.#device.createBuffer({
197
+ size: PARAMS_BUFFER_SIZE,
198
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
199
+ });
200
+ }
201
+ }
features/upscaler/engine/gpu-tile-renderer.js ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * GpuTileRenderer — renders CHW float32 ORT output buffers directly to a
3
+ * GPU texture via a WGSL fragment shader, avoiding the GPU→CPU readback.
4
+ *
5
+ * Usage:
6
+ * const renderer = new GpuTileRenderer(device);
7
+ * renderer.configure(canvas, outW, outH);
8
+ * // per tile:
9
+ * renderer.renderTile(gpuBuffer, tileW, tileH, destX, destY, overlap, outputScale);
10
+ * renderer.presentToCanvas();
11
+ * // cleanup:
12
+ * renderer.destroy();
13
+ */
14
+
15
+ import { overlapCrop } from './tiling.js';
16
+
17
+ const SHADER = /* wgsl */ `
18
+ struct Params {
19
+ tileW: u32,
20
+ tileH: u32,
21
+ destX: u32,
22
+ destY: u32,
23
+ outputScale: f32,
24
+ }
25
+
26
+ @group(0) @binding(0) var<storage, read> chw: array<f32>;
27
+ @group(0) @binding(1) var<uniform> params: Params;
28
+
29
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
30
+ var pos = array<vec2f, 6>(
31
+ vec2f(-1, -1), vec2f(1, -1), vec2f(-1, 1),
32
+ vec2f(-1, 1), vec2f(1, -1), vec2f(1, 1),
33
+ );
34
+ return vec4f(pos[vi], 0, 1);
35
+ }
36
+
37
+ @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
38
+ let x = u32(pos.x) - params.destX;
39
+ let y = u32(pos.y) - params.destY;
40
+ let plane = params.tileW * params.tileH;
41
+ let s = params.outputScale;
42
+ return vec4f(
43
+ clamp(chw[y * params.tileW + x] * s, 0.0, 1.0),
44
+ clamp(chw[plane + y * params.tileW + x] * s, 0.0, 1.0),
45
+ clamp(chw[2u * plane + y * params.tileW + x] * s, 0.0, 1.0),
46
+ 1.0,
47
+ );
48
+ }
49
+ `;
50
+
51
+ const PARAMS_SIZE = 5 * 4; // 5 u32/f32 fields × 4 bytes, padded to 16-byte alignment
52
+ const PARAMS_BUFFER_SIZE = Math.ceil(PARAMS_SIZE / 16) * 16;
53
+
54
+ /**
55
+ * Thrown by GpuTileRenderer.configure() when the requested canvas/texture
56
+ * dimensions exceed the device's maxTextureDimension2D. Callers can catch
57
+ * this and fall back to a CPU readback path instead of silently producing
58
+ * a black canvas (which is what WebGPU does on validation failure).
59
+ */
60
+ export class GpuOutputTooLargeError extends Error {
61
+ constructor(width, height, maxDim) {
62
+ super(`Output ${width}×${height} exceeds GPU max texture dimension (${maxDim}).`);
63
+ this.name = 'GpuOutputTooLargeError';
64
+ this.width = width;
65
+ this.height = height;
66
+ this.maxDim = maxDim;
67
+ }
68
+ }
69
+
70
+ export class GpuTileRenderer {
71
+ #device;
72
+ #pipeline = null;
73
+ #bindGroupLayout = null;
74
+ #paramsBuffer = null;
75
+ #outputTexture = null;
76
+ #canvasCtx = null;
77
+ #canvasFormat;
78
+ #width = 0;
79
+ #height = 0;
80
+ #lost = false;
81
+
82
+ constructor(device) {
83
+ this.#device = device;
84
+ this.#canvasFormat = navigator.gpu.getPreferredCanvasFormat();
85
+ this.#initPipeline();
86
+ this.#device.lost.then((info) => {
87
+ this.#lost = true;
88
+ console.warn('[GpuTileRenderer] GPU device lost:', info.message);
89
+ });
90
+ }
91
+
92
+ get lost() { return this.#lost; }
93
+
94
+ /**
95
+ * @throws {GpuOutputTooLargeError} when width/height exceeds
96
+ * device.limits.maxTextureDimension2D. Both the WebGPU canvas surface
97
+ * and the persistent output texture are subject to that cap; exceeding
98
+ * it causes WebGPU to silently produce a black canvas, so we surface
99
+ * the failure up-front instead.
100
+ */
101
+ configure(canvas, width, height) {
102
+ const maxDim = this.#device.limits?.maxTextureDimension2D ?? 8192;
103
+ if (width > maxDim || height > maxDim) {
104
+ throw new GpuOutputTooLargeError(width, height, maxDim);
105
+ }
106
+
107
+ this.#canvasCtx = canvas.getContext('webgpu');
108
+ this.#canvasCtx.configure({
109
+ device: this.#device,
110
+ format: this.#canvasFormat,
111
+ alphaMode: 'opaque',
112
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST,
113
+ });
114
+
115
+ this.#width = width;
116
+ this.#height = height;
117
+
118
+ this.#outputTexture?.destroy();
119
+ this.#outputTexture = this.#device.createTexture({
120
+ size: [width, height],
121
+ format: this.#canvasFormat,
122
+ usage:
123
+ GPUTextureUsage.RENDER_ATTACHMENT |
124
+ GPUTextureUsage.COPY_SRC |
125
+ GPUTextureUsage.TEXTURE_BINDING,
126
+ });
127
+
128
+ // Clear the persistent texture to black
129
+ const encoder = this.#device.createCommandEncoder();
130
+ const pass = encoder.beginRenderPass({
131
+ colorAttachments: [{
132
+ view: this.#outputTexture.createView(),
133
+ loadOp: 'clear',
134
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
135
+ storeOp: 'store',
136
+ }],
137
+ });
138
+ pass.end();
139
+ this.#device.queue.submit([encoder.finish()]);
140
+ }
141
+
142
+ /**
143
+ * Render one tile from an ORT GPU buffer onto the persistent output texture.
144
+ *
145
+ * @param {GPUBuffer} gpuBuffer - ORT output tensor's GPUBuffer (CHW float32)
146
+ * @param {number} tileW - output tile width in pixels
147
+ * @param {number} tileH - output tile height in pixels
148
+ * @param {number} destX - destination X on the output texture
149
+ * @param {number} destY - destination Y on the output texture
150
+ * @param {number} overlap - overlap in output-space pixels
151
+ * @param {number} outputScale - multiply CHW values by this (1.0 for 0-1 models, 1/255 for 0-255 models)
152
+ */
153
+ renderTile(gpuBuffer, tileW, tileH, destX, destY, overlap, outputScale) {
154
+ const scissor = overlapCrop(destX, destY, tileW, tileH, this.#width, this.#height, overlap);
155
+ if (scissor.w <= 0 || scissor.h <= 0) return;
156
+
157
+ // Write tile params to uniform buffer
158
+ const paramsData = new ArrayBuffer(PARAMS_BUFFER_SIZE);
159
+ const u32 = new Uint32Array(paramsData);
160
+ const f32 = new Float32Array(paramsData);
161
+ u32[0] = tileW;
162
+ u32[1] = tileH;
163
+ u32[2] = destX;
164
+ u32[3] = destY;
165
+ f32[4] = outputScale;
166
+ this.#device.queue.writeBuffer(this.#paramsBuffer, 0, paramsData);
167
+
168
+ const bindGroup = this.#device.createBindGroup({
169
+ layout: this.#bindGroupLayout,
170
+ entries: [
171
+ { binding: 0, resource: { buffer: gpuBuffer } },
172
+ { binding: 1, resource: { buffer: this.#paramsBuffer } },
173
+ ],
174
+ });
175
+
176
+ const encoder = this.#device.createCommandEncoder();
177
+ const pass = encoder.beginRenderPass({
178
+ colorAttachments: [{
179
+ view: this.#outputTexture.createView(),
180
+ loadOp: 'load',
181
+ storeOp: 'store',
182
+ }],
183
+ });
184
+
185
+ pass.setPipeline(this.#pipeline);
186
+ pass.setBindGroup(0, bindGroup);
187
+ pass.setViewport(destX, destY, tileW, tileH, 0, 1);
188
+ pass.setScissorRect(scissor.x, scissor.y, scissor.w, scissor.h);
189
+ pass.draw(6);
190
+ pass.end();
191
+
192
+ this.#device.queue.submit([encoder.finish()]);
193
+ }
194
+
195
+ /** Copy the persistent output texture to the canvas for display / toBlob. */
196
+ presentToCanvas() {
197
+ const canvasTex = this.#canvasCtx.getCurrentTexture();
198
+ const encoder = this.#device.createCommandEncoder();
199
+ encoder.copyTextureToTexture(
200
+ { texture: this.#outputTexture },
201
+ { texture: canvasTex },
202
+ [this.#width, this.#height],
203
+ );
204
+ this.#device.queue.submit([encoder.finish()]);
205
+ }
206
+
207
+ destroy() {
208
+ this.#outputTexture?.destroy();
209
+ this.#outputTexture = null;
210
+ this.#paramsBuffer?.destroy();
211
+ this.#paramsBuffer = null;
212
+ }
213
+
214
+ #initPipeline() {
215
+ const module = this.#device.createShaderModule({ code: SHADER });
216
+
217
+ this.#bindGroupLayout = this.#device.createBindGroupLayout({
218
+ entries: [
219
+ {
220
+ binding: 0,
221
+ visibility: GPUShaderStage.FRAGMENT,
222
+ buffer: { type: 'read-only-storage' },
223
+ },
224
+ {
225
+ binding: 1,
226
+ visibility: GPUShaderStage.FRAGMENT,
227
+ buffer: { type: 'uniform' },
228
+ },
229
+ ],
230
+ });
231
+
232
+ const pipelineLayout = this.#device.createPipelineLayout({
233
+ bindGroupLayouts: [this.#bindGroupLayout],
234
+ });
235
+
236
+ this.#pipeline = this.#device.createRenderPipeline({
237
+ layout: pipelineLayout,
238
+ vertex: { module, entryPoint: 'vs' },
239
+ fragment: {
240
+ module,
241
+ entryPoint: 'fs',
242
+ targets: [{ format: this.#canvasFormat }],
243
+ },
244
+ });
245
+
246
+ this.#paramsBuffer = this.#device.createBuffer({
247
+ size: PARAMS_BUFFER_SIZE,
248
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
249
+ });
250
+ }
251
+ }
features/upscaler/engine/tiling.js ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tiling utilities — dividing images into overlapping tiles and
3
+ * reassembling them with seam-free stitching.
4
+ */
5
+
6
+ /**
7
+ * Compute the interior region a tile should contribute, trimming overlap
8
+ * margins at seams. Returns a canvas-space rect { x, y, w, h }.
9
+ *
10
+ * Each side touching another tile is trimmed by half the overlap;
11
+ * edges against the canvas boundary keep their full extent.
12
+ */
13
+ export function overlapCrop(destX, destY, tileW, tileH, canvasW, canvasH, overlap) {
14
+ const cropL = destX > 0 ? (overlap / 2) | 0 : 0;
15
+ const cropT = destY > 0 ? (overlap / 2) | 0 : 0;
16
+ const cropR = (destX + tileW) < canvasW ? (overlap / 2) | 0 : 0;
17
+ const cropB = (destY + tileH) < canvasH ? (overlap / 2) | 0 : 0;
18
+ return {
19
+ x: destX + cropL,
20
+ y: destY + cropT,
21
+ w: tileW - cropL - cropR,
22
+ h: tileH - cropT - cropB,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Build the grid of overlapping source tiles that cover the image.
28
+ * Returns an array of { x, y, w, h } in source-pixel coordinates.
29
+ */
30
+ export function buildTileGrid(srcW, srcH, tileSize, overlap) {
31
+ const noTiling = tileSize <= 0;
32
+ const size = noTiling ? Math.max(srcW, srcH) : tileSize;
33
+ const step = noTiling ? size : size - overlap;
34
+ const tiles = [];
35
+ for (let ty = 0; ty < srcH; ty += step) {
36
+ for (let tx = 0; tx < srcW; tx += step) {
37
+ tiles.push({
38
+ x: tx, y: ty,
39
+ w: Math.min(size, srcW - tx),
40
+ h: Math.min(size, srcH - ty),
41
+ });
42
+ }
43
+ }
44
+ return tiles;
45
+ }
46
+
47
+ /** Write an ImageData to the canvas, trimming overlap margins at tile seams. */
48
+ export function pasteTileCropped(ctx, imgData, dx, dy, canvasW, canvasH, overlap) {
49
+ const crop = overlapCrop(dx, dy, imgData.width, imgData.height, canvasW, canvasH, overlap);
50
+ if (crop.w <= 0 || crop.h <= 0) return;
51
+ ctx.putImageData(imgData, dx, dy, crop.x - dx, crop.y - dy, crop.w, crop.h);
52
+ }
53
+
54
+ // ─── Gaussian tile blending ────────────────────────────────────────────
55
+ // For diffusion-style refiners (e.g. TinySR) the tile-edge artifacts are
56
+ // strong enough that the half-overlap hard crop above shows visible seams.
57
+ // `makeGaussianWeights2D` produces a per-pixel weight kernel matching
58
+ // TinySR's pipeline.js: variance=0.01 scaled by tile^2 so the shape is
59
+ // scale-invariant. Engines that opt in maintain a float32 accumulator and
60
+ // a contribution buffer, then divide once at the end.
61
+
62
+ /**
63
+ * 2D Gaussian weight kernel for tile blending. Returns a Float32Array of
64
+ * length tileH*tileW with pixel-space weights (single channel — apply the
65
+ * same weight to all 3 colour channels at accumulation time).
66
+ *
67
+ * Matches the formula in tinysr/tools/web/pipeline.js:makeGaussianWeights
68
+ * so the visual behaviour ports over directly.
69
+ */
70
+ export function makeGaussianWeights2D(tileH, tileW) {
71
+ const variance = 0.01;
72
+ const midX = (tileW - 1) / 2;
73
+ const midY = tileH / 2; // intentional asymmetry — matches the upstream
74
+ const denomX = tileW * tileW * 2 * variance;
75
+ const denomY = tileH * tileH * 2 * variance;
76
+ const norm = 1 / Math.sqrt(2 * Math.PI * variance);
77
+ const xs = new Float32Array(tileW);
78
+ const ys = new Float32Array(tileH);
79
+ for (let i = 0; i < tileW; i++) xs[i] = norm * Math.exp(-((i - midX) ** 2) / denomX);
80
+ for (let i = 0; i < tileH; i++) ys[i] = norm * Math.exp(-((i - midY) ** 2) / denomY);
81
+ const out = new Float32Array(tileH * tileW);
82
+ for (let y = 0; y < tileH; y++) {
83
+ const yw = ys[y];
84
+ const rowOff = y * tileW;
85
+ for (let x = 0; x < tileW; x++) out[rowOff + x] = yw * xs[x];
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Accumulate one model-output tile into the canvas-sized float32 buffers
92
+ * `accumRGB` (3*outW*outH, RGB-planar) and `accumW` (outW*outH), weighted
93
+ * by `weights`. `srcRGB` is in [0, modelValueRange]; `layout` is 'chw' for
94
+ * RGB-planar input or 'hwc' for RGB-interleaved. Crops to the top-left
95
+ * (tileW × tileH) region if the model output was padded.
96
+ */
97
+ export function accumulateGaussianTile(
98
+ accumRGB, accumW, outW, outH,
99
+ srcRGB, srcStrideW, srcStrideH,
100
+ tileW, tileH, destX, destY,
101
+ weights, valueScale, layout,
102
+ ) {
103
+ const outPlane = outW * outH;
104
+ const isCHW = layout === 'chw';
105
+ const chanStride = isCHW ? srcStrideW * srcStrideH : 1;
106
+ const colStride = isCHW ? 1 : 3;
107
+ const rowStride = isCHW ? srcStrideW : srcStrideW * 3;
108
+ for (let y = 0; y < tileH; y++) {
109
+ const dy = destY + y;
110
+ if (dy < 0 || dy >= outH) continue;
111
+ const wRow = y * tileW;
112
+ const sRow = y * rowStride;
113
+ const dRow = dy * outW;
114
+ for (let x = 0; x < tileW; x++) {
115
+ const dx = destX + x;
116
+ if (dx < 0 || dx >= outW) continue;
117
+ const w = weights[wRow + x];
118
+ const srcIdx = sRow + x * colStride;
119
+ const dstIdx = dRow + dx;
120
+ accumRGB[dstIdx] += srcRGB[srcIdx] * valueScale * w;
121
+ accumRGB[outPlane + dstIdx] += srcRGB[srcIdx + chanStride] * valueScale * w;
122
+ accumRGB[2 * outPlane + dstIdx] += srcRGB[srcIdx + 2 * chanStride] * valueScale * w;
123
+ accumW[dstIdx] += w;
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Divide accumulated RGB by weights inside a rectangular region of the
130
+ * output canvas, clamp to [0,255], and write via putImageData. Called
131
+ * after each tile accumulates so the user sees progressive preview; the
132
+ * last tile to touch any given pixel ends up writing the final value
133
+ * (which is the same value a single full-canvas finalize would produce).
134
+ *
135
+ * Clips the region to the canvas bounds, so callers can pass tile-sized
136
+ * rects without worrying about edge tiles.
137
+ */
138
+ export function finalizeGaussianRegion(ctx, outX, outY, regionW, regionH, outW, outH, accumRGB, accumW) {
139
+ const x0 = Math.max(0, outX | 0);
140
+ const y0 = Math.max(0, outY | 0);
141
+ const x1 = Math.min(outW, (outX + regionW) | 0);
142
+ const y1 = Math.min(outH, (outY + regionH) | 0);
143
+ const w = x1 - x0;
144
+ const h = y1 - y0;
145
+ if (w <= 0 || h <= 0) return;
146
+ const imgData = ctx.createImageData(w, h);
147
+ const px = imgData.data;
148
+ const plane = outW * outH;
149
+ for (let y = 0; y < h; y++) {
150
+ const srcRow = (y0 + y) * outW;
151
+ const dstRow = y * w;
152
+ for (let x = 0; x < w; x++) {
153
+ // accumW is zero only if no tile covered this pixel — shouldn't
154
+ // happen for pixels reached by this call, but guard against NaN.
155
+ const srcIdx = srcRow + x0 + x;
156
+ const wAcc = accumW[srcIdx] || 1;
157
+ const r = accumRGB[srcIdx] / wAcc;
158
+ const g = accumRGB[plane + srcIdx] / wAcc;
159
+ const b = accumRGB[2 * plane + srcIdx] / wAcc;
160
+ const o = (dstRow + x) * 4;
161
+ px[o] = r < 0 ? 0 : r > 255 ? 255 : (r + 0.5) | 0;
162
+ px[o + 1] = g < 0 ? 0 : g > 255 ? 255 : (g + 0.5) | 0;
163
+ px[o + 2] = b < 0 ? 0 : b > 255 ? 255 : (b + 0.5) | 0;
164
+ px[o + 3] = 255;
165
+ }
166
+ }
167
+ ctx.putImageData(imgData, x0, y0);
168
+ }
features/upscaler/engine/upscaler-engine.js ADDED
@@ -0,0 +1,924 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UpscalerEngine — tiled ONNX super-resolution inference.
3
+ * Downloads a model, creates a session, runs tiled inference on images.
4
+ * Uses Canvas 2D for pixel I/O in the WASM/WebGL path; GPU paths avoid readback.
5
+ */
6
+
7
+ import { fetchWithProgress } from 'lib/fetch-progress';
8
+ import { GpuTileRenderer, GpuOutputTooLargeError } from './gpu-tile-renderer.js';
9
+ import { GpuFrameExtractor } from './gpu-frame-extractor.js';
10
+ import {
11
+ buildTileGrid,
12
+ pasteTileCropped,
13
+ overlapCrop,
14
+ makeGaussianWeights2D,
15
+ accumulateGaussianTile,
16
+ finalizeGaussianRegion,
17
+ } from './tiling.js';
18
+ import { readMetaEntry, isFp16InputType } from 'lib/onnx-meta';
19
+ import { dispatchBackendEvent } from 'lib/backend-events';
20
+ import { loadSession } from 'lib/backend';
21
+
22
+ const DEFAULT_SCALE = 4;
23
+ const DEFAULT_OVERLAP = 16;
24
+
25
+ function clampByte(v) {
26
+ return v < 0 ? 0 : v > 255 ? 255 : (v + 0.5) | 0;
27
+ }
28
+
29
+ // Normalize the various aliases the caller might pass for backend intent.
30
+ // New code should pass 'gpu' or 'cpu' directly; the legacy ORT-Web strings
31
+ // 'webgpu' and 'wasm' are still accepted so a half-migrated UI keeps working.
32
+ function normalizeIntent(value) {
33
+ if (value === 'webgpu' || value === 'gpu') return 'gpu';
34
+ if (value === 'wasm' || value === 'cpu') return 'cpu';
35
+ return 'cpu';
36
+ }
37
+
38
+ function yieldToEventLoop() {
39
+ return new Promise(resolve => {
40
+ const ch = new MessageChannel();
41
+ ch.port1.onmessage = resolve;
42
+ ch.port2.postMessage(undefined);
43
+ });
44
+ }
45
+
46
+ function clampCoord(v, max) {
47
+ if (v < 0) return 0;
48
+ if (v > max) return max;
49
+ return v;
50
+ }
51
+
52
+ /**
53
+ * Extract a tile from ImageData as Float32 in CHW layout
54
+ * (channels-first: [R plane, G plane, B plane]), with edge replication padding.
55
+ *
56
+ * @param {number} valueScale - multiply each pixel byte by this (e.g. 1/255 for [0,1] output)
57
+ */
58
+ function extractTileNCHW(imageData, tx, ty, tw, th, padW, padH, valueScale) {
59
+ const { data, width } = imageData;
60
+ const out = new Float32Array(3 * padH * padW);
61
+ const planeSize = padH * padW;
62
+ const maxX = tx + tw - 1;
63
+ const maxY = ty + th - 1;
64
+ for (let row = 0; row < padH; row++) {
65
+ for (let col = 0; col < padW; col++) {
66
+ const srcX = clampCoord(tx + col, maxX);
67
+ const srcY = clampCoord(ty + row, maxY);
68
+ const srcIdx = (srcY * width + srcX) * 4;
69
+ const dstIdx = row * padW + col;
70
+ out[dstIdx] = data[srcIdx] * valueScale;
71
+ out[planeSize + dstIdx] = data[srcIdx + 1] * valueScale;
72
+ out[2 * planeSize + dstIdx] = data[srcIdx + 2] * valueScale;
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Extract a tile from ImageData as Float32 in HWC layout
80
+ * (channels-last: [R,G,B, R,G,B, ...]), with edge replication padding.
81
+ */
82
+ function extractTileNHWC(imageData, tx, ty, tw, th, padW, padH, valueScale) {
83
+ const { data, width } = imageData;
84
+ const out = new Float32Array(padH * padW * 3);
85
+ const maxX = tx + tw - 1;
86
+ const maxY = ty + th - 1;
87
+ for (let row = 0; row < padH; row++) {
88
+ for (let col = 0; col < padW; col++) {
89
+ const srcX = clampCoord(tx + col, maxX);
90
+ const srcY = clampCoord(ty + row, maxY);
91
+ const srcIdx = (srcY * width + srcX) * 4;
92
+ const dstIdx = (row * padW + col) * 3;
93
+ out[dstIdx] = data[srcIdx] * valueScale;
94
+ out[dstIdx + 1] = data[srcIdx + 1] * valueScale;
95
+ out[dstIdx + 2] = data[srcIdx + 2] * valueScale;
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Convert CHW float32 data (channels-first: [R plane, G plane, B plane])
103
+ * back into an RGBA ImageData. Inverse of extractTileCHW.
104
+ *
105
+ * @param {number} valueScale - multiply each CHW value by this to get [0,255] bytes
106
+ */
107
+ function chwToImageData(chwData, width, height, valueScale) {
108
+ const imgData = new ImageData(width, height);
109
+ const px = imgData.data;
110
+ const planeSize = width * height;
111
+ for (let row = 0; row < height; row++) {
112
+ for (let col = 0; col < width; col++) {
113
+ const srcIdx = row * width + col;
114
+ const dstIdx = srcIdx * 4;
115
+ px[dstIdx] = clampByte(chwData[srcIdx] * valueScale);
116
+ px[dstIdx + 1] = clampByte(chwData[planeSize + srcIdx] * valueScale);
117
+ px[dstIdx + 2] = clampByte(chwData[2 * planeSize + srcIdx] * valueScale);
118
+ px[dstIdx + 3] = 255;
119
+ }
120
+ }
121
+ return imgData;
122
+ }
123
+
124
+ /**
125
+ * Convert HWC float32 data (channels-last: [R,G,B, R,G,B, …] per pixel)
126
+ * into an RGBA ImageData. Used when the WebGPU EP returns NHWC-ordered output.
127
+ */
128
+ function hwcToImageData(hwcData, width, height, valueScale) {
129
+ const imgData = new ImageData(width, height);
130
+ const px = imgData.data;
131
+ for (let row = 0; row < height; row++) {
132
+ for (let col = 0; col < width; col++) {
133
+ const srcIdx = (row * width + col) * 3;
134
+ const dstIdx = (row * width + col) * 4;
135
+ px[dstIdx] = clampByte(hwcData[srcIdx] * valueScale);
136
+ px[dstIdx + 1] = clampByte(hwcData[srcIdx + 1] * valueScale);
137
+ px[dstIdx + 2] = clampByte(hwcData[srcIdx + 2] * valueScale);
138
+ px[dstIdx + 3] = 255;
139
+ }
140
+ }
141
+ return imgData;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // fp16 packing — ORT-Web fp16 tensors take/return a Uint16Array of IEEE-754
146
+ // binary16 bit patterns. We use the platform's Float16Array when available
147
+ // (Chromium 122+, Safari 18.2+) and fall back to a manual packer otherwise.
148
+ // fp16 only kicks in when the model is declared fp16; everything else stays
149
+ // fp32 with no extra work.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const HAS_NATIVE_FLOAT16 = typeof globalThis.Float16Array === 'function';
153
+
154
+ function packFloat32ToFloat16Bits(f32) {
155
+ if (HAS_NATIVE_FLOAT16) {
156
+ const f16 = new globalThis.Float16Array(f32);
157
+ return new Uint16Array(f16.buffer, f16.byteOffset, f16.length);
158
+ }
159
+ const out = new Uint16Array(f32.length);
160
+ // f32arr is a Float32Array; reinterpret as Uint32 to read bit fields.
161
+ const u32 = new Uint32Array(f32.buffer, f32.byteOffset, f32.length);
162
+ for (let i = 0; i < f32.length; i++) {
163
+ const x = u32[i];
164
+ const sign = (x >>> 16) & 0x8000;
165
+ const expRaw = (x >>> 23) & 0xff;
166
+ const mantissa = x & 0x7fffff;
167
+ let exp = expRaw - 127 + 15;
168
+ if (expRaw === 0xff) {
169
+ out[i] = sign | 0x7c00 | (mantissa ? 0x200 : 0);
170
+ } else if (exp >= 31) {
171
+ out[i] = sign | 0x7c00;
172
+ } else if (exp <= 0) {
173
+ if (exp < -10) {
174
+ out[i] = sign;
175
+ } else {
176
+ const m = mantissa | 0x800000;
177
+ out[i] = sign | (m >>> (14 - exp));
178
+ }
179
+ } else {
180
+ out[i] = sign | (exp << 10) | (mantissa >>> 13);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function unpackFloat16BitsToFloat32(u16) {
187
+ if (HAS_NATIVE_FLOAT16) {
188
+ const f16 = new globalThis.Float16Array(u16.buffer, u16.byteOffset, u16.length);
189
+ return new Float32Array(f16);
190
+ }
191
+ const out = new Float32Array(u16.length);
192
+ const u32 = new Uint32Array(out.buffer);
193
+ for (let i = 0; i < u16.length; i++) {
194
+ const h = u16[i];
195
+ const sign = (h & 0x8000) << 16;
196
+ const exp = (h >> 10) & 0x1f;
197
+ const mantissa = h & 0x3ff;
198
+ if (exp === 0) {
199
+ if (mantissa === 0) {
200
+ u32[i] = sign;
201
+ } else {
202
+ let e = -14;
203
+ let m = mantissa;
204
+ while (!(m & 0x400)) { m <<= 1; e--; }
205
+ m &= 0x3ff;
206
+ u32[i] = sign | ((e + 127) << 23) | (m << 13);
207
+ }
208
+ } else if (exp === 0x1f) {
209
+ u32[i] = sign | 0x7f800000 | (mantissa << 13);
210
+ } else {
211
+ u32[i] = sign | ((exp - 15 + 127) << 23) | (mantissa << 13);
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ // TODO(ort-web): remove this whole block when ORT-Web fixes
218
+ // program-manager.ts normalizeDispatchGroupSize, which today does a lossy
219
+ // rewrite of dispatch shape (X, 1, 1) → (sqrt(X), sqrt(X), 1) and breaks
220
+ // the (X=col, Y=row) contract Conv2DMatMul and MatMul shaders expect.
221
+ //
222
+ // What goes wrong: ORT reshuffles whenever X > maxComputeWorkgroupsPerDimension
223
+ // (65535). Conv2DMatMul/MatMul then treat the synthesised Y as a row-tile
224
+ // index, the row-bounds guard rejects ~99% of writes, and the output
225
+ // buffer is left mostly uninitialised — visible as scrambled output for
226
+ // any model whose post-PixelShuffle activation has H*W > ~2.1M pixels.
227
+ //
228
+ // Workaround: monkey-patch device.createShaderModule to recover the
229
+ // effective column from both workgroup ids when dim_a_outer is small
230
+ // enough that the original dispatch Y was 1.
231
+
232
+ const WGPU_DISPATCH_FIX_INSTALLED = Symbol.for('updraft.wgslDispatchOverflowFix');
233
+
234
+ const CONV2D_MM_FIND =
235
+ 'let globalRowStart = i32(workgroupId.y) * 32;\n' +
236
+ ' let globalColStart = i32(workgroupId.x) * 32;';
237
+ const CONV2D_MM_REPLACE =
238
+ 'let p_isSmallA = uniforms.dim_a_outer <= 32;\n' +
239
+ ' let p_totalCols = (u32(uniforms.dim_b_outer) + 31u) / 32u;\n' +
240
+ ' let p_dispatchX = u32(ceil(sqrt(f32(p_totalCols))));\n' +
241
+ ' let p_effectiveCol = workgroupId.x + workgroupId.y * p_dispatchX;\n' +
242
+ ' let globalRowStart = select(i32(workgroupId.y) * 32, 0, p_isSmallA);\n' +
243
+ ' let globalColStart = select(i32(workgroupId.x) * 32, i32(p_effectiveCol) * 32, p_isSmallA);';
244
+
245
+ const MATMUL_FIND =
246
+ 'let globalRow =i32(globalId.y) * rowPerThread;\n' +
247
+ ' let globalCol = i32(globalId.x);';
248
+ const MATMUL_REPLACE =
249
+ 'let p_isSmallA = uniforms.dim_a_outer <= 8;\n' +
250
+ ' let p_totalVecCols = (u32(uniforms.dim_b_outer) + 31u) / 32u;\n' +
251
+ ' let p_dispatchX = u32(ceil(sqrt(f32(p_totalVecCols))));\n' +
252
+ ' let p_effectiveWgX = workgroupId.x + workgroupId.y * p_dispatchX;\n' +
253
+ ' let globalRow = select(i32(globalId.y) * rowPerThread, i32(localId.y) * rowPerThread, p_isSmallA);\n' +
254
+ ' let globalCol = select(i32(globalId.x), i32(p_effectiveWgX) * 8 + i32(localId.x), p_isSmallA);';
255
+
256
+ function patchWGSLForDispatchOverflow(code, label) {
257
+ if (label === 'Conv2DMatMul' && code.includes(CONV2D_MM_FIND)) {
258
+ return code.split(CONV2D_MM_FIND).join(CONV2D_MM_REPLACE);
259
+ }
260
+ if (label === 'MatMul' && code.includes(MATMUL_FIND)) {
261
+ return code.split(MATMUL_FIND).join(MATMUL_REPLACE);
262
+ }
263
+ return code;
264
+ }
265
+
266
+ function installWebGPUDispatchFix(device) {
267
+ if (!device || device[WGPU_DISPATCH_FIX_INSTALLED]) return false;
268
+ const origCreate = device.createShaderModule.bind(device);
269
+ device.createShaderModule = (descriptor) => {
270
+ const patched = patchWGSLForDispatchOverflow(descriptor.code, descriptor.label || '');
271
+ if (patched === descriptor.code) return origCreate(descriptor);
272
+ return origCreate({ ...descriptor, code: patched });
273
+ };
274
+ device[WGPU_DISPATCH_FIX_INSTALLED] = true;
275
+ return true;
276
+ }
277
+
278
+ export class UpscalerEngine {
279
+ #session = null;
280
+ #modelBuffer = null;
281
+ #modelUrl;
282
+ #scale;
283
+ #overlap;
284
+ #modelValueRange;
285
+ #modelLayout;
286
+ #modelInputMultiple;
287
+ #modelPrecision;
288
+ #upscaleBefore;
289
+ #tileBlend;
290
+ #profiling = false;
291
+ // What the user actually got: a label like 'web-webgpu', 'web-wasm',
292
+ // 'native-coreml/MLProgram', 'native-cpu'. Set by loadSession on every
293
+ // successful load AND kept current by #backendListener for the rest of
294
+ // the session — without that, runtime EP fallbacks (e.g. native worker
295
+ // drops from CoreML to CPU mid-tile) would leave it stale and the
296
+ // loadModel early-return path would mis-announce on the next run.
297
+ #realizedBackend = null;
298
+ #backendListener = null;
299
+ // What the caller asked for ('gpu' | 'cpu'). The loadModel short-circuit
300
+ // keys off this so the engine doesn't pointlessly reload when the user
301
+ // re-runs with the same intent.
302
+ #intent = null;
303
+ #device = null;
304
+ #gpuRenderer = null;
305
+ #gpuExtractor = null;
306
+
307
+ constructor({
308
+ modelUrl,
309
+ scale = DEFAULT_SCALE,
310
+ overlap = DEFAULT_OVERLAP,
311
+ modelValueRange = 1,
312
+ modelLayout = 'nchw',
313
+ modelInputMultiple = 1,
314
+ modelPrecision = 'fp32',
315
+ // upscaleBefore=true: the model operates in HR pixel space (e.g. a
316
+ // refiner that takes a pre-upsampled LR image and returns an HR image
317
+ // at the SAME resolution). Tile coordinates and modelInputMultiple
318
+ // stay in LR-pixel units (consistent with regular SR models advertised
319
+ // with scale > 1); the engine bicubic-upsamples LR->HR before tile
320
+ // extraction and multiplies extraction coords by `scale` so the
321
+ // backend sees HR tensors. All GPU fast paths remain viable — they're
322
+ // coordinate-agnostic.
323
+ upscaleBefore = false,
324
+ // tileBlend='gaussian' replaces the default half-overlap hard crop
325
+ // with float32 Gaussian-weighted accumulation. Use for diffusion-
326
+ // style models with visible tile seams. Costs ~16 bytes/HR-pixel
327
+ // working memory and forces the CPU readback path (the GPU output
328
+ // renderer writes directly to the bgra8unorm canvas surface, which
329
+ // can't host the float32 accumulator).
330
+ tileBlend = 'overlapCrop',
331
+ profile = false,
332
+ }) {
333
+ this.#modelUrl = modelUrl;
334
+ this.#scale = scale;
335
+ this.#overlap = overlap;
336
+ this.#modelValueRange = modelValueRange;
337
+ this.#modelLayout = modelLayout === 'nhwc' ? 'nhwc' : 'nchw';
338
+ this.#modelInputMultiple = Number.isFinite(modelInputMultiple) ? Math.max(1, Math.floor(modelInputMultiple)) : 1;
339
+ this.#modelPrecision = modelPrecision === 'fp16' ? 'fp16' : 'fp32';
340
+ this.#upscaleBefore = !!upscaleBefore;
341
+ this.#tileBlend = tileBlend === 'gaussian' ? 'gaussian' : 'overlapCrop';
342
+ this.#profiling = profile;
343
+ }
344
+
345
+ get scale() { return this.#scale; }
346
+ // The realized backend label (e.g. 'web-webgpu', 'native-coreml/MLProgram').
347
+ // For UI display via friendlyBackend; not used for identity checks.
348
+ get realizedBackend() { return this.#realizedBackend; }
349
+ // The user's load intent ('gpu' | 'cpu'). EnginePool and loadModel both
350
+ // key off this for "do we already have the right session?" decisions.
351
+ get intent() { return this.#intent; }
352
+ get isLoaded() { return this.#session !== null; }
353
+ get profiling() { return this.#profiling; }
354
+ set profiling(v) { this.#profiling = !!v; }
355
+ get modelPrecision() { return this.#modelPrecision; }
356
+
357
+ async loadModel(intent = 'cpu', onProgress) {
358
+ if (onProgress != null && typeof onProgress !== 'function') {
359
+ console.warn('[UpscalerEngine] Ignoring non-function onProgress callback.', {
360
+ type: typeof onProgress,
361
+ value: onProgress,
362
+ intent,
363
+ });
364
+ }
365
+ intent = normalizeIntent(intent);
366
+ const report = typeof onProgress === 'function' ? onProgress : null;
367
+ if (this.#session && this.#intent === intent) {
368
+ // Reusing the existing session; re-announce so per-run backend
369
+ // trackers (status bar's "Done via X" line) record a success this run.
370
+ if (this.#realizedBackend) {
371
+ dispatchBackendEvent({ kind: 'success', backend: this.#realizedBackend });
372
+ }
373
+ return;
374
+ }
375
+ this.#releaseSession();
376
+
377
+ if (!this.#modelBuffer) {
378
+ this.#modelBuffer = await fetchWithProgress(this.#modelUrl, report);
379
+ }
380
+
381
+ report?.(1, 'Loading model into runtime\u2026');
382
+
383
+ // The GPU fast paths (zero-copy input extract via GpuFrameExtractor and
384
+ // zero-readback output render via GpuTileRenderer) both assume fp32
385
+ // storage buffers in their WGSL shaders. fp16 models go through the
386
+ // standard CPU readback path: ONNX still runs on the GPU, but the tile
387
+ // tensors round-trip through the CPU as Uint16 bit patterns. We make the
388
+ // initial decision from the configured precision, then re-validate after
389
+ // the session is created (the model's declared input dtype is the source
390
+ // of truth — see comment below).
391
+ let canUseGpuFastPath =
392
+ this.#modelPrecision !== 'fp16' &&
393
+ this.#modelLayout === 'nchw' &&
394
+ this.#modelInputMultiple === 1;
395
+ const sessionLoadOpts = { profile: this.#profiling };
396
+ if (intent === 'gpu' && canUseGpuFastPath) {
397
+ sessionLoadOpts.preferredOutputLocation = 'gpu-buffer';
398
+ }
399
+
400
+ // loadSession picks between native (desktop bridge) and web (ort-web)
401
+ // based on whether __nativeOrt is exposed; it dispatches its own
402
+ // attempt/fallback/success backend-events so we don't need to here.
403
+ const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent, sessionLoadOpts);
404
+ this.#session = session;
405
+ this.#intent = intent;
406
+ this.#realizedBackend = realizedBackend;
407
+ this.#trackRealizedBackend();
408
+
409
+ // Self-correct modelPrecision from the model's declared input dtype.
410
+ // Stale custom-model records (e.g. uploaded before fp16 support existed,
411
+ // or before the inspector started reading the right metadata field)
412
+ // can carry the wrong precision; the model graph itself doesn't lie.
413
+ // Without this, the engine would build fp32 tensors for an fp16 model
414
+ // and ORT would throw "Unexpected input data type" at the first run.
415
+ const sessionInputName = this.#session.inputNames?.[0];
416
+ const sessionInMeta = readMetaEntry(this.#session.inputMetadata, sessionInputName, 0);
417
+ const declaredInputType = sessionInMeta?.type;
418
+ const detectedPrecision = isFp16InputType(declaredInputType)
419
+ ? 'fp16'
420
+ : 'fp32';
421
+ if (detectedPrecision !== this.#modelPrecision) {
422
+ console.warn(
423
+ `[UpscalerEngine] Configured precision (${this.#modelPrecision}) disagrees with the model's declared input dtype (${declaredInputType}); using ${detectedPrecision}. ` +
424
+ `If this is a saved custom model, edit it and set Precision = ${detectedPrecision} to make this explicit.`,
425
+ );
426
+ this.#modelPrecision = detectedPrecision;
427
+ canUseGpuFastPath =
428
+ this.#modelPrecision !== 'fp16' &&
429
+ this.#modelLayout === 'nchw' &&
430
+ this.#modelInputMultiple === 1;
431
+ }
432
+
433
+ if (this.#realizedBackend === 'web-webgpu') {
434
+ const ort = globalThis.ort;
435
+ try {
436
+ this.#device = await ort.env.webgpu.device;
437
+ // installWebGPUDispatchFix is idempotent across model loads. If we
438
+ // install it here for the first time, the just-created session's
439
+ // shader modules were compiled by ORT-Web BEFORE the wrapper
440
+ // existed — release and recreate so all shaders go through it now.
441
+ if (installWebGPUDispatchFix(this.#device)) {
442
+ await this.#session.release();
443
+ const reloaded = await loadSession(this.#modelBuffer, intent, sessionLoadOpts);
444
+ this.#session = reloaded.session;
445
+ this.#realizedBackend = reloaded.realizedBackend;
446
+ }
447
+ if (canUseGpuFastPath) {
448
+ this.#gpuRenderer = new GpuTileRenderer(this.#device);
449
+ }
450
+ if (canUseGpuFastPath && typeof ort.Tensor.fromGpuBuffer === 'function') {
451
+ this.#gpuExtractor = new GpuFrameExtractor(this.#device);
452
+ }
453
+ } catch (err) {
454
+ console.warn('[UpscalerEngine] GPU pipeline init failed, using CPU fallback:', err);
455
+ this.#device = null;
456
+ this.#gpuRenderer = null;
457
+ this.#gpuExtractor = null;
458
+ }
459
+ }
460
+
461
+ this.#modelBuffer = null;
462
+ report?.(1, 'Model loaded.');
463
+ }
464
+
465
+ async upscale(img, tileSize, { onTile, signal } = {}) {
466
+ if (!this.#session) throw new Error('Model not loaded — call loadModel() first');
467
+
468
+ const perf = {
469
+ setup: 0,
470
+ extract: 0,
471
+ inference: 0,
472
+ inferenceEstimated: 0,
473
+ readback: 0,
474
+ gpuRender: 0,
475
+ writeTile: 0,
476
+ dispose: 0,
477
+ total: 0,
478
+ };
479
+ const tTotal = performance.now();
480
+
481
+ const scale = this.#scale;
482
+ const overlap = this.#overlap;
483
+ const srcW = img.videoWidth ?? img.width;
484
+ const srcH = img.videoHeight ?? img.height;
485
+ const outW = srcW * scale;
486
+ const outH = srcH * scale;
487
+ const gaussianBlend = this.#tileBlend === 'gaussian';
488
+ // The GPU output renderer writes via fragment shader directly to the
489
+ // canvas's bgra8unorm surface, so it can't host the float32
490
+ // accumulator Gaussian blending needs. Force the CPU readback path.
491
+ let useGpu = this.#gpuRenderer !== null && !gaussianBlend;
492
+ const useGpuInput = this.#gpuExtractor !== null;
493
+
494
+ // In upscaleBefore mode the model takes HR-sized tiles, so we
495
+ // multiply all input-side tile coords/dims by `scale`. The output
496
+ // side already uses HR coords (tx*scale, outTW=tw*scale, …) for
497
+ // every model, so the GPU output renderer needs no changes.
498
+ const pixelScale = this.#upscaleBefore ? scale : 1;
499
+
500
+ // Gaussian accumulator buffers + a per-tile-size weight cache. The
501
+ // accumRGB stores values in [0, 255] before clamping (matching what
502
+ // the existing chwToImageData decoder produces), so finalize can do
503
+ // a single divide+clamp+pack pass.
504
+ const accumRGB = gaussianBlend ? new Float32Array(3 * outW * outH) : null;
505
+ const accumW = gaussianBlend ? new Float32Array(outW * outH) : null;
506
+ const gaussWeightCache = gaussianBlend ? new Map() : null;
507
+
508
+ // The WebGPU canvas surface and the renderer's persistent output texture
509
+ // are both bounded by maxTextureDimension2D (commonly 8192, sometimes
510
+ // 16384). Exceeding this cap doesn't throw — WebGPU pushes a validation
511
+ // error and the canvas stays black — so we proactively fall back to the
512
+ // CPU readback path whenever the destination is too big. ONNX inference
513
+ // continues to run on WebGPU; only the output rendering path changes.
514
+ if (useGpu) {
515
+ const maxDim = this.#device?.limits?.maxTextureDimension2D ?? 8192;
516
+ if (outW > maxDim || outH > maxDim) {
517
+ console.info(
518
+ `[UpscalerEngine] Output ${outW}\u00d7${outH} exceeds GPU max texture dimension ${maxDim}; using CPU readback path for this image.`,
519
+ );
520
+ useGpu = false;
521
+ }
522
+ }
523
+
524
+ // For upscaleBefore models, pre-rasterize an HR bicubic upsample on
525
+ // a 2D canvas and use that as the source for tile extraction. The
526
+ // GPU extractor and CPU getImageData paths both accept any canvas;
527
+ // they just see a larger texture/ImageData with HR coordinates.
528
+ let extractImg = img;
529
+ let extractW = srcW;
530
+ let extractH = srcH;
531
+ if (this.#upscaleBefore) {
532
+ const hrCanvas = document.createElement('canvas');
533
+ hrCanvas.width = outW;
534
+ hrCanvas.height = outH;
535
+ const hrCtx = hrCanvas.getContext('2d');
536
+ hrCtx.imageSmoothingEnabled = true;
537
+ hrCtx.imageSmoothingQuality = 'high';
538
+ hrCtx.drawImage(img, 0, 0, outW, outH);
539
+ extractImg = hrCanvas;
540
+ extractW = outW;
541
+ extractH = outH;
542
+ }
543
+
544
+ const srcData = this.#prepareSource(extractImg, extractW, extractH, useGpuInput, perf);
545
+
546
+ const outCanvas = document.createElement('canvas');
547
+ outCanvas.width = outW;
548
+ outCanvas.height = outH;
549
+
550
+ let outCtx = null;
551
+ if (useGpu) {
552
+ try {
553
+ this.#gpuRenderer.configure(outCanvas, outW, outH);
554
+ } catch (err) {
555
+ if (err instanceof GpuOutputTooLargeError) {
556
+ console.info(
557
+ `[UpscalerEngine] ${err.message} Using CPU readback path for this image.`,
558
+ );
559
+ } else {
560
+ console.warn(
561
+ '[UpscalerEngine] GPU canvas configure failed, falling back to CPU readback:',
562
+ err,
563
+ );
564
+ }
565
+ useGpu = false;
566
+ }
567
+ }
568
+ if (!useGpu) {
569
+ outCtx = outCanvas.getContext('2d');
570
+ }
571
+
572
+ const tiles = buildTileGrid(srcW, srcH, tileSize, overlap);
573
+
574
+ const inputName = this.#session.inputNames[0];
575
+ const outputName = this.#session.outputNames[0];
576
+
577
+ if (this.#profiling) try { this.#session.startProfiling(); } catch {}
578
+
579
+ let firstInferAt = 0;
580
+ let callbackMs = 0;
581
+ let yieldMs = 0;
582
+ for (let i = 0; i < tiles.length; i++) {
583
+ if (signal?.aborted) throw new DOMException('Upscale cancelled', 'AbortError');
584
+ const rendererLost = useGpu && this.#gpuRenderer?.lost;
585
+ const extractorLost = useGpuInput && this.#gpuExtractor?.lost;
586
+ if (rendererLost || extractorLost) {
587
+ throw new Error('GPU device was lost (browser or OS interrupted). Please retry or switch to the WASM backend.');
588
+ }
589
+
590
+ const { x: tx, y: ty, w: tw, h: th } = tiles[i];
591
+
592
+ const paddedTW = this.#alignToMultiple(tw);
593
+ const paddedTH = this.#alignToMultiple(th);
594
+ const tExtract = performance.now();
595
+ // In upscaleBefore mode the source canvas is HR-sized, so extraction
596
+ // coords/dims scale up. Equality checks against the LR-side padded
597
+ // values are unaffected (both sides scale by the same factor).
598
+ const tensor = this.#createTileTensor(
599
+ srcData,
600
+ tx * pixelScale,
601
+ ty * pixelScale,
602
+ tw * pixelScale,
603
+ th * pixelScale,
604
+ paddedTW * pixelScale,
605
+ paddedTH * pixelScale,
606
+ useGpuInput && paddedTW === tw && paddedTH === th,
607
+ );
608
+ const extractMs = performance.now() - tExtract;
609
+ perf.extract += extractMs;
610
+
611
+ const tInfer = performance.now();
612
+ if (!firstInferAt) firstInferAt = tInfer;
613
+ const results = await this.#session.run({ [inputName]: tensor });
614
+ const inferenceMs = performance.now() - tInfer;
615
+ perf.inference += inferenceMs;
616
+
617
+ const outTW = tw * scale;
618
+ const outTH = th * scale;
619
+ const outPaddedTW = paddedTW * scale;
620
+ const outPaddedTH = paddedTH * scale;
621
+ let renderMs = 0, readbackMs = 0;
622
+
623
+ if (useGpu && outPaddedTW === outTW && outPaddedTH === outTH) {
624
+ const tGpu = performance.now();
625
+ this.#gpuRenderer.renderTile(
626
+ results[outputName].gpuBuffer, outTW, outTH,
627
+ tx * scale, ty * scale, overlap * scale, 1 / this.#modelValueRange,
628
+ );
629
+ this.#gpuRenderer.presentToCanvas();
630
+ renderMs = performance.now() - tGpu;
631
+ perf.gpuRender += renderMs;
632
+ } else {
633
+ const tReadback = performance.now();
634
+ const outTensor = results[outputName];
635
+ // When the session was opened with preferredOutputLocation:'gpu-buffer'
636
+ // (gpu fast path enabled at load time) but we're falling back per-image
637
+ // because the destination canvas would exceed maxTextureDimension2D,
638
+ // the tensor lives on the GPU and `.data` is empty. getData(true)
639
+ // downloads the data and releases the GPU buffer in one step.
640
+ const rawOutData = outTensor.location === 'gpu-buffer'
641
+ ? await outTensor.getData(true)
642
+ : outTensor.data;
643
+ // fp16 output tensors expose a Uint16Array of bit patterns; unpack
644
+ // to Float32 once so the existing CHW/HWC decoders can stay fp32.
645
+ const outData = outTensor.type === 'float16'
646
+ ? unpackFloat16BitsToFloat32(rawOutData)
647
+ : rawOutData;
648
+ readbackMs = performance.now() - tReadback;
649
+ perf.readback += readbackMs;
650
+
651
+ const tWrite = performance.now();
652
+ const dims = outTensor.dims;
653
+ const isNHWC = dims.length === 4 && dims[3] === 3 && dims[1] !== 3;
654
+ if (gaussianBlend) {
655
+ // Look up / build the weight kernel for this tile's HR-side
656
+ // content dimensions (outTH, outTW). Edge tiles can be smaller
657
+ // than interior tiles; cache by key.
658
+ const key = `${outTH}x${outTW}`;
659
+ let weights = gaussWeightCache.get(key);
660
+ if (!weights) {
661
+ weights = makeGaussianWeights2D(outTH, outTW);
662
+ gaussWeightCache.set(key, weights);
663
+ }
664
+ const valueScale = 255 / this.#modelValueRange;
665
+ accumulateGaussianTile(
666
+ accumRGB, accumW, outW, outH,
667
+ outData, outPaddedTW, outPaddedTH,
668
+ outTW, outTH, tx * scale, ty * scale,
669
+ weights, valueScale, isNHWC ? 'hwc' : 'chw',
670
+ );
671
+ // Finalize just the rectangle this tile touched so the user
672
+ // sees progressive preview. Overlapping tiles will rewrite
673
+ // their shared region as they accumulate; the last tile to
674
+ // touch a pixel writes the same value a final full-canvas
675
+ // finalize would.
676
+ finalizeGaussianRegion(
677
+ outCtx, tx * scale, ty * scale, outTW, outTH,
678
+ outW, outH, accumRGB, accumW,
679
+ );
680
+ } else {
681
+ const decode = isNHWC ? hwcToImageData : chwToImageData;
682
+ const paddedImgData = decode(outData, outPaddedTW, outPaddedTH, 255 / this.#modelValueRange);
683
+ const imgData = outPaddedTW === outTW && outPaddedTH === outTH
684
+ ? paddedImgData
685
+ : this.#cropImageData(paddedImgData, outTW, outTH);
686
+ pasteTileCropped(outCtx, imgData, tx * scale, ty * scale, outW, outH, overlap * scale);
687
+ }
688
+ renderMs = performance.now() - tWrite;
689
+ perf.writeTile += renderMs;
690
+ }
691
+
692
+ const tDispose = performance.now();
693
+ tensor.dispose();
694
+ results[outputName].dispose();
695
+ const disposeMs = performance.now() - tDispose;
696
+ perf.dispose += disposeMs;
697
+
698
+ const crop = overlapCrop(tx * scale, ty * scale, outTW, outTH, outW, outH, overlap * scale);
699
+ const tCallback = performance.now();
700
+ onTile?.({
701
+ index: i, total: tiles.length, tileMs: inferenceMs, tilePixels: tw * th,
702
+ canvas: outCanvas, outX: tx * scale, outY: ty * scale, outW: outTW, outH: outTH,
703
+ crop,
704
+ perf: { extractMs, inferenceMs, readbackMs, renderMs, disposeMs },
705
+ });
706
+ callbackMs += performance.now() - tCallback;
707
+
708
+ const tYield = performance.now();
709
+ await yieldToEventLoop();
710
+ yieldMs += performance.now() - tYield;
711
+ }
712
+
713
+ if (useGpu) {
714
+ this.#gpuRenderer.presentToCanvas();
715
+ await this.#waitForGpuWork();
716
+ }
717
+
718
+ const tDone = performance.now();
719
+ perf.total = tDone - tTotal;
720
+ if (useGpu && firstInferAt) {
721
+ const gpuSpanMs = tDone - firstInferAt;
722
+ const otherTrackedMs =
723
+ perf.extract +
724
+ perf.gpuRender +
725
+ perf.readback +
726
+ perf.writeTile +
727
+ perf.dispose +
728
+ callbackMs +
729
+ yieldMs;
730
+ perf.inferenceEstimated = Math.max(0, gpuSpanMs - otherTrackedMs);
731
+ }
732
+
733
+ let ortProfile = null;
734
+ if (this.#profiling) {
735
+ ortProfile = this.#collectOrtProfile();
736
+ }
737
+
738
+ // 'gpu-gpu' = GPU input extract + GPU output render (zero readback).
739
+ // 'gpu' = ONNX runs on GPU but at least one of input/output uses CPU
740
+ // (e.g., per-image fallback when output exceeds maxTexDim).
741
+ // 'cpu' = WASM/CPU end-to-end.
742
+ const pipeline = useGpu && useGpuInput
743
+ ? 'gpu-gpu'
744
+ : (useGpu || useGpuInput) ? 'gpu' : 'cpu';
745
+ return {
746
+ canvas: outCanvas,
747
+ perf: { ...perf, tiles: tiles.length, tileSize, srcW, srcH, outW, outH, pipeline },
748
+ ortProfile,
749
+ };
750
+ }
751
+
752
+ destroy() {
753
+ this.#releaseSession();
754
+ this.#modelBuffer = null;
755
+ }
756
+
757
+ #releaseSession() {
758
+ this.#untrackRealizedBackend();
759
+ this.#gpuRenderer?.destroy();
760
+ this.#gpuRenderer = null;
761
+ this.#gpuExtractor?.destroy();
762
+ this.#gpuExtractor = null;
763
+ this.#device = null;
764
+ try { this.#session?.release(); } catch {}
765
+ this.#session = null;
766
+ this.#realizedBackend = null;
767
+ this.#intent = null;
768
+ }
769
+
770
+ // While a session is alive, follow runtime backend changes via the
771
+ // backend-event channel — the native worker can fall back from CoreML to
772
+ // CPU between tiles, and that's the only signal we get. Without this the
773
+ // next loadModel-early-return announces a stale realizedBackend.
774
+ #trackRealizedBackend() {
775
+ if (this.#backendListener) return;
776
+ this.#backendListener = (e) => {
777
+ const d = e?.detail;
778
+ if (d && d.kind === 'success' && typeof d.backend === 'string') {
779
+ this.#realizedBackend = d.backend;
780
+ }
781
+ };
782
+ document.addEventListener('aitools:backend-event', this.#backendListener);
783
+ }
784
+
785
+ #untrackRealizedBackend() {
786
+ if (!this.#backendListener) return;
787
+ document.removeEventListener('aitools:backend-event', this.#backendListener);
788
+ this.#backendListener = null;
789
+ }
790
+
791
+ #prepareSource(img, srcW, srcH, useGpuInput, perf) {
792
+ const tSetup = performance.now();
793
+ let srcData = null;
794
+ if (useGpuInput) {
795
+ this.#gpuExtractor.uploadFrame(img, srcW, srcH);
796
+ } else {
797
+ const tmpC = document.createElement('canvas');
798
+ tmpC.width = srcW;
799
+ tmpC.height = srcH;
800
+ const tmpCtx = tmpC.getContext('2d');
801
+ tmpCtx.drawImage(img, 0, 0);
802
+ srcData = tmpCtx.getImageData(0, 0, srcW, srcH);
803
+ tmpC.width = 0;
804
+ tmpC.height = 0;
805
+ }
806
+ perf.setup = performance.now() - tSetup;
807
+ return srcData;
808
+ }
809
+
810
+ #alignToMultiple(value) {
811
+ const m = this.#modelInputMultiple;
812
+ if (!Number.isFinite(m) || m <= 1) return value;
813
+ return Math.ceil(value / m) * m;
814
+ }
815
+
816
+ #cropImageData(imgData, width, height) {
817
+ if (imgData.width === width && imgData.height === height) return imgData;
818
+ const out = new ImageData(width, height);
819
+ const src = imgData.data;
820
+ const dst = out.data;
821
+ const srcStride = imgData.width * 4;
822
+ const dstStride = width * 4;
823
+ for (let row = 0; row < height; row++) {
824
+ const srcStart = row * srcStride;
825
+ const dstStart = row * dstStride;
826
+ dst.set(src.subarray(srcStart, srcStart + dstStride), dstStart);
827
+ }
828
+ return out;
829
+ }
830
+
831
+ #createTileTensor(srcData, tx, ty, tw, th, paddedTW, paddedTH, useGpuInput) {
832
+ const ort = globalThis.ort;
833
+ if (useGpuInput) {
834
+ // GPU input fast path is gated to fp32 in loadModel(), so we only
835
+ // reach here when the model is fp32.
836
+ const gpuBuf = this.#gpuExtractor.extractTile(tx, ty, tw, th, this.#modelValueRange);
837
+ return ort.Tensor.fromGpuBuffer(gpuBuf, {
838
+ dataType: 'float32',
839
+ dims: [1, 3, th, tw],
840
+ dispose: () => {},
841
+ });
842
+ }
843
+ const isNHWC = this.#modelLayout === 'nhwc';
844
+ const extract = isNHWC ? extractTileNHWC : extractTileNCHW;
845
+ const dims = isNHWC ? [1, paddedTH, paddedTW, 3] : [1, 3, paddedTH, paddedTW];
846
+ const f32 = extract(
847
+ srcData,
848
+ tx,
849
+ ty,
850
+ tw,
851
+ th,
852
+ paddedTW,
853
+ paddedTH,
854
+ this.#modelValueRange / 255,
855
+ );
856
+ if (this.#modelPrecision === 'fp16') {
857
+ const u16 = packFloat32ToFloat16Bits(f32);
858
+ return new ort.Tensor('float16', u16, dims);
859
+ }
860
+ return new ort.Tensor('float32', f32, dims);
861
+ }
862
+
863
+ async #waitForGpuWork() {
864
+ try {
865
+ if (this.#device?.queue?.onSubmittedWorkDone) {
866
+ await this.#device.queue.onSubmittedWorkDone();
867
+ }
868
+ } catch {
869
+ // Ignore sync failures and let the caller continue.
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Capture ORT's profiling output (logged to console by endProfiling)
875
+ * and return it as structured data instead of formatted strings.
876
+ */
877
+ #collectOrtProfile() {
878
+ const captured = [];
879
+ const origLog = console.log;
880
+ const origWarn = console.warn;
881
+ const intercept = (...args) => captured.push(args.join(' '));
882
+ console.log = intercept;
883
+ console.warn = intercept;
884
+ try { this.#session.endProfiling(); } catch {}
885
+ console.log = origLog;
886
+ console.warn = origWarn;
887
+
888
+ if (!captured.length) return null;
889
+ let events;
890
+ try {
891
+ const raw = captured.join('\n');
892
+ events = JSON.parse(raw.substring(raw.indexOf('['), raw.lastIndexOf(']') + 1));
893
+ } catch { return null; }
894
+
895
+ const nodes = events.filter(e => e.cat === 'Node');
896
+ const runs = events.filter(e => e.name === 'model_run');
897
+ if (!nodes.length) return null;
898
+
899
+ const gpuOps = {}, cpuOps = {};
900
+ let toHostUs = 0, toHostN = 0, fromHostUs = 0, fromHostN = 0;
901
+
902
+ for (const n of nodes) {
903
+ const op = n.args?.op_name ?? n.name;
904
+ const us = n.dur ?? 0;
905
+ if (op === 'MemcpyToHost') { toHostUs += us; toHostN++; continue; }
906
+ if (op === 'MemcpyFromHost') { fromHostUs += us; fromHostN++; continue; }
907
+ const bucket = n.args?.provider === 'CPUExecutionProvider' ? cpuOps : gpuOps;
908
+ bucket[op] ??= { us: 0, n: 0 };
909
+ bucket[op].us += us;
910
+ bucket[op].n++;
911
+ }
912
+
913
+ return {
914
+ runs: runs.length,
915
+ modelRunUs: runs.reduce((s, r) => s + (r.dur ?? 0), 0),
916
+ gpuOps,
917
+ cpuOps,
918
+ memcpy: {
919
+ toHost: { us: toHostUs, n: toHostN },
920
+ fromHost: { us: fromHostUs, n: fromHostN },
921
+ },
922
+ };
923
+ }
924
+ }
features/upscaler/model-registry.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Generated by deploy-hf-space.js — do not edit by hand.
2
+
3
+ export const UPSCALER_MODELS = [
4
+ {
5
+ "url": "models/4x-UpdraftSmall.onnx",
6
+ "scale": 4,
7
+ "label": "Updraft Small (Custom)",
8
+ "sizeMB": 1.4,
9
+ "multipleOf": 32
10
+ },
11
+ {
12
+ "url": "models/4x-ClearRealityV1.onnx",
13
+ "scale": 4,
14
+ "label": "ClearReality (SPAN)",
15
+ "sizeMB": 1.9
16
+ },
17
+ {
18
+ "url": "models/DAT_light_x4_dyn_OTF_4.onnx",
19
+ "scale": 4,
20
+ "label": "DAT Light Restore (DAT-Light OTF)",
21
+ "sizeMB": 5
22
+ },
23
+ {
24
+ "url": "models/4x-UltraSharpV2_Lite.onnx",
25
+ "scale": 4,
26
+ "label": "UltraSharp V2 Lite (RealPLKSR)",
27
+ "sizeMB": 30
28
+ }
29
+ ];
30
+
31
+ export const UPSCALER_RESAMPLER_MODELS = [
32
+ {
33
+ "url": "builtin:lanczos-4x",
34
+ "scale": 4,
35
+ "label": "Lanczos"
36
+ },
37
+ {
38
+ "url": "builtin:bicubic-4x",
39
+ "scale": 4,
40
+ "label": "Bicubic"
41
+ }
42
+ ];
43
+
44
+ /**
45
+ * Shared model definitions for the image and video upscaler features.
46
+ * Single source of truth — all model <select> elements render from this list.
47
+ */
48
+
49
+ export const UPSCALER_MODELS = [
50
+ { url: 'models/4x-UpdraftSmall.onnx', scale: 4, label: 'Updraft Small (Custom)', sizeMB: 1.4, multipleOf: 32 },
51
+ { url: 'models/4x-ClearRealityV1.onnx', scale: 4, label: 'ClearReality (SPAN)', sizeMB: 1.9 },
52
+ { url: 'models/DAT_light_x4_dyn_OTF_4.onnx', scale: 4, label: 'DAT Light Restore (DAT-Light OTF)', sizeMB: 5 },
53
+ { url: 'models/4x-UltraSharpV2_Lite.onnx', scale: 4, label: 'UltraSharp V2 Lite (RealPLKSR)', sizeMB: 30 },
54
+ { url: 'models/4x-UltraSharpV2.onnx', scale: 4, label: 'UltraSharp V2 (DAT)', sizeMB: 52 },
55
+ // { url: 'models/super.onnx', scale: 4, label: 'Apple Super 188k', sizeMB: 5.5, multipleOf: 32 },
56
+ // { url: 'models/super_2.onnx', scale: 4, label: 'Apple Super 2 202k', sizeMB: 5.5, multipleOf: 32 },
57
+ // { url: 'models/super_3.onnx', scale: 4, label: 'Apple Super 3 244k', sizeMB: 5.5, multipleOf: 32 },
58
+ // { url: 'models/super_4.onnx', scale: 4, label: 'Apple Super 4 308k', sizeMB: 5.5, multipleOf: 32 },
59
+ // { url: 'models/super_5.onnx', scale: 4, label: 'Apple Super 5 358k', sizeMB: 5.5, multipleOf: 32 },
60
+ // { url: 'models/super_6.onnx', scale: 4, label: 'Apple Super 6 416k', sizeMB: 5.5, multipleOf: 32 },
61
+ // { url: 'models/super_7.onnx', scale: 4, label: 'Apple Super 7 450k', sizeMB: 5.5, multipleOf: 32 },
62
+ // { url: 'models/super_8.onnx', scale: 4, label: 'Apple Super 8 488k', sizeMB: 5.5, multipleOf: 32 },
63
+ // { url: 'models/super_9.onnx', scale: 4, label: 'Apple Super 9 521k', sizeMB: 5.5, multipleOf: 32 },
64
+ // { url: 'models/super_10.onnx', scale: 4, label: 'Apple Super 10 570k', sizeMB: 5.5, multipleOf: 32 },
65
+ // { url: 'models/super_11.onnx', scale: 4, label: 'Apple Super 11 617k', sizeMB: 5.5, multipleOf: 32 },
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+ {
77
+ url: 'models/tinysr_fused.onnx',
78
+ scale: 4,
79
+ label: 'TinySR (DiT refiner)',
80
+ sizeMB: 687,
81
+ multipleOf: 128, // 128 LR × 4 = 512 HR (the fixed model input)
82
+ maxTileSize: 128, // same — every tile pads/crops to exactly 128 LR
83
+ precision: 'fp16',
84
+ upscaleBefore: true,
85
+ tileBlend: 'gaussian', // diffusion-style: hard-overlap shows seams
86
+ }
87
+
88
+ ];
89
+
90
+ export const UPSCALER_RESAMPLER_MODELS = [
91
+ { url: 'builtin:lanczos-4x', scale: 4, label: 'Lanczos' },
92
+ { url: 'builtin:bicubic-4x', scale: 4, label: 'Bicubic' },
93
+ ];
94
+
95
+ /**
96
+ * Render <option> elements for a model <select>.
97
+ * @param {typeof UPSCALER_MODELS} [models]
98
+ * @param {{ selected?: string, includeResamplers?: boolean }} [opts]
99
+ * - `selected` is matched against model URL
100
+ * - `includeResamplers` appends built-in non-ONNX upscale methods
101
+ */
102
+ export function modelOptionsHTML(models = UPSCALER_MODELS, { selected, includeResamplers = false } = {}) {
103
+ const modelList = includeResamplers
104
+ ? [...models, ...UPSCALER_RESAMPLER_MODELS]
105
+ : models;
106
+
107
+ return modelList.map(m => {
108
+ const attrs = [
109
+ `value="${m.url}"`,
110
+ `data-scale="${m.scale}"`,
111
+ ];
112
+ if (m.range) attrs.push(`data-range="${m.range}"`);
113
+ if (m.backend) attrs.push(`data-backend="${m.backend}"`);
114
+ if (m.sizeMB != null) attrs.push(`data-sizemb="${m.sizeMB}"`);
115
+ if (Number.isFinite(m.maxTileSize)) attrs.push(`data-maxtilesize="${m.maxTileSize}"`);
116
+ if (Number.isFinite(m.multipleOf) && m.multipleOf > 1) {
117
+ attrs.push(`data-multipleof="${m.multipleOf}"`);
118
+ }
119
+ // Default precision is fp32; only emit data-precision when the model is
120
+ // fp16 so unannotated registry entries stay legible.
121
+ if (m.precision === 'fp16') attrs.push(`data-precision="fp16"`);
122
+ // upscaleBefore=true marks HR-space refiners (e.g. fused diffusion SR
123
+ // graphs). The engine bicubic-upsamples LR->HR before tiling so the
124
+ // model sees HR pixel patches; multipleOf / maxTileSize stay in LR units.
125
+ if (m.upscaleBefore) attrs.push(`data-upscalebefore="true"`);
126
+ // tileBlend='gaussian' switches the tile stitcher to float32 Gaussian-
127
+ // weighted accumulation (forces CPU readback path). Use for diffusion-
128
+ // style models where the default half-overlap hard crop shows seams.
129
+ if (m.tileBlend === 'gaussian') attrs.push(`data-tileblend="gaussian"`);
130
+ if (m.url === selected) attrs.push('selected');
131
+ const sizeStr = m.sizeMB != null ? ` (~${m.sizeMB}MB)` : '';
132
+ return `<option ${attrs.join(' ')}>${m.label}${sizeStr}</option>`;
133
+ }).join('\n ');
134
+ }
135
+
features/upscaler/ui/perf-monitor.js ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <perf-monitor> — fixed-position performance overlay.
3
+ * Consumes events from the upscaler engine to display live tile stats,
4
+ * session timing breakdowns, and optional ORT kernel profiles.
5
+ */
6
+
7
+ import { morph } from 'lib/morph';
8
+
9
+ function fmtTime(ms) {
10
+ if (ms < 1000) return ms.toFixed(0) + ' ms';
11
+ const s = ms / 1000;
12
+ if (s < 60) return s.toFixed(1) + ' s';
13
+ const m = Math.floor(s / 60);
14
+ return m + ':' + String(Math.floor(s % 60)).padStart(2, '0');
15
+ }
16
+
17
+ function fmtMs(ms) {
18
+ return ms.toFixed(1) + ' ms';
19
+ }
20
+
21
+ function fmtMB(bytes) {
22
+ return (bytes / 1048576).toFixed(1) + ' MB';
23
+ }
24
+
25
+ function humanStepName(step) {
26
+ const names = {
27
+ tiledUpscale: 'Base pass',
28
+ blendAll: 'All-pass blend',
29
+ detectFaces: 'Face detection',
30
+ enhanceFaces: 'Face enhance',
31
+ pipeline: 'Pipeline',
32
+ };
33
+ return names[step] || step || '—';
34
+ }
35
+
36
+ class PerfMonitor extends HTMLElement {
37
+ #tileTimes = [];
38
+ #startTime = 0;
39
+ #heapInterval = null;
40
+ #state = 'idle';
41
+ #tilePerf = null;
42
+ #currentStep = null;
43
+
44
+ #stats = {
45
+ backend: '\u2014', tile: '\u2014', tileTime: '\u2014', avgTile: '\u2014',
46
+ elapsed: '\u2014', eta: '\u2014', heap: '\u2014', heapLimit: '\u2014',
47
+ heapPct: 0, heapClass: '', throughput: '\u2014', stage: '\u2014',
48
+ };
49
+
50
+ #results = null;
51
+
52
+ connectedCallback() {
53
+ this.classList.add('perf-monitor');
54
+ this.style.display = 'none';
55
+ this.#render();
56
+ this.addEventListener('click', (e) => {
57
+ if (e.target.closest('.perf-close')) this.hide();
58
+ });
59
+ }
60
+
61
+ disconnectedCallback() {
62
+ if (this.#heapInterval) {
63
+ clearInterval(this.#heapInterval);
64
+ this.#heapInterval = null;
65
+ }
66
+ }
67
+
68
+ start(backend) {
69
+ this.#tileTimes = [];
70
+ this.#startTime = performance.now();
71
+ this.#state = 'running';
72
+ this.#tilePerf = null;
73
+ this.#currentStep = null;
74
+ this.#results = null;
75
+
76
+ const s = this.#stats;
77
+ s.backend = backend.toUpperCase();
78
+ s.tile = '\u2014'; s.tileTime = '\u2014'; s.avgTile = '\u2014';
79
+ s.elapsed = '0 s'; s.eta = '\u2014'; s.throughput = '\u2014';
80
+ s.stage = '\u2014';
81
+
82
+ this.#refreshHeap();
83
+ this.style.display = 'block';
84
+ this.#render();
85
+ this.#heapInterval = setInterval(() => { this.#refreshHeap(); this.#render(); }, 500);
86
+ }
87
+
88
+ update({ step, index, total, tileMs, tilePixels, perf: tilePerf }) {
89
+ if (step && step !== this.#currentStep) {
90
+ this.#currentStep = step;
91
+ this.#tileTimes = [];
92
+ }
93
+ this.#tileTimes.push(tileMs);
94
+ this.#tilePerf = tilePerf || null;
95
+ const elapsed = performance.now() - this.#startTime;
96
+ const avg = this.#tileTimes.reduce((a, b) => a + b, 0) / this.#tileTimes.length;
97
+ const remaining = (total - index - 1) * avg;
98
+ const totalPixels = this.#tileTimes.length * tilePixels;
99
+ const mpxPerSec = (totalPixels / (elapsed / 1000)) / 1e6;
100
+
101
+ const s = this.#stats;
102
+ s.stage = humanStepName(step);
103
+ s.tile = `${index + 1} / ${total}`;
104
+ s.tileTime = fmtTime(tileMs);
105
+ s.avgTile = fmtTime(avg);
106
+ s.elapsed = fmtTime(elapsed);
107
+ s.eta = remaining > 0 ? '~' + fmtTime(remaining) : '\u2014';
108
+ s.throughput = mpxPerSec.toFixed(2) + ' Mpx/s';
109
+
110
+ this.#refreshHeap();
111
+ this.#render();
112
+ }
113
+
114
+ updateStage({ step, phase, message }) {
115
+ if (this.#state !== 'running') return;
116
+ const label = humanStepName(step);
117
+ this.#stats.stage = phase === 'done' && label ? `${label} done` : label;
118
+ if (message) this.#stats.tile = message;
119
+ this.#render();
120
+ }
121
+
122
+ showResults(perf, ortProfile, pipelinePerf) {
123
+ this.#state = 'done';
124
+ this.#results = { perf, ortProfile, pipelinePerf };
125
+ if (this.#heapInterval) {
126
+ clearInterval(this.#heapInterval);
127
+ this.#heapInterval = null;
128
+ }
129
+ this.#refreshHeap();
130
+ this.#render();
131
+ }
132
+
133
+ stop() {
134
+ if (this.#heapInterval) {
135
+ clearInterval(this.#heapInterval);
136
+ this.#heapInterval = null;
137
+ }
138
+ if (this.#state === 'running') {
139
+ this.#stats.eta = 'Done';
140
+ this.#state = 'done';
141
+ }
142
+ this.#render();
143
+ }
144
+
145
+ get elapsedFormatted() {
146
+ return fmtTime(performance.now() - this.#startTime);
147
+ }
148
+
149
+ show() { this.style.display = 'block'; this.#render(); }
150
+ hide() { this.style.display = 'none'; }
151
+ get visible() { return this.style.display !== 'none'; }
152
+
153
+ #refreshHeap() {
154
+ const s = this.#stats;
155
+ const mem = performance.memory;
156
+ if (!mem) {
157
+ s.heap = 'N/A'; s.heapLimit = 'N/A'; s.heapPct = 0; s.heapClass = '';
158
+ return;
159
+ }
160
+ const used = mem.usedJSHeapSize;
161
+ const limit = mem.jsHeapSizeLimit;
162
+ const pct = (used / limit) * 100;
163
+ s.heap = fmtMB(used);
164
+ s.heapLimit = fmtMB(limit);
165
+ s.heapPct = pct;
166
+ s.heapClass = pct > 80 ? ' crit' : pct > 60 ? ' warn' : '';
167
+ }
168
+
169
+ #render() {
170
+ const s = this.#stats;
171
+ const r = this.#results;
172
+ const tp = this.#tilePerf;
173
+ const isRunning = this.#state === 'running';
174
+ const isDone = this.#state === 'done';
175
+
176
+ let tileBreakdownHtml = '';
177
+ if (isRunning && tp) {
178
+ tileBreakdownHtml = `
179
+ <div class="perf-row sub"><span class="perf-label">Extract</span><span class="perf-value">${fmtMs(tp.extractMs)}</span></div>
180
+ <div class="perf-row sub"><span class="perf-label">Inference</span><span class="perf-value">${fmtMs(tp.inferenceMs)}</span></div>
181
+ <div class="perf-row sub"><span class="perf-label">Render</span><span class="perf-value">${fmtMs(tp.renderMs)}</span></div>
182
+ <div class="perf-row sub"><span class="perf-label">Dispose</span><span class="perf-value">${fmtMs(tp.disposeMs)}</span></div>`;
183
+ }
184
+
185
+ let resultsHtml = '';
186
+ if (isDone && r?.perf) {
187
+ const p = r.perf;
188
+ const pipelineLabel = p.pipeline === 'gpu-gpu' ? 'GPU\u2192GPU' : p.pipeline === 'gpu' ? 'GPU' : 'CPU';
189
+ const tileSizeLabel = p.tileSize > 0 ? p.tileSize + 'px' : 'full';
190
+ const isGpuPipeline = p.pipeline === 'gpu' || p.pipeline === 'gpu-gpu';
191
+ resultsHtml = `
192
+ <div class="perf-divider"></div>
193
+ <div class="perf-section-title">Session Summary</div>
194
+ <div class="perf-row"><span class="perf-label">Pipeline</span><span class="perf-value">${pipelineLabel}</span></div>
195
+ <div class="perf-row"><span class="perf-label">Resolution</span><span class="perf-value">${p.srcW}\u00d7${p.srcH} \u2192 ${p.outW}\u00d7${p.outH}</span></div>
196
+ <div class="perf-row"><span class="perf-label">Tiles</span><span class="perf-value">${p.tiles} @ ${tileSizeLabel}</span></div>
197
+ <div class="perf-row"><span class="perf-label">Total</span><span class="perf-value em">${fmtMs(p.total)}</span></div>
198
+ ${p.modelLoad > 0 ? `<div class="perf-row sub"><span class="perf-label">Model load</span><span class="perf-value">${fmtMs(p.modelLoad)}</span></div>` : ''}
199
+ <div class="perf-row sub"><span class="perf-label">Setup</span><span class="perf-value">${fmtMs(p.setup)}</span></div>
200
+ <div class="perf-row sub"><span class="perf-label">Extract</span><span class="perf-value">${fmtMs(p.extract)}</span></div>
201
+ <div class="perf-row sub"><span class="perf-label">${isGpuPipeline ? 'Inference est.' : 'Inference'}</span><span class="perf-value">${fmtMs(isGpuPipeline ? (p.inferenceEstimated || 0) : p.inference)}</span></div>
202
+ ${p.readback > 0 ? `<div class="perf-row sub"><span class="perf-label">Readback</span><span class="perf-value">${fmtMs(p.readback)}</span></div>` : ''}
203
+ ${p.gpuRender > 0 ? `<div class="perf-row sub"><span class="perf-label">GPU render</span><span class="perf-value">${fmtMs(p.gpuRender)}</span></div>` : ''}
204
+ ${p.writeTile > 0 ? `<div class="perf-row sub"><span class="perf-label">Write tiles</span><span class="perf-value">${fmtMs(p.writeTile)}</span></div>` : ''}
205
+ <div class="perf-row sub"><span class="perf-label">Dispose</span><span class="perf-value">${fmtMs(p.dispose)}</span></div>`;
206
+
207
+ if (r.ortProfile) {
208
+ const ort = r.ortProfile;
209
+ const ms = us => (us / 1000).toFixed(1) + 'ms';
210
+ const gpuTotal = Object.values(ort.gpuOps).reduce((acc, e) => acc + e.us, 0);
211
+ const cpuTotal = Object.values(ort.cpuOps).reduce((acc, e) => acc + e.us, 0);
212
+ const topGpuOps = Object.entries(ort.gpuOps)
213
+ .sort(([, a], [, b]) => b.us - a.us)
214
+ .slice(0, 4)
215
+ .map(([op, { us, n }]) => `${op}\u00d7${n} ${ms(us)}`)
216
+ .join(', ');
217
+
218
+ resultsHtml += `
219
+ <div class="perf-divider"></div>
220
+ <div class="perf-section-title">ORT Profile</div>
221
+ <div class="perf-row"><span class="perf-label">Runs</span><span class="perf-value">${ort.runs}, model_run ${ms(ort.modelRunUs)}</span></div>
222
+ ${gpuTotal ? `<div class="perf-row"><span class="perf-label">GPU ops</span><span class="perf-value">${ms(gpuTotal)}</span></div>
223
+ <div class="perf-row sub"><span class="perf-label"></span><span class="perf-value dim">${topGpuOps}</span></div>` : ''}
224
+ ${cpuTotal ? `<div class="perf-row"><span class="perf-label">CPU ops</span><span class="perf-value">${ms(cpuTotal)}</span></div>` : ''}
225
+ ${ort.memcpy.toHost.n ? `<div class="perf-row"><span class="perf-label">GPU\u2192CPU</span><span class="perf-value">${ms(ort.memcpy.toHost.us)} \u00d7${ort.memcpy.toHost.n}</span></div>` : ''}
226
+ ${ort.memcpy.fromHost.n ? `<div class="perf-row"><span class="perf-label">CPU\u2192GPU</span><span class="perf-value">${ms(ort.memcpy.fromHost.us)} \u00d7${ort.memcpy.fromHost.n}</span></div>` : ''}`;
227
+ }
228
+ if (r.pipelinePerf?.steps) {
229
+ const rows = Object.entries(r.pipelinePerf.steps)
230
+ .map(([name, data]) => {
231
+ const tiles = data.perf?.tiles ? ` (${data.perf.tiles} tiles)` : '';
232
+ return `<div class="perf-row sub"><span class="perf-label">${humanStepName(name)}</span><span class="perf-value dim">${fmtMs(data.durationMs)}${tiles}</span></div>`;
233
+ })
234
+ .join('');
235
+ resultsHtml += `
236
+ <div class="perf-divider"></div>
237
+ <div class="perf-section-title">Pipeline Steps</div>
238
+ ${rows}
239
+ <div class="perf-row"><span class="perf-label">Pipeline total</span><span class="perf-value">${fmtMs(r.pipelinePerf.totalMs || 0)}</span></div>`;
240
+ }
241
+ }
242
+
243
+ morph(this, `
244
+ <style>
245
+ .perf-monitor {
246
+ display: none; position: fixed; bottom: 12px; right: 12px; z-index: 9999;
247
+ background: rgba(0,0,0,0.85); border: 1px solid #333; border-radius: 6px;
248
+ padding: 10px 14px; font-family: 'SF Mono', 'Consolas', monospace; font-size: 0.72rem;
249
+ color: #ccc; min-width: 240px; max-height: calc(100vh - 24px); overflow-y: auto;
250
+ backdrop-filter: blur(8px); box-shadow: 0 4px 12px rgba(0,0,0,0.5);
251
+ }
252
+ .perf-monitor .perf-title {
253
+ font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em;
254
+ color: #666; margin-bottom: 6px; border-bottom: 1px solid #333; padding-bottom: 4px;
255
+ display: flex; justify-content: space-between; align-items: center;
256
+ }
257
+ .perf-monitor .perf-close {
258
+ background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem;
259
+ padding: 0 2px; line-height: 1; width: auto; margin: 0;
260
+ }
261
+ .perf-monitor .perf-close:hover { color: #ccc; }
262
+ .perf-monitor .perf-row { display: flex; justify-content: space-between; padding: 1px 0; gap: 1rem; }
263
+ .perf-monitor .perf-row.sub { padding-left: 10px; }
264
+ .perf-monitor .perf-row.sub .perf-label { color: #666; font-size: 0.68rem; }
265
+ .perf-monitor .perf-row.sub .perf-value { color: #999; font-size: 0.68rem; }
266
+ .perf-monitor .perf-label { color: #888; white-space: nowrap; }
267
+ .perf-monitor .perf-value { color: var(--pico-primary, #4c8); font-weight: 600; text-align: right; }
268
+ .perf-monitor .perf-value.em { color: #fff; }
269
+ .perf-monitor .perf-value.dim { color: #777; font-weight: 400; font-size: 0.65rem; }
270
+ .perf-monitor .perf-bar-track {
271
+ height: 3px; background: #333; border-radius: 2px; margin-top: 4px; overflow: hidden;
272
+ }
273
+ .perf-monitor .perf-bar-fill { height: 100%; background: var(--pico-primary, #4c8); transition: width 0.3s; width: 0%; }
274
+ .perf-monitor .perf-bar-fill.warn { background: #c84; }
275
+ .perf-monitor .perf-bar-fill.crit { background: #c44; }
276
+ .perf-monitor .perf-divider { border-top: 1px solid #333; margin: 6px 0; }
277
+ .perf-monitor .perf-section-title {
278
+ font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.06em;
279
+ color: #555; margin-bottom: 4px;
280
+ }
281
+ </style>
282
+ <div class="perf-title">
283
+ <span>Performance${isDone ? ' \u2014 Done' : ''}</span>
284
+ <button class="perf-close" title="Close">\u00d7</button>
285
+ </div>
286
+ <div class="perf-row"><span class="perf-label">Backend</span><span class="perf-value">${s.backend}</span></div>
287
+ <div class="perf-row"><span class="perf-label">Stage</span><span class="perf-value">${s.stage}</span></div>
288
+ <div class="perf-row"><span class="perf-label">Tile</span><span class="perf-value">${s.tile}</span></div>
289
+ <div class="perf-row"><span class="perf-label">Tile time</span><span class="perf-value">${s.tileTime}</span></div>
290
+ ${tileBreakdownHtml}
291
+ <div class="perf-row"><span class="perf-label">Avg tile</span><span class="perf-value">${s.avgTile}</span></div>
292
+ <div class="perf-row"><span class="perf-label">Elapsed</span><span class="perf-value">${s.elapsed}</span></div>
293
+ <div class="perf-row"><span class="perf-label">ETA</span><span class="perf-value">${s.eta}</span></div>
294
+ <div class="perf-row"><span class="perf-label">JS Heap</span><span class="perf-value">${s.heap}</span></div>
295
+ <div class="perf-bar-track">
296
+ <div class="perf-bar-fill${s.heapClass}" style="width:${s.heapPct.toFixed(1)}%"></div>
297
+ </div>
298
+ <div class="perf-row" style="margin-top:4px"><span class="perf-label">Heap limit</span><span class="perf-value">${s.heapLimit}</span></div>
299
+ <div class="perf-row"><span class="perf-label">Throughput</span><span class="perf-value">${s.throughput}</span></div>
300
+ ${resultsHtml}
301
+ `);
302
+ }
303
+ }
304
+
305
+ customElements.define('perf-monitor', PerfMonitor);
features/upscaler/ui/upscale-preview.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * <upscale-preview> — canvas that shows live tile-by-tile upscale progress.
3
+ *
4
+ * Pure presentation component. The parent orchestrates engines and the
5
+ * upscale pipeline; this element just renders tiles as they arrive.
6
+ */
7
+
8
+ import { morph } from 'lib/morph';
9
+
10
+ class UpscalePreview extends HTMLElement {
11
+ #ctx = null;
12
+
13
+ connectedCallback() {
14
+ this.classList.add('upscale-preview');
15
+ this.#render();
16
+ }
17
+
18
+ /**
19
+ * Set up the canvas with a dimmed version of the source image,
20
+ * indicating that upscaling is about to begin.
21
+ */
22
+ showDimmedPreview(image, outW, outH) {
23
+ this.style.setProperty('--ar', `${outW} / ${outH}`);
24
+ this.style.setProperty('--ar-num', `${outW / outH}`);
25
+ this.style.setProperty('--natural-w', `${outW}px`);
26
+ this.#render();
27
+ this.style.display = 'block';
28
+
29
+ const canvas = this.querySelector('canvas');
30
+ canvas.width = outW;
31
+ canvas.height = outH;
32
+ const ctx = canvas.getContext('2d');
33
+ ctx.imageSmoothingEnabled = false;
34
+ ctx.drawImage(image, 0, 0, outW, outH);
35
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
36
+ ctx.fillRect(0, 0, outW, outH);
37
+ this.#ctx = ctx;
38
+ }
39
+
40
+ /**
41
+ * Draw a completed tile onto the preview canvas.
42
+ * Uses the overlap-cropped rect when available so each pixel is written
43
+ * exactly once per step (avoids double-blend artifacts at tile seams).
44
+ * @param {{ canvas: HTMLCanvasElement, outX: number, outY: number, outW: number, outH: number, crop?: {x:number,y:number,w:number,h:number} }} tileInfo
45
+ * @param {{ opacity?: number }} [opts]
46
+ */
47
+ drawTile(tileInfo, { opacity = 1 } = {}) {
48
+ if (!this.#ctx) return;
49
+ const { x, y, w, h } = tileInfo.crop ?? {
50
+ x: tileInfo.outX, y: tileInfo.outY, w: tileInfo.outW, h: tileInfo.outH,
51
+ };
52
+ const needsAlpha = opacity < 1;
53
+ if (needsAlpha) { this.#ctx.save(); this.#ctx.globalAlpha = opacity; }
54
+ this.#ctx.drawImage(tileInfo.canvas, x, y, w, h, x, y, w, h);
55
+ if (needsAlpha) this.#ctx.restore();
56
+ }
57
+
58
+ hide() {
59
+ this.style.display = 'none';
60
+ this.style.removeProperty('--ar');
61
+ this.style.removeProperty('--ar-num');
62
+ this.style.removeProperty('--natural-w');
63
+ const canvas = this.querySelector('canvas');
64
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
65
+ this.#ctx = null;
66
+ }
67
+
68
+ #render() {
69
+ morph(this, `
70
+ <style>
71
+ .upscale-preview { display: none; position: relative; }
72
+ .upscale-preview:not(.expanded) {
73
+ width: 100%;
74
+ max-width: 100%;
75
+ aspect-ratio: var(--ar, auto);
76
+ margin-inline: auto;
77
+ }
78
+ .upscale-preview.expanded {
79
+ height: calc(100vh - 1rem);
80
+ width: calc((100vh - 1rem) * var(--ar-num, 1));
81
+ max-width: none;
82
+ margin-inline: auto;
83
+ }
84
+ /* native-size: canvas at its natural pixel dimensions, centered in
85
+ a workspace-sized scroll container. Mirrors the compare-slider. */
86
+ .upscale-preview.native-size {
87
+ width: 100%;
88
+ max-width: 100%;
89
+ height: calc(100vh - 1rem);
90
+ max-height: calc(100vh - 1rem);
91
+ overflow: auto;
92
+ display: flex;
93
+ margin-inline: auto;
94
+ }
95
+ .upscale-preview.native-size canvas {
96
+ margin: auto;
97
+ width: auto;
98
+ height: auto;
99
+ max-width: none;
100
+ flex: 0 0 auto;
101
+ }
102
+ .upscale-preview canvas {
103
+ display: block;
104
+ width: 100%;
105
+ height: auto;
106
+ max-width: 100%;
107
+ border: 1px solid var(--pico-muted-border-color, #333);
108
+ border-radius: var(--pico-border-radius, 4px);
109
+ background: #000;
110
+ }
111
+ </style>
112
+ <canvas></canvas>
113
+ `);
114
+ }
115
+ }
116
+
117
+ customElements.define('upscale-preview', UpscalePreview);
features/upscaler/ui/upscaler-canvas-area.js ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'components/image-drop-zone';
2
+ import 'components/image-cropper';
3
+ import './upscale-preview.js';
4
+ import 'components/compare-slider';
5
+
6
+ class UpscalerCanvasArea extends HTMLElement {
7
+ #image = null;
8
+ #viewMode = 'fit-width';
9
+
10
+ connectedCallback() {
11
+ this.#render();
12
+ this.#wireEvents();
13
+ }
14
+
15
+ #q(sel) { return this.querySelector(sel); }
16
+
17
+ // ── Public surface ─────────────────────────────────────────────────────
18
+
19
+ get image() { return this.#image; }
20
+
21
+ get currentCrop() {
22
+ return this.#q('image-cropper')?.crop || null;
23
+ }
24
+
25
+ /**
26
+ * Extract the image (cropped or not) to feed the pipeline. Throws if no
27
+ * image is loaded.
28
+ */
29
+ get croppedImage() {
30
+ return this.#q('image-cropper').extractImage();
31
+ }
32
+
33
+ get viewMode() { return this.#viewMode; }
34
+ set viewMode(mode) {
35
+ if (!['fit-width', 'fit-height', 'one-to-one'].includes(mode)) return;
36
+ if (mode === this.#viewMode) return;
37
+ this.#viewMode = mode;
38
+ this.#applyViewState();
39
+ }
40
+
41
+ /**
42
+ * Pick a default mode based on the loaded image's aspect ratio vs. the
43
+ * viewport: fit-width when the image is at least as wide (relative to
44
+ * height) as the viewport, fit-height otherwise.
45
+ */
46
+ defaultModeForImage(image) {
47
+ const vw = window.innerWidth || 1;
48
+ const vh = window.innerHeight || 1;
49
+ const imgRatio = image.width / image.height;
50
+ const vpRatio = vw / vh;
51
+ return imgRatio >= vpRatio ? 'fit-width' : 'fit-height';
52
+ }
53
+
54
+ showInitial() {
55
+ this.#q('image-cropper').hide();
56
+ this.#q('upscale-preview').hide();
57
+ this.#q('compare-slider').hide();
58
+ this.#q('image-drop-zone').show();
59
+ }
60
+
61
+ showCropping(image) {
62
+ this.#image = image;
63
+ this.#q('upscale-preview').hide();
64
+ this.#q('compare-slider').hide();
65
+ this.#q('image-drop-zone').hide();
66
+ this.#q('image-cropper').show(image);
67
+ }
68
+
69
+ showPreview(image, outW, outH) {
70
+ this.#q('image-cropper').style.display = 'none';
71
+ this.#q('compare-slider').hide();
72
+ this.#q('upscale-preview').showDimmedPreview(image, outW, outH);
73
+ }
74
+
75
+ drawPreviewTile(info, opts) {
76
+ this.#q('upscale-preview').drawTile(info, opts);
77
+ }
78
+
79
+ async showResult(beforeCanvas, afterCanvas, opts) {
80
+ this.#q('image-cropper').style.display = 'none';
81
+ this.#q('upscale-preview').hide();
82
+ await this.#q('compare-slider').show(beforeCanvas, afterCanvas, opts);
83
+ this.#applyViewState();
84
+ }
85
+
86
+ clearCrop() { this.#q('image-cropper').clearCrop(); }
87
+ openInTab() { this.#q('compare-slider').openInTab(); }
88
+ download() { this.#q('compare-slider').download(); }
89
+
90
+ /**
91
+ * Scroll the currently-visible stage into view. Useful after a view-mode
92
+ * change resizes the stage and pushes it offscreen.
93
+ */
94
+ snapCenterVisible() {
95
+ const el = this.#visibleStage();
96
+ if (!el) return;
97
+ requestAnimationFrame(() => {
98
+ const rect = el.getBoundingClientRect();
99
+ const vh = window.innerHeight;
100
+ const fullyVisible = rect.top >= 0 && rect.bottom <= vh;
101
+ if (fullyVisible) return;
102
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
103
+ });
104
+ }
105
+
106
+ // ── Internal ───────────────────────────────────────────────────────────
107
+
108
+ #visibleStage() {
109
+ for (const sel of ['compare-slider', 'upscale-preview', 'image-cropper', 'image-drop-zone']) {
110
+ const el = this.#q(sel);
111
+ if (el && el.offsetParent !== null) return el;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ #applyViewState() {
117
+ const mode = this.#viewMode;
118
+ const isFitHeight = mode === 'fit-height';
119
+ const isOneToOne = mode === 'one-to-one';
120
+ for (const sel of ['image-cropper', 'upscale-preview', 'compare-slider']) {
121
+ const el = this.#q(sel);
122
+ if (!el) continue;
123
+ el.classList.toggle('expanded', isFitHeight);
124
+ el.classList.toggle('native-size', isOneToOne);
125
+ }
126
+ }
127
+
128
+ #wireEvents() {
129
+ // Hold the image reference locally; image-loaded also bubbles up so the
130
+ // orchestrator can react. crop-changed similarly bubbles from the cropper.
131
+ this.#q('image-drop-zone').addEventListener('image-loaded', (e) => {
132
+ this.#image = e.detail.image;
133
+ });
134
+ }
135
+
136
+ #render() {
137
+ this.innerHTML = `
138
+ <image-drop-zone></image-drop-zone>
139
+ <image-cropper></image-cropper>
140
+ <upscale-preview></upscale-preview>
141
+ <compare-slider></compare-slider>
142
+ `;
143
+ }
144
+ }
145
+
146
+ customElements.define('upscaler-canvas-area', UpscalerCanvasArea);
features/upscaler/ui/upscaler-controls.js ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { morph } from 'lib/morph';
2
+ import { modelOptionsHTML } from '../model-registry.js';
3
+ import {
4
+ deleteCustomModelByUrl,
5
+ getCustomModelByUrl,
6
+ getUploadCustomOptionHTML,
7
+ listCustomModels,
8
+ } from '../custom-models/custom-model-store.js';
9
+ import '../custom-models/custom-model-upload-dialog.js';
10
+
11
+ // User-facing backend selector returns 'gpu' or 'cpu' (the engine's intent
12
+ // vocabulary). Older builds stored 'webgpu' / 'wasm' in localStorage; this
13
+ // normalizer keeps a returning user from getting a broken dropdown.
14
+ function normalizeIntent(value) {
15
+ if (value === 'webgpu' || value === 'gpu') return 'gpu';
16
+ if (value === 'wasm' || value === 'cpu') return 'cpu';
17
+ return 'gpu';
18
+ }
19
+
20
+ function escapeHtml(value) {
21
+ return String(value)
22
+ .replace(/&/g, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;')
26
+ .replace(/'/g, '&#39;');
27
+ }
28
+
29
+ const UPLOAD_CUSTOM_VALUE = '__upload_custom__';
30
+
31
+ // Single source of truth for localStorage <-> form control wiring.
32
+ // `kind` is 'value' for inputs/selects, 'checked' for checkboxes.
33
+ const PERSISTED_CONTROLS = [
34
+ { selector: '.tilesize-select', key: 'upscaler_tilesize', kind: 'value', event: 'change' },
35
+ { selector: '.backend-select', key: 'upscaler_backend', kind: 'value', event: 'change' },
36
+ { selector: '.output-select', key: 'upscaler_output', kind: 'value', event: 'change' },
37
+ { selector: '.pass-all-enabled', key: 'upscaler_pass_all_enabled', kind: 'checked', event: 'change' },
38
+ { selector: '.pass-all-blend', key: 'upscaler_pass_all_blend', kind: 'value', event: 'input' },
39
+ { selector: '.pass-all-model', key: 'upscaler_pass_all_model', kind: 'value', event: 'change' },
40
+ { selector: '.pass-compare-enabled', key: 'upscaler_pass_compare_enabled', kind: 'checked', event: 'change' },
41
+ { selector: '.pass-compare-model', key: 'upscaler_pass_compare_model', kind: 'value', event: 'change' },
42
+ { selector: '.detector-face-enabled', key: 'upscaler_detector_face_enabled', kind: 'checked', event: 'change' },
43
+ { selector: '.detector-face-padding', key: 'upscaler_detector_face_padding_px', kind: 'value', event: 'input' },
44
+ { selector: '.detector-face-score', key: 'upscaler_detector_face_score', kind: 'value', event: 'input' },
45
+ { selector: '.detector-face-blend', key: 'upscaler_detector_face_blend', kind: 'value', event: 'input' },
46
+ { selector: '.detector-face-model', key: 'upscaler_detector_face_model', kind: 'value', event: 'change' },
47
+ ];
48
+
49
+ function readControl(el, kind) {
50
+ return kind === 'checked' ? (el.checked ? '1' : '0') : el.value;
51
+ }
52
+ function writeControl(el, kind, saved) {
53
+ if (kind === 'checked') el.checked = saved === '1';
54
+ else el.value = saved;
55
+ }
56
+
57
+ class UpscalerControls extends HTMLElement {
58
+ #customModels = [];
59
+ #previousModelValue = '';
60
+ #outputBaseLabels = null;
61
+ #isRunning = false;
62
+
63
+ connectedCallback() {
64
+ this.#render();
65
+ this.#customModels = listCustomModels();
66
+ this.#refreshModelSelectOptions(localStorage.getItem('upscaler_model') || undefined);
67
+ this.#setupPersistence();
68
+ this.#wireEvents();
69
+ this.#restoreSettings();
70
+ }
71
+
72
+ #q(sel) { return this.querySelector(sel); }
73
+ #isBuiltInResampler(modelOpt) { return !!modelOpt?.value?.startsWith('builtin:'); }
74
+
75
+ // ── Public surface ─────────────────────────────────────────────────────
76
+
77
+ get selectedModelOption() {
78
+ return this.#q('.model-select')?.selectedOptions?.[0] || null;
79
+ }
80
+
81
+ get outputScale() {
82
+ const parsed = parseInt(this.#q('.output-select')?.value, 10);
83
+ return Number.isFinite(parsed) ? parsed : 4;
84
+ }
85
+
86
+ // The user's load intent: 'gpu' or 'cpu'. Per-model-option overrides via
87
+ // data-backend take precedence over the global select. Legacy values
88
+ // ('webgpu', 'wasm') from prior localStorage are normalized so a returning
89
+ // user doesn't get a broken dropdown.
90
+ get backend() {
91
+ const raw = this.selectedModelOption?.dataset.backend
92
+ || this.#q('.backend-select')?.value
93
+ || 'gpu';
94
+ return normalizeIntent(raw);
95
+ }
96
+
97
+ set isRunning(b) {
98
+ this.#isRunning = !!b;
99
+ this.#q('.clear-cache-btn').disabled = this.#isRunning;
100
+ this.#updateCustomDeleteVisibility();
101
+ }
102
+
103
+ refreshAfterCustomModelChange(url) {
104
+ this.#customModels = listCustomModels();
105
+ this.#refreshModelSelectOptions(url);
106
+ if (url) {
107
+ this.#previousModelValue = url;
108
+ localStorage.setItem('upscaler_model', url);
109
+ }
110
+ this.#updateModelBoundControls();
111
+ this.#updateCustomDeleteVisibility();
112
+ }
113
+
114
+ /**
115
+ * Build the Pipeline config from current form state. Caller adds `profile`
116
+ * from the perf-monitor's visibility — the controls component doesn't see
117
+ * the perf monitor (lives at the orchestrator level).
118
+ */
119
+ get config() {
120
+ const opt = this.#q('.model-select').selectedOptions[0];
121
+ const modelUrl = opt.value;
122
+ const scale = parseInt(opt.dataset.scale, 10) || 4;
123
+ const modelValueRange = parseInt(opt.dataset.range, 10) || 1;
124
+ const modelLayout = opt.dataset.layout || 'nchw';
125
+ const modelInputMultiple = parseInt(opt.dataset.multipleof, 10) || 1;
126
+ const modelPrecision = opt.dataset.precision === 'fp16' ? 'fp16' : 'fp32';
127
+ const upscaleBefore = opt.dataset.upscalebefore === 'true';
128
+ const tileBlend = opt.dataset.tileblend === 'gaussian' ? 'gaussian' : 'overlapCrop';
129
+ const backend = opt.dataset.backend || this.#q('.backend-select').value;
130
+ let tileSize = parseInt(this.#q('.tilesize-select').value, 10);
131
+
132
+ const config = { modelUrl, scale, modelValueRange, modelLayout, modelInputMultiple, modelPrecision, upscaleBefore, tileBlend, backend, tileSize };
133
+
134
+ const optionsForClamp = [opt];
135
+
136
+ // Comparison runs the base + a second SR pass and shows them side-by-
137
+ // side; All/Faces would mutate the base canvas the slider is supposed to
138
+ // expose, so we suppress them entirely whenever Comparison is on. The UI
139
+ // already disables those rows — this is the matching defensive guard at
140
+ // the config layer.
141
+ const compareOn = this.#q('.pass-compare-enabled').checked;
142
+
143
+ if (compareOn) {
144
+ const copt = this.#q('.pass-compare-model').selectedOptions[0];
145
+ if (copt) optionsForClamp.push(copt);
146
+ config.comparison = {
147
+ modelUrl: copt?.value || modelUrl,
148
+ scale: parseInt(copt?.dataset.scale, 10) || scale,
149
+ modelValueRange: parseInt(copt?.dataset.range, 10) || 1,
150
+ modelLayout: copt?.dataset.layout || 'nchw',
151
+ modelInputMultiple: parseInt(copt?.dataset.multipleof, 10) || 1,
152
+ modelPrecision: copt?.dataset.precision === 'fp16' ? 'fp16' : 'fp32',
153
+ upscaleBefore: copt?.dataset.upscalebefore === 'true',
154
+ tileBlend: copt?.dataset.tileblend === 'gaussian' ? 'gaussian' : 'overlapCrop',
155
+ backend: copt?.dataset.backend || backend,
156
+ };
157
+ } else {
158
+ if (this.#q('.pass-all-enabled').checked) {
159
+ const aopt = this.#q('.pass-all-model').selectedOptions[0];
160
+ if (aopt) optionsForClamp.push(aopt);
161
+ config.all = {
162
+ modelUrl: aopt?.value || modelUrl,
163
+ scale: parseInt(aopt?.dataset.scale, 10) || scale,
164
+ modelValueRange: parseInt(aopt?.dataset.range, 10) || 1,
165
+ modelLayout: aopt?.dataset.layout || 'nchw',
166
+ modelInputMultiple: parseInt(aopt?.dataset.multipleof, 10) || 1,
167
+ modelPrecision: aopt?.dataset.precision === 'fp16' ? 'fp16' : 'fp32',
168
+ upscaleBefore: aopt?.dataset.upscalebefore === 'true',
169
+ tileBlend: aopt?.dataset.tileblend === 'gaussian' ? 'gaussian' : 'overlapCrop',
170
+ backend: aopt?.dataset.backend || backend,
171
+ blendOpacity: parseFloat(this.#q('.pass-all-blend').value),
172
+ };
173
+ }
174
+
175
+ if (this.#q('.detector-face-enabled').checked) {
176
+ const fopt = this.#q('.detector-face-model').selectedOptions[0];
177
+ if (fopt) optionsForClamp.push(fopt);
178
+ config.face = {
179
+ modelUrl: fopt?.value || modelUrl,
180
+ scale: parseInt(fopt?.dataset.scale, 10) || scale,
181
+ modelValueRange: parseInt(fopt?.dataset.range, 10) || 1,
182
+ modelLayout: fopt?.dataset.layout || 'nchw',
183
+ modelInputMultiple: parseInt(fopt?.dataset.multipleof, 10) || 1,
184
+ modelPrecision: fopt?.dataset.precision === 'fp16' ? 'fp16' : 'fp32',
185
+ upscaleBefore: fopt?.dataset.upscalebefore === 'true',
186
+ tileBlend: fopt?.dataset.tileblend === 'gaussian' ? 'gaussian' : 'overlapCrop',
187
+ backend: fopt?.dataset.backend || backend,
188
+ paddingPx: parseInt(this.#q('.detector-face-padding').value, 10) || 0,
189
+ featherPx: 16,
190
+ blendOpacity: parseFloat(this.#q('.detector-face-blend').value),
191
+ scoreThreshold: parseFloat(this.#q('.detector-face-score').value),
192
+ };
193
+ }
194
+ }
195
+
196
+ // Models with a hard input-size cap (e.g. DAT exports with baked-in
197
+ // window counts) only accept tiles up to a fixed dim. Clamp the shared
198
+ // tile size to the strictest selected model. When multipleOf ===
199
+ // maxTileSize the model has a fixed input size: smaller tiles still
200
+ // work via edge-replication padding but pay the same per-tile cost over
201
+ // a smaller real region, so floor to the fixed size too.
202
+ const fixedSizes = optionsForClamp
203
+ .map((o) => {
204
+ const m = parseInt(o?.dataset?.multipleof, 10);
205
+ const c = parseInt(o?.dataset?.maxtilesize, 10);
206
+ return (Number.isFinite(m) && Number.isFinite(c) && m === c && m >= 1) ? c : null;
207
+ })
208
+ .filter((v) => v != null);
209
+ if (fixedSizes.length) {
210
+ const floor = Math.max(...fixedSizes);
211
+ if (tileSize > 0 && tileSize < floor) tileSize = floor;
212
+ }
213
+ const caps = optionsForClamp
214
+ .map((o) => parseInt(o?.dataset?.maxtilesize, 10))
215
+ .filter((v) => Number.isFinite(v) && v >= 1);
216
+ if (caps.length) {
217
+ const minCap = Math.min(...caps);
218
+ if (tileSize <= 0 || tileSize > minCap) tileSize = minCap;
219
+ config.tileSize = tileSize;
220
+ }
221
+
222
+ return config;
223
+ }
224
+
225
+ // ── Custom model select rendering ──────────────────────────────────────
226
+
227
+ #getCustomModelOptionsHTML(selected) {
228
+ if (!this.#customModels.length) return '';
229
+ return this.#customModels.map((model) => {
230
+ const attrs = [
231
+ `value="${model.url}"`,
232
+ `data-scale="${model.scale}"`,
233
+ `data-range="${model.range || 1}"`,
234
+ `data-layout="${model.layout || 'nchw'}"`,
235
+ `data-multipleof="${model.multipleOf || 1}"`,
236
+ `data-sizemb="${model.sizeMB}"`,
237
+ ];
238
+ if (Number.isFinite(model.maxTileSize)) {
239
+ attrs.push(`data-maxtilesize="${model.maxTileSize}"`);
240
+ }
241
+ if (model.precision === 'fp16') attrs.push(`data-precision="fp16"`);
242
+ if (model.url === selected) attrs.push('selected');
243
+ const sizeStr = model.sizeMB != null ? ` (~${model.sizeMB}MB)` : '';
244
+ return `<option ${attrs.join(' ')}>${escapeHtml(model.label)}${sizeStr}</option>`;
245
+ }).join('\n ');
246
+ }
247
+
248
+ #refreshModelSelectOptions(selected) {
249
+ const modelEl = this.#q('.model-select');
250
+ if (!modelEl) return;
251
+ modelEl.innerHTML = [
252
+ modelOptionsHTML(undefined, { selected, includeResamplers: true }),
253
+ this.#getCustomModelOptionsHTML(selected),
254
+ getUploadCustomOptionHTML(),
255
+ ].filter(Boolean).join('\n ');
256
+ if (selected) modelEl.value = selected;
257
+ if (!modelEl.selectedOptions.length) modelEl.selectedIndex = 0;
258
+
259
+ // Pass selectors share the same custom-model list as the primary select.
260
+ // We only refresh their option lists — never their selection — so adding
261
+ // or editing a custom model from the main select doesn't disturb whatever
262
+ // the user has chosen for the all-pass / face-pass.
263
+ this.#refreshPassModelSelect('.pass-all-model');
264
+ this.#refreshPassModelSelect('.pass-compare-model');
265
+ this.#refreshPassModelSelect('.detector-face-model');
266
+ }
267
+
268
+ #refreshPassModelSelect(selector) {
269
+ const el = this.#q(selector);
270
+ if (!el) return;
271
+ const previousValue = el.value;
272
+ el.innerHTML = [
273
+ modelOptionsHTML(undefined, { selected: previousValue }),
274
+ this.#getCustomModelOptionsHTML(previousValue),
275
+ ].filter(Boolean).join('\n ');
276
+ if (previousValue) {
277
+ el.value = previousValue;
278
+ if (!el.selectedOptions.length) el.selectedIndex = 0;
279
+ } else if (!el.selectedOptions.length) {
280
+ el.selectedIndex = 0;
281
+ }
282
+ }
283
+
284
+ // ── Persistence ────────────────────────────────────────────────────────
285
+
286
+ #setupPersistence() {
287
+ for (const { selector, key, kind, event } of PERSISTED_CONTROLS) {
288
+ const el = this.#q(selector);
289
+ el.addEventListener(event, () => localStorage.setItem(key, readControl(el, kind)));
290
+ }
291
+ }
292
+
293
+ #restoreSettings() {
294
+ for (const { selector, key, kind } of PERSISTED_CONTROLS) {
295
+ const saved = localStorage.getItem(key);
296
+ if (saved === null) continue;
297
+ writeControl(this.#q(selector), kind, saved);
298
+ }
299
+ const modelEl = this.#q('.model-select');
300
+ if (!modelEl.selectedOptions.length) modelEl.selectedIndex = 0;
301
+ this.#previousModelValue = modelEl.value;
302
+ this.#syncComparisonExclusion();
303
+ this.#updateModelBoundControls();
304
+ this.#updateInputMirrors();
305
+ this.#updateCustomDeleteVisibility();
306
+ }
307
+
308
+ // ── Event wiring ───────────────────────────────────────────────────────
309
+
310
+ #wireEvents() {
311
+ const modelEl = this.#q('.model-select');
312
+ const editCustomBtn = this.#q('.edit-custom-model-btn');
313
+ const deleteCustomBtn = this.#q('.delete-custom-model-btn');
314
+ const dialog = this.#q('custom-model-upload-dialog');
315
+
316
+ modelEl.addEventListener('change', async () => {
317
+ if (modelEl.value === UPLOAD_CUSTOM_VALUE) {
318
+ const previousOption = Array.from(modelEl.options).find((opt) => opt.value === this.#previousModelValue);
319
+ const defaultScale = parseInt(previousOption?.dataset.scale, 10) || 4;
320
+ const customModel = await dialog.open({ defaultScale });
321
+ if (!customModel) {
322
+ modelEl.value = this.#previousModelValue;
323
+ } else {
324
+ this.refreshAfterCustomModelChange(customModel.url);
325
+ this.#emitStatus('Model added', `Added "${customModel.label}" (${customModel.scale}x, ~${customModel.sizeMB}MB).`);
326
+ return;
327
+ }
328
+ } else {
329
+ this.#previousModelValue = modelEl.value;
330
+ }
331
+ localStorage.setItem('upscaler_model', modelEl.value);
332
+ this.#updateModelBoundControls();
333
+ this.#updateCustomDeleteVisibility();
334
+ });
335
+
336
+ editCustomBtn.addEventListener('click', async () => {
337
+ if (this.#isRunning) return;
338
+ const selected = getCustomModelByUrl(modelEl.value);
339
+ if (!selected) return;
340
+ const updated = await dialog.open({ editModel: selected });
341
+ if (!updated) return;
342
+ this.refreshAfterCustomModelChange(updated.url);
343
+ this.#emitStatus('Model updated', `Updated "${updated.label}".`);
344
+ });
345
+
346
+ deleteCustomBtn.addEventListener('click', async () => {
347
+ if (this.#isRunning) return;
348
+ const selected = getCustomModelByUrl(modelEl.value);
349
+ if (!selected) return;
350
+ const ok = globalThis.confirm(`Delete custom model "${selected.label}"?\n\nThis will remove it from the local model cache.`);
351
+ if (!ok) return;
352
+ await deleteCustomModelByUrl(selected.url);
353
+ this.#customModels = listCustomModels();
354
+ this.#refreshModelSelectOptions();
355
+ if (modelEl.value === UPLOAD_CUSTOM_VALUE && modelEl.options.length > 1) {
356
+ modelEl.selectedIndex = 0;
357
+ }
358
+ this.#previousModelValue = modelEl.value;
359
+ localStorage.setItem('upscaler_model', modelEl.value);
360
+ this.#updateModelBoundControls();
361
+ this.#updateCustomDeleteVisibility();
362
+ this.#emitStatus('Model deleted', `Deleted "${selected.label}".`);
363
+ });
364
+
365
+ this.#q('.tilesize-select').addEventListener('change', () => this.#updateHangWarning());
366
+ this.#q('.backend-select').addEventListener('change', () => this.#updateHangWarning());
367
+
368
+ // Toggling a pass on/off or switching its model can change the strictest
369
+ // selected max-tile cap, so refresh the tile-size dropdown the same way
370
+ // a primary-model change does.
371
+ for (const sel of [
372
+ '.pass-all-enabled',
373
+ '.pass-all-model',
374
+ '.pass-compare-enabled',
375
+ '.pass-compare-model',
376
+ '.detector-face-enabled',
377
+ '.detector-face-model',
378
+ ]) {
379
+ this.#q(sel)?.addEventListener('change', () => this.#updateModelBoundControls());
380
+ }
381
+
382
+ this.#q('.pass-compare-enabled').addEventListener('change', () => this.#syncComparisonExclusion());
383
+
384
+ const wireMirror = (selector, mirrorSelector) => {
385
+ this.#q(selector).addEventListener('input', (e) => {
386
+ this.#q(mirrorSelector).textContent = e.target.value;
387
+ });
388
+ };
389
+ wireMirror('.pass-all-blend', '.pass-all-blend-val');
390
+ wireMirror('.detector-face-score', '.detector-face-score-val');
391
+ wireMirror('.detector-face-blend', '.detector-face-blend-val');
392
+
393
+ const bubble = (name) => this.dispatchEvent(new CustomEvent(name, { bubbles: true }));
394
+ this.#q('.perf-toggle-btn').addEventListener('click', () => bubble('perf-toggle'));
395
+ this.#q('.clear-cache-btn').addEventListener('click', () => bubble('clear-cache'));
396
+ }
397
+
398
+ #emitStatus(title, details) {
399
+ this.dispatchEvent(new CustomEvent('status-message', {
400
+ bubbles: true,
401
+ detail: {
402
+ title,
403
+ state: 'idle',
404
+ details: details || '',
405
+ progress: -1,
406
+ tileCount: null,
407
+ },
408
+ }));
409
+ }
410
+
411
+ #emitModelChange() {
412
+ const opt = this.selectedModelOption;
413
+ const scale = parseInt(opt?.dataset.scale, 10) || 4;
414
+ const verb = scale === 1 ? 'Enhance' : 'Upscale';
415
+ this.dispatchEvent(new CustomEvent('model-change', {
416
+ bubbles: true, detail: { scale, verb, isBuiltInResampler: this.#isBuiltInResampler(opt) },
417
+ }));
418
+ }
419
+
420
+ // ── Model-bound UI sync ────────────────────────────────────────────────
421
+
422
+ #updateModelBoundControls() {
423
+ const modelEl = this.#q('.model-select');
424
+ const outputEl = this.#q('.output-select');
425
+ const tileEl = this.#q('.tilesize-select');
426
+
427
+ if (!this.#outputBaseLabels) {
428
+ this.#outputBaseLabels = new Map(Array.from(outputEl.options).map(opt => [
429
+ opt.value,
430
+ opt.textContent.replace(/\s+\(no downscale\)$/i, ''),
431
+ ]));
432
+ }
433
+
434
+ const modelOpt = modelEl.selectedOptions[0];
435
+ const scale = parseInt(modelOpt?.dataset.scale, 10) || 4;
436
+ const maxOutputScale = Math.max(1, Math.min(scale, 4));
437
+ const isBuiltInResampler = this.#isBuiltInResampler(modelOpt);
438
+ const previousOutputScale = parseInt(outputEl.value, 10);
439
+
440
+ for (const opt of outputEl.options) {
441
+ const optionScale = parseInt(opt.value, 10) || 1;
442
+ const baseLabel = this.#outputBaseLabels.get(opt.value) || `${optionScale}x`;
443
+ opt.textContent = !isBuiltInResampler && optionScale === maxOutputScale
444
+ ? `${baseLabel} (no downscale)`
445
+ : baseLabel;
446
+ opt.disabled = optionScale > maxOutputScale;
447
+ }
448
+
449
+ const preferredScale = Number.isFinite(previousOutputScale) ? previousOutputScale : maxOutputScale;
450
+ const nextOutputScale = Math.max(1, Math.min(maxOutputScale, preferredScale));
451
+ outputEl.value = String(nextOutputScale);
452
+ localStorage.setItem('upscaler_output', outputEl.value);
453
+ this.#q('.backend-select').disabled = isBuiltInResampler;
454
+ tileEl.disabled = isBuiltInResampler;
455
+
456
+ // Strictest tile-size cap from the main model plus enabled pass models.
457
+ const capCandidates = [modelOpt];
458
+ if (this.#q('.pass-all-enabled')?.checked) {
459
+ capCandidates.push(this.#q('.pass-all-model')?.selectedOptions[0]);
460
+ }
461
+ if (this.#q('.pass-compare-enabled')?.checked) {
462
+ capCandidates.push(this.#q('.pass-compare-model')?.selectedOptions[0]);
463
+ }
464
+ if (this.#q('.detector-face-enabled')?.checked) {
465
+ capCandidates.push(this.#q('.detector-face-model')?.selectedOptions[0]);
466
+ }
467
+ const caps = capCandidates
468
+ .map((o) => parseInt(o?.dataset?.maxtilesize, 10))
469
+ .filter((v) => Number.isFinite(v) && v >= 1);
470
+ const hasMaxTile = caps.length > 0;
471
+ const maxTileSize = hasMaxTile ? Math.min(...caps) : Infinity;
472
+ const fixedSizes = capCandidates
473
+ .map((o) => {
474
+ const m = parseInt(o?.dataset?.multipleof, 10);
475
+ const c = parseInt(o?.dataset?.maxtilesize, 10);
476
+ return (Number.isFinite(m) && Number.isFinite(c) && m === c && m >= 1) ? c : null;
477
+ })
478
+ .filter((v) => v != null);
479
+ const tileFloor = fixedSizes.length ? Math.max(...fixedSizes) : 0;
480
+ let largestEnabledTileVal = null;
481
+ for (const opt of tileEl.options) {
482
+ const optVal = parseInt(opt.value, 10);
483
+ const isFullImage = optVal === 0;
484
+ const exceeds = hasMaxTile && (isFullImage || optVal > maxTileSize);
485
+ const belowFloor = tileFloor > 0 && !isFullImage && optVal < tileFloor;
486
+ opt.disabled = exceeds || belowFloor;
487
+ if (!opt.disabled && Number.isFinite(optVal) && optVal > 0) {
488
+ if (largestEnabledTileVal === null || optVal > largestEnabledTileVal) {
489
+ largestEnabledTileVal = optVal;
490
+ }
491
+ }
492
+ }
493
+ if ((hasMaxTile || tileFloor > 0) && tileEl.selectedOptions[0]?.disabled && largestEnabledTileVal != null) {
494
+ tileEl.value = String(largestEnabledTileVal);
495
+ localStorage.setItem('upscaler_tilesize', tileEl.value);
496
+ }
497
+
498
+ this.#updateHangWarning();
499
+ this.#emitModelChange();
500
+ }
501
+
502
+ #updateCustomDeleteVisibility() {
503
+ const modelEl = this.#q('.model-select');
504
+ const editCustomBtn = this.#q('.edit-custom-model-btn');
505
+ const deleteCustomBtn = this.#q('.delete-custom-model-btn');
506
+ const selected = getCustomModelByUrl(modelEl.value);
507
+ deleteCustomBtn.hidden = !selected;
508
+ deleteCustomBtn.disabled = !selected || this.#isRunning;
509
+ deleteCustomBtn.title = selected ? `Delete custom model "${selected.label}"` : 'Delete selected custom model';
510
+ editCustomBtn.hidden = !selected;
511
+ editCustomBtn.disabled = !selected || this.#isRunning;
512
+ editCustomBtn.title = selected ? `Edit custom model "${selected.label}"` : 'Edit selected custom model';
513
+ }
514
+
515
+ #updateInputMirrors() {
516
+ this.#q('.pass-all-blend-val').textContent = this.#q('.pass-all-blend').value;
517
+ this.#q('.detector-face-score-val').textContent = this.#q('.detector-face-score').value;
518
+ this.#q('.detector-face-blend-val').textContent = this.#q('.detector-face-blend').value;
519
+ }
520
+
521
+ // When Comparison is on, the All/Faces passes are mutually exclusive — they
522
+ // would muddy what the slider is showing. Disable their controls and dim
523
+ // the rows, but leave the underlying values untouched so toggling
524
+ // Comparison off restores the user's prior pass setup verbatim.
525
+ #syncComparisonExclusion() {
526
+ const compareOn = !!this.#q('.pass-compare-enabled')?.checked;
527
+ const otherRows = this.querySelectorAll('.detector-row:not(.pass-compare-row)');
528
+ for (const row of otherRows) {
529
+ row.classList.toggle('passes-disabled', compareOn);
530
+ for (const ctrl of row.querySelectorAll('input, select')) {
531
+ ctrl.disabled = compareOn;
532
+ }
533
+ }
534
+ }
535
+
536
+ #updateHangWarning() {
537
+ const warnEl = this.#q('.hang-warn');
538
+ const tipEl = this.#q('.hang-warn-tip');
539
+ const modelOpt = this.#q('.model-select').selectedOptions[0];
540
+ if (this.#isBuiltInResampler(modelOpt)) {
541
+ warnEl.classList.remove('visible');
542
+ return;
543
+ }
544
+ const sizeMB = parseFloat(modelOpt?.dataset.sizemb) || 0;
545
+ const tileSize = parseInt(this.#q('.tilesize-select').value, 10);
546
+ const isLargeOnBigTile = sizeMB > 10 && tileSize > 128;
547
+
548
+ // fp16 inference effectively requires GPU — ORT-Web's WASM EP has very
549
+ // limited fp16 op coverage so most fp16 models throw a kernel-not-found
550
+ // or input-dtype error. Surface this before the user runs.
551
+ const backend = normalizeIntent(modelOpt?.dataset.backend || this.#q('.backend-select').value);
552
+ const isFp16OnCpu = modelOpt?.dataset.precision === 'fp16' && backend !== 'gpu';
553
+
554
+ const show = isLargeOnBigTile || isFp16OnCpu;
555
+ warnEl.classList.toggle('visible', show);
556
+ if (!show) return;
557
+
558
+ const messages = [];
559
+ if (isFp16OnCpu) {
560
+ messages.push(
561
+ `<strong>fp16 model on CPU backend:</strong> this model uses 16-bit precision, which ONNX Runtime's CPU/WASM backend has very limited support for. Inference will almost certainly fail with an "unexpected input data type" or "kernel not found" error. Switch <em>Backend</em> to <em>GPU</em>.`,
562
+ );
563
+ }
564
+ if (isLargeOnBigTile) {
565
+ messages.push(
566
+ `<strong>Large model with big tiles:</strong> models &gt;10 MB combined with tile sizes above 128 can block the browser's main thread for extended periods, causing the UI to freeze. You may not be able to click Stop until the current tile finishes. Consider reducing the tile size or using a smaller model.`,
567
+ );
568
+ }
569
+ tipEl.innerHTML = messages.join('<br><br>');
570
+ }
571
+
572
+ // ── Template ───────────────────────────────────────────────────────────
573
+
574
+ #render() {
575
+ morph(this, `
576
+ <style>
577
+ upscaler-controls .controls {
578
+ display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem;
579
+ align-items: center; margin-bottom: 1rem;
580
+ }
581
+ upscaler-controls .controls label {
582
+ display: inline-flex; align-items: center; gap: 0.35rem;
583
+ font-size: 0.85rem; margin-bottom: 0; white-space: nowrap;
584
+ }
585
+ upscaler-controls .controls select,
586
+ upscaler-controls .controls input {
587
+ margin-bottom: 0; padding: 0.3rem 0.5rem;
588
+ font-size: 0.85rem; width: auto;
589
+ }
590
+ upscaler-controls select:not([multiple], [size]) {
591
+ max-width: 100%;
592
+ padding-left: 0.7rem;
593
+ padding-right: 2.25rem;
594
+ padding-inline-start: 0.7rem;
595
+ padding-inline-end: 2.25rem;
596
+ background-position: center right 0.7rem;
597
+ overflow: hidden;
598
+ white-space: nowrap;
599
+ }
600
+ upscaler-controls select.model-select,
601
+ upscaler-controls select.pass-all-model,
602
+ upscaler-controls select.pass-compare-model,
603
+ upscaler-controls select.detector-face-model {
604
+ width: min(100%, 25em);
605
+ max-width: 25em;
606
+ text-overflow: ellipsis;
607
+ }
608
+ upscaler-controls select.output-select {
609
+ width: min(100%, calc(2ch + 0.7rem + 2.25rem));
610
+ max-width: calc(2ch + 0.7rem + 2.25rem);
611
+ }
612
+ upscaler-controls select.tilesize-select {
613
+ width: min(100%, calc(3ch + 0.7rem + 2.25rem));
614
+ max-width: calc(3ch + 0.7rem + 2.25rem);
615
+ }
616
+ upscaler-controls select.backend-select {
617
+ width: min(100%, calc(4ch + 0.7rem + 2.25rem));
618
+ max-width: calc(4ch + 0.7rem + 2.25rem);
619
+ }
620
+ upscaler-controls .delete-custom-model-btn,
621
+ upscaler-controls .edit-custom-model-btn {
622
+ padding: 0.3rem 0.55rem;
623
+ min-width: 2rem;
624
+ }
625
+ upscaler-controls .controls button {
626
+ margin-bottom: 0; padding: 0.4rem 0.8rem;
627
+ font-size: 0.85rem; width: auto;
628
+ }
629
+ upscaler-controls .local-controls {
630
+ display: inline-flex; flex-wrap: wrap; gap: 0.4rem 0.75rem;
631
+ align-items: center;
632
+ }
633
+ upscaler-controls .passes-panel {
634
+ margin-bottom: 1rem;
635
+ padding: 0.6rem 0.7rem;
636
+ border: 1px solid var(--pico-muted-border-color);
637
+ border-radius: var(--pico-border-radius);
638
+ }
639
+ upscaler-controls .passes-panel > summary {
640
+ cursor: pointer;
641
+ font-size: 0.9rem;
642
+ user-select: none;
643
+ margin-bottom: 0;
644
+ padding: 0.15rem 0;
645
+ }
646
+ upscaler-controls .detector-row {
647
+ display: grid;
648
+ grid-template-columns:
649
+ minmax(11rem, 1fr)
650
+ minmax(17rem, 2.2fr)
651
+ minmax(13rem, 1.2fr)
652
+ minmax(14rem, 1.35fr);
653
+ gap: 0.35rem 0.55rem;
654
+ align-items: center;
655
+ width: 100%;
656
+ max-width: none;
657
+ margin-top: 0.45rem;
658
+ }
659
+ upscaler-controls .detector-row:first-of-type {
660
+ margin-top: 0;
661
+ }
662
+ upscaler-controls .detector-row.passes-disabled {
663
+ opacity: 0.5;
664
+ }
665
+ upscaler-controls .detector-row label {
666
+ margin-bottom: 0;
667
+ display: grid;
668
+ grid-template-columns: auto minmax(0, 1fr);
669
+ align-items: center;
670
+ column-gap: 0.35rem;
671
+ font-size: 0.85rem;
672
+ min-height: 2rem;
673
+ width: 100%;
674
+ }
675
+ upscaler-controls .detector-row .check-control {
676
+ display: inline-flex;
677
+ align-items: center;
678
+ gap: 0.35rem;
679
+ width: auto;
680
+ }
681
+ upscaler-controls .detector-row .range-control {
682
+ grid-template-columns: auto auto;
683
+ }
684
+ upscaler-controls .detector-row .range-field {
685
+ display: inline-grid;
686
+ grid-auto-flow: column;
687
+ align-items: center;
688
+ column-gap: 0.3rem;
689
+ }
690
+ upscaler-controls .detector-row .range-input {
691
+ width: 7rem;
692
+ vertical-align: middle;
693
+ }
694
+ upscaler-controls .detector-row .range-value {
695
+ min-width: 4ch;
696
+ font-variant-numeric: tabular-nums;
697
+ }
698
+ upscaler-controls .detector-row input,
699
+ upscaler-controls .detector-row select {
700
+ margin-bottom: 0;
701
+ }
702
+ upscaler-controls .detector-row input[type="checkbox"] {
703
+ margin-top: 0;
704
+ }
705
+ upscaler-controls .detector-row .model-control select {
706
+ width: 100%;
707
+ min-width: 0;
708
+ }
709
+ upscaler-controls .detector-row .model-control {
710
+ grid-template-columns: minmax(0, 1fr);
711
+ }
712
+ @media (max-width: 980px) {
713
+ upscaler-controls .detector-row {
714
+ grid-template-columns: 1fr;
715
+ }
716
+ }
717
+ upscaler-controls .hang-warn {
718
+ display: none;
719
+ position: relative;
720
+ color: var(--pico-del-color, #c62828);
721
+ font-size: 1rem;
722
+ cursor: help;
723
+ align-self: center;
724
+ }
725
+ upscaler-controls .hang-warn.visible {
726
+ display: inline-flex;
727
+ }
728
+ upscaler-controls .hang-warn .hang-warn-tip {
729
+ display: none;
730
+ position: absolute;
731
+ bottom: calc(100% + 0.45rem);
732
+ left: 50%;
733
+ transform: translateX(-50%);
734
+ background: var(--pico-card-background-color, #1e1e2e);
735
+ color: var(--pico-color, #cdd6f4);
736
+ border: 1px solid var(--pico-muted-border-color);
737
+ border-radius: var(--pico-border-radius);
738
+ padding: 0.5rem 0.65rem;
739
+ font-size: 0.78rem;
740
+ line-height: 1.4;
741
+ white-space: normal;
742
+ width: max-content;
743
+ max-width: 26rem;
744
+ z-index: 10;
745
+ pointer-events: none;
746
+ box-shadow: 0 2px 8px rgba(0,0,0,.25);
747
+ }
748
+ upscaler-controls .hang-warn:hover .hang-warn-tip {
749
+ display: block;
750
+ }
751
+ </style>
752
+
753
+ <div class="controls">
754
+ <span class="local-controls">
755
+ <label>Model:
756
+ <select class="model-select">
757
+ ${modelOptionsHTML(undefined, { includeResamplers: true })}
758
+ ${getUploadCustomOptionHTML()}
759
+ </select>
760
+ </label>
761
+ <button class="secondary outline edit-custom-model-btn" type="button" hidden title="Edit selected custom model" aria-label="Edit selected custom model">
762
+ <i class="fas fa-pen"></i>
763
+ </button>
764
+ <button class="secondary outline delete-custom-model-btn" type="button" hidden title="Delete selected custom model" aria-label="Delete selected custom model">
765
+ <i class="fas fa-trash"></i>
766
+ </button>
767
+ <label>Backend:
768
+ <select class="backend-select">
769
+ <option value="gpu">GPU</option>
770
+ <option value="cpu">CPU</option>
771
+ </select>
772
+ </label>
773
+ <label>Tile size:
774
+ <select class="tilesize-select">
775
+ <option value="64">64</option>
776
+ <option value="80">80</option>
777
+ <option value="128">128</option>
778
+ <option value="192" selected>192</option>
779
+ <option value="256">256</option>
780
+ <option value="384">384</option>
781
+ <option value="512">512</option>
782
+ <option value="0">Full image (no tiling)</option>
783
+ </select>
784
+ </label>
785
+ <label>Final Output:
786
+ <select class="output-select">
787
+ <option value="1">1x</option>
788
+ <option value="2">2x</option>
789
+ <option value="3">3x</option>
790
+ <option value="4" selected>4x (no downscale)</option>
791
+ </select>
792
+ </label>
793
+ </span>
794
+
795
+ <button class="perf-toggle-btn secondary outline" title="Toggle performance monitor">
796
+ <i class="fas fa-gauge-high"></i>
797
+ </button>
798
+ <span class="hang-warn" aria-label="Performance warning">
799
+ <i class="fas fa-triangle-exclamation"></i>
800
+ <span class="hang-warn-tip"></span>
801
+ </span>
802
+ <button class="clear-cache-btn secondary outline" hidden title="Clear cached ONNX models (frees memory)">
803
+ <i class="fas fa-broom"></i> Clear Cache
804
+ </button>
805
+ </div>
806
+
807
+ <details class="passes-panel">
808
+ <summary><i class="fas fa-user-check"></i> Additional Passes</summary>
809
+ <div class="detector-row pass-compare-row">
810
+ <label class="check-control">
811
+ <input class="pass-compare-enabled" type="checkbox">
812
+ Comparison
813
+ </label>
814
+ <label class="model-control">
815
+ <select class="pass-compare-model" aria-label="Comparison pass model">
816
+ ${modelOptionsHTML()}
817
+ </select>
818
+ </label>
819
+ </div>
820
+ <div class="detector-row pass-all-row">
821
+ <label class="check-control">
822
+ <input class="pass-all-enabled" type="checkbox">
823
+ All (full image blend)
824
+ </label>
825
+ <label class="model-control">
826
+ <select class="pass-all-model" aria-label="All pass model">
827
+ ${modelOptionsHTML()}
828
+ </select>
829
+ </label>
830
+ <label class="range-control" title="Blend opacity of the secondary full-image pass over the base upscale">
831
+ Blend:
832
+ <span class="range-field">
833
+ <input class="pass-all-blend range-input" type="range" min="0" max="1" step="0.05" value="0.40">
834
+ <span class="pass-all-blend-val range-value">0.40</span>
835
+ </span>
836
+ </label>
837
+ </div>
838
+ <div class="detector-row pass-faces-row">
839
+ <label class="check-control">
840
+ <input class="detector-face-enabled" type="checkbox">
841
+ Faces (YuNet)
842
+ </label>
843
+ <label class="model-control">
844
+ <select class="detector-face-model" aria-label="Face pass model">
845
+ ${modelOptionsHTML(undefined, { selected: 'models/RMBN_M4C8_FACES_x4.onnx' })}
846
+ </select>
847
+ </label>
848
+ <label class="range-control" title="Blend opacity of the face patch over the base upscale (1 = full replace, lower = transparent blend)">
849
+ Blend:
850
+ <span class="range-field">
851
+ <input class="detector-face-blend range-input" type="range" min="0" max="1" step="0.05" value="0.65">
852
+ <span class="detector-face-blend-val range-value">0.65</span>
853
+ </span>
854
+ </label>
855
+ <label hidden>Padding:
856
+ <input class="detector-face-padding" type="number" min="0" max="512" step="1" value="20" style="width:7ch">
857
+ px
858
+ </label>
859
+ <label class="range-control" title="Minimum face detection confidence">
860
+ Confidence Threshold:
861
+ <span class="range-field">
862
+ <input class="detector-face-score range-input" type="range" min="0.3" max="0.95" step="0.01" value="0.70">
863
+ <span class="detector-face-score-val range-value">0.70</span>
864
+ </span>
865
+ </label>
866
+ </div>
867
+ </details>
868
+
869
+ <custom-model-upload-dialog></custom-model-upload-dialog>
870
+ `);
871
+ }
872
+ }
873
+
874
+ customElements.define('upscaler-controls', UpscalerControls);
features/upscaler/ui/upscaler-toolbar.js ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'components/status-bar';
2
+ import 'components/view-mode-controls';
3
+
4
+ const STATES = ['empty', 'ready', 'running', 'done'];
5
+
6
+ class UpscalerToolbar extends HTMLElement {
7
+ #state = 'empty';
8
+ #hasCrop = false;
9
+
10
+ connectedCallback() {
11
+ this.#render();
12
+ this.#wireEvents();
13
+ this.#applyState();
14
+ }
15
+
16
+ #q(sel) { return this.querySelector(sel); }
17
+
18
+ // ── Public surface ─────────────────────────────────────────────────────
19
+
20
+ get state() { return this.#state; }
21
+ set state(s) {
22
+ if (!STATES.includes(s)) return;
23
+ this.#state = s;
24
+ this.#applyState();
25
+ }
26
+
27
+ set hasCrop(b) {
28
+ this.#hasCrop = !!b;
29
+ this.#applyState();
30
+ }
31
+
32
+ get viewMode() { return this.#q('view-mode-controls').mode; }
33
+ set viewMode(mode) {
34
+ const vmc = this.#q('view-mode-controls');
35
+ if (vmc) vmc.mode = mode;
36
+ }
37
+
38
+ setUpscaleLabel(label) {
39
+ const span = this.#q('.upscale-btn .btn-label');
40
+ if (span) span.textContent = label;
41
+ }
42
+
43
+ // status-bar lives inside this component visually; expose it so the
44
+ // orchestrator can write progress/messages directly during a run.
45
+ get statusBar() { return this.#q('status-bar'); }
46
+
47
+ // ── Internal ───────────────────────────────────────────────────────────
48
+
49
+ #applyState() {
50
+ const setDisplay = (sel, show) => {
51
+ const el = this.#q(sel);
52
+ if (el) el.style.display = show ? 'inline-block' : 'none';
53
+ };
54
+ const setHidden = (sel, hidden) => {
55
+ const el = this.#q(sel);
56
+ if (el) el.hidden = hidden;
57
+ };
58
+
59
+ const s = this.#state;
60
+ const upscaleBtn = this.#q('.upscale-btn');
61
+ upscaleBtn.disabled = s === 'empty' || s === 'running';
62
+
63
+ setDisplay('.stop-btn', s === 'running');
64
+ setDisplay('.startover-btn', s === 'ready' || s === 'done');
65
+ setDisplay('.back-to-crop-btn', s === 'done');
66
+ setDisplay('.clear-crop-btn', s === 'ready' && this.#hasCrop);
67
+
68
+ setHidden('.canvas-toolbar-left', s === 'empty');
69
+ setHidden('.canvas-toolbar-right', s !== 'done');
70
+ }
71
+
72
+ #wireEvents() {
73
+ const fire = (name) => () => this.dispatchEvent(
74
+ new CustomEvent(name, { bubbles: true }),
75
+ );
76
+
77
+ this.#q('.upscale-btn' ).addEventListener('click', fire('upscale-click'));
78
+ this.#q('.stop-btn' ).addEventListener('click', fire('stop-click'));
79
+ this.#q('.startover-btn' ).addEventListener('click', fire('start-over-click'));
80
+ this.#q('.clear-crop-btn' ).addEventListener('click', fire('clear-crop-click'));
81
+ this.#q('.back-to-crop-btn' ).addEventListener('click', fire('back-to-crop-click'));
82
+ this.#q('.open-in-tab-btn' ).addEventListener('click', fire('open-in-tab-click'));
83
+ this.#q('.download-btn' ).addEventListener('click', fire('download-click'));
84
+
85
+ // Re-emit view-mode-controls' change as a bubbling event so the
86
+ // orchestrator can forward to canvas-area without poking through.
87
+ this.#q('view-mode-controls').addEventListener('mode-change', (e) => {
88
+ this.dispatchEvent(new CustomEvent('view-mode-change', {
89
+ bubbles: true, detail: { mode: e.detail.mode },
90
+ }));
91
+ });
92
+ }
93
+
94
+ #render() {
95
+ this.innerHTML = `
96
+ <style>
97
+ upscaler-toolbar {
98
+ position: sticky;
99
+ top: 0.75rem;
100
+ height: 0;
101
+ z-index: 10;
102
+ pointer-events: none;
103
+ display: block;
104
+ }
105
+ upscaler-toolbar .canvas-toolbar-stack-left {
106
+ position: absolute;
107
+ top: 0;
108
+ left: 0.75rem;
109
+ display: flex;
110
+ flex-direction: column;
111
+ align-items: flex-start;
112
+ gap: 0.25rem;
113
+ max-width: calc(100% - 1.5rem);
114
+ pointer-events: none;
115
+ }
116
+ upscaler-toolbar .canvas-toolbar-stack-left > * {
117
+ pointer-events: auto;
118
+ }
119
+ upscaler-toolbar .canvas-toolbar {
120
+ display: inline-flex;
121
+ gap: 0.25rem;
122
+ align-items: center;
123
+ padding: 0.25rem 0.3rem;
124
+ background: color-mix(in oklab, var(--pico-card-background-color, #1e1e2e) 32%, transparent);
125
+ border: 1px solid color-mix(in oklab, var(--pico-muted-border-color) 45%, transparent);
126
+ border-radius: var(--pico-border-radius);
127
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
128
+ backdrop-filter: blur(10px) saturate(1.1);
129
+ -webkit-backdrop-filter: blur(10px) saturate(1.1);
130
+ pointer-events: auto;
131
+ max-width: 100%;
132
+ }
133
+ upscaler-toolbar .canvas-toolbar-right {
134
+ position: absolute;
135
+ top: 0;
136
+ right: 0.75rem;
137
+ max-width: calc(100% - 1.5rem);
138
+ pointer-events: auto;
139
+ }
140
+ upscaler-toolbar .canvas-toolbar[hidden] {
141
+ display: none;
142
+ }
143
+ upscaler-toolbar .canvas-toolbar button {
144
+ margin-bottom: 0;
145
+ padding: 0.25rem 0.5rem;
146
+ font-size: 0.72rem;
147
+ line-height: 1.2;
148
+ width: auto;
149
+ white-space: nowrap;
150
+ }
151
+ upscaler-toolbar .canvas-toolbar button.secondary,
152
+ upscaler-toolbar .canvas-toolbar button.outline {
153
+ opacity: 0.78;
154
+ transition: opacity 0.15s ease;
155
+ background: transparent;
156
+ border-color: currentColor;
157
+ color: #fff;
158
+ mix-blend-mode: difference;
159
+ }
160
+ upscaler-toolbar .canvas-toolbar button.secondary:hover,
161
+ upscaler-toolbar .canvas-toolbar button.outline:hover,
162
+ upscaler-toolbar .canvas-toolbar button.secondary:focus-visible,
163
+ upscaler-toolbar .canvas-toolbar button.outline:focus-visible {
164
+ opacity: 1;
165
+ background: transparent;
166
+ border-color: currentColor;
167
+ color: #fff;
168
+ }
169
+ upscaler-toolbar .canvas-toolbar button .fas {
170
+ font-size: 0.78em;
171
+ margin-right: 0.15rem;
172
+ }
173
+ upscaler-toolbar .canvas-toolbar button .btn-label {
174
+ display: inline;
175
+ }
176
+ @media (max-width: 768px) {
177
+ upscaler-toolbar .canvas-toolbar button .btn-label {
178
+ display: none;
179
+ }
180
+ upscaler-toolbar .canvas-toolbar button .fas {
181
+ margin-right: 0;
182
+ }
183
+ }
184
+ upscaler-toolbar .canvas-toolbar status-bar {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ margin-left: 0.3rem;
188
+ flex: 1 1 8rem;
189
+ min-width: 0;
190
+ max-width: 22rem;
191
+ }
192
+ upscaler-toolbar .canvas-toolbar status-bar .status-text {
193
+ font-size: 0.68rem;
194
+ line-height: 1.25;
195
+ margin-bottom: 0;
196
+ color: #fff;
197
+ mix-blend-mode: difference;
198
+ flex-shrink: 0;
199
+ overflow: visible;
200
+ }
201
+ upscaler-toolbar .canvas-toolbar status-bar .progress-track {
202
+ height: 5px;
203
+ margin-bottom: 0;
204
+ flex: 1 1 10rem;
205
+ min-width: 3rem;
206
+ }
207
+ upscaler-toolbar .canvas-toolbar status-bar .progress-count {
208
+ font-size: 0.62rem;
209
+ color: #fff;
210
+ mix-blend-mode: difference;
211
+ }
212
+ </style>
213
+
214
+ <div class="canvas-toolbar-stack-left">
215
+ <div class="canvas-toolbar canvas-toolbar-left" hidden>
216
+ <button class="back-to-crop-btn secondary outline" style="display:none" type="button" title="Back to crop / change selection">
217
+ <i class="fas fa-arrow-left"></i><i class="fas fa-crop-simple"></i> <span class="btn-label">Edit Crop</span>
218
+ </button>
219
+ <button class="upscale-btn" disabled title="Upscale image">
220
+ <i class="fas fa-wand-magic-sparkles"></i> <span class="btn-label">Upscale 4x</span>
221
+ </button>
222
+ <button class="stop-btn secondary" style="display:none" title="Stop upscale">
223
+ <i class="fas fa-stop"></i> <span class="btn-label">Stop</span>
224
+ </button>
225
+ <view-mode-controls></view-mode-controls>
226
+ <button class="clear-crop-btn secondary outline" style="display:none" type="button" title="Clear the selected crop region">
227
+ <i class="fas fa-eraser"></i> <span class="btn-label">Clear Selection</span>
228
+ </button>
229
+ <button class="startover-btn secondary outline" style="display:none" title="Clear and start over with a new image">
230
+ <i class="fas fa-xmark"></i> <span class="btn-label">Clear</span>
231
+ </button>
232
+ <status-bar></status-bar>
233
+ </div>
234
+ </div>
235
+ <div class="canvas-toolbar canvas-toolbar-right" hidden>
236
+ <button class="open-in-tab-btn secondary outline" type="button" title="Open the upscaled image in a new tab">
237
+ <i class="fas fa-up-right-from-square"></i> <span class="btn-label">Open in Tab</span>
238
+ </button>
239
+ <button class="download-btn secondary outline" type="button" title="Download the upscaled image">
240
+ <i class="fas fa-download"></i> <span class="btn-label">Download</span>
241
+ </button>
242
+ </div>
243
+ `;
244
+ }
245
+ }
246
+
247
+ customElements.define('upscaler-toolbar', UpscalerToolbar);
features/upscaler/upscale-pipeline.js ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Step-based upscale pipeline — unified processing for image and video upscaling.
3
+ * Only the Pipeline class is exported; everything else is module-private.
4
+ */
5
+
6
+ import { UpscalerEngine } from './engine/upscaler-engine.js';
7
+ import { FaceDetectorEngine } from './engine/face-detector-engine.js';
8
+ import {
9
+ expandRect,
10
+ cropToCanvas,
11
+ compositeFeathered,
12
+ computeFeatherPx,
13
+ ensureCanvas,
14
+ blendCanvas,
15
+ } from 'lib/canvas';
16
+ import { buildTileGrid } from './engine/tiling.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Engine pool — caches engines by tag, recreates when config diverges.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ class EnginePool {
23
+ #slots = new Map();
24
+
25
+ #evict(tag) {
26
+ const slot = this.#slots.get(tag);
27
+ if (!slot) return;
28
+ this.#slots.delete(tag);
29
+ (slot.engine.destroy ?? slot.engine.release)?.call(slot.engine);
30
+ }
31
+
32
+ getUpscaler(tag, { modelUrl, scale, modelValueRange, modelLayout = 'nchw', modelInputMultiple = 1, modelPrecision = 'fp32', upscaleBefore = false, tileBlend = 'overlapCrop', profile = false }) {
33
+ // Backend isn't a construction-time parameter — the engine's loadModel
34
+ // handles intent transitions internally (release session, load new). The
35
+ // pool's identity is purely the things the engine can't change after the
36
+ // fact: model URL, layout, precision, tile-blend strategy.
37
+ const slot = this.#slots.get(tag);
38
+ if (
39
+ slot &&
40
+ slot.modelUrl === modelUrl &&
41
+ slot.modelLayout === modelLayout &&
42
+ slot.modelInputMultiple === modelInputMultiple &&
43
+ slot.modelPrecision === modelPrecision &&
44
+ slot.upscaleBefore === upscaleBefore &&
45
+ slot.tileBlend === tileBlend
46
+ ) {
47
+ slot.engine.profiling = profile;
48
+ return slot.engine;
49
+ }
50
+ this.#evict(tag);
51
+ const engine = new UpscalerEngine({ modelUrl, scale, modelValueRange, modelLayout, modelInputMultiple, modelPrecision, upscaleBefore, tileBlend, profile });
52
+ this.#slots.set(tag, { engine, modelUrl, modelLayout, modelInputMultiple, modelPrecision, upscaleBefore, tileBlend });
53
+ return engine;
54
+ }
55
+
56
+ getDetector(tag) {
57
+ // Backend transitions are handled inside FaceDetectorEngine.loadModel;
58
+ // the pool just returns the persistent engine instance.
59
+ let slot = this.#slots.get(tag);
60
+ if (slot) return slot.engine;
61
+ const engine = new FaceDetectorEngine();
62
+ this.#slots.set(tag, { engine });
63
+ return engine;
64
+ }
65
+
66
+ destroyAll() {
67
+ for (const { engine } of this.#slots.values()) {
68
+ (engine.destroy ?? engine.release)?.call(engine);
69
+ }
70
+ this.#slots.clear();
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Steps — { name, shouldRun?(ctx), run(ctx, cb) → ctx }
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const tiledUpscaleStep = {
79
+ name: 'tiledUpscale',
80
+ async run(ctx, cb) {
81
+ const { modelUrl, scale, modelValueRange, modelLayout, modelInputMultiple, modelPrecision, upscaleBefore, tileBlend, backend, tileSize, profile } = ctx.config;
82
+ const engine = ctx.pool.getUpscaler('base', {
83
+ modelUrl,
84
+ scale,
85
+ modelValueRange,
86
+ modelLayout,
87
+ modelInputMultiple,
88
+ modelPrecision,
89
+ upscaleBefore,
90
+ tileBlend,
91
+ backend,
92
+ profile,
93
+ });
94
+ emitStage(cb, 'tiledUpscale', 'loading', { message: 'Loading base model…' });
95
+ const tLoad = performance.now();
96
+ await engine.loadModel(backend, (frac, msg) => {
97
+ cb.onProgress?.(frac, msg);
98
+ emitStage(cb, 'tiledUpscale', 'loading', { progress: frac, message: msg });
99
+ });
100
+ const modelLoadMs = Number((performance.now() - tLoad).toFixed(1));
101
+ emitStage(cb, 'tiledUpscale', 'running', { message: 'Running base upscale pass…' });
102
+ const { canvas, perf, ortProfile } = await engine.upscale(ctx.image, tileSize, {
103
+ onTile: (info) => cb.onTile?.({ ...info, step: 'tiledUpscale' }),
104
+ signal: cb.signal,
105
+ });
106
+ return {
107
+ ...ctx,
108
+ image: canvas,
109
+ scale: engine.scale,
110
+ perf,
111
+ ortProfile,
112
+ stepPerf: { ...(ctx.stepPerf || {}), tiledUpscale: { ...perf, modelLoadMs } },
113
+ };
114
+ },
115
+ };
116
+
117
+ const comparisonStep = {
118
+ name: 'comparison',
119
+ shouldRun: (ctx) => !!ctx.config.comparison,
120
+ async run(ctx, cb) {
121
+ const { comparison, backend, tileSize } = ctx.config;
122
+ const passBackend = comparison.backend || backend;
123
+ const engine = ctx.pool.getUpscaler('comparison-upscaler', {
124
+ modelUrl: comparison.modelUrl,
125
+ scale: comparison.scale,
126
+ modelValueRange: comparison.modelValueRange,
127
+ modelLayout: comparison.modelLayout,
128
+ modelInputMultiple: comparison.modelInputMultiple,
129
+ modelPrecision: comparison.modelPrecision,
130
+ upscaleBefore: comparison.upscaleBefore,
131
+ tileBlend: comparison.tileBlend,
132
+ backend: passBackend,
133
+ });
134
+ emitStage(cb, 'comparison', 'loading', { message: 'Loading comparison model…' });
135
+ const tLoad = performance.now();
136
+ await engine.loadModel(passBackend, (progress, message) => {
137
+ emitStage(cb, 'comparison', 'loading', { progress, message });
138
+ });
139
+ const modelLoadMs = Number((performance.now() - tLoad).toFixed(1));
140
+ emitStage(cb, 'comparison', 'running', { message: 'Running comparison upscale pass…' });
141
+
142
+ const { canvas, perf } = await engine.upscale(ctx.source, tileSize, {
143
+ onTile: (info) => cb.onTile?.({ ...info, step: 'comparison' }),
144
+ signal: cb.signal,
145
+ });
146
+ return {
147
+ ...ctx,
148
+ comparisonImage: canvas,
149
+ stepPerf: { ...(ctx.stepPerf || {}), comparison: { ...perf, modelLoadMs } },
150
+ };
151
+ },
152
+ };
153
+
154
+ const blendAllStep = {
155
+ name: 'blendAll',
156
+ shouldRun: (ctx) => !!ctx.config.all,
157
+ async run(ctx, cb) {
158
+ const { all, backend, tileSize } = ctx.config;
159
+ const passBackend = all.backend || backend;
160
+ const engine = ctx.pool.getUpscaler('all-upscaler', {
161
+ modelUrl: all.modelUrl,
162
+ scale: all.scale,
163
+ modelValueRange: all.modelValueRange,
164
+ modelLayout: all.modelLayout,
165
+ modelInputMultiple: all.modelInputMultiple,
166
+ modelPrecision: all.modelPrecision,
167
+ upscaleBefore: all.upscaleBefore,
168
+ tileBlend: all.tileBlend,
169
+ backend: passBackend,
170
+ });
171
+ emitStage(cb, 'blendAll', 'loading', { message: 'Loading all-pass model…' });
172
+ const tLoad = performance.now();
173
+ await engine.loadModel(passBackend, (progress, message) => {
174
+ emitStage(cb, 'blendAll', 'loading', { progress, message });
175
+ });
176
+ const modelLoadMs = Number((performance.now() - tLoad).toFixed(1));
177
+ emitStage(cb, 'blendAll', 'running', { message: 'Running all-pass tiled blend…' });
178
+
179
+ const { canvas: overlayRaw, perf } = await engine.upscale(ctx.source, tileSize, {
180
+ onTile: (info) => cb.onTile?.({ ...info, step: 'blendAll' }),
181
+ signal: cb.signal,
182
+ });
183
+ let baseCanvas = ensureCanvas(ctx.image);
184
+ let overlayCanvas = overlayRaw;
185
+ if (overlayRaw.width !== baseCanvas.width || overlayRaw.height !== baseCanvas.height) {
186
+ overlayCanvas = document.createElement('canvas');
187
+ overlayCanvas.width = baseCanvas.width;
188
+ overlayCanvas.height = baseCanvas.height;
189
+ overlayCanvas.getContext('2d').drawImage(overlayRaw, 0, 0, baseCanvas.width, baseCanvas.height);
190
+ }
191
+ baseCanvas = blendCanvas(baseCanvas, overlayCanvas, all.blendOpacity);
192
+ return {
193
+ ...ctx,
194
+ image: baseCanvas,
195
+ stepPerf: { ...(ctx.stepPerf || {}), blendAll: { ...perf, modelLoadMs } },
196
+ };
197
+ },
198
+ };
199
+
200
+ const detectFacesStep = {
201
+ name: 'detectFaces',
202
+ shouldRun: (ctx) => !!ctx.config.face,
203
+ async run(ctx, cb) {
204
+ const { backend, face } = ctx.config;
205
+ const detector = ctx.pool.getDetector('face-detector');
206
+ emitStage(cb, 'detectFaces', 'loading', { message: 'Loading face detector…' });
207
+ const tLoad = performance.now();
208
+ await detector.loadModel('face-yunet', backend, (progress, message) => {
209
+ emitStage(cb, 'detectFaces', 'loading', { progress, message });
210
+ });
211
+ const modelLoadMs = Number((performance.now() - tLoad).toFixed(1));
212
+ emitStage(cb, 'detectFaces', 'running', { message: 'Detecting faces…' });
213
+ const tDetect = performance.now();
214
+ const faces = await detector.detectFaces(ctx.source, {
215
+ detectorKey: 'face-yunet',
216
+ scoreThreshold: face.scoreThreshold,
217
+ signal: cb.signal,
218
+ });
219
+ const detectPerf = {
220
+ modelLoadMs,
221
+ total: performance.now() - tDetect,
222
+ detections: faces.length,
223
+ };
224
+ emitStage(cb, 'detectFaces', 'running', { message: `Detected ${faces.length} face(s).` });
225
+ return {
226
+ ...ctx,
227
+ detections: { ...ctx.detections, face: faces },
228
+ stepPerf: { ...(ctx.stepPerf || {}), detectFaces: detectPerf },
229
+ };
230
+ },
231
+ };
232
+
233
+ const enhanceFacesStep = {
234
+ name: 'enhanceFaces',
235
+ shouldRun: (ctx) => ctx.detections.face?.length > 0,
236
+ async run(ctx, cb) {
237
+ const { face, backend } = ctx.config;
238
+ const faceBackend = face.backend || backend;
239
+ const { source, scale } = ctx;
240
+
241
+ let canvas = ensureCanvas(ctx.image);
242
+
243
+ const engine = ctx.pool.getUpscaler('face-upscaler', {
244
+ modelUrl: face.modelUrl,
245
+ scale: face.scale,
246
+ modelValueRange: face.modelValueRange,
247
+ modelLayout: face.modelLayout,
248
+ modelInputMultiple: face.modelInputMultiple,
249
+ modelPrecision: face.modelPrecision,
250
+ upscaleBefore: face.upscaleBefore,
251
+ tileBlend: face.tileBlend,
252
+ backend: faceBackend,
253
+ });
254
+ emitStage(cb, 'enhanceFaces', 'loading', { message: 'Loading face enhancer model…' });
255
+ const tLoad = performance.now();
256
+ await engine.loadModel(faceBackend, (progress, message) => {
257
+ emitStage(cb, 'enhanceFaces', 'loading', { progress, message });
258
+ });
259
+ const modelLoadMs = Number((performance.now() - tLoad).toFixed(1));
260
+ emitStage(cb, 'enhanceFaces', 'running', { message: 'Enhancing detected faces…' });
261
+
262
+ const srcW = source.naturalWidth ?? source.width;
263
+ const srcH = source.naturalHeight ?? source.height;
264
+ const faces = ctx.detections.face || [];
265
+ const configuredFaceTileSize = Number.isFinite(face.tileSize) ? face.tileSize : ctx.config.tileSize;
266
+ const agg = {
267
+ setup: 0, extract: 0, inference: 0, inferenceEstimated: 0, readback: 0, gpuRender: 0, writeTile: 0, dispose: 0, total: 0, tiles: 0, modelLoadMs,
268
+ };
269
+
270
+ for (let faceIndex = 0; faceIndex < faces.length; faceIndex++) {
271
+ const det = faces[faceIndex];
272
+ if (cb.signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
273
+ emitStage(cb, 'enhanceFaces', 'running', {
274
+ message: `Enhancing face ${faceIndex + 1}/${faces.length}…`,
275
+ });
276
+
277
+ const roi = expandRect(det, face.paddingPx, srcW, srcH);
278
+ if (roi.w < 1 || roi.h < 1) continue;
279
+
280
+ const crop = cropToCanvas(source, roi);
281
+ if (!crop) continue;
282
+
283
+ const maxTileForRoi = Math.max(1, Math.min(roi.w, roi.h));
284
+ const requestedTileSize = Number.isFinite(configuredFaceTileSize) ? configuredFaceTileSize : 192;
285
+ // Honor selected tile size for face patches so large faces are not over-tiled.
286
+ const tileSize = requestedTileSize <= 0
287
+ ? 0
288
+ : Math.min(maxTileForRoi, Math.max(64, requestedTileSize));
289
+ const faceTileTotal = buildTileGrid(roi.w, roi.h, tileSize, 16).length;
290
+ const { canvas: patchRaw, perf } = await engine.upscale(crop, tileSize, {
291
+ onTile: (info) => cb.onTile?.({
292
+ ...info,
293
+ step: 'enhanceFaces',
294
+ faceIndex,
295
+ faceTotal: faces.length,
296
+ faceTileTotal,
297
+ }),
298
+ signal: cb.signal,
299
+ });
300
+ agg.setup += perf.setup;
301
+ agg.extract += perf.extract;
302
+ agg.inference += perf.inference;
303
+ agg.inferenceEstimated += perf.inferenceEstimated || 0;
304
+ agg.readback += perf.readback;
305
+ agg.gpuRender += perf.gpuRender;
306
+ agg.writeTile += perf.writeTile;
307
+ agg.dispose += perf.dispose;
308
+ agg.total += perf.total;
309
+ agg.tiles += perf.tiles;
310
+
311
+ const tw = roi.w * scale;
312
+ const th = roi.h * scale;
313
+ const patch = document.createElement('canvas');
314
+ patch.width = tw;
315
+ patch.height = th;
316
+ const pctx = patch.getContext('2d');
317
+ pctx.imageSmoothingEnabled = true;
318
+ pctx.imageSmoothingQuality = 'high';
319
+ pctx.drawImage(patchRaw, 0, 0, tw, th);
320
+
321
+ const roiOutX = roi.x * scale;
322
+ const roiOutY = roi.y * scale;
323
+ compositeFeathered(canvas, patch, roiOutX, roiOutY, {
324
+ featherPx: computeFeatherPx({
325
+ configuredFeatherPx: face.featherPx ?? 16,
326
+ regionW: det.w, regionH: det.h,
327
+ patchW: tw, patchH: th,
328
+ paddingPx: face.paddingPx,
329
+ scale,
330
+ }),
331
+ innerRect: {
332
+ x: Math.max(0, (det.x - roi.x) * scale),
333
+ y: Math.max(0, (det.y - roi.y) * scale),
334
+ w: Math.max(1, det.w * scale),
335
+ h: Math.max(1, det.h * scale),
336
+ },
337
+ blendOpacity: face.blendOpacity,
338
+ });
339
+
340
+ cb.onTile?.({
341
+ canvas,
342
+ outX: roiOutX,
343
+ outY: roiOutY,
344
+ outW: tw,
345
+ outH: th,
346
+ step: 'enhanceFaces',
347
+ faceIndex,
348
+ faceTotal: faces.length,
349
+ composited: true,
350
+ index: faceIndex,
351
+ total: faces.length,
352
+ });
353
+
354
+ crop.width = crop.height = 0;
355
+ patch.width = patch.height = 0;
356
+ patchRaw.width = patchRaw.height = 0;
357
+ }
358
+
359
+ return { ...ctx, image: canvas, stepPerf: { ...(ctx.stepPerf || {}), enhanceFaces: agg } };
360
+ },
361
+ };
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Step runner
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const STEPS = [tiledUpscaleStep, comparisonStep, blendAllStep, detectFacesStep, enhanceFacesStep];
368
+
369
+ function getImageSize(image) {
370
+ if (!image) return null;
371
+ const width = image.naturalWidth ?? image.videoWidth ?? image.width ?? 0;
372
+ const height = image.naturalHeight ?? image.videoHeight ?? image.height ?? 0;
373
+ return width > 0 && height > 0 ? `${width}x${height}` : null;
374
+ }
375
+
376
+ function logStep(event, stepName, details = {}) {
377
+ console.debug(`[UpscalePipeline] ${event} ${stepName}`, details);
378
+ }
379
+
380
+ function emitStage(cb, step, phase, details = {}) {
381
+ cb.onStage?.({ step, phase, ...details });
382
+ }
383
+
384
+ const UPSCALE_PERF_KEYS = [
385
+ 'setup',
386
+ 'extract',
387
+ 'inference',
388
+ 'inferenceEstimated',
389
+ 'readback',
390
+ 'gpuRender',
391
+ 'writeTile',
392
+ 'dispose',
393
+ ];
394
+
395
+ function getImageDims(image) {
396
+ return {
397
+ width: image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? 0,
398
+ height: image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? 0,
399
+ };
400
+ }
401
+
402
+ function looksLikeUpscalePerf(perf) {
403
+ return perf && typeof perf === 'object' && (
404
+ Number.isFinite(perf.inference) ||
405
+ Number.isFinite(perf.setup) ||
406
+ Number.isFinite(perf.gpuRender)
407
+ );
408
+ }
409
+
410
+ function aggregateSessionPerf(stepPerf, ctx, input, config, totalMs) {
411
+ const src = getImageDims(input);
412
+ const out = getImageDims(ctx.image);
413
+ const session = {
414
+ setup: 0,
415
+ extract: 0,
416
+ inference: 0,
417
+ inferenceEstimated: 0,
418
+ readback: 0,
419
+ gpuRender: 0,
420
+ writeTile: 0,
421
+ dispose: 0,
422
+ modelLoad: 0,
423
+ total: totalMs,
424
+ tiles: 0,
425
+ tileSize: Number.isFinite(config.tileSize) ? config.tileSize : 0,
426
+ srcW: src.width,
427
+ srcH: src.height,
428
+ outW: out.width,
429
+ outH: out.height,
430
+ pipeline: config.backend === 'webgpu' ? 'gpu' : 'cpu',
431
+ };
432
+
433
+ for (const { perf } of Object.values(stepPerf)) {
434
+ if (!perf) continue;
435
+ if (Number.isFinite(perf.modelLoadMs)) session.modelLoad += perf.modelLoadMs;
436
+ if (!looksLikeUpscalePerf(perf)) continue;
437
+ if (Number.isFinite(perf.tiles)) session.tiles += perf.tiles;
438
+ if (perf.pipeline === 'gpu-gpu') session.pipeline = 'gpu-gpu';
439
+ else if (perf.pipeline === 'gpu' && session.pipeline !== 'gpu-gpu') session.pipeline = 'gpu';
440
+ for (const key of UPSCALE_PERF_KEYS) {
441
+ if (Number.isFinite(perf[key])) session[key] += perf[key];
442
+ }
443
+ }
444
+
445
+ return session;
446
+ }
447
+
448
+ async function runSteps(steps, pool, input, config, cb = {}) {
449
+ let ctx = {
450
+ image: input,
451
+ source: input,
452
+ scale: 1,
453
+ detections: {},
454
+ perf: null,
455
+ ortProfile: null,
456
+ config,
457
+ pool,
458
+ };
459
+ const tPipeline = performance.now();
460
+ const stepPerf = {};
461
+ logStep('start', 'pipeline', {
462
+ steps: steps.length,
463
+ input: getImageSize(input),
464
+ backend: config.backend,
465
+ tileSize: config.tileSize,
466
+ });
467
+ for (const step of steps) {
468
+ if (cb.signal?.aborted) throw new DOMException('Cancelled', 'AbortError');
469
+ if (step.shouldRun && !step.shouldRun(ctx)) {
470
+ logStep('skip', step.name);
471
+ emitStage(cb, step.name, 'skip');
472
+ continue;
473
+ }
474
+ const tStep = performance.now();
475
+ emitStage(cb, step.name, 'start');
476
+ logStep('start', step.name);
477
+ try {
478
+ ctx = await step.run(ctx, cb);
479
+ const durationMs = Number((performance.now() - tStep).toFixed(1));
480
+ stepPerf[step.name] = {
481
+ durationMs,
482
+ ...(ctx.stepPerf?.[step.name] ? { perf: ctx.stepPerf[step.name] } : {}),
483
+ };
484
+ emitStage(cb, step.name, 'done', { durationMs });
485
+ logStep('done', step.name, {
486
+ durationMs,
487
+ output: getImageSize(ctx.image),
488
+ });
489
+ } catch (error) {
490
+ emitStage(cb, step.name, 'error', { message: error?.message || String(error) });
491
+ console.warn(`[UpscalePipeline] failed ${step.name}`, error);
492
+ throw error;
493
+ }
494
+ }
495
+ const totalMs = Number((performance.now() - tPipeline).toFixed(1));
496
+ emitStage(cb, 'pipeline', 'done', { durationMs: totalMs });
497
+ logStep('done', 'pipeline', {
498
+ durationMs: totalMs,
499
+ output: getImageSize(ctx.image),
500
+ });
501
+ const sessionPerf = aggregateSessionPerf(stepPerf, ctx, input, config, totalMs);
502
+ return { ...ctx, perf: sessionPerf, pipelinePerf: { totalMs, steps: stepPerf } };
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Public API
507
+ // ---------------------------------------------------------------------------
508
+
509
+ export class Pipeline {
510
+ #pool = new EnginePool();
511
+
512
+ async run(input, config, callbacks) {
513
+ return runSteps(STEPS, this.#pool, input, config, callbacks);
514
+ }
515
+
516
+ async warmup(config, { onProgress } = {}) {
517
+ const { modelUrl, scale, modelValueRange, modelLayout, modelInputMultiple, modelPrecision, upscaleBefore, tileBlend, backend, profile } = config;
518
+ const engine = this.#pool.getUpscaler('base', {
519
+ modelUrl,
520
+ scale,
521
+ modelValueRange,
522
+ modelLayout,
523
+ modelInputMultiple,
524
+ modelPrecision,
525
+ upscaleBefore,
526
+ tileBlend,
527
+ backend,
528
+ profile,
529
+ });
530
+ await engine.loadModel(backend, onProgress);
531
+ }
532
+
533
+ destroy() {
534
+ this.#pool.destroyAll();
535
+ }
536
+ }
features/upscaler/upscaler-app.js ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { morph } from 'lib/morph';
2
+ import { trackBackendEvents, friendlyBackend, realizedIsGpu } from 'lib/backend-events';
3
+ import { Pipeline } from './upscale-pipeline.js';
4
+ import './ui/upscaler-controls.js';
5
+ import './ui/upscaler-canvas-area.js';
6
+ import './ui/upscaler-toolbar.js';
7
+ import './ui/perf-monitor.js';
8
+
9
+ /**
10
+ * Format a pipeline/inference error for end users.
11
+ * Detects the ONNX reshape-window-size failure and adds a remediation hint
12
+ * derived from the model's declared layout/multiple-of/maxTileSize and any
13
+ * dimensions the runtime reported in the raw error. Pure: caller passes the
14
+ * relevant model facts so this function stays DOM-free and unit-testable.
15
+ */
16
+ function formatUpscaleErrorMessage(error, { layout = 'nchw', multipleOf = 1, maxTileSize = null, precision = 'fp32', backend = null } = {}) {
17
+ const raw = error?.message || String(error || 'Unknown error');
18
+
19
+ const isFp16DtypeError =
20
+ /Unexpected input data type/i.test(raw) && /tensor\(float16\)/i.test(raw);
21
+ const isFp16KernelMissing =
22
+ precision === 'fp16' && backend !== 'gpu' &&
23
+ (/kernel.*not.*found/i.test(raw) || /not.*supported/i.test(raw) || /no kernel/i.test(raw));
24
+ if (isFp16DtypeError && backend === 'gpu') {
25
+ return `Model precision metadata was stale: this model declares fp16 inputs but the engine had it tagged as fp32. The engine has now corrected itself — try running again. (If the error persists, open the model from the Edit pencil and set Precision = fp16, or re-upload it.) Raw error: ${raw}`;
26
+ }
27
+ if (isFp16DtypeError || isFp16KernelMissing) {
28
+ return `This model uses fp16 (16-bit) precision, which the CPU backend does not fully support. Switch Backend to "GPU" and run again. Raw error: ${raw}`;
29
+ }
30
+
31
+ const isReshapeWindowError =
32
+ /reshape_helper\.h/i.test(raw) ||
33
+ /input_shape_size == size/i.test(raw) ||
34
+ /cannot be reshaped to the requested shape/i.test(raw);
35
+ if (!isReshapeWindowError) return raw;
36
+
37
+ const upperLayout = String(layout).toUpperCase();
38
+ const altLayout = upperLayout === 'NHWC' ? 'NCHW' : 'NHWC';
39
+ const parseShape = (text) => String(text || '')
40
+ .split(',')
41
+ .map((v) => parseInt(v.trim(), 10))
42
+ .filter(Number.isFinite);
43
+ const shapeMatch = raw.match(/Input shape:\{([^}]*)\}.*requested shape:\{([^}]*)\}/i);
44
+ const inputDims = shapeMatch ? parseShape(shapeMatch[1]) : [];
45
+ const requestedDims = shapeMatch ? parseShape(shapeMatch[2]) : [];
46
+
47
+ // When the model is known to have a hard input-size cap (detected by the
48
+ // inspector or set manually), the right remediation is to reduce tile size,
49
+ // not to bump Multiple-of — bumping it would push padded edge tiles past
50
+ // the cap.
51
+ if (Number.isFinite(maxTileSize) && maxTileSize >= 1) {
52
+ return `Model reshape failed: this model only accepts inputs up to ${maxTileSize}×${maxTileSize}. Reduce Tile size to ≤ ${maxTileSize} and set Multiple-of = ${maxTileSize} so edge tiles get padded back into range. Raw error: ${raw}`;
53
+ }
54
+
55
+ let inferredMultiple = 0;
56
+ const pow2Requested = requestedDims.filter((d) => d > 1 && d <= 256 && (d & (d - 1)) === 0);
57
+ if (pow2Requested.length) {
58
+ inferredMultiple = Math.max(...pow2Requested);
59
+ }
60
+ if (inputDims.length > 0 && requestedDims.length > 1) {
61
+ const likelyGroup = requestedDims[1];
62
+ if (Number.isFinite(likelyGroup) && likelyGroup > 1 && inputDims[0] % likelyGroup !== 0) {
63
+ inferredMultiple = Math.max(inferredMultiple, likelyGroup);
64
+ }
65
+ }
66
+
67
+ const suggestedMultiple = Math.max(multipleOf > 1 ? multipleOf : 8, inferredMultiple || 0);
68
+ const specificHint = inferredMultiple > 8
69
+ ? ` Based on the reported reshape, try Multiple-of ${inferredMultiple} first.`
70
+ : '';
71
+ return `Model reshape failed (likely window-size constraint). Try setting Multiple-of to ${suggestedMultiple} (common values: 8/16/32/64) and/or switch Layout to ${altLayout}.${specificHint} If reducing the tile size below ~64 makes it work, the model may have a hard upper bound — set "Max tile" on the custom model. Raw error: ${raw}`;
72
+ }
73
+
74
+ const STEP_LABEL = {
75
+ tiledUpscale: 'Upscaling',
76
+ comparison: 'Comparison',
77
+ blendAll: 'All-pass',
78
+ detectFaces: 'Detecting',
79
+ enhanceFaces: 'Faces',
80
+ };
81
+
82
+ function scaleCanvasToOutput(srCanvas, image, outputScale) {
83
+ const targetScale = Math.max(1, outputScale || Math.round(srCanvas.width / image.width) || 1);
84
+ const w = image.width * targetScale;
85
+ const h = image.height * targetScale;
86
+ if (srCanvas.width === w && srCanvas.height === h) return srCanvas;
87
+ const out = document.createElement('canvas');
88
+ out.width = w;
89
+ out.height = h;
90
+ const ctx = out.getContext('2d');
91
+ ctx.imageSmoothingEnabled = true;
92
+ ctx.imageSmoothingQuality = 'high';
93
+ ctx.drawImage(srCanvas, 0, 0, w, h);
94
+ return out;
95
+ }
96
+
97
+ function makeComparisonCanvases(resultCanvas, image, outputScale) {
98
+ const afterCanvas = scaleCanvasToOutput(resultCanvas, image, outputScale);
99
+ const w = afterCanvas.width;
100
+ const h = afterCanvas.height;
101
+ const beforeCanvas = document.createElement('canvas');
102
+ beforeCanvas.width = w;
103
+ beforeCanvas.height = h;
104
+ const bCtx = beforeCanvas.getContext('2d');
105
+ bCtx.imageSmoothingEnabled = false;
106
+ bCtx.drawImage(image, 0, 0, w, h);
107
+ return { beforeCanvas, afterCanvas };
108
+ }
109
+
110
+ // For Comparison mode: both layers are SR canvases of the same size, so the
111
+ // before slot is the base SR (no pixelated LR upscale) and the after slot
112
+ // is the comparison SR. We still honor the user's Final Output downscale.
113
+ function makeComparisonPairCanvases(baseSR, comparisonSR, image, outputScale) {
114
+ return {
115
+ beforeCanvas: scaleCanvasToOutput(baseSR, image, outputScale),
116
+ afterCanvas: scaleCanvasToOutput(comparisonSR, image, outputScale),
117
+ };
118
+ }
119
+
120
+ class UpscalerApp extends HTMLElement {
121
+ #pipeline = new Pipeline();
122
+ #abortController = null;
123
+ #running = false;
124
+ #generation = 0;
125
+
126
+ connectedCallback() {
127
+ this.#render();
128
+ this.#wire();
129
+ this.#restoreViewState();
130
+ }
131
+
132
+ #q(sel) { return this.querySelector(sel); }
133
+
134
+ // ── Wiring ─────────────────────────────────────────────────────────────
135
+
136
+ #wire() {
137
+ const controls = this.#q('upscaler-controls');
138
+ const canvasArea = this.#q('upscaler-canvas-area');
139
+ const toolbar = this.#q('upscaler-toolbar');
140
+ const perfMon = this.#q('perf-monitor');
141
+
142
+ // Empty initial state — no "Ready" copy; icon alone communicates "waiting".
143
+ toolbar.statusBar.set({ title: '', state: 'idle', details: '', progress: -1, tileCount: null });
144
+
145
+ // Status updates from controls (custom-model upload/edit/delete) carry
146
+ // their own { title, state, details } payload; forward verbatim.
147
+ this.addEventListener('status-message', (e) => {
148
+ toolbar.statusBar.set(e.detail);
149
+ });
150
+
151
+ // Model change → update upscale button label.
152
+ this.addEventListener('model-change', (e) => {
153
+ toolbar.setUpscaleLabel(`${e.detail.verb} ${e.detail.scale}x`);
154
+ });
155
+
156
+ // Image loaded → switch to ready phase, pick a default view mode that
157
+ // keeps the whole image on screen.
158
+ canvasArea.addEventListener('image-loaded', (e) => {
159
+ if (this.#running) {
160
+ this.#abortController?.abort();
161
+ this.#running = false;
162
+ this.#generation++;
163
+ this.#abortController = null;
164
+ }
165
+ const img = e.detail.image;
166
+ this.#setMode(canvasArea.defaultModeForImage(img));
167
+ canvasArea.showCropping(img);
168
+ toolbar.state = 'ready';
169
+ toolbar.hasCrop = false;
170
+ toolbar.statusBar.set({
171
+ title: 'Image loaded',
172
+ state: 'idle',
173
+ details: `${img.width}×${img.height}. Drag to crop (optional), then click Upscale.`,
174
+ progress: -1,
175
+ tileCount: null,
176
+ });
177
+ });
178
+
179
+ canvasArea.addEventListener('crop-changed', (e) => {
180
+ const crop = e.detail.crop;
181
+ const img = canvasArea.image;
182
+ toolbar.hasCrop = !!crop;
183
+ toolbar.statusBar.set(crop ? {
184
+ title: 'Crop selected',
185
+ state: 'idle',
186
+ details: `${img.width}×${img.height}, cropped to ${crop.w}×${crop.h}.`,
187
+ } : {
188
+ title: 'Image loaded',
189
+ state: 'idle',
190
+ details: `${img.width}×${img.height}. Drag to crop (optional), then click Upscale.`,
191
+ });
192
+ });
193
+
194
+ // View mode (toolbar → canvas).
195
+ toolbar.addEventListener('view-mode-change', (e) => {
196
+ this.#setMode(e.detail.mode);
197
+ canvasArea.snapCenterVisible();
198
+ });
199
+
200
+ // Button events from toolbar.
201
+ toolbar.addEventListener('upscale-click', () => this.#runUpscale());
202
+ toolbar.addEventListener('stop-click', () => this.#abortController?.abort());
203
+ toolbar.addEventListener('start-over-click', () => {
204
+ if (this.#running) this.#abortController?.abort();
205
+ this.#reset();
206
+ });
207
+ toolbar.addEventListener('back-to-crop-click', () => {
208
+ if (this.#running || !canvasArea.image) return;
209
+ this.#showReady();
210
+ });
211
+ toolbar.addEventListener('clear-crop-click', () => canvasArea.clearCrop());
212
+ toolbar.addEventListener('open-in-tab-click', () => canvasArea.openInTab());
213
+ toolbar.addEventListener('download-click', () => canvasArea.download());
214
+
215
+ // perf-toggle + clear-cache buttons live inside <upscaler-controls>
216
+ // but their actions are orchestrator-level (perf monitor, pipeline cache).
217
+ this.addEventListener('perf-toggle', () => {
218
+ perfMon.visible ? perfMon.hide() : perfMon.show();
219
+ });
220
+ this.addEventListener('clear-cache', () => {
221
+ if (this.#running) return;
222
+ this.#pipeline.destroy();
223
+ toolbar.statusBar.set({
224
+ title: 'Cache cleared',
225
+ state: 'idle',
226
+ details: 'Model cache cleared. Next run will re-download.',
227
+ });
228
+ });
229
+ }
230
+
231
+ // ── Phase helpers ──────────────────────────────────────────────────────
232
+
233
+ #showReady() {
234
+ const canvasArea = this.#q('upscaler-canvas-area');
235
+ const toolbar = this.#q('upscaler-toolbar');
236
+ const img = canvasArea.image;
237
+ canvasArea.showCropping(img);
238
+ const existingCrop = canvasArea.currentCrop;
239
+ toolbar.state = 'ready';
240
+ toolbar.hasCrop = !!existingCrop;
241
+ toolbar.statusBar.set(existingCrop ? {
242
+ title: 'Crop selected',
243
+ state: 'idle',
244
+ details: `${img.width}×${img.height}, cropped to ${existingCrop.w}×${existingCrop.h}.`,
245
+ progress: -1,
246
+ tileCount: null,
247
+ } : {
248
+ title: 'Image loaded',
249
+ state: 'idle',
250
+ details: `${img.width}×${img.height}. Drag to crop (optional), then click Upscale.`,
251
+ progress: -1,
252
+ tileCount: null,
253
+ });
254
+ }
255
+
256
+ #reset() {
257
+ this.#running = false;
258
+ this.#generation++;
259
+ this.#abortController = null;
260
+ const canvasArea = this.#q('upscaler-canvas-area');
261
+ const toolbar = this.#q('upscaler-toolbar');
262
+ canvasArea.showInitial();
263
+ toolbar.state = 'empty';
264
+ toolbar.hasCrop = false;
265
+ toolbar.statusBar.set({ title: '', state: 'idle', details: '', progress: -1, tileCount: null });
266
+ }
267
+
268
+ // ── View-mode persistence (orchestrator-level so it survives across phases) ──
269
+
270
+ #setMode(mode) {
271
+ const canvasArea = this.#q('upscaler-canvas-area');
272
+ const toolbar = this.#q('upscaler-toolbar');
273
+ if (canvasArea.viewMode === mode) return;
274
+ canvasArea.viewMode = mode;
275
+ toolbar.viewMode = mode;
276
+ localStorage.setItem('upscaler_view_mode', mode);
277
+ }
278
+
279
+ #restoreViewState() {
280
+ const saved = localStorage.getItem('upscaler_view_mode');
281
+ const mode = ['fit-width', 'fit-height', 'one-to-one'].includes(saved) ? saved : 'fit-width';
282
+ this.#q('upscaler-canvas-area').viewMode = mode;
283
+ this.#q('upscaler-toolbar').viewMode = mode;
284
+ }
285
+
286
+ // ── Upscale run flow ───────────────────────────────────────────────────
287
+
288
+ async #runUpscale() {
289
+ if (this.#running) return;
290
+ const controls = this.#q('upscaler-controls');
291
+ const canvasArea = this.#q('upscaler-canvas-area');
292
+ const toolbar = this.#q('upscaler-toolbar');
293
+ const perfMon = this.#q('perf-monitor');
294
+ const status = toolbar.statusBar;
295
+
296
+ if (!canvasArea.image) return;
297
+ this.#running = true;
298
+ const gen = ++this.#generation;
299
+ this.#abortController = new AbortController();
300
+ const signal = this.#abortController.signal;
301
+
302
+ controls.isRunning = true;
303
+ toolbar.state = 'running';
304
+
305
+ // runState is monotonic: 'running' → 'warning' (stays once warned).
306
+ // The tracker updates it as backend events arrive; the orchestrator
307
+ // reads it on completion to pick the final icon color.
308
+ let runState = 'running';
309
+ const tracker = trackBackendEvents((ev) => {
310
+ if (ev.kind === 'attempt') {
311
+ status.set({ title: `Loading on ${friendlyBackend(ev.backend)}`, state: runState });
312
+ } else if (ev.kind === 'success') {
313
+ status.set({ title: `Running on ${friendlyBackend(ev.backend)}`, state: runState });
314
+ } else if (ev.kind === 'fallback') {
315
+ runState = 'warning';
316
+ status.set({ title: `Fallback from ${friendlyBackend(ev.backend)}`, state: runState });
317
+ } else if (ev.kind === 'skipped') {
318
+ runState = 'warning';
319
+ status.set({ title: `Skipping ${friendlyBackend(ev.backend)}`, state: runState });
320
+ }
321
+ });
322
+
323
+ try {
324
+ status.set({
325
+ title: 'Loading model',
326
+ state: 'running',
327
+ details: '',
328
+ progress: 0,
329
+ tileCount: null,
330
+ });
331
+ const inputImage = canvasArea.croppedImage;
332
+ const requestedOutputScale = controls.outputScale;
333
+
334
+ const { beforeCanvas, afterCanvas, scale, comparison } =
335
+ await this.#runPipeline(controls, canvasArea, perfMon, status, signal, inputImage, requestedOutputScale, () => runState);
336
+
337
+ const outW = inputImage.width * scale;
338
+ const outH = inputImage.height * scale;
339
+ const summary = tracker.summary();
340
+ // The tracker only flags hadFallback when a fallback event fires *this
341
+ // run*. After a prior runtime fallback (CoreML→CPU), the worker's
342
+ // session is already on CPU, so the next run produces no fallback
343
+ // event of its own — yet the user asked for GPU and is silently on
344
+ // CPU. Treat that intent/reality mismatch as warning-worthy too.
345
+ const userWantsGpu = controls.backend === 'gpu';
346
+ const ranOnCpuDespiteIntent = userWantsGpu && summary.activeBackend && !realizedIsGpu(summary.activeBackend);
347
+ const finalState = (summary.hadFallback || summary.hadSkip || ranOnCpuDespiteIntent) ? 'warning' : 'success';
348
+ const via = summary.activeBackend ? ` via ${friendlyBackend(summary.activeBackend)}` : '';
349
+ const detailsLines = [`${inputImage.width}×${inputImage.height} → ${outW}×${outH}${via}`];
350
+ if (ranOnCpuDespiteIntent && !summary.hadFallback) {
351
+ detailsLines.push(`Requested GPU but running on ${friendlyBackend(summary.activeBackend)} (prior fallback this session — reload to retry GPU).`);
352
+ }
353
+ if (summary.lines.length) detailsLines.push(...summary.lines);
354
+ status.set({
355
+ title: 'Done',
356
+ state: finalState,
357
+ details: detailsLines.join('\n'),
358
+ progress: -1,
359
+ tileCount: null,
360
+ });
361
+
362
+ await canvasArea.showResult(beforeCanvas, afterCanvas, {
363
+ downloadName: comparison ? `comparison_${scale}x.png` : `upscaled_${scale}x.png`,
364
+ });
365
+ toolbar.state = 'done';
366
+ } catch (e) {
367
+ if (e.name === 'AbortError') {
368
+ status.set({
369
+ title: 'Cancelled',
370
+ state: 'idle',
371
+ details: 'You stopped this run.',
372
+ progress: -1,
373
+ tileCount: null,
374
+ });
375
+ } else {
376
+ console.error(e);
377
+ const opt = controls.selectedModelOption;
378
+ const parsedMaxTile = parseInt(opt?.dataset?.maxtilesize, 10);
379
+ const errMsg = formatUpscaleErrorMessage(e, {
380
+ layout: opt?.dataset?.layout,
381
+ multipleOf: parseInt(opt?.dataset?.multipleof, 10),
382
+ maxTileSize: Number.isFinite(parsedMaxTile) ? parsedMaxTile : null,
383
+ precision: opt?.dataset?.precision === 'fp16' ? 'fp16' : 'fp32',
384
+ backend: controls.backend,
385
+ });
386
+ status.set({
387
+ title: 'Error',
388
+ state: 'error',
389
+ details: errMsg,
390
+ progress: -1,
391
+ tileCount: null,
392
+ });
393
+ }
394
+ perfMon.stop();
395
+ // Fall back to ready so the user can adjust and retry.
396
+ if (this.#generation === gen) toolbar.state = canvasArea.image ? 'ready' : 'empty';
397
+ } finally {
398
+ tracker.stop();
399
+ }
400
+
401
+ if (this.#generation === gen) {
402
+ this.#running = false;
403
+ this.#abortController = null;
404
+ controls.isRunning = false;
405
+ }
406
+ }
407
+
408
+ async #runPipeline(controls, canvasArea, perfMon, status, signal, inputImage, requestedOutputScale, getRunState) {
409
+ const modelOpt = controls.selectedModelOption;
410
+ const isBuiltInResampler = !!modelOpt?.value?.startsWith('builtin:');
411
+
412
+ if (isBuiltInResampler) {
413
+ return this.#runBuiltInResample(inputImage, modelOpt.value, requestedOutputScale, canvasArea, status, signal);
414
+ }
415
+
416
+ const config = { ...controls.config, profile: perfMon.visible };
417
+ if (perfMon.visible) perfMon.start(config.backend);
418
+
419
+ const outW = inputImage.width * config.scale;
420
+ const outH = inputImage.height * config.scale;
421
+ canvasArea.showPreview(inputImage, outW, outH);
422
+
423
+ const result = await this.#pipeline.run(inputImage, config, {
424
+ onProgress(frac, msg) {
425
+ status.set({ progress: frac, details: msg });
426
+ },
427
+ onStage(stage) {
428
+ const label = STEP_LABEL[stage.step] || stage.step;
429
+ const updates = { state: getRunState() };
430
+ if (typeof stage.progress === 'number') updates.progress = stage.progress;
431
+ if (stage.message) {
432
+ updates.title = label;
433
+ updates.details = stage.message;
434
+ } else if (stage.phase === 'start') {
435
+ updates.title = label;
436
+ }
437
+ status.set(updates);
438
+ if (perfMon.visible) perfMon.updateStage(stage);
439
+ },
440
+ onTile: (info) => {
441
+ if (info.step === 'tiledUpscale' || info.step === 'comparison') {
442
+ canvasArea.drawPreviewTile(info);
443
+ } else if (info.step === 'blendAll') {
444
+ canvasArea.drawPreviewTile(info, { opacity: config.all?.blendOpacity ?? 1 });
445
+ } else if (info.step === 'enhanceFaces' && info.composited) {
446
+ canvasArea.drawPreviewTile(info);
447
+ }
448
+ const label = STEP_LABEL[info.step] || info.step || 'Pass';
449
+ const progress = (info.index + 1) / info.total;
450
+ if (info.step === 'enhanceFaces') {
451
+ if (info.composited) {
452
+ status.set({
453
+ title: label,
454
+ state: getRunState(),
455
+ details: `face ${(info.faceIndex ?? 0) + 1}/${info.faceTotal ?? '?'} done`,
456
+ progress,
457
+ tileCount: { done: info.index + 1, total: info.total },
458
+ });
459
+ } else {
460
+ const faceN = Number.isFinite(info.faceIndex) ? info.faceIndex + 1 : null;
461
+ const faceTotal = Number.isFinite(info.faceTotal) ? info.faceTotal : null;
462
+ const facePrefix = faceN && faceTotal ? `face ${faceN}/${faceTotal}, ` : '';
463
+ const faceTileTotal = Number.isFinite(info.faceTileTotal) ? info.faceTileTotal : info.total;
464
+ status.set({
465
+ title: label,
466
+ state: getRunState(),
467
+ details: `${facePrefix}tile ${info.index + 1}/${faceTileTotal}`,
468
+ progress,
469
+ tileCount: { done: info.index + 1, total: faceTileTotal },
470
+ });
471
+ }
472
+ } else {
473
+ status.set({
474
+ title: label,
475
+ state: getRunState(),
476
+ details: `tile ${info.index + 1}/${info.total} — ${inputImage.width}×${inputImage.height} → ${outW}×${outH}`,
477
+ progress,
478
+ tileCount: { done: info.index + 1, total: info.total },
479
+ });
480
+ }
481
+ if (perfMon.visible) perfMon.update({
482
+ step: info.step,
483
+ index: info.index, total: info.total,
484
+ tileMs: info.tileMs, tilePixels: info.tilePixels, perf: info.perf,
485
+ });
486
+ },
487
+ signal,
488
+ });
489
+
490
+ if (result.perf || result.pipelinePerf) {
491
+ perfMon.showResults(result.perf, result.ortProfile, result.pipelinePerf);
492
+ }
493
+
494
+ const outputScale = Math.max(1, Math.min(requestedOutputScale, result.scale));
495
+ if (config.comparison && result.comparisonImage) {
496
+ const canvases = makeComparisonPairCanvases(result.image, result.comparisonImage, inputImage, outputScale);
497
+ return { ...canvases, scale: outputScale, comparison: true };
498
+ }
499
+ const canvases = makeComparisonCanvases(result.image, inputImage, outputScale);
500
+ return { ...canvases, scale: outputScale, comparison: false };
501
+ }
502
+
503
+ async #runBuiltInResample(inputImage, modelUrl, requestedOutputScale, canvasArea, status, signal) {
504
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
505
+
506
+ const isLanczos = modelUrl === 'builtin:lanczos-4x';
507
+ const methodLabel = isLanczos ? 'Lanczos' : 'Bicubic';
508
+ const scale = 4;
509
+ const outW = inputImage.width * scale;
510
+ const outH = inputImage.height * scale;
511
+
512
+ canvasArea.showPreview(inputImage, outW, outH);
513
+ status.set({
514
+ title: 'Resampling',
515
+ state: 'running',
516
+ details: `${methodLabel} resampling, ${inputImage.width}×${inputImage.height} → ${outW}×${outH}`,
517
+ progress: 0.25,
518
+ });
519
+
520
+ const resultCanvas = document.createElement('canvas');
521
+ resultCanvas.width = outW;
522
+ resultCanvas.height = outH;
523
+ const ctx = resultCanvas.getContext('2d');
524
+ ctx.imageSmoothingEnabled = true;
525
+ ctx.imageSmoothingQuality = isLanczos ? 'high' : 'medium';
526
+ ctx.drawImage(inputImage, 0, 0, outW, outH);
527
+
528
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
529
+
530
+ status.set({ progress: 1 });
531
+ const outputScale = Math.max(1, Math.min(requestedOutputScale, scale));
532
+ const canvases = makeComparisonCanvases(resultCanvas, inputImage, outputScale);
533
+ return { ...canvases, scale: outputScale, comparison: false };
534
+ }
535
+
536
+ // ── Template ───────────────────────────────────────────────────────────
537
+
538
+ #render() {
539
+ morph(this, `
540
+ <style>
541
+ upscaler-app .canvas-stack {
542
+ position: relative;
543
+ background: rgba(0, 0, 0, 0.4);
544
+ border-radius: var(--pico-border-radius);
545
+ padding: 0.5rem;
546
+ }
547
+ </style>
548
+ <upscaler-controls></upscaler-controls>
549
+ <div class="canvas-stack">
550
+ <upscaler-toolbar></upscaler-toolbar>
551
+ <upscaler-canvas-area></upscaler-canvas-area>
552
+ </div>
553
+ <perf-monitor></perf-monitor>
554
+ `);
555
+ }
556
+ }
557
+
558
+ customElements.define('upscaler-app', UpscalerApp);
index.html CHANGED
@@ -1,19 +1,88 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>AI Tools</title>
8
+ <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
9
+ <link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
10
+ <link rel="apple-touch-icon" sizes="180x180" href="assets/favicon-180x180.png">
11
+ <link rel="stylesheet" href="vendor/picocss/pico.min.css">
12
+ <link rel="stylesheet" href="vendor/font-awesome/css/all.min.css">
13
+ <link rel="stylesheet" href="assets/shared.css">
14
+ </head>
15
+ <body>
16
+ <nav class="container-fluid">
17
+ <ul>
18
+ <li class="brand"><strong>up<img src="assets/favicon.svg" alt="" class="brand-icon">draft</strong></li>
19
+ </ul>
20
+ <ul>
21
+ <li><a href="#" data-feature="upscaler" aria-current="page">
22
+ <i class="fas fa-expand"></i> Upscaler
23
+ </a></li>
24
+ <li><a href="#" data-feature="bg-removal">
25
+ <i class="fas fa-eraser"></i> Background Removal
26
+ </a></li>
27
+ <li><a href="https://nickcelestin.com/applications/aitools/index.html" target="_blank" rel="noopener">
28
+ <i class="fas fa-arrow-up-right-from-square"></i> Full version
29
+ </a></li>
30
+ </ul>
31
+ </nav>
32
+
33
+ <main class="container-fluid">
34
+ <section id="feature-upscaler">
35
+ <upscaler-app></upscaler-app>
36
+ </section>
37
+ <section id="feature-bg-removal" style="display: none;">
38
+ <bg-removal-app></bg-removal-app>
39
+ </section>
40
+ </main>
41
+
42
+ <script src="vendor/idiomorph/idiomorph.min.js"></script>
43
+ <script src="vendor/onnxruntime-web/ort.all.min.js"></script>
44
+ <script type="importmap">
45
+ {
46
+ "imports": {
47
+ "lib/morph": "./lib/morph.js",
48
+ "lib/fetch-progress": "./lib/fetch-progress.js",
49
+ "lib/canvas": "./lib/canvas.js",
50
+ "lib/onnx-meta": "./lib/onnx-meta.js",
51
+ "lib/backend-events": "./lib/backend-events.js",
52
+ "lib/backend": "./lib/backend.js",
53
+ "components/image-drop-zone": "./components/image-drop-zone.js",
54
+ "components/status-bar": "./components/status-bar.js",
55
+ "components/compare-slider": "./components/compare-slider.js",
56
+ "components/image-cropper": "./components/image-cropper.js",
57
+ "components/view-mode-controls": "./components/view-mode-controls.js"
58
+ }
59
+ }
60
+ </script>
61
+ <script type="module">
62
+ import './features/upscaler/upscaler-app.js';
63
+ import './features/bg-removal/bg-removal-app.js';
64
+ function activateFeature(link) {
65
+ const feature = link.dataset.feature;
66
+ document.querySelectorAll('nav a[data-feature]').forEach(l => {
67
+ if (l === link) l.setAttribute('aria-current', 'page');
68
+ else l.removeAttribute('aria-current');
69
+ });
70
+ document.querySelectorAll('[id^="feature-"]').forEach(el => {
71
+ el.style.display = el.id === 'feature-' + feature ? '' : 'none';
72
+ });
73
+ localStorage.setItem('aitools_feature', feature);
74
+ }
75
+
76
+ document.querySelectorAll('nav a[data-feature]').forEach(link => {
77
+ link.addEventListener('click', (e) => {
78
+ e.preventDefault();
79
+ activateFeature(link);
80
+ });
81
+ });
82
+
83
+ const savedFeature = localStorage.getItem('aitools_feature');
84
+ const savedLink = savedFeature && document.querySelector(`nav a[data-feature="${savedFeature}"]`);
85
+ if (savedLink) activateFeature(savedLink);
86
+ </script>
87
+ </body>
88
  </html>
lib/backend-events.js ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unified channel for inference-backend narrative events. Both the web-mode
3
+ * engine (when it tries WebGPU then falls back to WASM) and the native
4
+ * desktop bridge (when its EP ladder tries CoreML/CUDA/DML then falls back
5
+ * to CPU) emit events on `document` with the same shape:
6
+ *
7
+ * document.dispatchEvent(new CustomEvent('aitools:backend-event', {
8
+ * detail: {
9
+ * kind: 'attempt' | 'success' | 'fallback' | 'skipped',
10
+ * backend: string, // canonical EP name
11
+ * reason?: string, // short failure or skip reason
12
+ * }
13
+ * }));
14
+ *
15
+ * `trackBackendEvents()` lets a feature orchestrator subscribe for the
16
+ * duration of one run, then ask for a summary on completion.
17
+ */
18
+
19
+ const CHANNEL = 'aitools:backend-event';
20
+
21
+ export function dispatchBackendEvent(detail) {
22
+ document.dispatchEvent(new CustomEvent(CHANNEL, { detail }));
23
+ }
24
+
25
+ export function trackBackendEvents(onEvent) {
26
+ const events = [];
27
+ const handler = (e) => {
28
+ events.push(e.detail);
29
+ onEvent?.(e.detail);
30
+ };
31
+ document.addEventListener(CHANNEL, handler);
32
+ return {
33
+ stop() { document.removeEventListener(CHANNEL, handler); },
34
+ events: () => events.slice(),
35
+ summary() {
36
+ let activeBackend = null;
37
+ let hadFallback = false;
38
+ let hadSkip = false;
39
+ const lines = [];
40
+ for (const e of events) {
41
+ if (e.kind === 'success') {
42
+ activeBackend = e.backend || activeBackend;
43
+ } else if (e.kind === 'fallback') {
44
+ hadFallback = true;
45
+ const friendly = friendlyBackend(e.backend);
46
+ lines.push(e.reason ? `Failed on ${friendly}: ${e.reason}` : `Failed on ${friendly}`);
47
+ } else if (e.kind === 'skipped') {
48
+ hadSkip = true;
49
+ const friendly = friendlyBackend(e.backend);
50
+ lines.push(e.reason ? `Skipped ${friendly} (${e.reason})` : `Skipped ${friendly}`);
51
+ }
52
+ }
53
+ return { activeBackend, hadFallback, hadSkip, lines };
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Trim a runtime error message down to the first useful signal phrase, or
60
+ * the first 120 chars if no known phrase matches. ORT/native errors tend
61
+ * to be long and noisy; the tooltip wants something compact.
62
+ */
63
+ export function shortenReason(e) {
64
+ const s = String(e?.message || e || '');
65
+ const m = s.match(/Error in building plan|Non-zero status code returned|Failed to load|Could not create|cannot be reshaped|Unexpected input data type|kernel.*not.*found/i);
66
+ if (m) return m[0];
67
+ return s.length > 120 ? s.slice(0, 117) + '…' : s;
68
+ }
69
+
70
+ /**
71
+ * True if the realized backend label denotes a GPU-class execution provider.
72
+ *
73
+ * 'web-webgpu' counts; everything 'native-*' except 'native-cpu' counts
74
+ * (coreml / dml / cuda / rocm all run on the GPU or neural accelerator).
75
+ * 'web-wasm' and 'native-cpu' are CPU.
76
+ *
77
+ * Used by status-bar logic to detect intent/reality mismatches like
78
+ * "user asked for GPU but a warm CPU session is what's actually serving".
79
+ */
80
+ export function realizedIsGpu(backend) {
81
+ if (!backend) return false;
82
+ if (backend === 'web-wasm' || backend === 'native-cpu') return false;
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Map a realized backend identifier to a user-facing label.
88
+ *
89
+ * The canonical shape produced by lib/backend.js is two-part: a mode prefix
90
+ * (`web-` or `native-`) plus the concrete EP. We unpack both — the prefix
91
+ * tells the user whether they're on the browser stack or the native one,
92
+ * which materially affects perf and error shape.
93
+ *
94
+ * 'web-webgpu' → 'WebGPU'
95
+ * 'web-wasm' → 'CPU (WASM)'
96
+ * 'native-cpu' → 'CPU (native)'
97
+ * 'native-cuda' → 'CUDA'
98
+ * 'native-rocm' → 'ROCm'
99
+ * 'native-dml' → 'DirectML'
100
+ * 'native-coreml/MLProgram' → 'CoreML (MLProgram)'
101
+ *
102
+ * Legacy values from before the refactor are also accepted so a half-migrated
103
+ * caller doesn't render "unknown".
104
+ */
105
+ export function friendlyBackend(b) {
106
+ if (!b) return 'unknown';
107
+
108
+ // New-style two-part labels.
109
+ if (b === 'web-webgpu') return 'WebGPU';
110
+ if (b === 'web-wasm') return 'CPU (WASM)';
111
+ if (b.startsWith('native-')) {
112
+ const ep = b.slice('native-'.length);
113
+ if (ep === 'cpu') return 'CPU (native)';
114
+ if (ep === 'cuda') return 'CUDA';
115
+ if (ep === 'rocm') return 'ROCm';
116
+ if (ep === 'dml') return 'DirectML';
117
+ if (ep.startsWith('coreml')) {
118
+ const variant = ep.includes('/') ? ep.split('/')[1] : null;
119
+ return variant ? `CoreML (${variant})` : 'CoreML';
120
+ }
121
+ return ep;
122
+ }
123
+
124
+ // Legacy single-token forms (kept for any not-yet-migrated callers).
125
+ if (b === 'webgpu') return 'WebGPU';
126
+ if (b === 'wasm' || b === 'cpu') return 'CPU';
127
+ if (b === 'cuda') return 'CUDA';
128
+ if (b === 'dml') return 'DirectML';
129
+ if (b.startsWith('coreml')) {
130
+ const variant = b.includes('/') ? b.split('/')[1] : null;
131
+ return variant ? `CoreML (${variant})` : 'CoreML';
132
+ }
133
+ return b;
134
+ }
lib/backend.js ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Unified session loader for inference engines. One entry point, two arms.
2
+ //
3
+ // loadSession(bytes, intent, opts) -> { session, realizedBackend }
4
+ //
5
+ // intent 'gpu' | 'cpu' — what the user wants
6
+ // realizedBackend 'web-webgpu' | 'web-wasm' — what actually ran
7
+ // | 'native-cpu' | 'native-coreml/...' — (display only)
8
+ // | 'native-dml' | 'native-cuda' | ...
9
+ //
10
+ // Callers (the engines) shouldn't need to know whether we're running native
11
+ // (Electron + onnxruntime-node) or web (browser + ort-web). They pass intent
12
+ // in; they get a session-shaped object out plus a string label of what it's
13
+ // running on. No magic options-bag properties, no synthesised exceptions to
14
+ // drive control flow, no monkey-patching of ORT-Web's surface.
15
+
16
+ import { dispatchBackendEvent, shortenReason } from 'lib/backend-events';
17
+
18
+ // ───── Mode detection ──────────────────────────────────────────────────────
19
+ // `__nativeOrt` is installed by desktop/preload.cjs only when the renderer is
20
+ // running inside Electron *and* AITOOLS_NATIVE isn't explicitly disabled. Its
21
+ // presence is the entire signal — no `enabled` flag to interrogate.
22
+
23
+ export function isNativeMode() {
24
+ return !!globalThis.__nativeOrt;
25
+ }
26
+
27
+ // ───── Auto-disable on worker crash ────────────────────────────────────────
28
+ // If the native worker crashes mid-session, we trip a one-way switch so the
29
+ // rest of this page's loads fall through to the web path. Same semantics as
30
+ // the old inject.js `tripAutoDisable`, just lifted here.
31
+
32
+ let nativeAutoDisabled = false;
33
+
34
+ function isWorkerCrash(err) {
35
+ const m = err && err.message;
36
+ return !!m && /worker crashed|worker not available|native ort unavailable/i.test(m);
37
+ }
38
+
39
+ // ───── Wire tensor codec ───────────────────────────────────────────────────
40
+ // Mirrors WIRE_DTYPES in ort-worker.cjs.
41
+
42
+ const WIRE_DTYPES = {
43
+ float32: Float32Array, float16: Uint16Array,
44
+ int32: Int32Array, int64: BigInt64Array, uint8: Uint8Array,
45
+ };
46
+
47
+ // ───── Public entry point ──────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Load a model and return a session-shaped object plus the realized backend.
51
+ *
52
+ * @param {Uint8Array|ArrayBuffer} modelBytes
53
+ * @param {'gpu'|'cpu'} intent
54
+ * @param {{
55
+ * profile?: boolean,
56
+ * preferredOutputLocation?: 'gpu-buffer' | 'cpu',
57
+ * }} [opts]
58
+ * `preferredOutputLocation` is forwarded only on the web-webgpu path; it
59
+ * enables zero-readback output tensors for the upscaler's GPU fast path.
60
+ * Ignored on web-wasm and on native.
61
+ * @returns {Promise<{ session: object, realizedBackend: string }>}
62
+ */
63
+ export async function loadSession(modelBytes, intent, opts = {}) {
64
+ if (intent !== 'gpu' && intent !== 'cpu') {
65
+ throw new Error(`loadSession: unknown intent ${JSON.stringify(intent)} (expected 'gpu' or 'cpu')`);
66
+ }
67
+ if (isNativeMode() && !nativeAutoDisabled) {
68
+ try {
69
+ return await loadNative(modelBytes, intent);
70
+ } catch (e) {
71
+ if (isWorkerCrash(e)) {
72
+ nativeAutoDisabled = true;
73
+ console.warn(`[backend] native ORT auto-disabled for this page session: ${e.message} Future loads use ORT-Web. Reload to retry native.`);
74
+ // Fall through to web path so this load still has a chance.
75
+ } else {
76
+ throw e;
77
+ }
78
+ }
79
+ }
80
+ return loadWeb(modelBytes, intent, opts);
81
+ }
82
+
83
+ // ───── Native arm ──────────────────────────────────────────────────────────
84
+
85
+ let nativeSeq = 0;
86
+
87
+ async function loadNative(modelBytes, intent) {
88
+ const transferable = toArrayBuffer(modelBytes);
89
+ const key = `m${++nativeSeq}_${transferable.byteLength}`;
90
+ // Host emits attempt/fallback/skipped events via the model-event channel
91
+ // (forwarded to backend-events by desktop/inject.js). We don't synthesise
92
+ // an attempt here — let the worker's actual rung outcomes speak.
93
+ const meta = await globalThis.__nativeOrt.load(key, transferable, { intent });
94
+ console.log(`[backend] native session ${key}: ${meta.inputNames.join(',')} -> ${meta.outputNames.join(',')} via ${meta.rung}`);
95
+ return {
96
+ session: makeNativeSession(key, meta),
97
+ realizedBackend: `native-${meta.rung}`,
98
+ };
99
+ }
100
+
101
+ function makeNativeSession(key, meta) {
102
+ // ORT-Web sessions expose inputMetadata / outputMetadata as arrays of
103
+ // {name, type, dimensions}; the engines self-correct from dims so a narrow
104
+ // 'tensor(float)' placeholder is fine.
105
+ const inputMetadata = meta.inputNames.map(name => ({ name, type: 'tensor(float)', dimensions: [] }));
106
+ const outputMetadata = meta.outputNames.map(name => ({ name, type: 'tensor(float)', dimensions: [] }));
107
+
108
+ return {
109
+ inputNames: meta.inputNames,
110
+ outputNames: meta.outputNames,
111
+ inputMetadata,
112
+ outputMetadata,
113
+
114
+ async run(feeds /*, runOptions */) {
115
+ const wire = {};
116
+ for (const [name, t] of Object.entries(feeds)) {
117
+ const data = t.data;
118
+ const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
119
+ wire[name] = { type: t.type, dims: t.dims, data: ab };
120
+ }
121
+ let raw;
122
+ try {
123
+ raw = await globalThis.__nativeOrt.run(key, wire);
124
+ } catch (e) {
125
+ if (isWorkerCrash(e)) {
126
+ // Native is now dead for this page. The caller's current run still
127
+ // fails (we can't reload mid-session-run), but subsequent loadSession
128
+ // calls will fall through to the web path via nativeAutoDisabled.
129
+ nativeAutoDisabled = true;
130
+ console.warn(`[backend] native ORT auto-disabled mid-run: ${e.message}`);
131
+ }
132
+ throw e;
133
+ }
134
+ const out = {};
135
+ for (const [name, t] of Object.entries(raw)) {
136
+ const Arr = WIRE_DTYPES[t.type];
137
+ if (!Arr) throw new Error(`[backend] unsupported output tensor type: ${t.type}`);
138
+ const ortGlobal = globalThis.ort;
139
+ const tensor = new ortGlobal.Tensor(t.type, new Arr(t.data), t.dims);
140
+ if (typeof tensor.dispose !== 'function') tensor.dispose = () => {};
141
+ out[name] = tensor;
142
+ }
143
+ return out;
144
+ },
145
+
146
+ async release() {
147
+ try { await globalThis.__nativeOrt.release(key); } catch {}
148
+ },
149
+ startProfiling() {},
150
+ endProfiling() {},
151
+ };
152
+ }
153
+
154
+ // ───── Web arm ─────────────────────────────────────────────────────────────
155
+
156
+ async function loadWeb(modelBytes, intent, { profile = false, preferredOutputLocation } = {}) {
157
+ const ort = globalThis.ort;
158
+ if (!ort) throw new Error('[backend] ort-web is not loaded — include vendor/onnxruntime-web/ort.all.min.js before using loadSession');
159
+
160
+ ort.env.wasm.wasmPaths =
161
+ globalThis.__ORT_WASM_PATHS__ ||
162
+ new URL('vendor/onnxruntime-web/', document.baseURI).toString();
163
+ ort.env.wasm.numThreads = navigator.hardwareConcurrency || 4;
164
+
165
+ // ort.env.webgpu.profilingMode is global; clear when not profiling so a
166
+ // prior run can't leave it stuck on.
167
+ if (ort.env.webgpu) {
168
+ ort.env.webgpu.profilingMode = (profile && intent === 'gpu') ? 'default' : 'off';
169
+ }
170
+
171
+ const sessionOpts = {
172
+ graphOptimizationLevel: 'all',
173
+ ...(profile && { enableProfiling: true }),
174
+ };
175
+
176
+ if (intent === 'gpu') {
177
+ sessionOpts.executionProviders = [{ name: 'webgpu', preferredLayout: 'NCHW' }];
178
+ if (preferredOutputLocation) sessionOpts.preferredOutputLocation = preferredOutputLocation;
179
+ dispatchBackendEvent({ kind: 'attempt', backend: 'web-webgpu' });
180
+ try {
181
+ const session = await ort.InferenceSession.create(modelBytes, sessionOpts);
182
+ dispatchBackendEvent({ kind: 'success', backend: 'web-webgpu' });
183
+ return { session, realizedBackend: 'web-webgpu' };
184
+ } catch (e) {
185
+ console.warn(`[backend] WebGPU failed, falling back to WASM. Reason:`, e);
186
+ dispatchBackendEvent({ kind: 'fallback', backend: 'web-webgpu', reason: shortenReason(e) });
187
+ // Strip WebGPU-only opts before retrying on WASM.
188
+ delete sessionOpts.preferredOutputLocation;
189
+ }
190
+ }
191
+
192
+ sessionOpts.executionProviders = ['wasm'];
193
+ dispatchBackendEvent({ kind: 'attempt', backend: 'web-wasm' });
194
+ const session = await ort.InferenceSession.create(modelBytes, sessionOpts);
195
+ dispatchBackendEvent({ kind: 'success', backend: 'web-wasm' });
196
+ return { session, realizedBackend: 'web-wasm' };
197
+ }
198
+
199
+ // ───── helpers ─────────────────────────────────────────────────────────────
200
+
201
+ function toArrayBuffer(modelBytes) {
202
+ if (modelBytes instanceof ArrayBuffer) return modelBytes;
203
+ if (modelBytes instanceof Uint8Array) {
204
+ return modelBytes.buffer.slice(modelBytes.byteOffset, modelBytes.byteOffset + modelBytes.byteLength);
205
+ }
206
+ throw new Error(`[backend] modelBytes must be ArrayBuffer or Uint8Array, got ${typeof modelBytes}`);
207
+ }
lib/canvas.js ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function clamp(v, min, max) {
2
+ return v < min ? min : v > max ? max : v;
3
+ }
4
+
5
+ function smoothstep(edge0, edge1, x) {
6
+ if (edge1 <= edge0) return x >= edge1 ? 1 : 0;
7
+ const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
8
+ return t * t * (3 - 2 * t);
9
+ }
10
+
11
+ export function expandRect(rect, paddingPx, maxW, maxH) {
12
+ const x1 = clamp(rect.x - paddingPx, 0, maxW);
13
+ const y1 = clamp(rect.y - paddingPx, 0, maxH);
14
+ const x2 = clamp(rect.x + rect.w + paddingPx, 0, maxW);
15
+ const y2 = clamp(rect.y + rect.h + paddingPx, 0, maxH);
16
+ return {
17
+ x: Math.floor(x1),
18
+ y: Math.floor(y1),
19
+ w: Math.max(1, Math.ceil(x2 - x1)),
20
+ h: Math.max(1, Math.ceil(y2 - y1)),
21
+ };
22
+ }
23
+
24
+ export function ensureCanvas(imageLike) {
25
+ if (imageLike?.getContext?.('2d')) return imageLike;
26
+ const copy = document.createElement('canvas');
27
+ copy.width = imageLike.width;
28
+ copy.height = imageLike.height;
29
+ copy.getContext('2d').drawImage(imageLike, 0, 0);
30
+ return copy;
31
+ }
32
+
33
+ export function cropToCanvas(image, rect) {
34
+ const c = document.createElement('canvas');
35
+ c.width = rect.w;
36
+ c.height = rect.h;
37
+ const ctx = c.getContext('2d');
38
+ if (!ctx) return null;
39
+ ctx.drawImage(
40
+ image,
41
+ rect.x, rect.y, rect.w, rect.h,
42
+ 0, 0, rect.w, rect.h,
43
+ );
44
+ return c;
45
+ }
46
+
47
+ export function canvasToBlobUrl(canvas) {
48
+ return new Promise(resolve => canvas.toBlob(blob => resolve(URL.createObjectURL(blob)), 'image/png'));
49
+ }
50
+
51
+ export function imageToBlobUrl(image) {
52
+ const c = document.createElement('canvas');
53
+ c.width = image.width;
54
+ c.height = image.height;
55
+ c.getContext('2d').drawImage(image, 0, 0);
56
+ return canvasToBlobUrl(c);
57
+ }
58
+
59
+ export function blendCanvas(destCanvas, srcCanvas, opacity) {
60
+ const alpha = clamp(opacity, 0, 1);
61
+ if (alpha <= 0) return destCanvas;
62
+ const ctx = destCanvas.getContext('2d');
63
+ if (!ctx) return destCanvas;
64
+ ctx.save();
65
+ ctx.globalAlpha = alpha;
66
+ ctx.drawImage(srcCanvas, 0, 0, destCanvas.width, destCanvas.height);
67
+ ctx.restore();
68
+ return destCanvas;
69
+ }
70
+
71
+ // Composite `patch` onto `dest` at (x, y) with a feathered alpha mask.
72
+ // `innerRect` (in patch-local coords) marks the unfeathered region; pixels
73
+ // outside it fade out over `featherPx`. Without it, feathering runs from
74
+ // the patch edges inward.
75
+ export function compositeFeathered(destCanvas, patchCanvas, x, y, {
76
+ featherPx = 8,
77
+ innerRect = null,
78
+ blendOpacity = 1,
79
+ } = {}) {
80
+ const w = patchCanvas.width;
81
+ const h = patchCanvas.height;
82
+ if (w < 1 || h < 1) return false;
83
+ const opacity = clamp(blendOpacity, 0, 1);
84
+ if (opacity <= 0) return true;
85
+ const minDim = Math.min(w, h);
86
+ const t = Math.max(1, Math.min(Math.floor(featherPx), Math.floor(minDim / 2)));
87
+
88
+ const maskCanvas = document.createElement('canvas');
89
+ maskCanvas.width = w;
90
+ maskCanvas.height = h;
91
+ const mctx = maskCanvas.getContext('2d');
92
+ if (!mctx) return false;
93
+ const mask = mctx.createImageData(w, h);
94
+ const mpx = mask.data;
95
+ for (let py = 0; py < h; py++) {
96
+ for (let px = 0; px < w; px++) {
97
+ const idx = (py * w + px) * 4;
98
+ let a = 0;
99
+ if (innerRect) {
100
+ const ix1 = innerRect.x;
101
+ const iy1 = innerRect.y;
102
+ const ix2 = innerRect.x + innerRect.w;
103
+ const iy2 = innerRect.y + innerRect.h;
104
+ const ox = px < ix1 ? (ix1 - px) : px >= ix2 ? (px - ix2 + 1) : 0;
105
+ const oy = py < iy1 ? (iy1 - py) : py >= iy2 ? (py - iy2 + 1) : 0;
106
+ const d = Math.hypot(ox, oy);
107
+ a = d <= 0 ? 1 : (1 - smoothstep(0, t, d));
108
+ } else {
109
+ const dx = Math.min(px, w - 1 - px);
110
+ const dy = Math.min(py, h - 1 - py);
111
+ const d = Math.min(dx, dy);
112
+ a = smoothstep(0, t, d);
113
+ }
114
+ const alpha = Math.max(0, Math.min(255, Math.round(a * opacity * 255)));
115
+ mpx[idx] = 255;
116
+ mpx[idx + 1] = 255;
117
+ mpx[idx + 2] = 255;
118
+ mpx[idx + 3] = alpha;
119
+ }
120
+ }
121
+ mctx.putImageData(mask, 0, 0);
122
+
123
+ const patchMasked = document.createElement('canvas');
124
+ patchMasked.width = w;
125
+ patchMasked.height = h;
126
+ const pctx = patchMasked.getContext('2d');
127
+ if (!pctx) return false;
128
+ pctx.drawImage(patchCanvas, 0, 0);
129
+ pctx.globalCompositeOperation = 'destination-in';
130
+ pctx.drawImage(maskCanvas, 0, 0);
131
+
132
+ const dctx = destCanvas.getContext('2d');
133
+ if (!dctx) return false;
134
+ dctx.drawImage(patchMasked, x, y);
135
+
136
+ maskCanvas.width = 0;
137
+ maskCanvas.height = 0;
138
+ patchMasked.width = 0;
139
+ patchMasked.height = 0;
140
+ return true;
141
+ }
142
+
143
+ // Feather width (output px) for compositing a detection patch back onto an
144
+ // upscaled canvas. Scales with the detected region's min dimension, clamped
145
+ // by the padding ring and patch size so the transition stays inside the pad.
146
+ export function computeFeatherPx({
147
+ configuredFeatherPx,
148
+ regionW,
149
+ regionH,
150
+ patchW,
151
+ patchH,
152
+ paddingPx,
153
+ scale,
154
+ }) {
155
+ const minOut = 4;
156
+ const maxOut = 12;
157
+ const configuredOut = Math.max(0, configuredFeatherPx * scale);
158
+ const regionMinOut = Math.max(1, Math.min(regionW, regionH) * scale);
159
+ const regionDriven = Math.round(regionMinOut * 0.015);
160
+ let feather = clamp(
161
+ Math.max(configuredOut, regionDriven),
162
+ minOut,
163
+ maxOut,
164
+ );
165
+
166
+ const paddingOut = Math.max(0, paddingPx * scale);
167
+ if (paddingOut > 0) {
168
+ feather = Math.min(feather, Math.max(4, Math.round(paddingOut * 0.9)));
169
+ } else {
170
+ feather = Math.min(feather, 12);
171
+ }
172
+
173
+ const patchLimit = Math.max(2, Math.floor(Math.min(patchW, patchH) / 4));
174
+ return Math.max(2, Math.min(Math.round(feather), patchLimit));
175
+ }
lib/fetch-progress.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Fetch a URL with streaming download progress and Cache API persistence.
3
+ *
4
+ * Built-in models use relative URLs (e.g. `models/foo.onnx`). In the web
5
+ * app those resolve against the deploy origin and just work. In the
6
+ * desktop app they resolve against `http://127.0.0.1:<random>/`, where
7
+ * the file is only present if the user bundled it at download time. When
8
+ * the local fetch 404s and `branding.json` defines a `remoteOrigin`, we
9
+ * re-fetch from there — turning the local bundle into a preload
10
+ * optimization rather than a hard gate.
11
+ *
12
+ * Cache keys are normalised against `remoteOrigin` (when configured) so
13
+ * the Cache API entry survives across desktop launches even though the
14
+ * localhost port is ephemeral.
15
+ *
16
+ * @param {string} url
17
+ * @param {(frac: number, message: string) => void} [onProgress]
18
+ * @returns {Promise<ArrayBuffer>}
19
+ */
20
+
21
+ const MODEL_CACHE_NAME = 'aitools-models-v1';
22
+ export const CUSTOM_MODEL_URL_PREFIX = 'https://cache.aitools.local/custom-model/';
23
+ const LEGACY_CUSTOM_MODEL_URL_PREFIX = 'aitools-custom-model://';
24
+
25
+ export async function getModelCache() {
26
+ try { return await caches.open(MODEL_CACHE_NAME); }
27
+ catch { return null; }
28
+ }
29
+
30
+ export function isCustomModelUrl(url) {
31
+ return typeof url === 'string' && (
32
+ url.startsWith(CUSTOM_MODEL_URL_PREFIX) ||
33
+ url.startsWith(LEGACY_CUSTOM_MODEL_URL_PREFIX)
34
+ );
35
+ }
36
+
37
+ function isRelativeHttpUrl(url) {
38
+ return typeof url === 'string' && !/^https?:\/\//i.test(url) && !isCustomModelUrl(url);
39
+ }
40
+
41
+ let _remoteOriginPromise = null;
42
+ function getRemoteOrigin() {
43
+ if (_remoteOriginPromise) return _remoteOriginPromise;
44
+ _remoteOriginPromise = fetch('branding.json', { cache: 'no-store' })
45
+ .then((r) => (r.ok ? r.json() : null))
46
+ .then((b) => b?.remoteOrigin || null)
47
+ .catch(() => null);
48
+ return _remoteOriginPromise;
49
+ }
50
+
51
+ // A cache key that doesn't depend on the (possibly ephemeral) origin we
52
+ // fetched from. In desktop mode the localhost port changes each launch,
53
+ // so caching under that URL would mean a fresh download every run.
54
+ async function stableCacheKey(url) {
55
+ if (!isRelativeHttpUrl(url)) return url;
56
+ const remoteOrigin = await getRemoteOrigin();
57
+ return remoteOrigin ? new URL(url, remoteOrigin).toString() : url;
58
+ }
59
+
60
+ export async function fetchWithProgress(url, onProgress) {
61
+ if (onProgress != null && typeof onProgress !== 'function') {
62
+ console.warn('[fetchWithProgress] Ignoring non-function onProgress callback.', {
63
+ type: typeof onProgress,
64
+ value: onProgress,
65
+ url,
66
+ });
67
+ }
68
+ const report = typeof onProgress === 'function' ? onProgress : null;
69
+ const cache = await getModelCache();
70
+ const cacheKey = await stableCacheKey(url);
71
+
72
+ if (cache) {
73
+ const cached = await cache.match(cacheKey);
74
+ if (cached) {
75
+ report?.(1, 'Loading model from cache…');
76
+ return cached.arrayBuffer();
77
+ }
78
+ if (isCustomModelUrl(url)) {
79
+ throw new Error('Custom model not found in cache. Please re-upload the model file.');
80
+ }
81
+ }
82
+
83
+ report?.(0, 'Downloading model…');
84
+ let resp = await fetch(url);
85
+ if (!resp.ok && resp.status === 404 && isRelativeHttpUrl(url)) {
86
+ const remoteOrigin = await getRemoteOrigin();
87
+ if (remoteOrigin) {
88
+ const remoteUrl = new URL(url, remoteOrigin).toString();
89
+ report?.(0, 'Model not in local bundle, fetching from server…');
90
+ resp = await fetch(remoteUrl);
91
+ }
92
+ }
93
+ if (!resp.ok) throw new Error(`Model download failed: HTTP ${resp.status}`);
94
+
95
+ const respForCache = resp.clone();
96
+
97
+ const total = parseInt(resp.headers.get('content-length') || '0', 10);
98
+ const reader = resp.body.getReader();
99
+ const chunks = [];
100
+ let loaded = 0;
101
+
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ chunks.push(value);
106
+ loaded += value.length;
107
+ if (total) {
108
+ const frac = loaded / total;
109
+ report?.(frac, `Downloading model… ${(loaded / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB`);
110
+ }
111
+ }
112
+
113
+ if (cache) {
114
+ cache.put(cacheKey, respForCache).catch(() => {});
115
+ }
116
+
117
+ const buf = new Uint8Array(loaded);
118
+ let off = 0;
119
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
120
+ return buf.buffer;
121
+ }
lib/morph.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Idiomorph wrapper — uses the global Idiomorph loaded via CDN.
3
+ * Prevents nested custom elements from being torn down and re-created.
4
+ */
5
+
6
+ export function morph(component, html) {
7
+ Idiomorph.morph(component, html, {
8
+ morphStyle: 'innerHTML',
9
+ ignoreActiveValue: true,
10
+ callbacks: {
11
+ beforeNodeMorphed(oldNode, newNode) {
12
+ if (isNestedCustomElement(oldNode, component)) {
13
+ syncAttributes(oldNode, newNode);
14
+ return false;
15
+ }
16
+ }
17
+ }
18
+ });
19
+ }
20
+
21
+ function isNestedCustomElement(node, owner) {
22
+ return node instanceof Element && node.tagName.includes('-') && node !== owner;
23
+ }
24
+
25
+ function syncAttributes(oldNode, newNode) {
26
+ if (!(newNode instanceof Element)) return;
27
+ for (const attr of newNode.attributes) {
28
+ if (oldNode.getAttribute(attr.name) !== attr.value) {
29
+ oldNode.setAttribute(attr.name, attr.value);
30
+ }
31
+ }
32
+ }
lib/onnx-meta.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ORT-Web 1.18+ exposes inputMetadata/outputMetadata as a readonly array of
2
+ // ValueMetadata ordered to match inputNames/outputNames. Older versions
3
+ // exposed it as a Record keyed by tensor name. Accept both shapes.
4
+ export function readMetaEntry(metaCollection, name, index = 0) {
5
+ if (!metaCollection) return null;
6
+ if (Array.isArray(metaCollection)) {
7
+ if (name) {
8
+ const byName = metaCollection.find((m) => m?.name === name);
9
+ if (byName) return byName;
10
+ }
11
+ return metaCollection[index] || null;
12
+ }
13
+ return (name && metaCollection[name]) || null;
14
+ }
15
+
16
+ export function isFp16InputType(inputType) {
17
+ return typeof inputType === 'string' && inputType.toLowerCase().includes('float16');
18
+ }
models/4x-ClearRealityV1.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:58381c8df79457670dbb286d36921a044c506eacfe8826bbb6aefc644826f91a
3
+ size 1904717
models/4x-UltraSharpV2_Lite.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:931e668b639175e4e98a993b5bc7fed65685f3583bad57a0d132bc125a66e349
3
+ size 29745754
models/4x-UpdraftSmall.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c7c550b52fbe36e03670fe1a229395c740111d002d361c3520cfa831a7a7f7dc
3
+ size 1414617
models/DAT_light_x4_dyn_OTF_4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d5ece82fef2c1511b80f77b3fe014297dfc21e38320c47983991e0e909428e5
3
+ size 5085946
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/fflate/index.mjs ADDED
@@ -0,0 +1,2665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DEFLATE is a complex format; to read this code, you should probably check the RFC first:
2
+ // https://tools.ietf.org/html/rfc1951
3
+ // You may also wish to take a look at the guide I made about this program:
4
+ // https://gist.github.com/101arrowz/253f31eb5abc3d9275ab943003ffecad
5
+ // Some of the following code is similar to that of UZIP.js:
6
+ // https://github.com/photopea/UZIP.js
7
+ // However, the vast majority of the codebase has diverged from UZIP.js to increase performance and reduce bundle size.
8
+ // Sometimes 0 will appear where -1 would be more appropriate. This is because using a uint
9
+ // is better for memory in most engines (I *think*).
10
+ var ch2 = {};
11
+ var wk = (function (c, id, msg, transfer, cb) {
12
+ var w = new Worker(ch2[id] || (ch2[id] = URL.createObjectURL(new Blob([
13
+ c + ';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'
14
+ ], { type: 'text/javascript' }))));
15
+ w.onmessage = function (e) {
16
+ var d = e.data, ed = d.$e$;
17
+ if (ed) {
18
+ var err = new Error(ed[0]);
19
+ err['code'] = ed[1];
20
+ err.stack = ed[2];
21
+ cb(err, null);
22
+ }
23
+ else
24
+ cb(null, d);
25
+ };
26
+ w.postMessage(msg, transfer);
27
+ return w;
28
+ });
29
+
30
+ // aliases for shorter compressed code (most minifers don't do this)
31
+ var u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array;
32
+ // fixed length extra bits
33
+ var fleb = new u8([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, /* unused */ 0, 0, /* impossible */ 0]);
34
+ // fixed distance extra bits
35
+ var fdeb = new u8([0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, /* unused */ 0, 0]);
36
+ // code length index map
37
+ var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
38
+ // get base, reverse index map from extra bits
39
+ var freb = function (eb, start) {
40
+ var b = new u16(31);
41
+ for (var i = 0; i < 31; ++i) {
42
+ b[i] = start += 1 << eb[i - 1];
43
+ }
44
+ // numbers here are at max 18 bits
45
+ var r = new i32(b[30]);
46
+ for (var i = 1; i < 30; ++i) {
47
+ for (var j = b[i]; j < b[i + 1]; ++j) {
48
+ r[j] = ((j - b[i]) << 5) | i;
49
+ }
50
+ }
51
+ return { b: b, r: r };
52
+ };
53
+ var _a = freb(fleb, 2), fl = _a.b, revfl = _a.r;
54
+ // we can ignore the fact that the other numbers are wrong; they never happen anyway
55
+ fl[28] = 258, revfl[258] = 28;
56
+ var _b = freb(fdeb, 0), fd = _b.b, revfd = _b.r;
57
+ // map of value to reverse (assuming 16 bits)
58
+ var rev = new u16(32768);
59
+ for (var i = 0; i < 32768; ++i) {
60
+ // reverse table algorithm from SO
61
+ var x = ((i & 0xAAAA) >> 1) | ((i & 0x5555) << 1);
62
+ x = ((x & 0xCCCC) >> 2) | ((x & 0x3333) << 2);
63
+ x = ((x & 0xF0F0) >> 4) | ((x & 0x0F0F) << 4);
64
+ rev[i] = (((x & 0xFF00) >> 8) | ((x & 0x00FF) << 8)) >> 1;
65
+ }
66
+ // create huffman tree from u8 "map": index -> code length for code index
67
+ // mb (max bits) must be at most 15
68
+ // TODO: optimize/split up?
69
+ var hMap = (function (cd, mb, r) {
70
+ var s = cd.length;
71
+ // index
72
+ var i = 0;
73
+ // u16 "map": index -> # of codes with bit length = index
74
+ var l = new u16(mb);
75
+ // length of cd must be 288 (total # of codes)
76
+ for (; i < s; ++i) {
77
+ if (cd[i])
78
+ ++l[cd[i] - 1];
79
+ }
80
+ // u16 "map": index -> minimum code for bit length = index
81
+ var le = new u16(mb);
82
+ for (i = 1; i < mb; ++i) {
83
+ le[i] = (le[i - 1] + l[i - 1]) << 1;
84
+ }
85
+ var co;
86
+ if (r) {
87
+ // u16 "map": index -> number of actual bits, symbol for code
88
+ co = new u16(1 << mb);
89
+ // bits to remove for reverser
90
+ var rvb = 15 - mb;
91
+ for (i = 0; i < s; ++i) {
92
+ // ignore 0 lengths
93
+ if (cd[i]) {
94
+ // num encoding both symbol and bits read
95
+ var sv = (i << 4) | cd[i];
96
+ // free bits
97
+ var r_1 = mb - cd[i];
98
+ // start value
99
+ var v = le[cd[i] - 1]++ << r_1;
100
+ // m is end value
101
+ for (var m = v | ((1 << r_1) - 1); v <= m; ++v) {
102
+ // every 16 bit value starting with the code yields the same result
103
+ co[rev[v] >> rvb] = sv;
104
+ }
105
+ }
106
+ }
107
+ }
108
+ else {
109
+ co = new u16(s);
110
+ for (i = 0; i < s; ++i) {
111
+ if (cd[i]) {
112
+ co[i] = rev[le[cd[i] - 1]++] >> (15 - cd[i]);
113
+ }
114
+ }
115
+ }
116
+ return co;
117
+ });
118
+ // fixed length tree
119
+ var flt = new u8(288);
120
+ for (var i = 0; i < 144; ++i)
121
+ flt[i] = 8;
122
+ for (var i = 144; i < 256; ++i)
123
+ flt[i] = 9;
124
+ for (var i = 256; i < 280; ++i)
125
+ flt[i] = 7;
126
+ for (var i = 280; i < 288; ++i)
127
+ flt[i] = 8;
128
+ // fixed distance tree
129
+ var fdt = new u8(32);
130
+ for (var i = 0; i < 32; ++i)
131
+ fdt[i] = 5;
132
+ // fixed length map
133
+ var flm = /*#__PURE__*/ hMap(flt, 9, 0), flrm = /*#__PURE__*/ hMap(flt, 9, 1);
134
+ // fixed distance map
135
+ var fdm = /*#__PURE__*/ hMap(fdt, 5, 0), fdrm = /*#__PURE__*/ hMap(fdt, 5, 1);
136
+ // find max of array
137
+ var max = function (a) {
138
+ var m = a[0];
139
+ for (var i = 1; i < a.length; ++i) {
140
+ if (a[i] > m)
141
+ m = a[i];
142
+ }
143
+ return m;
144
+ };
145
+ // read d, starting at bit p and mask with m
146
+ var bits = function (d, p, m) {
147
+ var o = (p / 8) | 0;
148
+ return ((d[o] | (d[o + 1] << 8)) >> (p & 7)) & m;
149
+ };
150
+ // read d, starting at bit p continuing for at least 16 bits
151
+ var bits16 = function (d, p) {
152
+ var o = (p / 8) | 0;
153
+ return ((d[o] | (d[o + 1] << 8) | (d[o + 2] << 16)) >> (p & 7));
154
+ };
155
+ // get end of byte
156
+ var shft = function (p) { return ((p + 7) / 8) | 0; };
157
+ // typed array slice - allows garbage collector to free original reference,
158
+ // while being more compatible than .slice
159
+ var slc = function (v, s, e) {
160
+ if (s == null || s < 0)
161
+ s = 0;
162
+ if (e == null || e > v.length)
163
+ e = v.length;
164
+ // can't use .constructor in case user-supplied
165
+ return new u8(v.subarray(s, e));
166
+ };
167
+ /**
168
+ * Codes for errors generated within this library
169
+ */
170
+ export var FlateErrorCode = {
171
+ UnexpectedEOF: 0,
172
+ InvalidBlockType: 1,
173
+ InvalidLengthLiteral: 2,
174
+ InvalidDistance: 3,
175
+ StreamFinished: 4,
176
+ NoStreamHandler: 5,
177
+ InvalidHeader: 6,
178
+ NoCallback: 7,
179
+ InvalidUTF8: 8,
180
+ ExtraFieldTooLong: 9,
181
+ InvalidDate: 10,
182
+ FilenameTooLong: 11,
183
+ StreamFinishing: 12,
184
+ InvalidZipData: 13,
185
+ UnknownCompressionMethod: 14
186
+ };
187
+ // error codes
188
+ var ec = [
189
+ 'unexpected EOF',
190
+ 'invalid block type',
191
+ 'invalid length/literal',
192
+ 'invalid distance',
193
+ 'stream finished',
194
+ 'no stream handler',
195
+ ,
196
+ 'no callback',
197
+ 'invalid UTF-8 data',
198
+ 'extra field too long',
199
+ 'date not in range 1980-2099',
200
+ 'filename too long',
201
+ 'stream finishing',
202
+ 'invalid zip data'
203
+ // determined by unknown compression method
204
+ ];
205
+ ;
206
+ var err = function (ind, msg, nt) {
207
+ var e = new Error(msg || ec[ind]);
208
+ e.code = ind;
209
+ if (Error.captureStackTrace)
210
+ Error.captureStackTrace(e, err);
211
+ if (!nt)
212
+ throw e;
213
+ return e;
214
+ };
215
+ // expands raw DEFLATE data
216
+ var inflt = function (dat, st, buf, dict) {
217
+ // source length dict length
218
+ var sl = dat.length, dl = dict ? dict.length : 0;
219
+ if (!sl || st.f && !st.l)
220
+ return buf || new u8(0);
221
+ var noBuf = !buf;
222
+ // have to estimate size
223
+ var resize = noBuf || st.i != 2;
224
+ // no state
225
+ var noSt = st.i;
226
+ // Assumes roughly 33% compression ratio average
227
+ if (noBuf)
228
+ buf = new u8(sl * 3);
229
+ // ensure buffer can fit at least l elements
230
+ var cbuf = function (l) {
231
+ var bl = buf.length;
232
+ // need to increase size to fit
233
+ if (l > bl) {
234
+ // Double or set to necessary, whichever is greater
235
+ var nbuf = new u8(Math.max(bl * 2, l));
236
+ nbuf.set(buf);
237
+ buf = nbuf;
238
+ }
239
+ };
240
+ // last chunk bitpos bytes
241
+ var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
242
+ // total bits
243
+ var tbts = sl * 8;
244
+ do {
245
+ if (!lm) {
246
+ // BFINAL - this is only 1 when last chunk is next
247
+ final = bits(dat, pos, 1);
248
+ // type: 0 = no compression, 1 = fixed huffman, 2 = dynamic huffman
249
+ var type = bits(dat, pos + 1, 3);
250
+ pos += 3;
251
+ if (!type) {
252
+ // go to end of byte boundary
253
+ var s = shft(pos) + 4, l = dat[s - 4] | (dat[s - 3] << 8), t = s + l;
254
+ if (t > sl) {
255
+ if (noSt)
256
+ err(0);
257
+ break;
258
+ }
259
+ // ensure size
260
+ if (resize)
261
+ cbuf(bt + l);
262
+ // Copy over uncompressed data
263
+ buf.set(dat.subarray(s, t), bt);
264
+ // Get new bitpos, update byte count
265
+ st.b = bt += l, st.p = pos = t * 8, st.f = final;
266
+ continue;
267
+ }
268
+ else if (type == 1)
269
+ lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
270
+ else if (type == 2) {
271
+ // literal lengths
272
+ var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
273
+ var tl = hLit + bits(dat, pos + 5, 31) + 1;
274
+ pos += 14;
275
+ // length+distance tree
276
+ var ldt = new u8(tl);
277
+ // code length tree
278
+ var clt = new u8(19);
279
+ for (var i = 0; i < hcLen; ++i) {
280
+ // use index map to get real code
281
+ clt[clim[i]] = bits(dat, pos + i * 3, 7);
282
+ }
283
+ pos += hcLen * 3;
284
+ // code lengths bits
285
+ var clb = max(clt), clbmsk = (1 << clb) - 1;
286
+ // code lengths map
287
+ var clm = hMap(clt, clb, 1);
288
+ for (var i = 0; i < tl;) {
289
+ var r = clm[bits(dat, pos, clbmsk)];
290
+ // bits read
291
+ pos += r & 15;
292
+ // symbol
293
+ var s = r >> 4;
294
+ // code length to copy
295
+ if (s < 16) {
296
+ ldt[i++] = s;
297
+ }
298
+ else {
299
+ // copy count
300
+ var c = 0, n = 0;
301
+ if (s == 16)
302
+ n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
303
+ else if (s == 17)
304
+ n = 3 + bits(dat, pos, 7), pos += 3;
305
+ else if (s == 18)
306
+ n = 11 + bits(dat, pos, 127), pos += 7;
307
+ while (n--)
308
+ ldt[i++] = c;
309
+ }
310
+ }
311
+ // length tree distance tree
312
+ var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
313
+ // max length bits
314
+ lbt = max(lt);
315
+ // max dist bits
316
+ dbt = max(dt);
317
+ lm = hMap(lt, lbt, 1);
318
+ dm = hMap(dt, dbt, 1);
319
+ }
320
+ else
321
+ err(1);
322
+ if (pos > tbts) {
323
+ if (noSt)
324
+ err(0);
325
+ break;
326
+ }
327
+ }
328
+ // Make sure the buffer can hold this + the largest possible addition
329
+ // Maximum chunk size (practically, theoretically infinite) is 2^17
330
+ if (resize)
331
+ cbuf(bt + 131072);
332
+ var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
333
+ var lpos = pos;
334
+ for (;; lpos = pos) {
335
+ // bits read, code
336
+ var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
337
+ pos += c & 15;
338
+ if (pos > tbts) {
339
+ if (noSt)
340
+ err(0);
341
+ break;
342
+ }
343
+ if (!c)
344
+ err(2);
345
+ if (sym < 256)
346
+ buf[bt++] = sym;
347
+ else if (sym == 256) {
348
+ lpos = pos, lm = null;
349
+ break;
350
+ }
351
+ else {
352
+ var add = sym - 254;
353
+ // no extra bits needed if less
354
+ if (sym > 264) {
355
+ // index
356
+ var i = sym - 257, b = fleb[i];
357
+ add = bits(dat, pos, (1 << b) - 1) + fl[i];
358
+ pos += b;
359
+ }
360
+ // dist
361
+ var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
362
+ if (!d)
363
+ err(3);
364
+ pos += d & 15;
365
+ var dt = fd[dsym];
366
+ if (dsym > 3) {
367
+ var b = fdeb[dsym];
368
+ dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
369
+ }
370
+ if (pos > tbts) {
371
+ if (noSt)
372
+ err(0);
373
+ break;
374
+ }
375
+ if (resize)
376
+ cbuf(bt + 131072);
377
+ var end = bt + add;
378
+ if (bt < dt) {
379
+ var shift = dl - dt, dend = Math.min(dt, end);
380
+ if (shift + bt < 0)
381
+ err(3);
382
+ for (; bt < dend; ++bt)
383
+ buf[bt] = dict[shift + bt];
384
+ }
385
+ for (; bt < end; ++bt)
386
+ buf[bt] = buf[bt - dt];
387
+ }
388
+ }
389
+ st.l = lm, st.p = lpos, st.b = bt, st.f = final;
390
+ if (lm)
391
+ final = 1, st.m = lbt, st.d = dm, st.n = dbt;
392
+ } while (!final);
393
+ // don't reallocate for streams or user buffers
394
+ return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
395
+ };
396
+ // starting at p, write the minimum number of bits that can hold v to d
397
+ var wbits = function (d, p, v) {
398
+ v <<= p & 7;
399
+ var o = (p / 8) | 0;
400
+ d[o] |= v;
401
+ d[o + 1] |= v >> 8;
402
+ };
403
+ // starting at p, write the minimum number of bits (>8) that can hold v to d
404
+ var wbits16 = function (d, p, v) {
405
+ v <<= p & 7;
406
+ var o = (p / 8) | 0;
407
+ d[o] |= v;
408
+ d[o + 1] |= v >> 8;
409
+ d[o + 2] |= v >> 16;
410
+ };
411
+ // creates code lengths from a frequency table
412
+ var hTree = function (d, mb) {
413
+ // Need extra info to make a tree
414
+ var t = [];
415
+ for (var i = 0; i < d.length; ++i) {
416
+ if (d[i])
417
+ t.push({ s: i, f: d[i] });
418
+ }
419
+ var s = t.length;
420
+ var t2 = t.slice();
421
+ if (!s)
422
+ return { t: et, l: 0 };
423
+ if (s == 1) {
424
+ var v = new u8(t[0].s + 1);
425
+ v[t[0].s] = 1;
426
+ return { t: v, l: 1 };
427
+ }
428
+ t.sort(function (a, b) { return a.f - b.f; });
429
+ // after i2 reaches last ind, will be stopped
430
+ // freq must be greater than largest possible number of symbols
431
+ t.push({ s: -1, f: 25001 });
432
+ var l = t[0], r = t[1], i0 = 0, i1 = 1, i2 = 2;
433
+ t[0] = { s: -1, f: l.f + r.f, l: l, r: r };
434
+ // efficient algorithm from UZIP.js
435
+ // i0 is lookbehind, i2 is lookahead - after processing two low-freq
436
+ // symbols that combined have high freq, will start processing i2 (high-freq,
437
+ // non-composite) symbols instead
438
+ // see https://reddit.com/r/photopea/comments/ikekht/uzipjs_questions/
439
+ while (i1 != s - 1) {
440
+ l = t[t[i0].f < t[i2].f ? i0++ : i2++];
441
+ r = t[i0 != i1 && t[i0].f < t[i2].f ? i0++ : i2++];
442
+ t[i1++] = { s: -1, f: l.f + r.f, l: l, r: r };
443
+ }
444
+ var maxSym = t2[0].s;
445
+ for (var i = 1; i < s; ++i) {
446
+ if (t2[i].s > maxSym)
447
+ maxSym = t2[i].s;
448
+ }
449
+ // code lengths
450
+ var tr = new u16(maxSym + 1);
451
+ // max bits in tree
452
+ var mbt = ln(t[i1 - 1], tr, 0);
453
+ if (mbt > mb) {
454
+ // more algorithms from UZIP.js
455
+ // TODO: find out how this code works (debt)
456
+ // ind debt
457
+ var i = 0, dt = 0;
458
+ // left cost
459
+ var lft = mbt - mb, cst = 1 << lft;
460
+ t2.sort(function (a, b) { return tr[b.s] - tr[a.s] || a.f - b.f; });
461
+ for (; i < s; ++i) {
462
+ var i2_1 = t2[i].s;
463
+ if (tr[i2_1] > mb) {
464
+ dt += cst - (1 << (mbt - tr[i2_1]));
465
+ tr[i2_1] = mb;
466
+ }
467
+ else
468
+ break;
469
+ }
470
+ dt >>= lft;
471
+ while (dt > 0) {
472
+ var i2_2 = t2[i].s;
473
+ if (tr[i2_2] < mb)
474
+ dt -= 1 << (mb - tr[i2_2]++ - 1);
475
+ else
476
+ ++i;
477
+ }
478
+ for (; i >= 0 && dt; --i) {
479
+ var i2_3 = t2[i].s;
480
+ if (tr[i2_3] == mb) {
481
+ --tr[i2_3];
482
+ ++dt;
483
+ }
484
+ }
485
+ mbt = mb;
486
+ }
487
+ return { t: new u8(tr), l: mbt };
488
+ };
489
+ // get the max length and assign length codes
490
+ var ln = function (n, l, d) {
491
+ return n.s == -1
492
+ ? Math.max(ln(n.l, l, d + 1), ln(n.r, l, d + 1))
493
+ : (l[n.s] = d);
494
+ };
495
+ // length codes generation
496
+ var lc = function (c) {
497
+ var s = c.length;
498
+ // Note that the semicolon was intentional
499
+ while (s && !c[--s])
500
+ ;
501
+ var cl = new u16(++s);
502
+ // ind num streak
503
+ var cli = 0, cln = c[0], cls = 1;
504
+ var w = function (v) { cl[cli++] = v; };
505
+ for (var i = 1; i <= s; ++i) {
506
+ if (c[i] == cln && i != s)
507
+ ++cls;
508
+ else {
509
+ if (!cln && cls > 2) {
510
+ for (; cls > 138; cls -= 138)
511
+ w(32754);
512
+ if (cls > 2) {
513
+ w(cls > 10 ? ((cls - 11) << 5) | 28690 : ((cls - 3) << 5) | 12305);
514
+ cls = 0;
515
+ }
516
+ }
517
+ else if (cls > 3) {
518
+ w(cln), --cls;
519
+ for (; cls > 6; cls -= 6)
520
+ w(8304);
521
+ if (cls > 2)
522
+ w(((cls - 3) << 5) | 8208), cls = 0;
523
+ }
524
+ while (cls--)
525
+ w(cln);
526
+ cls = 1;
527
+ cln = c[i];
528
+ }
529
+ }
530
+ return { c: cl.subarray(0, cli), n: s };
531
+ };
532
+ // calculate the length of output from tree, code lengths
533
+ var clen = function (cf, cl) {
534
+ var l = 0;
535
+ for (var i = 0; i < cl.length; ++i)
536
+ l += cf[i] * cl[i];
537
+ return l;
538
+ };
539
+ // writes a fixed block
540
+ // returns the new bit pos
541
+ var wfblk = function (out, pos, dat) {
542
+ // no need to write 00 as type: TypedArray defaults to 0
543
+ var s = dat.length;
544
+ var o = shft(pos + 2);
545
+ out[o] = s & 255;
546
+ out[o + 1] = s >> 8;
547
+ out[o + 2] = out[o] ^ 255;
548
+ out[o + 3] = out[o + 1] ^ 255;
549
+ for (var i = 0; i < s; ++i)
550
+ out[o + i + 4] = dat[i];
551
+ return (o + 4 + s) * 8;
552
+ };
553
+ // writes a block
554
+ var wblk = function (dat, out, final, syms, lf, df, eb, li, bs, bl, p) {
555
+ wbits(out, p++, final);
556
+ ++lf[256];
557
+ var _a = hTree(lf, 15), dlt = _a.t, mlb = _a.l;
558
+ var _b = hTree(df, 15), ddt = _b.t, mdb = _b.l;
559
+ var _c = lc(dlt), lclt = _c.c, nlc = _c.n;
560
+ var _d = lc(ddt), lcdt = _d.c, ndc = _d.n;
561
+ var lcfreq = new u16(19);
562
+ for (var i = 0; i < lclt.length; ++i)
563
+ ++lcfreq[lclt[i] & 31];
564
+ for (var i = 0; i < lcdt.length; ++i)
565
+ ++lcfreq[lcdt[i] & 31];
566
+ var _e = hTree(lcfreq, 7), lct = _e.t, mlcb = _e.l;
567
+ var nlcc = 19;
568
+ for (; nlcc > 4 && !lct[clim[nlcc - 1]]; --nlcc)
569
+ ;
570
+ var flen = (bl + 5) << 3;
571
+ var ftlen = clen(lf, flt) + clen(df, fdt) + eb;
572
+ var dtlen = clen(lf, dlt) + clen(df, ddt) + eb + 14 + 3 * nlcc + clen(lcfreq, lct) + 2 * lcfreq[16] + 3 * lcfreq[17] + 7 * lcfreq[18];
573
+ if (bs >= 0 && flen <= ftlen && flen <= dtlen)
574
+ return wfblk(out, p, dat.subarray(bs, bs + bl));
575
+ var lm, ll, dm, dl;
576
+ wbits(out, p, 1 + (dtlen < ftlen)), p += 2;
577
+ if (dtlen < ftlen) {
578
+ lm = hMap(dlt, mlb, 0), ll = dlt, dm = hMap(ddt, mdb, 0), dl = ddt;
579
+ var llm = hMap(lct, mlcb, 0);
580
+ wbits(out, p, nlc - 257);
581
+ wbits(out, p + 5, ndc - 1);
582
+ wbits(out, p + 10, nlcc - 4);
583
+ p += 14;
584
+ for (var i = 0; i < nlcc; ++i)
585
+ wbits(out, p + 3 * i, lct[clim[i]]);
586
+ p += 3 * nlcc;
587
+ var lcts = [lclt, lcdt];
588
+ for (var it = 0; it < 2; ++it) {
589
+ var clct = lcts[it];
590
+ for (var i = 0; i < clct.length; ++i) {
591
+ var len = clct[i] & 31;
592
+ wbits(out, p, llm[len]), p += lct[len];
593
+ if (len > 15)
594
+ wbits(out, p, (clct[i] >> 5) & 127), p += clct[i] >> 12;
595
+ }
596
+ }
597
+ }
598
+ else {
599
+ lm = flm, ll = flt, dm = fdm, dl = fdt;
600
+ }
601
+ for (var i = 0; i < li; ++i) {
602
+ var sym = syms[i];
603
+ if (sym > 255) {
604
+ var len = (sym >> 18) & 31;
605
+ wbits16(out, p, lm[len + 257]), p += ll[len + 257];
606
+ if (len > 7)
607
+ wbits(out, p, (sym >> 23) & 31), p += fleb[len];
608
+ var dst = sym & 31;
609
+ wbits16(out, p, dm[dst]), p += dl[dst];
610
+ if (dst > 3)
611
+ wbits16(out, p, (sym >> 5) & 8191), p += fdeb[dst];
612
+ }
613
+ else {
614
+ wbits16(out, p, lm[sym]), p += ll[sym];
615
+ }
616
+ }
617
+ wbits16(out, p, lm[256]);
618
+ return p + ll[256];
619
+ };
620
+ // deflate options (nice << 13) | chain
621
+ var deo = /*#__PURE__*/ new i32([65540, 131080, 131088, 131104, 262176, 1048704, 1048832, 2114560, 2117632]);
622
+ // empty
623
+ var et = /*#__PURE__*/ new u8(0);
624
+ // compresses data into a raw DEFLATE buffer
625
+ var dflt = function (dat, lvl, plvl, pre, post, st) {
626
+ var s = st.z || dat.length;
627
+ var o = new u8(pre + s + 5 * (1 + Math.ceil(s / 7000)) + post);
628
+ // writing to this writes to the output buffer
629
+ var w = o.subarray(pre, o.length - post);
630
+ var lst = st.l;
631
+ var pos = (st.r || 0) & 7;
632
+ if (lvl) {
633
+ if (pos)
634
+ w[0] = st.r >> 3;
635
+ var opt = deo[lvl - 1];
636
+ var n = opt >> 13, c = opt & 8191;
637
+ var msk_1 = (1 << plvl) - 1;
638
+ // prev 2-byte val map curr 2-byte val map
639
+ var prev = st.p || new u16(32768), head = st.h || new u16(msk_1 + 1);
640
+ var bs1_1 = Math.ceil(plvl / 3), bs2_1 = 2 * bs1_1;
641
+ var hsh = function (i) { return (dat[i] ^ (dat[i + 1] << bs1_1) ^ (dat[i + 2] << bs2_1)) & msk_1; };
642
+ // 24576 is an arbitrary number of maximum symbols per block
643
+ // 424 buffer for last block
644
+ var syms = new i32(25000);
645
+ // length/literal freq distance freq
646
+ var lf = new u16(288), df = new u16(32);
647
+ // l/lcnt exbits index l/lind waitdx blkpos
648
+ var lc_1 = 0, eb = 0, i = st.i || 0, li = 0, wi = st.w || 0, bs = 0;
649
+ for (; i + 2 < s; ++i) {
650
+ // hash value
651
+ var hv = hsh(i);
652
+ // index mod 32768 previous index mod
653
+ var imod = i & 32767, pimod = head[hv];
654
+ prev[imod] = pimod;
655
+ head[hv] = imod;
656
+ // We always should modify head and prev, but only add symbols if
657
+ // this data is not yet processed ("wait" for wait index)
658
+ if (wi <= i) {
659
+ // bytes remaining
660
+ var rem = s - i;
661
+ if ((lc_1 > 7000 || li > 24576) && (rem > 423 || !lst)) {
662
+ pos = wblk(dat, w, 0, syms, lf, df, eb, li, bs, i - bs, pos);
663
+ li = lc_1 = eb = 0, bs = i;
664
+ for (var j = 0; j < 286; ++j)
665
+ lf[j] = 0;
666
+ for (var j = 0; j < 30; ++j)
667
+ df[j] = 0;
668
+ }
669
+ // len dist chain
670
+ var l = 2, d = 0, ch_1 = c, dif = imod - pimod & 32767;
671
+ if (rem > 2 && hv == hsh(i - dif)) {
672
+ var maxn = Math.min(n, rem) - 1;
673
+ var maxd = Math.min(32767, i);
674
+ // max possible length
675
+ // not capped at dif because decompressors implement "rolling" index population
676
+ var ml = Math.min(258, rem);
677
+ while (dif <= maxd && --ch_1 && imod != pimod) {
678
+ if (dat[i + l] == dat[i + l - dif]) {
679
+ var nl = 0;
680
+ for (; nl < ml && dat[i + nl] == dat[i + nl - dif]; ++nl)
681
+ ;
682
+ if (nl > l) {
683
+ l = nl, d = dif;
684
+ // break out early when we reach "nice" (we are satisfied enough)
685
+ if (nl > maxn)
686
+ break;
687
+ // now, find the rarest 2-byte sequence within this
688
+ // length of literals and search for that instead.
689
+ // Much faster than just using the start
690
+ var mmd = Math.min(dif, nl - 2);
691
+ var md = 0;
692
+ for (var j = 0; j < mmd; ++j) {
693
+ var ti = i - dif + j & 32767;
694
+ var pti = prev[ti];
695
+ var cd = ti - pti & 32767;
696
+ if (cd > md)
697
+ md = cd, pimod = ti;
698
+ }
699
+ }
700
+ }
701
+ // check the previous match
702
+ imod = pimod, pimod = prev[imod];
703
+ dif += imod - pimod & 32767;
704
+ }
705
+ }
706
+ // d will be nonzero only when a match was found
707
+ if (d) {
708
+ // store both dist and len data in one int32
709
+ // Make sure this is recognized as a len/dist with 28th bit (2^28)
710
+ syms[li++] = 268435456 | (revfl[l] << 18) | revfd[d];
711
+ var lin = revfl[l] & 31, din = revfd[d] & 31;
712
+ eb += fleb[lin] + fdeb[din];
713
+ ++lf[257 + lin];
714
+ ++df[din];
715
+ wi = i + l;
716
+ ++lc_1;
717
+ }
718
+ else {
719
+ syms[li++] = dat[i];
720
+ ++lf[dat[i]];
721
+ }
722
+ }
723
+ }
724
+ for (i = Math.max(i, wi); i < s; ++i) {
725
+ syms[li++] = dat[i];
726
+ ++lf[dat[i]];
727
+ }
728
+ pos = wblk(dat, w, lst, syms, lf, df, eb, li, bs, i - bs, pos);
729
+ if (!lst) {
730
+ st.r = (pos & 7) | w[(pos / 8) | 0] << 3;
731
+ // shft(pos) now 1 less if pos & 7 != 0
732
+ pos -= 7;
733
+ st.h = head, st.p = prev, st.i = i, st.w = wi;
734
+ }
735
+ }
736
+ else {
737
+ for (var i = st.w || 0; i < s + lst; i += 65535) {
738
+ // end
739
+ var e = i + 65535;
740
+ if (e >= s) {
741
+ // write final block
742
+ w[(pos / 8) | 0] = lst;
743
+ e = s;
744
+ }
745
+ pos = wfblk(w, pos + 1, dat.subarray(i, e));
746
+ }
747
+ st.i = s;
748
+ }
749
+ return slc(o, 0, pre + shft(pos) + post);
750
+ };
751
+ // CRC32 table
752
+ var crct = /*#__PURE__*/ (function () {
753
+ var t = new Int32Array(256);
754
+ for (var i = 0; i < 256; ++i) {
755
+ var c = i, k = 9;
756
+ while (--k)
757
+ c = ((c & 1) && -306674912) ^ (c >>> 1);
758
+ t[i] = c;
759
+ }
760
+ return t;
761
+ })();
762
+ // CRC32
763
+ var crc = function () {
764
+ var c = -1;
765
+ return {
766
+ p: function (d) {
767
+ // closures have awful performance
768
+ var cr = c;
769
+ for (var i = 0; i < d.length; ++i)
770
+ cr = crct[(cr & 255) ^ d[i]] ^ (cr >>> 8);
771
+ c = cr;
772
+ },
773
+ d: function () { return ~c; }
774
+ };
775
+ };
776
+ // Adler32
777
+ var adler = function () {
778
+ var a = 1, b = 0;
779
+ return {
780
+ p: function (d) {
781
+ // closures have awful performance
782
+ var n = a, m = b;
783
+ var l = d.length | 0;
784
+ for (var i = 0; i != l;) {
785
+ var e = Math.min(i + 2655, l);
786
+ for (; i < e; ++i)
787
+ m += n += d[i];
788
+ n = (n & 65535) + 15 * (n >> 16), m = (m & 65535) + 15 * (m >> 16);
789
+ }
790
+ a = n, b = m;
791
+ },
792
+ d: function () {
793
+ a %= 65521, b %= 65521;
794
+ return (a & 255) << 24 | (a & 0xFF00) << 8 | (b & 255) << 8 | (b >> 8);
795
+ }
796
+ };
797
+ };
798
+ ;
799
+ // deflate with opts
800
+ var dopt = function (dat, opt, pre, post, st) {
801
+ if (!st) {
802
+ st = { l: 1 };
803
+ if (opt.dictionary) {
804
+ var dict = opt.dictionary.subarray(-32768);
805
+ var newDat = new u8(dict.length + dat.length);
806
+ newDat.set(dict);
807
+ newDat.set(dat, dict.length);
808
+ dat = newDat;
809
+ st.w = dict.length;
810
+ }
811
+ }
812
+ return dflt(dat, opt.level == null ? 6 : opt.level, opt.mem == null ? (st.l ? Math.ceil(Math.max(8, Math.min(13, Math.log(dat.length))) * 1.5) : 20) : (12 + opt.mem), pre, post, st);
813
+ };
814
+ // Walmart object spread
815
+ var mrg = function (a, b) {
816
+ var o = {};
817
+ for (var k in a)
818
+ o[k] = a[k];
819
+ for (var k in b)
820
+ o[k] = b[k];
821
+ return o;
822
+ };
823
+ // worker clone
824
+ // This is possibly the craziest part of the entire codebase, despite how simple it may seem.
825
+ // The only parameter to this function is a closure that returns an array of variables outside of the function scope.
826
+ // We're going to try to figure out the variable names used in the closure as strings because that is crucial for workerization.
827
+ // We will return an object mapping of true variable name to value (basically, the current scope as a JS object).
828
+ // The reason we can't just use the original variable names is minifiers mangling the toplevel scope.
829
+ // This took me three weeks to figure out how to do.
830
+ var wcln = function (fn, fnStr, td) {
831
+ var dt = fn();
832
+ var st = fn.toString();
833
+ var ks = st.slice(st.indexOf('[') + 1, st.lastIndexOf(']')).replace(/\s+/g, '').split(',');
834
+ for (var i = 0; i < dt.length; ++i) {
835
+ var v = dt[i], k = ks[i];
836
+ if (typeof v == 'function') {
837
+ fnStr += ';' + k + '=';
838
+ var st_1 = v.toString();
839
+ if (v.prototype) {
840
+ // for global objects
841
+ if (st_1.indexOf('[native code]') != -1) {
842
+ var spInd = st_1.indexOf(' ', 8) + 1;
843
+ fnStr += st_1.slice(spInd, st_1.indexOf('(', spInd));
844
+ }
845
+ else {
846
+ fnStr += st_1;
847
+ for (var t in v.prototype)
848
+ fnStr += ';' + k + '.prototype.' + t + '=' + v.prototype[t].toString();
849
+ }
850
+ }
851
+ else
852
+ fnStr += st_1;
853
+ }
854
+ else
855
+ td[k] = v;
856
+ }
857
+ return fnStr;
858
+ };
859
+ var ch = [];
860
+ // clone bufs
861
+ var cbfs = function (v) {
862
+ var tl = [];
863
+ for (var k in v) {
864
+ if (v[k].buffer) {
865
+ tl.push((v[k] = new v[k].constructor(v[k])).buffer);
866
+ }
867
+ }
868
+ return tl;
869
+ };
870
+ // use a worker to execute code
871
+ var wrkr = function (fns, init, id, cb) {
872
+ if (!ch[id]) {
873
+ var fnStr = '', td_1 = {}, m = fns.length - 1;
874
+ for (var i = 0; i < m; ++i)
875
+ fnStr = wcln(fns[i], fnStr, td_1);
876
+ ch[id] = { c: wcln(fns[m], fnStr, td_1), e: td_1 };
877
+ }
878
+ var td = mrg({}, ch[id].e);
879
+ return wk(ch[id].c + ';onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage=' + init.toString() + '}', id, td, cbfs(td), cb);
880
+ };
881
+ // base async inflate fn
882
+ var bInflt = function () { return [u8, u16, i32, fleb, fdeb, clim, fl, fd, flrm, fdrm, rev, ec, hMap, max, bits, bits16, shft, slc, err, inflt, inflateSync, pbf, gopt]; };
883
+ var bDflt = function () { return [u8, u16, i32, fleb, fdeb, clim, revfl, revfd, flm, flt, fdm, fdt, rev, deo, et, hMap, wbits, wbits16, hTree, ln, lc, clen, wfblk, wblk, shft, slc, dflt, dopt, deflateSync, pbf]; };
884
+ // gzip extra
885
+ var gze = function () { return [gzh, gzhl, wbytes, crc, crct]; };
886
+ // gunzip extra
887
+ var guze = function () { return [gzs, gzl]; };
888
+ // zlib extra
889
+ var zle = function () { return [zlh, wbytes, adler]; };
890
+ // unzlib extra
891
+ var zule = function () { return [zls]; };
892
+ // post buf
893
+ var pbf = function (msg) { return postMessage(msg, [msg.buffer]); };
894
+ // get opts
895
+ var gopt = function (o) { return o && {
896
+ out: o.size && new u8(o.size),
897
+ dictionary: o.dictionary
898
+ }; };
899
+ // async helper
900
+ var cbify = function (dat, opts, fns, init, id, cb) {
901
+ var w = wrkr(fns, init, id, function (err, dat) {
902
+ w.terminate();
903
+ cb(err, dat);
904
+ });
905
+ w.postMessage([dat, opts], opts.consume ? [dat.buffer] : []);
906
+ return function () { w.terminate(); };
907
+ };
908
+ // auto stream
909
+ var astrm = function (strm) {
910
+ strm.ondata = function (dat, final) { return postMessage([dat, final], [dat.buffer]); };
911
+ return function (ev) {
912
+ if (ev.data.length) {
913
+ strm.push(ev.data[0], ev.data[1]);
914
+ postMessage([ev.data[0].length]);
915
+ }
916
+ else
917
+ strm.flush();
918
+ };
919
+ };
920
+ // async stream attach
921
+ var astrmify = function (fns, strm, opts, init, id, flush, ext) {
922
+ var t;
923
+ var w = wrkr(fns, init, id, function (err, dat) {
924
+ if (err)
925
+ w.terminate(), strm.ondata.call(strm, err);
926
+ else if (!Array.isArray(dat))
927
+ ext(dat);
928
+ else if (dat.length == 1) {
929
+ strm.queuedSize -= dat[0];
930
+ if (strm.ondrain)
931
+ strm.ondrain(dat[0]);
932
+ }
933
+ else {
934
+ if (dat[1])
935
+ w.terminate();
936
+ strm.ondata.call(strm, err, dat[0], dat[1]);
937
+ }
938
+ });
939
+ w.postMessage(opts);
940
+ strm.queuedSize = 0;
941
+ strm.push = function (d, f) {
942
+ if (!strm.ondata)
943
+ err(5);
944
+ if (t)
945
+ strm.ondata(err(4, 0, 1), null, !!f);
946
+ strm.queuedSize += d.length;
947
+ w.postMessage([d, t = f], [d.buffer]);
948
+ };
949
+ strm.terminate = function () { w.terminate(); };
950
+ if (flush) {
951
+ strm.flush = function () { w.postMessage([]); };
952
+ }
953
+ };
954
+ // read 2 bytes
955
+ var b2 = function (d, b) { return d[b] | (d[b + 1] << 8); };
956
+ // read 4 bytes
957
+ var b4 = function (d, b) { return (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; };
958
+ var b8 = function (d, b) { return b4(d, b) + (b4(d, b + 4) * 4294967296); };
959
+ // write bytes
960
+ var wbytes = function (d, b, v) {
961
+ for (; v; ++b)
962
+ d[b] = v, v >>>= 8;
963
+ };
964
+ // gzip header
965
+ var gzh = function (c, o) {
966
+ var fn = o.filename;
967
+ c[0] = 31, c[1] = 139, c[2] = 8, c[8] = o.level < 2 ? 4 : o.level == 9 ? 2 : 0, c[9] = 3; // assume Unix
968
+ if (o.mtime != 0)
969
+ wbytes(c, 4, Math.floor(new Date(o.mtime || Date.now()) / 1000));
970
+ if (fn) {
971
+ c[3] = 8;
972
+ for (var i = 0; i <= fn.length; ++i)
973
+ c[i + 10] = fn.charCodeAt(i);
974
+ }
975
+ };
976
+ // gzip footer: -8 to -4 = CRC, -4 to -0 is length
977
+ // gzip start
978
+ var gzs = function (d) {
979
+ if (d[0] != 31 || d[1] != 139 || d[2] != 8)
980
+ err(6, 'invalid gzip data');
981
+ var flg = d[3];
982
+ var st = 10;
983
+ if (flg & 4)
984
+ st += (d[10] | d[11] << 8) + 2;
985
+ for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d[st++])
986
+ ;
987
+ return st + (flg & 2);
988
+ };
989
+ // gzip length
990
+ var gzl = function (d) {
991
+ var l = d.length;
992
+ return (d[l - 4] | d[l - 3] << 8 | d[l - 2] << 16 | d[l - 1] << 24) >>> 0;
993
+ };
994
+ // gzip header length
995
+ var gzhl = function (o) { return 10 + (o.filename ? o.filename.length + 1 : 0); };
996
+ // zlib header
997
+ var zlh = function (c, o) {
998
+ var lv = o.level, fl = lv == 0 ? 0 : lv < 6 ? 1 : lv == 9 ? 3 : 2;
999
+ c[0] = 120, c[1] = (fl << 6) | (o.dictionary && 32);
1000
+ c[1] |= 31 - ((c[0] << 8) | c[1]) % 31;
1001
+ if (o.dictionary) {
1002
+ var h = adler();
1003
+ h.p(o.dictionary);
1004
+ wbytes(c, 2, h.d());
1005
+ }
1006
+ };
1007
+ // zlib start
1008
+ var zls = function (d, dict) {
1009
+ if ((d[0] & 15) != 8 || (d[0] >> 4) > 7 || ((d[0] << 8 | d[1]) % 31))
1010
+ err(6, 'invalid zlib data');
1011
+ if ((d[1] >> 5 & 1) == +!dict)
1012
+ err(6, 'invalid zlib data: ' + (d[1] & 32 ? 'need' : 'unexpected') + ' dictionary');
1013
+ return (d[1] >> 3 & 4) + 2;
1014
+ };
1015
+ function StrmOpt(opts, cb) {
1016
+ if (typeof opts == 'function')
1017
+ cb = opts, opts = {};
1018
+ this.ondata = cb;
1019
+ return opts;
1020
+ }
1021
+ /**
1022
+ * Streaming DEFLATE compression
1023
+ */
1024
+ var Deflate = /*#__PURE__*/ (function () {
1025
+ function Deflate(opts, cb) {
1026
+ if (typeof opts == 'function')
1027
+ cb = opts, opts = {};
1028
+ this.ondata = cb;
1029
+ this.o = opts || {};
1030
+ this.s = { l: 0, i: 32768, w: 32768, z: 32768 };
1031
+ // Buffer length must always be 0 mod 32768 for index calculations to be correct when modifying head and prev
1032
+ // 98304 = 32768 (lookback) + 65536 (common chunk size)
1033
+ this.b = new u8(98304);
1034
+ if (this.o.dictionary) {
1035
+ var dict = this.o.dictionary.subarray(-32768);
1036
+ this.b.set(dict, 32768 - dict.length);
1037
+ this.s.i = 32768 - dict.length;
1038
+ }
1039
+ }
1040
+ Deflate.prototype.p = function (c, f) {
1041
+ this.ondata(dopt(c, this.o, 0, 0, this.s), f);
1042
+ };
1043
+ /**
1044
+ * Pushes a chunk to be deflated
1045
+ * @param chunk The chunk to push
1046
+ * @param final Whether this is the last chunk
1047
+ */
1048
+ Deflate.prototype.push = function (chunk, final) {
1049
+ if (!this.ondata)
1050
+ err(5);
1051
+ if (this.s.l)
1052
+ err(4);
1053
+ var endLen = chunk.length + this.s.z;
1054
+ if (endLen > this.b.length) {
1055
+ if (endLen > 2 * this.b.length - 32768) {
1056
+ var newBuf = new u8(endLen & -32768);
1057
+ newBuf.set(this.b.subarray(0, this.s.z));
1058
+ this.b = newBuf;
1059
+ }
1060
+ var split = this.b.length - this.s.z;
1061
+ this.b.set(chunk.subarray(0, split), this.s.z);
1062
+ this.s.z = this.b.length;
1063
+ this.p(this.b, false);
1064
+ this.b.set(this.b.subarray(-32768));
1065
+ this.b.set(chunk.subarray(split), 32768);
1066
+ this.s.z = chunk.length - split + 32768;
1067
+ this.s.i = 32766, this.s.w = 32768;
1068
+ }
1069
+ else {
1070
+ this.b.set(chunk, this.s.z);
1071
+ this.s.z += chunk.length;
1072
+ }
1073
+ this.s.l = final & 1;
1074
+ if (this.s.z > this.s.w + 8191 || final) {
1075
+ this.p(this.b, final || false);
1076
+ this.s.w = this.s.i, this.s.i -= 2;
1077
+ }
1078
+ };
1079
+ /**
1080
+ * Flushes buffered uncompressed data. Useful to immediately retrieve the
1081
+ * deflated output for small inputs.
1082
+ */
1083
+ Deflate.prototype.flush = function () {
1084
+ if (!this.ondata)
1085
+ err(5);
1086
+ if (this.s.l)
1087
+ err(4);
1088
+ this.p(this.b, false);
1089
+ this.s.w = this.s.i, this.s.i -= 2;
1090
+ };
1091
+ return Deflate;
1092
+ }());
1093
+ export { Deflate };
1094
+ /**
1095
+ * Asynchronous streaming DEFLATE compression
1096
+ */
1097
+ var AsyncDeflate = /*#__PURE__*/ (function () {
1098
+ function AsyncDeflate(opts, cb) {
1099
+ astrmify([
1100
+ bDflt,
1101
+ function () { return [astrm, Deflate]; }
1102
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1103
+ var strm = new Deflate(ev.data);
1104
+ onmessage = astrm(strm);
1105
+ }, 6, 1);
1106
+ }
1107
+ return AsyncDeflate;
1108
+ }());
1109
+ export { AsyncDeflate };
1110
+ export function deflate(data, opts, cb) {
1111
+ if (!cb)
1112
+ cb = opts, opts = {};
1113
+ if (typeof cb != 'function')
1114
+ err(7);
1115
+ return cbify(data, opts, [
1116
+ bDflt,
1117
+ ], function (ev) { return pbf(deflateSync(ev.data[0], ev.data[1])); }, 0, cb);
1118
+ }
1119
+ /**
1120
+ * Compresses data with DEFLATE without any wrapper
1121
+ * @param data The data to compress
1122
+ * @param opts The compression options
1123
+ * @returns The deflated version of the data
1124
+ */
1125
+ export function deflateSync(data, opts) {
1126
+ return dopt(data, opts || {}, 0, 0);
1127
+ }
1128
+ /**
1129
+ * Streaming DEFLATE decompression
1130
+ */
1131
+ var Inflate = /*#__PURE__*/ (function () {
1132
+ function Inflate(opts, cb) {
1133
+ // no StrmOpt here to avoid adding to workerizer
1134
+ if (typeof opts == 'function')
1135
+ cb = opts, opts = {};
1136
+ this.ondata = cb;
1137
+ var dict = opts && opts.dictionary && opts.dictionary.subarray(-32768);
1138
+ this.s = { i: 0, b: dict ? dict.length : 0 };
1139
+ this.o = new u8(32768);
1140
+ this.p = new u8(0);
1141
+ if (dict)
1142
+ this.o.set(dict);
1143
+ }
1144
+ Inflate.prototype.e = function (c) {
1145
+ if (!this.ondata)
1146
+ err(5);
1147
+ if (this.d)
1148
+ err(4);
1149
+ if (!this.p.length)
1150
+ this.p = c;
1151
+ else if (c.length) {
1152
+ var n = new u8(this.p.length + c.length);
1153
+ n.set(this.p), n.set(c, this.p.length), this.p = n;
1154
+ }
1155
+ };
1156
+ Inflate.prototype.c = function (final) {
1157
+ this.s.i = +(this.d = final || false);
1158
+ var bts = this.s.b;
1159
+ var dt = inflt(this.p, this.s, this.o);
1160
+ this.ondata(slc(dt, bts, this.s.b), this.d);
1161
+ this.o = slc(dt, this.s.b - 32768), this.s.b = this.o.length;
1162
+ this.p = slc(this.p, (this.s.p / 8) | 0), this.s.p &= 7;
1163
+ };
1164
+ /**
1165
+ * Pushes a chunk to be inflated
1166
+ * @param chunk The chunk to push
1167
+ * @param final Whether this is the final chunk
1168
+ */
1169
+ Inflate.prototype.push = function (chunk, final) {
1170
+ this.e(chunk), this.c(final);
1171
+ };
1172
+ return Inflate;
1173
+ }());
1174
+ export { Inflate };
1175
+ /**
1176
+ * Asynchronous streaming DEFLATE decompression
1177
+ */
1178
+ var AsyncInflate = /*#__PURE__*/ (function () {
1179
+ function AsyncInflate(opts, cb) {
1180
+ astrmify([
1181
+ bInflt,
1182
+ function () { return [astrm, Inflate]; }
1183
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1184
+ var strm = new Inflate(ev.data);
1185
+ onmessage = astrm(strm);
1186
+ }, 7, 0);
1187
+ }
1188
+ return AsyncInflate;
1189
+ }());
1190
+ export { AsyncInflate };
1191
+ export function inflate(data, opts, cb) {
1192
+ if (!cb)
1193
+ cb = opts, opts = {};
1194
+ if (typeof cb != 'function')
1195
+ err(7);
1196
+ return cbify(data, opts, [
1197
+ bInflt
1198
+ ], function (ev) { return pbf(inflateSync(ev.data[0], gopt(ev.data[1]))); }, 1, cb);
1199
+ }
1200
+ /**
1201
+ * Expands DEFLATE data with no wrapper
1202
+ * @param data The data to decompress
1203
+ * @param opts The decompression options
1204
+ * @returns The decompressed version of the data
1205
+ */
1206
+ export function inflateSync(data, opts) {
1207
+ return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
1208
+ }
1209
+ // before you yell at me for not just using extends, my reason is that TS inheritance is hard to workerize.
1210
+ /**
1211
+ * Streaming GZIP compression
1212
+ */
1213
+ var Gzip = /*#__PURE__*/ (function () {
1214
+ function Gzip(opts, cb) {
1215
+ this.c = crc();
1216
+ this.l = 0;
1217
+ this.v = 1;
1218
+ Deflate.call(this, opts, cb);
1219
+ }
1220
+ /**
1221
+ * Pushes a chunk to be GZIPped
1222
+ * @param chunk The chunk to push
1223
+ * @param final Whether this is the last chunk
1224
+ */
1225
+ Gzip.prototype.push = function (chunk, final) {
1226
+ this.c.p(chunk);
1227
+ this.l += chunk.length;
1228
+ Deflate.prototype.push.call(this, chunk, final);
1229
+ };
1230
+ Gzip.prototype.p = function (c, f) {
1231
+ var raw = dopt(c, this.o, this.v && gzhl(this.o), f && 8, this.s);
1232
+ if (this.v)
1233
+ gzh(raw, this.o), this.v = 0;
1234
+ if (f)
1235
+ wbytes(raw, raw.length - 8, this.c.d()), wbytes(raw, raw.length - 4, this.l);
1236
+ this.ondata(raw, f);
1237
+ };
1238
+ /**
1239
+ * Flushes buffered uncompressed data. Useful to immediately retrieve the
1240
+ * GZIPped output for small inputs.
1241
+ */
1242
+ Gzip.prototype.flush = function () {
1243
+ Deflate.prototype.flush.call(this);
1244
+ };
1245
+ return Gzip;
1246
+ }());
1247
+ export { Gzip };
1248
+ /**
1249
+ * Asynchronous streaming GZIP compression
1250
+ */
1251
+ var AsyncGzip = /*#__PURE__*/ (function () {
1252
+ function AsyncGzip(opts, cb) {
1253
+ astrmify([
1254
+ bDflt,
1255
+ gze,
1256
+ function () { return [astrm, Deflate, Gzip]; }
1257
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1258
+ var strm = new Gzip(ev.data);
1259
+ onmessage = astrm(strm);
1260
+ }, 8, 1);
1261
+ }
1262
+ return AsyncGzip;
1263
+ }());
1264
+ export { AsyncGzip };
1265
+ export function gzip(data, opts, cb) {
1266
+ if (!cb)
1267
+ cb = opts, opts = {};
1268
+ if (typeof cb != 'function')
1269
+ err(7);
1270
+ return cbify(data, opts, [
1271
+ bDflt,
1272
+ gze,
1273
+ function () { return [gzipSync]; }
1274
+ ], function (ev) { return pbf(gzipSync(ev.data[0], ev.data[1])); }, 2, cb);
1275
+ }
1276
+ /**
1277
+ * Compresses data with GZIP
1278
+ * @param data The data to compress
1279
+ * @param opts The compression options
1280
+ * @returns The gzipped version of the data
1281
+ */
1282
+ export function gzipSync(data, opts) {
1283
+ if (!opts)
1284
+ opts = {};
1285
+ var c = crc(), l = data.length;
1286
+ c.p(data);
1287
+ var d = dopt(data, opts, gzhl(opts), 8), s = d.length;
1288
+ return gzh(d, opts), wbytes(d, s - 8, c.d()), wbytes(d, s - 4, l), d;
1289
+ }
1290
+ /**
1291
+ * Streaming single or multi-member GZIP decompression
1292
+ */
1293
+ var Gunzip = /*#__PURE__*/ (function () {
1294
+ function Gunzip(opts, cb) {
1295
+ this.v = 1;
1296
+ this.r = 0;
1297
+ Inflate.call(this, opts, cb);
1298
+ }
1299
+ /**
1300
+ * Pushes a chunk to be GUNZIPped
1301
+ * @param chunk The chunk to push
1302
+ * @param final Whether this is the last chunk
1303
+ */
1304
+ Gunzip.prototype.push = function (chunk, final) {
1305
+ Inflate.prototype.e.call(this, chunk);
1306
+ this.r += chunk.length;
1307
+ if (this.v) {
1308
+ var p = this.p.subarray(this.v - 1);
1309
+ var s = p.length > 3 ? gzs(p) : 4;
1310
+ if (s > p.length) {
1311
+ if (!final)
1312
+ return;
1313
+ }
1314
+ else if (this.v > 1 && this.onmember) {
1315
+ this.onmember(this.r - p.length);
1316
+ }
1317
+ this.p = p.subarray(s), this.v = 0;
1318
+ }
1319
+ // necessary to prevent TS from using the closure value
1320
+ // This allows for workerization to function correctly
1321
+ Inflate.prototype.c.call(this, final);
1322
+ // process concatenated GZIP
1323
+ if (this.s.f && !this.s.l && !final) {
1324
+ this.v = shft(this.s.p) + 9;
1325
+ this.s = { i: 0 };
1326
+ this.o = new u8(0);
1327
+ this.push(new u8(0), final);
1328
+ }
1329
+ };
1330
+ return Gunzip;
1331
+ }());
1332
+ export { Gunzip };
1333
+ /**
1334
+ * Asynchronous streaming single or multi-member GZIP decompression
1335
+ */
1336
+ var AsyncGunzip = /*#__PURE__*/ (function () {
1337
+ function AsyncGunzip(opts, cb) {
1338
+ var _this = this;
1339
+ astrmify([
1340
+ bInflt,
1341
+ guze,
1342
+ function () { return [astrm, Inflate, Gunzip]; }
1343
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1344
+ var strm = new Gunzip(ev.data);
1345
+ strm.onmember = function (offset) { return postMessage(offset); };
1346
+ onmessage = astrm(strm);
1347
+ }, 9, 0, function (offset) { return _this.onmember && _this.onmember(offset); });
1348
+ }
1349
+ return AsyncGunzip;
1350
+ }());
1351
+ export { AsyncGunzip };
1352
+ export function gunzip(data, opts, cb) {
1353
+ if (!cb)
1354
+ cb = opts, opts = {};
1355
+ if (typeof cb != 'function')
1356
+ err(7);
1357
+ return cbify(data, opts, [
1358
+ bInflt,
1359
+ guze,
1360
+ function () { return [gunzipSync]; }
1361
+ ], function (ev) { return pbf(gunzipSync(ev.data[0], ev.data[1])); }, 3, cb);
1362
+ }
1363
+ /**
1364
+ * Expands GZIP data
1365
+ * @param data The data to decompress
1366
+ * @param opts The decompression options
1367
+ * @returns The decompressed version of the data
1368
+ */
1369
+ export function gunzipSync(data, opts) {
1370
+ var st = gzs(data);
1371
+ if (st + 8 > data.length)
1372
+ err(6, 'invalid gzip data');
1373
+ return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
1374
+ }
1375
+ /**
1376
+ * Streaming Zlib compression
1377
+ */
1378
+ var Zlib = /*#__PURE__*/ (function () {
1379
+ function Zlib(opts, cb) {
1380
+ this.c = adler();
1381
+ this.v = 1;
1382
+ Deflate.call(this, opts, cb);
1383
+ }
1384
+ /**
1385
+ * Pushes a chunk to be zlibbed
1386
+ * @param chunk The chunk to push
1387
+ * @param final Whether this is the last chunk
1388
+ */
1389
+ Zlib.prototype.push = function (chunk, final) {
1390
+ this.c.p(chunk);
1391
+ Deflate.prototype.push.call(this, chunk, final);
1392
+ };
1393
+ Zlib.prototype.p = function (c, f) {
1394
+ var raw = dopt(c, this.o, this.v && (this.o.dictionary ? 6 : 2), f && 4, this.s);
1395
+ if (this.v)
1396
+ zlh(raw, this.o), this.v = 0;
1397
+ if (f)
1398
+ wbytes(raw, raw.length - 4, this.c.d());
1399
+ this.ondata(raw, f);
1400
+ };
1401
+ /**
1402
+ * Flushes buffered uncompressed data. Useful to immediately retrieve the
1403
+ * zlibbed output for small inputs.
1404
+ */
1405
+ Zlib.prototype.flush = function () {
1406
+ Deflate.prototype.flush.call(this);
1407
+ };
1408
+ return Zlib;
1409
+ }());
1410
+ export { Zlib };
1411
+ /**
1412
+ * Asynchronous streaming Zlib compression
1413
+ */
1414
+ var AsyncZlib = /*#__PURE__*/ (function () {
1415
+ function AsyncZlib(opts, cb) {
1416
+ astrmify([
1417
+ bDflt,
1418
+ zle,
1419
+ function () { return [astrm, Deflate, Zlib]; }
1420
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1421
+ var strm = new Zlib(ev.data);
1422
+ onmessage = astrm(strm);
1423
+ }, 10, 1);
1424
+ }
1425
+ return AsyncZlib;
1426
+ }());
1427
+ export { AsyncZlib };
1428
+ export function zlib(data, opts, cb) {
1429
+ if (!cb)
1430
+ cb = opts, opts = {};
1431
+ if (typeof cb != 'function')
1432
+ err(7);
1433
+ return cbify(data, opts, [
1434
+ bDflt,
1435
+ zle,
1436
+ function () { return [zlibSync]; }
1437
+ ], function (ev) { return pbf(zlibSync(ev.data[0], ev.data[1])); }, 4, cb);
1438
+ }
1439
+ /**
1440
+ * Compress data with Zlib
1441
+ * @param data The data to compress
1442
+ * @param opts The compression options
1443
+ * @returns The zlib-compressed version of the data
1444
+ */
1445
+ export function zlibSync(data, opts) {
1446
+ if (!opts)
1447
+ opts = {};
1448
+ var a = adler();
1449
+ a.p(data);
1450
+ var d = dopt(data, opts, opts.dictionary ? 6 : 2, 4);
1451
+ return zlh(d, opts), wbytes(d, d.length - 4, a.d()), d;
1452
+ }
1453
+ /**
1454
+ * Streaming Zlib decompression
1455
+ */
1456
+ var Unzlib = /*#__PURE__*/ (function () {
1457
+ function Unzlib(opts, cb) {
1458
+ Inflate.call(this, opts, cb);
1459
+ this.v = opts && opts.dictionary ? 2 : 1;
1460
+ }
1461
+ /**
1462
+ * Pushes a chunk to be unzlibbed
1463
+ * @param chunk The chunk to push
1464
+ * @param final Whether this is the last chunk
1465
+ */
1466
+ Unzlib.prototype.push = function (chunk, final) {
1467
+ Inflate.prototype.e.call(this, chunk);
1468
+ if (this.v) {
1469
+ if (this.p.length < 6 && !final)
1470
+ return;
1471
+ this.p = this.p.subarray(zls(this.p, this.v - 1)), this.v = 0;
1472
+ }
1473
+ if (final) {
1474
+ if (this.p.length < 4)
1475
+ err(6, 'invalid zlib data');
1476
+ this.p = this.p.subarray(0, -4);
1477
+ }
1478
+ // necessary to prevent TS from using the closure value
1479
+ // This allows for workerization to function correctly
1480
+ Inflate.prototype.c.call(this, final);
1481
+ };
1482
+ return Unzlib;
1483
+ }());
1484
+ export { Unzlib };
1485
+ /**
1486
+ * Asynchronous streaming Zlib decompression
1487
+ */
1488
+ var AsyncUnzlib = /*#__PURE__*/ (function () {
1489
+ function AsyncUnzlib(opts, cb) {
1490
+ astrmify([
1491
+ bInflt,
1492
+ zule,
1493
+ function () { return [astrm, Inflate, Unzlib]; }
1494
+ ], this, StrmOpt.call(this, opts, cb), function (ev) {
1495
+ var strm = new Unzlib(ev.data);
1496
+ onmessage = astrm(strm);
1497
+ }, 11, 0);
1498
+ }
1499
+ return AsyncUnzlib;
1500
+ }());
1501
+ export { AsyncUnzlib };
1502
+ export function unzlib(data, opts, cb) {
1503
+ if (!cb)
1504
+ cb = opts, opts = {};
1505
+ if (typeof cb != 'function')
1506
+ err(7);
1507
+ return cbify(data, opts, [
1508
+ bInflt,
1509
+ zule,
1510
+ function () { return [unzlibSync]; }
1511
+ ], function (ev) { return pbf(unzlibSync(ev.data[0], gopt(ev.data[1]))); }, 5, cb);
1512
+ }
1513
+ /**
1514
+ * Expands Zlib data
1515
+ * @param data The data to decompress
1516
+ * @param opts The decompression options
1517
+ * @returns The decompressed version of the data
1518
+ */
1519
+ export function unzlibSync(data, opts) {
1520
+ return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
1521
+ }
1522
+ // Default algorithm for compression (used because having a known output size allows faster decompression)
1523
+ export { gzip as compress, AsyncGzip as AsyncCompress };
1524
+ export { gzipSync as compressSync, Gzip as Compress };
1525
+ /**
1526
+ * Streaming GZIP, Zlib, or raw DEFLATE decompression
1527
+ */
1528
+ var Decompress = /*#__PURE__*/ (function () {
1529
+ function Decompress(opts, cb) {
1530
+ this.o = StrmOpt.call(this, opts, cb) || {};
1531
+ this.G = Gunzip;
1532
+ this.I = Inflate;
1533
+ this.Z = Unzlib;
1534
+ }
1535
+ // init substream
1536
+ // overriden by AsyncDecompress
1537
+ Decompress.prototype.i = function () {
1538
+ var _this = this;
1539
+ this.s.ondata = function (dat, final) {
1540
+ _this.ondata(dat, final);
1541
+ };
1542
+ };
1543
+ /**
1544
+ * Pushes a chunk to be decompressed
1545
+ * @param chunk The chunk to push
1546
+ * @param final Whether this is the last chunk
1547
+ */
1548
+ Decompress.prototype.push = function (chunk, final) {
1549
+ if (!this.ondata)
1550
+ err(5);
1551
+ if (!this.s) {
1552
+ if (this.p && this.p.length) {
1553
+ var n = new u8(this.p.length + chunk.length);
1554
+ n.set(this.p), n.set(chunk, this.p.length);
1555
+ }
1556
+ else
1557
+ this.p = chunk;
1558
+ if (this.p.length > 2) {
1559
+ this.s = (this.p[0] == 31 && this.p[1] == 139 && this.p[2] == 8)
1560
+ ? new this.G(this.o)
1561
+ : ((this.p[0] & 15) != 8 || (this.p[0] >> 4) > 7 || ((this.p[0] << 8 | this.p[1]) % 31))
1562
+ ? new this.I(this.o)
1563
+ : new this.Z(this.o);
1564
+ this.i();
1565
+ this.s.push(this.p, final);
1566
+ this.p = null;
1567
+ }
1568
+ }
1569
+ else
1570
+ this.s.push(chunk, final);
1571
+ };
1572
+ return Decompress;
1573
+ }());
1574
+ export { Decompress };
1575
+ /**
1576
+ * Asynchronous streaming GZIP, Zlib, or raw DEFLATE decompression
1577
+ */
1578
+ var AsyncDecompress = /*#__PURE__*/ (function () {
1579
+ function AsyncDecompress(opts, cb) {
1580
+ Decompress.call(this, opts, cb);
1581
+ this.queuedSize = 0;
1582
+ this.G = AsyncGunzip;
1583
+ this.I = AsyncInflate;
1584
+ this.Z = AsyncUnzlib;
1585
+ }
1586
+ AsyncDecompress.prototype.i = function () {
1587
+ var _this = this;
1588
+ this.s.ondata = function (err, dat, final) {
1589
+ _this.ondata(err, dat, final);
1590
+ };
1591
+ this.s.ondrain = function (size) {
1592
+ _this.queuedSize -= size;
1593
+ if (_this.ondrain)
1594
+ _this.ondrain(size);
1595
+ };
1596
+ };
1597
+ /**
1598
+ * Pushes a chunk to be decompressed
1599
+ * @param chunk The chunk to push
1600
+ * @param final Whether this is the last chunk
1601
+ */
1602
+ AsyncDecompress.prototype.push = function (chunk, final) {
1603
+ this.queuedSize += chunk.length;
1604
+ Decompress.prototype.push.call(this, chunk, final);
1605
+ };
1606
+ return AsyncDecompress;
1607
+ }());
1608
+ export { AsyncDecompress };
1609
+ export function decompress(data, opts, cb) {
1610
+ if (!cb)
1611
+ cb = opts, opts = {};
1612
+ if (typeof cb != 'function')
1613
+ err(7);
1614
+ return (data[0] == 31 && data[1] == 139 && data[2] == 8)
1615
+ ? gunzip(data, opts, cb)
1616
+ : ((data[0] & 15) != 8 || (data[0] >> 4) > 7 || ((data[0] << 8 | data[1]) % 31))
1617
+ ? inflate(data, opts, cb)
1618
+ : unzlib(data, opts, cb);
1619
+ }
1620
+ /**
1621
+ * Expands compressed GZIP, Zlib, or raw DEFLATE data, automatically detecting the format
1622
+ * @param data The data to decompress
1623
+ * @param opts The decompression options
1624
+ * @returns The decompressed version of the data
1625
+ */
1626
+ export function decompressSync(data, opts) {
1627
+ return (data[0] == 31 && data[1] == 139 && data[2] == 8)
1628
+ ? gunzipSync(data, opts)
1629
+ : ((data[0] & 15) != 8 || (data[0] >> 4) > 7 || ((data[0] << 8 | data[1]) % 31))
1630
+ ? inflateSync(data, opts)
1631
+ : unzlibSync(data, opts);
1632
+ }
1633
+ // flatten a directory structure
1634
+ var fltn = function (d, p, t, o) {
1635
+ for (var k in d) {
1636
+ var val = d[k], n = p + k, op = o;
1637
+ if (Array.isArray(val))
1638
+ op = mrg(o, val[1]), val = val[0];
1639
+ if (val instanceof u8)
1640
+ t[n] = [val, op];
1641
+ else {
1642
+ t[n += '/'] = [new u8(0), op];
1643
+ fltn(val, n, t, o);
1644
+ }
1645
+ }
1646
+ };
1647
+ // text encoder
1648
+ var te = typeof TextEncoder != 'undefined' && /*#__PURE__*/ new TextEncoder();
1649
+ // text decoder
1650
+ var td = typeof TextDecoder != 'undefined' && /*#__PURE__*/ new TextDecoder();
1651
+ // text decoder stream
1652
+ var tds = 0;
1653
+ try {
1654
+ td.decode(et, { stream: true });
1655
+ tds = 1;
1656
+ }
1657
+ catch (e) { }
1658
+ // decode UTF8
1659
+ var dutf8 = function (d) {
1660
+ for (var r = '', i = 0;;) {
1661
+ var c = d[i++];
1662
+ var eb = (c > 127) + (c > 223) + (c > 239);
1663
+ if (i + eb > d.length)
1664
+ return { s: r, r: slc(d, i - 1) };
1665
+ if (!eb)
1666
+ r += String.fromCharCode(c);
1667
+ else if (eb == 3) {
1668
+ c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63)) - 65536,
1669
+ r += String.fromCharCode(55296 | (c >> 10), 56320 | (c & 1023));
1670
+ }
1671
+ else if (eb & 1)
1672
+ r += String.fromCharCode((c & 31) << 6 | (d[i++] & 63));
1673
+ else
1674
+ r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63));
1675
+ }
1676
+ };
1677
+ /**
1678
+ * Streaming UTF-8 decoding
1679
+ */
1680
+ var DecodeUTF8 = /*#__PURE__*/ (function () {
1681
+ /**
1682
+ * Creates a UTF-8 decoding stream
1683
+ * @param cb The callback to call whenever data is decoded
1684
+ */
1685
+ function DecodeUTF8(cb) {
1686
+ this.ondata = cb;
1687
+ if (tds)
1688
+ this.t = new TextDecoder();
1689
+ else
1690
+ this.p = et;
1691
+ }
1692
+ /**
1693
+ * Pushes a chunk to be decoded from UTF-8 binary
1694
+ * @param chunk The chunk to push
1695
+ * @param final Whether this is the last chunk
1696
+ */
1697
+ DecodeUTF8.prototype.push = function (chunk, final) {
1698
+ if (!this.ondata)
1699
+ err(5);
1700
+ final = !!final;
1701
+ if (this.t) {
1702
+ this.ondata(this.t.decode(chunk, { stream: true }), final);
1703
+ if (final) {
1704
+ if (this.t.decode().length)
1705
+ err(8);
1706
+ this.t = null;
1707
+ }
1708
+ return;
1709
+ }
1710
+ if (!this.p)
1711
+ err(4);
1712
+ var dat = new u8(this.p.length + chunk.length);
1713
+ dat.set(this.p);
1714
+ dat.set(chunk, this.p.length);
1715
+ var _a = dutf8(dat), s = _a.s, r = _a.r;
1716
+ if (final) {
1717
+ if (r.length)
1718
+ err(8);
1719
+ this.p = null;
1720
+ }
1721
+ else
1722
+ this.p = r;
1723
+ this.ondata(s, final);
1724
+ };
1725
+ return DecodeUTF8;
1726
+ }());
1727
+ export { DecodeUTF8 };
1728
+ /**
1729
+ * Streaming UTF-8 encoding
1730
+ */
1731
+ var EncodeUTF8 = /*#__PURE__*/ (function () {
1732
+ /**
1733
+ * Creates a UTF-8 decoding stream
1734
+ * @param cb The callback to call whenever data is encoded
1735
+ */
1736
+ function EncodeUTF8(cb) {
1737
+ this.ondata = cb;
1738
+ }
1739
+ /**
1740
+ * Pushes a chunk to be encoded to UTF-8
1741
+ * @param chunk The string data to push
1742
+ * @param final Whether this is the last chunk
1743
+ */
1744
+ EncodeUTF8.prototype.push = function (chunk, final) {
1745
+ if (!this.ondata)
1746
+ err(5);
1747
+ if (this.d)
1748
+ err(4);
1749
+ this.ondata(strToU8(chunk), this.d = final || false);
1750
+ };
1751
+ return EncodeUTF8;
1752
+ }());
1753
+ export { EncodeUTF8 };
1754
+ /**
1755
+ * Converts a string into a Uint8Array for use with compression/decompression methods
1756
+ * @param str The string to encode
1757
+ * @param latin1 Whether or not to interpret the data as Latin-1. This should
1758
+ * not need to be true unless decoding a binary string.
1759
+ * @returns The string encoded in UTF-8/Latin-1 binary
1760
+ */
1761
+ export function strToU8(str, latin1) {
1762
+ if (latin1) {
1763
+ var ar_1 = new u8(str.length);
1764
+ for (var i = 0; i < str.length; ++i)
1765
+ ar_1[i] = str.charCodeAt(i);
1766
+ return ar_1;
1767
+ }
1768
+ if (te)
1769
+ return te.encode(str);
1770
+ var l = str.length;
1771
+ var ar = new u8(str.length + (str.length >> 1));
1772
+ var ai = 0;
1773
+ var w = function (v) { ar[ai++] = v; };
1774
+ for (var i = 0; i < l; ++i) {
1775
+ if (ai + 5 > ar.length) {
1776
+ var n = new u8(ai + 8 + ((l - i) << 1));
1777
+ n.set(ar);
1778
+ ar = n;
1779
+ }
1780
+ var c = str.charCodeAt(i);
1781
+ if (c < 128 || latin1)
1782
+ w(c);
1783
+ else if (c < 2048)
1784
+ w(192 | (c >> 6)), w(128 | (c & 63));
1785
+ else if (c > 55295 && c < 57344)
1786
+ c = 65536 + (c & 1023 << 10) | (str.charCodeAt(++i) & 1023),
1787
+ w(240 | (c >> 18)), w(128 | ((c >> 12) & 63)), w(128 | ((c >> 6) & 63)), w(128 | (c & 63));
1788
+ else
1789
+ w(224 | (c >> 12)), w(128 | ((c >> 6) & 63)), w(128 | (c & 63));
1790
+ }
1791
+ return slc(ar, 0, ai);
1792
+ }
1793
+ /**
1794
+ * Converts a Uint8Array to a string
1795
+ * @param dat The data to decode to string
1796
+ * @param latin1 Whether or not to interpret the data as Latin-1. This should
1797
+ * not need to be true unless encoding to binary string.
1798
+ * @returns The original UTF-8/Latin-1 string
1799
+ */
1800
+ export function strFromU8(dat, latin1) {
1801
+ if (latin1) {
1802
+ var r = '';
1803
+ for (var i = 0; i < dat.length; i += 16384)
1804
+ r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
1805
+ return r;
1806
+ }
1807
+ else if (td) {
1808
+ return td.decode(dat);
1809
+ }
1810
+ else {
1811
+ var _a = dutf8(dat), s = _a.s, r = _a.r;
1812
+ if (r.length)
1813
+ err(8);
1814
+ return s;
1815
+ }
1816
+ }
1817
+ ;
1818
+ // deflate bit flag
1819
+ var dbf = function (l) { return l == 1 ? 3 : l < 6 ? 2 : l == 9 ? 1 : 0; };
1820
+ // skip local zip header
1821
+ var slzh = function (d, b) { return b + 30 + b2(d, b + 26) + b2(d, b + 28); };
1822
+ // read zip header
1823
+ var zh = function (d, b, z) {
1824
+ var fnl = b2(d, b + 28), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl, bs = b4(d, b + 20);
1825
+ var _a = z && bs == 4294967295 ? z64e(d, es) : [bs, b4(d, b + 24), b4(d, b + 42)], sc = _a[0], su = _a[1], off = _a[2];
1826
+ return [b2(d, b + 10), sc, su, fn, es + b2(d, b + 30) + b2(d, b + 32), off];
1827
+ };
1828
+ // read zip64 extra field
1829
+ var z64e = function (d, b) {
1830
+ for (; b2(d, b) != 1; b += 4 + b2(d, b + 2))
1831
+ ;
1832
+ return [b8(d, b + 12), b8(d, b + 4), b8(d, b + 20)];
1833
+ };
1834
+ // extra field length
1835
+ var exfl = function (ex) {
1836
+ var le = 0;
1837
+ if (ex) {
1838
+ for (var k in ex) {
1839
+ var l = ex[k].length;
1840
+ if (l > 65535)
1841
+ err(9);
1842
+ le += l + 4;
1843
+ }
1844
+ }
1845
+ return le;
1846
+ };
1847
+ // write zip header
1848
+ var wzh = function (d, b, f, fn, u, c, ce, co) {
1849
+ var fl = fn.length, ex = f.extra, col = co && co.length;
1850
+ var exl = exfl(ex);
1851
+ wbytes(d, b, ce != null ? 0x2014B50 : 0x4034B50), b += 4;
1852
+ if (ce != null)
1853
+ d[b++] = 20, d[b++] = f.os;
1854
+ d[b] = 20, b += 2; // spec compliance? what's that?
1855
+ d[b++] = (f.flag << 1) | (c < 0 && 8), d[b++] = u && 8;
1856
+ d[b++] = f.compression & 255, d[b++] = f.compression >> 8;
1857
+ var dt = new Date(f.mtime == null ? Date.now() : f.mtime), y = dt.getFullYear() - 1980;
1858
+ if (y < 0 || y > 119)
1859
+ err(10);
1860
+ wbytes(d, b, (y << 25) | ((dt.getMonth() + 1) << 21) | (dt.getDate() << 16) | (dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)), b += 4;
1861
+ if (c != -1) {
1862
+ wbytes(d, b, f.crc);
1863
+ wbytes(d, b + 4, c < 0 ? -c - 2 : c);
1864
+ wbytes(d, b + 8, f.size);
1865
+ }
1866
+ wbytes(d, b + 12, fl);
1867
+ wbytes(d, b + 14, exl), b += 16;
1868
+ if (ce != null) {
1869
+ wbytes(d, b, col);
1870
+ wbytes(d, b + 6, f.attrs);
1871
+ wbytes(d, b + 10, ce), b += 14;
1872
+ }
1873
+ d.set(fn, b);
1874
+ b += fl;
1875
+ if (exl) {
1876
+ for (var k in ex) {
1877
+ var exf = ex[k], l = exf.length;
1878
+ wbytes(d, b, +k);
1879
+ wbytes(d, b + 2, l);
1880
+ d.set(exf, b + 4), b += 4 + l;
1881
+ }
1882
+ }
1883
+ if (col)
1884
+ d.set(co, b), b += col;
1885
+ return b;
1886
+ };
1887
+ // write zip footer (end of central directory)
1888
+ var wzf = function (o, b, c, d, e) {
1889
+ wbytes(o, b, 0x6054B50); // skip disk
1890
+ wbytes(o, b + 8, c);
1891
+ wbytes(o, b + 10, c);
1892
+ wbytes(o, b + 12, d);
1893
+ wbytes(o, b + 16, e);
1894
+ };
1895
+ /**
1896
+ * A pass-through stream to keep data uncompressed in a ZIP archive.
1897
+ */
1898
+ var ZipPassThrough = /*#__PURE__*/ (function () {
1899
+ /**
1900
+ * Creates a pass-through stream that can be added to ZIP archives
1901
+ * @param filename The filename to associate with this data stream
1902
+ */
1903
+ function ZipPassThrough(filename) {
1904
+ this.filename = filename;
1905
+ this.c = crc();
1906
+ this.size = 0;
1907
+ this.compression = 0;
1908
+ }
1909
+ /**
1910
+ * Processes a chunk and pushes to the output stream. You can override this
1911
+ * method in a subclass for custom behavior, but by default this passes
1912
+ * the data through. You must call this.ondata(err, chunk, final) at some
1913
+ * point in this method.
1914
+ * @param chunk The chunk to process
1915
+ * @param final Whether this is the last chunk
1916
+ */
1917
+ ZipPassThrough.prototype.process = function (chunk, final) {
1918
+ this.ondata(null, chunk, final);
1919
+ };
1920
+ /**
1921
+ * Pushes a chunk to be added. If you are subclassing this with a custom
1922
+ * compression algorithm, note that you must push data from the source
1923
+ * file only, pre-compression.
1924
+ * @param chunk The chunk to push
1925
+ * @param final Whether this is the last chunk
1926
+ */
1927
+ ZipPassThrough.prototype.push = function (chunk, final) {
1928
+ if (!this.ondata)
1929
+ err(5);
1930
+ this.c.p(chunk);
1931
+ this.size += chunk.length;
1932
+ if (final)
1933
+ this.crc = this.c.d();
1934
+ this.process(chunk, final || false);
1935
+ };
1936
+ return ZipPassThrough;
1937
+ }());
1938
+ export { ZipPassThrough };
1939
+ // I don't extend because TypeScript extension adds 1kB of runtime bloat
1940
+ /**
1941
+ * Streaming DEFLATE compression for ZIP archives. Prefer using AsyncZipDeflate
1942
+ * for better performance
1943
+ */
1944
+ var ZipDeflate = /*#__PURE__*/ (function () {
1945
+ /**
1946
+ * Creates a DEFLATE stream that can be added to ZIP archives
1947
+ * @param filename The filename to associate with this data stream
1948
+ * @param opts The compression options
1949
+ */
1950
+ function ZipDeflate(filename, opts) {
1951
+ var _this = this;
1952
+ if (!opts)
1953
+ opts = {};
1954
+ ZipPassThrough.call(this, filename);
1955
+ this.d = new Deflate(opts, function (dat, final) {
1956
+ _this.ondata(null, dat, final);
1957
+ });
1958
+ this.compression = 8;
1959
+ this.flag = dbf(opts.level);
1960
+ }
1961
+ ZipDeflate.prototype.process = function (chunk, final) {
1962
+ try {
1963
+ this.d.push(chunk, final);
1964
+ }
1965
+ catch (e) {
1966
+ this.ondata(e, null, final);
1967
+ }
1968
+ };
1969
+ /**
1970
+ * Pushes a chunk to be deflated
1971
+ * @param chunk The chunk to push
1972
+ * @param final Whether this is the last chunk
1973
+ */
1974
+ ZipDeflate.prototype.push = function (chunk, final) {
1975
+ ZipPassThrough.prototype.push.call(this, chunk, final);
1976
+ };
1977
+ return ZipDeflate;
1978
+ }());
1979
+ export { ZipDeflate };
1980
+ /**
1981
+ * Asynchronous streaming DEFLATE compression for ZIP archives
1982
+ */
1983
+ var AsyncZipDeflate = /*#__PURE__*/ (function () {
1984
+ /**
1985
+ * Creates an asynchronous DEFLATE stream that can be added to ZIP archives
1986
+ * @param filename The filename to associate with this data stream
1987
+ * @param opts The compression options
1988
+ */
1989
+ function AsyncZipDeflate(filename, opts) {
1990
+ var _this = this;
1991
+ if (!opts)
1992
+ opts = {};
1993
+ ZipPassThrough.call(this, filename);
1994
+ this.d = new AsyncDeflate(opts, function (err, dat, final) {
1995
+ _this.ondata(err, dat, final);
1996
+ });
1997
+ this.compression = 8;
1998
+ this.flag = dbf(opts.level);
1999
+ this.terminate = this.d.terminate;
2000
+ }
2001
+ AsyncZipDeflate.prototype.process = function (chunk, final) {
2002
+ this.d.push(chunk, final);
2003
+ };
2004
+ /**
2005
+ * Pushes a chunk to be deflated
2006
+ * @param chunk The chunk to push
2007
+ * @param final Whether this is the last chunk
2008
+ */
2009
+ AsyncZipDeflate.prototype.push = function (chunk, final) {
2010
+ ZipPassThrough.prototype.push.call(this, chunk, final);
2011
+ };
2012
+ return AsyncZipDeflate;
2013
+ }());
2014
+ export { AsyncZipDeflate };
2015
+ // TODO: Better tree shaking
2016
+ /**
2017
+ * A zippable archive to which files can incrementally be added
2018
+ */
2019
+ var Zip = /*#__PURE__*/ (function () {
2020
+ /**
2021
+ * Creates an empty ZIP archive to which files can be added
2022
+ * @param cb The callback to call whenever data for the generated ZIP archive
2023
+ * is available
2024
+ */
2025
+ function Zip(cb) {
2026
+ this.ondata = cb;
2027
+ this.u = [];
2028
+ this.d = 1;
2029
+ }
2030
+ /**
2031
+ * Adds a file to the ZIP archive
2032
+ * @param file The file stream to add
2033
+ */
2034
+ Zip.prototype.add = function (file) {
2035
+ var _this = this;
2036
+ if (!this.ondata)
2037
+ err(5);
2038
+ // finishing or finished
2039
+ if (this.d & 2)
2040
+ this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, false);
2041
+ else {
2042
+ var f = strToU8(file.filename), fl_1 = f.length;
2043
+ var com = file.comment, o = com && strToU8(com);
2044
+ var u = fl_1 != file.filename.length || (o && (com.length != o.length));
2045
+ var hl_1 = fl_1 + exfl(file.extra) + 30;
2046
+ if (fl_1 > 65535)
2047
+ this.ondata(err(11, 0, 1), null, false);
2048
+ var header = new u8(hl_1);
2049
+ wzh(header, 0, file, f, u, -1);
2050
+ var chks_1 = [header];
2051
+ var pAll_1 = function () {
2052
+ for (var _i = 0, chks_2 = chks_1; _i < chks_2.length; _i++) {
2053
+ var chk = chks_2[_i];
2054
+ _this.ondata(null, chk, false);
2055
+ }
2056
+ chks_1 = [];
2057
+ };
2058
+ var tr_1 = this.d;
2059
+ this.d = 0;
2060
+ var ind_1 = this.u.length;
2061
+ var uf_1 = mrg(file, {
2062
+ f: f,
2063
+ u: u,
2064
+ o: o,
2065
+ t: function () {
2066
+ if (file.terminate)
2067
+ file.terminate();
2068
+ },
2069
+ r: function () {
2070
+ pAll_1();
2071
+ if (tr_1) {
2072
+ var nxt = _this.u[ind_1 + 1];
2073
+ if (nxt)
2074
+ nxt.r();
2075
+ else
2076
+ _this.d = 1;
2077
+ }
2078
+ tr_1 = 1;
2079
+ }
2080
+ });
2081
+ var cl_1 = 0;
2082
+ file.ondata = function (err, dat, final) {
2083
+ if (err) {
2084
+ _this.ondata(err, dat, final);
2085
+ _this.terminate();
2086
+ }
2087
+ else {
2088
+ cl_1 += dat.length;
2089
+ chks_1.push(dat);
2090
+ if (final) {
2091
+ var dd = new u8(16);
2092
+ wbytes(dd, 0, 0x8074B50);
2093
+ wbytes(dd, 4, file.crc);
2094
+ wbytes(dd, 8, cl_1);
2095
+ wbytes(dd, 12, file.size);
2096
+ chks_1.push(dd);
2097
+ uf_1.c = cl_1, uf_1.b = hl_1 + cl_1 + 16, uf_1.crc = file.crc, uf_1.size = file.size;
2098
+ if (tr_1)
2099
+ uf_1.r();
2100
+ tr_1 = 1;
2101
+ }
2102
+ else if (tr_1)
2103
+ pAll_1();
2104
+ }
2105
+ };
2106
+ this.u.push(uf_1);
2107
+ }
2108
+ };
2109
+ /**
2110
+ * Ends the process of adding files and prepares to emit the final chunks.
2111
+ * This *must* be called after adding all desired files for the resulting
2112
+ * ZIP file to work properly.
2113
+ */
2114
+ Zip.prototype.end = function () {
2115
+ var _this = this;
2116
+ if (this.d & 2) {
2117
+ this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, true);
2118
+ return;
2119
+ }
2120
+ if (this.d)
2121
+ this.e();
2122
+ else
2123
+ this.u.push({
2124
+ r: function () {
2125
+ if (!(_this.d & 1))
2126
+ return;
2127
+ _this.u.splice(-1, 1);
2128
+ _this.e();
2129
+ },
2130
+ t: function () { }
2131
+ });
2132
+ this.d = 3;
2133
+ };
2134
+ Zip.prototype.e = function () {
2135
+ var bt = 0, l = 0, tl = 0;
2136
+ for (var _i = 0, _a = this.u; _i < _a.length; _i++) {
2137
+ var f = _a[_i];
2138
+ tl += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0);
2139
+ }
2140
+ var out = new u8(tl + 22);
2141
+ for (var _b = 0, _c = this.u; _b < _c.length; _b++) {
2142
+ var f = _c[_b];
2143
+ wzh(out, bt, f, f.f, f.u, -f.c - 2, l, f.o);
2144
+ bt += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0), l += f.b;
2145
+ }
2146
+ wzf(out, bt, this.u.length, tl, l);
2147
+ this.ondata(null, out, true);
2148
+ this.d = 2;
2149
+ };
2150
+ /**
2151
+ * A method to terminate any internal workers used by the stream. Subsequent
2152
+ * calls to add() will fail.
2153
+ */
2154
+ Zip.prototype.terminate = function () {
2155
+ for (var _i = 0, _a = this.u; _i < _a.length; _i++) {
2156
+ var f = _a[_i];
2157
+ f.t();
2158
+ }
2159
+ this.d = 2;
2160
+ };
2161
+ return Zip;
2162
+ }());
2163
+ export { Zip };
2164
+ export function zip(data, opts, cb) {
2165
+ if (!cb)
2166
+ cb = opts, opts = {};
2167
+ if (typeof cb != 'function')
2168
+ err(7);
2169
+ var r = {};
2170
+ fltn(data, '', r, opts);
2171
+ var k = Object.keys(r);
2172
+ var lft = k.length, o = 0, tot = 0;
2173
+ var slft = lft, files = new Array(lft);
2174
+ var term = [];
2175
+ var tAll = function () {
2176
+ for (var i = 0; i < term.length; ++i)
2177
+ term[i]();
2178
+ };
2179
+ var cbd = function (a, b) {
2180
+ mt(function () { cb(a, b); });
2181
+ };
2182
+ mt(function () { cbd = cb; });
2183
+ var cbf = function () {
2184
+ var out = new u8(tot + 22), oe = o, cdl = tot - o;
2185
+ tot = 0;
2186
+ for (var i = 0; i < slft; ++i) {
2187
+ var f = files[i];
2188
+ try {
2189
+ var l = f.c.length;
2190
+ wzh(out, tot, f, f.f, f.u, l);
2191
+ var badd = 30 + f.f.length + exfl(f.extra);
2192
+ var loc = tot + badd;
2193
+ out.set(f.c, loc);
2194
+ wzh(out, o, f, f.f, f.u, l, tot, f.m), o += 16 + badd + (f.m ? f.m.length : 0), tot = loc + l;
2195
+ }
2196
+ catch (e) {
2197
+ return cbd(e, null);
2198
+ }
2199
+ }
2200
+ wzf(out, o, files.length, cdl, oe);
2201
+ cbd(null, out);
2202
+ };
2203
+ if (!lft)
2204
+ cbf();
2205
+ var _loop_1 = function (i) {
2206
+ var fn = k[i];
2207
+ var _a = r[fn], file = _a[0], p = _a[1];
2208
+ var c = crc(), size = file.length;
2209
+ c.p(file);
2210
+ var f = strToU8(fn), s = f.length;
2211
+ var com = p.comment, m = com && strToU8(com), ms = m && m.length;
2212
+ var exl = exfl(p.extra);
2213
+ var compression = p.level == 0 ? 0 : 8;
2214
+ var cbl = function (e, d) {
2215
+ if (e) {
2216
+ tAll();
2217
+ cbd(e, null);
2218
+ }
2219
+ else {
2220
+ var l = d.length;
2221
+ files[i] = mrg(p, {
2222
+ size: size,
2223
+ crc: c.d(),
2224
+ c: d,
2225
+ f: f,
2226
+ m: m,
2227
+ u: s != fn.length || (m && (com.length != ms)),
2228
+ compression: compression
2229
+ });
2230
+ o += 30 + s + exl + l;
2231
+ tot += 76 + 2 * (s + exl) + (ms || 0) + l;
2232
+ if (!--lft)
2233
+ cbf();
2234
+ }
2235
+ };
2236
+ if (s > 65535)
2237
+ cbl(err(11, 0, 1), null);
2238
+ if (!compression)
2239
+ cbl(null, file);
2240
+ else if (size < 160000) {
2241
+ try {
2242
+ cbl(null, deflateSync(file, p));
2243
+ }
2244
+ catch (e) {
2245
+ cbl(e, null);
2246
+ }
2247
+ }
2248
+ else
2249
+ term.push(deflate(file, p, cbl));
2250
+ };
2251
+ // Cannot use lft because it can decrease
2252
+ for (var i = 0; i < slft; ++i) {
2253
+ _loop_1(i);
2254
+ }
2255
+ return tAll;
2256
+ }
2257
+ /**
2258
+ * Synchronously creates a ZIP file. Prefer using `zip` for better performance
2259
+ * with more than one file.
2260
+ * @param data The directory structure for the ZIP archive
2261
+ * @param opts The main options, merged with per-file options
2262
+ * @returns The generated ZIP archive
2263
+ */
2264
+ export function zipSync(data, opts) {
2265
+ if (!opts)
2266
+ opts = {};
2267
+ var r = {};
2268
+ var files = [];
2269
+ fltn(data, '', r, opts);
2270
+ var o = 0;
2271
+ var tot = 0;
2272
+ for (var fn in r) {
2273
+ var _a = r[fn], file = _a[0], p = _a[1];
2274
+ var compression = p.level == 0 ? 0 : 8;
2275
+ var f = strToU8(fn), s = f.length;
2276
+ var com = p.comment, m = com && strToU8(com), ms = m && m.length;
2277
+ var exl = exfl(p.extra);
2278
+ if (s > 65535)
2279
+ err(11);
2280
+ var d = compression ? deflateSync(file, p) : file, l = d.length;
2281
+ var c = crc();
2282
+ c.p(file);
2283
+ files.push(mrg(p, {
2284
+ size: file.length,
2285
+ crc: c.d(),
2286
+ c: d,
2287
+ f: f,
2288
+ m: m,
2289
+ u: s != fn.length || (m && (com.length != ms)),
2290
+ o: o,
2291
+ compression: compression
2292
+ }));
2293
+ o += 30 + s + exl + l;
2294
+ tot += 76 + 2 * (s + exl) + (ms || 0) + l;
2295
+ }
2296
+ var out = new u8(tot + 22), oe = o, cdl = tot - o;
2297
+ for (var i = 0; i < files.length; ++i) {
2298
+ var f = files[i];
2299
+ wzh(out, f.o, f, f.f, f.u, f.c.length);
2300
+ var badd = 30 + f.f.length + exfl(f.extra);
2301
+ out.set(f.c, f.o + badd);
2302
+ wzh(out, o, f, f.f, f.u, f.c.length, f.o, f.m), o += 16 + badd + (f.m ? f.m.length : 0);
2303
+ }
2304
+ wzf(out, o, files.length, cdl, oe);
2305
+ return out;
2306
+ }
2307
+ /**
2308
+ * Streaming pass-through decompression for ZIP archives
2309
+ */
2310
+ var UnzipPassThrough = /*#__PURE__*/ (function () {
2311
+ function UnzipPassThrough() {
2312
+ }
2313
+ UnzipPassThrough.prototype.push = function (data, final) {
2314
+ this.ondata(null, data, final);
2315
+ };
2316
+ UnzipPassThrough.compression = 0;
2317
+ return UnzipPassThrough;
2318
+ }());
2319
+ export { UnzipPassThrough };
2320
+ /**
2321
+ * Streaming DEFLATE decompression for ZIP archives. Prefer AsyncZipInflate for
2322
+ * better performance.
2323
+ */
2324
+ var UnzipInflate = /*#__PURE__*/ (function () {
2325
+ /**
2326
+ * Creates a DEFLATE decompression that can be used in ZIP archives
2327
+ */
2328
+ function UnzipInflate() {
2329
+ var _this = this;
2330
+ this.i = new Inflate(function (dat, final) {
2331
+ _this.ondata(null, dat, final);
2332
+ });
2333
+ }
2334
+ UnzipInflate.prototype.push = function (data, final) {
2335
+ try {
2336
+ this.i.push(data, final);
2337
+ }
2338
+ catch (e) {
2339
+ this.ondata(e, null, final);
2340
+ }
2341
+ };
2342
+ UnzipInflate.compression = 8;
2343
+ return UnzipInflate;
2344
+ }());
2345
+ export { UnzipInflate };
2346
+ /**
2347
+ * Asynchronous streaming DEFLATE decompression for ZIP archives
2348
+ */
2349
+ var AsyncUnzipInflate = /*#__PURE__*/ (function () {
2350
+ /**
2351
+ * Creates a DEFLATE decompression that can be used in ZIP archives
2352
+ */
2353
+ function AsyncUnzipInflate(_, sz) {
2354
+ var _this = this;
2355
+ if (sz < 320000) {
2356
+ this.i = new Inflate(function (dat, final) {
2357
+ _this.ondata(null, dat, final);
2358
+ });
2359
+ }
2360
+ else {
2361
+ this.i = new AsyncInflate(function (err, dat, final) {
2362
+ _this.ondata(err, dat, final);
2363
+ });
2364
+ this.terminate = this.i.terminate;
2365
+ }
2366
+ }
2367
+ AsyncUnzipInflate.prototype.push = function (data, final) {
2368
+ if (this.i.terminate)
2369
+ data = slc(data, 0);
2370
+ this.i.push(data, final);
2371
+ };
2372
+ AsyncUnzipInflate.compression = 8;
2373
+ return AsyncUnzipInflate;
2374
+ }());
2375
+ export { AsyncUnzipInflate };
2376
+ /**
2377
+ * A ZIP archive decompression stream that emits files as they are discovered
2378
+ */
2379
+ var Unzip = /*#__PURE__*/ (function () {
2380
+ /**
2381
+ * Creates a ZIP decompression stream
2382
+ * @param cb The callback to call whenever a file in the ZIP archive is found
2383
+ */
2384
+ function Unzip(cb) {
2385
+ this.onfile = cb;
2386
+ this.k = [];
2387
+ this.o = {
2388
+ 0: UnzipPassThrough
2389
+ };
2390
+ this.p = et;
2391
+ }
2392
+ /**
2393
+ * Pushes a chunk to be unzipped
2394
+ * @param chunk The chunk to push
2395
+ * @param final Whether this is the last chunk
2396
+ */
2397
+ Unzip.prototype.push = function (chunk, final) {
2398
+ var _this = this;
2399
+ if (!this.onfile)
2400
+ err(5);
2401
+ if (!this.p)
2402
+ err(4);
2403
+ if (this.c > 0) {
2404
+ var len = Math.min(this.c, chunk.length);
2405
+ var toAdd = chunk.subarray(0, len);
2406
+ this.c -= len;
2407
+ if (this.d)
2408
+ this.d.push(toAdd, !this.c);
2409
+ else
2410
+ this.k[0].push(toAdd);
2411
+ chunk = chunk.subarray(len);
2412
+ if (chunk.length)
2413
+ return this.push(chunk, final);
2414
+ }
2415
+ else {
2416
+ var f = 0, i = 0, is = void 0, buf = void 0;
2417
+ if (!this.p.length)
2418
+ buf = chunk;
2419
+ else if (!chunk.length)
2420
+ buf = this.p;
2421
+ else {
2422
+ buf = new u8(this.p.length + chunk.length);
2423
+ buf.set(this.p), buf.set(chunk, this.p.length);
2424
+ }
2425
+ var l = buf.length, oc = this.c, add = oc && this.d;
2426
+ var _loop_2 = function () {
2427
+ var _a;
2428
+ var sig = b4(buf, i);
2429
+ if (sig == 0x4034B50) {
2430
+ f = 1, is = i;
2431
+ this_1.d = null;
2432
+ this_1.c = 0;
2433
+ var bf = b2(buf, i + 6), cmp_1 = b2(buf, i + 8), u = bf & 2048, dd = bf & 8, fnl = b2(buf, i + 26), es = b2(buf, i + 28);
2434
+ if (l > i + 30 + fnl + es) {
2435
+ var chks_3 = [];
2436
+ this_1.k.unshift(chks_3);
2437
+ f = 2;
2438
+ var sc_1 = b4(buf, i + 18), su_1 = b4(buf, i + 22);
2439
+ var fn_1 = strFromU8(buf.subarray(i + 30, i += 30 + fnl), !u);
2440
+ if (sc_1 == 4294967295) {
2441
+ _a = dd ? [-2] : z64e(buf, i), sc_1 = _a[0], su_1 = _a[1];
2442
+ }
2443
+ else if (dd)
2444
+ sc_1 = -1;
2445
+ i += es;
2446
+ this_1.c = sc_1;
2447
+ var d_1;
2448
+ var file_1 = {
2449
+ name: fn_1,
2450
+ compression: cmp_1,
2451
+ start: function () {
2452
+ if (!file_1.ondata)
2453
+ err(5);
2454
+ if (!sc_1)
2455
+ file_1.ondata(null, et, true);
2456
+ else {
2457
+ var ctr = _this.o[cmp_1];
2458
+ if (!ctr)
2459
+ file_1.ondata(err(14, 'unknown compression type ' + cmp_1, 1), null, false);
2460
+ d_1 = sc_1 < 0 ? new ctr(fn_1) : new ctr(fn_1, sc_1, su_1);
2461
+ d_1.ondata = function (err, dat, final) { file_1.ondata(err, dat, final); };
2462
+ for (var _i = 0, chks_4 = chks_3; _i < chks_4.length; _i++) {
2463
+ var dat = chks_4[_i];
2464
+ d_1.push(dat, false);
2465
+ }
2466
+ if (_this.k[0] == chks_3 && _this.c)
2467
+ _this.d = d_1;
2468
+ else
2469
+ d_1.push(et, true);
2470
+ }
2471
+ },
2472
+ terminate: function () {
2473
+ if (d_1 && d_1.terminate)
2474
+ d_1.terminate();
2475
+ }
2476
+ };
2477
+ if (sc_1 >= 0)
2478
+ file_1.size = sc_1, file_1.originalSize = su_1;
2479
+ this_1.onfile(file_1);
2480
+ }
2481
+ return "break";
2482
+ }
2483
+ else if (oc) {
2484
+ if (sig == 0x8074B50) {
2485
+ is = i += 12 + (oc == -2 && 8), f = 3, this_1.c = 0;
2486
+ return "break";
2487
+ }
2488
+ else if (sig == 0x2014B50) {
2489
+ is = i -= 4, f = 3, this_1.c = 0;
2490
+ return "break";
2491
+ }
2492
+ }
2493
+ };
2494
+ var this_1 = this;
2495
+ for (; i < l - 4; ++i) {
2496
+ var state_1 = _loop_2();
2497
+ if (state_1 === "break")
2498
+ break;
2499
+ }
2500
+ this.p = et;
2501
+ if (oc < 0) {
2502
+ var dat = f ? buf.subarray(0, is - 12 - (oc == -2 && 8) - (b4(buf, is - 16) == 0x8074B50 && 4)) : buf.subarray(0, i);
2503
+ if (add)
2504
+ add.push(dat, !!f);
2505
+ else
2506
+ this.k[+(f == 2)].push(dat);
2507
+ }
2508
+ if (f & 2)
2509
+ return this.push(buf.subarray(i), final);
2510
+ this.p = buf.subarray(i);
2511
+ }
2512
+ if (final) {
2513
+ if (this.c)
2514
+ err(13);
2515
+ this.p = null;
2516
+ }
2517
+ };
2518
+ /**
2519
+ * Registers a decoder with the stream, allowing for files compressed with
2520
+ * the compression type provided to be expanded correctly
2521
+ * @param decoder The decoder constructor
2522
+ */
2523
+ Unzip.prototype.register = function (decoder) {
2524
+ this.o[decoder.compression] = decoder;
2525
+ };
2526
+ return Unzip;
2527
+ }());
2528
+ export { Unzip };
2529
+ var mt = typeof queueMicrotask == 'function' ? queueMicrotask : typeof setTimeout == 'function' ? setTimeout : function (fn) { fn(); };
2530
+ export function unzip(data, opts, cb) {
2531
+ if (!cb)
2532
+ cb = opts, opts = {};
2533
+ if (typeof cb != 'function')
2534
+ err(7);
2535
+ var term = [];
2536
+ var tAll = function () {
2537
+ for (var i = 0; i < term.length; ++i)
2538
+ term[i]();
2539
+ };
2540
+ var files = {};
2541
+ var cbd = function (a, b) {
2542
+ mt(function () { cb(a, b); });
2543
+ };
2544
+ mt(function () { cbd = cb; });
2545
+ var e = data.length - 22;
2546
+ for (; b4(data, e) != 0x6054B50; --e) {
2547
+ if (!e || data.length - e > 65558) {
2548
+ cbd(err(13, 0, 1), null);
2549
+ return tAll;
2550
+ }
2551
+ }
2552
+ ;
2553
+ var lft = b2(data, e + 8);
2554
+ if (lft) {
2555
+ var c = lft;
2556
+ var o = b4(data, e + 16);
2557
+ var z = o == 4294967295 || c == 65535;
2558
+ if (z) {
2559
+ var ze = b4(data, e - 12);
2560
+ z = b4(data, ze) == 0x6064B50;
2561
+ if (z) {
2562
+ c = lft = b4(data, ze + 32);
2563
+ o = b4(data, ze + 48);
2564
+ }
2565
+ }
2566
+ var fltr = opts && opts.filter;
2567
+ var _loop_3 = function (i) {
2568
+ var _a = zh(data, o, z), c_1 = _a[0], sc = _a[1], su = _a[2], fn = _a[3], no = _a[4], off = _a[5], b = slzh(data, off);
2569
+ o = no;
2570
+ var cbl = function (e, d) {
2571
+ if (e) {
2572
+ tAll();
2573
+ cbd(e, null);
2574
+ }
2575
+ else {
2576
+ if (d)
2577
+ files[fn] = d;
2578
+ if (!--lft)
2579
+ cbd(null, files);
2580
+ }
2581
+ };
2582
+ if (!fltr || fltr({
2583
+ name: fn,
2584
+ size: sc,
2585
+ originalSize: su,
2586
+ compression: c_1
2587
+ })) {
2588
+ if (!c_1)
2589
+ cbl(null, slc(data, b, b + sc));
2590
+ else if (c_1 == 8) {
2591
+ var infl = data.subarray(b, b + sc);
2592
+ // Synchronously decompress under 512KB, or barely-compressed data
2593
+ if (su < 524288 || sc > 0.8 * su) {
2594
+ try {
2595
+ cbl(null, inflateSync(infl, { out: new u8(su) }));
2596
+ }
2597
+ catch (e) {
2598
+ cbl(e, null);
2599
+ }
2600
+ }
2601
+ else
2602
+ term.push(inflate(infl, { size: su }, cbl));
2603
+ }
2604
+ else
2605
+ cbl(err(14, 'unknown compression type ' + c_1, 1), null);
2606
+ }
2607
+ else
2608
+ cbl(null, null);
2609
+ };
2610
+ for (var i = 0; i < c; ++i) {
2611
+ _loop_3(i);
2612
+ }
2613
+ }
2614
+ else
2615
+ cbd(null, {});
2616
+ return tAll;
2617
+ }
2618
+ /**
2619
+ * Synchronously decompresses a ZIP archive. Prefer using `unzip` for better
2620
+ * performance with more than one file.
2621
+ * @param data The raw compressed ZIP file
2622
+ * @param opts The ZIP extraction options
2623
+ * @returns The decompressed files
2624
+ */
2625
+ export function unzipSync(data, opts) {
2626
+ var files = {};
2627
+ var e = data.length - 22;
2628
+ for (; b4(data, e) != 0x6054B50; --e) {
2629
+ if (!e || data.length - e > 65558)
2630
+ err(13);
2631
+ }
2632
+ ;
2633
+ var c = b2(data, e + 8);
2634
+ if (!c)
2635
+ return {};
2636
+ var o = b4(data, e + 16);
2637
+ var z = o == 4294967295 || c == 65535;
2638
+ if (z) {
2639
+ var ze = b4(data, e - 12);
2640
+ z = b4(data, ze) == 0x6064B50;
2641
+ if (z) {
2642
+ c = b4(data, ze + 32);
2643
+ o = b4(data, ze + 48);
2644
+ }
2645
+ }
2646
+ var fltr = opts && opts.filter;
2647
+ for (var i = 0; i < c; ++i) {
2648
+ var _a = zh(data, o, z), c_2 = _a[0], sc = _a[1], su = _a[2], fn = _a[3], no = _a[4], off = _a[5], b = slzh(data, off);
2649
+ o = no;
2650
+ if (!fltr || fltr({
2651
+ name: fn,
2652
+ size: sc,
2653
+ originalSize: su,
2654
+ compression: c_2
2655
+ })) {
2656
+ if (!c_2)
2657
+ files[fn] = slc(data, b, b + sc);
2658
+ else if (c_2 == 8)
2659
+ files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
2660
+ else
2661
+ err(14, 'unknown compression type ' + c_2);
2662
+ }
2663
+ }
2664
+ return files;
2665
+ }
vendor/font-awesome/css/all.min.css ADDED
The diff for this file is too large to render. See raw diff
 
vendor/font-awesome/webfonts/fa-brands-400.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e28096fa75a96ac77020155ea3a6dd7312983e84115366d4cf49a0c312ec6d51
3
+ size 209128
vendor/font-awesome/webfonts/fa-brands-400.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:232c6f6a7678304f9efaa26f30b1610debc2ba9f4cd636b5e6751c8d73761b92
3
+ size 117852
vendor/font-awesome/webfonts/fa-regular-400.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9174757efc83e072436e873c22be1663d3c103b0a16d7fb73569af4918d4d351
3
+ size 67860
vendor/font-awesome/webfonts/fa-regular-400.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c27da6f833431da5aa295c44540bfac0fd8270ba6a3c4346427006d8a7b34b76
3
+ size 25392
vendor/font-awesome/webfonts/fa-solid-900.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4990d0d0c5f5d38d62e936eea120674e584c7eea8dcee38a975c0cf9a37539b
3
+ size 420332
vendor/font-awesome/webfonts/fa-solid-900.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ae17c16afbea216707b2203ea1cf9bdb45b9bfe47d0f4ae3258ddbc6294dd02f
3
+ size 156400