From 428e7fa682b0971f10f06bdc05e9071e2a99c2d1 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 08:32:28 +0700 Subject: [PATCH] web: a film sim is colour and tone only The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME, CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID. Grain, clarity, saturation and light moves were dropped from their `adjustments`, so a sim is a clean starting point and the general knobs read their defaults while the look still lands on the pixels. LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in where the Exposure slider applies, so the picture gets the lift and the parameter stays at 0. Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the WATERMARK chip column, the real admin stats, and the fix that stopped presets from doubling and a frame from refusing to come off when a photo was reopened (/file is the finished render, /base the editable pixels). --- HSL_PORT.md | 865 ++++++++++++++++++ docker/backend/src/db.ts | 37 +- docker/backend/src/place.ts | 61 ++ docker/backend/src/server.ts | 98 +- docker/frontend/shared/utils/colorUtils.ts | 23 +- .../frontend/shared/utils/defaultRecipes.ts | 67 +- docker/frontend/src/App.tsx | 438 +++++++-- docker/frontend/src/Landing.tsx | 71 +- docker/frontend/src/PhotosPage.tsx | 11 +- docker/frontend/src/api.ts | 24 +- docker/frontend/src/engine/exportEngine.ts | 7 +- docker/frontend/src/engine/session.ts | 8 +- docker/frontend/src/i18n/en.ts | 13 +- docker/frontend/src/i18n/vi.ts | 13 +- docker/frontend/src/styles/app.css | 14 +- docker/frontend/src/styles/landing.css | 17 +- docker/frontend/src/ui/ImageStage.tsx | 197 +++- docker/frontend/src/ui/TopBar.tsx | 15 +- 18 files changed, 1831 insertions(+), 148 deletions(-) create mode 100644 HSL_PORT.md create mode 100644 docker/backend/src/place.ts 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}