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
+194 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
import { samplePixel } from '../engine/imageOps';
@@ -10,6 +10,44 @@ const ZOOM_MAX = 8;
// How long the wheel has to rest before the stage asks for a sharper copy.
const ZOOM_SETTLE_MS = 160;
// The watermarks' own face — the engine draws both marks with the bundled
// Cousine face (the custom mark's FONT option is never set on web), and
// renderPhoto's numbers are what the boxes below read back: the face is 3.2% of
// the stamp width, its advance is 0.6001em and its ascent/descent
// 0.8325/0.3003em. The custom mark's baseline sits 0.35em under y; GPS draws
// its rows 1.2em apart from a baseline 1.1em under y, with an icon column 1.6em
// wide before the text. A box built from those same constants hugs exactly the
// text the file will carry.
const WM_FONT_FRAC = 0.032;
const WM_ADVANCE_EM = 0.60009765625;
const WM_ASCENT_EM = 0.83251953125;
const WM_DESCENT_EM = 0.30029296875;
const WM_ROW_STEP_EM = 1.2;
const WM_SIZE_MIN = 0.3;
const WM_SIZE_MAX = 6;
// The engine rounds the face size down to whole pixels before it draws, so the
// box must round the same way or it drifts off the glyphs at large sizes.
const wmFontPx = (boxW: number, size: number) => Math.round(Math.round(boxW * WM_FONT_FRAC) * size);
// A watermark the stage draws a box over. The engine places both the custom
// mark and GPS with the same three numbers (x, y, size) plus a face's own
// metrics, so one shape covers the two.
export type WmMark = {
// Which mark this is: the drag handlers only ever report back through here.
key: string;
x: number;
y: number;
size: number;
// The first row's baseline, in em under y.
baselineEm: number;
// The icon column GPS prints before its text, in em (0 for the custom mark).
gapEm: number;
// The rows as they will be drawn; the face is monospace, so the longest line
// in characters is the width and the count is the height.
lines: string[];
onMove: (p: { x?: number; y?: number; size?: number }) => void;
};
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
@@ -27,10 +65,12 @@ export function ImageStage({
onCropRectChange,
onStageZoom,
canCompare = false,
onSaveOriginal,
picking = false,
onPickColor,
pickPanel = null,
pickPanelAt = null,
marks = [],
straightenRuler = null,
}: {
previewUrl: string | null;
@@ -53,6 +93,9 @@ export function ImageStage({
// Compare needs the un-cropped photo on one side and the render on the other,
// so it is only offered while the crop is untouched.
canCompare?: boolean;
// Puts the untouched photo file itself in the owner's hands — on a phone that
// is the share sheet, whose "Save Image" is what reaches the photo library.
onSaveOriginal?: () => void;
// Eyedropper mode: clicks on the photo report the colour underneath instead of
// panning it. The HSL mixer's PICK chip owns this flag.
picking?: boolean;
@@ -62,11 +105,19 @@ export function ImageStage({
// they are moving.
pickPanel?: ReactNode;
pickPanelAt?: { fx: number; fy: number } | null;
// FRAME's watermarks (the custom mark, GPS), while their own panel is open:
// each gets a box on the photo that can be dragged (x/y) and resized from its
// corner (size). The engine draws them at (x, y) with size × the base face,
// so these are the exact numbers the render already reads.
marks?: WmMark[];
// FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here
// rather than in the panel column because the angle is read off the picture.
straightenRuler?: ReactNode;
}) {
const showStraighten = !!straightenRuler;
// `marks` is a fresh array on every parent render, and this one is only ever
// read for its length, so the effects below key off that instead.
const wmCount = marks.length;
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@@ -136,14 +187,14 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking && !pickPanelAt && !showStraighten) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten) {
setBox(null);
return;
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cropEditing, compare, picking, pickPanelAt, showStraighten, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]);
// Compare is a property of the un-cropped photo, so a crop taking over (or a
// new photo arriving) puts it away.
@@ -408,6 +459,97 @@ export function ImageStage({
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
// Each mark's box, in the photo's own box: the engine's geometry restated with
// the same constants, so a frame lands on its text whatever size the stage is
// painting it at. Both marks are a block of rows: the width is the icon column
// plus the longest line, the height the face plus the rows below the first.
const wmBoxes = useMemo(() => {
if (!box) return [];
return marks.map((mark) => {
const fontPx = wmFontPx(box.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
return {
mark,
fontPx,
left: mark.x * box.width,
top: mark.y * box.height + (mark.baselineEm - WM_ASCENT_EM) * fontPx,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
height: (WM_ASCENT_EM + WM_DESCENT_EM + (mark.lines.length - 1) * WM_ROW_STEP_EM) * fontPx,
};
});
}, [marks, box]);
// Dragging a box writes x/y, its corner handle writes size. Both measure the
// img's own box (not the layer's), so a zoom on the stage cannot skew them.
const wmDragRef = useRef<{
mode: 'move' | 'size';
mark: WmMark;
x: number;
y: number;
size: number;
px: number;
py: number;
left: number;
width: number;
fontPx: number;
} | null>(null);
const beginWmDrag = (mark: WmMark, mode: 'move' | 'size') => (e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const b = imgRef.current?.getBoundingClientRect();
if (!b || !(b.width > 0 && b.height > 0)) return;
e.currentTarget.setPointerCapture(e.pointerId);
const fontPx = wmFontPx(b.width, mark.size);
const longest = Math.max(0, ...mark.lines.map((l) => l.length));
wmDragRef.current = {
mode,
mark,
x: mark.x,
y: mark.y,
size: mark.size,
px: e.clientX,
py: e.clientY,
left: b.left + mark.x * b.width,
width: Math.max(fontPx, (mark.gapEm + longest * WM_ADVANCE_EM) * fontPx),
fontPx,
};
};
const moveWmDrag = (e: React.PointerEvent<HTMLElement>) => {
const d = wmDragRef.current;
const img = imgRef.current;
if (!d || !img) return;
const b = img.getBoundingClientRect();
if (!(b.width > 0 && b.height > 0)) return;
e.preventDefault();
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
if (d.mode === 'move') {
d.mark.onMove({
x: cl(d.x + (e.clientX - d.px) / b.width, 0, 1),
y: cl(d.y + (e.clientY - d.py) / b.height, 0, 1),
});
return;
}
// The box's top-left corner stays put while the face grows: the top sits
// (ascent - the first baseline)em above y, so y gives back exactly that
// much of the height the larger face adds over the corner.
const f = d.width > 0 ? (e.clientX - d.left) / d.width : 1;
const size = cl(d.size * f, WM_SIZE_MIN, WM_SIZE_MAX);
const fontPx = wmFontPx(b.width, size);
d.mark.onMove({
size,
y: cl(d.y + ((WM_ASCENT_EM - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1),
});
};
const endWmDrag = (e: React.PointerEvent<HTMLElement>) => {
if (!wmDragRef.current) return;
wmDragRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className="stage"
@@ -554,6 +696,44 @@ export function ImageStage({
</div>
</div>
) : null}
{/* FRAME's watermarks: each mark's own box, so the text can be dragged
around the photo and scaled from its corner. */}
{box && !cropEditing && !picking && !peek && wmBoxes.length ? (
<div
className="wm-layer"
data-key="wm-layer"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
>
{wmBoxes.map(({ mark, left, top, width, height }) => (
<div
key={mark.key}
className="wm-rect"
data-key={`wm-rect-${mark.key}`}
style={{ left, top, width, height }}
onPointerDown={beginWmDrag(mark, 'move')}
onPointerMove={moveWmDrag}
onPointerUp={endWmDrag}
onPointerCancel={endWmDrag}
>
<span
className="wm-handle br"
data-key={`wm-handle-${mark.key}`}
onPointerDown={beginWmDrag(mark, 'size')}
onPointerMove={moveWmDrag}
onPointerUp={endWmDrag}
onPointerCancel={endWmDrag}
/>
</div>
))}
</div>
) : null}
{/* FRAME's STRAIGHTEN scale: a graduated ruler along the foot of the
photo, so the angle is turned while the picture that answers it is
in front of the eye. */}
@@ -654,6 +834,17 @@ export function ImageStage({
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
{t('drop.replace')}
</button>
{onSaveOriginal ? (
<button
type="button"
className="btn"
data-key="save-original"
title={t('drop.keepHint')}
onClick={onSaveOriginal}
>
{t('drop.keep')}
</button>
) : null}
<button
type="button"
className={compare ? 'btn primary' : 'btn'}
+11 -4
View File
@@ -36,9 +36,10 @@ export function TopBar({
onSavePhoto: () => void;
onExport: () => void;
// This account's own folder: the SAVE PHOTO button states the count, and the
// API refuses past the cap. A guest sees 0/12 and is sent to sign in.
// API refuses past the cap. A guest sees 0/12 and is sent to sign in. null is
// the admin, who the API lets past the cap — the button then just counts.
photoCount: number;
photoMax: number;
photoMax: number | null;
onAuth: () => void;
onSignup: () => void;
onLogout: () => void;
@@ -97,10 +98,16 @@ export function TopBar({
className="btn ghost"
data-key="save-photo"
disabled={savingPhoto || exporting}
title={photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined}
title={
photoMax !== null && photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined
}
onClick={onSavePhoto}
>
{savingPhoto ? t('photo.uploading') : t('save.photo', { n: photoCount, max: photoMax })}
{savingPhoto
? t('photo.uploading')
: photoMax === null
? t('save.photoNoMax', { n: photoCount })
: t('save.photo', { n: photoCount, max: photoMax })}
</button>
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}