/ concept-collection / numbl-image-filter
Sign in
concept-collection / numbl-image-filter
numbl-image-filter / src / App.tsx
233 lines · 7.2 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from "react";
2import type { RawImage } from "./imageConvert.ts";
3import type { SyntheticSample } from "./samples.ts";
4import { SAMPLES } from "./samples.ts";
5import { DEFAULT_SCRIPT } from "./examples.ts";
6import { createFilterClient } from "./filterClient.ts";
7import type { FilterClient } from "./filterClient.ts";
8import type { UploadRecord } from "./imageStore.ts";
9import {
10 addUpload,
11 deleteUpload,
12 getUploadImage,
13 listUploads,
14} from "./imageStore.ts";
15import { ImageView } from "./components/ImageView.tsx";
16import { SamplePicker } from "./components/SamplePicker.tsx";
17import { ScriptPanel } from "./components/ScriptPanel.tsx";
19interface Loaded {
20 image: RawImage;
21 label: string;
24export 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 }, []);
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);
45 const scriptRef = useRef(script);
46 scriptRef.current = script;
47 const originalRef = useRef(original);
48 originalRef.current = original;
49 const runIdRef = useRef(0);
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]);
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]);
82 const selectSample = useCallback((sample: SyntheticSample) => {
83 setSelectedId(sample.id);
84 setError(null);
85 setOriginal({ image: sample.generate(), label: sample.name });
86 }, []);
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 }, []);
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 }, []);
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 );
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]);
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]);
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>
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>
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>
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 Runto 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>
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 );
moveopenescclose