Selected workPhotoshoot

Photoshoot

A photobooth for desktop and browser that never sends a camera frame anywhere.

Role
Sole developer — graphics pipeline, security model, both runtimes, deployment
Team
Solo
Timeline
June 2026
Status
Live on the web · desktop build from source, no published release
Category
Photobooth · desktop and web
Platforms
Web (WebGL2 browsers) · Desktop (Electron) — built from source, not released
Built with
TypeScript 5.6 (strict)Electron 31WebGL2 · GLSL ES 3.00MediaPipe Tasks Vision — FaceLandmarkeresbuildWeb Audio APICanvas2D · MediaRecorderIndexedDB · localStorageFirebase Hosting

01/EXECUTIVE SUMMARY

What it is.

Photobooth software normally asks you to hand your camera to somebody's server. Photoshoot does the opposite: the webcam feed is rendered by a hand-written WebGL2 pipeline inside the app, face tracking runs against a model served from the app's own origin, and captures are written to a local folder or to IndexedDB. There is no backend anywhere in the repository. One TypeScript renderer serves both an Electron desktop build and a browser build, talking only to a twenty-method bridge interface that is implemented once per runtime. I built it alone — the twenty-seven fragment shaders, the zero-trust import format for community themes and filters, the Electron hardening, the landing page and the static deploy. It matters because it is the version of this product that does not need to be trusted: the privacy claim is a property of the architecture, not a promise in a policy.

02/THE PROBLEM

What was broken.

A photobooth is a camera app, and a camera app asks for the most sensitive permission a hobby project can ask for. The interesting constraint was making it playful — warps that follow the pointer, face effects, a printed four-shot strip — without a single frame leaving the machine.

  • Effects have to hold display refresh over a live camera stream. CSS filter functions cannot express a pointer-driven warp, and a Canvas2D pixel loop cannot run one at that cadence.
  • Face effects need landmark tracking, which is exactly the capability that usually justifies uploading frames to a server.
  • The desktop and browser builds have to behave identically, but one has Node, IPC and a filesystem and the other has none of them and never can.
  • The app invites community themes and colour filters. A file downloaded from a stranger has to be able to change how the app looks and what the GPU does, without being able to run anything.

03/ROLE & OWNERSHIP

What I owned.

What I designed and engineered

  • The full application: 34 TypeScript files, 5,902 lines under src/, plus 2,550 lines of CSS across the four app stylesheets and the landing page.
  • The graphics layer — 27 hand-written GLSL ES 3.00 fragment shaders, the compile/link/VAO core, the program cache and the WebGL context-loss recovery.
  • The security model — Electron window hardening, the app:// scheme handler, filesystem path containment, and the two shared validators that gate every imported theme and filter.
  • The shared bridge contract and both of its implementations: the Electron preload over IPC, and the browser shim over IndexedDB, localStorage and a download anchor.
  • All original assets: the app icon is generated by a build script using only node:zlib, the six backdrops are drawn procedurally with Canvas2D, and all eight sound cues are synthesized from oscillators and band-passed noise. No audio files exist anywhere in the project, and the only image that ships is the app icon, which a build script generates from node:zlib deflate.
  • The landing page, the esbuild pipeline, the content-hashed web build and the Firebase Hosting deploy.

What I did not own

  • MediaPipe Tasks Vision — Google's FaceLandmarker model and WASM runtime. Vendored into the app and served from its own origin, but not written by me. It is the project's single runtime dependency.
  • Electron, Chromium and esbuild.
  • Firebase Hosting, used only as a static file host — firebase.json contains a hosting block and nothing else.
  • No design collaborator, no QA and no code review. The gaps in the testing section are a consequence of that, and are stated rather than papered over.

04/CONSTRAINTS

What shaped the build.

No backend, by construction

There is no server code anywhere in the repository. Every decision downstream — where captures live, how settings persist, how the face model loads — had to resolve locally or not at all.

A Content Security Policy with no 'unsafe-inline'

Nothing in the app can inject a style tag or an inline script. That single omission is what forced the theming design: tokens are written through the CSSOM with element.style.setProperty, which is governed by script-src rather than style-src.

Untrusted input is the normal case, not the exception

Community themes and filters are a feature, so the import path is a permanent, expected attack surface rather than an edge case to be handled later.

One developer, two working days

The whole application was written across 13 commits between 14 and 17 June 2026. That budget is why there is one test suite rather than several, and why it was pointed at the validator instead of the UI.

06/PRODUCT FLOW

How it works.

  1. getUserMedia opens the camera into a <video> element mounted one pixel wide at opacity 0 — pixels flow through WebGL, not through the tag.

  2. requestAnimationFrame ticks at display refresh; prepareFrame() uploads the current video contents into one reusable GL texture.

  3. If a face effect is active, MediaPipe FaceLandmarker — throttled to roughly 22 fps so the render loop keeps its own cadence — supplies iris, nose, mouth, chin, brow and cheek positions as uniforms.

  4. drawEffect() runs the cached fragment program for the selected effect over a single full-screen quad. Nothing is allocated per frame.

  5. grabFrame() re-renders and copies the GL canvas, so the saved file is exactly what the preview showed — effect, mirror and replaced background included.

  6. Single shot saves one image; strip mode composes four onto a warm paper gradient with per-pixel grain and a printed footer; video mode records the canvas stream to WebM.

  7. The capture is written to Pictures/Photoshoot on the desktop, or to IndexedDB in the browser.

One frame, from lens to strip. Nothing in this path crosses the network.

07/SYSTEM ARCHITECTURE

How it fits together.

  • Camera RenderergetUserMedia — MediaStream, local only
  • Renderer Sharedre-validates every token and parameter before use
  • Renderer BridgePhotoshootBridge — 20 methods, the only way out
  • Bridge Electron maindesktop: contextBridge over 20 IPC channels
  • Bridge On-device storageweb: IndexedDB, localStorage, download anchor
  • Electron main Sharedvalidates imports before anything is persisted
  • Electron main On-device storagepath-contained reads and writes
  • Renderer Third partiesloads the face model from app://photoshoot, not a CDN
  • Third parties RendererFirebase Hosting serves the web bundle; no runtime API calls
Three processes, one shared renderer, and a single audited seam between them.

08/ENGINEERING CHALLENGES

The hard parts.

A plugin format that is data, never code

Why it was hard

Themes change the app's entire chrome and filters change what the GPU does to every pixel. Both are things a user should be able to download from a stranger and try, and both are the classic route to arbitrary execution. A theme that can set a CSS value can smuggle url(javascript:…), @import or a script tag; a filter that can supply shader text can rewrite the pipeline. The desktop app is an Electron process, so a mistake here is not a defaced page — it is a foothold.

Options considered

  • Ship only built-in themes and filters, and drop the community format entirely
  • Accept CSS text from a manifest and sanitize it on the way in
  • Accept GLSL snippets and validate the source before compiling it
  • Reduce both formats to whitelisted, typed values with no free text anywhere

What I built

A theme manifest may set only the 59 whitelisted CSS custom properties — 39 colour, 9 length, 5 shadow, 2 font and 4 texture. Each is matched against a per-kind regular expression, then screened against 24 forbidden substrings including javascript:, expression(, @import, <script, url(, http://, file:, blob:, backslash escapes, braces and semicolons. Texture tokens can never be set from a manifest string at all; they come only from bytes the importer has already verified by magic number. A filter is 11 numbers, each hard-clamped to a fixed minimum, maximum and default, plus an optional lookup table. Every value is validated twice — once by the importer, and again by the renderer immediately before it reaches the CSSOM or a GL uniform.

What it cost

Community themes can only do what the whitelist anticipated. Anything genuinely new needs a new token, a new regular expression and a new release, so the format cannot be extended by the people using it. That is the price of it being data.

How I know it works

44 adversarial assertions run over the filter validator: out-of-range values, NaN, Infinity, wrong types, unknown keys, injected strings, junk manifests, and the renderer's re-clamp of a store that was edited on disk. The invariant is written into the shader file so it survives the next reader: no part of a manifest ever becomes shader source, JavaScript, CSS or HTML.

Result

An imported filter controls numbers, never code, and an imported theme controls a fixed vocabulary of values. The theme validator follows the same design but has no tests — it is the larger and riskier of the two, and it is the largest known gap in the project.

One compiled shader for every user-supplied filter

Why it was hard

Imported filters need to look different from one another — brightness, contrast, temperature, hue rotation, film grain, a colour lookup table. The obvious implementation generates shader source per filter, which puts untrusted data on the path to the GPU compiler and adds a compile stall the first time each one is selected.

Options considered

  • Generate GLSL per filter from the manifest and compile on first use
  • Precompile one program per known filter shape and switch between them
  • Compile one fixed program and drive every filter entirely from uniforms

What I built

All imported filters resolve to a single program: the effect id is mapped with `const key = id.startsWith('custom:') ? 'customfilter' : id`, so every custom filter shares one compiled shader. The difference between two filters is three vec4 uniforms and an optional LUT texture. The shader runs brightness, contrast, saturation, temperature, tint, gamma, fade, a hue rotation about the (1,1,1) axis, a 64-level lookup table with blue-channel interpolation, vignette and grain — in that fixed order.

What it cost

Every imported filter pays for every stage whether it uses it or not, and a filter can only ever be a point inside that fixed parameter space. Generated shaders would be cheaper per filter and unbounded in what they could express; this trades expressiveness for a surface area of eleven range checks.

How I know it works

gamma is clamped to a floor of 0.2 so the shader's 1/gamma can never divide by zero, and that floor is asserted directly in the suite. A lookup table must be a real 512×512 PNG, checked by magic bytes and by the width and height read out of the IHDR chunk at byte offsets 16 and 20 — never by file extension. Non-square images, truncated files, oversized files and a JPEG wearing a .png name are all covered.

Result

Switching between imported filters costs uniform writes, not a shader compile, and the security argument for the whole feature collapses to a range check on eleven numbers.

The camera worked; the preview was blank

Why it was hard

The camera <video> element is deliberately mounted one pixel wide at opacity 0, because pixels flow through WebGL rather than through the tag. Driving the render loop from requestVideoFrameCallback is the textbook choice — it fires once per decoded video frame, exactly the cadence a texture upload wants. On some browser, GPU and operating-system combinations it never fires at all for an element in that state. getUserMedia had resolved, the tracks were live, and the app looked broken.

Options considered

  • Mount the video visibly and hide it behind the canvas so rVFC has a painted element
  • Keep rVFC and add a watchdog that restarts the loop when no frame arrives
  • Drive the loop on requestAnimationFrame and stop depending on rVFC entirely

What I built

The loop always ticks on requestAnimationFrame, which fires at display refresh whether or not a video frame was presented, and the texture upload takes whatever the video element currently holds. The reasoning is recorded as an eight-line comment at the scheduling call itself, so the next reader does not reintroduce the optimisation.

What it cost

On a camera running below display refresh, the same frame is uploaded more than once and that GPU work is wasted. Predictable liveness was worth more than avoiding redundant uploads.

How I know it works

The debug overlay reports the active backend, which now reads `WebGL2 · rAF`. The fix is a discrete commit rather than a change bundled into unrelated work.

Result

Fixed on 17 June 2026. The README has not caught up — it still describes the rVFC loop that this commit removed, which is documentation drift I have not yet corrected.

Replacing the background without a second model

Why it was hard

Background replacement means deciding, per pixel and per frame, what is the person and what is the room. The modern answer is a segmentation model, which would have meant a second on-device model to download and a second inference budget on top of the face mesh. The naive alternative — reading pixels back from the GPU at full resolution and differencing them on the CPU — stalls the pipeline every frame.

Options considered

  • Add a second MediaPipe model for selfie segmentation
  • Difference every frame against a reference at full resolution on the CPU
  • Difference against a low-resolution reference and let the compositor do the upscaling

What I built

The user captures a reference frame of the empty scene at 192 px wide. Each frame is differenced against it with a sum-of-channels threshold derived from a user tolerance setting — the only CPU pixel loop in the application, deliberately confined to that resolution. The resulting low-resolution alpha mask is upscaled by the GPU during destination-in compositing, which softens the matte edge for free rather than requiring a separate feather pass. The composited canvas is then fed back into the GL pipeline as the source, so effects and mirroring still apply on top.

What it cost

It asks the user to step out of frame once, and it degrades if the camera or the lighting moves. A segmentation model would need neither. It also means the six backdrops had to be drawable rather than photographable — they are generated procedurally with Canvas2D and cached per size, so no image assets ship.

How I know it works

The cost is bounded by construction rather than by measurement: the loop is fixed at a 192 px sample regardless of camera resolution, and the upscale is the compositor's work, not the CPU's. There is no benchmark in the repository, so I have no frame-rate figure to publish for it and will not quote one.

Result

Background replacement composites in a single pass ahead of the effect pipeline, with one bounded CPU loop and no second model to load.

One renderer, two runtimes

Why it was hard

The desktop app has Node, IPC, a filesystem and a main process. The browser has none of those and cannot be given them. Keeping two builds in step usually degrades one of two ways: a shared core with two thin apps around it that quietly drift apart, or platform checks sprinkled through the renderer that multiply with every feature.

Options considered

  • Two codebases kept in step by hand
  • One codebase with `if (isElectron)` branches inside the renderer
  • A bridge interface the renderer talks to, implemented once per runtime

What I built

The renderer only ever calls PhotoshootBridge, a twenty-method interface declared in src/shared/ipc-contract.ts alongside the twenty IPC channel names. preload.ts implements it over contextBridge and ipcRenderer; src/web/shim.ts implements the same twenty methods over IndexedDB, localStorage and a download anchor. src/shared/ imports neither Node nor the DOM, so it bundles unchanged into the main process, the preload, the renderer and the web shim. The web build bundles src/renderer/main.ts verbatim and only swaps the script tags and sets data-platform="web".

What it cost

The interface is the lowest common denominator of the two runtimes, so capabilities that only make sense on the desktop — revealing a file in its folder, for example — still have to exist in the browser and do the nearest native equivalent. Adding one feature means touching the contract, both implementations and the preload bridge: four files for one capability.

How I know it works

`tsc --noEmit` runs clean with strict mode on, zero diagnostics, which is what actually enforces that both implementations satisfy the interface. The deployed web app references exactly the hashed filenames produced by the local build, so the two runtimes are demonstrably built from the same renderer source.

Result

34 TypeScript files and 5,902 lines produce one renderer and two shipping runtimes, with the platform difference isolated to two files totalling 486 lines.

09/TECHNICAL DECISIONS

Why this way.

Lock the renderer down before writing anything into it

Chose contextIsolation and sandbox on, nodeIntegration off, a CSP with no 'unsafe-inline', and a network kill-switch

The renderer's job is to hold a live camera feed and apply visual data that may have come from a stranger. Everything it does not need, it should not have. The window is created with contextIsolation: true, nodeIntegration: false, sandbox: true, webgl: true, spellcheck: false and devTools: isDev, so production builds ship without dev tools. The preload is the only seam and exposes exactly twenty methods — no ipcRenderer, no require and no Node reaches the page. The CSP exists as two hand-maintained copies — the header injected from main.ts:20-32 and the meta tag in index.html:5-8. They are deliberately not identical, because frame-ancestors is ignored in a meta tag, and that divergence is also the drift risk. Only the media permission is ever granted; geolocation, notifications and MIDI are refused by both the request and the check handler. Every new window is denied and http(s) links are routed to the OS browser instead. And onBeforeRequest cancels any request whose URL does not begin with app:, file:, devtools:, blob:, data: or chrome-extension: — the desktop app cannot make a network call at all.

Instead of Electron's defaults with contextIsolation left on and nothing else touched, which is what most desktop wrappers ship.

Trade-off Because 'unsafe-inline' is absent, nothing can inject a style tag, so themes had to be applied through the CSSOM instead of by writing CSS. Disabling dev tools in production means a user-reported rendering bug has to be reproduced locally before it can be looked at.

A custom app:// origin instead of file://

Chose Register app://photoshoot as a privileged scheme and serve the packaged app from it

MediaPipe's FaceLandmarker fetches its WASM runtime and its model file at startup. Chromium blocks fetch() of file:// resources but allows it for a standard scheme, so a file:// app cannot load an on-device model without reaching for the network — which would defeat the entire point of the product. A real origin also gives 'self' in the CSP something to resolve to. The scheme is registered as standard, secure, supportFetchAPI and stream, and its handler rejects anything resolving outside dist/. The reason is written into the code above the constant, not left in a commit message.

Instead of Loading dist/index.html straight from file://, or running a local HTTP server inside the desktop app.

Trade-off A scheme handler is code I own and have to keep correct. A path-containment mistake in it would be a file-read primitive, which is why it is one of the places isInside() is applied.

Hand-written GLSL rather than a graphics library

Chose Raw WebGL2 with GLSL ES 3.00 — 27 fragment shaders written by hand over a single full-screen quad

The entire app is one quad with a different fragment shader on it. A scene-graph library would have added a dependency, a bundle and an abstraction whose value — meshes, cameras, lighting, material systems — is entirely unused here. CSS filter functions cannot express the seven pointer-driven warps or the face-tracked distortions, and a Canvas2D pixel loop cannot run them at display refresh. Writing the shaders directly is also what made the single fixed custom-filter program possible, which is the thing that makes untrusted filters safe.

Instead of three.js or PixiJS for the pipeline, or CSS filter functions and Canvas2D for the effects.

Trade-off Everything a library would have handled is mine: compilation, linking, VAO setup, uniform plumbing and context loss. Programs are compiled lazily and cached per effect id; webglcontextlost and webglcontextrestored handlers clear and rebuild every program and re-upload every LUT texture; a shader that fails to compile falls back to the pass-through effect once and raises a toast rather than crashing the app.

No UI framework, no state library, esbuild for everything

Chose Plain DOM built with createElement, a 43-line mutable singleton for shared state, and three esbuild targets

The interface is a viewfinder, a control bar, a 3×3 effects grid and a few sheets. Reconciliation buys nothing when the thing changing sixty times a second is a GPU texture rather than a DOM tree. esbuild produces the main, preload and renderer bundles from one script with no plugin system to learn, and type checking is deliberately a separate command — esbuild does none, so a type error can never stop the app from building and running while I am debugging something unrelated.

Instead of React or Svelte on Vite, with a store library for shared state.

Trade-off There is no component model, so UI code is imperative, and shared state is a singleton any module can mutate. That is workable at 5,902 lines and would not be at five times that.

Content-hashed static assets instead of a versioned deploy

Chose sha256-prefixed filenames cached immutably for a year, with the HTML documents served no-cache

Every bundle and stylesheet is emitted with an eight-character content hash and Cache-Control: public, max-age=31536000, immutable, while the HTML and the /, /app and /app/ routes revalidate on every load. A push therefore reaches users on their next page load without anyone clearing a cache, and a stale HTML document can never pair with a mismatched bundle, because the document names the exact files it needs. There is no backend to version alongside it.

Instead of Query-string cache busting, or a short max-age on everything and hoping.

Trade-off Every deploy writes new filenames, so the hosting bucket accumulates the old ones.

10/DESIGN EVOLUTION

How it changed.

14 June 2026 — the app, in one sitting

Ten commits: the Electron and web shell with the landing page and Firebase Hosting config, then the effects registry with drag-to-move distortion centres, print-to-tray capture, the MediaPipe face-mesh effects, the community filter format with lookup tables, a redesigned landing page, and the Darkroom theme.

17 June 2026 — the three fixes that only appear once you run it

Nothing on the 15th or 16th; three commits on the 17th, each one a defect that reading the code would not have found. The render loop moved from requestVideoFrameCallback to requestAnimationFrame. The web build started content-hashing its assets so deploys propagate. And a saved camera deviceId stopped being able to permanently break the app.

Where it stands

Version 1.0.0, 13 commits, no git tags and no GitHub release. The web deployment is current with HEAD. The README carries two statements the code has since contradicted, and the landing page still says four themes where the app ships five — small drift, but drift I can point at rather than pretend away.

11/TESTING & RELIABILITY

How it is proven.

There is one test file and no continuous integration. What it covers is the part that matters most — the validator standing between a downloaded file and the GPU — and it is written as adversarial cases rather than happy paths. Stating its scope exactly is more useful than implying the project is tested end to end, so the gap is listed alongside the coverage.

Adversarial assertions passing
44
Test files in the repository
1
Type errors under strict mode
0
CI pipelines
None

Representative cases

  • Numeric clamping across all eleven filter parameters: values below the minimum, above the maximum, NaN, Infinity, and the wrong type entirely.
  • Manifest hygiene: unknown keys dropped, injected strings sanitized, and a junk manifest rejected outright rather than partially applied.
  • Lookup-table filename traversal: ../../etc/passwd, absolute paths, backslashes, double extensions, dotfiles, overlong names and control characters.
  • Lookup-table bytes: PNG magic numbers, a 512×512 IHDR, non-square images, a JPEG wearing a .png name, truncated files and oversized files.
  • Tamper recovery: the renderer's clampParams re-clamps a filter store that was edited on disk after import, which is the second half of the validate-twice rule.
  • Not covered, stated plainly: the theme validator, the Electron main process, filesystem path containment, the renderer and the GL layer. The theme validator is the larger of the two — 59 tokens, 24 forbidden substrings and four regular expressions — and it is the untested one.

12/RESULTS

What shipped.

Deployed, and current with the source
The web app is live and the deployment matches HEAD exactly: the served document references the same content-hashed filenames the local build produces, and the host's last-modified timestamp is thirteen seconds after the final commit.
Seventeen effects and eight face effects, all on-device
The registry holds 26 entries: one pass-through, 17 GLSL effects and 8 MediaPipe face effects, plus a 27th shader for the audited custom-filter program. Seven of the seventeen are warps whose centre follows the pointer, mirror-corrected so it lines up with the cursor; two are time-animated. Face tracking runs with numFaces: 1 against a model served from the app's own origin, throttled to roughly 22 fps so the render loop keeps its own cadence — and if the model fails to load, face effects render the plain image instead of failing.
One runtime dependency, and no shipped assets
@mediapipe/tasks-vision is the only entry under dependencies; everything else is a devDependency. Nothing else is downloaded and nothing is bundled that was not written for this project: the app icon is generated by a build script using only node:zlib deflate, the six backdrops are drawn procedurally, and all eight sound cues are synthesized from oscillators and band-passed noise. There are no audio files in the repository at all.

13/REFLECTION

What I learned.

What worked

  • Validating twice — once at import and again at use — cost almost nothing and meant the renderer never has to assume the importer ran, or ran the current version of itself.
  • Splitting the texture upload from the draw call was a small refactor that made the effects menu possible at all: it uploads one frame and draws several different effects from it, three tiles per animation frame through a second small renderer capped at 420 px.
  • Writing the reason into the code rather than the commit message. The rVFC comment, the app:// comment and the symlink comment each exist so the next reader does not undo the fix while tidying up.

What I underestimated

  • Platform behaviour that is technically correct and practically useless. requestVideoFrameCallback declining to fire for an off-screen video is legal, and it presents to the user as "the camera is broken".
  • State that rots between sessions. Browser deviceIds rotate, so a saved camera id turned into a permanent "No camera found" until the error handler learned to retry with default constraints and persist whatever actually opened.
  • How fast documentation drifts from code. The README described the render loop the way it worked for three days, and the landing page still counts four themes against the five the app ships.

What I would do next

  • Tests for the theme validator. It is the larger and riskier of the two shared validators and it has none — the honest reading is that the filter path is tested and the theme path is only reviewed.
  • A packaged, signed desktop build. electron-builder is configured for a Windows NSIS installer and a portable binary, but no GitHub release exists, so the README's download link and the landing page's download button both currently lead to an empty releases page.
  • Symlink rejection currently guards only the lookup-table import path, because isInside() is lexical and a symlink named lut.png could otherwise point anywhere on disk. The same lstat guard belongs on every path that accepts a file from outside the app, not just that one.
The Photoshoot capture window: a dark viewfinder marked REC with an ISO 400 · F/1.8 · ON-DEVICE readout, a row of effect swatches, a red shutter button, and a four-frame paper strip printed PHOTO·SHOOT alongside it.
The capture window and the four-shot strip. Drawn for this archive — the repository ships no product screenshots.