web: a film sim is colour and tone only

The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.

LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.

Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
This commit is contained in:
2026-09-22 08:32:28 +07:00
parent 52b672deec
commit 428e7fa682
18 changed files with 1831 additions and 148 deletions
+93 -5
View File
@@ -1,5 +1,6 @@
import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import { placeName } from './place';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -36,6 +37,7 @@ import {
replacePhoto,
avatarPath,
photoFile,
photoFileOwned,
photoPath,
photoPreset,
rateLook,
@@ -316,7 +318,11 @@ const SYSTEMS: [string, RegExp][] = [
function parseUa(ua: string): { browser: string | null; os: string | null; device: string } {
const u = ua.toLowerCase();
const device = /bot|crawler|spider|httpclient|curl|wget|python-requests/.test(u)
// Crawlers and headless browsers never count as traffic: a bot that runs the
// beacon would otherwise land in the stats page as a visitor — see the guard
// in /api/events and the `device <> 'bot'` filter on every stats query.
// No UA at all is a script, not a browser: every real one sends a string.
const device = !u.trim() || /bot|crawler|spider|crawl|slurp|headless|puppeteer|playwright|phantomjs|lighthouse|httpclient|curl|wget|python-requests/.test(u)
? 'bot'
: /ipad|tablet|android(?!.*mobile)/.test(u)
? 'tablet'
@@ -379,6 +385,10 @@ app.post('/api/events', async (req, reply) => {
if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send();
const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '');
// A bot's visit is not traffic: it is answered, never stored, and never
// looked up against the geo service either. The stats queries filter `bot`
// out as well, so rows written before this guard stay out of the numbers.
if (ua.device === 'bot') return reply.status(204).send();
const geo = await lookupGeo(ip);
createEvent({
kind: b.kind,
@@ -630,6 +640,20 @@ app.get('/api/photos/mine', async (req, reply) => {
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
// The name of a coordinate, for the stamp. A browser cannot ask the OS the way
// the phone app does, so the lookup happens here — which is also why it is a
// pro route: every miss takes a call out to the public geocoder.
app.get('/api/place', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const q = req.query as { lat?: string; lng?: string };
const lat = Number(q.lat);
const lng = Number(q.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180)
return reply.status(400).send({ error: 'invalid coordinates' });
return reply.status(200).send({ place: await placeName(lat, lng) });
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
@@ -757,6 +781,66 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
.send(data);
});
// The editable base behind a saved render: the pixels as they went INTO the
// look, before the frame, the grade and the stamp were applied. `/file` is what
// the folder and the landing strip show — the finished frame — and this is the
// layer underneath it. The studio loads it when a saved photo is opened again,
// so the stored look lands on the original instead of a second time on its own
// output (which doubled the frame and stacked the grade). Owner only: the
// render may be public on the strip, the base never is.
app.put<{ Params: { id: string } }>(
'/api/photos/:id/base',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const row = photoFileOwned(user.id, id);
if (!row) return reply.status(404).send({ error: 'photo not found' });
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
const mime = sniffImage(body);
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
// One base per photo, beside the render and overwritten in place by a
// re-save: a replace throws the old file away, and `unlink` takes this
// with it.
writeFileSync(photoPath(`${row.file}.b`), body);
return reply.status(204).send();
},
);
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
const row = photoFileOwned(user.id, id);
if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' });
let data: Buffer;
let mime: ReturnType<typeof sniffImage> = null;
try {
data = readFileSync(photoPath(`${row.file}.b`));
mime = sniffImage(data);
} catch {
return reply.status(404).send({ error: 'not_found' });
}
// A row saved before the base existed has no `.b` beside it, and the studio
// falls back to the render (the old, look-already-baked behaviour).
if (!mime) return reply.status(404).send({ error: 'not_found' });
return reply
.header('content-type', mime)
.header('x-content-type-options', 'nosniff')
.header('cache-control', 'private, max-age=60')
.send(data);
});
// The QR card's payload: the `.recipe` file the app reads back on IMPORT, built
// from the look the photo was uploaded with. Public like the strip, but only
// for a row the curator ticked into the `qr` section — that checkbox is the
@@ -816,10 +900,14 @@ function admin(req: FastifyRequest): User | { status: number } {
}
function unlink(file: string): void {
try {
unlinkSync(photoPath(file));
} catch {
// Already gone; the row is what matters.
// The editable base lives beside the render as `<file>.b`; the row is gone,
// so it goes too.
for (const name of [file, `${file}.b`]) {
try {
unlinkSync(photoPath(name));
} catch {
// Already gone; the row is what matters.
}
}
}