Photobox: nine live camera previews

Open Photobox

Photobox is a photo booth that runs in the browser. It shows the camera feed in a 3 × 3 grid of live previews. The Effects and Distortions tabs each hold 8 effects around a center tile that shows the camera with no effect. When the user selects a tile, Photobox opens a full-size view of that effect. In that view, the user takes a photo or records a video. [P1] [P2] [P5]

How it uses Noisemaker

Each effect is a short DSL program that reads the camera through media(): [P5]

Three of the 16 effect programs in effects.js
media().grade(saturation: 0.3, exposure: 0.1).write(o0)
media().celShading(mix: 0.75, edgeThreshold: 0.25).write(o0)
media().grade(preset: noir).write(o0)

Each program also has a search line and ends with render(o0).

The grid creates nine PhotoboxRenderer objects, and each one wraps its own CanvasRenderer. All nine read the same <video> element. A tenth renderer draws the full-size view at the camera’s resolution. [P2] [P6]

Each renderer uploads the current video frame on every animation frame:

PhotoboxRenderer._uploadVideoTexture() in renderer.js
_uploadVideoTexture() {
    if (!this._videoSource) return
    if (this._videoSource.readyState < 2) return // HAVE_CURRENT_DATA
    // Texture ID must match compiled step: media() is always step 0
    this._renderer.updateTextureFromSource?.('imageTex_step_0', this._videoSource, FLIP_Y_FALSE)
    // imageSize must match canvas resolution (shader maps gl_FragCoord.xy /
    // imageSize). It's constant between resize/compile, and applying it walks
    // every pipeline pass, so apply only when it actually changed.
    if (this._imageSizeDirty) {
        this._renderer.applyStepParameterValues?.({ step_0: { imageSize: [this.width, this.height] } })
        this._imageSizeDirty = false
    }
}

FLIP_Y_FALSE is one shared { flipY: false } object, so the loop does not make a new object on each frame. The renderer sets the dirty flag after each compile and each resize(). imageSize is the canvas resolution, not the camera resolution. [P3]

Grid tiles render at 320 pixels wide. The tile height is 320 × camera height ÷ camera width, rounded. For a 1280 × 720 camera, each tile renders at 320 × 180. [P2]

A tab change stops all nine renderers and compiles the nine new programs in parallel. [P2] When the user opens the full-size view, the grid stops its renderers. When the user returns, the grid starts them again without a new compile. [P2] [P6]

Work outside the engine

  • A photo is a PNG from canvas.toBlob(), after a 3-2-1 countdown. [P4]

  • A video uses canvas.captureStream(30) and MediaRecorder, with the first supported format from WebM with VP9, WebM with VP8, WebM, and MP4. [P4]

  • Photobox stores each capture in an IndexedDB gallery and also downloads it. [P1] [P6]

  • Users can switch between the front and rear cameras. [P1]

Limits

  • The renderer sets preferWebGPU: false, so Photobox renders only with WebGL2. [P3]

  • In the grid view, nine renderers each upload the video frame on every animation frame. The shared <video> element does not remove that cost. [P2] [P3]

  • Photobox calculates the tile size once. A camera switch does not change the tile size, even when the new camera has a different aspect ratio. [P2] [P6]

  • Camera access needs browser permission. [P6]

Sources

All links point to photobox revision 685ea04.