diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 23e7cb8..939b5d5 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -8,6 +8,12 @@ server { # Static only, plus the API proxy below; a stricter CSP needs a per-app tune # (canvas wasm, the QR image host) and belongs here once it is done. + # Cross-origin isolation: super-resolution runs onnxruntime's threaded wasm, + # which needs a SharedArrayBuffer, which the browser only hands over when the + # page is isolated. Both headers are required, and both must also sit on the + # script responses a nested worker fetches — see the two locations below. + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; @@ -21,10 +27,16 @@ server { # the server hands over as octet-stream: onnxruntime's glue is one of those. # First match wins among regex locations, so this one stands before the # /wasm/ block that would otherwise swallow the file. + # (Both locations below restate the isolation headers from above: an add_header + # in a location drops every add_header inherited, and a worker script is fetched + # under the embedder's COEP — Chromium blocks it as + # `coep-frame-resource-needs-coep-header` without them.) location ~* \.mjs$ { types { application/javascript mjs; } expires 30d; add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; try_files $uri =404; } @@ -34,6 +46,8 @@ server { location ~* ^/(wasm|models|assets)/ { expires 30d; add_header Cache-Control "public, immutable"; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; try_files $uri =404; } diff --git a/docker/frontend/src/engine/superRes.ts b/docker/frontend/src/engine/superRes.ts index 2a79e2a..873c7b2 100644 --- a/docker/frontend/src/engine/superRes.ts +++ b/docker/frontend/src/engine/superRes.ts @@ -42,10 +42,14 @@ function load(): Promise { loaded = (async () => { const ort = await import('onnxruntime-web'); ort.env.wasm.wasmPaths = WASM_DIR; - // No cross-origin isolation on this site, so the threaded path has no - // SharedArrayBuffer to use; saying so keeps the runtime from fetching a - // proxy worker it cannot run. - ort.env.wasm.numThreads = 1; + // nginx sends COOP+COEP, so the page is cross-origin isolated and there is + // a SharedArrayBuffer for the threaded build to spread a tile over. The + // check is for the case the headers ever go missing: asking for more than + // one thread without isolation makes the runtime throw instead of falling + // back, and a slow export beats a broken one. + ort.env.wasm.numThreads = self.crossOriginIsolated + ? Math.min(8, navigator.hardwareConcurrency || 1) + : 1; const session = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] }).catch(() => ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'] }) ); @@ -125,19 +129,31 @@ export async function upscaleJpeg( const oh = dims[2]; const opts = tensor.data as Float32Array; - // Paint the tile's own pixels into a canvas of their own, then draw only - // the unpadded middle of it into the output, at the output's scale. - const tile = new OffscreenCanvas(ow, oh); + // Paint the tile's own pixels into a canvas of their own — the padded ring + // the model needed to see is skipped here instead of drawn and clipped — + // then draw that canvas into the output at the output's scale. + const cw = tw * MODEL_SCALE; + const ch = th * MODEL_SCALE; + const tile = new OffscreenCanvas(cw, ch); const tileCtx = tile.getContext('2d'); if (!tileCtx) return bytes; - const img = tileCtx.createImageData(ow, oh); + const img = tileCtx.createImageData(cw, ch); const oplane = ow * oh; - for (let i = 0; i < oplane; i++) { - const d = i * 4; - img.data[d] = clamp8(opts[i]); - img.data[d + 1] = clamp8(opts[oplane + i]); - img.data[d + 2] = clamp8(opts[2 * oplane + i]); - img.data[d + 3] = 255; + const ox = left * MODEL_SCALE; + const oy = top * MODEL_SCALE; + for (let y = 0; y < ch; y++) { + const srcRow = (oy + y) * ow + ox; + let d = y * cw * 4; + for (let x = 0; x < cw; x++) { + const s = srcRow + x; + // A Uint8ClampedArray clamps and rounds on assignment; a clamp call + // per channel was most of this loop's cost. + img.data[d] = opts[s] * 255; + img.data[d + 1] = opts[oplane + s] * 255; + img.data[d + 2] = opts[2 * oplane + s] * 255; + img.data[d + 3] = 255; + d += 4; + } } tileCtx.putImageData(img, 0, 0); // Destination edges snapped to whole pixels: neighbouring tiles then share @@ -148,10 +164,10 @@ export async function upscaleJpeg( const dy0 = Math.round(y0 * scale); dstCtx.drawImage( tile, - left * MODEL_SCALE, - top * MODEL_SCALE, - tw * MODEL_SCALE, - th * MODEL_SCALE, + 0, + 0, + cw, + ch, dx0, dy0, Math.round((x0 + tw) * scale) - dx0, @@ -169,8 +185,3 @@ export async function upscaleJpeg( bitmap.close(); } } - -function clamp8(v: number): number { - const n = v * 255; - return n <= 0 ? 0 : n >= 255 ? 255 : Math.round(n); -} diff --git a/docker/frontend/src/ui/QrCode.tsx b/docker/frontend/src/ui/QrCode.tsx index 0b9122b..69eef5e 100644 --- a/docker/frontend/src/ui/QrCode.tsx +++ b/docker/frontend/src/ui/QrCode.tsx @@ -8,6 +8,8 @@ import { useState } from 'react'; export function QrCode({ url, size = 180, className }: { url: string; size?: number; className?: string }) { const [ok, setOk] = useState(true); if (!ok) return null; + // `crossOrigin`: the page is cross-origin isolated (COEP), which rejects a + // cross-origin image unless it opted in with CORS — qrserver answers `*`. return ( setOk(false)} src={`https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}`} />