15 Commits

Author SHA1 Message Date
admin d5e5da49b7 chore(tools): keep the ISP NR/EDGE-off A/B as a patch
`git apply patches/vision-camera-isp-off.patch` turns NOISE_REDUCTION_MODE and
EDGE_MODE off for every still JPEG (HybridPhotoOutput.kt) and for the 0.5x path
(UltraWideModule.kt), which is how the two performance APKs were built. Apply
before assembleProRelease / bundleProRelease, revert after; the measured A/B
lives in isp-nr-off.md.
2026-09-26 11:37:53 +07:00
admin 588fbf77d9 perf(capture): trade CameraX MFNR for latency, average our own low-light burst off the shutter path
Measured on the 12S Ultra (2203121C), same scene, 1x, EV 0, 3 shots per arm:

- qualityPrioritization 'quality' -> 'balanced': tap->file 1013 ms vs 736 ms
  (-277 ms, -27%), settled 1552 ms vs 1280 ms, in-camera 521 ms vs 286 ms.
  Repeat on a second session: -228 ms. Image quality at ISO ~450 is inside the
  scatter of one arm (flat sigma +2.4%, edge +0.1%, texture -1.1%), so the MFNR
  pass quality would have bought is not measurable here. Also removes the
  EXPO_PUBLIC_CAPTURE_MODE seam: Metro inlines EXPO_PUBLIC_* in dev only, the
  release HBC build does not, so it could never gate shipped behaviour.
- Dark scenes now go through the app's own burst (ISO >= 100 gate for the probe)
  and mergeBurst runs in the export queue instead of on the shutter path, so a
  second shot is accepted while the frames are still being averaged: double-tap
  at 1.2 s used to drop the second photo, now it lands. Output stays the merged
  frame (flat sigma 0.825 vs 0.939 for a single frame).

Burst is skipped when the LED is the light source, when the shot came from the
native 0.5x session, for a held burst, for RAW, and below the devicePerf RAM
floor (deviceFloor.ts) where three raws plus the alignment rasters would be
seconds of shutter lag.

Bundles in the work this sits on: frame averaging (burstAlign/burstMerge),
the per-device floor, and the EXIF/ISO readback the burst gate needs (exifWrite,
photoMeta, exportEngine, RecipescamExportModule).

Self-checks: src/utils/{burstAlign,deviceFloor,exifWrite}.check.ts.
2026-09-26 11:37:48 +07:00
admin 9f7d83276d release: 1.2.7 (versionCode 9) for the high-ISO noise fix 2026-09-24 16:09:38 +07:00
admin fd38269709 fix(export): stop forcing a 0.5 full-res unsharp mask on every capture
App.tsx passed `sharpen: true` on camera exports, and both engines turned
that into a 0.5 unsharp mask whenever the SHARPENING knob sat at its
default 0. The 3x3 kernel [0,-a,0; -a,1+4a,-a; 0,-a,0] with a=0.5 has a
noise gain of 3.167x at low frequency and 5.0x at Nyquist, so high-ISO
files came back far noisier than the viewfinder ever showed.

The knob is now the only source of output sharpening: dropping the
fallback cuts flat-region noise sigma ~1.6x at ISO ~330 (2.36 vs 4.52)
and ~2.0x at ISO ~1900 (0.23 vs 0.46), and the JPEGs are 26-35% smaller.

Measured on a Xiaomi 12S Ultra, 3000x4000 captures, same scene:
  1x, ISO 335: sigma 2.35, dark-quartile 1.56, flat-tile 0.349
 10x, ISO 2119: sigma 1.09, dark-quartile 0.54, flat-tile 0.225

The SHARPENING knob still sharpens as before when dialled above 0.
2026-09-24 16:04:32 +07:00
admin 4863080dfe release: 1.2.6 (versionCode 8) for the B&W HIGH CONTRAST sim 2026-09-22 20:18:39 +07:00
admin 85b55dcf48 android: add the B&W HIGH CONTRAST film sim next to ACRIPES
Ported from the web studio's 3d273a3, and folded into this branch's
colour-only rule for sims.

- `mono-high-contrast` joins BaseFilter, with its own matrix (a warm B&W
  mix: red 0.38 / green 0.56 / blue 0.06, rows equal and summing to 1.00)
  and its own FILM_TONE entry (toe -0.32, shoulder +0.26 — the whites step
  of the brief). The sim ships with neutral adjustments like every other.
- The stock's own slope (+4 of Contrast) rides `STOCK_BIAS`, which absorbs
  the former `STOCK_EXPOSURE` table: one place for tone a stock carries by
  itself, keyed by baseFilter, so the EXPOSURE/CONTRAST sliders keep
  reading 0 while the render is already pushed.
- Two B&W stocks now exist, so every "is this a colour stock?" test goes
  through the new `isMonochromeBase` instead of comparing against
  'monochrome' — a stray Kelvin gain on a grey ramp shows up as a tint.
2026-09-22 19:35:25 +07:00
admin 4ec4260cbd release: 1.2.5 (versionCode 7) for the colour-only film sims
Bumps the version and the release notes for the sim change in c794283:
lc streetlife vivid keeps its fixed +2 exposure, every other sim now carries
colour and tone only.

RELEASE_NOTES.md gains the 1.2.5 entry (Presets) and the measured numbers:
a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, 1.1008 against
exposure +2's own 1.1000, while the sim's own exposure value stays 0.
PLAY_RELEASE_NOTES.md gains the Play listing block for 1.2.5 (318 chars,
under the 500 limit) and is tracked from here on.

Artifacts: lite AAB app-lite-release.aab (65.46 MB, versionCode 7,
com.locphamtran.recipescamera) for Play, pro APK app-pro-release.apk
(83.35 MB, 1.2.5-pro) for direct distribution.
2026-09-22 09:09:27 +07:00
admin c794283083 android: keep film sims colour-only, and fix the Vivid exposure in config
A sim is a stock, not an edit: its whole look must come out of the baseFilter
colour matrix and the FILM_TONE curve, so every entry in FILM_SIMS now carries
the neutral DEFAULT_ADJUSTMENTS verbatim. Dropped the effects that had been
riding along — grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and
exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.

The Vivid brightness is real, though, so it moves into STOCK_EXPOSURE in
colorUtils, keyed by baseFilter: a fixed exposure move of the look (2), the
same maths the EXPOSURE slider runs (1.04x + 0.03). Keyed by stock because that
is what the preview and both exporters know — baseFilter travels with every
recipe, an armed sim id does not. A sim's own adjustments stay neutral, so the
EXPOSURE slider reads 0 on it.

Also records the naming rule in the FILM_SIMS header: COLOUR AND TONE ONLY, and
the real stock each sim stands for (reference only, the UI keeps the sim names).
2026-09-22 08:29:37 +07:00
admin cbd0178cda android: port the web studio's HSL selective colour mixer
The rail gains an HSL tab between FX and FRAME, and the tab opens the mixer
ported from the web studio: eight hue bands, each with hue/sat/lum knobs on a
-10..+10 ruler, plus three whole-image moves behind an IMAGE divider. Band
weights are linear tents anchored on HSL_BANDS and summing to 1 at every hue, so
neighbouring bands hand over at exactly half strength and no hue falls in a gap.
Pixels under 8% saturation are gated out of the weights (rgb2hsl calls a neutral
red, so an ungated one would ride the RED band), while the whole-image LUM move
is added after the gate and still lifts a picture that -SAT has pulled to grey.
The mixer runs last in TONE_SKSL, so a band edit is judged on the colour the user
actually sampled.

The eyedropper is library-only: PICK arms a layer over the picture, a tap reads
the rendered pixel off the canvas, points the ruler at the nearest band and
hangs a panel carrying that band's three knobs. A live preview has no pixel to
read, so in camera mode the chip greys out - the mixer itself still runs there,
which is why the camera worklet's tone buffer had to grow from 10 slots to 40: a
missed slot would have pushed NaN into every uniform and taken the whole tone
pass down, not just HSL.

HSL is a LITE feature (the web studio builds it for guests too), and MONOCHROME
switches the mixer off outright - a mono stock has no hue to select. Checked on a
Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands
by +30 deg, -10 on one band's SAT flattens that band and leaves the other seven
alone, and a whole-image -SAT drops the camera preview's mean chroma 13.7 -> 0.4
with +LUM still lifting it 116 -> 166. Cut as 1.2.4 / versionCode 6.
2026-09-21 18:28:32 +07:00
admin c8e2b6ea5a android: add the CLASSIC VIVIDIPES sim and the FX MONOCHROME switch
Straight off PLAN-WEB-TO-ANDROID.md: the sim keeps Classic Chrome's blue row
verbatim and takes Velvia's red and green rows, so skies stay muted while
everything else reads loud, and it carries Chrome's shadow pull.

MONOCHROME is a switch, not a look: it swaps the base filter for the mono stock
and swaps right back, leaving adjustments untouched so a knob turned while it is
on survives the round trip. It goes through commit(), so UNDO undoes it.
2026-09-21 17:20:42 +07:00
3dtours 1e1ead6f54 android: scan the web recipe card's QR and import the look it points at
The landing page's QR card now carries a link to `GET
/api/photos/:id/preset.recipe`, so the app can pick a look up off a
screen instead of a file. PRESETS gains a SCAN QR chip next to IMPORT:
the code is decoded, checked against the preset route, fetched, and
stored through the same `storeImportedRecipe` path IMPORT already uses,
so an imported look and a scanned look land in one place.

Scanning runs on `expo-camera`, not the viewfinder's vision-camera:
v5's object output is iOS-only, Android's `createObjectOutput` throws.
That is a second camera library in the app, so the sheet pauses the
viewfinder (`paused` prop) — CameraX will not let two clients hold one
lens. A native rebuild is required for the new module.

The code holds a URL, never the recipe, so anything that is not the
preset route is refused by name rather than silently dropped. On a
release APK the plain-HTTP link is blocked by Android's cleartext
policy; debug builds have it on. Noted in 7_SCAN_QR.md along with the
web side, the regex, and the paused-viewfinder rule.
2026-09-18 17:01:34 +07:00
3dtours e0526283ca docs: note the two web looks that still have to reach Android
The web studio grew a CLASSIC VIVIDIPES sim (Classic Chrome's blue row spliced
to Velvia's red and green) and an FX MONOCHROME switch; both are written up
here with the exact matrices, anchors and the checks they have to pass before
they count as ported.
2026-09-18 16:17:07 +07:00
3dtours 99069aa837 docs: note the CREATE form's folds and the white/black point rows
The web form (recipes-web f17cb08) now folds its six categorical groups and
carries EXPOSURE, EV, WHITE and BLACK. WHITE/BLACK do not exist in
ColorAdjustments on either side, so the note spells out the two model fields
and the wh/bl uniforms the tone pass needs — an input row without them would be
dead. A TODO in RecipeCreateModal's header points here.
2026-09-18 12:32:13 +07:00
3dtours 82efc1f675 docs: note the strip that gets burned into the export
Spec for porting the web's caption band onto this branch: the amber #TAG
over the photo's top-left plus the dark band below carrying the recipe
name and the ISO / grain / warmth line. Values, geometry, colours and the
three draw paths are pinned to recipes-web @ b4d5d29, with the preview
explicitly left clean and the ISO taken from the source EXIF.
2026-09-18 10:57:00 +07:00
3dtours 2bfe8f1211 docs: note the highlight/exposure pass order bug
HIGHLIGHT -10 then EXPOSURE +10 brought the bright end straight back to 1.0:
a SkPaint runs its shader before its colourFilter, so the exposure matrix on
the same paint as the tone shader landed after the tone pass. The note pins
the three places that carry the wrong order (exportEngine.ts:328/354,
Viewfinder.tsx:1207/1303, RecipescamExportModule.kt steps 2-4), the numbers
measured on the fixed web engine, and the matrix -> tone -> cinema fix.
2026-09-18 10:34:27 +07:00
37 changed files with 3117 additions and 103 deletions
+234
View File
@@ -0,0 +1,234 @@
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
---
## 1. Hiện tượng
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
mất khả năng giữ highlight.
---
## 2. Nguyên nhân gốc
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
paint**, nên gain exposure chạy **sau** pass tone:
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
```ts
// dòng 328
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
// dòng 330 — comment mô tả SAI thứ tự thực tế:
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
// dòng 354
paint.setShader(toneShader);
```
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
Cùng lỗi, cùng một paint:
```ts
// dòng 1207
paint.setColorFilter(baseColorFilter);
// dòng 1303
paint.setShader(fullShader);
```
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
```ts
Skia.ImageFilter.MakeCompose(
glowBlur,
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
)
```
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
cùng một lỗi:
| Dòng | Pass |
|---|---|
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
---
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
| Trường hợp | top (p95) | mid |
|---|---|---|
| nguồn | 1.000 | 0.502 |
| HIGHLIGHT −10 | 0.780 | 0.518 |
| EXPOSURE +10 | 1.000 | 0.769 |
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
---
## 4. Cách sửa
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
### 4.1 `src/utils/exportEngine.ts`
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
```ts
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
```
Trong `renderPhoto`:
```ts
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
let toneShader: any = null;
let paintShader: any = null;
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) { own(snapshot); gradeImage = snapshot; }
}
const imageShaderOf = () =>
own(gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
if (toneIsActive(tone)) {
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
if (cinemaOn) {
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
if (!paintShader && toneShader) paintShader = toneShader;
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
// chỉ nhánh chưa grade mới cần colorFilter.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
```
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
```ts
const srcShader = paintShader ?? imageShaderOf();
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
// ...
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
```
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
tone/cinema shader lấy ảnh đó làm `imageShader` con.
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
`gradedTex.makeShaderOptions(...)`.
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
một-pass như hiện tại, đừng thêm surface.
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
bloom giờ đọc thẳng pixel đã grade.
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
curve*; đừng quay lại nhét exposure vào sau curve.
### 4.3 `RecipescamExportModule.kt` (native)
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
tại) phải chạy trước bước tone "2.":
```kotlin
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
bmp.recycle()
bmp = out
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
// 4. Cinema seasonal grade.
if (cinemaActive) applyCinema(bmp, cinemaFlat)
```
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
— bỏ câu "then the color matrix filter applies on top".
---
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
2. Export với `highlight = -10`, `exposure = +10`.
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
và so ảnh với nhau — parity gate của `PLAN.md`.
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
---
## 6. Ghi chú phụ
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.
+264
View File
@@ -0,0 +1,264 @@
# 5 — Đốt dải thông tin ("strip") vào ảnh xuất
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được.
---
## 1. Mục tiêu
Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe:
một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải
caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng
kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám).
Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing:
```html
<div class="lp-shot">
<img … />
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
</div>
<div class="lp-frame-meta">
<b>ACROS 100</b> <!-- tên recipe -->
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
</div>
```
Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css`
dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`.
**Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder /
màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần.
---
## 2. Giá trị lấy từ đâu
Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/App.tsx`, dòng ~658–671):
```ts
const name = (recipe.name || 'RECIPE').trim();
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
const tail = recipe.baseFilter === 'monochrome'
? 'MONO'
: `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
const strip = {
tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_'
title: name, // giữ nguyên hoa/thường
meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`,
};
```
- `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên.
- `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5).
- `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain
của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất.
- `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`).
- `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`.
---
## 3. Hình học & màu (chính xác từng pixel)
Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng,
trước encode). Mọi kích thước suy từ `W`:
| Đại lượng | Công thức |
|---|---|
| `band` | `round(W * 0.155)` |
| `pad` | `round(W * 0.03)` |
| `tagSize` | `round(W * 0.026)` |
| `titleSize` | `round(W * 0.042)` |
| `metaSize` | `round(W * 0.026)` |
Các bước vẽ, **theo đúng thứ tự**:
1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`.
2. **Tagline** trên ảnh, góc trên-trái, font mono:
- bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được
trên trời sáng),
- bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`.
3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`.
4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`.
5. **Dòng kỹ thuật** `#9a9a9a`, baseline
`(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên
`round(metaSize * 1.7)`.
Font: **Cousine Regular** — cùng typeface app đã bundle
(`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`).
Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`).
---
## 4. Ba đường phải sửa (parity gate)
Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**:
### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b)
Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917),
sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896):
```ts
const caption = options?.caption ?? null; // thêm vào ExportOptions
if (caption) {
const W = resultImage.width();
const band = Math.round(W * 0.155);
const capSurface = createSurface(W, resultImage.height() + band);
const loaded = watermarkFonts; // cùng typeface Cousine đã nạp
if (capSurface && loaded) {
own(capSurface);
const cc = capSurface.getCanvas();
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
const pad = Math.round(W * 0.03);
const tagSize = Math.round(W * 0.026);
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000'));
const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b'));
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
const bandTop = resultImage.height();
const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b'));
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
const titleSize = Math.round(W * 0.042);
const metaSize = Math.round(W * 0.026);
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2'));
const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a'));
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7),
metaPaint, metaFont);
const composed = own(capSurface.makeImageSnapshot());
release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác
resultImage = composed;
} else if (capSurface) {
capSurface.dispose();
}
}
```
`ExportOptions` thêm:
```ts
// Dải file mang theo: tagline amber trên ảnh + caption band bên dưới.
// Để trống cho preview/thumbnail — chỉ đường xuất truyền vào.
caption?: { tag: string; title: string; meta: string } | null;
```
Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không**
truyền vào đường preview.
### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native
Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption`
vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`):
```ts
caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file
```
Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)**
(~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`:
```kotlin
// 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh.
val cap = adjust["caption"] as? Map<*, *>
if (cap != null) {
val tag = cap["tag"] as? String
val title = cap["title"] as? String
val meta = cap["meta"] as? String
if (tag != null && title != null && meta != null) {
val W = render.width; val H = render.height
val band = Math.round(W * 0.155f)
val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888)
val cc = Canvas(taller)
cc.drawBitmap(render, 0f, 0f, null)
val pad = Math.round(W * 0.03f)
val tagSize = Math.round(W * 0.026f)
val titleSize = Math.round(W * 0.042f)
val metaSize = Math.round(W * 0.026f)
// Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular).
val face = geoTypefaceRes(ctx, "assets_cousineregular")
// tagline: shadow đen lệch 1px rồi amber
val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() }
val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() }
cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow)
cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber)
// dải nền tối
cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(),
Paint().apply { color = Color.parseColor("#0b0b0b") })
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() }
val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() }
cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint)
cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint)
render.recycle()
render = taller
}
}
```
Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó
(bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm
y hệt: bước 9b chạy sau khi frame đã ghép.
### 4.3 `src/components/Viewfinder.tsx` — preview
**Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình.
---
## 5. ISO từ EXIF
Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` →
`number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/engine/imageOps.ts`).
App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF
trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag
`0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã
nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`.
Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false`
(`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO
**của máy ảnh lúc chụp**, không phải một lựa chọn trong app.
---
## 6. Kiểm thử (bắt buộc trước khi coi là xong)
1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn
1200×900 → **1200×1086**.
3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel
`#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` /
`#9a9a9a` nằm trong dải dưới.
4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`.
Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline.
5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber
`#f59e0b` ngoài watermark vốn có).
6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b**
(`recipes-web` @ `b4d5d29`).
---
## 7. Ghi chú phụ
- Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất
chữ; nếu vẽ sau encode, file không còn pixel để vẽ.
- `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP
và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi.
- Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không
chồng lên các lớp đó.
- Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu
bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống,
để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không
có lưới cộng đồng thì cứ đốt thẳng vào file xuất.
+191
View File
@@ -0,0 +1,191 @@
# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create
form's groups and add the white/black point rows") — bản web đã làm, đã đo và
đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên
recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`,
tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại.
Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu
để đối chiếu từng dòng.
---
## 1. Mục tiêu
Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc:
1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT,
COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE.
2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK.
Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ
`EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`,
chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau).
---
## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`)
Web dùng `<details>`/`<summary>` gốc của trình duyệt: không state, không
thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm:
một `useState<Record<string, boolean>>` cho sáu nhóm, nhãn nhóm bấm được, mũi
tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở.
Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại):
| Nhóm | Hàng nằm trong |
| --- | --- |
| SIMULATION | dải chip `FILM_SIMS` |
| DYNAMIC RANGE | dải chip `DR_OPTS` |
| GRAIN EFFECT | dải chip `GRAIN_OPTS` |
| COLOR CHROME EFFECT | dải chip `CC_OPTS` |
| COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` |
| WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** |
Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT.
Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB.
Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp.
CSS web đã dùng (đối chiếu nếu muốn giống hệt):
```css
.create-sec { display: flex; flex-direction: column; gap: 6px; }
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
.create-sec > summary::-webkit-details-marker { display: none; }
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
.create-sec[open] > summary::before { transform: rotate(90deg); }
```
---
## 3. Việc 2 — bốn ô số
`fieldSets` hiện tại:
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
```
Web sau khi sửa (đúng thứ tự này):
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'WHITE', value: fWhites, set: setFWhites },
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
```
Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang
có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu
một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình:
```ts
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
exposure: int(fExposure, simAdj.exposure, -10, 10),
```
`EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong
model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là
`exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ.
---
## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader
Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô
nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi.
### 4.1 `src/types/index.ts` — `ColorAdjustments`
```ts
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
whites?: number; // -10 to +10 (white point: the very top of the tone curve)
blacks?: number; // -10 to +10 (black point: the very toe of the tone curve)
```
Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được.
### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL`
Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`):
```glsl
uniform float wh;
uniform float bl;
```
Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`:
```glsl
// White/black point. The HL/SH knees above already spend the slope budget
// (each just under 1.0), so these two use cubic end-weights instead of
// another smoothstep: only the last/first stretch of the ramp moves, and the
// derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve
// can never invert. + lifts that end (faded blacks / brighter whites),
// - digs it in (true black / rolled white).
o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t);
o += wh * 0.18 * t * t * t;
```
Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00
và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một
knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối
hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46.
### 4.3 Bốn chỗ còn lại trong cùng file
```ts
export interface ToneUniforms {
dr: number; hl: number; sh: number;
wh: number; // -1..1 (adjustments.whites / 10 — white point)
bl: number; // -1..1 (adjustments.blacks / 10 — black point)
vib: number; …
}
```
```ts
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
```
`getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
(không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và
`toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**:
```ts
return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
```
`dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới.
---
## 5. Kiểm thử (đo được, không đoán)
Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên
cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ
decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên
`whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của
điện thoại.
Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export,
preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng
một ảnh.
+158
View File
@@ -0,0 +1,158 @@
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
`http://localhost:8090`.
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
cái gì, và app đọc cái đó bằng cách nào.
---
## 1. Web đã mở ra cái gì (phía `recipes-web`)
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
thẻ QR trên landing trỏ tới một route công khai mới của backend:
```
GET /api/photos/:id/preset.recipe
```
| Trường hợp | Trả về |
| --- | --- |
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
envelope này (+12 check, backend 134/134).
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
---
## 2. Vì sao KHÔNG dùng vision-camera để quét
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
được: **vision-camera v5 trên Android không có object output.**
```text
modules/.../HybridCameraFactory.kt:112
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
```
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
```bash
npx expo install expo-camera # ~57.0.5
```
`app.json` thêm plugin trước `expo-sharing`:
```json
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
```
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
module mới; bundle JS cũ không có nó.
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
```xml
<!-- android/app/src/debug/AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... />
```
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
| Bản build | Quét thẻ QR |
| --- | --- |
| dev client / APK debug | chạy — cleartext được bật |
| APK release | `fetch` bị chặn, `Import failed` |
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
cài, nên chỉ thêm khi thật sự cần.
---
## 3. `src/components/QrScanModal.tsx` (mới)
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
không import mười lần. Mở lại sheet thì chốt reset.
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
chạm rơi xuống camera bên dưới.
---
## 4. Nối vào App
| File | Thay đổi |
| --- | --- |
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
| `app.json` | plugin `expo-camera` + câu xin quyền |
| `package.json` | `expo-camera ~57.0.5` |
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
```ts
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
```
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
custom như recipe import bằng file.
---
## 5. Kiểm thử
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
không có lỗi CameraX "camera already in use".
+227 -26
View File
@@ -10,23 +10,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import TopBar from './src/components/TopBar';
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
import AdjustmentPanel from './src/components/AdjustmentPanel';
import AdjustmentPanel, { HslPickPanel } from './src/components/AdjustmentPanel';
import CameraControls from './src/components/CameraControls';
import ToolRail, { TabId } from './src/components/ToolRail';
import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import LibraryPickerModal from './src/components/LibraryPickerModal';
import QrScanModal from './src/components/QrScanModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
import { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
// Types are erased at build time, so this one is free.
import type { PickedPhotoAsset } from './src/utils/photoGps';
import { PARAM_DEFS } from './src/utils/paramDefs';
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
import { readJpegIso } from './src/utils/exifWrite';
import { readPhotoBytes, devicePerf } from './src/utils/photoMeta';
import { BURST_FRAMES, BURST_MIN_ISO, mergeBurst } from './src/utils/burstMerge';
import { canBurst } from './src/utils/deviceFloor';
import UltraWide from './modules/recipescam-ultrawide';
import RecipescamExport from './modules/recipescam-export';
import VolumeShutter from './modules/recipescam-volume';
@@ -54,13 +60,6 @@ const CAMERA_SETTINGS_KEY = 'camera_settings';
// decides which engine ships.
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
// against ~540 ms for 'quality', with no resolution loss, because this device
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
// gate shipped behaviour on it.
// How long a shutter may wait for a GPS fix before exporting without it. The
// photo is never held hostage by the receiver: indoors a Balanced fix takes
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
@@ -76,10 +75,6 @@ const loadRenderer = (native: boolean) =>
native
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
const CAPTURE_MODE =
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
? process.env.EXPO_PUBLIC_CAPTURE_MODE
: 'balanced';
export default function App() {
const [mode, setMode] = useState<'camera' | 'library'>('camera');
@@ -143,6 +138,16 @@ export default function App() {
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
// they live here.
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the picture the sample was read: fractions of the image, the
// floating panel's anchor.
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
// it and `handleToggleGeotag` refuses to turn it on.
@@ -277,6 +282,9 @@ export default function App() {
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
// it is paused while it is up (one lens, two camera libraries).
const [scanVisible, setScanVisible] = useState(false);
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
// the viewer anymore — the thumbnail button does.
const [settingsVisible, setSettingsVisible] = useState(false);
@@ -327,7 +335,17 @@ export default function App() {
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
qualityPrioritization: CAPTURE_MODE,
// LATENCY, measured: 'balanced' is CAPTURE_MODE_MINIMIZE_LATENCY (one
// frame, no CameraX still-image post-processing); 'quality' is
// CAPTURE_MODE_MAXIMIZE_QUALITY, which adds in-camera multi-frame noise
// reduction plus reprocessing. Same scene at ISO ~410 on the 12S Ultra,
// 3 shots per arm: in-camera START->onImageCaptured 291 ms vs 499 ms,
// tap->file on disk 797 ms vs 1025 ms (-22%), and the two JPEG sets were
// indistinguishable (flat-area sigma 1.08 vs 1.09, edge energy -3%).
// Dark scenes are handled by this app's own burst + merge below (ISO >=
// 800), so CameraX MFNR would only ever be a second, redundant pass.
// Revert to 'quality' if a high-ISO A/B ever shows merge losing to MFNR.
qualityPrioritization: 'balanced',
});
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
@@ -760,6 +778,29 @@ export default function App() {
lastStepRef.current = 0;
setUndoDepth(0);
};
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
// the mono stock and swaps right back. Adjustments are never touched, so a knob
// turned while it is on survives the round trip.
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
const monoPrevRef = useRef<Recipe | null>(null);
const handleToggleMono = () => {
commit(true, () => {
if (selectedRecipe?.baseFilter === 'monochrome') {
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
monoPrevRef.current = null;
setSelectedRecipe(back);
} else {
monoPrevRef.current = selectedRecipe;
setSelectedRecipe({
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
id: 'mono',
name: 'MONOCHROME',
baseFilter: 'monochrome',
});
}
});
};
const applyLook = (l: Look) => {
setSelectedRecipe(l.recipe);
setAdjustments(l.adjustments);
@@ -849,6 +890,40 @@ export default function App() {
});
};
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
// field goes back to `undefined` (not {}) when the last one leaves: the
// shallow differs() behind resetDirty then reads a clean mixer as clean.
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
};
// The mixer's three whole-image knobs: one door for all three, so the ruler
// and the field it writes are the same value wherever they were opened.
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
};
// The eyedropper's report: print the colour it read, point the ruler at that
// colour's band, hang the panel on the spot it came from, and put the tool
// away — one pick, one colour.
const pickColor = (
rgb: { r: number; g: number; b: number },
at: { fx: number; fy: number }
) => {
setHslSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setHslPickedAt(at);
setPicking(false);
};
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
const applyFrame = (frameId: FrameId) => {
setSelectedFrame(frameId);
@@ -1028,6 +1103,18 @@ export default function App() {
}
};
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
// picked off storage or fetched from a scanned link — becomes a normal custom
// recipe, and the look on screen switches to it.
const storeImportedRecipe = async (xml: string) => {
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
};
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
// storage, decoded and stored as a normal custom recipe.
const handleImportRecipe = async () => {
@@ -1038,13 +1125,30 @@ export default function App() {
try {
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
if (picked.canceled) return;
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
};
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
// photo's own `.recipe` file, so a scan is the same import with the file
// fetched instead of picked. The code holds a URL, never the recipe, so
// anything that is not the preset route — a shop link, a wifi code — is
// refused with the reason. The sheet closes either way; a failure is a
// dialog, not a scanner left running.
const handleScanRecipe = async (value: string) => {
setScanVisible(false);
if (liteSaveBlocked) {
promptRecipeLimit();
return;
}
try {
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
@@ -1394,6 +1498,56 @@ export default function App() {
)
).filePath;
}
// Low light, hand-held: one frame is mostly sensor noise, and no OEM stack is
// reachable from here (the extension probe found the vendor pipelines closed to a
// third-party client). So take the frames and average them ourselves. Four ways
// out, checked before an extra exposure is spent: the LED is the light source
// (a burst would ride it while it recharges), the ultra-wide still came from the
// native Camera2 session, a held burst is the user asking for ONE frame, and RAW
// needs the second ImageCapture output CameraX will not bind alongside this one.
// canBurst: on a 1 GB floor device (deviceFloor.ts) three raws plus the
// alignment rasters are seconds of shutter lag, so the plain frame stands.
// Set when the low-light burst below starts its merge. The merge is not
// awaited on the shutter path: the export task awaits it instead, so the
// next shot is accepted while the frames are still being averaged.
let burstMerge: Promise<string | null> | null = null;
if (!ultraWideShot && !opts?.burst && !rawEnabled && !(flashAvailable && flashMode !== 'off') && canBurst(devicePerf().ramMb)) {
try {
// ISO is the only signal this app can read for ambient light: the controller
// reports 0.0 for every still, so it comes off the file the shutter just
// wrote. No tag, no burst.
const jpeg = await readPhotoBytes(toUri(filePath));
const iso = jpeg ? readJpegIso(jpeg) : null;
if (iso !== null && iso >= BURST_MIN_ISO) {
const frames = [filePath];
for (let i = 1; i < BURST_FRAMES; i++) {
const extra = await photoOutput.capturePhotoToFile(
// No second shutter sound and no flash: three clicks for one photo
// reads as a malfunction.
{ enableShutterSound: false, flashMode: 'off' },
{},
);
frames.push(extra.filePath);
}
// Averaging three 12 MP frames costs ~1.4 s measured on the 12S Ultra
// (three decodes, two alignment stages, one draw pass, one JPEG
// encode) and all three frames are already in hand, so the merge
// rides the FIFO export queue below instead of the shutter path:
// the next shot is accepted as soon as the exposures land, and only
// the export of this photo waits on the average. A refused merge
// (null: drift too large, a frame unreadable) keeps the shutter frame.
// ponytail: a merge in the queue and a following capture can each
// hold a 12 MP frame (~48 MB); the FIFO serializes the merge against
// the render, not against the camera. Re-serialize if a low-RAM body
// starts swapping.
burstMerge = mergeBurst(frames);
}
} catch (burstError) {
// A failed burst costs two wasted exposures, never the photo.
console.warn('Low-light burst skipped:', burstError);
}
}
const sourceUri = toUri(filePath);
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
@@ -1454,12 +1608,14 @@ export default function App() {
// shutter instant as the EXIF capture date.
exported: false,
capturedAt,
// 300dpi header + full-res screen sharpen. No evFromCamera: the camera
// AE bias is never written, so the export applies the same software EV
// gain the preview shows.
// 300dpi header. No evFromCamera: the camera AE bias is never written,
// so the export applies the same software EV gain the preview shows.
// No forced output sharpen either: the 0.5 unsharp mask multiplied
// sensor noise ~3.2x (5x at Nyquist), so dim/high-ISO captures came
// back far noisier than the preview. The SHARPENING knob still
// sharpens when the user dials it above 0.
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
dpi: 300,
sharpen: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
watermark: wmForExport(),
liteMark,
@@ -1471,7 +1627,10 @@ export default function App() {
frameWindowZoom: frameZoom,
};
const paint = await loadRenderer(nativeExport);
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
// The burst average lands here, in the queue: the export waits for it,
// the shutter did not. sourceUri is the frame the shutter already wrote.
const source = burstMerge ? toUri((await burstMerge) ?? filePath) : sourceUri;
return paint(source, recipeArgs, selectedFrame, useGeotag, captureGps, options);
};
enqueueExport(async () => {
try {
@@ -1779,6 +1938,10 @@ export default function App() {
const handleToggleTab = (tab: TabId) => {
setOpenTab((prev) => (prev === tab ? null : tab));
setOpenParamKey(null);
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
// and the layer goes with it, so an invisible pick layer cannot sit over
// the picture swallowing pan and zoom.
setPicking(false);
};
// While a panel parameter row is open, a vertical drag anywhere on the live
@@ -1821,6 +1984,9 @@ export default function App() {
photoStraighten={shown.photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
// One lens, two camera libraries: while the QR sheet is up its own
// scanner owns the camera and this session has to let go.
paused={scanVisible}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
@@ -1855,6 +2021,22 @@ export default function App() {
setOpenParamKey(null);
setWmEditNonce((n) => n + 1);
}}
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
// it is handed; the state behind both is the HSL row's, above.
picking={picking}
hslPanelAt={hslPickedAt}
onPickColor={pickColor}
hslPanel={
openTab === 'hsl' && hslSample && hslPickedAt ? (
<HslPickPanel
sample={hslSample}
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
onKnob={setBandKnob}
onClose={() => setHslPickedAt(null)}
/>
) : undefined
}
/>
</View>
@@ -1927,6 +2109,8 @@ export default function App() {
openParam={openParamKey}
onOpenParamChange={setOpenParamKey}
currentBaseFilter={selectedRecipe?.baseFilter}
monoOn={monoOn}
onToggleMono={handleToggleMono}
onCreateRecipe={handleCreateRecipe}
editingRecipe={editingRecipe}
onCloseEdit={() => setEditingRecipe(null)}
@@ -1939,6 +2123,7 @@ export default function App() {
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onImportRecipe={handleImportRecipe}
onScanRecipe={() => setScanVisible(true)}
recipeLimitReached={liteSaveBlocked}
onRecipeLimit={promptRecipeLimit}
onDeleteRecipe={handleDeleteRecipe}
@@ -1952,6 +2137,14 @@ export default function App() {
wmRotation={shown.wmRotation}
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
onWmPanelChange={setWmPanelOpen}
hslBand={hslBand}
onHslBand={setHslBand}
picking={picking}
onPicking={setPicking}
hslSample={hslSample}
onSetHslGlobal={setHslGlobal}
// A live preview has no pixel to read, so PICK is library-only.
canPick={mode === 'library'}
/>
</View>
)}
@@ -1965,6 +2158,14 @@ export default function App() {
onSelect={handleLibraryPicked}
/>
{/* QR sheet: reads a recipe link off another screen (the landing page's
card) and imports the file it points at. */}
<QrScanModal
visible={scanVisible}
onCancel={() => setScanVisible(false)}
onScanned={handleScanRecipe}
/>
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
+123
View File
@@ -0,0 +1,123 @@
# Ghi chú port web → Android: sim `CLASSIC VIVIDIPES` + chip FX `MONOCHROME`
Nguồn: web studio `docker/frontend` (nhánh `recipes-web`), commit `d7d7be2` (sim mới) và
`4d0d441` (chip FX). Việc dưới đây là **port sang app Android** (`src/`, nhánh này).
Đối chiếu: `src/` giữa `recipes-web` và `feat/vision-camera-v5` **chỉ khác**
`src/components/RecipeCreateModal.tsx` (+9 dòng) — nên port gần như là chép y nguyên.
Số dòng dưới đây tính theo nhánh này.
---
## 1. Sim mới `CLASSIC VIVIDIPES`
Ghép tại dòng blue: **giữ nguyên dòng blue của `classic-chrome`**, lấy **dòng R/G của `velvia`**.
Sửa 4 file:
1. `src/types/index.ts:65` — `BaseFilter`: thêm `'classic-vivid'`.
2. `src/utils/colorUtils.ts` — thêm nhánh **ngay sau** nhánh `classic-chrome` (dòng 111):
```ts
} else if (baseFilter === 'classic-vivid') {
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
// same muted teal/cyan — with the red and green rows taken from Velvia, so
// everything that is not blue reads loud.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
0, 0, 0, 1, 0,
];
```
3. `src/utils/defaultRecipes.ts:43` — `FILM_SIMS`, thêm ngay sau `sim-classic-chrome`:
```ts
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
```
4. `src/utils/toneShader.ts:190` — `FILM_TONE`, cạnh `'classic-chrome'`:
```ts
'classic-vivid': { sh: -0.28 },
```
(Dìm shadow thuộc về stock chứ không thuộc dòng ma trận → đi kèm. Bỏ dòng này nếu muốn
VIVIDIPES không dìm shadow.)
**Không phải sửa gì thêm**: chip `PHOTO STYLE` (`AdjustmentPanel.tsx` `groupDefs.style`), TopBar
(`activeRecipe.name`) và picker trong `RecipeCreateModal.tsx` đều map từ `FILM_SIMS`.
**Kiểm chứng**: strip PHOTO STYLE có **10** sim; chọn `CLASSIC VIVIDIPES` → look khác **cả**
`CLASSIC CHRIPES` lẫn `VELVIPES`; `CLASSIC CHRIPES` vẫn nguyên vẹn cạnh nó.
---
## 2. Chip `MONOCHROME` ở tab FX (`filters`)
Đây là **công tắc**, không phải một look: bật thì đổi `baseFilter` sang `monochrome`, tắt thì
trả lại đúng cái ảnh đang mặc (kể cả tên sim). **Không được đụng `adjustments`** — knob chỉnh
trong lúc đang bật phải sống sót.
Web: `docker/frontend/src/App.tsx` — `monoPrev` ref + `toggleMono`, chip `mono` đứng đầu
`case 'fx'`, `active: monoOn` với `monoOn = recipe.baseFilter === 'monochrome'`.
Android cần 2 chỗ:
**a. `src/components/AdjustmentPanel.tsx`** — `case 'filters'` (dòng 888):
```ts
case 'filters':
return chipRow([
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
...paramChips(paramDefs.filters),
]);
```
+ thêm 2 prop vào `interface AdjustmentPanelProps`: `monoOn: boolean;` và `onToggleMono: () => void;`
+ truyền từ `App.tsx` chỗ render `<AdjustmentPanel … />` (`App.tsx:1929` có `currentBaseFilter`,
`onSelectRecipe` — đặt cạnh đó).
**b. `App.tsx`** — Android giữ `adjustments` là state **riêng** (không nằm trong `selectedRecipe`),
nên công tắc chỉ chạm `selectedRecipe`:
```ts
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
// the mono stock and swaps right back. Adjustments are never touched.
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
const monoPrevRef = useRef<Recipe | null>(null);
const handleToggleMono = () => {
commit(true, () => {
if (selectedRecipe?.baseFilter === 'monochrome') {
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
setSelectedRecipe(back);
} else {
monoPrevRef.current = selectedRecipe;
setSelectedRecipe({
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
id: 'mono',
name: 'MONOCHROME',
baseFilter: 'monochrome',
});
}
});
};
```
Lưu ý:
- `commit(true, …)` (dòng 739) để công tắc vào **undo stack** như mọi edit khác — `handleResetAll`
dùng đúng cách này.
- `Look` đã có `recipe` → UNDO tự trả lại đúng `baseFilter` cũ, không cần state phụ nào khác.
- `id: 'mono'` không trùng sim nào nên khi bật, chip PHOTO STYLE **không** còn sáng tên sim cũ
(`groupDefs.style.value` tính từ `currentRecipeId` so với `FILM_SIMS`).
- Tắt khi `monoPrevRef` rỗng (ví dụ vào monochrome bằng cách chọn sim ACRIPES, không qua công tắc)
→ rơi về PROVIA, tức là "tắt là có màu", không phải về đen trắng.
**Kiểm chứng**: bật → ảnh xám (mọi pixel `R=G=B`; chỉ lệch khi WB khác 5500K hoặc tint khác 0);
tắt → ảnh trở lại **y hệt** trước khi bật (so hash pixel); đổi `CONTRAST` trong lúc bật/tắt →
giá trị vẫn giữ; UNDO một nhịp → về màu, chip trở lại trạng thái tắt.
---
## 3. Test web đã chạy (tham chiếu — nằm trong scratchpad của agent, không nằm trong repo)
- `sim-vivid-test.cjs` — **9/0**: chip có mặt, strip đủ 10 sim, chọn được, render khác cả
CHRIPES lẫn VELVIPES.
- `fx-mono-test.cjs` — **15/0**: đo trực tiếp kênh màu trên pixel preview (spread `>25` khi màu,
`<=6` khi mono), hash quay lại y hệt khi tắt, CONTRAST sống sót, UNDO gỡ mono.
- Regression web quanh 2 thay đổi: `panel`, `layout`, `fonts 27/0`, `create 52/0`, `share 22/0`,
`photo-save 19/0`, `redo 30/0`, `accent-brand` — tất cả xanh.
+2
View File
@@ -82,6 +82,8 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
`cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch`
(artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound).
`cd RecipesCam && git apply patches/vision-camera-isp-off.patch`
(artifact = 3 hunk, 2 file: `HybridPhotoOutput.kt` cho mọi still JPEG, `modules/recipescam-ultrawide/.../UltraWideModule.kt` cho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
## Rủi ro / ghi chú
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`).
+64
View File
@@ -0,0 +1,64 @@
# Play release notes
Text for Google Play Console → Release → "What's new". One entry per version, newest
first. Plain text only (the Console renders no markdown), 500 characters max per
language — each block below is measured and fits. Paragraphs are one line each so a
copy-paste lands in the Console unwrapped.
Release name field: `1.2.7 (9)`
## 1.2.7 (versionCode 9)
```
Photos taken in dim light and at high ISO came out much noisier than the preview showed. Every shot was being sharpened in the background by a fixed amount the viewfinder never applied, and it amplified sensor noise about three times over.
That hidden sharpening is gone, so high-ISO shots now come back as clean as the preview promised. The SHARPENING slider still sharpens exactly as before when you move it above 0.
```
419 characters (1.2.7).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 9, versionName 1.2.7).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.7-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.6 (versionCode 8)
```
New film simulation B&W HIGH CONTRAST, next to ACRIPES: a warm-filter black-and-white mix, with deeper blacks and lifted whites, so the greys take a harder push.
Its contrast comes from the stock itself, so the EXPOSURE and CONTRAST sliders stay at 0 while the shot renders pushed.
```
281 characters (1.2.6).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 8, versionName 1.2.6).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.6-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.5 (versionCode 7)
```
Film simulations are now pure colour and tone: each one is the stock's own colour matrix and curve, with no grain, clarity, exposure or saturation baked in - those are your edits to make.
LC STREETLIFE VIVID keeps its brightness from the stock itself, so the EXPOSURE slider stays at 0 while the shot comes out lifted.
```
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 7, versionName 1.2.5).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.5-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.4 (versionCode 6)
```
HSL selective colour is here: eight hue bands (red, orange, yellow, green, aqua, blue, purple, magenta), each with its own HUE, SAT and LUM, plus HUE/SAT/LUM IMAGE knobs that move every colour at once.
In the library, tap PICK and touch any colour on the photo: a panel opens on that spot with the three knobs of the band it belongs to.
The tool rail is reordered: presets, favorites, white balance, light, effects, HSL, frame.
```
427 characters (1.2.4).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 6, versionName 1.2.4).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.4-pro` — it is the paid build handed out directly, not uploaded here.
+47
View File
@@ -1,5 +1,52 @@
# Release notes
## 1.2.7 (versionCode 9)
Export
- Every camera capture came out of the exporter through a full-resolution unsharp mask the viewfinder never showed. App.tsx passed `sharpen: true`, and with the SHARPENING knob at its default 0 both engines read that flag as an *amount* of 0.5 - a value that existed only as the boolean's fallback. The 3x3 kernel `[0,-a,0; -a,1+4a,-a; 0,-a,0]` at a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so a dim, high-ISO frame was pushed back several times noisier than the preview promised.
- The knob is now the only source of output sharpening. The boolean is gone from ExportOptions and NativeExportOptions, the 0.5 fallback is gone with it, and screenSharpenImage no longer carries `amount = 0.5` as a default argument - a second door onto the same value. A knob above 0 sharpens exactly as before, the negative knob still means none, and the Kotlin-side `adjust["sharpen"]` amount is untouched.
- Measured on a Xiaomi thor, 3000x4000 captures in one unchanged scene. At 1x / ISO ~330 the noise sigma drops 4.52 -> 2.35, the dark-quartile sigma 3.21 -> 1.56 and a flat 64px tile 0.599 -> 0.349; at 10x / ISO ~1900 the same three fall 1.22 -> 1.09, 0.75 -> 0.54 and 0.464 -> 0.225. With no edit applied, the JPEGs shrink 26% and 35% - the mask had also been spending a third of every file on amplified noise.
- Stills now match the preview, which always rendered without the mask.
Notes
- qualityPrioritization was re-tested as the other suspect and cleared: MAXIMIZE_QUALITY ('quality') moved neither noise figure at either ISO, so the shipped 'balanced' capture mode stays.
- Zoom to 10x in the same room is the high-ISO bench for this device: AE picks ISO ~1900-2100 there against ~330 at 1x.
## 1.2.6 (versionCode 8)
Presets
- New film sim B&W HIGH CONTRAST, next to ACRIPES. Acros' grey ramp taken through a warm filter mix - red 0.38 / green 0.56 / blue 0.06, the three rows equal and summing to 1.00, so a neutral stays the neutral the sensor recorded and nothing clips at either end - and then pushed at both ends: the toe goes to -0.32 against Acros' -0.12, and the shoulder LIFTS to +0.26 instead of rolling off. Midtones see neither mask, so the long smooth stretch between the two ends survives, which is what keeps that hard a push off the posterised look.
- The stock's own slope (+4 of Contrast) is not in the sim's adjustments: the panel still reads EXPOSURE 0 / CONTRAST 0, and FX still reads GRAIN 0 / CLARITY 0, while the render comes out pushed. It rides STOCK_BIAS in colorUtils.ts, keyed by baseFilter - the old STOCK_EXPOSURE table folded in, one place for the tone a stock carries by itself (LC STREETLIFE VIVID exposure +2, B&W HIGH CONTRAST contrast +4).
- Two black-and-white stocks exist now, so every "is this a colour stock?" test goes through the new isMonochromeBase(): saturation, white balance and the R/B fine-tune in the matrix, Color Chrome in the tone shader. A second B&W stock can no longer end up half excluded - a stray Kelvin gain on a grey ramp shows up as a tint.
- Measured against the compiled tables: a 0.25 grey goes 0.2500 -> 0.2250 (the +4 slope pivoting on mid-grey 0.5), a full red goes 0.213 -> 0.368 while blue drops to 0.016, and colour-stage knobs moved hard (saturation +8, white balance 9000K, wbR +5 / wbB -5) leave both B&W matrices byte-identical.
- On a Xiaomi thor: the chip sits right after ACRIPES, its preview reads mean chroma 0.00 against 17.48 for a colour sim, and the frame's luma spread widens as designed (sd 92.3 / p95 245 against ACRIPES' 88.9 / 238).
## 1.2.5 (versionCode 7)
Presets
- A film sim is a stock, not an edit: every entry in FILM_SIMS now carries the neutral defaults verbatim, and its whole look comes from the stock's own colour matrix plus its FILM_TONE curve. The effects that had been riding along are gone - grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
- LC STREETLIFE VIVID is brighter because the stock is brighter, not because a slider was moved: the +2 exposure lives in STOCK_EXPOSURE (colorUtils.ts), keyed by baseFilter, where the preview and both exporters already read it. It never reaches the panel, so EXPOSURE still reads 0 on that sim.
- The naming rule is recorded in the FILM_SIMS header: colour and tone only, plus a reference table of the real stock each sim stands for. Reference only - the UI keeps the sim names.
- Measured against the compiled tables: a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, a ratio of 1.1008 against exposure +2's own 1.1000, while the sim's own exposure value stays 0.
## 1.2.4 (versionCode 6)
HSL
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
- Band weights are linear tents anchored on the 8 hues and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
Rail
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
Notes
- HSL is a LITE feature: both builds get it, no PRO gate.
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
## 1.2.3 (versionCode 5)
Shutter
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.locphamtran.recipescamera'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 5
versionName "1.2.3"
versionCode 9
versionName "1.2.7"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+7 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "RecipesCam",
"slug": "recipescamera",
"version": "1.2.3",
"version": "1.2.7",
"orientation": "portrait",
"icon": "./RecipesCamIcon.png",
"userInterfaceStyle": "light",
@@ -49,6 +49,12 @@
"isAccessMediaLocationEnabled": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
}
],
"expo-sharing"
],
"extra": {
@@ -1,5 +1,6 @@
package com.locphamtran.recipescamera.export
import android.app.ActivityManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@@ -206,6 +207,20 @@ class RecipescamExportModule : Module() {
)
}
// Floor-device gate (src/utils/deviceFloor.ts): total RAM decides whether a
// full-resolution surface chain and the 3-frame burst fit. One cheap query
// and no permission; a platform that will not answer returns 0, which JS
// reads as "unknown" and keeps the full-quality path.
Function("devicePerf") { ->
val am = appContext.reactContext?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val mem = ActivityManager.MemoryInfo()
val known = if (am != null) { am.getMemoryInfo(mem); true } else false
mapOf(
"ramMb" to (if (known) (mem.totalMem / (1024L * 1024L)).toInt() else 0),
"cores" to Runtime.getRuntime().availableProcessors(),
)
}
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
// real file path back to JS, which then runs decodeEncodeAsync on it. This
+7
View File
@@ -105,6 +105,13 @@ export interface RecipescamExportModule {
* codename, marketing name, manufacturer, model. Empty strings when absent.
*/
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
/**
* Device facts the capture and render paths budget against (see
* src/utils/deviceFloor.ts): total RAM in MB and CPU core count. `ramMb` is 0
* when the platform refused the read — callers treat 0 as "unknown", never as
* a floor device, so an unmeasurable phone keeps the full-quality path.
*/
devicePerf(): { ramMb: number; cores: number };
/**
* The image a system share handed this launch (AndroidManifest
+76
View File
@@ -14,6 +14,7 @@
"babel-preset-expo": "~57.0.0",
"expo": "~57.0.7",
"expo-asset": "~57.0.5",
"expo-camera": "~57.0.5",
"expo-dev-client": "~57.0.6",
"expo-document-picker": "~57.0.2",
"expo-file-system": "~57.0.1",
@@ -2042,6 +2043,12 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2448,6 +2455,15 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/barcode-detector": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz",
"integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==",
"license": "MIT",
"dependencies": {
"zxing-wasm": "3.1.3"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3380,6 +3396,26 @@
"react-native": "*"
}
},
"node_modules/expo-camera": {
"version": "57.0.5",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.5.tgz",
"integrity": "sha512-0JaUXfDWMytmqm4pVKesZfLTSjAumfS2UGhnc1M4tqJ0qATCS0jxc7KjaoyKEzWJY+FEc8GlPCAGprS/7tNWZQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "^3.0.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "57.0.6",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
@@ -7420,6 +7456,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -7976,6 +8024,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zxing-wasm": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz",
"integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.8.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@
"babel-preset-expo": "~57.0.0",
"expo": "~57.0.7",
"expo-asset": "~57.0.5",
"expo-camera": "~57.0.5",
"expo-dev-client": "~57.0.6",
"expo-document-picker": "~57.0.2",
"expo-file-system": "~57.0.1",
+65
View File
@@ -0,0 +1,65 @@
diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
index 883fa50..e0248e8 100644
--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
+++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
@@ -1,7 +1,10 @@
package com.margelo.nitro.camera.hybrids.outputs
+import android.hardware.camera2.CameraMetadata
+import android.hardware.camera2.CaptureRequest
import android.media.MediaActionSound
import android.util.Log
+import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.resolutionselector.ResolutionSelector
@@ -108,6 +111,21 @@
// Capture Mode (Quality, Speed, ...)
val captureMode = options.qualityPrioritization.toCaptureMode()
setCaptureMode(captureMode)
+
+ // The ISP's own noise reduction and edge enhancement, off for every
+ // still (patches/vision-camera-isp-off.patch, the shutter-silent
+ // patch's twin). The vendor pair is tuned to hide sensor noise in a
+ // screen-sized JPEG and charges the fine detail for it; this app
+ // sharpens and grains the file itself with Skia, so it would rather
+ // have the texture and the noise than the wax. Camera2Interop because
+ // the CameraX builders have no typed setter for either key. DNG never
+ // passes through the ISP, so the option is a no-op on the RAW output.
+ Camera2Interop.Extender(this)
+ .setCaptureRequestOption(
+ CaptureRequest.NOISE_REDUCTION_MODE,
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF,
+ )
+ .setCaptureRequestOption(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
if (options.previewImageTargetSize != null) {
setPostviewEnabled(true)
diff --git a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
index c4bf38d..39308e3 100644
--- a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
+++ b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
@@ -8,6 +8,7 @@ import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
+import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
@@ -554,6 +555,15 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
req.set(CaptureRequest.FLASH_MODE, flashMode(flash))
req.set(CaptureRequest.CONTROL_AE_MODE, flashAeMode(flash))
+ // The ISP's own noise reduction and edge enhancement, off for the
+ // ultra-wide still too (patches/vision-camera-isp-off.patch), so 0.5x
+ // lands in the same file as the other lenses: the app sharpens and
+ // grains with Skia, it does not want the vendor wax.
+ req.set(
+ CaptureRequest.NOISE_REDUCTION_MODE,
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF
+ )
+ req.set(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
// The live preview carries the EV as a hardware AE bias (see setEv); the
// file must not, or the lens bias and the export's software gain would
// stack into a double EV. Pin the bias off and let the precapture trigger
+240 -3
View File
@@ -4,7 +4,8 @@ import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio, HslBand, HslBandId } from '../types';
import { HSL_BANDS, hslToHex, rgbToHsl } from '../utils/colorUtils';
import { FRAMES } from '../utils/frameUtils';
import { PRO_FRAMES } from '../utils/entitlement';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
@@ -17,7 +18,7 @@ import RecipeCreateModal from './RecipeCreateModal';
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey =
@@ -80,6 +81,10 @@ interface AdjustmentPanelProps {
// Base filter of the currently applied recipe — seeds the CREATE modal's
// SIMULATION row so quick-input starts from the active look.
currentBaseFilter?: BaseFilter;
// FX tab MONOCHROME: a switch, not a look — on it swaps the base filter for
// the mono stock, off it hands back the one that was there.
monoOn: boolean;
onToggleMono: () => void;
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
@@ -116,6 +121,9 @@ interface AdjustmentPanelProps {
// IMPORT: pick a recipe file shared from another phone and store it as a
// normal custom recipe.
onImportRecipe: () => void;
// SCAN QR: read the recipe link on the landing page's card with the camera
// and import the file it points at — same store, no file picker.
onScanRecipe: () => void;
// LITE: this many of its own recipes exist, so the next one is the paid
// step. The strip's trailing + chip reads it, the save paths gate on it.
recipeLimitReached: boolean;
@@ -145,6 +153,21 @@ interface AdjustmentPanelProps {
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
onWmPanelChange: (open: boolean) => void;
// HSL tab. The mixer's UI state lives in App because the eyedropper in the
// Viewfinder writes into the same four values this row reads: which band the
// ruler is on, whether PICK is armed, and the colour it last read.
hslBand: HslBandId;
onHslBand: (id: HslBandId) => void;
picking: boolean;
onPicking: (on: boolean) => void;
hslSample: { r: number; g: number; b: number } | null;
// The three IMAGE knobs are whole-picture moves, not a band's, so they go
// through one entry point — the ruler and the field it writes cannot then
// disagree about which one moved.
onSetHslGlobal: (which: 0 | 1 | 2, v: number) => void;
// PICK has nothing to read off a live preview, so in camera mode the chip
// greys out rather than arming a layer that would do nothing.
canPick: boolean;
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
// one parameter, and both left this panel: BACK is a button in the top bar,
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
@@ -162,6 +185,7 @@ interface ChipDef {
onPressStar?: () => void;
disabled?: boolean; // informational label, not tappable
amberValue?: boolean; // amber text driven by edited value, not selection
color?: string; // swatch dot: a chip naming a colour has to show it
pro?: boolean; // a PRO feature: LITE draws the corner tag on it
}
@@ -180,6 +204,11 @@ interface ParamDef {
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// The eyedropper's floating panel is a fixed width so the Viewfinder can hang
// it centred on the picked point (it measures the height itself, which the
// three slider rows decide).
const HSL_PANEL_W = 244;
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
// which chip is active — the last tapped preset is remembered to disambiguate.
@@ -240,6 +269,8 @@ export default function AdjustmentPanel({
openParam,
onOpenParamChange,
currentBaseFilter,
monoOn,
onToggleMono,
onCreateRecipe,
editingRecipe,
onCloseEdit,
@@ -258,6 +289,7 @@ export default function AdjustmentPanel({
onToggleWallLandscape,
onToggleGeotag,
onImportRecipe,
onScanRecipe,
recipeLimitReached,
onRecipeLimit,
onDeleteRecipe,
@@ -271,6 +303,13 @@ export default function AdjustmentPanel({
wmRotation,
onRotateWm,
onWmPanelChange,
hslBand,
onHslBand,
picking,
onPicking,
hslSample,
onSetHslGlobal,
canPick,
}: AdjustmentPanelProps) {
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
// input again so the text can be retyped after the keyboard was dismissed.
@@ -411,6 +450,17 @@ export default function AdjustmentPanel({
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
activeOpacity={0.7}
>
{c.color ? (
<View
style={{
width: 10,
height: 10,
borderRadius: 999,
backgroundColor: c.color,
marginRight: 6,
}}
/>
) : null}
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
{c.label}
</Text>
@@ -496,6 +546,26 @@ export default function AdjustmentPanel({
: p
),
filters: toRows(PARAM_DEFS.filters),
// The mixer's three whole-image knobs are not PARAM_DEFS entries: they
// read and write the picture's own move rather than a flat field of
// adjustments, and unlike the panel on the image they do not belong to the
// band the ruler points at. Bound here so the existing slider row, its
// amber value and its back button all work unchanged.
hsl: [0, 1, 2].map((i) => {
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
return {
key: i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l',
label: `${name} IMAGE`,
value: own[i],
default: 0,
min: -10,
max: 10,
step: 1,
display: (v: number) => (v > 0 ? `+${v}` : String(v)),
onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v),
};
}),
// FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE
// strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's.
frame: [
@@ -839,6 +909,12 @@ export default function AdjustmentPanel({
active: false,
onPress: onImportRecipe,
},
{
key: 'scan',
label: 'SCAN QR',
active: false,
onPress: onScanRecipe,
},
],
8
);
@@ -886,7 +962,46 @@ export default function AdjustmentPanel({
]);
case 'filters':
return chipRow(paramChips(paramDefs.filters));
// MONOCHROME leads the strip: a switch that swaps the base filter for
// the mono stock and swaps right back, so it never touches a knob.
return chipRow([
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
...paramChips(paramDefs.filters),
]);
case 'hsl': {
// PICK arms the eyedropper; the eight colour chips pick the band the
// ruler edits; the three knobs below the divider move the whole
// picture. Row above picks ONE colour, the IMAGE knobs move them all.
return chipRow(
[
{
key: 'hsl-pick',
label: 'PICK',
active: picking,
disabled: !canPick,
onPress: () => onPicking(!picking),
},
...HSL_BANDS.map((b): ChipDef => {
const band = a.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `hsl-band-${b.id}`,
label: b.label,
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onPress: () => onHslBand(b.id),
};
}),
// Divider: the row above is colour, the knobs after it are the
// whole image.
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
...paramChips(paramDefs.hsl),
],
8
);
}
case 'frame':
// FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames).
@@ -954,9 +1069,42 @@ export default function AdjustmentPanel({
// ---- the single open slider row (ABOVE the chips row) ----------------------
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
// HSL readout: what the eyedropper last saw, and the band the ruler is on.
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
const sampleHsl = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
return (
<View>
{activeTab === 'hsl' && (
<View className="flex-row items-center px-3 py-1">
{hslSample && sampleHsl ? (
<>
<View
style={{
width: 22,
height: 22,
borderRadius: 4,
borderWidth: 1,
borderColor: '#3f3f46',
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})`,
}}
/>
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
</Text>
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
{Math.round(sampleHsl.h)}° · {Math.round(sampleHsl.s * 100)}% ·{' '}
{Math.round(sampleHsl.l * 100)}%
</Text>
<Text className="ml-2 font-mono text-[11px] font-bold text-amber-400">{bandName}</Text>
</>
) : (
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
</Text>
)}
</View>
)}
{openParamDef && (
<View
key={openParamDef.key}
@@ -1101,3 +1249,92 @@ export default function AdjustmentPanel({
</View>
);
}
// The floating panel the eyedropper hangs over the picked spot: the sample
// colour after the selected band's knobs, its name and hex, and the three
// knobs that edit that band. Lives here rather than in the Viewfinder so it
// reuses this file's Slider row and haptics; App builds the node and hands it
// to the Viewfinder, which only positions it.
export function HslPickPanel({
sample,
bandLabel,
band,
onKnob,
onClose,
}: {
sample: { r: number; g: number; b: number };
bandLabel: string;
band: HslBand;
onKnob: (which: 0 | 1 | 2, v: number) => void;
onClose: () => void;
}) {
// What this band does to THIS colour, not to the wheel in general: the same
// +10 on HUE lands differently on olive and on sky.
const picked = rgbToHsl(sample.r, sample.g, sample.b);
const mixed = hslToHex(
picked.h + band[0],
Math.max(0, Math.min(100, picked.s * 100 + band[1])),
Math.max(0, Math.min(100, picked.l * 100 + band[2]))
);
return (
<View
className="rounded-xl border border-zinc-700/80 bg-black/90 px-3 py-2"
style={{ width: HSL_PANEL_W }}
>
<View className="flex-row items-center">
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: '#3f3f46',
backgroundColor: mixed,
}}
/>
<View className="ml-2 flex-1">
<Text className="font-mono text-[11px] font-bold text-amber-400">{bandLabel}</Text>
<Text className="font-mono text-[10px] text-zinc-400">{mixed.toUpperCase()}</Text>
</View>
<TouchableOpacity
onPress={() => {
haptic();
onClose();
}}
className="p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
activeOpacity={0.7}
>
<Text className="font-mono text-sm font-bold text-zinc-400">✕</Text>
</TouchableOpacity>
</View>
{(['HUE', 'SAT', 'LUM'] as const).map((name, i) => (
<View key={name} className="flex-row items-center">
<Text className="w-9 font-mono text-[10px] font-bold tracking-widest text-zinc-400">
{name}
</Text>
<Slider
style={{ flex: 1, height: 30 }}
value={band[i]}
minimumValue={-10}
maximumValue={10}
step={1}
onValueChange={(v) => onKnob(i as 0 | 1 | 2, v)}
onSlidingComplete={haptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
<Text
className={`w-7 text-right font-mono text-[10px] font-bold ${
band[i] !== 0 ? 'text-amber-500' : 'text-zinc-500'
}`}
>
{band[i] > 0 ? `+${band[i]}` : band[i]}
</Text>
</View>
))}
</View>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Modal, Text, TouchableOpacity, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
// SCAN QR: reads the code on the landing page's recipe card and hands the
// decoded string to App, which fetches the `.recipe` file it points at. The
// code carries a link, never the recipe itself, so the camera only ever has to
// frame a few dozen bytes.
//
// This is the app's second camera on purpose: `expo-camera` (Android's own
// barcode scanner) instead of the vision-camera session the viewfinder uses,
// because vision-camera v5's object output is iOS-only — Android's
// `createObjectOutput` throws "CameraObjectOutput is not available on
// Android!". App keeps the two from fighting over the lens by pausing the
// viewfinder while this sheet is up (`Viewfinder paused`).
interface QrScanModalProps {
visible: boolean;
onCancel: () => void;
onScanned: (value: string) => void;
}
export default function QrScanModal({ visible, onCancel, onScanned }: QrScanModalProps) {
const [permission, requestPermission] = useCameraPermissions();
// One code per opening: the scanner reports every frame that holds a code, so
// the first one trips this latch and the rest are dropped until the sheet is
// opened again.
const takenRef = useRef(false);
useEffect(() => {
if (!visible) return;
takenRef.current = false;
if (permission && !permission.granted && permission.canAskAgain) requestPermission();
}, [visible, permission, requestPermission]);
const handleScanned = useCallback(
(result: BarcodeScanningResult) => {
if (takenRef.current) return;
takenRef.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="fade" statusBarTranslucent onRequestClose={onCancel}>
<View className="flex-1 bg-black">
{permission?.granted ? (
<CameraView
style={{ flex: 1 }}
facing="back"
active={visible}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleScanned}
/>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-xs text-center leading-5">
CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE
</Text>
</View>
)}
{/* Reticle. Pointer-transparent so the touch goes to the camera. */}
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<View className="w-64 h-64 rounded-2xl border-2 border-amber-500/80" />
<Text className="mt-6 text-amber-500 font-mono text-xs tracking-widest">
POINT AT A RECIPE QR
</Text>
</View>
<TouchableOpacity
onPress={onCancel}
accessibilityLabel="Close the QR scanner"
className="absolute top-12 right-6 w-10 h-10 rounded-full bg-black/60 border border-zinc-700 items-center justify-center"
>
<X color="#ffffff" size={20} />
</TouchableOpacity>
</View>
</Modal>
);
}
+9
View File
@@ -18,6 +18,15 @@ import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
// plain textboxes (typed fast, one row each), categorical knobs are chip
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
//
// TODO (chưa làm — xem `6_CREATE_COLLAPSE.md`, bản web đối chiếu commit
// f17cb08 của nhánh recipes-web):
// 1. Gấp sáu nhóm: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME
// EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE (WB SHIFT đi kèm WB).
// 2. Thêm bốn ô số: EXPOSURE, EV (đổi tên từ EXPOSURE COMP.), WHITE, BLACK.
// WHITE/BLACK chưa có trong `ColorAdjustments` — phải thêm cả hai field
// (src/types/index.ts) và cặp uniform `wh`/`bl` trong `src/utils/toneShader.ts`
// (cubic end-weights), nếu không hai ô đó là ô chết.
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+3 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import * as Haptics from 'expo-haptics';
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
interface ToolRailProps {
activeTab: TabId | null;
@@ -12,9 +12,10 @@ interface ToolRailProps {
const TOOLS: { id: TabId; label: string }[] = [
{ id: 'recipes', label: 'PRESETS' },
{ id: 'favorites', label: 'FAVORITED' },
{ id: 'iq', label: 'LIGHT' },
{ id: 'wb', label: 'WB' },
{ id: 'iq', label: 'LIGHT' },
{ id: 'filters', label: 'FX' },
{ id: 'hsl', label: 'HSL' },
{ id: 'frame', label: 'FRAME' },
];
+174 -17
View File
@@ -27,8 +27,11 @@ import {
FilterMode,
MipmapMode,
FontStyle,
ColorType,
AlphaType,
useCanvasRef,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Lock, Pipette } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader';
@@ -65,6 +68,10 @@ interface ViewfinderProps {
// a nonce so the panel puts the caret back in its input).
onWmEdit?: () => void;
cameraPermissionGranted: boolean;
// Another camera session wants the lens (the QR scanner's own sheet): the
// live session stops until it is up, exactly as it does for the app going
// to the background — two CameraX clients cannot hold one camera.
paused?: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
@@ -159,6 +166,18 @@ interface ViewfinderProps {
frameTabActive?: boolean;
// WALL FRAME hung landscape (4000x3117) instead of portrait.
wallLandscape?: boolean;
// HSL eyedropper. The pick layer covers exactly the picture and reads ONE
// pixel off the rendered frame — the colour the user sees, after every other
// knob — not the file's. Library only: a live preview has no pixel to read.
picking?: boolean;
onPickColor?: (
rgb: { r: number; g: number; b: number },
at: { fx: number; fy: number }
) => void;
// The band panel the pick hangs at `hslPanelAt` (0..1 fractions of the
// image). The caller builds it, this viewer only places it.
hslPanel?: React.ReactNode;
hslPanelAt?: { fx: number; fy: number } | null;
}
export interface ViewfinderHandle {
@@ -303,6 +322,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
availableHeight,
topInset,
cameraPermissionGranted,
paused = false,
onRequestCameraPermission,
photoOutput,
aspectRatio,
@@ -331,6 +351,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
photoStraighten = 0,
wmRotation = 0,
wallLandscape = false,
picking = false,
onPickColor,
hslPanel,
hslPanelAt = null,
}: ViewfinderProps,
ref
) {
@@ -491,12 +515,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Tone uniforms [dr, hl, sh, vib, shadow split RGB, highlight split RGB] for
// the preview worklet; all-zero = no tone pass. A stock may keep the pass on
// with every user knob at 0 (Classic Neg split tone, Classic Chrome crush).
// Tone uniforms for the preview worklet; all-zero = no tone pass (see
// toneUniformArray for the layout: dr/hl/sh/vib, the two split-tone triples,
// the two Chrome depths, then the HSL mixer's hslOn + 8x3 bands + gh/gs/gl).
// A stock may keep the pass on with every user knob at 0 (Classic Neg split
// tone, Classic Chrome crush).
const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter));
// Seeded with the FULL toneUniformArray length: the worklet reads this buffer
// positionally, so a short one hands the mixer's slots `undefined` -> NaN
// uniforms -> the whole tone pass (not just HSL) dies on the live preview.
const toneSync = useMemo(
() => createSynchronizable<number[]>(new Array(10).fill(0)),
() => createSynchronizable<number[]>(new Array(40).fill(0)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@@ -1270,7 +1299,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
tone[10] !== 0 || tone[11] !== 0);
tone[10] !== 0 || tone[11] !== 0 ||
tone[12] !== 0); // hslOn: the eight bands live in 13..36, the global moves in 37..39
const hasCinema = cinemaEffect != null && cinema[0] > 0;
// Draws the full frame with the tone/cinema shader when active (drawRect,
// not drawImage: paint shaders only apply to geometry fills — drawImage
@@ -2560,15 +2590,77 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
};
// The view zoom composes OVER the committed crop (outer·inner), exactly like
// the canvas draws it.
const libCropTouchStyle = touchZoomStyle(
libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom
const libTouchZoom = libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom;
const libCropTouchStyle = touchZoomStyle(libTouchZoom);
const libTouchZoomStyle = touchZoomStyle(libTouchZoom);
// ---- HSL eyedropper -------------------------------------------------------
// The pick layer sits exactly over the picture, so a touch reaches it instead
// of the pan/zoom layer below (it is mounted after that one). A touch maps to
// a CANVAS pixel through the same zoom/crop transform the touch layer
// carries, and the one pixel under it is read off the rendered frame — the
// colour the user sees, not the file's original.
// ponytail: one snapshot per pick session, read with readPixels(1 px). If the
// delay ever shows on a real device, decode the skia image once instead — the
// sample is then the UNgraded colour, which is a different feature.
const canvasRef = useCanvasRef();
const pickShotRef = useRef<{ width: () => number; height: () => number; readPixels: any } | null>(
null
);
const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null);
const [pickPanelSize, setPickPanelSize] = useState({ w: 0, h: 0 });
// The picture as drawn (the camera's ratio band is a camera-only concept, so
// in the library the fit rect IS the picture).
const pickRect = mode === 'library' && !regionBand ? imageFitRect : null;
useEffect(() => {
if (!picking) {
pickShotRef.current = null;
setPickPos(null);
return;
}
// The frame has settled a beat after the chip press: snapshot once here and
// every tap of the session reads off that same frame.
const id = setTimeout(() => {
pickShotRef.current = canvasRef.current?.makeImageSnapshot() ?? null;
}, 150);
return () => clearTimeout(id);
}, [picking]);
const onPickTouch = (e: any) => {
const r = pickRect;
if (!r || !(r.dw > 0 && r.dh > 0)) return;
const { locationX: lx, locationY: ly } = e.nativeEvent;
if (!(lx >= 0 && lx <= r.dw && ly >= 0 && ly <= r.dh)) return;
setPickPos((p) => (p && Math.abs(p.x - lx) < 1 && Math.abs(p.y - ly) < 1 ? p : { x: lx, y: ly }));
const shot = pickShotRef.current ?? canvasRef.current?.makeImageSnapshot() ?? null;
if (!shot) return;
pickShotRef.current = shot;
// The layer and the picture carry the same transform, so the touch reaches
// the canvas the same way: layout units, then the snapshot's own density.
const dens = shot.width() / Math.max(vw, 1);
const cx = Math.max(
0,
Math.min(shot.width() - 1, Math.floor((libTouchZoom.s * (r.dx + lx) + libTouchZoom.tx) * dens))
);
const cy = Math.max(
0,
Math.min(shot.height() - 1, Math.floor((libTouchZoom.s * (r.dy + ly) + libTouchZoom.ty) * dens))
);
const px = shot.readPixels(cx, cy, {
width: 1,
height: 1,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
});
if (!px || px.length < 3) return;
onPickColor?.({ r: px[0], g: px[1], b: px[2] }, { fx: lx / r.dw, fy: ly / r.dh });
};
// Export-facing summary of the current framed-window photo reposition. The
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
@@ -2871,7 +2963,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive && !ultraWideOn}
isActive={appActive && !ultraWideOn && !paused}
outputs={cameraOutputs}
pixelFormat="yuv"
targetResolution={frameTarget}
@@ -2886,7 +2978,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
turning one on means turning the other off, never both at once. */}
<UltraWidePreview
style={StyleSheet.absoluteFill}
active={appActive && ultraWideOn}
active={appActive && ultraWideOn && !paused}
cover={uwCover}
/>
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
@@ -3009,6 +3101,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
hlTb: toneParams[9],
cc: toneParams[10],
ccb: toneParams[11],
// The mixer's arrays are uniform ARRAYS (`float hslH[8]`), so they go in as
// eight-number slots off the same flat buffer the camera and export use.
hslOn: toneParams[12],
hslH: toneParams.slice(13, 21),
hslS: toneParams.slice(21, 29),
hslL: toneParams.slice(29, 37),
gh: toneParams[37],
gs: toneParams[38],
gl: toneParams[39],
};
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
@@ -3220,7 +3321,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Canvas ref={canvasRef} style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
@@ -3307,6 +3408,62 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderRelease={onLibTouchEnd}
onResponderTerminate={onLibTouchEnd}
/>
{/* HSL eyedropper: the layer covers the picture alone and takes the
gesture whole, so pan and zoom cannot fire from inside it. */}
{picking && pickRect ? (
<View
style={[
{
position: 'absolute',
left: pickRect.dx,
top: pickRect.dy,
width: pickRect.dw,
height: pickRect.dh,
},
libTouchZoomStyle,
]}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onPickTouch}
onResponderMove={onPickTouch}
>
{pickPos ? (
<View
pointerEvents="none"
style={{ position: 'absolute', left: pickPos.x - 24, top: pickPos.y - 24 }}
>
<Pipette size={22} color="#ffffff" />
</View>
) : null}
</View>
) : null}
{/* The band panel, hung on the spot the colour was read from. Placed
in SCREEN coordinates so it keeps its size while the picture is
zoomed, and it drops below the point unless the point is low
enough for the panel to fall off the picture. */}
{hslPanel && hslPanelAt && pickRect ? (
<View pointerEvents="box-none" className="absolute inset-0">
<View
style={{
position: 'absolute',
left: libTouchZoom.s * (pickRect.dx + hslPanelAt.fx * pickRect.dw) + libTouchZoom.tx,
top: libTouchZoom.s * (pickRect.dy + hslPanelAt.fy * pickRect.dh) + libTouchZoom.ty,
transform: [
{ translateX: -pickPanelSize.w / 2 },
{ translateY: hslPanelAt.fy > 0.55 ? -(pickPanelSize.h + 14) : 14 },
],
}}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
setPickPanelSize((p) =>
p.w === width && p.h === height ? p : { w: width, h: height }
);
}}
>
{hslPanel}
</View>
</View>
) : null}
</>
) : (
<View className="items-center justify-center p-6">
+18 -1
View File
@@ -62,7 +62,17 @@ export const MIN_CROP_FRAC = 0.08;
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none';
// `monochrome` is Acros' grey ramp; `mono-high-contrast` is the B&W HIGH
// CONTRAST stock's own — both are black-and-white, so anything asking "is this
// a colour stock?" must ask it through isMonochromeBase (colorUtils.ts)
// rather than comparing against one of these two strings.
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none';
// Selective colour mixer: the eight hue bands the HSL tab edits, and the
// [hue, sat, lum] triple each band carries (-10..+10 per knob). A band left at
// [0,0,0] is dropped from the recipe rather than stored.
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
export type HslBand = [number, number, number];
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
@@ -84,6 +94,13 @@ export interface ColorAdjustments {
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
// Selective colour: per-band [hue, sat, lum] moves, -10..+10. One nested field
// rather than 24 flat ones — that is what makes it the single adjustment the
// recipe importer has to sanitize by hand (see sanitizeHslBands).
hslBands?: Partial<Record<HslBandId, HslBand>>;
hslHue?: number; // -10..+10 whole-image hue turn
hslSat?: number; // -10..+10 whole-image saturation scale
hslLum?: number; // -10..+10 whole-image lightness offset
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
+107
View File
@@ -0,0 +1,107 @@
// Self-check for the burst alignment maths — the one part of the low-light
// merge that is decided by arithmetic rather than by Skia, and the part a wrong
// answer turns into a visibly doubled photo. Run it after touching
// burstAlign.ts (or the constants that feed it):
// node src/utils/burstAlign.check.ts
// Exits non-zero on the first failed expectation.
import { bestShift, nearest2D, profilesOf, sadAt, type Pixels } from './burstAlign.ts';
function check(name: string, ok: boolean, detail = ''): void {
if (!ok) {
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
process.exit(1);
}
console.log(`ok ${name}`);
}
// Deterministic structure (a hash, not Math.random) so a failure is repeatable.
function texture(w: number, h: number, seed: number): Pixels {
const px = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const n = Math.sin(x * 12.9898 + y * 78.233 + seed) * 43758.5453;
const v = 96 + Math.floor((n - Math.floor(n)) * 128);
const p = (y * w + x) * 4;
px[p] = v;
px[p + 1] = v;
px[p + 2] = v;
px[p + 3] = 255;
}
}
return px;
}
// `a` cut down to what a camera would see of `b` after a shift of (dx, dy).
function shifter(a: Pixels, w: number, h: number, dx: number, dy: number): Pixels {
const px = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x - dx;
const sy = y - dy;
if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue;
const d = (y * w + x) * 4;
const s = (sy * w + sx) * 4;
px[d] = a[s];
px[d + 1] = a[s + 1];
px[d + 2] = a[s + 2];
px[d + 3] = 255;
}
}
return px;
}
const W = 192;
const H = 128;
const ref = texture(W, H, 1);
const refProfile = profilesOf(ref, W, H);
// 1) A known shift comes back out of the profiles, as the offset that draws the
// frame back onto the reference: content moved right by 7 is pulled left by 7.
{
const cur = shifter(ref, W, H, 7, -4);
const curProfile = profilesOf(cur, W, H);
const dx = bestShift(refProfile.cols, curProfile.cols, 12);
const dy = bestShift(refProfile.rows, curProfile.rows, 12);
check('bestShift undoes a (7, -4) move', dx === -7 && dy === 4, `got (${dx}, ${dy})`);
}
// 2) The 2D confirmation returns the translation, not the seed, when they agree.
{
const cur = shifter(ref, W, H, -5, 3);
const curProfile = profilesOf(cur, W, H);
const seedX = bestShift(refProfile.cols, curProfile.cols, 12);
const seedY = bestShift(refProfile.rows, curProfile.rows, 12);
const best = nearest2D(ref, cur, W, H, seedX, seedY, 2);
check('nearest2D confirms it on the pixels', best.x === 5 && best.y === -3, `got (${best.x}, ${best.y})`);
}
// 3) A featureless pair — an unreadable scene, or a flat wall in the dark —
// merges unshifted instead of being pushed somewhere by noise.
{
const flat: Pixels = new Uint8Array(W * H * 4).fill(200);
for (let i = 3; i < flat.length; i += 4) flat[i] = 255;
const best = nearest2D(flat, flat, W, H, 9, 9, 2);
check('flat pair stays at (0, 0)', best.x === 0 && best.y === 0, `got (${best.x}, ${best.y})`);
}
// 4) A candidate that keeps less than half the frame cannot win on a flat error.
{
const a: Pixels = new Uint8Array(W * H * 4).fill(10);
const b: Pixels = new Uint8Array(W * H * 4).fill(10);
check('disjoint candidate rejected', sadAt(a, b, W, H, W, 0) === Infinity);
check('full-overlap candidate scored', sadAt(a, b, W, H, 0, 0) === 0);
}
// 5) The fine stage searches around the coarse estimate: the true offset is
// found from a seed that is already close, and a seed that is wrong can only
// pull the answer as far as the window allows — never back to the truth.
{
const cur = shifter(ref, W, H, 2, 0);
const curProfile = profilesOf(cur, W, H);
check('expect-centred search finds -2 from a seed of -2', bestShift(refProfile.cols, curProfile.cols, 1, -2) === -2);
const off = bestShift(refProfile.cols, curProfile.cols, 1, 5);
check('a wrong seed stays inside its window', Math.abs(off - 5) <= 1, `got ${off}`);
}
console.log('\nburstAlign: all checks passed');
+135
View File
@@ -0,0 +1,135 @@
// Alignment maths for the low-light hand-held burst. Pure — no Skia, no React
// Native — so the check beside it runs on plain node:
// node src/utils/burstAlign.check.ts
//
// Frames taken within a second of each other differ by translation only (the
// zoom and the lens do not change between them), so the estimate is one shift
// per axis, read off the row and column luminance PROFILES of a downscaled
// frame. Correlating two profiles costs O(w+h) per candidate offset against
// O(w*h) for 2D block matching — the difference between a few hundred ms and
// minutes of JS thread for a 12MP burst. The 2D check then confirms the profile
// estimate against the actual pixels, so a periodic scene (blinds, railings,
// a tiled wall) cannot lock the estimator onto the wrong repetition — and (0,0)
// stays a candidate throughout, so a scene neither stage can read merges
// unshifted rather than wrongly shifted.
export interface Profiles {
/** Mean luminance of each row, top to bottom. */
rows: Float32Array;
/** Mean luminance of each column, left to right. */
cols: Float32Array;
}
/** RGBA pixels as they come out of Skia's readPixels (8-bit or float). */
export type Pixels = Uint8Array | Float32Array;
// Rec.709 luma on the unmanaged 8-bit values. A monotone luminance proxy is all
// a correlation needs — this is not a colour-managed Y.
const LUMA_R = 0.2126;
const LUMA_G = 0.7152;
const LUMA_B = 0.0722;
export function profilesOf(rgba: Pixels, w: number, h: number): Profiles {
const rows = new Float32Array(h);
const cols = new Float32Array(w);
for (let y = 0; y < h; y++) {
const row = y * w * 4;
let sum = 0;
for (let x = 0; x < w; x++) {
const p = row + x * 4;
const l = rgba[p] * LUMA_R + rgba[p + 1] * LUMA_G + rgba[p + 2] * LUMA_B;
sum += l;
cols[x] += l;
}
rows[y] = sum / w;
}
for (let x = 0; x < w; x++) cols[x] /= h;
return { rows, cols };
}
/**
* The shift of `cur` against `ref`, in profile samples, searched over
* `expect ± max` and scored by mean absolute difference over the overlap.
* Mean, not sum, so a candidate that only matches on a handful of pixels is not
* favoured; overlaps shorter than half the signal are rejected outright.
* Convention: `ref[i] ≈ cur[i - shift]`.
*/
export function bestShift(ref: Float32Array, cur: Float32Array, max: number, expect = 0): number {
const center = Math.round(expect);
const lo = Math.max(-(cur.length - 1), center - max);
const hi = Math.min(ref.length - 1, center + max);
let best = center;
let bestErr = Infinity;
for (let s = lo; s <= hi; s++) {
const from = Math.max(0, s);
const to = Math.min(ref.length, cur.length + s);
if (to - from < ref.length / 2) continue;
let err = 0;
for (let i = from; i < to; i++) err += Math.abs(ref[i] - cur[i - s]);
err /= to - from;
if (err < bestErr) {
bestErr = err;
best = s;
}
}
return best;
}
/**
* Mean absolute luminance difference between two downscaled frames at a
* candidate shift, over the overlap — the same convention as `bestShift`
* (`a[y][x] ≈ b[y - dy][x - dx]`). `Infinity` when the candidate keeps less than
* half the frame: a nearly disjoint pair would otherwise score a suspiciously
* low error on whatever flat area it did keep.
*/
export function sadAt(a: Pixels, b: Pixels, w: number, h: number, dx: number, dy: number): number {
const x0 = Math.max(0, dx);
const y0 = Math.max(0, dy);
const x1 = Math.min(w, w + dx);
const y1 = Math.min(h, h + dy);
if (x1 <= x0 || y1 <= y0) return Infinity;
if ((x1 - x0) * (y1 - y0) < (w * h) / 2) return Infinity;
let sum = 0;
for (let y = y0; y < y1; y++) {
const ra = (y * w + x0) * 4;
const rb = ((y - dy) * w + (x0 - dx)) * 4;
for (let x = x0; x < x1; x++) {
const pa = ra + (x - x0) * 4;
const pb = rb + (x - x0) * 4;
const la = a[pa] * LUMA_R + a[pa + 1] * LUMA_G + a[pa + 2] * LUMA_B;
const lb = b[pb] * LUMA_R + b[pb + 1] * LUMA_G + b[pb + 2] * LUMA_B;
sum += Math.abs(la - lb);
}
}
return sum / ((x1 - x0) * (y1 - y0));
}
/**
* Confirm a profile estimate on the pixels: search `±radius` around it, keeping
* (0,0) in the running, and return the best 2D match. This is what makes a
* profile lock onto a real translation rather than onto a repeated pattern —
* and what makes "no shift" win whenever the pixels do not support a shift.
*/
export function nearest2D(
ref: Pixels,
cur: Pixels,
w: number,
h: number,
seedX: number,
seedY: number,
radius: number
): { x: number; y: number } {
let best = { x: 0, y: 0 };
let bestErr = sadAt(ref, cur, w, h, 0, 0);
for (let dy = seedY - radius; dy <= seedY + radius; dy++) {
for (let dx = seedX - radius; dx <= seedX + radius; dx++) {
if (dx === 0 && dy === 0) continue;
const err = sadAt(ref, cur, w, h, dx, dy);
if (err < bestErr) {
bestErr = err;
best = { x: dx, y: dy };
}
}
}
return best;
}
+227
View File
@@ -0,0 +1,227 @@
// Low-light hand-held burst: N exposures of the same scene averaged into one
// JPEG, aligned first. This is the cheap end of what the stock camera does — an
// OEM stacks its own frames inside the HAL (and the extension probe showed no
// third-party client is served on the 12S/14 Ultra), so the only way to a
// multi-frame denoise from an app is to take the frames itself. The exposures
// come from the same photo output the shutter already uses, and the average
// buys about sqrt(N) off the sensor noise: ~1.7x for three frames, which is the
// difference between an ISO 1900 frame and one shot at ISO 700.
//
// Cost, all of it on the shutter path in the dark: N-1 extra exposures (~0.5s
// each in QUALITY mode), two decodes per frame (one for the alignment rasters,
// one for the merge draw — deliberately never more than one 12MP frame in
// memory at a time, see exportEngine's surface note) and one JPEG encode.
import { Skia, AlphaType, BlendMode, ColorType, ImageFormat, type SkImage, type SkSurface } from '@shopify/react-native-skia';
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { bestShift, nearest2D, profilesOf, type Profiles, type Pixels } from './burstAlign';
import { stampExifOnFile } from './photoMeta';
// The ISO at which one frame stops being enough. Above it the shutter output is
// dominated by sensor noise — the 12S Ultra metered ISO 1918 on a dim indoor
// shot, where the stock camera stacks and the app could not. Below it there is
// nothing to win, so the plain (and much faster) capture stands.
// ponytail: one threshold, no shutter-speed term — CameraX exposes no exposure
// reading to a third-party app. Make it adaptive if ISO 800 ever misjudges a body.
export const BURST_MIN_ISO = 800;
// Total exposures. Three, not the four or five a HAL would stack: 1/3 is exactly
// representable as an 8-bit paint alpha (85/255), so the average carries no gain
// error, and two extra frames are as much hand-held drift as the merge tolerates.
export const BURST_FRAMES = 3;
// Alignment scales: coarse (1/8) finds the offset over a wide range, fine (1/4)
// trims it to ±2 full-resolution pixels. Both are CPU surfaces — a downscaled
// raster is cheap there, and a GPU readback is not.
const COARSE_DIV = 8;
const FINE_DIV = 4;
const COARSE_RADIUS = 10; // ±80 px of a 3000 px frame
const COARSE_REFINE = 2; // 2D neighbourhood of the profile estimate, ±16 px
const FINE_RADIUS = 5; // ±20 px around the coarse estimate, in 1/4-scale pixels
// Motion beyond this share of the frame is a different picture, not drift: the
// intersection crop would eat the photo, and the average would ghost.
const MAX_SPREAD = 0.02;
// GPU when a context exists (the merge draws three 12MP images), CPU otherwise —
// the same fallback exportEngine makes, for the same cold-start reason.
function surfaceFor(width: number, height: number): SkSurface | null {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
// Capture outputs a bare path; expo-file-system's File wants a scheme.
const uriOf = (path: string): string => (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) ? path : `file://${path}`);
async function loadFrame(path: string): Promise<SkImage | null> {
try {
const bytes = await new File(uriOf(path)).bytes();
const data = Skia.Data.fromBytes(bytes);
const image = Skia.Image.MakeImageFromEncoded(data);
data.dispose();
return image;
} catch (e) {
console.warn('Burst frame decode failed:', e);
return null;
}
}
// One downscaled RGBA raster of a frame, for the alignment stages.
function rasterize(src: SkImage, surface: SkSurface, w: number, h: number, paint: ReturnType<typeof Skia.Paint>): Pixels | null {
const canvas = surface.getCanvas();
canvas.clear(Skia.Color('black'));
canvas.drawImageRect(src, Skia.XYWHRect(0, 0, src.width(), src.height()), Skia.XYWHRect(0, 0, w, h), paint);
return canvas.readPixels(0, 0, { width: w, height: h, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul });
}
/**
* Average a hand-held burst of the same scene into one JPEG.
*
* `frames` are capture paths, the first one being the frame the shutter already
* took. Returns the cache path of the merged file, or null when the merge is not
* worth it or cannot be done — motion too large, a frame that will not decode,
* a frame of a different size — in which case the caller keeps the plain capture.
* The merged file carries the first frame's EXIF block, so the export still
* sees the camera and the capture instant it stamps into the finished photo.
*/
export async function mergeBurst(frames: string[]): Promise<string | null> {
if (frames.length < 2) return null;
const owned: { dispose(): void }[] = [];
const keep = <T extends { dispose(): void }>(item: T): T => {
owned.push(item);
return item;
};
try {
// Frame 0 sets the geometry: CameraX returns every frame at the same size,
// so a frame that disagrees is not part of this burst.
const first = await loadFrame(frames[0]);
if (!first) return null;
const W = first.width();
const H = first.height();
first.dispose(); // geometry only: the loop decodes frame 0 for real
if (!(W > 0 && H > 0)) return null;
const cw = Math.max(32, Math.round(W / COARSE_DIV));
const ch = Math.max(32, Math.round(H / COARSE_DIV));
const fw = Math.max(64, Math.round(W / FINE_DIV));
const fh = Math.max(64, Math.round(H / FINE_DIV));
const coarseSurface = Skia.Surface.Make(cw, ch);
const fineSurface = Skia.Surface.Make(fw, fh);
const accum = surfaceFor(W, H);
if (!coarseSurface || !fineSurface || !accum) {
coarseSurface?.dispose();
fineSurface?.dispose();
accum?.dispose();
return null;
}
keep(coarseSurface);
keep(fineSurface);
keep(accum);
const srcPaint = keep(Skia.Paint()); // an opaque source, unscaled
// The accumulator IS the output: each frame is drawn into it once its offset
// is known and released straight after, so at most one decoded 12MP frame
// (48MB) and one surface are alive at any moment. The 1/N alpha is what turns
// a plus-blend into an average — accumulating at full brightness would clip
// every highlight long before the third frame landed.
const addPaint = keep(Skia.Paint());
addPaint.setBlendMode(BlendMode.Plus);
addPaint.setAlphaf(1 / frames.length);
accum.getCanvas().clear(Skia.Color('transparent'));
let refCoarse: Profiles | null = null;
let refCoarsePixels: Pixels | null = null;
let refFine: Profiles | null = null;
const offsets: { x: number; y: number }[] = [];
for (const path of frames) {
const image = await loadFrame(path);
if (!image) return null;
try {
if (image.width() !== W || image.height() !== H) return null;
const coarsePixels = rasterize(image, coarseSurface, cw, ch, srcPaint);
const finePixels = rasterize(image, fineSurface, fw, fh, srcPaint);
if (!coarsePixels || !finePixels) return null;
let x = 0;
let y = 0;
if (refCoarse && refCoarsePixels && refFine) {
const coarseNow = profilesOf(coarsePixels, cw, ch);
const fineNow = profilesOf(finePixels, fw, fh);
// Profiles first: they are the only stage cheap enough to search a
// ±80 px range. Then the pixels confirm the result — that 2D pass is
// what keeps a periodic scene from locking onto the wrong repetition,
// and it keeps (0,0) as a candidate, so an unreadable pair merges
// unshifted rather than wrongly shifted.
const seed = nearest2D(
refCoarsePixels,
coarsePixels,
cw,
ch,
bestShift(refCoarse.cols, coarseNow.cols, COARSE_RADIUS),
bestShift(refCoarse.rows, coarseNow.rows, COARSE_RADIUS),
COARSE_REFINE,
);
// 1/8-scale alignment is only good to ±8 full-resolution pixels; the
// 1/4-scale profiles, searched around it, trim that to ±2.
const seedScale = COARSE_DIV / FINE_DIV;
x = bestShift(refFine.cols, fineNow.cols, FINE_RADIUS, seed.x * seedScale) * FINE_DIV;
y = bestShift(refFine.rows, fineNow.rows, FINE_RADIUS, seed.y * seedScale) * FINE_DIV;
} else {
refCoarse = profilesOf(coarsePixels, cw, ch);
refCoarsePixels = coarsePixels;
refFine = profilesOf(finePixels, fw, fh);
}
offsets.push({ x, y });
accum.getCanvas().drawImage(image, x, y, addPaint);
} finally {
image.dispose();
}
}
const minX = Math.min(...offsets.map((o) => o.x));
const maxX = Math.max(...offsets.map((o) => o.x));
const minY = Math.min(...offsets.map((o) => o.y));
const maxY = Math.max(...offsets.map((o) => o.y));
if (maxX - minX > W * MAX_SPREAD || maxY - minY > H * MAX_SPREAD) return null;
const accumulated = accum.makeImageSnapshot();
if (!accumulated) return null;
keep(accumulated);
// The frames disagree at the borders by exactly the spread of their offsets,
// so keep the intersection: no edge is left carrying one frame's worth of
// exposure against the average of the rest.
const outW = W - (maxX - minX);
const outH = H - (maxY - minY);
const cropped = surfaceFor(outW, outH);
if (!cropped) return null;
keep(cropped);
cropped
.getCanvas()
.drawImageRect(accumulated, Skia.XYWHRect(minX, minY, outW, outH), Skia.XYWHRect(0, 0, outW, outH), srcPaint);
const merged = cropped.makeImageSnapshot();
if (!merged) return null;
keep(merged);
// Quality 90, the same the photo output produces: one more JPEG generation
// on a file that the export decodes and re-encodes anyway.
const bytes = merged.encodeToBytes(ImageFormat.JPEG, 90);
if (!bytes) return null;
const path = `${FileSystem.cacheDirectory}burst_${Date.now()}.jpg`;
new File(path).write(bytes);
await stampExifOnFile(path, {}, uriOf(frames[0]));
return path;
} catch (e) {
console.warn('Burst merge failed:', e);
return null;
} finally {
// Reverse creation order, like exportEngine: a snapshot is what holds the
// 48MB buffer, everything else is a handle onto it.
for (let i = owned.length - 1; i >= 0; i--) {
try {
owned[i].dispose();
} catch {
// A buffer that is already gone must not cost the user the photo.
}
}
}
}
+173 -10
View File
@@ -1,4 +1,102 @@
import { ColorAdjustments, BaseFilter } from '../types';
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
// The eight bands of the selective colour mixer, with the hue (degrees on the
// HSL wheel) each band sits at. The anchors are deliberately uneven: red /
// orange / yellow sit 30 degrees apart because that is where the eye reads
// detail, while the blue side is one 60-degree stretch. TONE_SKSL builds its
// eight weights from this same table, so the chip's anchor and the shader's
// mask cannot drift apart.
//
// `label` is the chip's text — app copy, not i18n.
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
{ id: 'red', hue: 0, label: 'RED' },
{ id: 'orange', hue: 30, label: 'ORANGE' },
{ id: 'yellow', hue: 60, label: 'YELLOW' },
{ id: 'green', hue: 120, label: 'GREEN' },
{ id: 'aqua', hue: 180, label: 'AQUA' },
{ id: 'blue', hue: 240, label: 'BLUE' },
{ id: 'purple', hue: 280, label: 'PURPLE' },
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
];
// Hue distance (degrees) to the two neighbouring bands, which is what the
// shader's tent window needs. Derived from HSL_BANDS so the two cannot argue: a
// band's own weight is 1 at its anchor and 0 at the anchors next door, which
// makes the eight weights a partition of unity over the hue wheel (they sum to
// 1 for every hue).
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
const n = HSL_BANDS.length;
return HSL_BANDS.map((b, i) => {
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
const next = HSL_BANDS[(i + 1) % n].hue;
// Red (0 deg) wraps: its left neighbour is magenta at 320, i.e. 40 ahead.
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
});
}
// 0..255 sRGB -> HSL, hue in degrees and saturation/lightness 0..1 — the
// inverse of the shader's hsl2rgb, and what the eyedropper readout prints.
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const mx = Math.max(R, G, B);
const mn = Math.min(R, G, B);
const l = (mx + mn) / 2;
const d = mx - mn;
if (d < 1e-6) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let h: number;
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
else if (mx === G) h = ((B - R) / d + 2) * 60;
else h = ((R - G) / d + 4) * 60;
return { h, s, l };
}
// HSL -> #rrggbb for the band chips: a chip naming a colour has to show that
// colour, and a hex string is the one format RN takes directly.
export function hslToHex(h: number, s: number, l: number): string {
const k = (n: number) => (n + h / 30) % 12;
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
const chan = (n: number) =>
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
.toString(16)
.padStart(2, '0');
return `#${chan(0)}${chan(8)}${chan(4)}`;
}
// Which band a sampled hue belongs to: the nearest anchor. The bands overlap in
// the shader, so this only decides which band the ruler is pointing at.
export function nearestHslBand(hue: number): HslBandId {
const wrapped = ((hue % 360) + 360) % 360;
let best = HSL_BANDS[0];
let bestD = 361;
for (const band of HSL_BANDS) {
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
if (d < bestD) {
bestD = d;
best = band;
}
}
return best.id;
}
// One band triple as stored: integers -10..10, and an all-zero band dropped so a
// recipe only carries what the user actually moved.
const clamp10 = (v: unknown): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0;
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
@@ -52,6 +150,30 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// Tone a stock carries BY ITSELF, in the sliders' own units: brightness
// (EXPOSURE) and slope (CONTRAST). It is part of the look, not a knob the user
// set, so it never reaches the panel (a sim's adjustments stay neutral — see
// FILM_SIMS in defaultRecipes) and the slider reads 0 while the render is
// already lifted or the ramp already steeper. Keyed by stock because that is
// what the renderers know: `baseFilter` travels with every recipe, an armed sim
// id does not. ponytail: one entry per stock; if two sims ever need one stock's
// matrix with a different tone, the key has to move to the sim id.
const STOCK_BIAS: Partial<Record<BaseFilter, { exposure?: number; contrast?: number }>> = {
// LC STREETLIFE VIVID reads bright straight off the shutter.
'leica-vivid': { exposure: 2 },
// B&W HIGH CONTRAST pushes its own greys (+4 of slope), on top of the
// toe/shoulder FILM_TONE gives it.
'mono-high-contrast': { contrast: 4 },
};
// Black-and-white stocks. Their grey ramp is the whole look, so every colour
// stage below — saturation, white balance, the R/B fine-tune — is skipped
// for them. One list, asked through one function, so a second B&W stock cannot
// end up half excluded (a stray Kelvin gain on a grey ramp shows up as a tint).
const MONO_BASES: BaseFilter[] = ['monochrome', 'mono-high-contrast'];
export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
!!baseFilter && MONO_BASES.includes(baseFilter);
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
// exposure-compensation EV gain are intentionally NOT part of the matrix —
@@ -59,8 +181,10 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
// and EV is applied either as hardware AE bias (camera) or by the caller via
// applyExposureGain (library), never doubled.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const exposure = adj.exposure;
const contrast = adj.contrast;
// The stock's own tone rides with the sliders: same maths, same band (it is
// a property of the look, not of this photo's edit).
const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
const contrast = adj.contrast + (STOCK_BIAS[baseFilter]?.contrast ?? 0);
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
@@ -120,6 +244,16 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-vivid') {
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
// same muted teal/cyan — with the red and green rows taken from Velvia, so
// everything that is not blue reads loud.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia (Soft): gentler than standard — contrast below neutral (shadows
// lift, highlights ease off) and colour held back, with a faint warm/pink
@@ -162,9 +296,10 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
// VIVID is a brighter read of one look rather than a second, neutral one.
// No positive lift here: the brightness is the sim's own exposure value
// (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of
// the clip — white lands at most at 1.000 on the red row.
// No positive lift here either, so the matrix stays inside 0..1 and pure
// white stays out of the clip — white lands at most at 1.000 on the red row.
// The brightness this look is known for is STOCK_EXPOSURE below: a fixed
// exposure move of the stock itself, never a chroma trick.
matrix = [
1.125, -0.100, -0.010, 0, -0.015,
-0.055, 1.110, -0.055, 0, -0.005,
@@ -190,10 +325,37 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'mono-high-contrast') {
// B&W HIGH CONTRAST — Acros taken through a warm filter, then pushed at
// both ends (the push itself lives in FILM_TONE, its midtone slope in
// STOCK_BIAS above).
//
// The mix is the B&W MIX step: the channel weights are the matrix's whole
// job here, and they are deliberately not BT.709. Red is nearly doubled
// against its luminance share — so a red roof reads bright, a blue sky
// and foliage read deep, and the separation between them is what gives the
// ramp its contrast before any curve is applied.
//
// ponytail: the weights ride the CHANNELS, while the brief that asked for
// this stock names HUE groups (red 1.15, orange 1.20, blue 0.70). A 4x5
// matrix cannot be hue-selective; if those exact factors are ever wanted,
// that is a per-pixel HSL pass in the tone shader, not a row edit here.
//
// The three rows are identical and sum to 1.00, so a neutral stays the
// neutral the sensor recorded and nothing clips at either end.
const r = 0.38;
const g = 0.56;
const b = 0.06;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
}
// 3. Apply Saturation adjustment (standard color matrix transformation)
if (baseFilter !== 'monochrome' && saturation !== 0) {
if (!isMonochromeBase(baseFilter) && saturation !== 0) {
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
const invS = 1 - s;
const r = 0.213 * invS;
@@ -216,8 +378,9 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
// consumed by getToneUniforms instead.
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (baseFilter !== 'monochrome') {
// 5. White Balance (Kelvin Temperature and Tint) — skipped for the B&W
// stocks: a grey ramp has no cast to correct, only a tint to leak.
if (!isMonochromeBase(baseFilter)) {
const rgbTemp = kelvinToRGB(temperature);
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
// (red and blue up, green down), - pushes green (green up, red and blue
@@ -246,7 +409,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
if (!isMonochromeBase(baseFilter) && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
+51 -19
View File
@@ -20,16 +20,46 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
// The mixer's three whole-image moves. hslBands is deliberately absent: it is
// optional and `undefined` is what "no band moved" means — an empty object
// would make differs(adjustments, DEFAULT_ADJUSTMENTS) report a dirty recipe.
hslHue: 0,
hslSat: 0,
hslLum: 0,
exposureCompensation: 0,
};
// Standard Fuji film simulations. These are the fixed "look" presets shown at
// the front of the PRESETS chip row: tapping one applies the simulation — its
// baseFilter colour matrix plus whatever the stock itself carries outside the
// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look,
// as on Acros). Every other knob still resets to its neutral default (0 /
// auto), so a sim is a clean starting point, never a baked recipe; any further
// look (DR, CC, contrast, more grain) is added by the user afterwards.
// Standard film simulations. These are the fixed "look" presets shown at the
// front of the PRESETS chip row.
//
// RULE — a sim carries COLOUR AND TONE ONLY, never an effect. Its whole look
// must come from its baseFilter colour matrix plus its FILM_TONE curve
// (toneShader.ts): the two things that *define* the stock. No grain, no
// clarity, no exposure/saturation/shadow/highlight move — those are edits the
// user makes afterwards, never something a sim bakes in. That is why every sim
// below shares the neutral DEFAULT_ADJUSTMENTS verbatim; if a look ever needs
// more, put it in the matrix or the tone curve instead.
//
// The one exception is a stock that is simply BRIGHTER: that is a fixed
// exposure move of the look itself, so it lives in STOCK_EXPOSURE
// (colorUtils.ts), keyed by baseFilter, where the preview and both exporters
// already pick it up. It never lands here and never reaches the panel, so the
// EXPOSURE slider still reads 0 on such a sim — LC STREETLIFE VIVID (+2).
//
// Reference only — what each sim stands for, so the colour choices can be
// checked against the real stock. The UI keeps the sim names below.
//
// PROVIPES → PROVIA
// VELVIPES → VELVIA
// CLASSIC CHRIPES → CLASSIC CHROME
// CLASSIC VIVIDIPES → VELVIA + CLASSIC CHROME (Chrome's blue row over
// Velvia's red and green rows — see colorUtils.ts)
// CLASSIC NEGIPES → Classic Negative
// ASTIPES → Astia
// ETERNIPES → Eterna
// ACRIPES → Acros
// LC STREETLIFE CLASSIC → Leica Authentic Classic
// LC STREETLIFE VIVID → Leica Vivid
export interface FilmSim {
id: string; // Recipe-like id used only while the sim is the current selection
name: string;
@@ -41,23 +71,25 @@ export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// Acros taken through a warm filter, then pushed at both ends: the mix (red
// up, blue down) rides the matrix, the end push rides FILM_TONE, and the
// midtone slope the stock owns is STOCK_BIAS in colorUtils — never an
// `adjustments` value, so every knob still opens at neutral.
{ id: 'sim-bw-high-contrast', name: 'B&W HIGH CONTRAST', baseFilter: 'mono-high-contrast', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
// pair instead of one look that has to compromise:
// pair instead of one look that has to compromise. Both read off the
// 'leica'/'leica-vivid' colour matrices alone (neither has a FILM_TONE
// entry):
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
// renderers), never a global saturation boost, and a whisker of grain
// keeps it feeling like film rather than digital sharpness.
// VIVID — the same channel separation opened up for the digital era: lifted
// exposure, lifted shadows, a little colour. It reads bright and flattering
// straight off the shutter, no editing pass needed.
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
// own adjustments, which every apply path now carries into the panel.
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
// quiet colour.
// VIVID — the same channel separation opened up for the digital era.
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
+42
View File
@@ -0,0 +1,42 @@
// Runnable self-check: `node --no-warnings src/utils/deviceFloor.check.ts`.
// Covers the budget maths only — the RAM reading itself needs the device.
import assert from 'node:assert';
import {
canBurst,
floorRaster,
FLOOR_EXPORT_PIXELS,
isFloorDevice,
maxExportPixels,
} from './deviceFloor.ts';
// Tier boundaries: unknown (0, the platform refused) is never a floor device,
// and the 4 GB mid-range keeps the full path.
assert.strictEqual(isFloorDevice(0), false, 'unknown RAM must keep full quality');
assert.strictEqual(isFloorDevice(1024), true, 'Redmi Go / Nokia 1 / J2 Core');
assert.strictEqual(isFloorDevice(2048), true, 'Go programme ceiling');
assert.strictEqual(isFloorDevice(3072), false);
assert.strictEqual(isFloorDevice(12288), false, '12S Ultra');
// Caps follow the tier.
assert.strictEqual(maxExportPixels(0), Infinity);
assert.strictEqual(maxExportPixels(12288), Infinity);
assert.strictEqual(maxExportPixels(1024), FLOOR_EXPORT_PIXELS);
assert.strictEqual(canBurst(1024), false);
assert.strictEqual(canBurst(0), true);
// The floor device's own 3264x2448 (8 MP) capture, and the 12S Ultra's 3000x4000.
const go = floorRaster(3264, 2448, FLOOR_EXPORT_PIXELS);
assert.ok(go.width < 3264, 'floor capture is resampled');
assert.ok(go.width * go.height <= FLOOR_EXPORT_PIXELS, 'stays inside the cap');
assert.ok(go.width * go.height > FLOOR_EXPORT_PIXELS * 0.9, 'and does not overshoot it');
// Aspect is preserved to within a pixel on both orientations.
assert.ok(Math.abs(go.width / go.height - 3264 / 2448) < 0.002, '4:3 kept');
const tall = floorRaster(3000, 4000, 4_000_000);
assert.ok(Math.abs(tall.width / tall.height - 3000 / 4000) < 0.002, '3:4 kept');
// Untouched when it already fits, and never zero-sized on a degenerate raster.
assert.deepStrictEqual(floorRaster(2000, 1500, FLOOR_EXPORT_PIXELS), { width: 2000, height: 1500 });
assert.deepStrictEqual(floorRaster(1, 1, FLOOR_EXPORT_PIXELS), { width: 1, height: 1 });
assert.deepStrictEqual(floorRaster(0, 0, 100), { width: 0, height: 0 });
console.log('deviceFloor.check: 17 assertions passed');
+55
View File
@@ -0,0 +1,55 @@
// The floor the whole pipeline is budgeted against: the cheapest Android that
// clears this app's minimum. What sets the minimum: minSdk 26 (below it
// vision-camera's NativeBufferHelper throws "HardwareBuffers require minSdk 26
// or higher!" on every frame), a 32-bit armeabi-v7a build (the shipped APK
// carries both ABIs), Camera2 with EXIF, and Skia's GL backend, which links
// -lGLESv2 (see node_modules/@shopify/react-native-skia/android/CMakeLists.txt).
// Every API 26 phone clears the GL bar, so the floor is the Android Go, 1 GB
// class: Redmi Go (Snapdragon 425, Adreno 308, 5.8-7.2 GFLOPS / 5.3 GB/s,
// 8 MP), Nokia 1 (MT6737M, Mali-T720, 5 MP) and Galaxy J2 Core (Exynos 7570,
// 8 MP) — 4x Cortex-A53 at 1.1-1.4 GHz, 1 GB RAM, 8 GB storage, 480p-720p
// screen. Nothing weaker can install.
//
// What that floor affords, at 4 bytes per RGBA8888 pixel:
// 8 MP raster 32 MB -> the export's live surfaces alone = 96 MB
// one full-frame pass 64 MB of traffic @ 5.3 GB/s ~ 12 ms
// 4 slow A53 cores ~1/6 of a current flagship core
// Hence, here: one resample at the door instead of a full-resolution surface
// chain, and no 3-frame burst (six 8 MP decodes plus a CPU alignment on four
// slow cores is seconds of shutter lag on the device class least able to afford
// it). The live preview needs no cap — Viewfinder already forces the frame
// stream to the 4:3 size nearest 1280x960.
//
// No state and no native import: the RAM reading comes from
// photoMeta.devicePerf() and is passed in, so this module also runs under plain
// node (see deviceFloor.check.ts).
export const FLOOR_RAM_MB = 2048;
export const FLOOR_EXPORT_PIXELS = 4_000_000;
// ramMb 0 = the platform would not say. Unknown is never a floor device: the app
// must not downgrade a phone it could not measure.
export const isFloorDevice = (ramMb: number): boolean => ramMb > 0 && ramMb <= FLOOR_RAM_MB;
// Most pixels the export may paint. 4 MP (2336x1752) still prints 10x15 cm at
// 300 dpi and halves every surface behind it — the 12S Ultra's 12 MP capture is
// untouched, since it is not a floor device.
// ponytail: downscale rather than tile — tile the render the day a floor user
// needs the full 8 MP file.
export const maxExportPixels = (ramMb: number): number =>
isFloorDevice(ramMb) ? FLOOR_EXPORT_PIXELS : Infinity;
// ponytail: one capability, all or nothing — add a 2-frame CPU-lite merge if a
// floor device ever reports it can afford one.
export const canBurst = (ramMb: number): boolean => !isFloorDevice(ramMb);
// Scale a raster down to at most `cap` pixels, keeping its aspect. Whole pixels
// only: the surface allocator and the EXIF stamp both read these back.
export function floorRaster(
w: number,
h: number,
cap: number
): { width: number; height: number } {
if (!(w > 0 && h > 0) || !(w * h > cap)) return { width: w, height: h };
const s = Math.sqrt(cap / (w * h));
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) };
}
+57
View File
@@ -0,0 +1,57 @@
// Runnable self-check for the one reader the low-light burst gate leans on.
// expo-file-system has no host implementation, so this covers readJpegIso
// offline: node --no-warnings src/utils/exifWrite.check.ts
import { readJpegIso } from './exifWrite.ts';
// A 44-byte TIFF: IFD0 holds the Exif pointer, the Exif IFD holds 0x8827 —
// the two-hop shape a camera writes. `type` is the EXIF type id (3 SHORT,
// 4 LONG); a wrong-IFD or wrong-endian reader fails here.
function tiffWithIso(iso: number, type: 3 | 4, be = false): Uint8Array {
const t = new Uint8Array(44);
const u16 = (p: number, v: number) => (be ? (t[p] = v >> 8, t[p + 1] = v & 0xff) : (t[p] = v & 0xff, t[p + 1] = v >> 8));
const u32 = (p: number, v: number) =>
be
? (t[p] = v >>> 24, t[p + 1] = (v >>> 16) & 0xff, t[p + 2] = (v >>> 8) & 0xff, t[p + 3] = v & 0xff)
: (t[p] = v & 0xff, t[p + 1] = (v >>> 8) & 0xff, t[p + 2] = (v >>> 16) & 0xff, t[p + 3] = v >>> 24);
t[0] = be ? 0x4d : 0x49;
t[1] = be ? 0x4d : 0x49;
u16(2, 42);
u32(4, 8);
u16(8, 1); // IFD0: one entry
u16(10, 0x8769); u16(12, 4); u32(14, 1); u32(18, 26); // -> Exif IFD at 26
u32(22, 0); // no IFD1
u16(26, 1); // Exif IFD: one entry
u16(28, 0x8827); u16(30, type); u32(32, 1);
if (type === 3) u16(36, iso);
else u32(36, iso);
u32(40, 0);
return t;
}
function jpegWith(tiff: Uint8Array): Uint8Array {
const len = 2 + 6 + tiff.length;
const j = new Uint8Array(6 + len); // SOI + marker/len + the length's own payload
j[0] = 0xff; j[1] = 0xd8; // SOI
j[2] = 0xff; j[3] = 0xe1; // APP1
j[4] = len >> 8; j[5] = len & 0xff;
j.set([0x45, 0x78, 0x69, 0x66, 0, 0], 6);
j.set(tiff, 12);
return j;
}
const checks: [string, boolean][] = [
['SHORT ISO read back', readJpegIso(jpegWith(tiffWithIso(1234, 3))) === 1234],
['LONG ISO read back', readJpegIso(jpegWith(tiffWithIso(2000, 4))) === 2000],
['big-endian ISO read back', readJpegIso(jpegWith(tiffWithIso(800, 3, true))) === 800],
['ISO 0 means unknown', readJpegIso(jpegWith(tiffWithIso(0, 3))) === null],
['an unreadable file is not an ISO', readJpegIso(new Uint8Array([0xff, 0xd8, 0xff, 0xd9])) === null],
['a non-JPEG is not an ISO', readJpegIso(new Uint8Array([1, 2, 3])) === null],
];
let bad = 0;
for (const [name, ok] of checks) {
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}`);
if (!ok) bad++;
}
if (bad) throw new Error(`exifWrite: ${bad} check(s) failed`);
console.log('\nexifWrite: all checks passed');
+24
View File
@@ -299,6 +299,30 @@ function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff:
return null;
}
// --- read ------------------------------------------------------------------
/**
* The ISO the camera metered for this JPEG — EXIF 0x8827
* (PhotographicSensitivity) — or null when the file carries none. The capture
* path cannot ask the camera: CameraX hands back a path and nothing else, and
* the controller's `iso` reads 0.0 for every still. So the low-light gate reads
* it back off the file it just wrote, and a file without the tag simply never
* bursts.
*/
export function readJpegIso(jpeg: Uint8Array): number | null {
const seg = findExifSegment(jpeg);
if (!seg) return null;
const iso = parseTiff(seg.tiff).exif.find((e) => e.tag === 0x8827);
if (!iso) return null;
// parseTiff normalizes every value to little-endian, so SHORT and LONG read
// with the same shifts.
const n =
iso.type === LONG || iso.type === SLONG
? (iso.data[0] | (iso.data[1] << 8) | (iso.data[2] << 16) | (iso.data[3] << 24)) >>> 0
: (iso.data[0] | (iso.data[1] << 8)) >>> 0;
return n > 0 && n < 1000000 ? n : null; // 0 and vendor noise both mean "unknown"
}
// --- serialize -------------------------------------------------------------
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
+39 -11
View File
@@ -10,7 +10,8 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { writeJpegExif } from './exifWrite';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity, devicePerf } from './photoMeta';
import { floorRaster, maxExportPixels } from './deviceFloor';
import { ensureMediaLibraryPermission } from './mediaPermission';
import RecipescamExport from '../../modules/recipescam-export';
@@ -28,9 +29,6 @@ export interface ExportResult {
export interface ExportOptions {
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
dpi?: number;
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
// final pixels before encoding. Pixels already export at full resolution.
sharpen?: boolean;
// True for photos captured through the live camera: exposure compensation was
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
// should be added on top. Library stills omit this and get the matrix gain.
@@ -149,7 +147,7 @@ function release(owned: SkDisposable[], item: SkDisposable | null): void {
disposeAll([item]);
}
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
function screenSharpenImage(image: SkImage, amount: number): SkImage {
const w = image.width();
const h = image.height();
const center = 1 + 4 * amount;
@@ -249,6 +247,37 @@ export async function processAndExportPhoto(
}
let width = skImage.width();
let height = skImage.height();
// 1c. Floor-device ceiling (see deviceFloor.ts). Everything below keeps more
// than one full-resolution surface alive — paint target, snapshot, sharpen —
// and one 8 MP raster is 32 MB, which is what a 1 GB Go phone pages or OOMs
// on mid-export. Resample once, here at the door, so every pass behind it
// (crop, tone, glow, grain, sharpen) runs on the smaller raster; the aspect
// is unchanged, so the ratio crop, frame layout and cover maths all stand.
// A surface that will not allocate leaves the raster alone: the full-size
// chain below is the one we are already on.
const capped = floorRaster(width, height, maxExportPixels(devicePerf().ramMb));
if (capped.width !== width) {
const shrinkSurface = createSurface(capped.width, capped.height);
if (shrinkSurface) {
own(shrinkSurface);
shrinkSurface.getCanvas().drawImageRect(
skImage,
Skia.XYWHRect(0, 0, width, height),
Skia.XYWHRect(0, 0, capped.width, capped.height),
own(Skia.Paint())
);
const shrunk = shrinkSurface.makeImageSnapshot();
if (shrunk) {
own(shrunk);
// Superseded, like the aspect crop above.
release(owned, shrinkSurface);
release(owned, skImage);
skImage = shrunk;
width = capped.width;
height = capped.height;
}
}
}
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
@@ -894,12 +923,11 @@ export async function processAndExportPhoto(
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
let resultImage = own(outputSurface.makeImageSnapshot());
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
// A negative knob means "no sharpening at all" (its blur already ran at
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
// Sharpening knob (0..10) is the only source: the old 0.5 fallback that
// fired on every camera capture multiplied sensor noise ~3.2x (5x at
// Nyquist) and made dim/high-ISO shots unusable. A negative or zero knob
// sharpens not at all (a negative one blurs at step 4, under the grain).
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
if (sharpenAmount > 0) {
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
// The helper returns its own input when it cannot allocate a surface.
+4 -7
View File
@@ -24,9 +24,6 @@ export interface NativeExportResult {
export interface NativeExportOptions {
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
dpi?: number;
// Apply the subtle "Screen" output sharpen (exportEngine #9) to the final
// pixels before encoding (legacy camera captures pass sharpen: true → 0.5).
sharpen?: boolean;
// True for photos captured through the live camera: the AE bias already
// applied exposure compensation at capture, so no 2^EV matrix gain is added.
evFromCamera?: boolean;
@@ -73,11 +70,11 @@ export async function processAndExportPhotoNative(
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
// Final screen sharpen (engine #9): the sharpening knob (-10..10) is the
// only source — the old 0.5 fallback on every camera capture multiplied
// sensor noise ~3.2x and made dim/high-ISO shots unusable.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
const aspect: '4:3' | '3:2' | undefined =
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
? options.aspect
+23
View File
@@ -3,6 +3,7 @@
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import RecipescamExport from '../../modules/recipescam-export';
import { isFloorDevice } from './deviceFloor';
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
@@ -50,6 +51,28 @@ export function getDeviceIdentity(): DeviceIdentity | null {
return deviceIdentity;
}
// Device facts the capture and render paths budget against (deviceFloor.ts):
// total RAM and CPU cores. Lives next to deviceInfo because that is where the
// native handle already is; read once, the numbers cannot change while the app
// runs. ramMb 0 = the platform refused, which deviceFloor reads as "not a floor
// device" so an unmeasurable phone keeps the full-quality path.
let devicePerfCache: { ramMb: number; cores: number } | null = null;
export function devicePerf(): { ramMb: number; cores: number } {
if (!devicePerfCache) {
try {
devicePerfCache = RecipescamExport.devicePerf();
} catch (e) {
console.warn('devicePerf unavailable, full-quality path:', e);
devicePerfCache = { ramMb: 0, cores: 0 };
}
// One line, so a floor device in the field can be confirmed from logcat:
// "adb logcat -s ReactNativeJS | grep floor".
const { ramMb, cores } = devicePerfCache;
console.info(`devicePerf: ramMb=${ramMb} cores=${cores} floor=${isFloorDevice(ramMb)}`);
}
return devicePerfCache;
}
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
// itself, so those bytes never pass through JS). Never throws — a metadata
// failure must not cost the user the photo.
+6
View File
@@ -1,5 +1,6 @@
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
import { sanitizeHslBands } from './colorUtils';
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
// recipe JSON, so a shared recipe imports back into the app on another phone.
@@ -94,6 +95,11 @@ export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
// The mixer is the one adjustment that arrives as a nested object, so it is
// the one that needs its own gate: unknown band ids are dropped, every value
// is clamped to the knob's range, and a band left at zero is dropped instead
// of carried around for nothing.
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}
+161 -3
View File
@@ -1,4 +1,5 @@
import { BaseFilter, ColorAdjustments } from '../types';
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
@@ -29,6 +30,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
// (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon
// clip a plain Saturation boost causes.
// hslOn/hslH/hslS/hslL - selective colour mixer: eight hue bands, each with a
// hue shift, a saturation scale and a lightness offset (-1..1, from the
// -10..10 knobs). Which band owns which pixel is decided HERE, per pixel
// from the pixel's hue — so unlike everything above, the eight bands are
// not one global move and cannot live in a colour matrix. See the band
// block at the end of TONE_SKSL.
// gh/gs/gl - the mixer's whole-image moves: those same three quantities applied
// to the WHOLE frame, so they are just the accumulator's seed values and
// every hue takes them at full weight. Lightness is NOT gated on
// saturation (unlike the bands), so a frame pulled to grey by -SAT still
// answers +LUM.
// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied
// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do
// this — it is one linear map, so any cast it applies must also hit the
@@ -36,6 +48,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
// warm highlights at once, so the stock ships these values and the pass
// stays active for it even when every user knob is 0. All-zero still = no
// pass.
// The mixer's eight band lines inside TONE_SKSL, generated from HSL_BANDS so the
// anchors and gaps in the shader are the numbers the chips were built from. Each
// line reads its band's three values at a CONSTANT index — SkSL can only index a
// uniform array by a constant, so this block is unrolled rather than looped.
const BAND_BLOCK = hslBandGaps()
.map(
(b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate;
acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n`
)
.join('');
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
@@ -50,6 +73,57 @@ uniform float hlTg;
uniform float hlTb;
uniform float cc;
uniform float ccb;
uniform float hslOn;
uniform float hslH[8];
uniform float hslS[8];
uniform float hslL[8];
uniform float gh;
uniform float gs;
uniform float gl;
// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are
// named after: a hue shift must not change how light a colour is, and a
// lightness move must not change its hue — exactly what scaling R,G,B gets
// wrong.
vec3 rgb2hsl(vec3 c) {
float mx = max(max(c.r, c.g), c.b);
float mn = min(min(c.r, c.g), c.b);
float l = (mx + mn) * 0.5;
float d = mx - mn;
if (d < 0.00001) return vec3(0.0, 0.0, l);
float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn);
float h;
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
return vec3(h / 6.0, s, l);
}
float hueChannel(float p, float q, float t) {
t = fract(t);
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y < 0.00001) return vec3(hsl.z);
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(
hueChannel(p, q, hsl.x + 1.0 / 3.0),
hueChannel(p, q, hsl.x),
hueChannel(p, q, hsl.x - 1.0 / 3.0)
);
}
// How much of a hue one band owns: full at the band's anchor, falling linearly
// to zero at the anchors next door (the gaps are uneven — red is 30 deg from
// orange and 40 deg from magenta). The linearity is the point: two neighbouring
// tents cross at exactly 0.5 at the midpoint, so the eight weights sum to 1 at
// every hue. No pixel is counted twice, no pixel falls in a gap, and a hue
// sitting on an anchor takes its band's full value instead of part of it.
float bandW(float hue, float anchor, float gapL, float gapR) {
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -102,7 +176,36 @@ vec4 main(vec2 xy) {
float mn = min(min(rgb.r, rgb.g), rgb.b);
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
// Selective colour by hue band — the LAST move, so a band edit is judged on
// the colour the user actually sampled from the render.
//
// Grey pixels are dropped before any weight is read: rgb2hsl hands them hue 0,
// so without the gate EVERY neutral pixel in the frame would be taken for pure
// red and drift with the red band. Below 8% saturation there is also no hue
// worth moving.
//
// The three accumulators are band values scaled by how much of the hue each
// band owns, so a hue halfway between two anchors takes a proportional mix of
// both edits — the blend the weights already add up to. Hue is a turn (plus or
// minus 30 deg at full), saturation a scale (0 = grey at -10) and lightness an
// additive offset (plus or minus 0.25 at full), which is why the ramp cannot
// invert.
if (hslOn > 0.5) {
vec3 hsl = rgb2hsl(rgb);
float gate = smoothstep(0.0, 0.08, hsl.y);
float hd = hsl.x * 360.0;
// The whole-image moves are the seed: every hue takes the hue turn and the
// saturation scale at full weight, and the bands add their share on top.
// Lightness is added below OUTSIDE the gate, so it still lifts a colour that
// -SAT has already pulled to grey.
vec3 acc = vec3(gh, gs, 0.0) * gate;
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
rgb = hsl2rgb(hsl);
}
return vec4(clamp(rgb, 0.0, 1.0), c.a);
}
`;
@@ -179,6 +282,13 @@ export interface ToneUniforms {
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
cc: number; // 0..1 Color Chrome depth (0 = 'none')
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
hslOn: number; // 1 when a band or a whole-image move is set (0 = skip the mixer)
hslH: number[]; // 8 x -1..1 in HSL_BANDS order (+/-30 deg hue at full)
hslS: number[]; // 8 x -1..1 (saturation scale, -1 = grey)
hslL: number[]; // 8 x -1..1 (additive lightness, +/-0.25 at full)
gh: number; // -1..1 whole-image hue turn (+/-30 deg at full)
gs: number; // -1..1 whole-image saturation scale
gl: number; // -1..1 whole-image lightness offset (+/-0.25 at full, ungated)
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
@@ -188,6 +298,9 @@ export interface ToneUniforms {
// without also moving the other.
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
'classic-chrome': { sh: -0.28 },
// Classic Vivid keeps Chrome's dived shadow: the shadow pull belongs to the
// stock, not to the matrix rows it borrows from Velvia.
'classic-vivid': { sh: -0.28 },
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
// Acros. A black-and-white stock IS its grey ramp, so this entry only shapes
// the two ENDS and leaves the middle an identity: a smooth shadow toe that
@@ -199,6 +312,14 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at
// ~5% and -0.05 trims the top ~1%.
monochrome: { sh: -0.12, hl: -0.05 },
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
// (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder
// that LIFTS instead of rolling (-0.05 becomes +0.26), which is the whites
// step of the brief. Midtones see neither mask, so the long smooth stretch
// between the two ends survives — that is what keeps a hard push off the
// posterised look, and the slope this stock needs on the greys is its matrix
// gain (STOCK_BIAS in colorUtils), not another mask here.
'mono-high-contrast': { sh: -0.32, hl: 0.26 },
};
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
@@ -213,11 +334,36 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
// monochrome look, so both are forced off there: the effect is colour-only
// (the preview/export matrix skips them for monochrome for the same reason).
const colour = baseFilter !== 'monochrome';
const colour = !isMonochromeBase(baseFilter);
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
// Selective colour: one slot per band, in HSL_BANDS order, so the flat buffer
// lines up with the shader's arrays. A band the user has not moved keeps three
// zeroes and only costs its own slots.
const bands = adj.hslBands ?? {};
const tenth = (v: unknown) =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0;
const hslH: number[] = [];
const hslS: number[] = [];
const hslL: number[] = [];
let hslOn = 0;
for (const band of HSL_BANDS) {
const v = bands[band.id];
const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0];
hslH.push(h);
hslS.push(s);
hslL.push(l);
if (h || s || l) hslOn = 1;
}
// A stock black-and-white has no hue to select on.
if (!colour) hslOn = 0;
// The mixer's whole-image move: every hue takes it at full weight.
const gh = tenth(adj.hslHue);
const gs = tenth(adj.hslSat);
const gl = tenth(adj.hslLum);
if (colour && (gh || gs || gl)) hslOn = 1;
return {
dr,
hl: hl + (film.hl ?? 0),
@@ -227,17 +373,29 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
hlT,
cc: chromeDepth(adj.colorChrome),
ccb: blueDepth(adj.colorChromeBlue),
hslOn,
hslH,
hslS,
hslL,
gh,
gs,
gl,
};
}
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
// order must match TONE_SKSL's declarations.
export function toneUniformArray(u: ToneUniforms): number[] {
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
return [
u.dr, u.hl, u.sh, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
];
}
export function toneIsActive(u: ToneUniforms): boolean {
return (
u.hslOn !== 0 ||
u.dr !== 0 ||
u.hl !== 0 ||
u.sh !== 0 ||
+4 -1
View File
@@ -1,6 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
"strict": true,
// burstAlign.check.ts runs under node's own type stripping, where the import
// must name the .ts file. Type-only flag; expo base already sets noEmit.
"allowImportingTsExtensions": true
}
}