No photos yet. Import… (top right) brings in a whole folder — originals stay
where they are — or individual files. Nothing is uploaded and nothing is copied.
The app's files are being updated or did not load completely. Reload in a moment; your library is untouched.
The image pipeline
Every photo you see here is computed on demand from the raw file plus a recipe: the list
of slider values stored with the photo. Nothing is ever baked in. Move a slider and the whole
pipeline re-runs; open the photo next year and the same recipe re-renders it, at any size. That is
the entire idea of non-destructive editing, and it is why Export is not a special path: it is the
same pipeline handed the full-resolution image instead of a preview.
The pipeline is a fixed sequence of stages, and the order is not a matter of taste. The raw file
holds linear values: numbers proportional to collected photons. Physics-like operations only
make sense there. White balance is a per-channel multiply, so it runs in linear light; so do the
color matrix, exposure, dehaze and lens blur. Near the end the values pass through the sRGB
encode (a gamma curve) into display space, where the perceptual controls live: tone curve,
HSL, grading. The band in the diagram below marks that crossing. Get the order wrong and familiar
operations misbehave: sharpening in linear brightens edges, averaging in gamma darkens blends.
Many stages offer a method menu: the same slider can drive a cheap global curve or a
local Laplacian filter, a reference JavaScript loop or a compiled kernel. The point is that the
stage is a contract and the algorithm is a choice, which is how a real ISP is built too. The
? next to each control shows the active method's pseudocode and links to the book
section that derives it.
Consecutive per-channel stages never run one by one. They are composed into a single
lookup table per channel (4096 entries), so exposure, contrast, whites, blacks and the tone
curve together cost one table read per pixel; cross-channel steps (the color matrix, vibrance) run
between the tables, and the whole pointwise chain compiles into one fused pass. Spatial stages,
which need neighboring pixels, each get their own pass. The diagram shows the plan for the photo
that is open right now: colored stages are active, grey ones are neutral and are not run at all.
Follow a photo through the pipeline
The photo you have open, at every step of its development — starting where every raw
photograph starts, as one filtered number per photosite. Each picture below is the
actual buffer the pipeline holds at that point, sent to the screen with no viewing
courtesy. That is why the early steps look dark and dull: they are linear light, numbers
proportional to photons, and your display expects gamma-encoded ones. Watch what the
encode step does; that jump is the whole reason the pipeline has an order.
The app around the pipeline
The app is plain JavaScript, no framework, in two halves. The main thread
(app.js) owns the interface: the grid, the panels, the sliders, the history. A
Web Worker (lib/worker.js) owns every pixel: it decodes raws, holds the
full-resolution image and its previews, and runs the pipeline. The two exchange messages, so a
slow develop never freezes a click, and the pixels never cross into the UI except as a finished
8-bit frame.
The library under lib/ is organized so that each file answers one question:
rawsource.js, dng.js — turn a file (Sony raw via LibRaw-wasm, DNG
via our own TIFF reader, JPG/PNG un-encoded to linear) into camera-linear RGB plus metadata.
stages.js — the pipeline as data: every stage, slider, range, default, and
which space it runs in. The panels, the plan, and the help sheets are all generated from it.
ops.js — the reference implementation of each (stage, method) pair.
pipeline.js — builds the plan from a recipe and runs it: LUT fusion, the fused
kernel when available, the JavaScript chain when not.
geometry.js, masks.js, detail.js — crop and
perspective as one resample; local adjustments; noise reduction and sharpening.
catalog.js, cull.js — storage and the assisted culling measures.
Storage is local, in the browser's IndexedDB. A folder import stores a permission handle and
no pixels; a drag-and-drop stores the file itself, because the browser gives no path to re-read; a
merge stores the result it computed as a 16-bit linear DNG written by the worker. Recipes,
ratings, keywords, history and map positions ride on the catalog rows. Nothing is uploaded
anywhere; there is no server side at all.
Each module (Library, Develop, Map, Slideshow, Web) is a section of one page sharing that
state. The Develop canvas, the loupe, the slideshow and the gallery builder all call the same
render; there is exactly one definition of what your photo looks like.
Performance
Interactive editing is a latency budget: a slider should answer in tens of milliseconds. The
raw is 50 megapixels; the screen shows about two. Everything here follows from refusing to
develop pixels nobody sees.
Previews. The worker keeps a ladder of them: a 320 px thumbnail for the grid, a
900 px proxy used while a slider drags, a 1600 px proxy for the settled view.
At 1:1 zoom there is no proxy that helps, so only the visible tile of the full image is
developed. Export and the benchmark below run the full image, which is the real cost.
The compiled kernels. The hot stages are written in Halide, compiled ahead of
time to WebAssembly. Halide's premise, argued at length in the book, is that the algorithm and
its schedule are separate: the same local-Laplacian definition runs here tiled and vectorized.
Copies in and out of wasm memory, which look like the obvious cost, measure
at about 2 ms of a 400 ms full-size pass: the kernels are compute-bound, and the win
lives in the schedule, not the plumbing.
Threads are off, and the reason is worth reading. A second build of the kernels runs
across cores, and it deadlocks: the first Highlights or Shadows render returns in about a
second and the second one never comes back. It is not thread creation, since all fifteen
threads are alive at the moment it stops. Turning it off costs 1.23× on that one stage,
1.36 s against 1.11 s at preview size, which is a cheap price for an app that
answers. This page previously claimed threads were worth 8×; that number had never been
re-checked after the schedule changed underneath it. Run the benchmark below on your own
machine rather than trusting either figure.
Fusion. A pointwise stage costs a full read and write of the image; the arithmetic is
nearly free by comparison. So the pointwise chain becomes one pass (the LUT story on the pipeline
tab), and geometry — crop, straighten, perspective, distortion — composes into a single resample
rather than four rounds of interpolation.
Caches. Whatever survives a slider move is kept: the cropped proxy (until the crop
changes), composed curve tables, rasterized brush masks, HSL and grading tables, thumbnails
(re-rendered only when the recipe settles). The rule is that dragging one slider should recompute
only what that slider touches.
Rendered previews. Each photograph you have looked at keeps its last settled render
as a JPEG at the size of this screen, as Lightroom keeps standard previews. Going back to it
draws that picture at once, and the live render replaces it when it is ready. A preview is
filed under the edit, the method choices, the lens correction and the version of the app that
made it, so an edit changed since is never shown with an old picture. The oldest are dropped
past the limit.
Photographs kept decoded. The worker also keeps the last few photographs you
opened as decoded raw data, with their demosaicked proxies, so going back and forth between
them reads no file and decodes nothing. It holds what the open photograph's own
full-resolution develop leaves of the memory budget, lets the oldest go first, and lets
them all go when memory runs short.
Measure, do not guess. Every number above was measured, and several beliefs died on the
way: the wasm copies were assumed expensive and are not; a fused-in-JavaScript chain was slower
than the naive loop it replaced; threads were assumed 4–8× and delivered 1.8×
until the schedule changed. The benchmark below runs the full pipeline on the open photo at
preview and at full size.
The workflow
CompPhotoRoom follows Lightroom's shape: a catalog of photos flows left to
right through the modules. Library is where photos arrive and get triaged — import a
folder or drop files, rate and flag, filter, group near-duplicates with Cull, and merge
brackets or panoramas. Develop is where one photo gets its look: every slider is an
algorithm from the book, and the ? next to each control shows the maths, the pseudocode and
where the book derives it. People finds and groups faces: name one and the same person is
recognised everywhere else — always as a pre-filled suggestion with a yes/no, never silently
(the loop Picasa got right); Map and Present (slideshow + web gallery, one module, shared settings) show the results;
Under the hood shows the pipeline that ran, stage by stage, with real timings.
Nothing you do touches the original file. Edits are a recipe stored beside
the photo, applied on every render — delete the recipe and the original is untouched. Import
keeps folders in place (no copies); drag-drops are kept inside the browser's storage;
everything survives reloads and none of it leaves your machine.
A sensible first tour: press Reset samples, cull the HDR
bracket, merge it (the Merge module → HDR bracket), watch the starting develop it gets, then stitch
the Guédelon panorama and click a horizon. The samples folder has raw files, JPGs, a
bracket and two panorama sets — one of each thing the app can do.
The interface
Library grid: click selects, ⌘-click adds, shift-click extends, drag
draws a marquee, double-click opens the loupe, background click deselects. The left panel
filters by folder: ⌘-click adds a folder to the ones shown, shift-click takes the folders
between, and the grid, the filters and every module's filmstrip then work on all of them. The filter bar filters by rating, flag and keyword, and + filter adds
Lightroom-style metadata columns — camera, lens, ISO, aperture, shutter, focal length, format,
colour label — each menu built from the values actually in your catalog, with counts. The
right panel shows EXIF and takes ratings, labels and keywords. The Merge module takes the current selection —
two or more photos — into an HDR bracket, a panorama, or both at once. Cull… groups lookalikes and ranks them on focus,
clipping and noise — it suggests, you decide.
Develop: panels on the right in Lightroom order, presets and history
on the left, filmstrip below. Tab hides or shows the side panels in every module, Shift-Tab every
panel (the top bar and the filmstrip too), F7 and F8 the left and the right one; the bar at
each edge brings a hidden panel back. F6, or the triangle on its edge, hides the filmstrip in every module at once. The histogram, clipping overlay (J) and focus peaking (K) read on the
developed image. Crop, Remove and Mask live above the panels; Z toggles 1:1.
Feedback… in the header sends a bug report or feature wish; the build
number beside the app name identifies your exact version (clicking it attaches thumbnail
diagnostics).
Keyboard shortcuts
Merging: select the frames in Library, open the Merge module. In the panorama report,
click along the horizon to level it (drag a mark to adjust, right-click removes it), Set
centre to recentre the projection, Re-solve to run again — only the version you keep lands
in the library.
Tag rules
Click the map to place the selected photo(s). Photos with GPS in their metadata appear automatically. Drag to pan, scroll to zoom, shift-drag to select the photos from that part of the world.
clipped at the sensor — gone
clipped by your settings — recoverable
crushed to black
Export
Each photo is developed at the chosen size by the same pipeline as the preview —
what you tuned is what you get. Metadata is written back as a fresh EXIF block (camera, exposure,
focal length, capture time) unless you strip it; a canvas-born JPEG carries none by itself.
Full-resolution Bayer RAW reconstruction using the AI Denoise amount.
Gharbi is baked in; crop, exposure and tone edits are not. White balance remains editable.
This DNG does not request Adobe AI Denoise. Uncompressed float pixels use about 12 MB per megapixel.
Downloads
Assisted culling
Near-duplicates are grouped, and inside each group the frames are compared on the
three things a machine can measure better than you can from a grid of thumbnails: whether the
picture is actually in focus at 1:1, how much of it is clipped, and how noisy it is. The crop
beside each frame is its sharpest tile at full resolution — the same thing you would zoom
to 100% to check, which is the slow part of culling. Nothing is deleted and nothing is applied
until you say so.