Commit Graph

108 Commits

Author SHA1 Message Date
admin 62b9735867 feat(exif): stamp RC_<ts> filenames + EXIF on capture and export 2026-09-11 17:59:44 +07:00
admin 6faef4aef7 feat(crop): two-step crop with APPLY + hidden amber border, persisted in session
- FRAME/CROP: choosing a ratio shows the amber band (border + 0.55 dim);
  APPLY collapses the preview to the crop rect with an opaque mask and
  removes the amber stroke; RESET returns to 'none'.
- Viewfinder: libCropView (k = min(vw/dw, vh/dh)) + cropScreenPx drive the
  mask/band, libViewMatrix folds the crop transform into the image groups,
  libCropTouchStyle keeps touch mapping aligned.
- Persist cropApplied in the session snapshot and restore it only when it
  still matches cropRatio.
- Add PLAN-2026-09-09.md with the measurements.

tsc --noEmit unchanged at 14 pre-existing errors.
2026-09-11 17:29:51 +07:00
admin 1b632ba78c feat(frame): read AUTO straighten from the photo, not the accelerometer
EXIF Orientation only knows 0/90/180/270, so a library still carries no
record of how the camera was held. The sensor had nothing to offer.

AUTO now measures the dominant line in the picture itself:
src/utils/horizon.ts runs a shear-projection search (coarse 1 deg over
-45..45, then a 0.5 deg refine) on a <=256px thumbnail, bails when no
line's score beats 3x the median, and returns the tilt in degrees.
App applies photoStraighten = -tilt, so preview and export share one
number exactly as the slider did.

Removes expo-sensors wiring, the horizonRoll state, effectiveStraighten
and the bubble-level overlay (autoRoll prop) from App/AdjustmentPanel/
Viewfinder. expo-sensors stays in package.json.
2026-09-11 14:46:28 +07:00
admin d6b3ebda40 fix(frame): take the bubble level off the image when leaving the strip
AUTO turns the amber level line on over the photo, but the line only left
when AUTO itself was switched off: switching to another frame chip, another
parameter or another rail tab kept it painted on the picture.

Gate the line on the ROTATE context instead: AdjustmentPanel reports whether
the ROTATE strip (or its STRAIGHTEN row) is open, App keeps autoRoll non-null
only then, and the frame chips now close the strip like every other chip
does. Verified on emulator-5554: AUTO on with the strip up shows the line
(row 1199-1203, 594 px); tapping LIGHT and coming back leaves it off.
2026-09-11 12:21:09 +07:00
admin da48ceccb5 fix(frame): make the ROTATE strip behave on switching parameters
Four follow-ups on the strip added for the quarter turns / straighten / AUTO.

AUTO is "snap to the sensor horizon": it now drops the manual STRAIGHTEN offset when it is switched on, so enabling it after a hand-set angle really levels the photo instead of leaving the angle untouched (the chip goes from "ROTATE 0 · +22°" to "ROTATE · AUTO").

The "<" back button on a slider row returns to the strip the row was opened from (STRAIGHTEN to ROTATE, COLOR TEMP to TEMP) instead of closing everything, and picking another parameter — a frame chip or the global RESET — closes the open row, so the straighten slider no longer outlives the parameter it belongs to.

Opening another photo from the library resets the turn, the fine straighten angle and AUTO with it: a new photo starts level.
2026-09-11 12:07:18 +07:00
admin 0ce561cb70 feat(frame): level the photo to the horizon with AUTO
The ROTATE strip gains an AUTO chip that reads the device tilt from expo-sensors and feeds it into the straighten angle while the library is open, so the export is levelled to the horizon. It sits right after RESET, combines with the manual STRAIGHTEN offset and with the quarter turns, and both RESET paths clear it.

The accelerator is sampled every 100 ms only while AUTO is on and the library is visible; near-flat and past 45 degrees readings are ignored so the level does not chase noise. AUTO rotates the image by +roll (a +10 degree emulator tilt exports 10 degrees clockwise), which is the sign that matches the preview.

A level line overlay is drawn in the preview: amber within one degree of level, white otherwise. Adds the expo-sensors dependency, so a native rebuild is required.
2026-09-11 11:35:48 +07:00
admin 9ddaeac260 feat(frame): give ROTATE quarter turns plus a fine straighten 2026-09-11 11:17:39 +07:00
admin 6788d51fb4 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT 2026-09-11 10:39:30 +07:00
admin dcb74a7243 feat(frame): pinch and drag the framed window on the camera
Two fingers scale the live feed inside a polaroid card / wall opening and
one finger drags it, both clamped to the window, and the shutter passes the
result to the export as frameWindowZoom so the printed crop matches what the
viewfinder showed. The crop centre is mapped through the same out-space ->
raw transform as the window rect; feeding it straight to the raw plane
swapped the axes on a rotated sensor.
2026-09-11 10:36:50 +07:00
admin 4131c9f401 feat(frame): move the watermark controls into a FRAME sub-panel 2026-09-11 10:00:10 +07:00
admin ba971f136c feat(panel): add a FAVORITED tab with a star on every recipe 2026-09-11 09:32:21 +07:00
admin ba7e579986 feat(panel): RESET shows a star while the look differs from a clean start 2026-09-11 09:08:37 +07:00
admin 7ed4301055 feat(panel): gather the white balance presets and COLOR TEMP into a TEMP chip 2026-09-11 08:59:49 +07:00
admin d66ae3dc59 feat(panel): collapse the film simulations into a PHOTO STYLE chip 2026-09-11 08:54:38 +07:00
admin 6adf0b5933 feat(sims): rename film simulations and bundled recipes
Sims: PROVIA->PROVIPES, VELVIA->VELVIPES, CLASSIC CHROME->CLASSIC CHRIPES, CLASSIC NEG->CLASSIC NEGIPES, ASTIA->ASTIPES, ETERNA->ETERNIPES, ACROS->ACRIPES, LEICA->LEITZ STREETLIFE.

Bundled recipes follow: PROVIPES STD, VELVIPES VIVID, ACRIPES MONO, CLASSIC NEGIPES. Ids and baseFilters untouched, so saved sessions keep resolving.
2026-09-11 08:44:58 +07:00
admin 7454ea1218 feat(panel): one RESET on every tab, back to the app's defaults
RESET was look-relative: it only put the sliders back on the values the
active recipe ships with, so the look itself (a saved recipe, a film sim)
survived the tap. It now restores the state a clean start leaves — the
PROVIA startup look, every parameter neutral, frame off, the custom and
GPS watermarks back to their defaults — and it sits on every tab, pinned
outside the scrolling chip row so it can never scroll out of reach.

Mode and aspect ratio stay put: they frame the shot being worked on, not
the look.
2026-09-10 22:18:43 +07:00
admin 02bc3aa4da feat(panel): editor keeps the values when the sim changes; one-tap RESET back to the look 2026-09-10 22:06:36 +07:00
admin 868ebfe6e2 feat(recipe): edit opens the prefilled create form; fields select their value on tap 2026-09-10 21:34:32 +07:00
admin b154e50191 feat(recipe): edit button on the top bar, with overwrite-or-save-as on save 2026-09-10 21:16:50 +07:00
admin 5186b2aab1 feat(sim): add a CLASSIC NEG chip to the film-sim presets 2026-09-10 21:16:50 +07:00
admin 91109cba54 feat(recipe): Save and Save As for recipes
Custom recipes can be overwritten in place (TopBar SAVE now offers CANCEL /
SAVE AS / SAVE and prefills the current name); bundled recipes stay read-only
and only offer Save As. Adds updateCustomRecipe to storageUtils.
2026-09-10 20:57:19 +07:00
admin 4bb2bda5c8 fix(sim): rebuild the six film-sim color matrices to match the stock looks
Provia/Velvia/Astia/Classic Chrome/Classic Neg/Acros each get their own
primaries+cross-talk matrix measured against the pipeline, plus a split-tone
stage (shadow/highlight tint uniforms) for Classic Chrome's teal and Classic
Neg's green-cyan shadows vs warm highlights.
2026-09-10 20:57:19 +07:00
admin 93f48537f8 feat(frame): CROP ratios in the library editor, and the framed-photo fixes
CROP chip on the FRAME tab for the library's plain frames: NONE, FREE
(drag the box) and the fixed ratios 1:1, 2:3, 3:2, 3:4, 4:3, 16:9. The
preview draws the keep-rectangle over the photo and the export honours it
pixel-for-pixel, pinching and dragging inside the band to pick the framing.

- Viewfinder: build frameRect from the normalised rect so the Skia canvas
  never sees undefined width/height (nothing to commit until now), keep
  the watermark drag target across the WATERMARK tab so a zoomed photo
  no longer resets, and derive the crop band + export rect from the photo
  fit rect.
- exportEngine: crop by fraction rect (or the ratio fallback) before the
  frame is composited.
- Leaving a plain frame clears the crop, and the ratio strip closes with
  the CROP chip it belongs to.
2026-09-10 20:12:03 +07:00
admin 299d81bade feat(wm): GPS watermark color + place-name/time toggles, "commune, city" locality 2026-09-10 18:05:57 +07:00
admin f13fd8e0f3 fix(library): drop the leftover zoom in the mark editor, re-anchor the framed crop
Two zoom-interaction bugs in the library viewer:

- Opening the WATERMARK editor now resets a photo zoom left over from the
  panel-closed pass. The zoom had shifted the mark's hit box along with the
  matrix and tripped the `libZoom.s <= 1.01` guards, so after visiting another
  tab and zooming, the mark could neither be dragged nor pinched.
- A polaroid/wall photo window that MOVES (deck grows: a chip slider or the
  watermark text field) now re-anchors the photo transform by the same u/v the
  export uses, so the crop inside the frame no longer slides away from the card
  (at 2.5x the content used to drift 2.5x the card's move).

Verified on device (9a6a7277): the mark drags again after tab-switch + zoom;
opening WATERMARK returns the photo to the pre-zoom framing; the framed crop
tracks the card within ~2 px over a 62 px deck-driven move.
2026-09-10 17:40:50 +07:00
admin f4a5b0c683 fix(wm): let the library editor pinch a watermark mark
onLibTouchStart only recorded the pinch baseline when both fingers arrived in
the same touch-start, so a second finger landing later (the normal case) left
wmPinchRef null and the move handler scaled nothing -- pinch-the-mark worked
on the camera viewfinder but not on a library photo. Seed the baseline in the
move handler, as the camera path already does.
2026-09-10 17:15:36 +07:00
admin 614ebb170b fix(gps): stop stamping the phone's current location on a picked photo
The library flow ended in a device-GPS fallback: whenever a photo's own
coordinates could not be read, getCurrentGPS() supplied the phone's present
spot, so the mark named wherever the user was standing instead of where the
photo was taken. Drop that tier entirely -- a photo with no readable location
gets no GPS mark. Also keep the legacy Android picker unconditionally so the
picked file keeps its EXIF GPS whatever the chip state, and prefer the city
over subAdminArea (county/district) as the displayed place name.
2026-09-10 17:08:09 +07:00
admin 423bf533f8 fix(gps): resolve place name when the watermark is switched on
A photo picked while the GPS chip was off kept its coordinates but no
locality (the geocode only ran when the chip was already on), and
handleToggleGeotag skipped GPS loading entirely when coordinates already
existed -- so switching the chip on showed STREET VIEW. Request location
permission inside reverseGeocode (expo-location's Android geocoder throws
LocationUnauthorizedException without it) and re-resolve the name from an
effect whenever the mark has coordinates but no place name. Both paths now
share localityName() so camera and photo stamps cannot disagree.
2026-09-10 17:01:48 +07:00
admin 67a2a52a6f Bump the app to version 1.2
versionName 1.2 / versionCode 2, kept in sync with app.json, package.json and the credits screen.
2026-09-10 16:43:03 +07:00
admin 11ed786758 Let the GPS watermark be placed, sized and hung landscape
Three WATERMARK/FRAME behaviours that share the stamp geometry:

* GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or
  smaller. The 0..1 anchor plus the size multiplier ride along in the export
  options, so a framed file (whose canvas IS the card/frame) burns the mark in
  at exactly the fraction the preview showed.
* The photo's own pinch zoom stays available while the WATERMARK editor is
  closed, so a two-finger zoom is never swallowed by the mark while its panel
  is open.
* WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs
  in its own 4000x3117 orientation instead of the default 90-degree rotation.
2026-09-10 16:35:35 +07:00
admin 47e424789d Revert "Zoom the finished photo with a double tap only"
This reverts commit 29b75ec3d3.
2026-09-10 16:06:23 +07:00
admin 29b75ec3d3 Zoom the finished photo with a double tap only 2026-09-10 15:59:15 +07:00
admin 6595e5cbc1 Let the GPS and custom watermarks be rotated 2026-09-10 15:56:06 +07:00
admin 2911dd57f0 Let the wall-frame photo be dragged inside its opening 2026-09-10 15:36:51 +07:00
admin 2835f46c37 Keep the dialled-in look when swapping film simulations 2026-09-10 15:16:21 +07:00
admin 045f5f2048 Keep a thumbnail of the finished export in library mode 2026-09-10 12:31:54 +07:00
admin e529c608f0 Keep the highlight slider off the greys, and start each mode clean
The highlight knee opened at 0.45, so dropping HIGHLIGHT pulled a mid-grey
down with the true highlights. It now opens at 0.65 (and the coefficient
drops 0.32 -> 0.22 to stay monotonic), so the slider leaves everything up to
a light grey untouched and still rolls the bright end off. Shadow is
untouched — its 0.00..0.55 knee was already right.

Switching between the library and the camera now drops the edits back to
the recipe's own values, no frame and no custom mark. A frame was picked
for the still it sat on and the mark was placed against that very photo, so
neither may follow the user across the switch (or into a live capture),
exactly like the sliders that were tuned on the photo in front of them.
2026-09-10 12:23:56 +07:00
admin bba2d76957 Keep the photo's colour when the highlight and shadow sliders move
Highlight/Shadow scaled R, G and B by one luma gain. That keeps the ratio
but crushes absolute chroma, so -SH turned a saturated blue into near-black
and -HL turned bright colours grey, and both sliders only bit at the very
ends of the range (knees 0.80..1.00 / 0.00..0.30) — they read as dead on
any photo without true whites or blacks.

The curve now moves the luma and carries the colour difference (rgb - luma)
along at clamp(gain, 0.55, 1.35), so hue survives darkening and lifting
instead of collapsing to black or white. Both knobs are pure additive luma
shifts with soft knees over the upper/lower half: the 0.50 midpoint moves
under 3%, and the curve stays monotonic (the old multiplicative form was
not — with hl=-1 a grey 0.73 came out darker than 0.80).

Kotlin applyTone port and the native bench probe gates follow.

Verified on device (4200x2800 probe: grey ramp + colour patches), library
export at ratio 4:3: -10 highlight leaves the darks alone and drops white
243->189 with the sky still blue; -10 shadow keeps blue as dark blue
(0,0,254)->(0,3,146), never black, ramp stays monotonic. Export keeps the
source 4200x2800 as well, so nothing is cropped.
2026-09-10 11:41:43 +07:00
admin 4ad26b514e Show library photos at their own aspect instead of cropping to the ratio band 2026-09-10 11:28:54 +07:00
admin 21e4932249 Reset the custom watermark when returning to the camera
The mark is placed against the library photo it was dragged on, but it
survived the switch to the live camera: the preview kept drawing it and
the next capture burned it in. Camera mode already drops the frame and
the library GPS for the same reason, so drop the accepted watermark too.

Resetting both the draft and the accepted copy keeps the chip honest and
stops the AsyncStorage session snapshot from restoring a camera mode
that still carries a library watermark.
2026-09-10 11:14:09 +07:00
admin e9de4203e9 Gate pinch zoom behind an existing zoom
A two-finger pinch only changes scale once the photo is already zoomed
in; at 100% the gesture is inert, so the paging strip keeps behaving
like a plain gallery. The responder is now held for the whole pinch so
the paging ScrollView cannot steal it mid-gesture.
2026-09-10 11:02:39 +07:00
admin 7c7a244b84 Rail without icons, FX glow chip renamed
The rail chips carried a lucide glyph before the label; drop the icons and
the icon field so the labels stand alone (the row is a plain text rail now,
and each chip is ~50px narrower). Also rename the FX knob from the
lens-vendor term to LIGHT GLOW - same key, same maths, just the label.
2026-09-10 10:44:33 +07:00
admin 6379c025c8 HDF on the live camera preview
Mirror export pass 5b in the onFrame worklet: extract the highlights with
the same 2.5x-1.5 contrast curve, blur that selection, Screen it back at
0.15..0.5 alpha. The selection must be built as an image filter so the
highlight curve runs before the blur (a paint color filter would run after,
smearing the matrix into the selection).

Kept the tone/cinema path intact: the base draw now records the shader it
used and the glow pass reuses it (drawRect) or falls back to the plain
drawImage, so the glow always sits on the same processed pixels.

Measured on the Xiaomi 2203121C: 5 interleaved 0/10 pairs, the HDF=10 mean
luma is +0.70..0.84 above HDF=0 every time (AE metering does not track the
software pass, so the toggle is the only variable), and frame counts are
unchanged (488/489 vs 492/488 frames per 15s; GPU busy rises ~30%, still
inside the 3 ms median GPU frame). No fps loss, so the pass stays.
2026-09-10 10:41:39 +07:00
admin 6c7d5b8f43 Keep the existing COLOR chip label
Reverts the COLOR -> SATURATION rename: the control already existed, so
it stays as it was.
2026-09-10 10:28:38 +07:00
admin 65d0850317 FX vignetting, LIGHT vibrance/saturation, reticle unlock
- FX: new VIGNETTING 0-10 control (chip + slider). Darkens the four
  corners of the photo with a radial gradient whose radius is the photo
  diagonal/2, so the falloff stays circular on any aspect. Applied in
  preview (camera overlay, polaroid/wall window, plain library) and in
  exportEngine pass 6b, always on the picture, never on the mat.
- LIGHT: new VIBRANCE -10..+10 (chroma-masked saturation in the tone
  shader: vivid pixels move least). Renamed the existing saturation
  control chip from COLOR to SATURATION - same param, clearer label.
- Fix: <Shader> takes a RuntimeEffect, not a gradient SkShader; the
  preview vignette threw "Object is not a HostObject" when it got one.
  Use the declarative <RadialGradient> child on the Rect instead.
- Preview: drop the "AE/AF LOCKED - TAP TO UNLOCK" banner; the lock
  reticle is now itself the unlock touch target.
2026-09-10 10:22:55 +07:00
admin d7a8e0681e Add CANCEL to the watermark editor, scope the mark's touch layer to that editor, and stop a library frame following the user onto the camera
The custom watermark captured EVERY image touch whenever the chip was ON
with text, in both modes: after switching from a library photo to the
camera there was no way to give the image back to tap-to-focus, and a
frame picked for the still rode along onto the live preview (and into the
capture).

- AdjustPanel: CANCEL chip next to ACCEPT. It drops the un-accepted draft
  and restores the last ACCEPTED mark (none if nothing was accepted).
- Viewfinder: draws the mark whenever the chip is ON with text, but only
  lets it CAPTURE touches while its editor is open (new wmEditing prop).
  A double-tap ON the mark still re-opens that editor from the library
  viewer.
- App: ACCEPT and CANCEL now close the editor (that closes the placement
  layer with it). Switching mode closes the deck panels, and leaves the
  frame behind: camera mode has no FRAME picker to undo one.

Verified on device (9a6a7277): camera tap locks AE/AF with the watermark
left ON; library polaroid no longer appears on the live preview after the
switch; CANCEL reverts the chip to CUSTOM WATERMARK OFF and closes the
panel; ACCEPT closes it and the next capture embeds the mark (amber text
found at 0.5/0.375 of the 3000x4000 export), while a capture before
ACCEPT carries none.
2026-09-10 09:58:50 +07:00
admin 28e18893cd Add HDF (Highlight Diffusion Filter) to the FX tab
The built-in lens filter, simulated the way it is done in Photoshop: pull the
highlights out with a hard contrast curve (out = 2.5*in - 1.5, black below 0.6),
Gaussian-blur that selection, then Screen it back over the photo at reduced
opacity. Screen against a near-black pixel is a no-op, so areas with no
highlight within the blur radius come back byte-identical while lit areas
spread their light.

One 0..10 knob drives both the radius (4%..1.9% of the width, ~10..50px at
2560 wide) and the opacity (0.15..0.5), matching the recipe's 15-50px radius
range. The extraction composes on top of the recipe colour matrix so a
monochrome look glows white instead of leaking the original colours back.

Runs in the Skia export engine only; the P5 native path is not wired into the
app (dev parity probe only), so it is left alone for now.
2026-09-10 09:45:51 +07:00
admin 3a8b110c1b Edit the watermark in place, rotate the frame photo, place a custom watermark
Watermark editing no longer covers the photo: the preview letterboxes the
image inside its fit rect while the deck is open, and the idle hint is gone.

The FRAME tab can rotate a library photo 90 degrees clockwise. Rotation
swaps the pixel dimensions instead of the frame, so the polaroid/wall window
keeps its aspect and every consumer (cover crop, watermark area, export) picks
up the new orientation from width()/height() with no extra layout branch. The
rotate surface is CPU-backed on purpose - a GPU snapshot loses its pixels
before the next frame and renders the photo black.

Custom watermark text can now be dragged anywhere on the photo, double-tapped
to re-edit, and is only baked into the file once ACCEPT is pressed; the export
engine takes the same position. It also gained COLOR/SIZE/FONT chips, with the
font list read from the OS via Skia's FontMgr so any installed family can be
used.
2026-09-10 09:26:55 +07:00
admin ff06045e02 Pinch/drag library photo inside polaroid & wall frame windows with export parity
The framed windows (polaroid card / wall frame opening) now take the same pinch + double-tap gestures as plain library, clamped so the photo keeps covering the mat opening, and the visible sub-rectangle is handed to the export engine so the file matches the preview.

Wall frame export also stopped crashing: on Android release an image asset resolves to a drawable *name* ('wallframe') and is flagged downloaded, so Asset.downloadAsync short-circuited and readAsStringAsync threw 'Unsupported scheme for location wallframe'. Clearing that state lets the native module copy the drawable into the cache.
2026-09-10 08:02:55 +07:00
admin c0c2f8532f Lift bottom deck above the keyboard so the custom watermark input stays visible
Edge-to-edge (edgeToEdgeEnabled) makes Android ignore the manifest's
adjustResize, so the IME never shrank the window and the text input sat
under the keyboard. Track keyboardDidShow/Hide height and pad the deck.
2026-09-10 07:44:03 +07:00