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).
This commit is contained in:
2026-09-22 08:32:28 +07:00
parent 52b672deec
commit 428e7fa682
18 changed files with 1831 additions and 148 deletions
+865
View File
@@ -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<Record<HslBandId, HslBand>>; // 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<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
```
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<number[]>(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 `<Shader>`) — 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 `<Shader uniforms>` 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<HslBandId>('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<ColorAdjustments>);
};
```
`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<TabId, ParamDef[]>` 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 ? <View style={{ width: 10, height: 10, borderRadius: 999, backgroundColor: c.color, marginRight: 6 }} /> : 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' && (
<View className="flex-row items-center gap-2 px-3 py-1">
{hslSample ? (
<>
<View style={{ width: 22, height: 22, borderRadius: 4, borderWidth: 1, borderColor: '#3f3f46',
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})` }} />
<Text className="font-mono text-[11px] text-zinc-400">
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
</Text>
<Text className="font-mono text-[11px] text-zinc-400">
{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)}%
</Text>
<Text className="font-mono text-[11px] text-amber-400">{bandName}</Text>
</>
) : (
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
</Text>
)}
</View>
)}
```
`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: `<Pipette size={22} color="#fff" />`).
- 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 && (
<View
pointerEvents="box-none"
style={{
position: 'absolute',
left: 0, top: 0, width: rectW, height: rectH,
}}
>
<View
style={{
position: 'absolute',
left: hslPanelAt.fx * rectW,
top: hslPanelAt.fy * rectH,
transform: [
{ translateX: -PANEL_HALF_W }, // translate(-50%)
{ translateY: hslPanelAt.fy > 0.55 ? -(PANEL_H + 14) : 14 },
],
}}
>
{hslPanel}
</View>
</View>
)}
```
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.
+35 -2
View File
@@ -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);
+61
View File
@@ -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<unknown> = 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, string | undefined>): 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<string | null> {
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<string, string> };
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;
}
}
+93 -5
View File
@@ -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<typeof sniffImage> = 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 `<file>.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.
}
}
}
+19 -4
View File
@@ -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<Record<BaseFilter, number>> = {
'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,
+40 -27
View File
@@ -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).
+382 -56
View File
@@ -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<number | null>(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<GpsStyle>(() => ({ ...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<MarkStyle>(() => ({ ...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<SavedRecipe[]>([]);
// 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<Recipe['adjustments']>) => {
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<Pick<MarkStyle, 'x' | 'y' | 'size'>>) => {
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<Pick<GpsStyle, 'x' | 'y' | 'size'>>) => {
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<string, string>)[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)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
<p className="hint">
{gps
? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual')
: t('wm.noExif')}
</p>
{!gps ? (
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
<>
<div className="chip-row">
<button
type="button"
className="chip"
data-key="gps-locate"
disabled={locating}
onClick={locateMe}
>
{locating ? t('wm.locating') : t('wm.useLocation')}
</button>
</div>
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
</>
) : null}
</>
) : null}
@@ -2002,6 +2275,19 @@ export function Workspace() {
{optGroup ? (
<div className="col col-sub strip" data-col="options">
<ChipRow chips={stripChips(optGroup)} />
{/* A watermark's colour: the presets above are a shortcut, the
well under them picks any colour at all. */}
{optGroup === 'wmColor' || optGroup === 'gpsColor' ? (
<label className="color-well" data-key={`wm-color-${optGroup}`}>
<input
type="color"
data-key={`wm-color-input-${optGroup}`}
value={optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}
onChange={(e) => setWmColor(e.target.value)}
/>
<span className="val">{optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}</span>
</label>
) : null}
</div>
) : 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' ? (
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
+50 -21
View File
@@ -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() {
<a className="lp-btn lp-sm lp-primary" data-key="nav-studio" href="/app">
{c({ en: 'RecipesCam web studio', vi: 'Studio web RecipesCam' })}
</a>
<button type="button" className="lp-btn lp-sm lp-primary" data-key="nav-download" onClick={() => showToast(toastDownload)}>
<a
className="lp-btn lp-sm lp-primary"
data-key="download-play"
href={STORE_LINKS.play}
target="_blank"
rel="noopener noreferrer"
>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button>
</a>
{/* 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')}
</button>
)}
<button
type="button"
<a
className="lp-btn lp-primary"
data-key="sheet-download"
onClick={() => { setMenu(false); showToast(toastDownload); }}
data-key="download-play"
href={STORE_LINKS.play}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMenu(false)}
>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button>
</a>
</div>
) : null}
</nav>
@@ -868,9 +879,15 @@ export function Landing() {
</ul>
<div className="lp-plan-cta">
<a className="lp-btn" href="/app">{c({ en: 'Open the studio', vi: 'Mở studio' })}</a>
<button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<a
className="lp-btn"
data-key="download-play"
href={STORE_LINKS.play}
target="_blank"
rel="noopener noreferrer"
>
{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}
</button>
</a>
</div>
</article>
<article className="lp-plan vip">
@@ -885,10 +902,16 @@ export function Landing() {
<li>{c({ en: 'Unlimited QR generation', vi: 'Tạo QR không giới hạn' })}</li>
</ul>
<div className="lp-plan-cta">
<button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<a
className="lp-btn"
data-key="download-play"
href={STORE_LINKS.play}
target="_blank"
rel="noopener noreferrer"
>
{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}
</button>
<button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
</a>
<button type="button" className="lp-btn" data-key="download-appstore" onClick={() => showToast(toastStore)}>
{c({ en: 'Download on the App Store', vi: 'Tải trên App Store' })}
</button>
</div>
@@ -934,12 +957,18 @@ export function Landing() {
<h2>{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.' })}</h2>
<p>{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.' })}</p>
<div className="lp-stores">
<button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<button type="button" className="lp-btn" data-key="download-appstore" onClick={() => showToast(toastStore)}>
<span className="lp-store"><small>{c({ en: 'COMING SOON', vi: 'SẮP CÓ' })}</small><b>{c({ en: 'Download on the App Store', vi: 'Tải trên App Store' })}</b></span>
</button>
<button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<span className="lp-store"><small>{c({ en: 'COMING SOON', vi: 'SẮP CÓ' })}</small><b>{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}</b></span>
</button>
<a
className="lp-btn"
data-key="download-play"
href={STORE_LINKS.play}
target="_blank"
rel="noopener noreferrer"
>
<span className="lp-store"><small>{c({ en: 'AVAILABLE NOW', vi: 'ĐÃ CÓ' })}</small><b>{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}</b></span>
</a>
<a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
</div>
{/* Share targets: the four networks take the URL as a query param,
+8 -3
View File
@@ -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 (
<div className="adm">
@@ -75,7 +78,9 @@ export function PhotosPage() {
</a>
<b className="adm-title">{t('photos.title')}</b>
<span className="adm-spacer" />
<span className="hint" data-key="photos-count">{t('photos.count', { n: photos.length, max: MAX_PHOTOS })}</span>
<span className="hint" data-key="photos-count">
{capped ? t('photos.count', { n: photos.length, max: MAX_PHOTOS }) : t('photos.countNoMax', { n: photos.length })}
</span>
<Avatar user={user} />
<a className="btn ghost" data-key="photos-home" href="/">{t('nav.back')}</a>
</header>
@@ -90,7 +95,7 @@ export function PhotosPage() {
{user ? (
<div className="adm-frame" data-key="photos-frame">
<div className="adm-pane">
<p className="hint adm-sub">{t('photos.hint', { max: MAX_PHOTOS })}</p>
<p className="hint adm-sub">{capped ? t('photos.hint', { max: MAX_PHOTOS }) : t('photos.hintNoMax')}</p>
{full ? <p className="hint adm-sub" data-key="photos-full">{t('photos.full', { max: MAX_PHOTOS })}</p> : null}
<div className="chip-row" style={{ marginBottom: 12 }}>
<button
+23 -1
View File
@@ -1,7 +1,7 @@
// Thin wrapper over the API container. Same origin in production (nginx proxies
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
import type { Recipe } from '../shared/types';
import { shrinkForUpload } from './engine/imageOps';
import { shrinkForUpload, resizedJpeg } from './engine/imageOps';
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
// browser first; the server then sniffs the bytes and requires the declared
@@ -216,6 +216,9 @@ export const api = {
// labels ride the query string, since the body is the image itself.
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
// The name of a coordinate, for the stamp. Resolved server-side (the browser
// has no OS geocoder), so a hit is a network round trip; null = no name.
place: (lat: number, lng: number) => call<{ place: string | null }>(`/place?lat=${lat}&lng=${lng}`),
// Film-strip ratings, keyed by subject (`photo:<id>` or `look:<TAG>`). Public:
// any visitor may read the tallies and cast one vote per frame.
ratings: () => call<{ ratings: Record<string, Rating> }>('/ratings'),
@@ -242,6 +245,25 @@ export const api = {
// The owner's own delete; an admin may pass any id here too.
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
photoUrl: (id: number) => `/api/photos/${id}/file`,
// The editable base beside a saved render — the pixels the look was applied
// to, written right after the photo itself. Opening a saved photo loads this,
// so its look lands on the original instead of a second time on its own
// output. 404 for a photo saved before the base existed.
photoBaseUrl: (id: number) => `/api/photos/${id}/base`,
putPhotoBase: async (id: number, bytes: Uint8Array) => {
// Always JPEG, always inside an upload's caps: the server compares the
// declared type against the bytes' magic number, and a base is only ever
// read back by the engine (`0` = no byte budget, so a copy already inside
// `MAX_PHOTO_DIM` is still re-encoded rather than passed through).
const out = await shrinkForUpload(bytes, MAX_PHOTO_DIM, 0);
const res = await fetch(`/api/photos/${id}/base`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'image/jpeg' },
body: out as unknown as BodyInit,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
},
// The look a photo was uploaded with, as the `.recipe` file the app imports on
// a scan. Only answers for a photo the curator ticked into the `qr` section.
presetUrl: (id: number) => `/api/photos/${id}/preset.recipe`,
+6 -1
View File
@@ -642,7 +642,12 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
iconPaint.setColor(Skia.Color('#ffffff'));
if (input.useGeotag && input.gpsInfo) {
const locationName = input.gpsInfo.locality || 'STREET VIEW';
// A phone's GPS carries no place name, and neither does a photo's EXIF,
// so the coordinates are what the stamp prints — the old literal
// 'STREET VIEW' placeholder read as if that were the real location.
const locationName =
input.gpsInfo.locality?.trim() ||
`${input.gpsInfo.latitude.toFixed(5)}, ${input.gpsInfo.longitude.toFixed(5)}`;
const d = new Date(input.gpsInfo.timestamp);
const pad = (n: number) => String(n).padStart(2, '0');
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+6 -2
View File
@@ -26,10 +26,14 @@ export interface StudioState {
gpsManual?: { lat: string; lng: string };
place?: string;
useGeotag?: boolean;
gpsStyle?: { color: string; showName: boolean; showTime: boolean };
// size/x/y are the GPS box on the photo; older sessions hold only the colour
// and the two switches and take the defaults for the rest.
gpsStyle?: { color: string; showName: boolean; showTime: boolean; size?: number; x?: number; y?: number };
markText?: string;
markOn?: boolean;
markStyle?: { color: string; size: number };
// x/y are fractions of the stamp the mark is drawn on (the drag box writes
// them straight back); older sessions hold no x/y and take the default.
markStyle?: { color: string; size: number; x?: number; y?: number };
}
export function loadState(): StudioState {
+12 -1
View File
@@ -26,6 +26,8 @@ export const en: Dict = {
'drop.peeking': 'SHOWING THE ORIGINAL',
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
'drop.compare': 'Compare with original picture',
'drop.keep': 'SAVE ORIGINAL',
'drop.keepHint': 'Save the untouched photo to the device photo library. A browser that cannot share files downloads it instead.',
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
'hist.title': 'HISTOGRAM',
@@ -97,7 +99,12 @@ export const en: Dict = {
'wm.lat': 'Latitude',
'wm.lng': 'Longitude',
'wm.hasExif': 'GPS read from the photo.',
'wm.noExif': 'This photo has no GPS — type it in if you want the stamp.',
'wm.noExif': 'This photo has no GPS — type it in, or use where you are now.',
'wm.useLocation': 'USE MY LOCATION',
'wm.locating': 'GETTING LOCATION…',
'wm.hasDevice': "GPS taken from the device's current position.",
'wm.hasManual': 'GPS typed in by you.',
'wm.locFail': 'Could not get a location — type the coordinates in.',
'auth.loginTitle': 'Log in',
'auth.signupTitle': 'Create an account',
@@ -143,6 +150,7 @@ export const en: Dict = {
'save.photoNeedLogin': 'Log in to save a photo.',
'save.photoSaved': 'Photo saved {n}/{max}.',
'save.photoFull': 'That is all {max} — remove one in My photos.',
'save.photoNoMax': 'SAVE PHOTO {n}',
'save.photoNoSource': 'Load a photo first.',
'save.photoFailed': 'Could not save the photo: {msg}',
'myp.empty': 'NO SAVED PHOTOS YET.',
@@ -161,6 +169,9 @@ export const en: Dict = {
'photos.title': 'My photos',
'photos.hint': 'Each account keeps up to {max} photos. What the studio saves lives here and shows up on the community reel.',
'photos.count': '{n}/{max} photos',
'photos.countNoMax': '{n} photos',
'photos.hintNoMax':
'Admin accounts keep no photo limit. What the studio saves lives here and shows up on the community reel.',
'photos.add': 'ADD A PHOTO',
'photos.empty': 'No photos yet. Open the studio and hit SAVE PHOTO, or add one right here.',
'photos.full': 'That is all {max}. Remove one before saving again.',
+12 -1
View File
@@ -33,6 +33,8 @@ export const vi = {
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
'drop.compare': 'So với ảnh gốc',
'drop.keep': 'LƯU ẢNH GỐC',
'drop.keepHint': 'Lưu nguyên ảnh gốc chưa chỉnh vào thư viện ảnh của máy. Trình duyệt không chia sẻ được tệp thì ảnh sẽ được tải về.',
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
@@ -105,7 +107,12 @@ export const vi = {
'wm.lat': 'Vĩ độ',
'wm.lng': 'Kinh độ',
'wm.hasExif': 'Đã đọc GPS từ ảnh.',
'wm.noExif': 'Ảnh không có GPS — nhập tay nếu muốn đóng dấu.',
'wm.noExif': 'Ảnh không có GPS — nhập tay hoặc lấy vị trí hiện tại.',
'wm.useLocation': 'DÙNG VỊ TRÍ CỦA TÔI',
'wm.locating': 'ĐANG LẤY VỊ TRÍ…',
'wm.hasDevice': 'GPS lấy từ vị trí hiện tại của thiết bị.',
'wm.hasManual': 'GPS do bạn nhập tay.',
'wm.locFail': 'Không lấy được vị trí — hãy nhập tay.',
'auth.loginTitle': 'Đăng nhập',
'auth.signupTitle': 'Tạo tài khoản',
@@ -151,6 +158,7 @@ export const vi = {
'save.photoNeedLogin': 'Đăng nhập để lưu ảnh.',
'save.photoSaved': 'Đã lưu ảnh {n}/{max}.',
'save.photoFull': 'Đã đủ {max} ảnh — xoá bớt trong Ảnh của tôi.',
'save.photoNoMax': 'LƯU ẢNH {n}',
'save.photoNoSource': 'Chưa có ảnh nào để lưu.',
'save.photoFailed': 'Không lưu được ảnh: {msg}',
'myp.empty': 'CHƯA LƯU ẢNH NÀO.',
@@ -169,6 +177,9 @@ export const vi = {
'photos.title': 'Ảnh của tôi',
'photos.hint': 'Mỗi tài khoản giữ tối đa {max} ảnh. Ảnh lưu từ studio nằm ở đây và xuất hiện trên dải phim cộng đồng.',
'photos.count': '{n}/{max} ảnh',
'photos.countNoMax': '{n} ảnh',
'photos.hintNoMax':
'Tài khoản admin không giới hạn số ảnh. Ảnh lưu từ studio nằm ở đây và xuất hiện trên dải phim cộng đồng.',
'photos.add': 'THÊM ẢNH',
'photos.empty': 'Chưa có ảnh nào. Mở studio rồi bấm LƯU ẢNH, hoặc thêm ảnh ngay ở đây.',
'photos.full': 'Đã đủ {max} ảnh. Xoá bớt trước khi lưu tiếp.',
+13 -1
View File
@@ -105,7 +105,10 @@
.col .chip { justify-content: space-between; text-align: left; }
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
.wm-extra { border-right-color: var(--accent-line); }
/* WATERMARK's own column: its chips carry the longest labels in the app
("GPS WATERMARK OFF", "CUSTOM WATERMARK OFF" + PRO), which wrap in the
default column width. */
.wm-extra { border-right-color: var(--accent-line); width: 200px; }
.chip {
border: 1px solid var(--border);
background: var(--bg-elev);
@@ -312,6 +315,15 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
.crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; }
.crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
/* FRAME's custom watermark box: drag the body to move, the corner to scale. */
.wm-layer { position: absolute; border-radius: var(--radius-sm); pointer-events: none; }
.wm-rect { position: absolute; border: 1px dashed var(--accent); cursor: move; pointer-events: auto; touch-action: none; }
.wm-handle { position: absolute; width: 14px; height: 14px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
.wm-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
/* A watermark's colour: the preset chips are a shortcut, the well picks any. */
.color-well { display: flex; align-items: center; gap: 8px; padding: 6px 8px; cursor: pointer; }
.color-well input[type="color"] { width: 34px; height: 22px; padding: 0; border: 1px solid var(--accent-line); border-radius: 3px; background: none; cursor: pointer; }
.color-well .val { font-size: 11px; letter-spacing: 0.08em; color: var(--muted); }
/* Eyedropper: the photo's own box, left transparent, with a crosshair cursor. */
.pick-layer {
position: absolute;
+1 -16
View File
@@ -441,22 +441,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 116px; }
/* --- 8. pricing --------------------------------------------------------- */
.lp-price { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; max-width: 860px; }
.lp-plan { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 18px; padding: 26px; display: flex; flex-direction: column; }
.lp-plan.vip { border-color: var(--accent-line); background: linear-gradient(180deg, var(--accent-soft), rgba(22, 24, 32, 0.9)); position: relative; }
.lp-plan.vip::after {
content: 'MOST POPULAR';
position: absolute;
top: -11px;
left: 26px;
background: linear-gradient(120deg, var(--lp-amber), var(--lp-orange));
color: #14100a;
border-radius: 999px;
padding: 3px 10px;
font-family: var(--lp-mono);
font-size: 9.5px;
letter-spacing: 0.12em;
}
/* Pseudo-element copy cannot call t(), so it follows the page's data-lang. */
[data-lang="vi"] .lp-plan.vip::after { content: 'PHỔ BIẾN NHẤT'; }
.lp-plan.vip { border-color: var(--accent-line); background: linear-gradient(180deg, var(--accent-soft), rgba(22, 24, 32, 0.9)); }
.lp-plan .lp-amount { font-family: var(--lp-display); font-size: 38px; margin: 12px 0 4px; }
.lp-plan .lp-amount small { font-family: var(--lp-mono); font-size: 11px; color: var(--lp-muted); }
.lp-plan ul { list-style: none; padding: 0; margin: 18px 0 22px; display: flex; flex-direction: column; gap: 9px; font-size: 13.5px; color: var(--lp-muted); }
+194 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
import { samplePixel } from '../engine/imageOps';
@@ -10,6 +10,44 @@ const ZOOM_MAX = 8;
// How long the wheel has to rest before the stage asks for a sharper copy.
const ZOOM_SETTLE_MS = 160;
// The watermarks' own face — the engine draws both marks with the bundled
// Cousine face (the custom mark's FONT option is never set on web), and
// renderPhoto's numbers are what the boxes below read back: the face is 3.2% of
// the stamp width, its advance is 0.6001em and its ascent/descent
// 0.8325/0.3003em. The custom mark's baseline sits 0.35em under y; GPS draws
// its rows 1.2em apart from a baseline 1.1em under y, with an icon column 1.6em
// wide before the text. A box built from those same constants hugs exactly the
// text the file will carry.
const WM_FONT_FRAC = 0.032;
const WM_ADVANCE_EM = 0.60009765625;
const WM_ASCENT_EM = 0.83251953125;
const WM_DESCENT_EM = 0.30029296875;
const WM_ROW_STEP_EM = 1.2;
const WM_SIZE_MIN = 0.3;
const WM_SIZE_MAX = 6;
// The engine rounds the face size down to whole pixels before it draws, so the
// box must round the same way or it drifts off the glyphs at large sizes.
const wmFontPx = (boxW: number, size: number) => Math.round(Math.round(boxW * WM_FONT_FRAC) * size);
// A watermark the stage draws a box over. The engine places both the custom
// mark and GPS with the same three numbers (x, y, size) plus a face's own
// metrics, so one shape covers the two.
export type WmMark = {
// Which mark this is: the drag handlers only ever report back through here.
key: string;
x: number;
y: number;
size: number;
// The first row's baseline, in em under y.
baselineEm: number;
// The icon column GPS prints before its text, in em (0 for the custom mark).
gapEm: number;
// The rows as they will be drawn; the face is monospace, so the longest line
// in characters is the width and the count is the height.
lines: string[];
onMove: (p: { x?: number; y?: number; size?: number }) => void;
};
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
@@ -27,10 +65,12 @@ export function ImageStage({
onCropRectChange,
onStageZoom,
canCompare = false,
onSaveOriginal,
picking = false,
onPickColor,
pickPanel = null,
pickPanelAt = null,
marks = [],
straightenRuler = null,
}: {
previewUrl: string | null;
@@ -53,6 +93,9 @@ export function ImageStage({
// Compare needs the un-cropped photo on one side and the render on the other,
// so it is only offered while the crop is untouched.
canCompare?: boolean;
// Puts the untouched photo file itself in the owner's hands — on a phone that
// is the share sheet, whose "Save Image" is what reaches the photo library.
onSaveOriginal?: () => void;
// Eyedropper mode: clicks on the photo report the colour underneath instead of
// panning it. The HSL mixer's PICK chip owns this flag.
picking?: boolean;
@@ -62,11 +105,19 @@ export function ImageStage({
// they are moving.
pickPanel?: ReactNode;
pickPanelAt?: { fx: number; fy: number } | null;
// FRAME's watermarks (the custom mark, GPS), while their own panel is open:
// each gets a box on the photo that can be dragged (x/y) and resized from its
// corner (size). The engine draws them at (x, y) with size × the base face,
// so these are the exact numbers the render already reads.
marks?: WmMark[];
// FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here
// rather than in the panel column because the angle is read off the picture.
straightenRuler?: ReactNode;
}) {
const showStraighten = !!straightenRuler;
// `marks` is a fresh array on every parent render, and this one is only ever
// read for its length, so the effects below key off that instead.
const wmCount = marks.length;
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@@ -136,14 +187,14 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking && !pickPanelAt && !showStraighten) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten) {
setBox(null);
return;
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cropEditing, compare, picking, pickPanelAt, showStraighten, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]);
// Compare is a property of the un-cropped photo, so a crop taking over (or a
// new photo arriving) puts it away.
@@ -408,6 +459,97 @@ export function ImageStage({
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
// Each mark's box, in the photo's own box: the engine's geometry restated with
// the same constants, so a frame lands on its text whatever size the stage is
// painting it at. Both marks are a block of rows: the width is the icon column
// plus the longest line, the height the face plus the rows below the first.
const wmBoxes = useMemo(() => {
if (!box) return [];
return marks.map((mark) => {
const fontPx = wmFontPx(box.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
return {
mark,
fontPx,
left: mark.x * box.width,
top: mark.y * box.height + (mark.baselineEm - WM_ASCENT_EM) * fontPx,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
height: (WM_ASCENT_EM + WM_DESCENT_EM + (mark.lines.length - 1) * WM_ROW_STEP_EM) * fontPx,
};
});
}, [marks, box]);
// Dragging a box writes x/y, its corner handle writes size. Both measure the
// img's own box (not the layer's), so a zoom on the stage cannot skew them.
const wmDragRef = useRef<{
mode: 'move' | 'size';
mark: WmMark;
x: number;
y: number;
size: number;
px: number;
py: number;
left: number;
width: number;
fontPx: number;
} | null>(null);
const beginWmDrag = (mark: WmMark, mode: 'move' | 'size') => (e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const b = imgRef.current?.getBoundingClientRect();
if (!b || !(b.width > 0 && b.height > 0)) return;
e.currentTarget.setPointerCapture(e.pointerId);
const fontPx = wmFontPx(b.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
wmDragRef.current = {
mode,
mark,
x: mark.x,
y: mark.y,
size: mark.size,
px: e.clientX,
py: e.clientY,
left: b.left + mark.x * b.width,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
fontPx,
};
};
const moveWmDrag = (e: React.PointerEvent<HTMLElement>) => {
const d = wmDragRef.current;
const img = imgRef.current;
if (!d || !img) return;
const b = img.getBoundingClientRect();
if (!(b.width > 0 && b.height > 0)) return;
e.preventDefault();
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
if (d.mode === 'move') {
d.mark.onMove({
x: cl(d.x + (e.clientX - d.px) / b.width, 0, 1),
y: cl(d.y + (e.clientY - d.py) / b.height, 0, 1),
});
return;
}
// The box's top-left corner stays put while the face grows: the top sits
// (ascent - the first baseline)em above y, so y gives back exactly that
// much of the height the larger face adds over the corner.
const f = d.width > 0 ? (e.clientX - d.left) / d.width : 1;
const size = cl(d.size * f, WM_SIZE_MIN, WM_SIZE_MAX);
const fontPx = wmFontPx(b.width, size);
d.mark.onMove({
size,
y: cl(d.y + ((WM_ASCENT_EM - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1),
});
};
const endWmDrag = (e: React.PointerEvent<HTMLElement>) => {
if (!wmDragRef.current) return;
wmDragRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className="stage"
@@ -554,6 +696,44 @@ export function ImageStage({
</div>
</div>
) : null}
{/* FRAME's watermarks: each mark's own box, so the text can be dragged
around the photo and scaled from its corner. */}
{box && !cropEditing && !picking && !peek && wmBoxes.length ? (
<div
className="wm-layer"
data-key="wm-layer"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
>
{wmBoxes.map(({ mark, left, top, width, height }) => (
<div
key={mark.key}
className="wm-rect"
data-key={`wm-rect-${mark.key}`}
style={{ left, top, width, height }}
onPointerDown={beginWmDrag(mark, 'move')}
onPointerMove={moveWmDrag}
onPointerUp={endWmDrag}
onPointerCancel={endWmDrag}
>
<span
className="wm-handle br"
data-key={`wm-handle-${mark.key}`}
onPointerDown={beginWmDrag(mark, 'size')}
onPointerMove={moveWmDrag}
onPointerUp={endWmDrag}
onPointerCancel={endWmDrag}
/>
</div>
))}
</div>
) : null}
{/* FRAME's STRAIGHTEN scale: a graduated ruler along the foot of the
photo, so the angle is turned while the picture that answers it is
in front of the eye. */}
@@ -654,6 +834,17 @@ export function ImageStage({
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
{t('drop.replace')}
</button>
{onSaveOriginal ? (
<button
type="button"
className="btn"
data-key="save-original"
title={t('drop.keepHint')}
onClick={onSaveOriginal}
>
{t('drop.keep')}
</button>
) : null}
<button
type="button"
className={compare ? 'btn primary' : 'btn'}
+11 -4
View File
@@ -36,9 +36,10 @@ export function TopBar({
onSavePhoto: () => void;
onExport: () => void;
// This account's own folder: the SAVE PHOTO button states the count, and the
// API refuses past the cap. A guest sees 0/12 and is sent to sign in.
// API refuses past the cap. A guest sees 0/12 and is sent to sign in. null is
// the admin, who the API lets past the cap — the button then just counts.
photoCount: number;
photoMax: number;
photoMax: number | null;
onAuth: () => void;
onSignup: () => void;
onLogout: () => void;
@@ -97,10 +98,16 @@ export function TopBar({
className="btn ghost"
data-key="save-photo"
disabled={savingPhoto || exporting}
title={photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined}
title={
photoMax !== null && photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined
}
onClick={onSavePhoto}
>
{savingPhoto ? t('photo.uploading') : t('save.photo', { n: photoCount, max: photoMax })}
{savingPhoto
? t('photo.uploading')
: photoMax === null
? t('save.photoNoMax', { n: photoCount })
: t('save.photo', { n: photoCount, max: photoMax })}
</button>
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}