Skip to content

Capture camera

The camera surface behind Tomoda's photo capture. Covers the shared component on top of vision-camera v5, the LUT pipeline, the recipe knob model, and the settings the camera exposes to the user.

Component tree

File Role
frontend/components/capture/TomodaCamera.native.tsx The camera itself. Vision-camera wrapper, pill row, top menu, capture pipeline. Native-only, wrapped by TomodaCamera.tsx (web stub).
frontend/app/(capture)/camera.tsx Thin route wrapper. Holds the recipe / photo URI in CaptureContext and pushes to preview after shutter.
frontend/app/(capture)/preview.tsx Post-shutter preview. Viewport, top bar (RAW peek, reset, tune, save), filter strip, action bar.
frontend/components/capture/FilterStrip.tsx Horizontal scrollable chip strip. Baked recipe thumbnails from useFilterPreviews.
frontend/components/capture/RecipeTunePanel.tsx 8-knob adjust panel. Icon strip + outline slider rail. Double-tap knob to zero it.
frontend/components/capture/CaptureFrame.tsx Shared frame for the wizard screens (check-in, place, compose-*). No inner modal, no mount animation, since the whole (capture) route group is presented as fullScreenModal by the root Stack.
frontend/utils/bakePhotoWithLUT.ts Skia + SkSL offline bake. Exports the shader sources + recipeUniforms so both the offline bake and any future live path share one shader.
frontend/utils/filterPreviewBaker.ts Bake + memoize per-recipe thumbnails against the reference image, keyed by SHADER_VERSION.
frontend/scripts/build-luts.js Offline .cube.png Hald converter. Also emits assets/luts/signatures.json with per-LUT characterization. Run via task build:luts.
frontend/constants/FilmSimulations.ts 23 recipes (Standard + 22 film LUTs). All knob defaults are zero; LUT owns the character.
frontend/constants/lutRegistry.ts Recipe id → require('assets/luts/*.png'). loadLUT() returns { id, localUri } via Asset.fromModule + downloadAsync.
frontend/constants/captureViewport.ts VIEWPORT_AR = 4/5 (portrait). Every aspect crop is relative to this base.

LUT pipeline (offline)

Every recipe except Standard is anchored to a 3D LUT stored as a 512×512 Hald PNG (8×8 grid of 64×64 tiles, 64 blue slices). Source .cube files live alongside the PNGs in assets/luts/.

The offline build runs in scripts/build-luts.js and does three things per LUT:

  1. Parse the .cube (33-cube typically, 3-float RGB per lattice cell).
  2. Upsample to 64-cube via trilinear interpolation, so the runtime shader uses the standard Hald-8 layout without an extra size uniform.
  3. Bake to .png (8-bit RGBA, 512×512) via pngjs. Community-verified layout matches Shopify/react-native-skia discussion #1436.

After baking, the same script runs a characterization pass and writes assets/luts/signatures.json. For each recipe:

Signature field What it measures How
blackLift Shadow lift, 0 = pure black preserved luma of LUT(0,0,0)
highlightRolloff Highlight compression, 0 = pure white preserved 1 - luma(LUT(1,1,1))
gamma Overall tone curve geo-mean of gamma fits at 0.25 and 0.75 grey
warmthShift Red vs blue on the daylight axis mid.r - mid.b
tintShift Green vs magenta mid.g - (mid.r + mid.b) / 2
saturation Chroma preservation red-primary chroma after LUT
swatch Recommended placeholder colour mid-grey output rounded to 8-bit RGB

The signatures are informative (they show at a glance what a film does), and could later feed a "recipe similarity" search or auto-tone suggestions. Nothing at runtime currently reads them.

Runtime bake pipeline

utils/bakePhotoWithLUT.ts compiles two SkSL runtime effects on demand and caches them at module scope:

  • LUT_SHADER_SRC for recipes with a LUT.
  • NO_LUT_SHADER_SRC for Standard, or for LUT-less user recipes with knob overrides.

Both shaders run the same operation order on the input photo:

sRGB in
  linearize (piecewise IEC-61966 curve, not pow-2.2)
  exposure: rgb *= 2^EV
  linear → sRGB (re-encode for LUT)
  shadows/highlights (luma-masked, smoothstep-blended)
  LUT lookup (only in the LUT shader; skipped in the no-LUT shader)
  contrast (S-curve around 0.5 pivot)
  warmth/tint (R vs B shift, G vs M shift, ±8%)
  saturation (luma-preserving mix around 0.2126/0.7152/0.0722)
  grain (hash-based additive noise, ±grain amplitude)
  clamp
sRGB out

The bake surface takes a targetWidth × targetHeight, pre-scales the source into a scratch surface via drawImageRect, then runs the shader on the pre-scaled image at 1:1. This avoids a subtle image-shader local-matrix inversion that fought us early on.

The shader's uniforms come from recipeUniforms(recipe):

Uniform Recipe field Range mapping
uExposure brightness (labeled "Exposure" in UI) ±100 → ±2 EV
uContrast contrast ±100 → ±0.6 slope
uHighlights highlights ±100 → ±0.6
uShadows shadows ±100 → ±0.6
uSaturation saturation ±100 → ±1.0
uWarmth warmth ±100 → ±1.0 (shader multiplies by 0.08 for the ±8% shift)
uTint tint ±100 → ±1.0 (same 0.08 scale)
uGrain grain 0..100 → 0..0.15 noise amplitude

Every knob defaults to zero on every recipe. The LUT carries the film's character (contrast, warmth, black lift). Knobs are the user's creative override on top.

The SHADER_VERSION constant lives at the top of bakePhotoWithLUT.ts. Any change to the shader order, uniform ranges, LUT format, or Hald tile layout must bump this string. The version prefixes both the on-disk baked filenames and the thumbnail cache key in filterPreviewBaker.ts, so a bump invalidates stale bakes on next launch. purgeStaleFilterPreviews() deletes disk files that don't match the current version prefix.

Recipe shape

interface FilmRecipe {
    id: FilmRecipeID | 'custom';
    label: string;
    brightness: number;   // ±100, exposed to users as "Exposure"
    contrast: number;     // ±100
    highlights: number;   // ±100
    shadows: number;      // ±100
    saturation: number;   // ±100
    warmth: number;       // ±100
    tint: number;         // ±100
    grain: number;        // 0..100
    straighten: number;   // reserved
    crop?: { x, y, width, height };
    lutId?: string;       // matches lutRegistry key when set
}

The id names the base preset and MUST NOT flip to 'custom' when knobs change. The FilterStrip highlights the chip whose id matches recipe.id, and flipping to 'custom' on every knob drag broke both the highlight and the strip's scroll anchor. RecipeTunePanel.update preserves the id.

Switching filters preserves knobs. Both preview and camera merge only id, label, lutId from the tapped chip into recipe. User adjustments carry across recipe changes. See preview.tsx::selectFilter and camera.tsx::onRecipeChange.

Persistence. CaptureProvider reads @tomoda:capture:recipe_id on mount and hydrates the recipe id from AsyncStorage. setRecipe writes it back for every non-custom recipe. Session state (knob values, photo URIs) is not persisted.

Capture flow

  1. Vision-camera presents the preview at the sensor's native format. useCameraFormat picks max photo resolution, max video resolution, 4:3 aspect. photoQualityBalance="quality" requests the highest- detail capture path. lowLightBoost is enabled per device support. HDR is only enabled when the current recipe is Standard, since a film LUT already encodes its own contrast character and running HDR through it double-tone-maps the result.
  2. Tap shutter. Haptics.impactAsync(Medium) fires immediately so the click is perceived as instant, even if the actual capture takes ~50ms.
  3. camera.current.takePhoto returns a PhotoFile with a native path and reported width / height.
  4. Aspect crop. The saved photo dims come from Image.getSize(rawUri) (respects EXIF orientation, unlike photo.width/height which report the physical sensor buffer). The center crop rect is computed against the target aspect (4:5, 1:1, 4:3, 3:2) and clamped to frame edges. If the target matches the sensor aspect, no crop runs. The crop uses expo-image-manipulator with SaveFormat.JPEG, compress: 0.95.
  5. onCapture hands the cropped URI + dims + GPS lat/lng to the route wrapper, which stores it in CaptureContext.rawPhotoUri and pushes to preview.

Preview + tune

  1. preview.tsx measures the displayed URI (baked if ready, raw otherwise) via Image.getSize to derive photoAR.
  2. Viewport sizing is adaptive: full screen width, height scales to preserve photoAR, clamped to the available vertical space between top bar and filter strip. Landscape shots no longer overflow; portrait shots use their full height. The wrap is flex: 1 with justifyContent: 'center', centered in the middle area.
  3. Bake runs on mount and re-runs when recipe changes. bakePhotoWithLUT writes to FileSystem.cacheDirectory with a baked-${SHADER_VERSION}-${timestamp}.jpg filename. The previously baked image stays visible during a re-bake, avoiding a flash.
  4. Adjust knobs in RecipeTunePanel. Icon strip on top selects one knob at a time; a fat outline slider drives the value. Double-tap a knob to zero it. Knobs that differ from zero show a small primary- colour dot. RecipeTunePanel.update preserves the recipe id.
  5. Switch filters in FilterStrip. The strip pre-computes initialX = idx * (CHIP_WIDTH + CHIP_GAP) - CHIP_WIDTH and passes it via contentOffset so the strip opens already at the selected chip. Later selection changes use scrollTo({ animated: false }). Selection merges only id, label, lutId, preserving knobs.
  6. RAW hold-to-preview on the top bar. onPressIn sets showRaw, onPressOut clears it. displayUri is photoSrc while held, otherwise capture.photoUri.
  7. Reset on the top bar zeros all eight knobs without changing the base filter.
  8. Save to the library via MediaLibrary.saveToLibraryAsync. Button flips to white background with a dark checkmark on success.

Camera settings

The pill row under the viewport shows five controls, left to right:

Slot Behavior
EV pill (bare, no outline) Icon + integer value (0, +1, -2). Tap opens a chevron-down state; the picker area below morphs to a full outline exposure rail (thin white line, hollow primary thumb, tick at 0). Range is capped to ±3 EV even if the device reports ±8; matches physical camera EV comp scale. Step 0.05.
Timer (bare) Cycles Off → 3s → 10s. Icon when off, text "3s" / "10s" when set. Countdown runs before shutter.
Focal circle (outline) Circle with the focal length number (26, 35, 50, or 13 for 0.5x). The multiplier ( / 0.5×) is a half-in half-out badge on the bottom edge with a black background so the border cleanly breaks through. 35mm and 50mm skip the mult badge. Tap opens the focal picker (same circle style, wider).
Flash (bare) Cycles Off → Auto → On. Icon flips to primary when non-off.
Flip (bare) sync-outline icon, immediate action. Front↔back swap.

Above, the top bar has:

Slot Behavior
Close Left-side X. Router.back().
Menu Right-side / . Expands aspect / level / grid / (front-only) mirror.
Aspect Text-only label showing current aspect (4:5, 1:1, 4:3, 3:2). Cycles on tap. The viewport stays fixed at 4:5; dim bands appear inside showing the target crop area. Center crop applies at capture.
Level Toggles an accelerometer subscription (expo-sensors, ~60 Hz). Renders a horizontal line across the viewport that rotates opposite device roll. Turns primary within ±0.5° of true horizontal.
Grid Rule-of-thirds overlay. Simple horizontal + vertical divisions.
Mirror (front only) Applies transform: [{ scaleX: -1 }] to the Camera view. Defaults on.

Tap the viewport at any point to trigger camera.focus({x, y}). This runs both AVCaptureFocusModeAutoFocus and AVCaptureExposureModeAutoExpose at the tap point. A minimal white circle reticle fades in, with an inline vertical outline exposure rail next to it (sun icon at top, moon at bottom, hollow primary thumb sliding along a thin white line). Reticle auto-hides after 3s.

Filter thumbnails

useFilterPreviews(FILM_RECIPES) runs on camera mount and bakes every recipe's thumbnail into the shared module-scope cache (keyed by SHADER_VERSION + recipe id). Bakes are serial to keep Skia happy; each ~30-80ms at 160px long-edge. By the time the user opens the filter strip, thumbnails are ready.

The reference image is frontend/assets/filter-preview.jpg. Swapping it (with a matching filename) auto-invalidates cached thumbnails on the next launch since the SHADER_VERSION-prefixed disk cache doesn't match. In-memory cache also invalidates on module reload.

Chip size is 88×88 with a 72×54 thumbnail. Selected chip gets a primary-colour border. The strip auto-scrolls (non-animated) to keep the selected chip visible when selection changes.

Vision-camera version

We are on react-native-vision-camera@^5.1.0 (Nitro-based rewrite, Fabric-native), paired with:

  • react-native-worklets@^0.10.1 (required by vision-camera v5)
  • react-native-reanimated@^4.5.0 (requires worklets 0.10.x)

Migrated from v4.7.3 during the capture-flow branch. The v5 API is a significant break from v4: session-level primitives (usePhotoOutput, useCameraSession, etc.), Nitro-based Photo returns, renamed device properties (neutralZoomzoomLensSwitchFactors[0], minExposureminExposureBias), controller-driven zoom / exposure via the reactive <Camera> props. See the migration commit for the concrete rewire points.

Deferred

Live LUT viewfinder (applying the recipe's LUT to the running camera preview so the user sees the film look before pressing shutter). Prototyped on v5 with both <SkiaCamera> and a <Camera> + Skia canvas overlay + useFrameOutput pipeline. Both paths topped out at ~15fps on iPhone 12-era hardware because the worklet → CPU snapshot → JS thread → Canvas re-render round-trip forces a GPU→CPU copy on every frame. The shader work itself was under budget; the cross-thread copy was the ceiling. Reverted.

Users get the film look on the preview screen after shutter (the saved photo is unchanged). Path to shipping live preview if needed in the future: custom native camera view (~1150 LOC iOS, AVCaptureSession + Metal + CIColorCubeWithColorSpace). Not on the roadmap.

Fine-tuning surfaces

Places to look when adjusting look, feel, or performance:

Want to change Where
Add / remove a film recipe Drop .cube into assets/luts/, add id to FilmRecipeID, add entry in FILM_RECIPES, run task build:luts.
Retune a film's baseline character Adjust the LUT itself (edit .cube, re-run build). Do NOT nudge recipe knob defaults; the LUT owns character.
Change what a knob does at max recipeUniforms in bakePhotoWithLUT.ts. Bump SHADER_VERSION.
Change shader order (e.g. move contrast before LUT) Edit LUT_SHADER_SRC / NO_LUT_SHADER_SRC. Bump SHADER_VERSION.
Change reference image for thumbnails Replace frontend/assets/filter-preview.jpg. Bumping SHADER_VERSION is not required (the cache key doesn't include the reference), so bump it if you want a full re-bake.
Add a top-bar setting Extend the pill row or menu in TomodaCamera.native.tsx. All labels come from t('capture.*') (add keys to every locale bundle).
Change aspect ratio set AspectMode, ASPECT_ORDER, ASPECT_WH at the top of TomodaCamera.native.tsx.
Change EV range clamp The Math.max(-3, device.minExposure) / Math.min(3, device.maxExposure) in both the reticle rail and the EV panel.
Change camera format priority useCameraFormat filters in TomodaCamera.native.tsx.

Style tokens

  • Primary accent: #D4955A (Tomoda gold). Used for active pill states, slider fills, focal circle active border, EV rail thumb, filter chip selected border.
  • Reticle + rail lines: #FFFFFF at 75-90% opacity.
  • Background bands (aspect crop guides): rgba(0, 0, 0, 0.55).
  • Camera surface root: #000 regardless of theme. Photos read best on black.
  • Multiplier badge background: solid #000 so the circle border cleanly breaks through the badge.

i18n

New camera keys land in every locale bundle (frontend/i18n/locales/*.json) at the same time as the code. Never ship defaultValue; missing translations must show through as key strings during dev so they're caught. Current camera keys are namespaced under capture.*: exposure, focal_length, filter, flash, timer, flip, mirror, grid, level, aspect_ratio, more_options, tap_to_focus, show_raw, reset_knobs, exposure_reset, save_to_library, retake, tune, journal_it, permission_camera_title, permission_camera_blurb, permission_location_title, permission_location_blurb, permission_library_title, permission_library_blurb, grant_access, no_device.

iOS InfoPlist.strings per-locale files live in frontend/assets/locales/*/InfoPlist.strings and are the same set for 39 App Store languages. Camera + location + microphone permission strings live there.