Compare commits
2 Commits
cd9dd28adc
...
e021f7d1ff
| Author | SHA1 | Date | |
|---|---|---|---|
| e021f7d1ff | |||
| 56d4b9df67 |
+7
-5
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user