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:
- Parse the
.cube(33-cube typically, 3-float RGB per lattice cell). - Upsample to 64-cube via trilinear interpolation, so the runtime shader uses the standard Hald-8 layout without an extra size uniform.
- Bake to
.png(8-bit RGBA, 512×512) viapngjs. 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_SRCfor recipes with a LUT.NO_LUT_SHADER_SRCfor 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¶
- Vision-camera presents the preview at the sensor's native format.
useCameraFormatpicks max photo resolution, max video resolution, 4:3 aspect.photoQualityBalance="quality"requests the highest- detail capture path.lowLightBoostis 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. - Tap shutter.
Haptics.impactAsync(Medium)fires immediately so the click is perceived as instant, even if the actual capture takes ~50ms. camera.current.takePhotoreturns aPhotoFilewith a native path and reported width / height.- Aspect crop. The saved photo dims come from
Image.getSize(rawUri)(respects EXIF orientation, unlikephoto.width/heightwhich 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 usesexpo-image-manipulatorwithSaveFormat.JPEG, compress: 0.95. onCapturehands the cropped URI + dims + GPS lat/lng to the route wrapper, which stores it inCaptureContext.rawPhotoUriand pushes to preview.
Preview + tune¶
preview.tsxmeasures the displayed URI (baked if ready, raw otherwise) viaImage.getSizeto derivephotoAR.- 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 isflex: 1withjustifyContent: 'center', centered in the middle area. - Bake runs on mount and re-runs when
recipechanges.bakePhotoWithLUTwrites toFileSystem.cacheDirectorywith abaked-${SHADER_VERSION}-${timestamp}.jpgfilename. The previously baked image stays visible during a re-bake, avoiding a flash. - 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.updatepreserves the recipe id. - Switch filters in
FilterStrip. The strip pre-computesinitialX = idx * (CHIP_WIDTH + CHIP_GAP) - CHIP_WIDTHand passes it viacontentOffsetso the strip opens already at the selected chip. Later selection changes usescrollTo({ animated: false }). Selection merges onlyid,label,lutId, preserving knobs. - RAW hold-to-preview on the top bar.
onPressInsetsshowRaw,onPressOutclears it.displayUriisphotoSrcwhile held, otherwisecapture.photoUri. - Reset on the top bar zeros all eight knobs without changing the base filter.
- 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 (1× / 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 (neutralZoom → zoomLensSwitchFactors[0],
minExposure → minExposureBias), 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:
#FFFFFFat 75-90% opacity. - Background bands (aspect crop guides):
rgba(0, 0, 0, 0.55). - Camera surface root:
#000regardless of theme. Photos read best on black. - Multiplier badge background: solid
#000so 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.