2 Commits

Author SHA1 Message Date
3dtours e021f7d1ff web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.

Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.

`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.

The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.

On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.

The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.

Verified:
  backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
    drives the route end to end against the source: signup leaves a six-digit
    code beside the link, a wrong code verifies nothing and leaves the account
    unproven, a five-digit body is refused, a signed-out caller cannot type one,
    the mailed code verifies, spending it spends the link, five wrong guesses
    lock the code out and the right code then does not help, a resent letter
    hands out a fresh code that is not locked out by the old guesses, and a code
    aged past its quarter hour is refused.
  otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
    against the rebuilt app and api, no page errors: a fresh signup lands on the
    verify face with the box ready, a wrong code says so and the account stays a
    guest, the mailed code unlocks PRO, and a proven address is not asked again
    on the next login.
  Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
    award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.

ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
2026-09-23 20:08:08 +07:00
3dtours 56d4b9df67 web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.

A recipe carries the curve as `adjustments.toneCurve`, an optional map from
channel to point list, `Partial<Record<'rgb'|'r'|'g'|'b', [number, number][]>>`.
The field is optional and the API stores the recipe JSON opaquely, so every
recipe and session written before this commit loads unchanged and simply has no
curve; nothing on the API or in the database moved.

The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into
a 256-entry table per channel and the shader looks the table up in a 256x1
texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup
has to come from a texture, and a table is the cheaper shape anyway — one
`lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between
points is a monotone cubic (Fritsch–Carlson) rather than a natural spline,
because a spline overshoots between two close points and that overshoot is the
classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic
through the points bends through them and never turns back on itself. The table
is built per channel and then composited through the master, the order the graph
draws it in, so an R point in the shadows survives an RGB contrast S and both
land where the lines say.

Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last —
after the stock, the matrix, the mixer and the seasonal grade, so a point placed
on the graph is the last word on that pixel. Preview and export both call
`renderPhoto`, so the two agree by construction rather than by two matching
implementations. The pass wraps whatever shader the pipeline had built
(`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts
towards `graded` for the same reason the tone shader does: the curve reads the
matrix's output, so when there is a matrix it has to be in the pixels the curve
samples. Turning the curve on costs one extra render pass and nothing else; off,
`curveIsActive` is false and the pass is not built at all.

That pass is also where this spent its time being invisible. The curve data
reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not
exist in the shim, so the call threw a TypeError inside the render, the preview
effect's catch swallowed it into `setError('err.generic')`, and the chip, the
graph and the recipe all looked healthy while the canvas kept the old frame. The
fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`)
and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists
on an explicit `colorSpace` where RN Skia's does not — everything this pipeline
builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape.
Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call
throws, the translated one returns a 256x1 image.

`ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no
zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost
behind a channel line so a channel edit is still visible against the other
three. Ends are pinned to x 0 and 1, a point cannot be dragged past its
neighbours (2% of the axis is the closest they may sit) and cannot be dragged
out of the square, so the graph can never describe a curve the renderer cannot
apply. One pointerdown grabs the nearest point inside 11px or adds one on the
line under the cursor and keeps dragging, so a click is a point and a drag is a
bend. Deleting a point is the graph's own double-click, not the circle's, and it
has to be: grabbing a point takes pointer capture, so the click that follows is
delivered to the SVG rather than the circle under the cursor.

RESET clears the whole graph, all four channels, and hands back an empty object
that `App.tsx` maps to `undefined` so the recipe drops the field rather than
keeping a `toneCurve: {}` — the field's presence is what "this picture has a
curve" means, and an empty map that means the same as no map is a state two
pieces of code would eventually disagree about. One undo step per visit to the
graph, the rule the ruler and the watermark box already ride: a drag is one
edit, not one per pointer move.

No new i18n keys: the chip and the panel labels are literal uppercase, the same
as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT
control like the rest of the tab.

Verified:
  tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to
    http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL,
    no page errors. The ramp is a ramp before (9..246), a flat curve is two
    points and no pass, the graph is drawn on the photo (graph 729,280 240x291
    against photo 719,325 256x256), every stop of the ramp lands on the drawn
    curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252,
    a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the
    R tab takes the graph over while the composite stays visible behind it and R
    drives red at black to 255 with G and B still on the composite (133,132
    against 132), the recipe carries toneCurve, it survives a reload (254 -> 254,
    chip still amber), a click adds a point and a double-click removes it again,
    RESET returns the ramp to its start (worst 0) and drops the field, and close
    takes the graph off the photo.
  tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own
    arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the
    graph says, a steeper segment never turns back on itself, a channel curve
    runs before the composite, a click lands on the line, two points cannot
    share a spot, an end cannot leave the axis, and the two ends survive a
    delete where a middle point does not.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10.
  web tsc --noEmit clean.

ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
2026-09-23 20:07:51 +07:00
17 changed files with 885 additions and 54 deletions
+7 -5
View File
@@ -6,12 +6,14 @@ WEB_PORT=8090
# Leave empty to make nobody an admin.
ADMIN_EMAILS=
# The mail relay that sends the address-verification link. A new account is a
# guest until it follows that link, so a deployment without a relay can only
# ever hand out guest access.
# The mail relay that sends the address-verification mail — the 6-digit code
# plus the link. A new account is a guest until it proves the address with one
# of the two, so a deployment without a relay can only ever hand out guest
# access.
#
# Leave SMTP_HOST empty and the link is written to the api container's log
# instead (`docker compose logs api`), which is enough for local work.
# Leave SMTP_HOST empty and both the code and the link are written to the api
# container's log instead (`docker compose logs api`), which is enough for local
# work.
SMTP_HOST=
# 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set
# SMTP_SECURE=true to force the latter on an unusual port.
+63 -10
View File
@@ -1,7 +1,7 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto';
export const SESSION_COOKIE = 'rc_session';
export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days
@@ -86,7 +86,12 @@ CREATE TABLE IF NOT EXISTS email_verifications (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
-- The six digits the same letter carries, so a visitor can prove the address
-- without leaving the page they signed up on. NULL for a row minted before
-- codes existed; attempts counts only the guesses at it.
code TEXT,
attempts INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS places (
key TEXT PRIMARY KEY,
@@ -165,6 +170,19 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// The mailed code, added once visitors were expected to prove an address
// without leaving the page. Rows minted before it keep working as links: their
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
{
const cols = db.prepare('PRAGMA table_info(email_verifications)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'code')) {
db.exec(`ALTER TABLE email_verifications ADD COLUMN code TEXT`);
}
if (!cols.some((c) => c.name === 'attempts')) {
db.exec(`ALTER TABLE email_verifications ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`);
}
}
// The strip's own labels, added after the first contributions were on disk: the
// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title
// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are
@@ -277,18 +295,30 @@ export function findUserById(id: number): User | undefined {
// mail is the only link that works and the table cannot grow past the user
// count. 24 hours is long enough to find the mail in a spam folder.
export const VERIFY_TTL_S = 24 * 60 * 60;
// The typed code is the same proof over a shorter clock: six digits are worth
// guessing for a quarter of an hour, not for a day. It rides the same row —
// the link's `expires_at` and the code's `created_at + CODE_TTL_S` are two
// clocks over one row, which is why the code needs no expiry column of its own.
export const CODE_TTL_S = 15 * 60;
// Five guesses at a million, then the row is spent: the count is what keeps a
// six-digit secret from being walked through a hundred requests a second.
export const MAX_CODE_ATTEMPTS = 5;
export function createEmailVerification(userId: number): string {
export interface Verification {
token: string;
code: string;
}
export function createEmailVerification(userId: number): Verification {
const token = randomBytes(32).toString('hex');
// randomInt, not Math.random: the code is the one value here worth predicting.
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
const at = now();
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
db.prepare('INSERT INTO email_verifications (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)').run(
token,
userId,
new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(),
at,
);
return token;
db.prepare(
'INSERT INTO email_verifications (token, code, user_id, expires_at, created_at, attempts) VALUES (?, ?, ?, ?, ?, 0)',
).run(token, code, userId, new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), at);
return { token, code };
}
// The account the token proves, or null when it is unknown or expired — the
@@ -305,6 +335,29 @@ export function verifyEmailToken(token: string): number | null {
return row.userId;
}
// What a typed code did. 'stale' covers both "no live code" and "too old", so
// the caller learns nothing about which — the two are one answer, "ask for
// another letter". 'locked' is the spent-attempts state, which the visitor can
// only leave by having a new code mailed.
export type CodeResult = 'ok' | 'bad' | 'stale' | 'locked';
export function verifyEmailCode(userId: number, code: string): CodeResult {
const row = db
.prepare('SELECT code, attempts, created_at AS createdAt FROM email_verifications WHERE user_id = ?')
.get(userId) as { code: string | null; attempts: number; createdAt: string } | undefined;
if (!row || !row.code) return 'stale';
if (new Date(row.createdAt).getTime() + CODE_TTL_S * 1000 <= Date.now()) return 'stale';
if (row.attempts >= MAX_CODE_ATTEMPTS) return 'locked';
// The attempt is counted before the answer is given, so an interrupted
// request cannot hand back a guess nobody paid for.
db.prepare('UPDATE email_verifications SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
if (code.length !== row.code.length || !timingSafeEqual(Buffer.from(code), Buffer.from(row.code))) return 'bad';
// One row is both proofs, so the code being spent spends the link with it.
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(userId);
return 'ok';
}
export function deleteEmailVerifications(userId: number): void {
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
}
+17 -7
View File
@@ -33,26 +33,36 @@ const transporter: Transporter | null = mailConfigured
: null;
// Both languages, because the account's language is not known before it exists.
const body = (url: string) =>
// The code comes first and the link second: the code is the one that works
// without leaving the page the visitor signed up on, so it is what most of them
// will use — but the link stays, for the phone that owns the address, and
// because a letter that only carries digits is a letter a spam filter can
// decide is a phishing attempt.
const body = (url: string, code: string) =>
[
'RecipesCam — xác thực địa chỉ email / verify your email address',
'',
`Mã xác thực của bạn / your verification code: ${code}`,
'',
'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.',
'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.',
'',
'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):',
url,
'',
'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.',
'The link expires in 24 hours. If you did not sign up, ignore this mail.',
'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.',
].join('\r\n');
// Fire and forget: the account already exists, so a relay that is slow, out of
// quota or misconfigured may not fail the signup that asked for it. The owner
// can ask for another link from the studio; the operator sees the error here.
export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void {
export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void {
if (!transporter) {
log(`[verify] SMTP not configured — verification link for ${to}: ${url}`);
log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`);
return;
}
transporter
.sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) })
.then(() => log(`[verify] link sent to ${to}`))
.sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) })
.then(() => log(`[verify] code and link sent to ${to}`))
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
}
+30 -6
View File
@@ -53,6 +53,7 @@ import {
updateRecipe,
updateUserEmail,
userAvatar,
verifyEmailCode,
verifyEmailToken,
verifyPassword,
type PhotoMeta,
@@ -267,13 +268,13 @@ function originOf(req: FastifyRequest): string {
return host ? `${proto}://${host}` : '';
}
// Mints the single live token and hands the link to the mailer. The URL is the
// API's own route, so a click needs no page of its own (see the redirect
// there). A relay that cannot send is not an error here: the link is in the
// log, and the account can ask again.
// Mints the single live token and code and hands both to the mailer. The URL is
// the API's own route, so a click needs no page of its own (see the redirect
// there). A relay that cannot send is not an error here: the code and the link
// are in the log, and the account can ask again.
function sendVerification(req: FastifyRequest, user: User): void {
const token = createEmailVerification(user.id);
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg));
const { token, code } = createEmailVerification(user.id);
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, code, (msg) => req.log.info(msg));
}
// ---- analytics ------------------------------------------------------------
@@ -475,6 +476,29 @@ app.get('/api/auth/verify', async (req, reply) => {
return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303);
});
// The code from the same letter, typed into the page the visitor signed up on.
// A POST, unlike the link: it is a mutation the visitor submits on purpose, and
// a code in a GET query string would land in every proxy log on the way.
//
// No limiter of its own: the row carries the cap (MAX_CODE_ATTEMPTS) and a
// fresh code costs one of the three resends an hour, so five guesses per code
// is the budget an attacker has either way — and the cap lives with the secret.
app.post('/api/auth/verify-code', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true });
const b = bodyOf(req);
const code = b && typeof b.code === 'string' ? b.code.trim() : '';
// Six digits or nothing, so the counter only ever counts real guesses.
if (!/^\d{6}$/.test(code)) return reply.status(400).send({ error: 'invalid code' });
const result = verifyEmailCode(user.id, code);
if (result === 'locked') return reply.header('retry-after', '900').status(429).send({ error: 'too_many_attempts' });
if (result !== 'ok') return reply.status(400).send({ error: 'invalid code' });
return reply.status(200).send({ ok: true, verified: true });
});
// The banner's own button. Capped like signup and keyed on the address, so the
// route is not a way to mail a stranger repeatedly.
const allowResend = limiter(3, 60 * 60_000);
+62
View File
@@ -100,6 +100,25 @@ async function activeSignup(a, email) {
await followVerifyLink(email);
return res;
}
// The same letter carries a code, and the suite reaches it the same way — the
// database is the only reader of the mail here.
function codeFor(email) {
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
const row = db
.prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
.get(email);
db.close();
return row?.code;
}
// The code's own clock starts at `created_at`, so an old one is made here
// rather than waited for.
function ageCode(email, minutes) {
const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.prepare(
'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)',
).run(new Date(Date.now() - minutes * 60_000).toISOString(), email);
db.close();
}
// Run the sources, not a possibly stale build: the point of this suite is to
// test the code as written.
@@ -225,6 +244,49 @@ try {
check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location')));
check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true);
// ---- the code in the same letter ----------------------------------------
// The link is not the only way to prove an address any more: the letter also
// carries six digits the visitor types into the dialog they signed up on. The
// code is the shorter-lived of the two proofs and the one worth guessing, so
// the checks below are mostly about what a wrong guess costs.
const typed = actor();
const typedEmail = `typed${stamp}@test.local`;
await typed.signup(typedEmail);
const code = codeFor(typedEmail);
check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code));
const wrongCode = code === '000000' ? '111111' : '000000';
const type = (a, value) =>
a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) });
check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400);
check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false);
check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400);
check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401);
check('the mailed code verifies the account', (await type(typed, code)).status === 200);
check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true);
check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail)));
const locked = actor();
const lockedEmail = `locked${stamp}@test.local`;
await locked.signup(lockedEmail);
const lockedCode = codeFor(lockedEmail);
const otherCode = lockedCode === '000000' ? '111111' : '000000';
for (let i = 0; i < 5; i++) await type(locked, otherCode);
const afterLock = await type(locked, lockedCode);
check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`);
check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false);
await locked.req('/auth/resend-verification', { method: 'POST' });
const freshCode = codeFor(lockedEmail);
check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode)));
check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200);
const stale = actor();
const staleEmail = `stale${stamp}@test.local`;
await stale.signup(staleEmail);
const staleCode = codeFor(staleEmail);
ageCode(staleEmail, 16); // past CODE_TTL_S
check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400);
check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false);
// ---- rate limiting ------------------------------------------------------
const brute = actor();
const bruteEmail = `brute${stamp}@test.local`;
+16
View File
@@ -84,6 +84,19 @@ export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue'
// stays empty until the mixer is actually used.
export type HslBand = [number, number, number];
// LIGHT tab > TONE CURVE. One editable curve per channel, each a list of
// control points as [input, output] pairs in 0..1 — the first pinned to x=0 and
// the last to x=1, so every curve answers for black and for white. The composite
// ('rgb') curve is applied AFTER the three channels (Photoshop's order), which is
// what makes the same points print the same numbers in every renderer.
//
// Only the channels the user actually moved are stored, so an untouched curve
// costs nothing, an old recipe stays valid, and the recipe JSON the API keeps
// carries it without a migration.
export type ToneCurvePoint = [number, number];
export type ToneCurveChannel = 'rgb' | 'r' | 'g' | 'b';
export type ToneCurve = Partial<Record<ToneCurveChannel, ToneCurvePoint[]>>;
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
contrast: number; // -10 to +10
@@ -116,6 +129,9 @@ export interface ColorAdjustments {
hslHue?: number;
hslSat?: number;
hslLum?: number;
// TONE CURVE, the last word on tone: applied after everything above. Absent or
// flat = no move at all.
toneCurve?: ToneCurve;
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
+203
View File
@@ -0,0 +1,203 @@
import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../types';
// LIGHT tab > TONE CURVE — the graph on the photo, and the table the renderer
// reads it as.
//
// One curve per channel, each a list of control points. The graph hands the
// renderer 256 numbers per channel rather than the points themselves: a point
// list would have to be interpolated per pixel inside SkSL, and SkSL indexes
// uniform arrays by constant only (see TONE_SKSL's unrolled mixer), so a table
// looked up in a texture is both the cheaper and the only workable shape.
//
// The interpolation is a monotone cubic (Fritsch–Carlson). A plain cubic spline
// overshoots between two close points — the classic tone-curve tell, a bright
// halo next to a lifted shadow — and Lightroom's curves do not do it: drag a
// point and the line bends through it without ever turning back on itself.
const EPS = 1e-6;
const FLAT = 0.002; // how far off the diagonal a point must sit to count as a move
export const CURVE_CHANNELS: ToneCurveChannel[] = ['rgb', 'r', 'g', 'b'];
export const CURVE_LUT_SIZE = 256;
// What each channel's line is drawn in, and what it does to the pixels.
export const CURVE_CHANNEL_COLOR: Record<ToneCurveChannel, string> = {
rgb: '#f2f2f2',
r: '#ff5a52',
g: '#4fd06a',
b: '#4f8dff',
};
export const IDENTITY_CURVE: ToneCurvePoint[] = [
[0, 0],
[1, 1],
];
// The closest two points may sit on the input axis — nearer than this and they
// would drag each other around instead of moving one bend.
export const CURVE_MIN_GAP = 0.02;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const num = (v: unknown, fallback: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
// The stored points, made readable: numbers, inside the square, ordered along
// the input axis, and pinned to the two ends. Everything below reads a curve
// through this, so a hand-written recipe file cannot produce a curve the graph
// and the renderer disagree about.
export function curvePoints(curve: ToneCurve | undefined, ch: ToneCurveChannel): ToneCurvePoint[] {
const raw = curve?.[ch];
if (!Array.isArray(raw) || raw.length < 2) return IDENTITY_CURVE.map((p) => [...p] as ToneCurvePoint);
const pts = raw
.map((p) => [clamp01(num(p?.[0], 0)), clamp01(num(p?.[1], 0))] as ToneCurvePoint)
.sort((a, b) => a[0] - b[0]);
pts[0][0] = 0;
pts[pts.length - 1][0] = 1;
return pts;
}
// True when the curve would move a pixel — the chip's amber dot, and whether the
// renderer bothers with the pass at all. A point off the diagonal is what makes
// a curve non-flat: a monotone cubic through points all ON the diagonal is the
// diagonal.
export function curveIsActive(curve: ToneCurve | undefined): boolean {
if (!curve) return false;
return CURVE_CHANNELS.some((ch) => curvePoints(curve, ch).some((p) => Math.abs(p[1] - p[0]) > FLAT));
}
// Fritsch–Carlson tangents: the slopes that keep the interpolant monotone
// between the points, and vanish at a flat segment instead of bulging past it.
function tangents(pts: ToneCurvePoint[]): number[] {
const n = pts.length;
const d: number[] = [];
for (let i = 0; i < n - 1; i++) d.push((pts[i + 1][1] - pts[i][1]) / Math.max(EPS, pts[i + 1][0] - pts[i][0]));
const m: number[] = new Array(n).fill(0);
m[0] = d[0];
m[n - 1] = d[n - 2];
for (let i = 1; i < n - 1; i++) m[i] = (d[i - 1] + d[i]) / 2;
for (let i = 0; i < n - 1; i++) {
if (Math.abs(d[i]) < EPS) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / d[i];
const b = m[i + 1] / d[i];
const s = a * a + b * b;
if (s > 9) {
const t = 3 / Math.sqrt(s);
m[i] = t * a * d[i];
m[i + 1] = t * b * d[i];
}
}
return m;
}
// The curve's output for one input, clamped into the square: the graph has no
// room above white or below black, so neither has the curve.
export function sampleCurve(pts: ToneCurvePoint[], x: number): number {
const p = Array.isArray(pts) && pts.length >= 2 ? pts : IDENTITY_CURVE;
const m = tangents(p);
const t = clamp01(x);
if (t <= p[0][0]) return clamp01(p[0][1]);
for (let i = 0; i < p.length - 1; i++) {
if (t <= p[i + 1][0]) {
const h = Math.max(EPS, p[i + 1][0] - p[i][0]);
const s = (t - p[i][0]) / h;
const s2 = s * s;
const s3 = s2 * s;
const y =
(2 * s3 - 3 * s2 + 1) * p[i][1] +
(s3 - 2 * s2 + s) * h * m[i] +
(-2 * s3 + 3 * s2) * p[i + 1][1] +
(s3 - s2) * h * m[i + 1];
return clamp01(y);
}
}
return clamp01(p[p.length - 1][1]);
}
// The curve as the renderer reads it: 256 entries per output channel, in one
// RGBA row — R, G, B are the three channels' own curves and A is unused (kept at
// 255 so the image is unambiguous). Each channel's curve runs first and the
// composite after it, so a point moved on R means "R, as the composite curve
// then leaves it".
export function curveLut(curve: ToneCurve | undefined): Uint8Array {
const master = curvePoints(curve, 'rgb');
const chans = (['r', 'g', 'b'] as const).map((ch) => curvePoints(curve, ch));
const out = new Uint8Array(CURVE_LUT_SIZE * 4);
for (let i = 0; i < CURVE_LUT_SIZE; i++) {
const v = i / (CURVE_LUT_SIZE - 1);
for (let c = 0; c < 3; c++) out[i * 4 + c] = Math.round(sampleCurve(master, sampleCurve(chans[c], v)) * 255);
out[i * 4 + 3] = 255;
}
return out;
}
// The curve pass: the graded pixels in, the table's answer out. The LUT is
// sampled at texel centres (`v * 255 + 0.5`), which is where the 256 values
// actually sit, so entry i returns entry i and the ends clamp to the end values.
export const CURVE_SKSL = `
uniform shader src;
uniform shader lut;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 v = clamp(c.rgb, 0.0, 1.0) * 255.0 + 0.5;
return vec4(
lut.eval(vec2(v.r, 0.5)).r,
lut.eval(vec2(v.g, 0.5)).g,
lut.eval(vec2(v.b, 0.5)).b,
c.a
);
}
`;
// --- what the graph itself does to a point list ----------------------------
// Kept here rather than in the panel: the rules (which point may move where,
// what a click adds, what survives a delete) are about the curve, not the SVG.
export function addCurvePoint(pts: ToneCurvePoint[], x: number, y: number): ToneCurvePoint[] {
const px = clamp01(x);
const py = clamp01(y);
let at = pts.length;
for (let i = 0; i < pts.length; i++) {
if (pts[i][0] >= px) {
at = i;
break;
}
}
const before = at > 0 ? pts[at - 1] : null;
const after = at < pts.length ? pts[at] : null;
if (before && px - before[0] < CURVE_MIN_GAP) return pts;
if (after && after[0] - px < CURVE_MIN_GAP) return pts;
// A click adds the point ON the line under it, not where the pointer was: the
// promise is "grab the curve here", and a click that moved the picture by
// itself would make the graph dangerous. A click that does not lift is a point
// added and left on the line — no pixel moves until one is dragged.
return [...pts.slice(0, at), [px, sampleCurve(pts, px)] as ToneCurvePoint, ...pts.slice(at)];
}
// `at` is the point's index in the list; the two ends keep their place on the
// input axis (they are black and white) and slide freely on the output axis.
export function moveCurvePoint(pts: ToneCurvePoint[], at: number, x: number, y: number): ToneCurvePoint[] {
if (at < 0 || at >= pts.length) return pts;
const next = pts.map((p) => [...p] as ToneCurvePoint);
const first = at === 0;
const last = at === pts.length - 1;
const lo = first ? 0 : Math.min(pts[at - 1][0] + CURVE_MIN_GAP, 1 - CURVE_MIN_GAP);
const hi = last ? 1 : Math.max(pts[at + 1][0] - CURVE_MIN_GAP, CURVE_MIN_GAP);
next[at][0] = first ? 0 : last ? 1 : Math.max(lo, Math.min(hi, clamp01(x)));
next[at][1] = clamp01(y);
return next;
}
export function removeCurvePoint(pts: ToneCurvePoint[], at: number): ToneCurvePoint[] {
if (at <= 0 || at >= pts.length - 1) return pts;
const next = pts.filter((_, i) => i !== at);
return next.length >= 2 ? next : IDENTITY_CURVE.map((p) => [...p] as ToneCurvePoint);
}
// Where a curve sits at the ends, for a caller that wants to say "R: +12 at the
// top" — the panel prints nothing today, but the reset button and the chip need
// the same "is it flat" answer.
export function isFlatCurve(pts: ToneCurvePoint[]): boolean {
return pts.every((p) => Math.abs(p[1] - p[0]) <= FLAT);
}
+36 -3
View File
@@ -3,6 +3,7 @@ import { ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { StraightenRuler } from './ui/StraightenRuler';
import { ToneCurvePanel } from './ui/ToneCurvePanel';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
@@ -35,6 +36,7 @@ import {
} from '../shared/types';
import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
import { grainPerInch, grainStockFor } from '../shared/utils/grainShader';
import { curveIsActive } from '../shared/utils/toneCurve';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -1674,8 +1676,9 @@ export function Workspace() {
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not
// stacked in this column at all. Its ruler lies across the foot of the
// photo (StraightenRuler, drawn by ImageStage) so the angle is read off the
// picture it is levelling; nothing opens here.
if (openParam === 'straighten') return null;
// picture it is levelling; nothing opens here. TONE CURVE is the same shape
// of tool: its graph is on the photo, so it opens no row either.
if (openParam === 'straighten' || openParam === 'curve') return null;
// The mixer's three knobs are not in PARAM_DEFS: they read and write the
// image's own move rather than a flat field of adjustments, and unlike the
// panel on the photo they are not tied to the band the mixer is aimed at.
@@ -2213,7 +2216,21 @@ export function Workspace() {
return rows;
}
case 'light':
return [...paramChips(PARAM_DEFS.iq), groupChip('dr')];
return [
...paramChips(PARAM_DEFS.iq),
groupChip('dr'),
// TONE CURVE is not a row of sliders: it opens the graph on the photo
// (ToneCurvePanel), so the chip toggles that overlay rather than a
// ruler in the last column. It glows amber once the graph is off the
// diagonal, which is the only place the curve is reported.
{
key: 'curve',
label: 'TONE CURVE',
active: openParam === 'curve',
amberValue: curveIsActive(recipe.adjustments.toneCurve),
onClick: () => toggleParam('curve'),
},
];
case 'wb':
return [
{
@@ -2768,6 +2785,22 @@ export function Workspace() {
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
) : null
}
curvePanel={
openParam === 'curve' ? (
<ToneCurvePanel
curve={recipe.adjustments.toneCurve}
// One undo step per visit to the graph, the same rule the ruler
// and the watermark box ride: a drag is one edit, not one per
// pointer move. RESET hands back an empty object, and that has
// to leave the recipe without a curve at all rather than with a
// `toneCurve: {}` — the field is what "there is a curve" means.
onChange={(next) =>
setAdjustmentOnce({ toneCurve: Object.keys(next).length ? next : undefined })
}
onClose={() => setOpenParam(null)}
/>
) : null
}
/>
</div>
+5
View File
@@ -221,6 +221,11 @@ export const api = {
// Mail the verification link to the signed-in address again. Works while
// unverified (that is the whole point); 429 once the hourly cap is spent.
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
// The six digits from the same letter, typed instead of clicking the link.
// The route answers to the session the signup already granted, so the code
// proves the address to the browser that asked for it.
verifyCode: (code: string) =>
call<{ ok: boolean; verified?: boolean }>('/auth/verify-code', { method: 'POST', body: JSON.stringify({ code }) }),
listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'),
createRecipe: (name: string, recipe: Recipe) =>
+48 -2
View File
@@ -33,6 +33,7 @@ import {
glowUniformArray,
} from '../../shared/utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
import {
GRAIN_SKSL,
HALATION_SKSL,
@@ -138,6 +139,7 @@ let toneEffect: any = null;
let cinemaEffect: any = null;
let glowEffect: any = null;
let halationEffect: any = null;
let curveEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
@@ -145,7 +147,8 @@ function effects() {
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect };
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect };
}
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
@@ -408,9 +411,16 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
const cinemaOn = !!cinema && cinemaIsActive(cinema.flat);
// 3d. TONE CURVE — the LIGHT tab's graph, applied as the outermost pass (see
// toneCurve.ts). It counts towards `graded` for the same reason the tone
// shader does: the curve reads whatever the matrix left, so when there is a
// matrix to apply it has to be in the pixels the curve's child shader
// samples rather than a colour filter on top of the result.
const curve = adjustments.toneCurve;
const curveOn = curveIsActive(curve);
let paintShader: any = null;
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
const graded = (toneIsActive(tone) || cinemaOn || curveOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) {
@@ -438,6 +448,42 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
}
if (!paintShader && toneShader) paintShader = toneShader;
// 3e. The curve pass, last: whatever the stock, the matrix, the mixer or the
// seasonal grade did, a point moved on the graph lands the pixel where the
// graph says. The table is an image, so the shader can look it up per channel
// at a computed coordinate — a uniform array cannot be indexed that way.
if (curveOn) {
const { curveEffect: effect } = effects();
const lut = effect
? own(
Skia.Image.MakeImage(
{
width: CURVE_LUT_SIZE,
height: 1,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Premul,
},
curveLut(curve),
CURVE_LUT_SIZE * 4
)
)
: null;
const lutShader = lut
? own(
lut.makeShaderOptions(
Skia.TileMode.Clamp,
Skia.TileMode.Clamp,
Skia.FilterMode.Linear,
Skia.MipmapMode.None
)
)
: null;
const cs =
effect && lutShader
? own(effect.makeShaderWithChildren([], [paintShader ?? imageShaderOf(), lutShader]))
: null;
if (cs) paintShader = cs;
}
// Only one of the two may tint the pixels: the graded image already carries
// the matrix, an ungraded draw still needs it.
if (paintShader) paint.setShader(paintShader);
+9
View File
@@ -159,6 +159,15 @@ export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
canvasKit.MakeImageFromEncoded(
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
),
// An image from bytes rather than from a file — the tone curve's 256x1
// lookup table (see toneCurve.ts). RN Skia takes the factory here as
// `Image.MakeImage(info, bytes, bytesPerRow)`; CanvasKit keeps that one
// top-level and only puts the encoded/lazy factories under `Image.`, so the
// call site has to be translated. CanvasKit's ImageInfo also insists on an
// explicit colorSpace where RN Skia's does not: everything this pipeline
// builds is sRGB, so it is filled in rather than repeated at every call.
MakeImage: (info: any, bytes: Uint8Array, bytesPerRow: number) =>
canvasKit.MakeImage({ colorSpace: canvasKit.ColorSpace.SRGB, ...info }, bytes, bytesPerRow),
},
Typeface: {
MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
+7 -4
View File
@@ -119,11 +119,14 @@ export const en: Dict = {
'auth.busy': 'Working…',
'auth.loggedInAs': 'Signed in as {email}',
'auth.verifyTitle': 'Verify your email',
'auth.verifyBody': 'We sent a verification link to {email}. Open it to unlock the PRO features.',
'auth.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — to unlock the PRO features.',
'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.',
'auth.verifySent': 'Verification email sent again.',
'auth.verifiedDone': 'I have verified',
'auth.signupVerifyHint': 'After signing up we email you a verification link — open it to unlock the PRO features.',
'auth.verifySent': 'A new code has been sent.',
'auth.codePlaceholder': 'The six digits from the mail',
'auth.verifyBtn': 'VERIFY',
'auth.codeBad': 'That code is wrong or has expired (15 minutes).',
'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.',
'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in to unlock the PRO features.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
+7 -4
View File
@@ -127,11 +127,14 @@ export const vi = {
'auth.busy': 'Đang xử lý…',
'auth.loggedInAs': 'Đã đăng nhập: {email}',
'auth.verifyTitle': 'Xác thực email',
'auth.verifyBody': 'Đã gửi một liên kết xác thực tới {email}. Mở liên kết đó để dùng được các tính năng PRO.',
'auth.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để dùng được các tính năng PRO.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.',
'auth.verifySent': 'Đã gửi lại email xác thực.',
'auth.verifiedDone': 'Tôi đã xác thực xong',
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một email xác thực — mở liên kết trong đó để bật các tính năng PRO.',
'auth.verifySent': 'Đã gửi lại mã xác thực.',
'auth.codePlaceholder': 'Mã 6 số trong email',
'auth.verifyBtn': 'XÁC THỰC',
'auth.codeBad': 'Mã không đúng hoặc đã hết hạn (15 phút).',
'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.',
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để bật các tính năng PRO.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
+78
View File
@@ -323,6 +323,84 @@ 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; }
/* LIGHT's TONE CURVE: the graph hung on the photo. The layer is the photo's own
box and takes no pointer events; the card sits at its bottom-left corner and
takes them all, because every gesture in it is about the curve. */
.curve-layer { position: absolute; pointer-events: none; }
.curve-panel {
position: absolute;
left: 10px;
bottom: 10px;
pointer-events: auto;
touch-action: none;
width: 240px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
user-select: none;
}
.curve-head { display: flex; align-items: center; justify-content: space-between; }
.curve-title { font-size: 11px; letter-spacing: 0.1em; color: var(--muted); }
.curve-close {
border: none;
background: none;
color: var(--muted);
font-size: 12px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.curve-close:hover { color: var(--fg); }
.curve-tabs { display: flex; gap: 4px; align-items: center; }
.curve-tab {
display: flex;
align-items: center;
gap: 5px;
flex: 1 1 0;
justify-content: center;
padding: 3px 0;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-tab.on { border-color: var(--accent-line); color: var(--fg); }
/* The dot carries the channel's own colour, so the tab says which line it draws;
it goes amber once that channel has left the diagonal. */
.curve-dot { width: 7px; height: 7px; border-radius: 999px; }
.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); }
.curve-reset {
flex: 0 0 auto;
padding: 3px 7px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); }
.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; }
.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; }
.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; }
/* The line the active tab draws, and — on a channel tab — the composite curve
under it, so the two moves can be told apart. */
.curve-line { fill: none; stroke-width: 2; stroke-linecap: round; }
.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; }
.curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; }
.curve-point:active { cursor: grabbing; }
/* 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; }
+55 -11
View File
@@ -7,10 +7,11 @@ import { api } from '../api';
// out, and only the PRO half is gated (see config/tiers.ts).
//
// Three faces, one form. `verify` is the third: the account exists but the
// address it gave has not been confirmed, so until the visitor opens the link
// it is served exactly like a guest. A fresh signup lands on that face by
// itself — telling someone their account is ready when it is not is the one
// thing this dialog must not do.
// address it gave has not been confirmed, so until the visitor proves it — by
// typing the code from the letter, or by opening the link in it — it is served
// exactly like a guest. A fresh signup lands on that face by itself, with the
// code box already waiting: telling someone their account is ready when it is
// not is the one thing this dialog must not do.
export function AuthModal({
initialMode = 'login',
email,
@@ -28,18 +29,45 @@ export function AuthModal({
const [mode, setMode] = useState<'login' | 'signup' | 'verify'>(initialMode);
const [address, setAddress] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
// The API answers in codes; the two a visitor can actually reach are worth a
// sentence of their own, and the rest are shown as they come.
const complain = (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setError(
msg === 'invalid code'
? t('auth.codeBad')
: msg === 'too_many_attempts'
? t('auth.codeLocked')
: msg,
);
};
const resend = async () => {
setBusy(true);
setError(null);
try {
await api.resendVerification();
setSent(true);
} catch (err) {
complain(err);
} finally {
setBusy(false);
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
if (mode === 'verify') {
await api.resendVerification();
setSent(true);
await api.verifyCode(code);
onDone();
return;
}
const { user } = await (mode === 'login'
@@ -47,13 +75,13 @@ export function AuthModal({
: api.signup(address, password));
setAddress(user.email);
if (!user.verified) {
if (mode === 'signup') setSent(true); // signup mails the first link itself
if (mode === 'signup') setSent(true); // signup mails the first code itself
setMode('verify');
return;
}
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
complain(err);
} finally {
setBusy(false);
}
@@ -67,12 +95,28 @@ export function AuthModal({
<h2>{t('auth.verifyTitle')}</h2>
<p className="hint">{t('auth.verifyBody', { email: who })}</p>
<p className="hint">{t('auth.verifyProHint')}</p>
<input
className="field"
inputMode="numeric"
autoComplete="one-time-code"
// A one-time code, so nothing may paste a password into it: six
// digits, and the browser puts the number pad up on a phone.
type="text"
maxLength={6}
pattern="[0-9]{6}"
required
placeholder={t('auth.codePlaceholder')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
{sent ? <p className="hint ok">{t('auth.verifySent')}</p> : null}
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" data-key="verify-resend" disabled={busy}>
{busy ? t('auth.busy') : t('pro.resend')}
<button type="submit" className="btn primary" data-key="verify-code" disabled={busy || code.length < 6}>
{busy ? t('auth.busy') : t('auth.verifyBtn')}
</button>
<button type="button" className="btn" data-key="verify-resend" onClick={() => void resend()} disabled={busy}>
{t('pro.resend')}
</button>
<button type="button" className="btn" onClick={onDone}>{t('auth.verifiedDone')}</button>
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
</form>
</div>
+25 -2
View File
@@ -103,6 +103,7 @@ export function ImageStage({
pickPanelAt = null,
marks = [],
straightenRuler = null,
curvePanel = null,
dims = null,
}: {
previewUrl: string | null;
@@ -149,12 +150,20 @@ export function ImageStage({
// 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;
// LIGHT's TONE CURVE graph, hung on the photo while that chip is on: the curve
// is read against the picture it is reshaping, so it belongs on the stage
// rather than in a column beside it. It is anchored, not draggable — the graph
// is a fixed square and the photo moves under it.
curvePanel?: ReactNode;
// The photo's own pixel size, as the stage stands: the file's shape turned by
// the quarter turn and cut by a committed crop, so the number in the row under
// the photo is the resolution an export at the photo's own size renders.
dims?: { w: number; h: number } | null;
}) {
const showStraighten = !!straightenRuler;
// The curve graph is a fresh node on every parent render too, and the measure
// effect below only ever asks whether it is there.
const showCurve = !!curvePanel;
// `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;
@@ -240,7 +249,7 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve) {
setBox(null);
return;
}
@@ -261,7 +270,7 @@ export function ImageStage({
ro?.disconnect();
window.removeEventListener('resize', measure);
};
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, measure, previewUrl]);
const grabPanel = (e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation(); // the photo must not pan under the card
@@ -801,6 +810,20 @@ export function ImageStage({
</div>
</div>
) : null}
{/* LIGHT's TONE CURVE: the graph hangs on the photo while that chip is
on, so the curve is read against the picture it is moving. It
carries no zoom transform — the graph is a fixed square at the
photo's own corner, and a zoom is a way of looking at the photo
underneath it, not at the curve. */}
{curvePanel && box && !peek ? (
<div
className="curve-layer"
data-key="curve-layer"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
>
{curvePanel}
</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 ? (
+217
View File
@@ -0,0 +1,217 @@
import { useRef, useState } from 'react';
import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../../shared/types';
import {
CURVE_CHANNELS,
CURVE_CHANNEL_COLOR,
addCurvePoint,
curvePoints,
isFlatCurve,
moveCurvePoint,
removeCurvePoint,
sampleCurve,
} from '../../shared/utils/toneCurve';
// LIGHT > TONE CURVE: the graph that hangs on the photo. Four curves — the
// composite one and one per channel — drawn over a grid, with the points the
// user drags. It reads and writes nothing but the `toneCurve` field, so the
// render is the only other thing that has to know what a curve is (see
// toneCurve.ts for the table it hands the renderer).
//
// A click on the line drops a point on it, a drag moves one, and a double-click
// drops one — the three gestures Lightroom teaches, so nothing here has to be
// explained in words on the photo.
const SIZE = 224; // the graph's own square, in px
const STEPS = 64; // how finely the line is drawn
const HIT = 11; // px within which the pointer takes hold of a point
const NEEDS = 0.002; // how far off the diagonal counts as a move, as in toneCurve.ts
const LABEL: Record<ToneCurveChannel, string> = { rgb: 'RGB', r: 'R', g: 'G', b: 'B' };
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const offDiagonal = (pts: ToneCurvePoint[]) => pts.some((p) => Math.abs(p[1] - p[0]) > NEEDS);
export function ToneCurvePanel({
curve,
onChange,
onClose,
}: {
curve: ToneCurve | undefined;
onChange: (next: ToneCurve) => void;
onClose: () => void;
}) {
const [channel, setChannel] = useState<ToneCurveChannel>('rgb');
const svgRef = useRef<SVGSVGElement>(null);
const drag = useRef<{ id: number; at: number } | null>(null);
const pts = curvePoints(curve, channel);
const master = curvePoints(curve, 'rgb');
// A channel pulled back to the diagonal stores nothing: the recipe stays as
// small as the curve is flat, and an old recipe reads the same either way.
const put = (next: ToneCurvePoint[]): ToneCurve => {
const out: ToneCurve = { ...curve };
if (isFlatCurve(next)) delete out[channel];
else out[channel] = next;
return out;
};
const at = (e: { clientX: number; clientY: number }) => {
const r = svgRef.current?.getBoundingClientRect();
if (!r || !(r.width > 0) || !(r.height > 0)) return null;
return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01(1 - (e.clientY - r.top) / r.height) };
};
const line = (p: ToneCurvePoint[]) => {
const out: string[] = [];
for (let i = 0; i <= STEPS; i++) {
const x = i / STEPS;
out.push(`${i ? 'L' : 'M'}${(x * SIZE).toFixed(2)} ${((1 - sampleCurve(p, x)) * SIZE).toFixed(2)}`);
}
return out.join(' ');
};
const down = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
e.stopPropagation(); // the photo must not pan under the graph
const p = at(e);
if (!p) return;
svgRef.current?.setPointerCapture(e.pointerId);
let near = -1;
let best = HIT / SIZE;
pts.forEach((q, i) => {
const d = Math.hypot(q[0] - p.x, q[1] - p.y);
if (d < best) {
best = d;
near = i;
}
});
if (near >= 0) {
drag.current = { id: e.pointerId, at: near };
return;
}
const next = addCurvePoint(pts, p.x, p.y);
if (next === pts) return; // too close to a neighbour to take another point
onChange(put(next));
// The points the old list already held keep their identity, so the one
// without it is the one just added — and the drag continues on it, which is
// what makes "click, then pull" one gesture.
drag.current = { id: e.pointerId, at: Math.max(0, next.findIndex((q) => !pts.includes(q))) };
};
const move = (e: React.PointerEvent<SVGSVGElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const p = at(e);
if (!p) return;
onChange(put(moveCurvePoint(pts, d.at, p.x, p.y)));
};
const up = (e: React.PointerEvent<SVGSVGElement>) => {
if (drag.current?.id === e.pointerId) drag.current = null;
};
const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4);
return (
<div className="curve-panel" data-key="curve-panel" onPointerDown={(e) => e.stopPropagation()}>
<div className="curve-head">
<span className="curve-title">TONE CURVE</span>
<button type="button" className="curve-close" data-key="curve-close" aria-label="close" onClick={onClose}>
✕
</button>
</div>
<div className="curve-tabs">
{CURVE_CHANNELS.map((ch) => (
<button
key={ch}
type="button"
className={`curve-tab${ch === channel ? ' on' : ''}${offDiagonal(curvePoints(curve, ch)) ? ' moved' : ''}`}
data-key={`curve-tab-${ch}`}
aria-pressed={ch === channel}
onClick={() => {
setChannel(ch);
drag.current = null;
}}
>
<span className="curve-dot" data-color={CURVE_CHANNEL_COLOR[ch]} style={{ background: CURVE_CHANNEL_COLOR[ch] }} />
{LABEL[ch]}
</button>
))}
<button
type="button"
className="curve-reset"
data-key="curve-reset"
onClick={() => {
drag.current = null;
// RESET is the whole graph, not the channel on screen: the button
// sits with the tabs, and "the curve" is what the chip and the
// pixels answer to, so a flat object means every channel is flat.
onChange({});
}}
>
RESET
</button>
</div>
<svg
ref={svgRef}
className="curve-graph"
data-key="curve-graph"
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onDoubleClick={(e) => {
// A double-click takes away the point under it. It listens on the
// graph rather than on the circle: taking hold of a point captures the
// pointer, and the click that follows is delivered to the capture
// element, so the circle itself would never hear it.
const p = at(e);
if (!p) return;
let near = -1;
let best = HIT / SIZE;
pts.forEach((q, i) => {
const d = Math.hypot(q[0] - p.x, q[1] - p.y);
if (d < best) {
best = d;
near = i;
}
});
if (near < 0) return;
drag.current = null;
onChange(put(removeCurvePoint(pts, near)));
}}
>
{grid.map((g) => (
<line key={`v${g}`} className="curve-grid" x1={g} y1={0} x2={g} y2={SIZE} />
))}
{grid.map((g) => (
<line key={`h${g}`} className="curve-grid" x1={0} y1={g} x2={SIZE} y2={g} />
))}
{/* The diagonal is the neutral line: a curve that never leaves it does
nothing, which is what makes "how far has this moved" readable. */}
<line className="curve-diagonal" x1={0} y1={SIZE} x2={SIZE} y2={0} />
{channel !== 'rgb' ? <path className="curve-line ghost" d={line(master)} /> : null}
<path
className="curve-line"
data-key="curve-line"
style={{ stroke: CURVE_CHANNEL_COLOR[channel] }}
d={line(pts)}
/>
{pts.map((p, i) => (
<circle
key={`p${i}`}
className="curve-point"
data-key={`curve-point-${i}`}
cx={p[0] * SIZE}
cy={(1 - p[1]) * SIZE}
r={i === 0 || i === pts.length - 1 ? 6 : 5}
style={{ stroke: CURVE_CHANNEL_COLOR[channel] }}
/>
))}
</svg>
</div>
);
}