diff --git a/HSL_PORT.md b/HSL_PORT.md new file mode 100644 index 0000000..76b3399 --- /dev/null +++ b/HSL_PORT.md @@ -0,0 +1,865 @@ +# Port HSL (selective colour mixer) — `docker/frontend` → Android + +Tài liệu này mô tả **chính xác** cách đưa tính năng HSL của bản web (`docker/frontend/`) sang app Android (Expo + Skia, source ở repo root). + +Web là **bản tham chiếu**: `docker/frontend/shared/` là bản copy của `src/` bên Android nhưng đã đi trước. Hướng port là: + +``` +docker/frontend/shared/** → src/** +docker/frontend/src/** → src/components/**, App.tsx +``` + +--- + +## 0. Phạm vi — chỉ HSL + +Diff giữa `src/` và `docker/frontend/shared/` **không chỉ có HSL**. Các delta sau nằm cùng file nhưng **KHÔNG thuộc tài liệu này**, tuyệt đối không port kèm: + +| Delta web-only | Nơi xuất hiện | Kết luận | +|---|---|---| +| WB white/black point (`whites`/`blacks`) | `types`, `paramDefs`, `toneShader` (`wh`/`bl`), `defaultRecipes` | **BỎ** | +| Base filter `classic-vivid` (`sim-classic-vivid`) | `types`, `toneShader` `FILM_TONE`, `defaultRecipes` | **BỎ** | +| Frame `wallframe-landscape` | `types` `FrameId`, `recipeShare` `FRAMES`, chips ROTATE/wall | **BỎ** | +| `GLOW_T0/GLOW_T1` 0.55/0.85 → 0.45/0.75 | `toneShader` | **BỎ** — đây là chỉnh demo của web; Android giữ `0.55/0.85` | +| `exifWrite.ts` diff (22 dòng) | — | không liên quan HSL | + +Chỉ port: **types HSL**, **`colorUtils` HSL**, **`toneShader` mixer**, **`recipeShare` sanitize**, **`defaultRecipes` 3 field toàn ảnh**, **UI tab HSL**. + +--- + +## 1. Ngữ nghĩa (đọc trước khi code) + +Mixer chọn màu theo **8 dải hue**. Mỗi dải có 3 knob `[hue, sat, lum]`, mỗi knob `-10..+10`: + +- **hue** — xoay hue, ±30° khi full (`acc.x * 30/360`). +- **sat** — scale saturation, `-10` = xám (`s * (1 + acc.y)`, `acc.y = -1`). +- **lum** — cộng lightness, ±0.25 khi full (`acc.z * 0.25`), additive nên không đảo ramp. + +Ba knob **toàn ảnh** (`hslHue`, `hslSat`, `hslLum`) là "seed" của accumulator: mọi hue nhận full weight. + +Quy tắc bất biến — port phải giữ đúng: + +1. **Partition of unity.** Weight mỗi dải là tent tuyến tính: full tại anchor của nó, về 0 tại anchor hai dải kề. Hai tent kề cắt nhau đúng tại 0.5 ở midpoint ⇒ 8 weight **cộng lại bằng 1 tại mọi hue**. Không pixel nào bị tính hai lần, không hue nào rơi vào khe. +2. **Anchor cố định 1 nguồn.** `HSL_BANDS` (cho chip) và `BAND_BLOCK` (cho shader) sinh từ cùng bảng qua `hslBandGaps()` ⇒ anchor không thể lệch. +3. **Gate xám.** `gate = smoothstep(0.0, 0.08, hsl.y)` — pixel có saturation < 8% bị loại khỏi weight, vì `rgb2hsl` trả hue 0 cho xám và nếu không gate thì **mọi pixel trung tính sẽ chạy theo dải RED**. +4. **`gl` KHÔNG gate.** Lightness toàn ảnh (`gl`) được cộng **sau** gate, nên ảnh đã bị `-SAT` rút về xám vẫn còn `+LUM`. +5. **Monochrome tắt mixer.** `if (!colour) hslOn = 0;` — stock đen trắng không có hue để chọn. +6. **Dải toàn 0 bị xoá.** Band `[0,0,0]` không bao giờ vào recipe; kéo knob về 0 thì dải biến mất không để lại dấu. +7. **Mixer chạy CUỐI**, sau split-tone/kv và sau vibrance — để band edit được đánh giá trên màu người dùng thực sự sample. + +--- + +## 2. `src/types/index.ts` + +Thêm 2 type (đặt cạnh `BaseFilter`/`ColorAdjustments`): + +```ts +export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta'; +// [hue, sat, lum], mỗi số -10..+10; dải toàn 0 bị xoá khỏi recipe +export type HslBand = [number, number, number]; +``` + +Trong `interface ColorAdjustments` (sau `vibrance?`, trước `exposureCompensation`): + +```ts + hslBands?: Partial>; // selective colour: mỗi dải [hue, sat, lum], -10..+10 + hslHue?: number; // -10..+10 xoay hue toàn ảnh + hslSat?: number; // -10..+10 scale saturation toàn ảnh + hslLum?: number; // -10..+10 cộng lightness toàn ảnh +``` + +> `hslBands` là **một field lồng**, không phải 24 field phẳng — đây là lý do nó cần gate riêng khi import (mục 5). + +--- + +## 3. `src/utils/colorUtils.ts` + +File Android hiện chỉ có `kelvinToRGB`, `getSkiaColorMatrix`, `applyExposureGain`. Thêm nguyên khối sau **lên đầu file** (sau import): + +```ts +import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types'; + +// 8 dải của mixer chọn màu, và anchor hue mỗi dải đứng (độ trên vòng HSL). +// Anchor cố tình không đều: red/orange/yellow cách nhau 30° vì mắt phân biệt +// kỹ vùng này, còn phía blue là một khúc 60°. TONE_SKSL dựng đúng 8 weight này +// từ bảng dưới, nên anchor chip sửa và anchor shader mask không thể lệch. +// +// `label` là chữ chip in ra — chuỗi của app, KHÔNG nằm trong 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 (độ) của hai dải kề mỗi dải, đúng như TONE_SKSL cần. Suy ra từ HSL_BANDS +// để hai bảng không thể cãi nhau: tent weight của một dải về 0 tại anchor của +// hai dải kề, khiến 8 weight là partition of unity trên vòng hue (cộng = 1 tại +// mọi 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°) wrap: dải kề trái là magenta 320°, tức 40° về sau. + return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 }; + }); +} + +// 0..255 sRGB → HSL. Hue theo độ, saturation/lightness 0..1 — chiều ngược của +// hsl2rgb trong shader, và là thứ readout của eyedropper in ra. +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 }; +} + +// Dải mà một hue sample thuộc về: anchor gần nhất. Các dải chồng nhau trong +// shader, nên hàm này chỉ quyết định ruler đang trỏ vào dải nào. +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; +} + +// Một band triple như khi lưu: số nguyên -10..10, dải toàn 0 bị xoá để recipe +// chỉ mang theo thứ người dùng thực sự đổi. +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> | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const out: Partial> = {}; + for (const band of HSL_BANDS) { + const v = (raw as Record)[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; +} +``` + +Không sửa `getSkiaColorMatrix`: mixer nằm ở tone shader, matrix 4×5 không làm được per-pixel hue. + +--- + +## 4. `src/utils/toneShader.ts` — shader mixer + +Đây là phần lõi. Sáu sửa đổi, theo thứ tự trong file. + +### 4.1 Import + `BAND_BLOCK` + +```ts +import { BaseFilter, ColorAdjustments } from '../types'; +import { HSL_BANDS, hslBandGaps } from './colorUtils'; +``` + +Thêm **trước** `export const TONE_SKSL`: + +```ts +// Tám dòng band của mixer trong TONE_SKSL, sinh từ HSL_BANDS để anchor và gap +// trong shader chính là số mà chip dựng ra. Mỗi dòng đọc 3 giá trị của dải nó +// với index HẰNG — SkSL chỉ index uniform array bằng hằng, nên block này phải +// unroll chứ không loop được. +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(''); +``` + +### 4.2 Comment header + +Thêm vào khối comment đầu file (cạnh `vib`): + +``` +// hslOn/hslH/hslS/hslL - mixer chọn màu: 8 dải hue, mỗi dải một hue shift, một +// saturation scale và một lightness offset (-1..1, từ knob -10..10). Dải +// nào sở hữu pixel nào được quyết định Ở ĐÂY, per-pixel theo hue — nên +// khác mọi thứ phía trên, 8 dải không phải một phép move toàn cục và +// không thể nằm trong colour matrix. Xem band block ở cuối TONE_SKSL. +// gh/gs/gl - move toàn ảnh của mixer: đúng ba đại lượng đó cho TOÀN ảnh, nên +// chúng chỉ là giá trị khởi tạo của accumulator và mọi hue nhận full +// weight. Lightness KHÔNG bị gate theo saturation (khác band), nên ảnh bị +// -SAT rút về xám vẫn trả lời +LUM. +``` + +### 4.3 Uniform khai báo trong `TONE_SKSL` + +Thêm **sau `uniform float ccb;`** (Android không có `wh`/`bl`, nên HSL nối thẳng sau `ccb`): + +```glsl +uniform float hslOn; +uniform float hslH[8]; +uniform float hslS[8]; +uniform float hslL[8]; +uniform float gh; +uniform float gs; +uniform float gl; +``` + +Rồi **ngay trước `vec4 main(vec2 xy)`**, thêm 4 helper + mixer vào chuỗi SkSL: + +```glsl +// sRGB <-> HSL. Mixer làm việc trong HSL vì đó là không gian knob được đặt tên +// theo: một hue shift không được đổi độ sáng của màu, và một lightness move +// không được đổi hue — đúng thứ mà scale RGB làm sai. +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) + ); +} +// Mức sở hữu một hue của một dải: full tại anchor của dải, giảm tuyến tính về 0 +// tại anchor hai dải kề (gap không đều — red cách orange 30° và cách magenta +// 40°). Tính tuyến tính là mấu chốt: hai tent kề cắt nhau đúng 0.5 tại midpoint, +// nên 8 weight cộng lại bằng 1 tại mọi hue. Không pixel nào bị tính hai lần, +// không pixel nào rơi vào khe, và hue nằm đúng anchor nhận full giá trị của dải +// đó thay vì một phần. +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); +} +``` + +### 4.4 Mixer trong `main()` — thay đoạn kết + +Trong `main`, đoạn cuối Android hiện là: + +```glsl + float kv = 1.0 + vib * 0.75 * (1.0 - chroma); + return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a); +} +``` + +Đổi thành: + +```glsl + float kv = 1.0 + vib * 0.75 * (1.0 - chroma); + rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0); + // Selective colour theo dải hue — move CUỐI, để một band edit được đánh giá + // trên đúng màu người dùng sample từ render. + // + // Pixel xám bị loại trước khi đọc weight: rgb2hsl trả hue 0 cho nó, nên nếu + // không gate thì MỌI pixel trung tính trong khung sẽ bị coi là đỏ nguyên chất + // và trôi theo dải red. Dưới 8% saturation thì cũng không có hue để move. + // + // Ba accumulator là giá trị band nhân mức sở hữu, nên một hue nằm giữa hai + // anchor nhận hỗn hợp tỉ lệ của hai edit — đúng blend mà weight đã cộng ra. + // Hue là phép xoay (±30° khi full), saturation là scale (0 = xám tại -10), + // lightness là additive (±0.25 khi full) nên không thể đảo ramp. + if (hslOn > 0.5) { + vec3 hsl = rgb2hsl(rgb); + float gate = smoothstep(0.0, 0.08, hsl.y); + float hd = hsl.x * 360.0; + // Move toàn ảnh là seed: mọi hue nhận hue turn và saturation scale ở full + // weight, các dải cộng thêm phần của mình lên trên. Lightness được cộng + // bên dưới KHÔNG gate, nên nó vẫn nâng một màu đã bị -SAT rút về xám. + 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); +} +``` + +> `${BAND_BLOCK}` phải nằm trong template literal — 8 dòng sinh ra nối liền ngay trước `hsl.x =`. Giữ đúng dạng đó, đừng thụt lề lại: SkSL không quan tâm, nhưng giữ khớp bản web để diff sau này đọc được. + +### 4.5 `ToneUniforms` + +Thêm vào interface (sau `ccb`): + +```ts + hslOn: number; // 1 khi có band hoặc move toàn ảnh được set (0 = skip mixer) + hslH: number[]; // 8 × -1..1 theo dải, thứ tự HSL_BANDS (±30° hue khi full) + hslS: number[]; // 8 × -1..1 (saturation scale, -1 = xám) + hslL: number[]; // 8 × -1..1 (lightness additive, ±0.25 khi full) + gh: number; // -1..1 xoay hue toàn ảnh (±30° khi full) + gs: number; // -1..1 scale saturation toàn ảnh + gl: number; // -1..1 cộng lightness toàn ảnh (±0.25 khi full, ungated) +``` + +### 4.6 `getToneUniforms` + +Trong thân hàm, **trước `return {`**: + +```ts + // Selective colour: một slot mỗi dải, theo thứ tự HSL_BANDS, để flat buffer + // khớp với array của shader. Dải người dùng chưa move giữ ba số 0 và chỉ tốn + // slot của nó. + 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; + } + // Stock monochrome không có hue để chọn theo. + if (!colour) hslOn = 0; + // Move toàn ảnh của mixer, mọi hue nhận full weight. + const gh = tenth(adj.hslHue); + const gs = tenth(adj.hslSat); + const gl = tenth(adj.hslLum); + if (colour && (gh || gs || gl)) hslOn = 1; +``` + +Và thêm vào object return (cuối, sau `ccb`): + +```ts + hslOn, + hslH, + hslS, + hslL, + gh, + gs, + gl, +``` + +> `colour` đã có sẵn trong hàm (`const colour = baseFilter !== 'monochrome'`). Đặt khối HSL **sau** dòng đó. + +### 4.7 `toneUniformArray` + +```ts +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, + u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl, + ]; +} +``` + +Tổng độ dài: `12 + 1 + 8 + 8 + 8 + 3 = 40`. + +### 4.8 `toneIsActive` + +`u.hslOn !== 0 ||` là **clause đầu tiên**: + +```ts +export function toneIsActive(u: ToneUniforms): boolean { + return ( + u.hslOn !== 0 || + u.dr !== 0 || + ... // phần còn lại giữ nguyên + ); +} +``` + +--- + +## 5. `src/utils/recipeShare.ts` + +Import: + +```ts +import { sanitizeHslBands } from './colorUtils'; +``` + +Trong `importRecipeXml`, **trước** dòng `const frameId: FrameId = ...`: + +```ts + // Mixer là adjustment duy nhất đến dưới dạng object lồng, nên là cái duy nhất + // cần gate riêng: band id lạ bị bỏ, mọi giá trị bị kẹp về range knob, và một + // dải để nguyên 0 bị xoá thay vì mang theo vô ích. + adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands); +``` + +`exportRecipeXml` **không cần sửa**: `adjustments` được nhân bản nguyên khối vào payload JSON, `hslBands` tự đi theo. Format `.recipe` (XML bọc hex xorshift) giữ nguyên `version="1"`. + +> `storageUtils.getAllRecipes` merge `{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }` — đủ cho 3 field toàn ảnh. `hslBands` từ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêm `sanitizeHslBands` trong `fill`. + +--- + +## 6. `src/utils/defaultRecipes.ts` + +Thêm 3 field vào `DEFAULT_ADJUSTMENTS` (sau `vibrance` nếu có, trước `exposureCompensation`): + +```ts + hslHue: 0, + hslSat: 0, + hslLum: 0, +``` + +Không thêm `hslBands` vào đây — nó optional và `undefined` mới là "không có dải nào"; thêm `hslBands: {}` sẽ làm `differs()` trong `App.tsx` báo dirty sai. + +--- + +## 7. Uniform buffer — các điểm Android phải sửa thêm + +`exportEngine.ts` **không cần sửa**: nó gọi `toneUniformArray(tone)` + `toneIsActive(tone)`, cả hai đã tự mang HSL. + +`nativeExport.ts` (dev probe, `EXPO_PUBLIC_NATIVE_EXPORT`) **không mang HSL** — giống `colorChrome`, nó chỉ truyền `toneDr/toneHl/toneSh` vào `PhotoAdjust`. Ghi rõ trong comment cạnh comment "port them into applyTone()" hiện có; không cần làm gì thêm cho tới khi native path thành đường chính. + +### 7.1 `src/components/Viewfinder.tsx` — BẮT BUỘC, dễ sót + +Camera worklet đọc flat buffer qua `toneSync`, có hai chỗ hardcode theo độ dài cũ: + +```tsx +// dòng ~499 +const toneSync = useMemo( + () => createSynchronizable(new Array(10).fill(0)), + [], +); +``` + +```tsx +// dòng ~1270 +const tone = toneSync.getDirty(); +const hasTone = + toneEffect != null && + (tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0); +``` + +Sửa: + +1. `new Array(40).fill(0)` — đúng bằng độ dài `toneUniformArray`. +2. `hasTone` thêm `tone[12] !== 0` (đây là `hslOn`). + +Nếu bỏ sót, worklet đọc `undefined` ở các slot mới ⇒ uniform `NaN` ⇒ **cả tone pass hỏng trên preview camera**, không chỉ HSL. + +Library (declarative ``) — thêm key vào object `toneUniforms` (dòng ~2999): + +```tsx + const toneUniforms = { + dr: toneParams[0], + hl: toneParams[1], + sh: toneParams[2], + vib: toneParams[3], + shTr: toneParams[4], + shTg: toneParams[5], + shTb: toneParams[6], + hlTr: toneParams[7], + hlTg: toneParams[8], + hlTb: toneParams[9], + cc: toneParams[10], + ccb: toneParams[11], + 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], + }; +``` + +`toneOn = toneParams.some((v) => v !== 0)` giữ nguyên — buffer phẳng nên vẫn đúng. + +> **Cần xác nhận trên máy:** nhánh declarative `` của RN Skia phải nhận **array** uniform (`float hslH[8]`). Nếu nó flatten/không nhận, chuyển nhánh library preview sang `makeShaderWithChildren(toneUniformArray(tone), [imageShader])` như camera/export đã làm — cùng một nguồn số, bỏ hẳn object named-uniform. + +--- + +## 8. UI Android + +### 8.1 `src/components/ToolRail.tsx` + +```ts +export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame'; +``` + +Thêm vào `TOOLS`, **giữa `filters` và `frame`** (web đặt HSL cùng nhóm màu): + +```ts + { id: 'filters', label: 'FX' }, + { id: 'hsl', label: 'HSL' }, + { id: 'frame', label: 'FRAME' }, +``` + +### 8.2 State — nâng lên `App.tsx` + +Web để state HSL trong `App`. Android cũng phải vậy, vì **cả `AdjustmentPanel` (chip) lẫn `Viewfinder` (eyedropper + panel nổi) đều cần**: + +```tsx +const [hslBand, setHslBand] = useState('red'); // dải ruler đang sửa +const [picking, setPicking] = useState(false); // eyedropper đã arm chưa +const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null); +const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null); // vị trí trên ảnh +``` + +Truyền xuống `Viewfinder`: `picking`, `hslPickedAt`, `onPickColor`, và một node `hslPanel` (JSX panel, xem 8.5). Truyền xuống `AdjustmentPanel`: `hslBand`, `onHslBand`, `picking`, `onPicking`, `hslSample`, `adjustments`, `onUpdateAdjustments` (đã có). + +Hai setter (đặt cạnh nhau, cả hai gọi `onUpdateAdjustments`): + +```tsx +// Một band triple như khi lưu. Dải về [0,0,0] bị XOÁ, và khi không còn dải nào +// thì field trả về undefined (không phải {}) để `differs(adjustments, +// DEFAULT_ADJUSTMENTS)` trong resetDirty không báo dirty sai. +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; + onUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined }); +}; + +// Move toàn ảnh: một cửa cho cả ba knob, nên ruler và field nó ghi luôn là cùng +// một giá trị dù mở từ đâu. +const setHslGlobal = (which: 0 | 1 | 2, v: number) => { + const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum'; + onUpdateAdjustments({ [key]: Math.max(-10, Math.min(10, Math.round(v))) } as Partial); +}; +``` + +`pickColor` (App đưa xuống Viewfinder): + +```tsx +// Báo cáo của eyedropper: in màu đọc được, trỏ ruler về dải của màu đó, treo +// panel lên đúng điểm nó được đọc, rồi cất dụng cụ đi — một lần pick một màu. +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); +}; +``` + +### 8.3 `src/components/AdjustmentPanel.tsx` + +1. `type TabId` (dòng 20) thêm `| 'hsl'`. +2. `paramDefs: Record` thêm `hsl: []` **hoặc** xử lý riêng trong `chipRow` (xem dưới). Ba knob toàn ảnh **không** vào `PARAM_DEFS` — chúng đọc/ghi field của `adjustments` nhưng label là `HUE IMAGE`/`SAT IMAGE`/`LUM IMAGE`, và mở row qua cùng cơ chế `openParam` với `PARAM_GROUP` để nút back đóng row. + +Cách gọn nhất, khớp cấu trúc sẵn có: mở rộng `paramDefs.hsl` bằng 3 `ParamDef` với `key` là `'hsl.h' | 'hsl.s' | 'hsl.l'` và `onChange: (v) => setHslGlobal(which, v)`. Khi đó `openParamDef` + row slider sẵn có tự hoạt động, chip hiện value, RESET/back hoạt động. + +```tsx + // Ba knob toàn ảnh của mixer không nằm trong PARAM_DEFS: chúng đọc/ghi move + // của CHÍNH ẢNH chứ không phải một field phẳng của adjustments, và khác panel + // trên ảnh, chúng không gắn với dải mà mixer đang trỏ. + hsl: [0, 1, 2].map((i) => { + const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0]; + const key = i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l'; + const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM'; + return { + key, + 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), + }; + }), +``` + +3. Thêm `case 'hsl'` vào switch `chipRow`: + +```tsx + case 'hsl': { + // PICK arm eyedropper; 8 chip dải chọn dải mà ruler sửa; ruler do ba + // knob bên dưới mở. Ba knob đó là move toàn ảnh, không phải của dải: + // chip dải chọn màu cho panel trên ảnh, còn HUE/SAT/LUM dưới divider + // move mọi hue trong khung cùng lúc. + return chipRow([ + { key: 'hsl-pick', label: 'PICK', active: picking, onPress: () => onSetPicking(!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, + // Chip gọi tên một màu nên nó hiện đúng màu đó. + color: hslToHex(b.hue, 70, 50), + active: hslBand === b.id, + amberValue: hslBand !== b.id && moved, + onPress: () => onSetHslBand(b.id), + }; + }), + // Divider giữ ba slider toàn ảnh ra ngoài hàng màu: hàng trên chọn + // một màu, ba cái này move tất cả. + { key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} }, + ...paramChips(paramDefs.hsl), + ], 8); + } +``` + +4. `ChipDef` cần thêm `color?: string` (chip màu). Trong `renderChip`, khi `c.color` có mặt, vẽ một dot màu trước label (Android dùng `View` với `backgroundColor`): + +```tsx +{c.color ? : null} +``` + +Chip màu vẫn giữ text label (RED/ORANGE/…) — dot là phụ, không thay chữ. + +5. Readout khi `activeTab === 'hsl'`: Android không có cột phụ như web. Đặt một **dòng readout mỏng ngay trên hàng chip** (chỗ `openParamDef` row đang render), chỉ khi tab hsl: + +```tsx +{activeTab === 'hsl' && ( + + {hslSample ? ( + <> + + + R {hslSample.r} · G {hslSample.g} · B {hslSample.b} + + + {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).h)}° ·{' '} + {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).s * 100)}% ·{' '} + {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).l * 100)}% + + {bandName} + + ) : ( + + BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH + + )} + +)} +``` + +`bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''`. + +### 8.4 `hslToHex` (dùng cho chip màu) + +Copy nguyên từ web (`docker/frontend/src/App.tsx` dòng 108) vào `src/utils/colorUtils.ts` hoặc ngay trong `AdjustmentPanel.tsx`: + +```ts +// HSL -> #rrggbb. Chip gọi tên một màu nên nó phải hiện đúng màu đó; hex là +// format duy nhất RN nhận thẳng từ chuỗi. +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)}`; +} +``` + +### 8.5 Eyedropper + panel nổi — `src/components/Viewfinder.tsx` + +Đây là phần **chưa có tương ứng Android**, phải viết mới. + +**Props thêm:** + +```tsx + picking?: boolean; + onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void; + hslPanel?: React.ReactNode; + hslPanelAt?: { fx: number; fy: number } | null; +``` + +**Lớp pick.** Web đặt một `div` phủ đúng **box của ảnh** (`box` = rect ảnh sau zoom/pan, không phải cả canvas). Android đã có `imageFitRect` và sẵn một touch layer trong library mode (`onLibTouchStart` với toạ độ fraction). Cách port đúng và ít code nhất: + +- Khi `picking`, thêm một `Pressable` (hoặc `View` với `onStartShouldSetResponder`) phủ **đúng `imageFitRect`**, chặn touch để pan/zoom không khởi động. +- Toạ độ fraction tính từ rect **của lớp đó**, không phải của canvas: + +```tsx +const fx = (e.nativeEvent.locationX) / rectW; +const fy = (e.nativeEvent.locationY) / rectH; +if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; +``` + +- Hiện một icon eyedropper đi theo ngón tay (`pick-icon` của web là SVG; Android dùng `lucide-react-native` đã có sẵn: ``). +- Chỉ arm trong **library mode**. Camera mode không có pixel để sample (và preview là luồng sống). + +**Lấy màu.** Web sample chính **ảnh preview đã render** (`previewUrl`), không phải file gốc — để màu đọc được đúng là màu đang thấy. Android tương đương: snapshot canvas đang vẽ. Đường ngắn nhất: + +```tsx +// canvasRef = useCanvasRef() trên canvas library đang vẽ `graded`. +const shot = canvasRef.current?.makeImageSnapshot(); // ảnh đã render +const px = await shot.readPixels(Math.floor(fx * shot.width()), Math.floor(fy * shot.height()), { + width: 1, height: 1, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul, +}); +onPickColor({ r: px[0], g: px[1], b: px[2] }, { fx, fy }); +``` + +Đọc **1 pixel** từ snapshot là đủ và rẻ; cache snapshot trong lúc `picking` để mỗi lần chạm không phải snapshot lại. + +> **ponytail:** snapshot canvas mỗi lần pick là đủ cho một lần chạm/ảnh; nếu độ trễ cảm nhận được trên máy thật, chuyển sang decode `skiaImage` một lần và `readPixels` trên đó — nhưng khi đó màu sample là màu **gốc**, không phải màu đã grade, và hue có thể lệch khỏi thứ người dùng nhìn thấy nếu các knob khác đã move. Chỉ hạ cấp khi có số đo. + +**Panel nổi.** Web đặt panel tại `left = fx*100%`, `top = fy*100%` **trong box ảnh**, với `transform: translate(-50%, 14px)`, hoặc `translate(-50%, calc(-100% - 14px))` khi `fy > 0.55` (điểm nằm thấp thì panel treo lên trên để không rơi khỏi ảnh). Android absolute-position trong chính box đó: + +```tsx +{hslPanel && hslPanelAt && ( + + 0.55 ? -(PANEL_H + 14) : 14 }, + ], + }} + > + {hslPanel} + + +)} +``` + +Panel dừng pointer để không rơi xuống lớp pan của ảnh. + +### 8.6 Panel nội dung (`hslPanel`) + +Web dựng `pickPanel` ở `App.tsx`; Android dựng trong `AdjustmentPanel` (nó đã có `Slider` + haptics) rồi App truyền node xuống Viewfinder, hoặc dựng thẳng trong App. Nội dung: + +- **Swatch** — màu của sample sau khi áp knob của dải đang chọn: + +```tsx +const picked = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null; +const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); +const mixedHex = picked + ? 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])), + ) + : null; +``` + +- **Tên dải + hex**, nút đóng `✕` (`onPress={() => setHslPickedAt(null)}`). +- **Ba MiniSlider** `HUE` / `SAT` / `LUM`, mỗi cái `value = band[i]`, `onValueChange = (v) => setBandKnob(i, v)`, và reset về 0 (web: double-click; Android: thêm một `TouchableOpacity` nhỏ hoặc `onSlidingComplete` khi |v| < 0.5 ⇒ 0). Dùng `Slider` (`@react-native-community/slider`) với `minimumValue={-10} maximumValue={10} step={1}`. + +### 8.7 `resetDirty` — `App.tsx` + +`differs()` là **shallow**. Với `setBandKnob` trả `hslBands: undefined` khi rỗng (8.2), `differs(adjustments, DEFAULT_ADJUSTMENTS)` đã bắt đúng cả `hslBands` lẫn `hslHue/hslSat/hslLum`. **Không cần thêm clause** — miễn là đừng để `hslBands: {}`. + +Nếu vì lý do nào đó muốn giữ `{}`, thì phải thêm `|| Object.keys(adjustments.hslBands ?? {}).length > 0` (đúng như web) — nhưng cách `undefined` sạch hơn. + +### 8.8 Camera mode + PRO gate — quyết định + +- **Camera mode:** mixer tự động chạy trong preview camera (7.1 sửa xong `toneSync`), nên tab HSL cứ hiện ở cả hai mode. **PICK + panel chỉ có nghĩa ở library** — ở camera mode cho chip `PICK` ở trạng thái `disabled` (chip xám, không mở). Đây là điểm khác web (web library-only) và là chỗ dễ tranh cãi nhất — **xác nhận với người dùng nếu muốn khác**. +- **PRO gate:** web **không** gate HSL (`proLookInUse()` chỉ phủ `PRO_FRAMES` + GPS + HDF; tab HSL mở cho guest). Port giữ nguyên: **HSL là tính năng LITE**, không thêm vào `entitlement.ts`. +- **`RecipeCreateModal`:** không cần thêm dòng HSL. Web cũng không có HSL trong form CREATE — mixer chỉ chỉnh trên ảnh, recipe lưu qua `adjustments` khi SAVE. + +--- + +## 9. Kiểm tra + +### 9.1 Trên Android (self-check + unit) + +1. **`HSL_BANDS` / `hslBandGaps`** — bất biến partition of unity: + +```ts +// Với mọi hue 0..359, tổng 8 tent weight phải = 1 (±1e-6). +const gaps = hslBandGaps(); +for (let h = 0; h < 360; h += 1) { + const sum = gaps.reduce((s, b) => { + const d = ((h - b.hue + 540) % 360) - 180; + const w = d <= 0 ? Math.max(0, 1 + d / b.left) : Math.max(0, 1 - d / b.right); + return s + w; + }, 0); + if (Math.abs(sum - 1) > 1e-6) throw new Error(`weights != 1 at ${h}: ${sum}`); +} +``` + +Và `hslBandGaps()` trả `red.left = 40`, `red.right = 30` (wrap đúng). + +2. **`sanitizeHslBands`**: + - `{ red: [99, -99, 0] }` → `{ red: [10, -10, 0] }` + - `{ red: [0,0,0] }` → `undefined` + - `{ red: [1,2] }`, `{ nope: [1,2,3] }` → bỏ + - `null` / `'x'` → `undefined` + +3. **`rgbToHsl` ↔ `nearestHslBand`**: đỏ thuần `(255,0,0)` → `red`; `(255,128,0)` → `orange`; xám `(128,128,128)` → `s = 0`, `nearestHslBand(0) = 'red'` (rồi shader gate nó đi). + +4. **Shader compile** — `Skia.RuntimeEffect.Make(TONE_SKSL)` không throw. Bắt buộc, vì `${BAND_BLOCK}` nội suy vào chuỗi. + +5. **`toneUniformArray`** — `length === 40`; `hslOn` ở index 12; `gh/gs/gl` ở 37/38/39. + +6. **`toneIsActive`** — `{ ...all zero, hslOn: 1 }` → `true`; `{ ...all zero }` → `false`. + +7. **`getToneUniforms`** — `baseFilter: 'monochrome'` + `hslBands` có giá trị ⇒ `hslOn === 0`; `colour` + `hslLum: 10` ⇒ `gl === 1`, `hslOn === 1`. + +8. **Round-trip `.recipe`** — recipe có `hslBands: { blue: [3,-2,5], red: [0,0,0] }` export rồi import: `red` biến mất, `blue` giữ nguyên; `hslHue/Sat/Lum` sống qua vòng. + +9. **Máy / emulator** (kiểm tra số, không nhìn ảnh): + - Preview camera với `+10` dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằng `readPixels` trên cùng toạ độ ⇒ hue trôi ~+30°. + - `-10` SAT dải RED trên vùng đỏ ⇒ saturation về ~0; `+10` LUM ⇒ lightness tăng ~0.25. + - `hslLum = +10` trên một ảnh đã `saturation = -10`: lightness vẫn phải tăng (kiểm tra `gl` không bị gate). + - Monochrome stock: uniform HSL toàn 0 bất kể `hslBands` có gì. + - Export ra file: pixel cuối phải khớp pixel preview (cùng một `toneUniformArray`). + +### 9.2 Tham chiếu hành vi từ web + +Các script e2e của web là **hợp đồng hành vi** để đối chiếu, nằm trong scratchpad: + +``` +/home/locpham/.penguin/data/default_project/agents/default_agent/scratchpad/session-2026-09-06-18-30-42-c24c8c7b/ + hsl-test.cjs # chips dải, setBandKnob, xoá dải toàn 0 + hsl-panel-test.cjs # PICK -> pickColor -> panel nổi -> knob panel + hsl-shader-test.cjs # partition of unity + kết quả shader theo hue + hsl-bundle.cjs # build standalone + hsl-entry.ts # entry +``` + +Web đã deploy (`docker/frontend`) là bản chạy được để so từng bước: mở tab `hsl`, PICK một màu, kéo HUE/SAT/LUM trên panel và trên ruler `IMAGE`, đối chiếu `R n · G n · B n` / `h° · s% · l%`. + +--- + +## 10. Thứ tự thực hiện (đề xuất) + +1. `types` → 2. `colorUtils` (HSL_BANDS … sanitize) → 3. `toneShader` (4.1–4.8) → 4. self-check 9.1.1–9.1.7 chạy pass → 5. `defaultRecipes` + `recipeShare` → 6. `Viewfinder` buffer (7.1) — **làm trước UI**, nếu không preview camera sẽ vỡ → 7. `ToolRail` + `AdjustmentPanel` chip/readout → 8. `Viewfinder` eyedropper + panel + state App → 9. máy thật. diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 6802550..01c17a1 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -88,6 +88,11 @@ CREATE TABLE IF NOT EXISTS email_verifications ( expires_at TEXT NOT NULL, created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS places ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + at TEXT NOT NULL +); CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); @@ -711,6 +716,14 @@ export function photoFile(id: number): { file: string; mime: string } | undefine | undefined; } +// The same row, owner-scoped: the routes that serve or write the editable base +// behind a saved render answer only to the account that made the photo. +export function photoFileOwned(userId: number, id: number): { file: string; mime: string } | undefined { + return db.prepare('SELECT file, mime FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as + | { file: string; mime: string } + | undefined; +} + // The QR card's payload: the stored look, as raw JSON, and where the photo is // allowed to show. The route decides who may read it (a curated `qr` slot), so // this returns the row as stored, recipe included. @@ -761,6 +774,22 @@ export function deleteAllPhotos(): string[] { // look, so every frame on the strip is rated the same way. `visitor` is the // salted-address hash the counter already uses, which is what makes a vote // one-per-visitor without an account and without storing anything identifying. +// Reverse-geocode cache, keyed by the coordinate rounded to ~11m. An empty +// string is a coordinate the geocoder answered about and had no name for, which +// is a real answer and is cached like any other; `undefined` means "never +// asked" — the caller then makes the call. +export function findPlace(key: string): string | undefined { + const row = db.prepare('SELECT name FROM places WHERE key = ?').get(key) as { name: string } | undefined; + return row?.name; +} + +export function savePlace(key: string, name: string): void { + db.prepare( + `INSERT INTO places (key, name, at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET name = excluded.name, at = excluded.at`, + ).run(key, name, new Date().toISOString()); +} + export type Rating = { avg: number; n: number; mine: number }; export function rateLook(key: string, visitor: string, stars: number): void { @@ -860,11 +889,15 @@ const BUCKET_COLUMN = { export function eventStats(days: number, limit = 12): EventStats { const since = new Date(Date.now() - days * 86_400_000).toISOString(); + // Real visits only: the crawler and headless-reading rows the counter used to + // keep are filtered here as well as at the door, so the numbers are traffic + // and not a scan of the page by something with no one behind it. const grouped = (column: string, kind: EventKind | null): EventBucket[] => db .prepare( `SELECT ${column} AS key, COUNT(*) AS n FROM events WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> '' + AND COALESCE(device, '') <> 'bot' AND (? IS NULL OR kind = ?) GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`, ) @@ -876,7 +909,7 @@ export function eventStats(days: number, limit = 12): EventStats { SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks, COUNT(DISTINCT visitor) AS visitors - FROM events WHERE at >= ?`, + FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot'`, ) .get(since) as { views: number | null; clicks: number | null; visitors: number }; @@ -892,7 +925,7 @@ export function eventStats(days: number, limit = 12): EventStats { `SELECT substr(at, 1, 10) AS date, SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks - FROM events WHERE at >= ? GROUP BY date`, + FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot' GROUP BY date`, ) .all(since) as { date: string; views: number; clicks: number }[]; for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row); diff --git a/docker/backend/src/place.ts b/docker/backend/src/place.ts new file mode 100644 index 0000000..281f08a --- /dev/null +++ b/docker/backend/src/place.ts @@ -0,0 +1,61 @@ +// Place names for a coordinate. The phone app asks the OS geocoder, and a +// browser has no twin of that, so the lookup lives here: one HTTP call behind a +// cache, and the same naming rule the app uses, so a stamp reads the same on +// both. +import { findPlace, savePlace } from './db'; + +// ~11m at the equator: fixes from the same spot share a cache row. +const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`; + +// Nominatim (OpenStreetMap). Its usage policy asks for one request per second, +// a caller that identifies itself, and answers that are kept — hence the queue +// below and the SQLite row every answer leaves behind. +const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse'; +const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)'; +const GAP_MS = 1100; +const TIMEOUT_MS = 8000; + +// One lookup at a time, never closer together than GAP_MS. +let queue: Promise = Promise.resolve(); + +// Android answers with the commune in `subregion` and the city in `region`; +// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's +// localityName: "COMMUNE, CITY", and a name that fills both slots prints once. +export function nameOf(address: Record): string | null { + const district = + address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county; + const city = address.city || address.town || address.municipality || address.village || address.state; + const parts = district && district !== city ? [district, city] : [district || city]; + const name = parts.filter(Boolean).join(', '); + return name ? name.toUpperCase() : null; +} + +export async function placeName(lat: number, lng: number): Promise { + const cached = findPlace(key(lat, lng)); + if (cached !== undefined) return cached || null; + + const call = queue.then(async () => { + await new Promise((r) => setTimeout(r, GAP_MS)); + const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`; + const res = await fetch(url, { + headers: { 'user-agent': AGENT, accept: 'application/json' }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`); + const body = (await res.json()) as { address?: Record }; + return body.address ? nameOf(body.address) : null; + }); + // The queue must not inherit a rejection, or every later lookup would fail + // with it; the caller gets the failure through `call` itself. + queue = call.catch(() => undefined); + + try { + const name = await call; + // Only an answer that came back is worth keeping — a timeout or an outage + // must not pin "no name here" on the coordinate for good. + savePlace(key(lat, lng), name ?? ''); + return name; + } catch { + return null; + } +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 5710ee7..4a561e1 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -1,5 +1,6 @@ import { recipeFile } from './recipeFile'; import { sendVerificationMail } from './mailer'; +import { placeName } from './place'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import { createHash, randomBytes } from 'node:crypto'; import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; @@ -36,6 +37,7 @@ import { replacePhoto, avatarPath, photoFile, + photoFileOwned, photoPath, photoPreset, rateLook, @@ -316,7 +318,11 @@ const SYSTEMS: [string, RegExp][] = [ function parseUa(ua: string): { browser: string | null; os: string | null; device: string } { const u = ua.toLowerCase(); - const device = /bot|crawler|spider|httpclient|curl|wget|python-requests/.test(u) + // Crawlers and headless browsers never count as traffic: a bot that runs the + // beacon would otherwise land in the stats page as a visitor — see the guard + // in /api/events and the `device <> 'bot'` filter on every stats query. + // No UA at all is a script, not a browser: every real one sends a string. + const device = !u.trim() || /bot|crawler|spider|crawl|slurp|headless|puppeteer|playwright|phantomjs|lighthouse|httpclient|curl|wget|python-requests/.test(u) ? 'bot' : /ipad|tablet|android(?!.*mobile)/.test(u) ? 'tablet' @@ -379,6 +385,10 @@ app.post('/api/events', async (req, reply) => { if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send(); const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : ''); const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''); + // A bot's visit is not traffic: it is answered, never stored, and never + // looked up against the geo service either. The stats queries filter `bot` + // out as well, so rows written before this guard stay out of the numbers. + if (ua.device === 'bot') return reply.status(204).send(); const geo = await lookupGeo(ip); createEvent({ kind: b.kind, @@ -630,6 +640,20 @@ app.get('/api/photos/mine', async (req, reply) => { return reply.status(200).send({ photos: listPhotosByUser(user.id) }); }); +// The name of a coordinate, for the stamp. A browser cannot ask the OS the way +// the phone app does, so the lookup happens here — which is also why it is a +// pro route: every miss takes a call out to the public geocoder. +app.get('/api/place', async (req, reply) => { + const user = requirePro(req, reply); + if (!user) return; + const q = req.query as { lat?: string; lng?: string }; + const lat = Number(q.lat); + const lng = Number(q.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) + return reply.status(400).send({ error: 'invalid coordinates' }); + return reply.status(200).send({ place: await placeName(lat, lng) }); +}); + app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = requirePro(req, reply); if (!user) return; @@ -757,6 +781,66 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) = .send(data); }); +// The editable base behind a saved render: the pixels as they went INTO the +// look, before the frame, the grade and the stamp were applied. `/file` is what +// the folder and the landing strip show — the finished frame — and this is the +// layer underneath it. The studio loads it when a saved photo is opened again, +// so the stored look lands on the original instead of a second time on its own +// output (which doubled the frame and stacked the grade). Owner only: the +// render may be public on the strip, the base never is. +app.put<{ Params: { id: string } }>( + '/api/photos/:id/base', + { bodyLimit: MAX_PHOTO_BYTES + 8192 }, + async (req, reply) => { + const user = requirePro(req, reply); + if (!user) return; + if (!allowUpload(String(user.id))) return tooMany(reply); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); + const row = photoFileOwned(user.id, id); + if (!row) return reply.status(404).send({ error: 'photo not found' }); + + const body = req.body; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); + if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); + + const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + const mime = sniffImage(body); + if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); + + // One base per photo, beside the render and overwritten in place by a + // re-save: a replace throws the old file away, and `unlink` takes this + // with it. + writeFileSync(photoPath(`${row.file}.b`), body); + return reply.status(204).send(); + }, +); + +app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => { + const user = requirePro(req, reply); + if (!user) return; + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); + const row = photoFileOwned(user.id, id); + if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' }); + let data: Buffer; + let mime: ReturnType = null; + try { + data = readFileSync(photoPath(`${row.file}.b`)); + mime = sniffImage(data); + } catch { + return reply.status(404).send({ error: 'not_found' }); + } + // A row saved before the base existed has no `.b` beside it, and the studio + // falls back to the render (the old, look-already-baked behaviour). + if (!mime) return reply.status(404).send({ error: 'not_found' }); + return reply + .header('content-type', mime) + .header('x-content-type-options', 'nosniff') + .header('cache-control', 'private, max-age=60') + .send(data); +}); + // The QR card's payload: the `.recipe` file the app reads back on IMPORT, built // from the look the photo was uploaded with. Public like the strip, but only // for a row the curator ticked into the `qr` section — that checkbox is the @@ -816,10 +900,14 @@ function admin(req: FastifyRequest): User | { status: number } { } function unlink(file: string): void { - try { - unlinkSync(photoPath(file)); - } catch { - // Already gone; the row is what matters. + // The editable base lives beside the render as `.b`; the row is gone, + // so it goes too. + for (const name of [file, `${file}.b`]) { + try { + unlinkSync(photoPath(name)); + } catch { + // Already gone; the row is what matters. + } } } diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index a452979..a709acb 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -138,6 +138,15 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s }; } +// Sim-owned exposure bias: brightness a stock needs that is part of the stock +// itself, never a user knob. Kept beside the matrices it compensates so the +// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries +// one — its matrix is a pure separation with no lift, and without this it reads +// a full step darker than the other sims. +const SIM_EXPOSURE_BIAS: Partial> = { + 'leica-vivid': 2, +}; + // 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 — @@ -145,7 +154,12 @@ 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; + // Sim-owned exposure bias, in Exposure-slider units. A sim is colour only and + // its `adjustments` stay neutral, so this does NOT surface on the Exposure + // knob — it is folded in here, at the same stage and with the same gain/offset + // mapping the knob uses, so the pixels get the lift the stock needs while the + // UI keeps reading 0. + const exposure = (adj.exposure ?? 0) + (SIM_EXPOSURE_BIAS[baseFilter] ?? 0); const contrast = adj.contrast; const saturation = adj.saturation; const temperature = adj.temperature; @@ -259,9 +273,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 in the rows themselves, so the matrix stays inside the + // 0..1 heads: the brightness this stock needs rides SIM_EXPOSURE_BIAS above + // (+2), which keeps white out of a hard clip and keeps the sim's + // `adjustments` neutral so the Exposure knob still reads 0. matrix = [ 1.125, -0.100, -0.010, 0, -0.015, -0.055, 1.110, -0.055, 0, -0.005, diff --git a/docker/frontend/shared/utils/defaultRecipes.ts b/docker/frontend/shared/utils/defaultRecipes.ts index 8fdd7c9..dde9d9e 100644 --- a/docker/frontend/shared/utils/defaultRecipes.ts +++ b/docker/frontend/shared/utils/defaultRecipes.ts @@ -26,13 +26,31 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = { 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. +// Film simulations. These are the fixed "look" presets at the front of the +// PRESETS chip row, and each one is COLOUR AND TONE ONLY. +// +// A sim carries exactly two things: its `baseFilter` colour matrix +// (colorUtils.ts) and, where the stock needs to shape one end of the curve, its +// FILM_TONE entry (toneShader.ts). Everything the sim does to the picture is in +// those two places — including the brightness a stock owns: that is folded into +// the matrix through SIM_EXPOSURE_BIAS (colorUtils.ts), never into +// `adjustments`. `adjustments` is therefore the neutral default for every sim — +// no grain, no clarity, no saturation push, no exposure/highlight/shadow move — +// because those are the user's knobs, not the stock's. A sim is a clean starting +// point, never a baked recipe; any further look is added afterwards. +// +// Name → stock it stands for. The name IS the sim's identity and the tag it +// exports as, so it is spelled as the stock is: +// PROVIA Provia / Standard +// VELVIA Velvia / Vivid +// CLASSIC CHROME Classic Chrome +// CLASSIC VIVID Velvia spliced with Classic Chrome at the blue row +// CLASSIC NEGATIVE Classic Negative (Superia-like) +// ASTIA Astia / Soft +// ETERNA Eterna (cinema) +// ACROS Acros (monochrome) +// 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,27 +59,22 @@ export interface FilmSim { } 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-provia', name: 'PROVIA', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-velvia', name: 'VELVIA', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-chrome', name: 'CLASSIC CHROME', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-vivid', name: 'CLASSIC VIVID', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-neg', name: 'CLASSIC NEGATIVE', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-astia', name: 'ASTIA', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-eterna', name: 'ETERNA', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-acros', name: 'ACROS', baseFilter: 'monochrome', 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: - // 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 } }, + // pair instead of one look that has to compromise. Both are still colour and + // tone only: CLASSIC is Leica Authentic (the matrix carries the channel + // separation and the warm European cast, nothing else), VIVID is Leica Vivid + // (the same separation opened up — again in the matrix, not as an exposure or + // saturation knob). + { 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). diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 4ec7f13..0fc24ec 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -35,9 +35,23 @@ import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. const MAX_PHOTOS = 12; +// Whether the browser can be asked for the device's own position — a secure +// origin on the desktop, https (or localhost) on the phone. This is the only +// automatic source left for a frame iOS hands back with its EXIF stripped. +const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation; // Preview renders on a downscaled copy; export always re-renders the original // bytes, so nothing shown here is baked into the file. const PREVIEW_MAX = 1600; +// What a file name says its bytes are. The source state keeps only the bytes, +// and the share sheet wants a type — an extension is all that is left of the +// picker's own. Anything unlisted is a JPEG, which is what the camera hands over. +const ORIGINAL_MIME = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + heic: 'image/heic', +}; // A committed crop keeps only a fraction of the photo, so the 1600px copy // leaves too few pixels behind and the result reads as blocky once the stage // scales it up. While a crop is live the source is re-derived larger, inverted @@ -59,6 +73,19 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'wallframe-landscape', label: 'frame.wallLand' }, ]; +// The recipe a saved photo's base is rendered through: the neutral stock — +// every knob at its default and no film filter — so the render keeps the +// geometry (rotation, straighten, crop) and leaves nothing of the look for the +// studio to apply a second time when the photo is opened again. +const BASE_RECIPE: Recipe = { + id: 'base', + name: 'BASE', + isCustom: true, + baseFilter: 'none', + adjustments: { ...DEFAULT_ADJUSTMENTS }, + useGeotag: false, +}; + // The printed frames are PRO, the plain export is not — the same split the // phone build draws (see src/utils/entitlement.ts there). Everything else the // gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in @@ -146,6 +173,26 @@ interface LoadedSource { url: string; } +// The custom watermark's own geometry. x/y are fractions of the stamp the +// engine draws on (x = left edge of the text, y = its baseline), size is the +// multiplier of the base face — 3.2% of the stamp width. The drag box on the +// photo reads and writes exactly these three numbers, so what the box encloses +// is what the file gets. +type MarkStyle = { color: string; size: number; x: number; y: number }; +const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92 }; + +// GPS carries the same three geometry numbers plus its two switches, and its +// box on the photo reads and writes them the same way the custom mark's does. +type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number }; +const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7 }; + +// Where each mark's first row sits under y, and the icon column GPS prints +// before its text — the engine's own em values, mirrored from the box that +// measures them (see ImageStage's WM_* constants). +const CUSTOM_BASELINE_EM = 0.35; +const GPS_BASELINE_EM = 1.1; +const GPS_GAP_EM = 1.6; + // Everything a single UNDO step puts back. The recipe alone was not enough: // frame, crop, rotation, marks and geotag all change the photo too. interface Look { @@ -159,9 +206,9 @@ interface Look { straighten: number; markOn: boolean; markText: string; - markStyle: { color: string; size: number }; + markStyle: MarkStyle; useGeotag: boolean; - gpsStyle: { color: string; showName: boolean; showTime: boolean }; + gpsStyle: GpsStyle; } export function Workspace() { @@ -213,14 +260,20 @@ export function Workspace() { // ISO off the loaded photo's EXIF; null means the strip prints AUTO. const [iso, setIso] = useState(null); const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' }); + const [locating, setLocating] = useState(false); + // Once the visitor turns the prompt down, a photo without GPS stops asking on + // its own: the panel's button still is there for a deliberate second try. + const locDeniedRef = useRef(false); const [place, setPlace] = useState(() => session.place ?? ''); const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false); - const [gpsStyle, setGpsStyle] = useState( - () => session.gpsStyle ?? { color: '#f59e0b', showName: true, showTime: true } - ); + // A session stored before GPS could be dragged carries only the colour and + // the two switches: the box's own numbers fall back to the defaults. + const [gpsStyle, setGpsStyle] = useState(() => ({ ...DEFAULT_GPS_STYLE, ...session.gpsStyle })); const [markText, setMarkText] = useState(() => session.markText ?? ''); const [markOn, setMarkOn] = useState(() => session.markOn ?? false); - const [markStyle, setMarkStyle] = useState(() => session.markStyle ?? { color: '#ffffff', size: 1 }); + // A session stored before the mark could be dragged carries only color/size: + // the box's own position falls back to the default instead of going undefined. + const [markStyle, setMarkStyle] = useState(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle })); // Every knob the workspace holds, written back on change — the reload half of // the session. Small JSON, so no debounce is worth the risk of losing the last @@ -327,9 +380,9 @@ export function Workspace() { straighten: 0, markOn: false, markText: '', - markStyle: { color: '#ffffff', size: 1 }, + markStyle: { ...DEFAULT_MARK_STYLE }, useGeotag: false, - gpsStyle: { color: '#f59e0b', showName: true, showTime: true }, + gpsStyle: { ...DEFAULT_GPS_STYLE }, }; }, []); const [exporting, setExporting] = useState(false); @@ -340,6 +393,18 @@ export function Workspace() { // like a guest — watermarked 2048px export, no saving, no PRO tools. An // allowlisted admin is trusted by construction and counts as verified. const pro = !!user?.verified; + // Admins are the server's own operators: the API skips MAX_PHOTOS_PER_USER + // for them (server.ts), so the studio stops counting at the same place. null + // means "no cap" all the way down to SAVE PHOTO's label. + const photoCap = user?.admin ? null : MAX_PHOTOS; + // What the stamp's name line prints: the visitor's own PLACE NAME wins over + // whatever the fix carried, because neither a phone's position nor a photo's + // EXIF ever brings one, and typing it after locating must still show up. + // Memoised: this object is a dependency of the preview and the export, and a + // fresh one on every render would re-arm the render effect with its own + // output — the stage would repaint itself forever and the panel would stop + // answering clicks. + const gpsInfo = useMemo(() => (gps ? { ...gps, locality: place.trim() || gps.locality } : null), [gps, place]); const [saved, setSaved] = useState([]); // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. @@ -450,7 +515,7 @@ export function Workspace() { // measures exactly what the engine will cut. frameId: cropEditing ? 'none' : frameId, useGeotag, - gpsInfo: gps, + gpsInfo, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', @@ -458,8 +523,8 @@ export function Workspace() { photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, - watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, - gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, + watermark: markOn && markText ? { text: markText, ...markStyle } : null, + gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }, }); if (token !== renderToken.current) return; @@ -477,7 +542,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); + }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -507,6 +572,51 @@ export function Workspace() { }); }, []); + // What the phone app gets from the OS geocoder, the browser has no twin of, + // so the name behind a coordinate comes from the API. Fire and forget: the + // stamp is already readable without it, and a name that arrives late (or not + // at all) must never hold up the picture. + const resolvePlace = useCallback((latitude: number, longitude: number) => { + void api + .place(latitude, longitude) + .then(({ place: found }) => { + if (!found) return; + // Only the fix still on screen is worth naming — the user may have + // moved on to another photo while the lookup was in flight. + setGps((prev) => (prev && prev.latitude === latitude && prev.longitude === longitude ? { ...prev, locality: found } : prev)); + }) + .catch(() => undefined); + }, []); + + // iPhone Safari returns a frame shot through the in-page camera with no EXIF + // at all (WebKit bug 207088), so the device's own position is the only + // automatic source left. Tried the moment such a photo lands (see adoptPhoto) + // and again on the panel's own button, for a permission that was refused or + // dismissed the first time. Secure origin only, which the check above gates. + const locateMe = useCallback(() => { + if (!navigator.geolocation) { + setError(t('wm.locFail')); + return; + } + setLocating(true); + navigator.geolocation.getCurrentPosition( + (pos) => { + const { latitude, longitude } = pos.coords; + setGpsManual({ lat: latitude.toFixed(6), lng: longitude.toFixed(6) }); + setGps({ latitude, longitude, locality: place, timestamp: Date.now(), source: 'device' }); + setUseGeotag(true); + setLocating(false); + resolvePlace(latitude, longitude); + }, + () => { + setLocating(false); + locDeniedRef.current = true; + setError(t('wm.locFail')); + }, + { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } + ); + }, [place, t, resolvePlace]); + const adoptPhoto = useCallback( async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); @@ -533,6 +643,9 @@ export function Workspace() { previewJobRef.current++; setPreviewBytes(preview); setGps(found); + // EXIF gave a position but never a name; ask the API for one. PRO only — + // the stamp is a PRO tool, and the lookup is a call to a public geocoder. + if (found && pro) resolvePlace(found.latitude, found.longitude); setIso(foundIso); // A new photo starts a new history whose floor is the default look, so // UNDO walks all the way back and still leaves the photo loaded. REDO has @@ -542,8 +655,13 @@ export function Workspace() { if (keepGeo) return; setUseGeotag(!!found); if (!found) setGpsManual({ lat: '', lng: '' }); + // A frame the phone handed over without EXIF gets one automatic try at the + // device's position, so the GPS stamp is there without a second tap. PRO + // only: the stamp is a PRO tool, and a guest's photo is not worth a + // location prompt. + if (!found && pro && canLocate && !locDeniedRef.current) locateMe(); }, - [defaultLook] + [defaultLook, pro, locateMe, resolvePlace] ); const loadFile = useCallback( @@ -603,6 +721,17 @@ export function Workspace() { useEffect(() => { sliderEditRef.current = false; }, [openParam]); + // The watermark's drag box rides the same one-edit-per-gesture rule as a knob: + // a whole drag is one undo step, not one per pointer move. The ref clears + // whenever the FRAME panel changes, which is when the box leaves the photo. + const wmEditRef = useRef(false); + // The colour wells drag the same way: one undo step per visit to the picker, + // not one per shade the slider under it passes through. + const wmColorRef = useRef(false); + useEffect(() => { + wmEditRef.current = false; + wmColorRef.current = false; + }, [openGroup]); const setAdjustmentOnce = useCallback( (patch: Partial) => { if (!sliderEditRef.current) { @@ -627,6 +756,47 @@ export function Workspace() { [remember] ); + // The mark's box on the photo writes through one door: dragging moves x/y, + // the corner handle moves size, and both go the same way a knob does — one + // undo step for the whole gesture. + const setWatermarkPos = useCallback( + (patch: Partial>) => { + if (!wmEditRef.current) { + wmEditRef.current = true; + remember(); + } + setMarkStyle((prev) => ({ ...prev, ...patch })); + }, + [remember] + ); + + // GPS's box writes through the same door: dragging moves x/y, the corner + // handle moves size, the whole gesture is one undo step. + const setGpsPos = useCallback( + (patch: Partial>) => { + if (!wmEditRef.current) { + wmEditRef.current = true; + remember(); + } + setGpsStyle((prev) => ({ ...prev, ...patch })); + }, + [remember] + ); + + // The colour well on either watermark's strip: a free colour, one undo step + // for as long as the panel stays open. + const setWmColor = useCallback( + (color: string) => { + if (!wmColorRef.current) { + wmColorRef.current = true; + remember(); + } + if (openGroup === 'gpsColor') setGpsStyle((prev) => ({ ...prev, color })); + else setMarkStyle((prev) => ({ ...prev, color })); + }, + [remember, openGroup] + ); + // One band of the mixer, read and written through one door: the tab's ruler // and the panel hung on the photo are the same three knobs. An all-zero band // is dropped, so an untouched band never lands in the recipe and a band put @@ -932,7 +1102,7 @@ export function Workspace() { // The file is the preview: an uncommitted frame is not exported. frameId: cropEditing ? 'none' : frameId, useGeotag, - gpsInfo: gps, + gpsInfo, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', @@ -942,11 +1112,11 @@ export function Workspace() { photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText - ? { text: markText, x: 0.5, y: 0.92, ...markStyle } + ? { text: markText, ...markStyle } : tier.watermark ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } : null, - gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, + gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); @@ -975,7 +1145,7 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its @@ -993,8 +1163,8 @@ export function Workspace() { // Re-saving the frame already on the stage replaces its own row, so the cap // only stands in the way of a new one. const open = myPhotos.find((p) => p.id === openPhotoId); - if (!open && myPhotos.length >= MAX_PHOTOS) { - setError(t('save.photoFull', { max: MAX_PHOTOS })); + if (!open && photoCap !== null && myPhotos.length >= photoCap) { + setError(t('save.photoFull', { max: photoCap })); return; } // A frame being filed for the first time is asked for a name — the folder @@ -1018,7 +1188,7 @@ export function Workspace() { recipe, frameId: cropEditing ? 'none' : frameId, useGeotag, - gpsInfo: gps, + gpsInfo, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', @@ -1027,14 +1197,35 @@ export function Workspace() { photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, - watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, - gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, + watermark: markOn && markText ? { text: markText, ...markStyle } : null, + gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); + // The pixels under the look: the same geometry, none of the grade, frame + // or stamp. The recipe does not carry the geometry, so it has to stay in + // these pixels — everything else is what reopening re-applies, and + // applying it to the render is what doubled the frame and the look. + const base = await renderPhoto({ + source: src, + recipe: BASE_RECIPE, + frameId: 'none', + assets: engine.assets, + options: { + dpi: 300, + // The live preview sharpens once on top of this, so the base is left + // unsharpened — the render the folder shows carries its own. + sharpen: false, + photoRotation: rotation, + photoStraighten: straighten, + cropRect: cropLive ? cropRect : null, + }, + }); + if (!base) throw new Error('render returned null'); const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, { type: 'image/jpeg', }); + let savedId: number; if (open) { // The frame on the stage is one of the folder's own: it keeps its id and // its place, and the look it carried before this save lands in history. @@ -1044,13 +1235,18 @@ export function Workspace() { consent: open.consent !== false, }); setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p))); + savedId = open.id; } else { const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings }); // The list entry carries the recipe we just sent, so reopening works // before the next /photos/mine refresh. setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); setOpenPhotoId(photo.id); + savedId = photo.id; } + // The render is what the folder shows; this base is what the studio opens. + // It has to land with the save, so a failure fails the save. + await api.putPhotoBase(savedId, base.bytes); // The stage now stands on the newest look of this frame, so no earlier // one in its history is the picked one. setHistPick(null); @@ -1059,7 +1255,30 @@ export function Workspace() { } finally { setSavingPhoto(false); } - }, [pro, promptPro, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); + }, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); + + // The original file, handed back byte for byte — the untouched frame the + // camera took, never the render. A page cannot write to the camera roll, so + // on a phone the file goes to the share sheet and "Save Image" on it is the + // tap that files it in the photo library; a browser with no file sharing (or + // a desktop) downloads it instead. + const saveOriginal = useCallback(async () => { + if (!source) return; + const ext = source.name.split('.').pop()?.toLowerCase() ?? ''; + const type = (ORIGINAL_MIME as Record)[ext] ?? 'image/jpeg'; + const file = new File([source.bytes as BlobPart], source.name, { type }); + try { + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file] }); + return; + } + } catch (err) { + // The sheet was closed: a "not now", not a failure, and no download on + // top of the choice the user just made. + if (err instanceof DOMException && err.name === 'AbortError') return; + } + download(source.bytes, source.name, type); + }, [source]); // Reopen one of this account's saved frames: the bytes come back as a File // through the same door an upload uses, then the stored look goes on top. @@ -1072,15 +1291,25 @@ export function Workspace() { clearPreview(); setHistPick(null); try { - const res = await fetch(api.photoUrl(photo.id)); + // The base is the pixels the look was applied to; `/file` is only the + // fallback, for a photo saved before its base was kept. Loading the + // render is what applied the look a second time — the doubled frame and + // the stacked grade. + const base = await fetch(api.photoBaseUrl(photo.id)); + const res = base.ok ? base : await fetch(api.photoUrl(photo.id)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' })); const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null; - if (saved) { + if (saved && base.ok) { applyRecipe(saved); if (saved.frameId) setFrameId(saved.frameId); setUseGeotag(!!saved.useGeotag); + } else { + // No base: those pixels already carry the look, so it stands as it + // is — the stored one is not laid over it again. The neutral stock + // leaves them untouched, and the stage starts from nothing. + restoreLook({ ...defaultLook(), recipe: BASE_RECIPE, simId: null, frameId: 'none' }); } setOpenPhotoId(photo.id); } catch { @@ -1089,7 +1318,7 @@ export function Workspace() { setBusy(null); } }, - [loadFile, applyRecipe, clearPreview, t] + [loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t] ); // The saved frame's own switch. The reel reads `consent`, so the tick answers @@ -1466,13 +1695,17 @@ export function Workspace() { })); const wmOn = useGeotag || markOn; + // An iPhone frame shot in the in-page camera carries no EXIF, so a photo with + // no GPS is no longer a dead end — the switch stays live wherever the browser + // can be asked for the device's own position (see locateMe). + const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation; const wmChips = (): ChipDef[] => [ { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, pro: true, - disabled: !gps && !(gpsManual.lat && gpsManual.lng), + disabled: !gps && !(gpsManual.lat && gpsManual.lng) && !canLocate, onClick: () => { if (!pro) { promptPro(); @@ -1506,8 +1739,11 @@ export function Workspace() { || Object.keys(a.hslBands ?? {}).length > 0 || (simId ?? 'sim-provia') !== 'sim-provia' || frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0 - || markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1 - || useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime; + || markOn || markText !== '' + || markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size + || markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y + || useGeotag + || (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]); }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); const resetChip = (): ChipDef => ({ @@ -1588,7 +1824,7 @@ export function Workspace() { const toggleMono = () => { remember(); if (monoOn) { - const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIPES' }; + const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIA' }; setRecipe((prev) => ({ ...prev, baseFilter: back.baseFilter, name: back.name, isCustom: true })); setSimId(back.simId); } else { @@ -1794,6 +2030,26 @@ export function Workspace() { const wmOpen = isWmPanel(openGroup); const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null; + // The lines GPS prints, spelled exactly as the engine spells them, so the box + // that frames them is the box the file gets. + const gpsLines = useMemo(() => { + if (!gpsInfo) return []; + const out: string[] = []; + if (gpsStyle.showName) { + out.push( + gpsInfo.locality?.trim() || `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}` + ); + } + if (gpsStyle.showTime) { + const d = new Date(gpsInfo.timestamp); + const pad = (n: number) => String(n).padStart(2, '0'); + out.push( + `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}` + ); + } + return out; + }, [gpsInfo, gpsStyle.showName, gpsStyle.showTime]); + const status = busy; return ( @@ -1811,7 +2067,7 @@ export function Workspace() { onSavePhoto={() => void savePhotoToFolder()} onExport={exportPhoto} photoCount={myPhotos.length} - photoMax={MAX_PHOTOS} + photoMax={photoCap} onAuth={() => setAuthMode('login')} onSignup={() => setAuthMode('signup')} onLogout={async () => { @@ -1966,32 +2222,49 @@ export function Workspace() { placeholder={t('wm.placePh')} onChange={(e) => setPlace(e.target.value)} /> -

{gps ? t('wm.hasExif') : t('wm.noExif')}

+

+ {gps + ? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual') + : t('wm.noExif')} +

{!gps ? ( -
- setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} - onBlur={() => { - const lat = Number(gpsManual.lat); - const lng = Number(gpsManual.lng); - if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { - setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' }); - setUseGeotag(true); - } - }} - /> - setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} - /> -
+ <> +
+ +
+
+ setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} + onBlur={() => { + const lat = Number(gpsManual.lat); + const lng = Number(gpsManual.lng); + if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { + setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now() }); + setUseGeotag(true); + } + }} + /> + setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} + /> +
+ ) : null} ) : null} @@ -2002,6 +2275,19 @@ export function Workspace() { {optGroup ? (
+ {/* A watermark's colour: the presets above are a shortcut, the + well under them picks any colour at all. */} + {optGroup === 'wmColor' || optGroup === 'gpsColor' ? ( + + ) : null}
) : null} {tab === 'presets' && showRecipes ? ( @@ -2083,10 +2369,50 @@ export function Workspace() { onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={crop === 'none'} + onSaveOriginal={() => void saveOriginal()} picking={picking} onPickColor={pickColor} pickPanel={pickPanel} pickPanelAt={pickedAt} + // FRAME's custom mark owns a box on the photo while its panel is + // open: the engine draws the text at (x, y) with size × the base + // face, and the box is those same three numbers read back off the + // render. Only while the panel shows — a box with nothing to edit it + // would just sit on the photo. + marks={ + wmOpen + ? [ + ...(markOn && markText + ? [ + { + key: 'custom', + x: markStyle.x, + y: markStyle.y, + size: markStyle.size, + baselineEm: CUSTOM_BASELINE_EM, + gapEm: 0, + lines: [markText], + onMove: setWatermarkPos, + }, + ] + : []), + ...(useGeotag && gps && gpsLines.length + ? [ + { + key: 'gps', + x: gpsStyle.x, + y: gpsStyle.y, + size: gpsStyle.size, + baselineEm: GPS_BASELINE_EM, + gapEm: GPS_GAP_EM, + lines: gpsLines, + onMove: setGpsPos, + }, + ] + : []), + ] + : [] + } straightenRuler={ openParam === 'straighten' ? ( setStraightenOnce(0)} /> diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 2b56fd9..810ae58 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -12,9 +12,13 @@ import { QrCode } from './ui/QrCode'; // pairs — the app dictionary holds UI strings, this is a page of copy, and the // blueprint stays the source of truth for it. The page follows the same theme // and language switches as the workspace (buttons in the nav, top-right). -// The store URLs are still unknown (WEB_PLAN.md W6), so the store badges raise -// the toast instead of navigating; fill STORE_LINKS in and drop the onClick. -const STORE_LINKS = { play: '#', appstore: '#' }; +// The Google Play listing is the internal-test track, so the store badge is a +// real link from today. The App Store has no listing yet (WEB_PLAN.md W6), so +// that badge still raises the toast until its URL lands here. +const STORE_LINKS = { + play: 'https://play.google.com/apps/internaltest/4700963739341162920', + appstore: '#', +}; type Txt = { en: string; vi: string }; @@ -359,8 +363,7 @@ export function Landing() { { href: '#pricing', label: { en: 'Pricing', vi: 'Bảng giá' } }, ]; - const toastDownload: Txt = { en: 'Download links land with the public release — the web studio is live now.', vi: 'Link tải sẽ có khi phát hành chính thức — web studio đã chạy được ngay.' }; - const toastStore: Txt = { en: 'Store listing arrives with the public release — use the web studio in the meantime.', vi: 'Cửa hàng ứng dụng sẽ mở khi phát hành chính thức — trong lúc chờ, hãy dùng web studio.' }; + const toastStore: Txt = { en: 'The App Store listing arrives with the public release — use the web studio in the meantime.', vi: 'App Store sẽ mở khi phát hành chính thức — trong lúc chờ, hãy dùng web studio.' }; // Sharing. The URL is the page's own origin, so the links point at whatever // host is serving this build (the crawler meta in index.html does the same @@ -410,9 +413,15 @@ export function Landing() { {c({ en: 'RecipesCam web studio', vi: 'Studio web RecipesCam' })} - + {/* Theme + language, the same two switches the workspace TopBar has. Theme opens a popover: light/dark plus the font group, which retypes the whole page. */} @@ -561,14 +570,16 @@ export function Landing() { {t('nav.login')} )} - + ) : null} @@ -868,9 +879,15 @@ export function Landing() {
@@ -885,10 +902,16 @@ export function Landing() {
  • {c({ en: 'Unlimited QR generation', vi: 'Tạo QR không giới hạn' })}
  • - -
    @@ -934,12 +957,18 @@ export function Landing() {

    {c({ en: 'Load a recipe. Shoot the rest of the day.', vi: 'Nạp một công thức. Chụp cả ngày còn lại.' })}

    {c({ en: 'RecipesCam is free on both stores, and the web studio runs right here in your browser.', vi: 'RecipesCam miễn phí trên cả hai cửa hàng, và web studio chạy ngay trong trình duyệt của bạn.' })}

    - - + + {c({ en: 'AVAILABLE NOW', vi: 'ĐÃ CÓ' })}{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })} + {c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}
    {/* Share targets: the four networks take the URL as a query param, diff --git a/docker/frontend/src/PhotosPage.tsx b/docker/frontend/src/PhotosPage.tsx index 34efb63..6ad57d4 100644 --- a/docker/frontend/src/PhotosPage.tsx +++ b/docker/frontend/src/PhotosPage.tsx @@ -64,7 +64,10 @@ export function PhotosPage() { setNote(t('photos.deleted')); }); - const full = photos.length >= MAX_PHOTOS; + // The API lets an admin past the cap (server.ts), so the folder stops counting + // there too — same split as the studio's SAVE PHOTO button. + const capped = !user?.admin; + const full = capped && photos.length >= MAX_PHOTOS; return (
    @@ -75,7 +78,9 @@ export function PhotosPage() { {t('photos.title')} - {t('photos.count', { n: photos.length, max: MAX_PHOTOS })} + + {capped ? t('photos.count', { n: photos.length, max: MAX_PHOTOS }) : t('photos.countNoMax', { n: photos.length })} + {t('nav.back')} @@ -90,7 +95,7 @@ export function PhotosPage() { {user ? (
    -

    {t('photos.hint', { max: MAX_PHOTOS })}

    +

    {capped ? t('photos.hint', { max: MAX_PHOTOS }) : t('photos.hintNoMax')}

    {full ?

    {t('photos.full', { max: MAX_PHOTOS })}

    : null}
    + {onSaveOriginal ? ( + + ) : null}