Spaces:
Running
Running
Nicholas Celestin commited on
Commit ·
3f22414
1
Parent(s): e6167a7
Build update — 2026-05-22T18:34:00.912Z
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +12 -33
- README.md +22 -6
- assets/favicon-180x180.png +3 -0
- assets/favicon-32x32.png +3 -0
- assets/favicon.ico +3 -0
- assets/favicon.svg +42 -0
- assets/shared.css +33 -0
- components/compare-slider.js +469 -0
- components/image-cropper.js +243 -0
- components/image-drop-zone.js +90 -0
- components/status-bar.js +179 -0
- components/view-mode-controls.js +97 -0
- features/bg-removal/bg-removal-app.js +564 -0
- features/bg-removal/bg-removal-engine.js +230 -0
- features/upscaler/custom-models/custom-model-inspector.js +336 -0
- features/upscaler/custom-models/custom-model-store.js +205 -0
- features/upscaler/custom-models/custom-model-upload-dialog.js +460 -0
- features/upscaler/engine/face-detector-engine.js +368 -0
- features/upscaler/engine/gpu-frame-extractor.js +201 -0
- features/upscaler/engine/gpu-tile-renderer.js +251 -0
- features/upscaler/engine/tiling.js +168 -0
- features/upscaler/engine/upscaler-engine.js +924 -0
- features/upscaler/model-registry.js +135 -0
- features/upscaler/ui/perf-monitor.js +305 -0
- features/upscaler/ui/upscale-preview.js +117 -0
- features/upscaler/ui/upscaler-canvas-area.js +146 -0
- features/upscaler/ui/upscaler-controls.js +874 -0
- features/upscaler/ui/upscaler-toolbar.js +247 -0
- features/upscaler/upscale-pipeline.js +536 -0
- features/upscaler/upscaler-app.js +558 -0
- index.html +87 -18
- lib/backend-events.js +134 -0
- lib/backend.js +207 -0
- lib/canvas.js +175 -0
- lib/fetch-progress.js +121 -0
- lib/morph.js +32 -0
- lib/onnx-meta.js +18 -0
- models/4x-ClearRealityV1.onnx +3 -0
- models/4x-UltraSharpV2_Lite.onnx +3 -0
- models/4x-UpdraftSmall.onnx +3 -0
- models/DAT_light_x4_dyn_OTF_4.onnx +3 -0
- style.css +0 -28
- vendor/fflate/index.mjs +2665 -0
- vendor/font-awesome/css/all.min.css +0 -0
- vendor/font-awesome/webfonts/fa-brands-400.ttf +3 -0
- vendor/font-awesome/webfonts/fa-brands-400.woff2 +3 -0
- vendor/font-awesome/webfonts/fa-regular-400.ttf +3 -0
- vendor/font-awesome/webfonts/fa-regular-400.woff2 +3 -0
- vendor/font-awesome/webfonts/fa-solid-900.ttf +3 -0
- 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 |
-
*.
|
| 33 |
-
*.
|
| 34 |
-
*.
|
| 35 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
assets/favicon-32x32.png
ADDED
|
|
Git LFS Details
|
assets/favicon.ico
ADDED
|
|
Git LFS Details
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 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, '&')
|
| 23 |
+
.replace(/</g, '<')
|
| 24 |
+
.replace(/>/g, '>')
|
| 25 |
+
.replace(/"/g, '"')
|
| 26 |
+
.replace(/'/g, ''');
|
| 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 >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 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|