concept-collection / numbl-image-filter
Initial commit: numbl image filter web app
Vite + React + TypeScript app that filters images with a numbl (MATLAB-syntax) conv2/elementwise script entirely in the browser. Uploads persist in IndexedDB; includes a GitHub Pages deploy workflow.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit c2ebaca47ff9 Browse files
27 changed files+5355−0
.github/workflows/deploy.ymladded+43−0View file
@@ -0,0 +1,43 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+# Allow one concurrent deployment; don't cancel an in-progress production deploy.
14+concurrency:
15+ group: pages
16+ cancel-in-progress: false
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - uses: actions/checkout@v4
23+ - uses: actions/setup-node@v4
24+ with:
25+ node-version: 22
26+ cache: npm
27+ - run: npm ci
28+ - name: Build
29+ run: npm run build:pages
30+ - uses: actions/configure-pages@v5
31+ - uses: actions/upload-pages-artifact@v3
32+ with:
33+ path: dist
34+
35+ deploy:
36+ needs: build
37+ runs-on: ubuntu-latest
38+ environment:
39+ name: github-pages
40+ url: ${{ steps.deployment.outputs.page_url }}
41+ steps:
42+ - id: deployment
43+ uses: actions/deploy-pages@v4
.gitignoreadded+12−0View file
@@ -0,0 +1,12 @@
1+node_modules
2+dist
3+dist-ssr
4+*.local
5+.vite
6+*.tsbuildinfo
7+
8+# Editor
9+.vscode/*
10+!.vscode/extensions.json
11+.idea
12+.DS_Store
README.mdadded+81−0View file
@@ -0,0 +1,81 @@
1+# numbl image filter
2+
3+A small web app that filters an image using a **[numbl](https://numbl.org)** script
4+(MATLAB syntax), running entirely in your browser. Pick a built-in sample or upload
5+your own image, write a filter function, and see the original and filtered images
6+side by side.
7+
8+It uses [numbl](https://www.npmjs.com/package/numbl) as a library: the image is
9+handed to the script as a tensor and the script's output tensor is drawn back to a
10+canvas.
11+
12+## Quick start
13+
14+```bash
15+npm install
16+npm run dev
17+```
18+
19+Then open the printed URL.
20+
21+```bash
22+npm run build # type-check + production build to dist/
23+npm run preview # serve the production build
24+```
25+
26+## How the filter works
27+
28+You write a function `filterImage` (MATLAB syntax). It receives the image and
29+returns a new one:
30+
31+```matlab
32+function out = filterImage(img)
33+ % img : H x W x 3 array of doubles in [0, 255] (RGB)
34+ % out : H x W x 3 (color) or H x W (grayscale) in [0, 255]
35+ out = 255 - img; % invert
36+end
37+```
38+
39+- `img(:,:,1)`, `img(:,:,2)`, `img(:,:,3)` are the R, G, B planes.
40+- Returning an `H x W` (2-D) array gives a grayscale result.
41+- Output values are clamped to `[0, 255]` when displayed.
42+- The function name doesn't have to be `filterImage` — the app calls whatever
43+ function the script defines. (A script with no `function` header is run as-is
44+ with `img` predefined and `out` read back.)
45+
46+Use the **Example** dropdown for ready-made filters: grayscale, sepia, posterize,
47+contrast, channel swap, Sobel edges, box blur, and more.
48+
49+## How it's wired
50+
51+```
52+upload / sample image
53+ │ decode to RGBA (canvas)
54+ ▼
55+RGBA bytes ── rgbaToTensorData ──► [H,W,3] doubles (column-major, 0-255)
56+ ▼
57+Web Worker: executeCode("out = filterImage(img);", { initialVariableValues: { img } })
58+ ▼
59+out tensor ── tensorToRaw ──► RGBA bytes ──► canvas
60+```
61+
62+- Marshaling lives in [`src/imageConvert.ts`](src/imageConvert.ts). numbl tensors are
63+ **column-major**; browser `ImageData` is row-major RGBA — the conversion handles both.
64+- The filter runs in a [Web Worker](src/filter.worker.ts) (`numbl`'s `executeCode` at
65+ optimization `"1"`, the JS-JIT, which is browser-safe) so full-resolution images with
66+ per-pixel scripts don't freeze the page.
67+
68+## Images
69+
70+Everything runs locally — there are **no remote images**. Two sources:
71+
72+- **Generated** patterns (RGB gradient, shapes, checkerboard) created in the browser
73+ ([`src/synthetic.ts`](src/synthetic.ts) / [`src/samples.ts`](src/samples.ts)).
74+- **Your uploads**, which are saved to **IndexedDB** and persist across sessions.
75+ They appear under "Your images" in the picker; each can be deleted with the × button.
76+ Storage lives in [`src/imageStore.ts`](src/imageStore.ts) (the original compressed
77+ file Blob + a small thumbnail are stored; the full image is decoded on demand).
78+
79+## License
80+
81+Apache-2.0 (matching numbl). Sample photos are CC0 / public domain from Wikimedia Commons.
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <title>numbl image filter</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
package-lock.jsonadded+3528−0View file
This diff is 3,533 lines long and is not shown.
package.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "name": "numbl-image-filter",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "Filter images in the browser with a MATLAB-syntax numbl script",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc -b && vite build",
10+ "build:pages": "tsc -b && vite build --mode pages",
11+ "preview": "vite preview",
12+ "typecheck": "tsc -b"
13+ },
14+ "dependencies": {
15+ "numbl": "^0.4.4",
16+ "react": "^19.2.0",
17+ "react-dom": "^19.2.0"
18+ },
19+ "devDependencies": {
20+ "@types/react": "^19.2.5",
21+ "@types/react-dom": "^19.2.3",
22+ "@vitejs/plugin-react": "^5.1.1",
23+ "typescript": "~5.9.3",
24+ "vite": "^7.2.4"
25+ }
26+}
public/favicon.svgadded+7−0View file
@@ -0,0 +1,7 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="6" fill="#1f2937" />
3+ <rect x="6" y="6" width="9" height="9" rx="1.5" fill="#ef4444" />
4+ <rect x="17" y="6" width="9" height="9" rx="1.5" fill="#22c55e" />
5+ <rect x="6" y="17" width="9" height="9" rx="1.5" fill="#3b82f6" />
6+ <rect x="17" y="17" width="9" height="9" rx="1.5" fill="#eab308" />
7+</svg>
src/App.tsxadded+233−0View file
@@ -0,0 +1,233 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+import type { RawImage } from "./imageConvert.ts";
3+import type { SyntheticSample } from "./samples.ts";
4+import { SAMPLES } from "./samples.ts";
5+import { DEFAULT_SCRIPT } from "./examples.ts";
6+import { createFilterClient } from "./filterClient.ts";
7+import type { FilterClient } from "./filterClient.ts";
8+import type { UploadRecord } from "./imageStore.ts";
9+import {
10+ addUpload,
11+ deleteUpload,
12+ getUploadImage,
13+ listUploads,
14+} from "./imageStore.ts";
15+import { ImageView } from "./components/ImageView.tsx";
16+import { SamplePicker } from "./components/SamplePicker.tsx";
17+import { ScriptPanel } from "./components/ScriptPanel.tsx";
18+
19+interface Loaded {
20+ image: RawImage;
21+ label: string;
22+}
23+
24+export default function App() {
25+ // The worker is created lazily and recreated after teardown. This survives
26+ // React StrictMode's dev-only mount→unmount→mount, whose cleanup would
27+ // otherwise terminate a worker held in a ref and never replace it.
28+ const clientRef = useRef<FilterClient | null>(null);
29+ const getClient = useCallback((): FilterClient => {
30+ if (!clientRef.current) clientRef.current = createFilterClient();
31+ return clientRef.current;
32+ }, []);
33+
34+ const [script, setScript] = useState(DEFAULT_SCRIPT);
35+ const [original, setOriginal] = useState<Loaded | null>(null);
36+ const [uploads, setUploads] = useState<UploadRecord[]>([]);
37+ const [selectedId, setSelectedId] = useState<string | null>(null);
38+ const [filtered, setFiltered] = useState<RawImage | null>(null);
39+ const [running, setRunning] = useState(false);
40+ const [loadingImage, setLoadingImage] = useState(false);
41+ const [error, setError] = useState<string | null>(null);
42+ const [logs, setLogs] = useState<string[]>([]);
43+ const [elapsedMs, setElapsedMs] = useState<number | null>(null);
44+
45+ const scriptRef = useRef(script);
46+ scriptRef.current = script;
47+ const originalRef = useRef(original);
48+ originalRef.current = original;
49+ const runIdRef = useRef(0);
50+
51+ useEffect(() => {
52+ getClient(); // ensure a live worker exists for this mount
53+ return () => {
54+ clientRef.current?.terminate();
55+ clientRef.current = null; // force a fresh worker on the next mount
56+ };
57+ }, [getClient]);
58+
59+ const run = useCallback(async () => {
60+ const loaded = originalRef.current;
61+ if (!loaded) return;
62+ const myId = ++runIdRef.current;
63+ setRunning(true);
64+ try {
65+ const res = await getClient().run(scriptRef.current, loaded.image);
66+ if (runIdRef.current !== myId) return;
67+ setFiltered({ width: res.width, height: res.height, rgba: res.rgba });
68+ setLogs(res.logs);
69+ setElapsedMs(res.elapsedMs);
70+ setError(null);
71+ } catch (e) {
72+ if (runIdRef.current !== myId) return;
73+ const err = e as Error & { logs?: string[] };
74+ setError(err.message);
75+ setLogs(err.logs ?? []);
76+ setElapsedMs(null);
77+ } finally {
78+ if (runIdRef.current === myId) setRunning(false);
79+ }
80+ }, [getClient]);
81+
82+ const selectSample = useCallback((sample: SyntheticSample) => {
83+ setSelectedId(sample.id);
84+ setError(null);
85+ setOriginal({ image: sample.generate(), label: sample.name });
86+ }, []);
87+
88+ const selectUpload = useCallback(async (rec: UploadRecord) => {
89+ setSelectedId(rec.id);
90+ setLoadingImage(true);
91+ setError(null);
92+ try {
93+ const image = await getUploadImage(rec);
94+ setOriginal({ image, label: rec.name });
95+ } catch (e) {
96+ setError((e as Error).message);
97+ } finally {
98+ setLoadingImage(false);
99+ }
100+ }, []);
101+
102+ const uploadFile = useCallback(async (file: File) => {
103+ setLoadingImage(true);
104+ setError(null);
105+ try {
106+ const { record, image } = await addUpload(file);
107+ setUploads((prev) => [record, ...prev]);
108+ setSelectedId(record.id);
109+ setOriginal({ image, label: record.name });
110+ } catch (e) {
111+ setError(
112+ `Could not load or store the image: ${(e as Error).message}`
113+ );
114+ } finally {
115+ setLoadingImage(false);
116+ }
117+ }, []);
118+
119+ const removeUpload = useCallback(
120+ async (id: string) => {
121+ try {
122+ await deleteUpload(id);
123+ } catch (e) {
124+ setError((e as Error).message);
125+ return;
126+ }
127+ setUploads((prev) => prev.filter((u) => u.id !== id));
128+ if (selectedId === id) selectSample(SAMPLES[0]);
129+ },
130+ [selectedId, selectSample]
131+ );
132+
133+ // Load persisted uploads, then show a default image, on first mount.
134+ useEffect(() => {
135+ let cancelled = false;
136+ listUploads()
137+ .then((list) => {
138+ if (!cancelled) setUploads(list);
139+ })
140+ .catch(() => {
141+ /* ignore — uploads list just stays empty */
142+ });
143+ selectSample(SAMPLES[0]);
144+ return () => {
145+ cancelled = true;
146+ };
147+ }, [selectSample]);
148+
149+ // No auto-run: when a new image is loaded, clear the previous result so the
150+ // Filtered panel never shows a stale output for a different image. The
151+ // filter runs only on the Run button (or ⌘/Ctrl+Enter).
152+ useEffect(() => {
153+ setFiltered(null);
154+ setLogs([]);
155+ setElapsedMs(null);
156+ }, [original]);
157+
158+ return (
159+ <div className="app">
160+ <header className="app-header">
161+ <h1>numbl image filter</h1>
162+ <p>
163+ Filter an image with a{" "}
164+ <a href="https://numbl.org" target="_blank" rel="noreferrer">
165+ numbl
166+ </a>{" "}
167+ script (MATLAB syntax), run entirely in your browser.
168+ </p>
169+ </header>
170+
171+ <section className="panel">
172+ <h2>1 · Choose an image</h2>
173+ <SamplePicker
174+ samples={SAMPLES}
175+ uploads={uploads}
176+ selectedId={selectedId}
177+ onSelectSample={selectSample}
178+ onSelectUpload={selectUpload}
179+ onUpload={uploadFile}
180+ onDeleteUpload={removeUpload}
181+ disabled={loadingImage}
182+ />
183+ </section>
184+
185+ <section className="panel">
186+ <h2>2 · Write a filter</h2>
187+ <ScriptPanel
188+ script={script}
189+ onChange={setScript}
190+ onRun={run}
191+ running={running}
192+ />
193+ </section>
194+
195+ <section className="panel">
196+ <h2>3 · Result</h2>
197+ {error && <div className="error-banner">⚠ {error}</div>}
198+ <div className="viewers">
199+ <ImageView
200+ title={`Original${original ? " · " + original.label : ""}`}
201+ image={original?.image ?? null}
202+ placeholder={loadingImage ? "Loading…" : "No image"}
203+ />
204+ <ImageView
205+ title="Filtered"
206+ image={filtered}
207+ placeholder={running ? "Running…" : "Press Run ▶ to apply the filter"}
208+ downloadName="filtered.png"
209+ overlay={
210+ running ? <div className="running-overlay">Running…</div> : null
211+ }
212+ />
213+ </div>
214+ <div className="status-row">
215+ {elapsedMs != null && !error && (
216+ <span className="timing">Ran in {elapsedMs.toFixed(0)} ms</span>
217+ )}
218+ {logs.length > 0 && (
219+ <pre className="console">{logs.join("")}</pre>
220+ )}
221+ </div>
222+ </section>
223+
224+ <footer className="app-footer">
225+ Powered by{" "}
226+ <a href="https://numbl.org" target="_blank" rel="noreferrer">
227+ numbl
228+ </a>{" "}
229+ · everything runs locally in your browser
230+ </footer>
231+ </div>
232+ );
233+}
src/components/ImageView.tsxadded+60−0View file
@@ -0,0 +1,60 @@
1+import { useEffect, useRef } from "react";
2+import type { RawImage } from "../imageConvert.ts";
3+import { drawRawToCanvas, rawToDataUrl } from "../imageLoad.ts";
4+
5+interface Props {
6+ title: string;
7+ image: RawImage | null;
8+ placeholder?: string;
9+ /** When set, shows a download link for the image (PNG). */
10+ downloadName?: string;
11+ overlay?: React.ReactNode;
12+}
13+
14+export function ImageView({
15+ title,
16+ image,
17+ placeholder,
18+ downloadName,
19+ overlay,
20+}: Props) {
21+ const canvasRef = useRef<HTMLCanvasElement>(null);
22+
23+ useEffect(() => {
24+ if (image && canvasRef.current) {
25+ drawRawToCanvas(canvasRef.current, image);
26+ }
27+ }, [image]);
28+
29+ return (
30+ <div className="imageview">
31+ <div className="imageview-head">
32+ <span className="imageview-title">{title}</span>
33+ {image && (
34+ <span className="imageview-dims">
35+ {image.width} × {image.height}
36+ {downloadName && (
37+ <>
38+ {" · "}
39+ <a
40+ href={image ? rawToDataUrl(image) : "#"}
41+ download={downloadName}
42+ >
43+ download
44+ </a>
45+ </>
46+ )}
47+ </span>
48+ )}
49+ </div>
50+ <div className="imageview-body">
51+ {image ? (
52+ <canvas ref={canvasRef} className="imageview-canvas" />
53+ ) : (
54+ <div className="imageview-placeholder">{placeholder ?? "—"}</div>
55+ )}
56+ {overlay}
57+ </div>
58+ </div>
59+ );
60+}
src/components/SamplePicker.tsxadded+122−0View file
@@ -0,0 +1,122 @@
1+import { useEffect, useRef } from "react";
2+import type { SyntheticSample } from "../samples.ts";
3+import type { UploadRecord } from "../imageStore.ts";
4+
5+function SyntheticThumb({ sample }: { sample: SyntheticSample }) {
6+ const ref = useRef<HTMLCanvasElement>(null);
7+ useEffect(() => {
8+ const canvas = ref.current;
9+ if (!canvas) return;
10+ const raw = sample.generate();
11+ const tmp = document.createElement("canvas");
12+ tmp.width = raw.width;
13+ tmp.height = raw.height;
14+ tmp
15+ .getContext("2d")!
16+ .putImageData(
17+ new ImageData(new Uint8ClampedArray(raw.rgba), raw.width, raw.height),
18+ 0,
19+ 0
20+ );
21+ const ctx = canvas.getContext("2d")!;
22+ ctx.clearRect(0, 0, canvas.width, canvas.height);
23+ ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
24+ }, [sample]);
25+ return <canvas ref={ref} width={72} height={72} className="thumb-canvas" />;
26+}
27+
28+interface Props {
29+ samples: SyntheticSample[];
30+ uploads: UploadRecord[];
31+ selectedId: string | null;
32+ onSelectSample: (sample: SyntheticSample) => void;
33+ onSelectUpload: (rec: UploadRecord) => void;
34+ onUpload: (file: File) => void;
35+ onDeleteUpload: (id: string) => void;
36+ disabled?: boolean;
37+}
38+
39+export function SamplePicker({
40+ samples,
41+ uploads,
42+ selectedId,
43+ onSelectSample,
44+ onSelectUpload,
45+ onUpload,
46+ onDeleteUpload,
47+ disabled,
48+}: Props) {
49+ const fileRef = useRef<HTMLInputElement>(null);
50+
51+ return (
52+ <div className="picker">
53+ <div className="picker-row">
54+ {samples.map((sample) => (
55+ <button
56+ key={sample.id}
57+ className={
58+ "sample" + (sample.id === selectedId ? " sample-selected" : "")
59+ }
60+ onClick={() => onSelectSample(sample)}
61+ disabled={disabled}
62+ title="Generated"
63+ >
64+ <SyntheticThumb sample={sample} />
65+ <span className="sample-name">{sample.name}</span>
66+ </button>
67+ ))}
68+
69+ {uploads.length > 0 && <div className="picker-divider" aria-hidden />}
70+
71+ {uploads.map((rec) => (
72+ <div className="sample-wrap" key={rec.id}>
73+ <button
74+ className={
75+ "sample" + (rec.id === selectedId ? " sample-selected" : "")
76+ }
77+ onClick={() => onSelectUpload(rec)}
78+ disabled={disabled}
79+ title={`${rec.name} · ${rec.width}×${rec.height}`}
80+ >
81+ <img className="thumb-canvas" src={rec.thumb} alt={rec.name} />
82+ <span className="sample-name">{rec.name}</span>
83+ </button>
84+ <button
85+ className="sample-delete"
86+ title="Delete"
87+ aria-label={`Delete ${rec.name}`}
88+ onClick={(e) => {
89+ e.stopPropagation();
90+ onDeleteUpload(rec.id);
91+ }}
92+ disabled={disabled}
93+ >
94+ ×
95+ </button>
96+ </div>
97+ ))}
98+
99+ <button
100+ className="sample sample-upload"
101+ onClick={() => fileRef.current?.click()}
102+ disabled={disabled}
103+ title="Upload an image (stored locally in your browser)"
104+ >
105+ <span className="upload-plus">+</span>
106+ <span className="sample-name">Upload</span>
107+ </button>
108+ <input
109+ ref={fileRef}
110+ type="file"
111+ accept="image/*"
112+ style={{ display: "none" }}
113+ onChange={(e) => {
114+ const f = e.target.files?.[0];
115+ if (f) onUpload(f);
116+ e.target.value = "";
117+ }}
118+ />
119+ </div>
120+ </div>
121+ );
122+}
src/components/ScriptPanel.tsxadded+59−0View file
@@ -0,0 +1,59 @@
1+import { EXAMPLES } from "../examples.ts";
2+
3+interface Props {
4+ script: string;
5+ onChange: (s: string) => void;
6+ onRun: () => void;
7+ running: boolean;
8+}
9+
10+export function ScriptPanel({ script, onChange, onRun, running }: Props) {
11+ return (
12+ <div className="script-panel">
13+ <div className="script-toolbar">
14+ <label className="field">
15+ <span>Example</span>
16+ <select
17+ value=""
18+ onChange={(e) => {
19+ const ex = EXAMPLES.find((x) => x.name === e.target.value);
20+ if (ex) onChange(ex.code);
21+ e.target.value = "";
22+ }}
23+ >
24+ <option value="" disabled>
25+ Load an example…
26+ </option>
27+ {EXAMPLES.map((ex) => (
28+ <option key={ex.name} value={ex.name}>
29+ {ex.name}
30+ </option>
31+ ))}
32+ </select>
33+ </label>
34+
35+ <button className="run-btn" onClick={onRun} disabled={running}>
36+ {running ? "Running…" : "Run ▶"}
37+ </button>
38+ </div>
39+
40+ <textarea
41+ className="editor"
42+ spellCheck={false}
43+ value={script}
44+ onChange={(e) => onChange(e.target.value)}
45+ onKeyDown={(e) => {
46+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
47+ e.preventDefault();
48+ onRun();
49+ }
50+ }}
51+ />
52+ <div className="editor-hint">
53+ Define <code>out = filterImage(img)</code>. <code>img</code> is an{" "}
54+ <code>H × W × 3</code> array of doubles in <code>[0, 255]</code> (RGB).
55+ Press <kbd>⌘/Ctrl</kbd>+<kbd>Enter</kbd> to run.
56+ </div>
57+ </div>
58+ );
59+}
src/examples.tsadded+108−0View file
@@ -0,0 +1,108 @@
1+/**
2+ * Preset numbl filter scripts.
3+ *
4+ * Each is a function `out = filterImage(img)` where:
5+ * - img : H x W x 3 array of doubles in [0, 255] (RGB)
6+ * - out : H x W x 3 (color) or H x W (grayscale) array in [0, 255]
7+ *
8+ * MATLAB syntax (this is what numbl runs). Values outside 0-255 are clamped
9+ * when the image is displayed.
10+ */
11+
12+export interface Example {
13+ name: string;
14+ code: string;
15+}
16+
17+export const EXAMPLES: Example[] = [
18+ {
19+ name: "Invert",
20+ code: `function out = filterImage(img)
21+ % Invert each channel
22+ out = 255 - img;
23+end
24+`,
25+ },
26+ {
27+ name: "Grayscale",
28+ code: `function out = filterImage(img)
29+ % Luminance-weighted grayscale, copied back to 3 channels
30+ g = 0.2989 * img(:,:,1) + 0.5870 * img(:,:,2) + 0.1140 * img(:,:,3);
31+ out = cat(3, g, g, g);
32+end
33+`,
34+ },
35+ {
36+ name: "Brighten",
37+ code: `function out = filterImage(img)
38+ % Scale brightness (min keeps it in range)
39+ out = min(255, img * 1.4);
40+end
41+`,
42+ },
43+ {
44+ name: "Sepia",
45+ code: `function out = filterImage(img)
46+ r = img(:,:,1); g = img(:,:,2); b = img(:,:,3);
47+ sr = 0.393*r + 0.769*g + 0.189*b;
48+ sg = 0.349*r + 0.686*g + 0.168*b;
49+ sb = 0.272*r + 0.534*g + 0.131*b;
50+ out = min(255, cat(3, sr, sg, sb));
51+end
52+`,
53+ },
54+ {
55+ name: "Swap R/B channels",
56+ code: `function out = filterImage(img)
57+ % Reorder the 3rd dimension: RGB -> BGR
58+ out = img(:, :, [3 2 1]);
59+end
60+`,
61+ },
62+ {
63+ name: "Posterize",
64+ code: `function out = filterImage(img)
65+ % Snap each channel to a few levels
66+ levels = 4;
67+ step = 255 / (levels - 1);
68+ out = round(img / step) * step;
69+end
70+`,
71+ },
72+ {
73+ name: "Increase contrast",
74+ code: `function out = filterImage(img)
75+ % Push values away from mid-gray (128)
76+ k = 1.6;
77+ out = min(255, max(0, (img - 128) * k + 128));
78+end
79+`,
80+ },
81+ {
82+ name: "Sobel edges",
83+ code: `function out = filterImage(img)
84+ % Edge magnitude on the grayscale image
85+ g = 0.2989*img(:,:,1) + 0.5870*img(:,:,2) + 0.1140*img(:,:,3);
86+ kx = [-1 0 1; -2 0 2; -1 0 1];
87+ ky = [-1 -2 -1; 0 0 0; 1 2 1];
88+ gx = conv2(g, kx, 'same');
89+ gy = conv2(g, ky, 'same');
90+ out = min(255, sqrt(gx.^2 + gy.^2));
91+end
92+`,
93+ },
94+ {
95+ name: "Box blur",
96+ code: `function out = filterImage(img)
97+ % 5x5 average blur, applied to each channel
98+ k = ones(5, 5) / 25;
99+ out = zeros(size(img));
100+ for c = 1:3
101+ out(:,:,c) = conv2(img(:,:,c), k, 'same');
102+ end
103+end
104+`,
105+ },
106+];
107+
108+export const DEFAULT_SCRIPT = EXAMPLES[0].code;
src/filter.worker.tsadded+70−0View file
@@ -0,0 +1,70 @@
1+/// <reference lib="webworker" />
2+/**
3+ * Runs a numbl filter script off the main thread so full-resolution images
4+ * with per-pixel scripts don't freeze the page.
5+ *
6+ * Contract: the script defines a function (default name `filterImage`) that
7+ * takes the image array `img` (H x W x 3 doubles, 0-255) and returns a new
8+ * image array. If the script has no function header it is run as-is with
9+ * `img` predefined and `out` read back.
10+ */
11+import { executeCode, RTV } from "numbl";
12+import type { RuntimeValue } from "numbl";
13+import {
14+ rgbaToTensorData,
15+ tensorToRaw,
16+ extractFunctionName,
17+} from "./imageConvert.ts";
18+import type { FilterRequest, FilterResponse } from "./filterTypes.ts";
19+
20+self.onmessage = (e: MessageEvent<FilterRequest>) => {
21+ const { id, script, width, height, rgba } = e.data;
22+ const logs: string[] = [];
23+ try {
24+ const { data, shape } = rgbaToTensorData(rgba, width, height);
25+ const img = RTV.tensor(data, shape);
26+
27+ const fnName = extractFunctionName(script);
28+ const source = fnName ? `out = ${fnName}(img);` : script;
29+ const workspaceFiles = fnName
30+ ? [{ name: `${fnName}.m`, source: script }]
31+ : [];
32+
33+ const t0 = performance.now();
34+ const result = executeCode(
35+ source,
36+ {
37+ initialVariableValues: { img },
38+ optimization: "1", // JS-JIT; browser-safe
39+ displayResults: false,
40+ onOutput: (text: string) => logs.push(text),
41+ },
42+ workspaceFiles,
43+ "main.m"
44+ );
45+ const elapsedMs = performance.now() - t0;
46+
47+ const out: RuntimeValue | undefined =
48+ result.variableValues.out ?? result.returnValue;
49+ const image = tensorToRaw(out);
50+
51+ const response: FilterResponse = {
52+ id,
53+ ok: true,
54+ width: image.width,
55+ height: image.height,
56+ rgba: image.rgba,
57+ logs,
58+ elapsedMs,
59+ };
60+ self.postMessage(response, [image.rgba.buffer]);
61+ } catch (err) {
62+ const response: FilterResponse = {
63+ id,
64+ ok: false,
65+ error: err instanceof Error ? err.message : String(err),
66+ logs,
67+ };
68+ self.postMessage(response);
69+ }
70+};
src/filterClient.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+/** Promise-based wrapper around the filter Web Worker. */
2+import type {
3+ FilterRequest,
4+ FilterResponse,
5+ FilterSuccess,
6+} from "./filterTypes.ts";
7+
8+export interface ImageInput {
9+ width: number;
10+ height: number;
11+ rgba: Uint8ClampedArray;
12+}
13+
14+export interface FilterClient {
15+ run(script: string, image: ImageInput): Promise<FilterSuccess>;
16+ terminate(): void;
17+}
18+
19+export function createFilterClient(): FilterClient {
20+ const worker = new Worker(new URL("./filter.worker.ts", import.meta.url), {
21+ type: "module",
22+ });
23+
24+ let nextId = 1;
25+ const pending = new Map<
26+ number,
27+ { resolve: (r: FilterSuccess) => void; reject: (e: Error) => void }
28+ >();
29+
30+ worker.onmessage = (e: MessageEvent<FilterResponse>) => {
31+ const res = e.data;
32+ const entry = pending.get(res.id);
33+ if (!entry) return;
34+ pending.delete(res.id);
35+ if (res.ok) {
36+ entry.resolve(res);
37+ } else {
38+ const err = new Error(res.error) as Error & { logs?: string[] };
39+ err.logs = res.logs;
40+ entry.reject(err);
41+ }
42+ };
43+
44+ worker.onerror = (e) => {
45+ // A worker-level error rejects everything in flight.
46+ const err = new Error(e.message || "Filter worker crashed");
47+ for (const { reject } of pending.values()) reject(err);
48+ pending.clear();
49+ };
50+
51+ return {
52+ run(script, image) {
53+ return new Promise<FilterSuccess>((resolve, reject) => {
54+ const id = nextId++;
55+ pending.set(id, { resolve, reject });
56+ // Copy the bytes so the caller's source image stays intact after the
57+ // buffer is transferred to the worker.
58+ const rgba = image.rgba.slice();
59+ const req: FilterRequest = {
60+ id,
61+ script,
62+ width: image.width,
63+ height: image.height,
64+ rgba,
65+ };
66+ worker.postMessage(req, [rgba.buffer]);
67+ });
68+ },
69+ terminate() {
70+ worker.terminate();
71+ pending.clear();
72+ },
73+ };
74+}
src/filterTypes.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+/** Messages exchanged with the filter Web Worker. */
2+
3+export interface FilterRequest {
4+ id: number;
5+ script: string;
6+ width: number;
7+ height: number;
8+ /** RGBA bytes of the source image (row-major). Buffer is transferred. */
9+ rgba: Uint8ClampedArray;
10+}
11+
12+export interface FilterSuccess {
13+ id: number;
14+ ok: true;
15+ width: number;
16+ height: number;
17+ rgba: Uint8ClampedArray;
18+ /** Lines printed by disp/fprintf/etc. in the script. */
19+ logs: string[];
20+ /** Execution time in milliseconds. */
21+ elapsedMs: number;
22+}
23+
24+export interface FilterFailure {
25+ id: number;
26+ ok: false;
27+ error: string;
28+ logs: string[];
29+}
30+
31+export type FilterResponse = FilterSuccess | FilterFailure;
src/imageConvert.tsadded+128−0View file
@@ -0,0 +1,128 @@
1+/**
2+ * Conversions between browser image data (RGBA, row-major, 0-255 bytes) and
3+ * numbl tensors (doubles, column-major, MATLAB image convention).
4+ *
5+ * Inside a numbl script an image is an `H x W x 3` array of doubles in the
6+ * range 0-255, exactly like `imread` returns (but as doubles, not uint8).
7+ *
8+ * - Browser ImageData: Uint8ClampedArray, length W*H*4, RGBA, ROW-major.
9+ * Pixel (x, y) red channel is at index (y * W + x) * 4.
10+ * - numbl tensor: Float64Array, COLUMN-major. Element (i, j, c) of an
11+ * [H, W, 3] array is at index i + j*H + c*H*W (i = row/y, j = col/x).
12+ */
13+
14+import type { RuntimeValue, RuntimeTensor } from "numbl";
15+
16+export interface RawImage {
17+ width: number;
18+ height: number;
19+ /** RGBA bytes, row-major (W*H*4). */
20+ rgba: Uint8ClampedArray;
21+}
22+
23+export interface TensorInput {
24+ data: Float64Array;
25+ shape: number[];
26+}
27+
28+/** RGBA row-major bytes -> column-major [H, W, 3] doubles (0-255). */
29+export function rgbaToTensorData(
30+ rgba: Uint8ClampedArray | Uint8Array,
31+ width: number,
32+ height: number
33+): TensorInput {
34+ const W = width;
35+ const H = height;
36+ const plane = H * W;
37+ const data = new Float64Array(plane * 3);
38+ for (let y = 0; y < H; y++) {
39+ for (let x = 0; x < W; x++) {
40+ const src = (y * W + x) * 4;
41+ const base = y + x * H; // column-major (row=y, col=x)
42+ data[base] = rgba[src];
43+ data[base + plane] = rgba[src + 1];
44+ data[base + 2 * plane] = rgba[src + 2];
45+ }
46+ }
47+ return { data, shape: [H, W, 3] };
48+}
49+
50+function describeValue(v: RuntimeValue | undefined): string {
51+ if (v === undefined) return "nothing";
52+ if (typeof v === "number") return `a scalar (${v})`;
53+ if (typeof v === "boolean") return "a logical scalar";
54+ if (typeof v === "string") return "a string";
55+ const kind = (v as { kind?: string }).kind;
56+ return kind ? `a ${kind}` : "an unsupported value";
57+}
58+
59+function isTensor(v: RuntimeValue | undefined): v is RuntimeTensor {
60+ return (
61+ typeof v === "object" &&
62+ v !== null &&
63+ (v as { kind?: string }).kind === "tensor"
64+ );
65+}
66+
67+/**
68+ * A numbl output value -> RGBA row-major bytes.
69+ *
70+ * Accepts:
71+ * - [H, W, 3] color image
72+ * - [H, W] or [H, W, 1] grayscale (replicated to RGB)
73+ *
74+ * Values are clamped to 0-255 (Uint8ClampedArray rounds + clamps). The
75+ * imaginary part of a complex result is ignored.
76+ */
77+export function tensorToRaw(v: RuntimeValue | undefined): RawImage {
78+ if (!isTensor(v)) {
79+ throw new Error(
80+ `filterImage must return an H x W x 3 image array, but returned ${describeValue(v)}.`
81+ );
82+ }
83+ const shape = v.shape;
84+ if (shape.length < 2) {
85+ throw new Error(
86+ `filterImage returned a ${shape.length}-D array; expected a 2-D (grayscale) or 3-D (RGB) image.`
87+ );
88+ }
89+ const H = shape[0];
90+ const W = shape[1];
91+ const C = shape.length >= 3 ? shape[2] : 1;
92+ const data = v.data;
93+ const plane = H * W;
94+ const rgba = new Uint8ClampedArray(W * H * 4);
95+ for (let y = 0; y < H; y++) {
96+ for (let x = 0; x < W; x++) {
97+ const dst = (y * W + x) * 4;
98+ const base = y + x * H;
99+ let r: number, g: number, b: number;
100+ if (C >= 3) {
101+ r = data[base];
102+ g = data[base + plane];
103+ b = data[base + 2 * plane];
104+ } else {
105+ r = g = b = data[base];
106+ }
107+ // Uint8ClampedArray assignment rounds to nearest and clamps to 0-255.
108+ rgba[dst] = r;
109+ rgba[dst + 1] = g;
110+ rgba[dst + 2] = b;
111+ rgba[dst + 3] = 255;
112+ }
113+ }
114+ return { width: W, height: H, rgba };
115+}
116+
117+/**
118+ * Pull the name of the primary function out of a `.m` script, so we can call
119+ * it (e.g. `out = filterImage(img);`). Returns null if the script has no
120+ * function header (treated as a plain script using `img` / `out`).
121+ */
122+export function extractFunctionName(script: string): string | null {
123+ // function out = name(...) | function [a,b] = name(...) | function name(...)
124+ const m = script.match(
125+ /function\s+(?:[\w\s,[\]]*?=\s*)?([A-Za-z]\w*)\s*\(/
126+ );
127+ return m ? m[1] : null;
128+}
src/imageLoad.tsadded+84−0View file
@@ -0,0 +1,84 @@
1+/** Loading and rasterizing images to RGBA byte arrays. */
2+import type { RawImage } from "./imageConvert.ts";
3+
4+function drawableToRaw(
5+ source: CanvasImageSource,
6+ width: number,
7+ height: number
8+): RawImage {
9+ const canvas = document.createElement("canvas");
10+ canvas.width = width;
11+ canvas.height = height;
12+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
13+ if (!ctx) throw new Error("Could not get a 2D canvas context.");
14+ ctx.drawImage(source, 0, 0, width, height);
15+ const imageData = ctx.getImageData(0, 0, width, height);
16+ return { width, height, rgba: imageData.data };
17+}
18+
19+/** Load an image from an uploaded File / Blob. */
20+export async function loadImageFromFile(file: Blob): Promise<RawImage> {
21+ const bitmap = await createImageBitmap(file);
22+ try {
23+ return drawableToRaw(bitmap, bitmap.width, bitmap.height);
24+ } finally {
25+ bitmap.close();
26+ }
27+}
28+
29+/** Render a RawImage onto a canvas element (sizing it to match). */
30+export function drawRawToCanvas(
31+ canvas: HTMLCanvasElement,
32+ raw: RawImage
33+): void {
34+ canvas.width = raw.width;
35+ canvas.height = raw.height;
36+ const ctx = canvas.getContext("2d");
37+ if (!ctx) return;
38+ ctx.putImageData(
39+ new ImageData(new Uint8ClampedArray(raw.rgba), raw.width, raw.height),
40+ 0,
41+ 0
42+ );
43+}
44+
45+/** Encode a RawImage to a PNG data URL (used for the download button). */
46+export function rawToDataUrl(raw: RawImage): string {
47+ const canvas = document.createElement("canvas");
48+ canvas.width = raw.width;
49+ canvas.height = raw.height;
50+ const ctx = canvas.getContext("2d");
51+ if (!ctx) throw new Error("Could not get a 2D canvas context.");
52+ ctx.putImageData(
53+ new ImageData(new Uint8ClampedArray(raw.rgba), raw.width, raw.height),
54+ 0,
55+ 0
56+ );
57+ return canvas.toDataURL("image/png");
58+}
59+
60+/** Encode a small (longest side = max px) JPEG thumbnail data URL. */
61+export function rawToThumbDataUrl(raw: RawImage, max = 72): string {
62+ const scale = Math.min(1, max / Math.max(raw.width, raw.height));
63+ const w = Math.max(1, Math.round(raw.width * scale));
64+ const h = Math.max(1, Math.round(raw.height * scale));
65+
66+ const src = document.createElement("canvas");
67+ src.width = raw.width;
68+ src.height = raw.height;
69+ src
70+ .getContext("2d")!
71+ .putImageData(
72+ new ImageData(new Uint8ClampedArray(raw.rgba), raw.width, raw.height),
73+ 0,
74+ 0
75+ );
76+
77+ const dst = document.createElement("canvas");
78+ dst.width = w;
79+ dst.height = h;
80+ const ctx = dst.getContext("2d");
81+ if (!ctx) throw new Error("Could not get a 2D canvas context.");
82+ ctx.drawImage(src, 0, 0, w, h);
83+ return dst.toDataURL("image/jpeg", 0.7);
84+}
src/imageStore.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+/**
2+ * Persistent storage for uploaded images, backed by IndexedDB.
3+ *
4+ * We store the original (compressed) file Blob plus a small thumbnail data URL
5+ * and basic metadata. The full image is decoded from the Blob on demand when a
6+ * stored upload is selected, so the database stays compact.
7+ */
8+import type { RawImage } from "./imageConvert.ts";
9+import { loadImageFromFile, rawToThumbDataUrl } from "./imageLoad.ts";
10+
11+const DB_NAME = "numbl-image-filter";
12+const DB_VERSION = 1;
13+const STORE = "uploads";
14+
15+export interface UploadRecord {
16+ id: string;
17+ name: string;
18+ width: number;
19+ height: number;
20+ /** Small data URL shown in the picker. */
21+ thumb: string;
22+ /** Original file bytes (compressed); decoded on demand. */
23+ blob: Blob;
24+ createdAt: number;
25+}
26+
27+function genId(): string {
28+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
29+ return crypto.randomUUID();
30+ }
31+ return `up-${Date.now()}-${Math.random().toString(36).slice(2)}`;
32+}
33+
34+let dbPromise: Promise<IDBDatabase> | null = null;
35+function getDb(): Promise<IDBDatabase> {
36+ if (!dbPromise) {
37+ dbPromise = new Promise((resolve, reject) => {
38+ const req = indexedDB.open(DB_NAME, DB_VERSION);
39+ req.onupgradeneeded = () => {
40+ const db = req.result;
41+ if (!db.objectStoreNames.contains(STORE)) {
42+ db.createObjectStore(STORE, { keyPath: "id" });
43+ }
44+ };
45+ req.onsuccess = () => resolve(req.result);
46+ req.onerror = () =>
47+ reject(req.error ?? new Error("Failed to open IndexedDB"));
48+ });
49+ }
50+ return dbPromise;
51+}
52+
53+function runRequest<T>(
54+ mode: IDBTransactionMode,
55+ fn: (store: IDBObjectStore) => IDBRequest<T>
56+): Promise<T> {
57+ return getDb().then(
58+ (db) =>
59+ new Promise<T>((resolve, reject) => {
60+ const t = db.transaction(STORE, mode);
61+ const req = fn(t.objectStore(STORE));
62+ req.onsuccess = () => resolve(req.result);
63+ req.onerror = () => reject(req.error);
64+ })
65+ );
66+}
67+
68+/** All stored uploads, newest first. */
69+export async function listUploads(): Promise<UploadRecord[]> {
70+ const all = await runRequest<UploadRecord[]>(
71+ "readonly",
72+ (s) => s.getAll() as IDBRequest<UploadRecord[]>
73+ );
74+ return all.sort((a, b) => b.createdAt - a.createdAt);
75+}
76+
77+/** Decode + store an uploaded file. Returns the record and decoded image. */
78+export async function addUpload(
79+ file: File
80+): Promise<{ record: UploadRecord; image: RawImage }> {
81+ const image = await loadImageFromFile(file);
82+ const record: UploadRecord = {
83+ id: genId(),
84+ name: file.name || "upload",
85+ width: image.width,
86+ height: image.height,
87+ thumb: rawToThumbDataUrl(image, 72),
88+ blob: file,
89+ createdAt: Date.now(),
90+ };
91+ await runRequest("readwrite", (s) => s.put(record));
92+ return { record, image };
93+}
94+
95+export async function deleteUpload(id: string): Promise<void> {
96+ await runRequest("readwrite", (s) => s.delete(id));
97+}
98+
99+/** Decode the full image for a stored upload. */
100+export function getUploadImage(rec: UploadRecord): Promise<RawImage> {
101+ return loadImageFromFile(rec.blob);
102+}
src/index.cssadded+384−0View file
@@ -0,0 +1,384 @@
1+:root {
2+ --bg: #0f172a;
3+ --panel: #1e293b;
4+ --panel-2: #273449;
5+ --text: #e2e8f0;
6+ --muted: #94a3b8;
7+ --border: #334155;
8+ --accent: #38bdf8;
9+ --accent-strong: #0ea5e9;
10+ --danger: #f87171;
11+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
12+ color-scheme: dark;
13+}
14+
15+* {
16+ box-sizing: border-box;
17+}
18+
19+body {
20+ margin: 0;
21+ background: var(--bg);
22+ color: var(--text);
23+}
24+
25+.app {
26+ max-width: 1100px;
27+ margin: 0 auto;
28+ padding: 24px 20px 60px;
29+}
30+
31+.app-header h1 {
32+ margin: 0 0 4px;
33+ font-size: 1.7rem;
34+ letter-spacing: -0.02em;
35+}
36+
37+.app-header p {
38+ margin: 0 0 8px;
39+ color: var(--muted);
40+}
41+
42+a {
43+ color: var(--accent);
44+}
45+
46+.panel {
47+ background: var(--panel);
48+ border: 1px solid var(--border);
49+ border-radius: 12px;
50+ padding: 16px 18px;
51+ margin-top: 18px;
52+}
53+
54+.panel h2 {
55+ margin: 0 0 12px;
56+ font-size: 0.85rem;
57+ font-weight: 600;
58+ text-transform: uppercase;
59+ letter-spacing: 0.06em;
60+ color: var(--muted);
61+}
62+
63+/* ── Sample picker ───────────────────────────────── */
64+.picker-row {
65+ display: flex;
66+ flex-wrap: nowrap;
67+ gap: 10px;
68+ overflow-x: auto;
69+ overflow-y: hidden;
70+ padding-bottom: 6px;
71+}
72+
73+.picker-divider {
74+ flex: 0 0 auto;
75+ align-self: stretch;
76+ width: 1px;
77+ background: var(--border);
78+ margin: 6px 2px;
79+}
80+
81+.sample-wrap {
82+ position: relative;
83+ flex: 0 0 auto;
84+}
85+
86+.sample-delete {
87+ position: absolute;
88+ top: 4px;
89+ right: 4px;
90+ width: 18px;
91+ height: 18px;
92+ border-radius: 50%;
93+ border: none;
94+ background: rgba(8, 15, 30, 0.7);
95+ color: #e2e8f0;
96+ font-size: 0.85rem;
97+ line-height: 1;
98+ cursor: pointer;
99+ display: flex;
100+ align-items: center;
101+ justify-content: center;
102+ padding: 0;
103+ opacity: 0;
104+ transition: opacity 0.12s;
105+}
106+.sample-wrap:hover .sample-delete,
107+.sample-delete:focus-visible {
108+ opacity: 1;
109+}
110+.sample-delete:hover:not(:disabled) {
111+ background: var(--danger);
112+ color: #fff;
113+}
114+
115+.sample {
116+ display: flex;
117+ flex-direction: column;
118+ align-items: center;
119+ gap: 6px;
120+ flex: 0 0 auto;
121+ width: 88px;
122+ padding: 8px 6px;
123+ background: var(--panel-2);
124+ border: 1px solid var(--border);
125+ border-radius: 10px;
126+ color: var(--text);
127+ cursor: pointer;
128+ transition: border-color 0.12s, transform 0.12s;
129+}
130+
131+.sample:hover:not(:disabled) {
132+ border-color: var(--accent);
133+}
134+
135+.sample:disabled {
136+ opacity: 0.5;
137+ cursor: default;
138+}
139+
140+.sample-selected {
141+ border-color: var(--accent-strong);
142+ box-shadow: 0 0 0 1px var(--accent-strong);
143+}
144+
145+.thumb-canvas {
146+ width: 72px;
147+ height: 72px;
148+ object-fit: cover;
149+ border-radius: 6px;
150+ background: #0b1120;
151+ display: block;
152+}
153+
154+.sample-name {
155+ font-size: 0.72rem;
156+ color: var(--muted);
157+ text-align: center;
158+ line-height: 1.1;
159+ max-width: 76px;
160+ white-space: nowrap;
161+ overflow: hidden;
162+ text-overflow: ellipsis;
163+}
164+
165+.sample-upload .upload-plus {
166+ width: 72px;
167+ height: 72px;
168+ display: flex;
169+ align-items: center;
170+ justify-content: center;
171+ font-size: 2rem;
172+ color: var(--muted);
173+ border: 1px dashed var(--border);
174+ border-radius: 6px;
175+}
176+
177+/* ── Script panel ────────────────────────────────── */
178+.script-toolbar {
179+ display: flex;
180+ align-items: flex-end;
181+ gap: 16px;
182+ flex-wrap: wrap;
183+ margin-bottom: 10px;
184+}
185+
186+.field {
187+ display: flex;
188+ flex-direction: column;
189+ gap: 4px;
190+ font-size: 0.75rem;
191+ color: var(--muted);
192+}
193+
194+.field select {
195+ background: var(--panel-2);
196+ color: var(--text);
197+ border: 1px solid var(--border);
198+ border-radius: 6px;
199+ padding: 6px 8px;
200+ font-size: 0.85rem;
201+}
202+
203+.run-btn {
204+ margin-left: auto;
205+ background: var(--accent-strong);
206+ color: #04222f;
207+ border: none;
208+ border-radius: 8px;
209+ padding: 9px 18px;
210+ font-size: 0.9rem;
211+ font-weight: 600;
212+ cursor: pointer;
213+}
214+
215+.run-btn:disabled {
216+ opacity: 0.6;
217+ cursor: default;
218+}
219+
220+.editor {
221+ width: 100%;
222+ min-height: 220px;
223+ resize: vertical;
224+ background: #0b1120;
225+ color: #d7e3f4;
226+ border: 1px solid var(--border);
227+ border-radius: 8px;
228+ padding: 12px;
229+ font-family: "SF Mono", ui-monospace, "Cascadia Code", Menlo, Consolas,
230+ monospace;
231+ font-size: 0.86rem;
232+ line-height: 1.5;
233+ tab-size: 2;
234+}
235+
236+.editor:focus {
237+ outline: none;
238+ border-color: var(--accent);
239+}
240+
241+.editor-hint {
242+ margin-top: 8px;
243+ font-size: 0.76rem;
244+ color: var(--muted);
245+}
246+
247+.editor-hint code {
248+ background: var(--panel-2);
249+ padding: 1px 5px;
250+ border-radius: 4px;
251+}
252+
253+kbd {
254+ background: var(--panel-2);
255+ border: 1px solid var(--border);
256+ border-radius: 4px;
257+ padding: 0 5px;
258+ font-size: 0.72rem;
259+}
260+
261+/* ── Viewers ─────────────────────────────────────── */
262+.viewers {
263+ display: grid;
264+ grid-template-columns: 1fr 1fr;
265+ gap: 16px;
266+}
267+
268+@media (max-width: 720px) {
269+ .viewers {
270+ grid-template-columns: 1fr;
271+ }
272+}
273+
274+.imageview {
275+ border: 1px solid var(--border);
276+ border-radius: 10px;
277+ overflow: hidden;
278+ background: var(--panel-2);
279+}
280+
281+.imageview-head {
282+ display: flex;
283+ justify-content: space-between;
284+ align-items: baseline;
285+ gap: 8px;
286+ padding: 8px 10px;
287+ font-size: 0.78rem;
288+ border-bottom: 1px solid var(--border);
289+}
290+
291+.imageview-title {
292+ font-weight: 600;
293+ overflow: hidden;
294+ text-overflow: ellipsis;
295+ white-space: nowrap;
296+}
297+
298+.imageview-dims {
299+ color: var(--muted);
300+ white-space: nowrap;
301+}
302+
303+.imageview-body {
304+ position: relative;
305+ display: flex;
306+ align-items: center;
307+ justify-content: center;
308+ min-height: 200px;
309+ background-image: linear-gradient(45deg, #16203360 25%, transparent 25%),
310+ linear-gradient(-45deg, #16203360 25%, transparent 25%),
311+ linear-gradient(45deg, transparent 75%, #16203360 75%),
312+ linear-gradient(-45deg, transparent 75%, #16203360 75%);
313+ background-size: 20px 20px;
314+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
315+}
316+
317+.imageview-canvas {
318+ max-width: 100%;
319+ max-height: 460px;
320+ display: block;
321+ image-rendering: auto;
322+}
323+
324+.imageview-placeholder {
325+ color: var(--muted);
326+ font-size: 0.85rem;
327+ padding: 40px;
328+}
329+
330+.running-overlay {
331+ position: absolute;
332+ inset: 0;
333+ display: flex;
334+ align-items: center;
335+ justify-content: center;
336+ background: rgba(8, 15, 30, 0.55);
337+ color: var(--text);
338+ font-weight: 600;
339+}
340+
341+/* ── Status / console ────────────────────────────── */
342+.error-banner {
343+ background: #7f1d1d40;
344+ border: 1px solid var(--danger);
345+ color: #fecaca;
346+ border-radius: 8px;
347+ padding: 10px 12px;
348+ margin-bottom: 12px;
349+ font-size: 0.85rem;
350+ white-space: pre-wrap;
351+}
352+
353+.status-row {
354+ margin-top: 12px;
355+ display: flex;
356+ flex-direction: column;
357+ gap: 8px;
358+}
359+
360+.timing {
361+ font-size: 0.78rem;
362+ color: var(--muted);
363+}
364+
365+.console {
366+ margin: 0;
367+ background: #0b1120;
368+ border: 1px solid var(--border);
369+ border-radius: 8px;
370+ padding: 10px 12px;
371+ font-family: ui-monospace, monospace;
372+ font-size: 0.8rem;
373+ color: #cbd5e1;
374+ white-space: pre-wrap;
375+ max-height: 160px;
376+ overflow: auto;
377+}
378+
379+.app-footer {
380+ margin-top: 28px;
381+ font-size: 0.76rem;
382+ color: var(--muted);
383+ text-align: center;
384+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import App from "./App.tsx";
4+import "./index.css";
5+
6+createRoot(document.getElementById("root")!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>
10+);
src/samples.tsadded+21−0View file
@@ -0,0 +1,21 @@
1+/**
2+ * Built-in sample images shown in the picker.
3+ *
4+ * These are generated in-browser (no network, always available). The only
5+ * other images in the app are the ones you upload, which are stored locally
6+ * in IndexedDB (see imageStore.ts).
7+ */
8+import type { RawImage } from "./imageConvert.ts";
9+import { gradientImage, shapesImage, checkerImage } from "./synthetic.ts";
10+
11+export interface SyntheticSample {
12+ id: string;
13+ name: string;
14+ generate: () => RawImage;
15+}
16+
17+export const SAMPLES: SyntheticSample[] = [
18+ { id: "gradient", name: "RGB gradient", generate: () => gradientImage() },
19+ { id: "shapes", name: "Shapes", generate: () => shapesImage() },
20+ { id: "checker", name: "Checkerboard", generate: () => checkerImage() },
21+];
src/synthetic.tsadded+90−0View file
@@ -0,0 +1,90 @@
1+/**
2+ * Procedurally-generated sample images. These need no network and are the
3+ * guaranteed-available samples (the remote photos in samples.ts may be
4+ * blocked offline or by cross-origin policy).
5+ */
6+import type { RawImage } from "./imageConvert.ts";
7+
8+function makeCanvas(w: number, h: number): {
9+ canvas: HTMLCanvasElement;
10+ ctx: CanvasRenderingContext2D;
11+} {
12+ const canvas = document.createElement("canvas");
13+ canvas.width = w;
14+ canvas.height = h;
15+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
16+ if (!ctx) throw new Error("Could not get a 2D canvas context.");
17+ return { canvas, ctx };
18+}
19+
20+function readBack(
21+ canvas: HTMLCanvasElement,
22+ ctx: CanvasRenderingContext2D
23+): RawImage {
24+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
25+ return { width: canvas.width, height: canvas.height, rgba: data.data };
26+}
27+
28+/** Smooth RGB field: red rises left→right, green top→bottom, blue diagonal. */
29+export function gradientImage(w = 320, h = 320): RawImage {
30+ const { canvas, ctx } = makeCanvas(w, h);
31+ const img = ctx.createImageData(w, h);
32+ for (let y = 0; y < h; y++) {
33+ for (let x = 0; x < w; x++) {
34+ const i = (y * w + x) * 4;
35+ img.data[i] = Math.round((255 * x) / (w - 1));
36+ img.data[i + 1] = Math.round((255 * y) / (h - 1));
37+ img.data[i + 2] = Math.round((255 * (x + y)) / (w + h - 2));
38+ img.data[i + 3] = 255;
39+ }
40+ }
41+ ctx.putImageData(img, 0, 0);
42+ return readBack(canvas, ctx);
43+}
44+
45+/** High-contrast colored shapes on white — good for edge-detection demos. */
46+export function shapesImage(w = 360, h = 280): RawImage {
47+ const { canvas, ctx } = makeCanvas(w, h);
48+ ctx.fillStyle = "#ffffff";
49+ ctx.fillRect(0, 0, w, h);
50+
51+ ctx.fillStyle = "#e23b3b";
52+ ctx.beginPath();
53+ ctx.arc(w * 0.32, h * 0.42, Math.min(w, h) * 0.22, 0, Math.PI * 2);
54+ ctx.fill();
55+
56+ ctx.fillStyle = "#2f7de1";
57+ ctx.fillRect(w * 0.5, h * 0.18, w * 0.34, h * 0.34);
58+
59+ ctx.fillStyle = "#2fae57";
60+ ctx.beginPath();
61+ ctx.moveTo(w * 0.62, h * 0.92);
62+ ctx.lineTo(w * 0.42, h * 0.58);
63+ ctx.lineTo(w * 0.86, h * 0.58);
64+ ctx.closePath();
65+ ctx.fill();
66+
67+ ctx.strokeStyle = "#222222";
68+ ctx.lineWidth = Math.max(2, w * 0.012);
69+ ctx.beginPath();
70+ ctx.moveTo(0, h * 0.5);
71+ ctx.bezierCurveTo(w * 0.25, h * 0.1, w * 0.75, h * 0.95, w, h * 0.45);
72+ ctx.stroke();
73+
74+ return readBack(canvas, ctx);
75+}
76+
77+/** Colored checkerboard — exercises many per-region filters. */
78+export function checkerImage(w = 320, h = 320, tiles = 8): RawImage {
79+ const { canvas, ctx } = makeCanvas(w, h);
80+ const palette = ["#222831", "#e2b53b", "#c0392b", "#16a085"];
81+ const tw = w / tiles;
82+ const th = h / tiles;
83+ for (let ty = 0; ty < tiles; ty++) {
84+ for (let tx = 0; tx < tiles; tx++) {
85+ ctx.fillStyle = palette[(tx + ty) % palette.length];
86+ ctx.fillRect(tx * tw, ty * th, Math.ceil(tw), Math.ceil(th));
87+ }
88+ }
89+ return readBack(canvas, ctx);
90+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.app.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+
9+ "moduleResolution": "bundler",
10+ "allowImportingTsExtensions": true,
11+ "verbatimModuleSyntax": true,
12+ "moduleDetection": "force",
13+ "noEmit": true,
14+ "jsx": "react-jsx",
15+
16+ "strict": true,
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "noFallthroughCasesInSwitch": true,
20+ "noUncheckedSideEffectImports": true
21+ },
22+ "include": ["src"]
23+}
tsconfig.jsonadded+4−0View file
@@ -0,0 +1,4 @@
1+{
2+ "files": [],
3+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
4+}
tsconfig.node.jsonadded+21−0View file
@@ -0,0 +1,21 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2023",
4+ "lib": ["ES2023"],
5+ "module": "ESNext",
6+ "skipLibCheck": true,
7+
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "verbatimModuleSyntax": true,
11+ "moduleDetection": "force",
12+ "noEmit": true,
13+
14+ "strict": true,
15+ "noUnusedLocals": true,
16+ "noUnusedParameters": true,
17+ "noFallthroughCasesInSwitch": true,
18+ "noUncheckedSideEffectImports": true
19+ },
20+ "include": ["vite.config.ts"]
21+}
vite.config.tsadded+20−0View file
@@ -0,0 +1,20 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+
4+// https://vite.dev/config/
5+export default defineConfig(({ mode }) => ({
6+ // Served from a project subpath on GitHub Pages
7+ // (https://magland.github.io/numbl-image-filter/). The deploy workflow builds
8+ // with `--mode pages`; local dev/build stays at root.
9+ base: mode === "pages" ? "/numbl-image-filter/" : "/",
10+ plugins: [react()],
11+ // numbl is built primarily for Node; a few code paths reference `process`.
12+ // The reachable ones are guarded with `typeof process !== "undefined"`, but
13+ // we statically replace the bare member accesses so nothing throws in the
14+ // browser bundle (and the optional C-JIT path, which we never use here, is
15+ // inert at opt level "1").
16+ define: {
17+ "process.platform": JSON.stringify("browser"),
18+ "process.env": "{}",
19+ },
20+}));