Commit Graph

105 Commits

Author SHA1 Message Date
3dtours d37671c359 web: print the grain zone by mixing two lattices, not by warping one cell
The coating's patches were drawn by varying the clump CELL with position:
cell = u * (1 + (grainZone(p * ZONE_FREQ) - 0.5) * ZONE_SWING), the same slow
value noise that picks the patch. A lattice whose cell varies with position
smears instead of resizing: its phase accumulates as
d(phase)/ds = 1/cell - s*cell'/cell^2, and c' is read along the radius from the
picture's own origin, so the second term grows with the distance s from it and
the clumps are drawn out wherever the patch's own cell runs. Measured on one
classic-neg paint (1024px, cell 1.09, the app's own Overlay at alpha 0.5, 64
tiles): with the swing on, the tiles' lag-1 correlation of the raw frame spans
-0.065..0.747 — clumps stretched into smooth blotches beside grain. The design's
+-20% swing cannot do that: the SAME field with the swing forced to 0 spans
-0.057..0.045 across its tiles, and the two-lattice field spans -0.055..0.052
(leica: -0.049..0.523 with the swing on, -0.068..0.024 at swing 0).

The zone now MIXES two FIXED lattices, 0.8x and 1.2x the stock's own cell,
weighted by that same patch noise. A fixed lattice's phase is linear in the
picture, so a patch can only choose how much of each is printed, never how
either is shaped — and the two lattices' beat falls at
1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm cell: the pixel scale, not
a line the eye reads. The mix is renormalised by sqrt(w^2 + (1-w)^2), the share
of one field's spread a two-field blend carries, so neither the mean nor the
spread follows the patch: the same classic-neg field reads sd 24.85 against
24.91 and leica 23.74 against 23.74, tile by tile.

The zone still reads what it is for. At preview scale (1600px, cell 1.70, 8x8
tiles of 200px) the mixed field's tiles span rho1 0.082..0.265, ratio 3.233,
against 0.169..0.193, ratio 1.141, with the swing forced to 0 — classic-neg;
leica 0.026..0.188, ratio 7.361, against 0.085..0.105, ratio 1.237. A coarser
patch still prints coarser clumps; it just never prints a stretched one.

Both copies carry it: docker/frontend/shared/utils/grainShader.ts and
src/utils/grainShader.ts (the phone's, which the root web harness imports too).
The field is evaluated once per lattice now, so the grain pass costs 1.94x what
one lattice did — the ratio, not the absolute.

Measured:
  _grain-zone2.cjs — the 64-tile lag-1 correlation spread above, three modules
    on one paint and one seed: swing 0.4 vs swing 0 vs the mix.
  _grain-zone-ck.cjs — the zone's own contribution at preview scale, zone on
    against the same module with the swing forced to 0, nothing else differing:
    classic-neg tile sd 24.21..24.86 (ratio 1.027), hf 0.769..0.937 (1.219),
    rho1 0.082..0.265 (3.233) against sd 29.04..32.95 (1.135), hf 0.834..0.858
    (1.028), rho1 0.169..0.193 (1.141); leica rho1 0.026..0.188 (7.361) against
    0.085..0.105 (1.237). The swing-off row's higher sd is the renormalisation
    of a blend with itself (a and b are one field at swing 0), not a contrast
    change in the shipped field.
  _grain-fft.cjs — same paint, same seeds, three rolls, 1024px: the mix's top
    spectral peak sits at 2.6px (classic-neg, cell 1.09) and 2.8..2.9px (leica,
    cell 1.00) against the warped field's 3.0/4.3/6.2px and 2.7/3.8/4.5px — both
    within a pixel of the clump cell, neither a coarse lattice.
  _grain-bench.cjs — 12.68s per 700px field (one lattice) against 24.60s (two),
    1.94x on software CanvasKit.
  grain-size-test.cjs 17/0 — the SIZE rule and the readout on the module, both
    lattices floored at the target's own pixel, file rho1 0.673, preview rho1
    0.003.
  grain-stock-test.cjs 53/0 on the deployed build — the stock table, the
    halation chain and its ordering, no page errors.
  grain-controls-test.cjs 20/0 on the deployed build — the patch claim still
    holds there: tile sd 56.58..65.48 (mean 61.9, max/min 1.157), tile mean
    spread 0.65, so a coarser patch is still not a brighter one.
  _grain-spectrum.cjs (app, deployed, 1600px) — residual autocorrelation peak
    0.020..0.021, top peaks at 2.0px@20/110 and 2.5px@51.
  tsc: web `--noEmit` clean (the docker build runs it); the phone's scoped
    config reports its pre-change baseline, nothing in grainShader.ts.

One honest number: the app-level spectral peak/median rises 4.6..6.1 to 10.2
(classic-neg, sim-classic-neg-g6/g10) because two fixed lattices beat where one
warped lattice spread. It is 20x below the value-noise field this work replaced
(29..35, tiling) and 5x below a lattice (50+), and it sits at 2px, the cell
itself.

ponytail: the field is evaluated once per lattice, so the grain pass costs
1.94x. One evaluation cannot hold two cell sizes; revisit only if a preview
budget asks for the pass back. The 0.8/1.2 rungs (ZONE_SWING/2 either side) are
one working set, not a search.

Verified: `grain-stock-test.cjs` 53/0, `grain-controls-test.cjs` 20/0 and
`_grain-spectrum.cjs` against the deployed build at localhost:8090;
`grain-size-test.cjs` 17/0; `_grain-zone2.cjs`, `_grain-zone-ck.cjs`,
`_grain-fft.cjs`, `_grain-bench.cjs` against the module built from HEAD,
`inversesqrt` still the one call the shader needed to renormalise; web
`tsc --noEmit` clean, phone scoped tsc down to its pre-existing errors.
2026-09-23 16:47:19 +07:00
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00
3dtours 64f1598076 web: print the grain as jittered clumps, not as value noise on a grid
The field was 20-degree-rotated value noise on a square lattice, three dyadic
octaves at 1 / 0.5 / 0.25 and a sin hash behind it. Value noise prints the
density of the cell's four corners, so every clump sat on a knot of one grid,
and the grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
read as diagonal lines. Measured on the old field through the app
(`grain-analog-test.cjs`, `_grain-spectrum.cjs`): off-origin autocorrelation
peak 0.32-0.40, spectral peak/median 29-35, top peaks at 5.4-7.8px.

The field is jittered clumps now, in both copies
(docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts).
A clump lands at a random spot inside its cell — Worley F1 over the 3x3
neighbourhood, `grainClump` — so no two clumps share a grid, and what is
printed is the distance to the nearest: a smooth mound, not one pixel of
static. The hash behind the jitter is sin-free (Hoskins' `p3 = fract(vec3 *
0.1031); p3 += dot(p3, p3.yzx + 33.33)`), because a float sinus whose argument
grows with the picture folds back on itself and is a lattice of its own. The
three octaves are turned to their own angles — 20, 47, 73 degrees — and sit on
0.53 and 0.29 off the dyadic 1 / 0.5 / 0.25, where a coarse octave's cells land
back on the fine one's and stack.

Clumps sit higher and tighter than the value noise they replace — mean 0.569
against 0.500, sigma 0.123 against 0.081, measured — so the sum is put back on
that mean and spread, `n = (n - 0.5685) * 0.52 + 0.5`, before the AMOUNT
knob's own gain. The web keeps its uniforms (cell, seed, per-stock weights,
spread); the phone keeps 0.55/0.30/0.15 and 2.95, which are the web's
classic-chrome row, so the two print the same texture.

Measured on the deployed build: `grain-stock-test.cjs` 53/0 — 35mm still
coarser than 120, the halation chain intact per stock, the "halation follows
the stock" ordering intact, the field still clumped (r1 0.336-0.506) and still
surviving a 2x downscale. `_grain-spectrum.cjs` residual autocorrelation peak
0.02 (was 0.32-0.40), spectral peak/median 7.0-12.7 (was 29-35), top peaks
only at 2-3px periods, the cell scale. `_grain-ck.cjs` at the preview cell
(U=1.481/1.704): rho1 0.093/0.177 against the old 0.505/0.586, acPeak
0.02/0.028 against 0.38/0.467, peak/med 10.1/11.2 against 76/46.5, top peaks
2.0-2.9px against 5.4-7.8px. `grain-size-test.cjs` 12/0.
`grain-analog-test.cjs` 7/0 — with the TEMP pair on AUTO, the field's channel
split measures 0 at a cast of 0, so the grain is exactly monochrome and no
neighbour lag carries structure (max |rho1..8| 0.118).

One honest number: the preview's apparent strength at GRAIN 10 is ~25% higher
than the old field's (sigma 61.3 against 47.9 in that harness), and that is the
PREVIEW, not the field. Step 9 of src/engine/exportEngine.ts sharpens the
preview at alpha 0.5, and the clumps now sit at the cell (~1.7px) instead of on
the old ~5px lattice, so that sharpen bites harder. The field's own composite
spread is 17% UNDER the old one at the same geometry — lab sdRaw 24.9 against
30.0, and geometry-flat where the old one was ~30 everywhere. The 0.52
normalisation was kept rather than re-tuned upward to the app-visible number:
the export path does not carry that sharpen, and a grain tuned to it would
print too strong.

ponytail: the octave angles and the 0.53/0.29 rungs are one working set, not a
search. Re-tune only if a stock's cell is changed again.

Verified: 53/0 + 12/0 + 7/0 grain harnesses, `_grain-spectrum.cjs` and
`_grain-ck.cjs` A/B against the field built from HEAD, `sims-test.cjs` 31/0,
`fx-mono-test.cjs` 15/0, no page errors.
2026-09-23 13:00:14 +07:00
3dtours c2a4740a3f web: let the white balance carry its tint, and take its ratio in the light
TEMP was a kelvin and nothing else, and the seven presets were seven numbers
the two platforms disagreed about: AUTO 5500, DAYLIGHT 5600, DAYLIGHT -3R
5800, CLOUDY 6500, SHADE 7500 here and 8000 in both recipe-creation forms,
TUNGSTEN 3200, FLUOR 4000. A chip tapped in the recipe panel and the same
chip tapped on the photo printed different frames.

The presets are now the phone's own WB_PAIRS — kelvin AND tint — in all three
places, so every chip lands the same pair on either platform:

  AUTO 5500/0   DAYLIGHT 5500/0   DAYLIGHT -3R 5500/-3   CLOUDY 6500/1
  SHADE 7500/2  TUNGSTEN 3200/0   FLUOR 4000/3

SHADE read 8000K in RecipeCreatePanel and RecipeCreateModal; it is the WB
tab's 7500K/+2 now, so a recipe created in a form prints what the same chip
prints on the photo.

AUTO and DAYLIGHT stand for one pair, so the pair alone cannot say which of
the two is lit. TEMP is therefore keyed by preset and not by value: `wbChoice`
remembers the chip last tapped (the phone's own wbChoice), and `wbValue()`
answers it only while the engine pair still matches, else the first preset
that pair maps to, else the bare kelvin. `wbLabel()` names that pick on the
chip, which now always carries one: TEMP AUTO at the neutral pair, TEMP SHADE
on a preset, TEMP 6300K on the app's own default recipe where a hand-dragged
ruler landed. Measured on the deployed build (`wb-preset-test.cjs`, 33/0):
AUTO and DAYLIGHT print the same frame to the level, DAYLIGHT -3R moves the
green away from DAYLIGHT at the same 5500K, the ruler warms monotonically
across TUNGSTEN 0.525 / FLUOR 0.730 / AUTO 1.000 / CLOUDY 1.141 / SHADE 1.295,
a hand-drag to 10000K names the chip `10000K` and warms the picture R x1.183
B x0.793, SHADE tapped after that drag restores the pair 7500/2 and prints the
same frame the first tap did (R 156.8 vs 156.8), and a temperature drag leaves
a preset's tint standing (FLUOR's +3).

kelvinToRGB itself was the other half. It took the ratio of the sRGB-ENCODED
blackbody colours and square-rooted it, which measured R x1.06 / B x0.89 from
5500K to 10000K — a shift a swatch shows and a sunset does not. White balance
is a gain on LIGHT, so the ratio is taken in linear light now
(`planckianLinear`) and tamed by a new `KELVIN_TAME = 0.5`: the same 10000K
moves the frame R x1.16 / B x0.76, and 2500K its mirror, which is what a
camera does with its WB set 4500K off the scene. One constant scales the whole
ruler and both platforms carry the same one. Measured through the app
(`_temp-probe.cjs`, gains against 5500K): 2500 R x0.569 B x1.640, 4000 R x0.866
B x1.197, 6500 R x1.057 B x0.940, 10000 R x1.140 B x0.759 — the readout is
compressed against the raw gain because the cast lands on encoded, clipping
pixels, which is the reason for the tame in the first place.

ponytail: no scene meter, so AUTO stays the neutral 5500K/0 pair and is a name
for it, not a measurement. Add one when the engine reads the frame.

ponytail: KELVIN_TAME scales the whole ruler both ways. Split it into a warm
and a cool constant only if the two ends are ever asked to move apart.

Verified: `wb-preset-test.cjs` 33/0 and `_temp-probe.cjs`, `sims-test.cjs`
31/0, `fx-mono-test.cjs` 15/0, `wm-font-test.cjs` 38/0 against the deployed
build; web `tsc --noEmit` clean, the phone's scoped check down to its two
pre-existing `skiaImage.ts` nulls.
2026-09-23 13:00:14 +07:00
3dtours 4795a2a0ee web: give each stock its own grain, and a halo where it belongs
The landing card sells "35mm & 120 Film Grain — authentic grain structures plus
halation bloom, tuned per stock rather than one global overlay", and the engine
printed one field for everything: a width/1080 cell, one spread, no bleed.
`shared/utils/grainShader.ts` (new, the web fork of the phone's
src/utils/grainShader.ts) now carries the stock table — format, cell, spread,
octave mix, halation, halo radius, halo tint — and `grainStockFor(
recipe.baseFilter)` picks the one this recipe prints.

FORMAT. 35mm cells are the 1.0 reference the knob was tuned at (classic-negative
1.15, B&W high contrast 1.25); the 120 emulsions sit at 0.55-0.72 and open their
base octave (mix 0.55/0.30/0.15 -> 0.62/0.26/0.12), so the same knob prints a
finer, smoother texture on the bigger negative. Measured on a flat 128 grey at a
3200px preview (cells 3.41px vs 1.63px), GRAIN 10, luma residual against a 17px
box:

  35mm  CLASSIC NEGIPES   r1 0.793   keeps 0.976
  35mm  CLASSIC CHRIPES   r1 0.744   keeps 0.931
  35mm  B&W HIGH CONTRAST r1 0.812   keeps 1.002
  120   PROVIPES          r1 0.423   keeps 0.728
  120   VELVIPES          r1 0.313   keeps 0.672
  120   ACRIPES           r1 0.543   keeps 0.794

r1 is the lag-1 autocorrelation of the residual — how coarse the clumps are —
and "keeps" is the residual sd after a 2x box downscale over the sd before, i.e.
how much of its texture a print at half size holds on to. Every 35mm stock beats
every 120 stock on both, and VELVIPES (0.55 cell) is finer than PROVIPES (0.62)
inside 120, so the format is a look and not a label. Raw sd is NOT the measure:
the knob drives one alpha for every stock, so a stock's amount follows its cell
and mix rather than the order anyone assumed.

HALATION. A new pass 6b thresholds the print (T0 0.62, T1 0.92), tints what is
left the stock's halo colour — red, because red is the light the emulsion passes
and the backing returns — blurs it at the stock's own radius and screens it back
at `halation * grain/10 * 0.6`. Riding the GRAIN knob keeps today's contract:
OFF is still a clean frame, the OFF/WEAK/STRONG chips still mean 0/3/6, and a
sensor stock carries none at any amount. Measured R-B of the ring around a white
block on black, GRAIN 6 minus GRAIN 0 (mean, and the ring's reddest pixel):

  CLASSIC NEGIPES    7.87  (peak 0 -> 14)    VELVIPES   3.71  (0 -> 13)
  CLASSIC CHRIPES    2.91  (0 -> 10)         PROVIPES   2.01  (0 -> 7)
  B&W HIGH CONTRAST  0.61  (0 -> 5)          ACRIPES    0.24  (0 -> 3)
  LC STREETLIFE CLASSIC 0.09 (0 -> 0)

which is the table's own halation column (0.45 > 0.30 > 0.25 > 0.18 > 0.15 >
0.12) in order: the colour negative halates hardest, the B&W emulsions barely,
Acros — no colour layer to bleed — least of all, and the sensor not at all. The
colour negative's own grade leaves its ring blue at GRAIN 0 (-5.96 there), so
the statistic is the change and not the absolute channel; in a crop of the block
the bloom itself is unmistakable at GRAIN 6 and 10 and absent at 0.

GRAIN_SEED moves here from exportEngine.ts so the roll is still one per page
load, and still shared by the preview, the compare copy and the file.

Checked: tsc --noEmit clean; grain-stock-test 53 PASS / 0 FAIL; sims-test 31/0,
fx-mono-test 15/0, grain-size-test 12/0, grain-analog-test 7/0, wm-font-test
green.

ponytail: halation rides the GRAIN knob instead of a control of its own, since
the card promises no more than "tuned per stock". Add a HALATION chip when the
phone grows one.
ponytail: `grainCell`'s 1px floor is the aliasing guard, and it also hides the
format ratio under a ~1600px preview. Nothing to add: the exported file is
always wide enough, and the harness renders at 3200 to see it.
2026-09-23 11:46:45 +07:00
3dtours 7a8c0aa2b5 web: print what the temperature ruler does, not the colour of the light
COLOR TEMP's swatch was Tanner Helland's blackbody fit, so it painted the
colour of the LIGHT the number names: 2500K came out orange #ff9f46 while the
engine cooled the frame on the same knob. Read off the render of a neutral
128-grey (mean of the painted preview, CCT via McCamy), the two ran opposite
ways at every stop:

  K      old swatch        swatch CCT   picture mean    picture CCT
  2500   #ff9f46           2384K        115,135,204     47691K  (blue)
  3200   #ffb87b           3096K        128,141,171     11246K
  4000   #ffcea6           3931K        135,141,156      8203K
  5500   #ffedde           5414K        144,139,143      6479K  (unity)
  6500   #fffefa           6322K        148,138,139      6029K
  7500   #e6ebff           7730K        149,138,132      5555K
 10000   #cadaff          10024K        153,138,128      5180K  (warm)

So the number is a Kelvin of the scene's light — which is exactly why the
engine warms the picture as K rises — and the swatch was the one thing on the
ruler that disagreed with the ruler. It is now painted from kelvinToRGB
itself, the same gains the render puts on every pixel, on a mid grey, so the
band under the knob can never drift from the frame above it. kelvinToHex goes
away with it: one Kelvin->colour mapping in the app, not two.

Measured after: the swatch's R-B tracks the render's own cast at every stop
(-82 vs -89.8 at 2500K, +23 vs +24.0 at 10000K), 5500K is flat #808080 — the
engine's unity point — and 3200K sits cool / 7500K warm either side of it.

temp-swatch-test.cjs (scratchpad): 33 PASS / 0 FAIL.
2026-09-23 11:17:59 +07:00
3dtours d55b7b49ca web: give each watermark its own collapse, and a face to print in
The panel shared one column between the two marks, so GPS's colour, its two
switches and its hand-typed place stood open beside the custom mark's text,
colour and size whether or not either mark was on. The two are now collapses,
one per mark: the header chip is the section, and that mark's own controls sit
under it. What opens a section is the mark itself — GPS WATERMARK ON opens
GPS's controls, CUSTOM WATERMARK ON opens the custom mark's — so there is no
new state and no way for a panel to disagree with the pixels.

Both marks gain the FONT strip the phone has had (TEXT FONT for the custom
mark, FONT for GPS, whose stamp the phone also lets you set a face on). A
browser has no font service, so the list is exactly what the bundle carries:
the site's two self-hosted families, Inter and Fraunces (SIL OFL), their latin,
latin-ext and vietnamese woff2 subsets decompressed, pinned to weight 400 @
opsz 14 and merged into ONE TTF per family — drawText has no glyph fallback, so
a family mapped to only the latin subset would print a Vietnamese place name as
tofu. DEFAULT stays the bundled Cousine face, which is what every existing
session and every mark without a family prints.

Two engine bugs came out of it. CanvasKit 0.42's Font.getGlyphWidths passes its
output pointer where the wasm export wants the bounds pointer, so every glyph in
a run comes back holding one identical, rounded width — at 64px on the merged
Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and 0.242em,
and a box measured off it was 27% too wide ("Hà Nội 09/23" 510px against a true
403px). The shim now rebinds it with the pointers in the order
_getGlyphWidthBounds reads them, and the stage's boxes measure with linear
metrics, which land on hmtx exactly (403.28px against 403.28; hinted is 407).
And CanvasKit's TypefaceFontProvider.matchFamilyStyle answers null for every
style shape this binding accepts, so a name registered with it never resolved —
the shim keeps its own registry keyed by family name instead.

Measured: tsc clean; the engine harness on the merged faces 26/26, including the
registry's advances against hmtx (Inter 6.3013em, Fraunces 6.3475em); the
deployed app under Playwright 38/38 over the two collapses and both FONT strips
— each mark's controls appear only with its own mark on, the DEFAULT/INTER/
FRAUNCES box widths match hmtx, the baked ink fills the box, the top edge
re-hangs off the new ascent (Inter 0.96875em against Cousine's 0.8325em, 3.4px
at this size) with the left edge fixed, and UNDO round-trips. Opening a section
narrows the stage by 168px with no window resize (955px -> 787px), so the stage
now re-measures its drop boxes off a ResizeObserver on the frame and the
picture rather than on the next render.

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
2026-09-23 10:59:49 +07:00
3dtours e8a0c0d076 web: print the grain as clumps, not as static
GRAIN was one value hash per cell, sampled straight off the pixel grid: a
square lattice at the picture's own axes, the same field in every session and
in every photo, and — measured on a flat gray frame, where every deviation IS
grain — a spread that was flat rather than emulsion-like (neighbour
correlation rho(1) = 0.183, so half the noise was one pixel wide).

The noise is now an emulsion. Three uncorrelated hashes are averaged into a
density (a bell, the way an emulsion's density swings, instead of the flat
spread of a single hash), that density is read as value noise with a
smoothstep cell, and the result is three octaves of it — the cell, then 2x and
4x that cell — over a lattice turned 20 degrees off the picture's axes, so no
grid shows through. Only coarser octaves: a finer one (tried 2.043x, 0.72px)
goes sub-pixel and rho(1) falls to 0, i.e. back to static. The whole domain is
offset by a seed rolled once per page load, so two visitors never print the
same clumps while the preview, the compare copy and the file of one session
still print the same roll.

Same flat 3000x2000 frame, preview render 1600x1067, GRAIN 10: sigma 52.53 ->
50.15 (the 2.95 gain keeps the spread the AMOUNT knob was tuned against, since
the repo notes the slider was calibrated on the old field), rho(1) 0.183 ->
0.275, and the three channels still move together — the 6.9 of sigma 50 that
is left over is the Overlay blend meeting the frame's own tint, the plane
itself is one gray value in all three. Re-rendering the same frame draws the
same clumps byte for byte; a reload rolls a new seed and a new field at the
same strength.
2026-09-23 10:01:49 +07:00
3dtours cf0091b741 web: name a position that lands before the account is known
A photo restored from the session reaches the stage while /me is still in
flight, so the account reading that put it there saw `pro` as false, the place
lookup was skipped, and reloading a photo left the stamp with bare coordinates.

Naming now happens in one effect that watches the position itself: the first
moment it is on the stage unnamed and the account is PRO, it gets a name,
wherever it came from. A position typed in by hand earns its name too, and a
guest's photo is named the moment they sign in.

The two call sites that used to ask for the name — in locateMe and adoptPhoto —
are gone, since the effect covers both.
2026-09-23 09:33:26 +07:00
3dtours 3bfa82e7f2 web: stamp the photo's own day on the GPS mark
The GPS mark printed Date.now(), so a photo taken in 2019 carried the day it
was opened. It now prints the frame's EXIF date — DateTimeOriginal, falling
back on CreateDate then ModifyDate — wherever the position came from:

- readCapturedAt() reads the date off the file, and readGps() uses it for a
  position found in the same EXIF.
- adoptPhoto holds it in its own state, so a frame with a date but no position
  still stamps the date when the position is typed in by hand.
- The device's own position stamps it too. That path runs inside adoptPhoto,
  where the render still holds the previous photo's date, so locateMe takes the
  date as an argument rather than reading state — the panel's own button, which
  has no such date to hand, passes none and reads the state as before.

A file with no date at all still falls back on the visitor's clock: there is
nothing else to believe.
2026-09-23 09:22:19 +07:00
3dtours a0965101e1 web: hand the upscaler's activations to the chip that is running them
The WebGPU execution provider has no PReLU kernel. The model is 34 convolutions
with a PReLU after every one of them, so an export that took the GPU path was
split 33 times: each activation came off the chip to be activated on the
processor and went straight back, a 64-channel map in both directions, per tile.
A machine with a good graphics chip was not exporting any faster for having it.

PReLU(x) is exactly Relu(x) - slope * Relu(-x), and Relu, Neg, Mul and Sub the
provider does implement, so scripts/realesr-gpu.py writes the 33 activations out
as those four and drops the slopes nobody reads any more. The model file is the
output of that script, not the file as published.

One 256x256 tile through the model before and after, on a WebGPU session: the
runtime no longer reports nodes left off the preferred provider (it did, once,
before) and the processor path answers bit for bit what it answered before. The
warning itself cannot be switched off from here - env.logLevel is read when the
runtime module initialises, before any of this runs - so the graph was fixed
rather than the lines hidden.
2026-09-23 09:02:53 +07:00
3dtours a2c12a2638 web: ask the GPU for the fast one before an export runs
onnxruntime hands a webgpu session whatever adapter the browser picks by
default, which on a laptop with both is the one built into the processor: the
export then waits on the slow half of the machine for no reason. The runtime
reads `env.webgpu.powerPreference` when it builds the webgpu session, so set it
to high-performance; a browser with nothing to honour the preference with
still falls back to the threaded wasm path exactly as before.
2026-09-23 08:36:03 +07:00
3dtours 28a688fd82 web: hand the upscaler only the pixels the export is asking for
The model's own factor is 4 and the export's target is some number of pixels,
and the two were never reconciled: a 2400x1800 photo exporting at 4K was run
through the model at 4x — 9600x7200 of invented detail — and then three
quarters of it were thrown away by the draw that lands the file on 3840. The
arithmetic was the whole wait. Measured on the wasm path, one export: 153.2s.

The photo is now resampled once to `targetLongest / 4` before the model reads
it, so the model still answers at its own 4x and the answer is the size the
export asked for. Same 2400x1800 to 4K: 42.7s, 80 tiles of model for 20. Half
the photo's pixels is the floor — below that the model is no longer enlarging
the picture, it is drawing a new one from memory — and the ceiling is the
photo's own size, so a gain past 4 behaves exactly as it did.

Nothing in the finished file gives the smaller input away: the 6px stripes come
back at full contrast (254.9 vs 254.8), the black-to-white step lands on the
same pixel (x=625 in both) and rises in 1px instead of 3.

The 32MB of runtime and model are also fetched, and one 16x16 tile pushed
through the graph, when the export menu opens rather than after a size is
picked: the visitor waits for the pixels, not for the download.

crop 1:1 2400x1800 to 4K: 155.8s -> 52.6s, crop 3:4: 153.0s -> 54.1s.
2026-09-23 08:27:00 +07:00
3dtours 52566f6966 web: read the photo's own size off the row under it
The row below the photo carried CLEAR, OPEN and SAVE ORIGINAL but never said how
big the picture was, so the only way to learn the resolution was to open the
export menu and read the hint there. The size now leads that row, before CLEAR:
the file's own pixels turned by the quarter turn and cut by an applied crop, so
it is the number an export at the photo's own size writes. A live crop does not
move it (nothing is cut yet), STRAIGHTEN never does (the rotated rectangle is
fitted back inside the same pixels), and the guest tier's 2048 cap is still only
announced in the export menu where the file itself is capped.

Verified end to end in photo-dims-probe.cjs: 2400x1800 opens as "2400 × 1800",
a 90 deg turn reads 1800 × 2400, an applied 1:1 crop reads 1800 × 1800, and the
export writes exactly that file.
2026-09-23 07:58:56 +07:00
3dtours d9bababfd7 web: filter the framed print draws so a scaled photo stops staircasing
The polaroid and the wall frame both draw the photo into a window that is not
the size of the photo — the instant print shrinks it to 0.898x, the wall frame
cover-scales it by max(winW/pw, winH/ph), which is 1.47x up for a 2400x1800
source. A plain drawImageRect is nearest on CanvasKit, so that resize dropped
the edge back onto the output pixel grid: a 5 deg straighten inside a frame
exported 59.8% of its rows with the crossing pinned to the same pixel as the
row above (61.1% in the wall frame), while the same photo without a frame came
out at 55.8%.

Both draws now go through drawImageRectOptions with FilterMode.Linear, the same
call shape the straighten draw already uses. Measured on a 2400x1800 hard-edge
fixture at 5 deg, exported at the photo's own size: polaroid 59.8% -> 37.5%
flat rows, fracStd 0.336 -> 0.184; wall frame 61.1% -> 25.7%, fracStd 0.467 ->
0.210 — and the residual matches the 0.202 of the unframed export, so the
window costs nothing beyond the resample underneath it. A 45 deg edge printed
through the instant frame at 0 deg is unchanged at 0.0% flat rows.
2026-09-23 07:46:19 +07:00
3dtours 3a09674831 web: filter the straighten draw so a rotated edge stops staircasing
The FRAME tab's fine rotation drew the photo through canvas.rotate() +
canvas.scale() with a plain drawImage, which CanvasKit samples with
nearest: the edge landed on the same pixel in every row, so a rotated
edge came out as 1px steps every 1/tan(angle) rows. Measured on the 30deg
export of a hard black/white edge: 42.3% of rows repeated the previous
row's edge position, the step across the edge was 252.9 of 255, and there
were no intermediate pixels at all.

Only the *Options/*Cubic call shapes take a sampling option, and
drawImageRectOptions exists in RN Skia too, so the shared renderer can use
it unchanged. The same export now moves the edge in every row (0.2% of
rows repeat, 0.35 intermediate pixels per row) and its edge step drops to
222. Cost: the filtered draw takes 0.35s against 0.24s for the 1600px
preview copy and 2.0s against 1.4s for a 12MP photo, once per render.

Preview and export share the function, so both change together.
2026-09-23 07:33:44 +07:00
3dtours 500068e63e web: mark SAVE PHOTO PRO and keep MY PHOTOS for the accounts that have one
Saving into the account's own folder has always been the account's act —
the button opened the way in and the API answers an unproven address with
a 403 — but nothing on the button said so, so it read as a button that
quietly did nothing. It now wears the same PRO marker the chips do, and
only while the folder is not the visitor's.

MY PHOTOS is that folder's listing, so the tab is only offered once an
account can hold one. A guest loses the tab entirely rather than opening
it on an empty folder that could never fill; an account that has signed
up but not proven its address keeps the tab, and the tab keeps offering
the way to prove it.
2026-09-22 21:46:05 +07:00
3dtours b9ac7746aa web: gate the newest film sims and the HSL mixer behind PRO
The last three PHOTO STYLE looks (B&W HIGH CONTRAST, LC STREETLIFE
CLASSIC and LC STREETLIFE VIVID) and the whole mixer now belong to the
account, the way PRO frames and the geotag already do: the chip wears
the PRO badge, a guest who picks it is shown the way in, and the look
stays off. The HSL tab keeps its place in the rail but offers the one
PRO chip while locked, so the tab itself is not a dead end; a look that
arrives without the chips — an imported .recipe, or a photo saved
before the gate — is still caught where the gate bites, at export.

STRAIGHTEN's scale turns with the wheel, one degree a notch, because
the ruler is where the angle is being judged and reaching for a slider
elsewhere loses the thread. The listener is native and stops the notch
before the stage sees it, so the photo does not zoom under the pointer.
The scale gives up its opaque card, its blur and its shadow: the frame
it is levelling has to stay readable through it, so legibility comes
from a text shadow on the heading and a drop shadow on the graduations
instead.
2026-09-22 21:33:11 +07:00
3dtours f201deee46 web: export a big photo without inventing pixels it already has
The export menu measured the photo off the 1600px preview copy, so a 2400px
photo was believed to be 1600px across: the hint named the wrong size, the
model was asked to upscale a photo that already had more pixels than the
target, and a guest's 2048 ceiling was skipped because 1600 never crossed it.
A committed crop made it worse — the crop's longest edge was taken from the
wider side of the crop rect rather than the side the frame actually keeps, so
a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the
model over 80 tiles (158.7s) to reach 2K.

The photo's own dimensions are now read off the original bytes, and the crop's
long edge is the same axis-aware fraction the stage already uses. The export
asks the model only when the photo itself is short of the requested size, or
when the crop would have to be stretched past 1.5x to get there; otherwise it
resamples — down, or a hair up to make up for the crop — which is what a photo
that already holds the pixels deserves.

Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to
3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a
1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and
exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the
superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact,
with the model's 16.6 edge energy against bilinear's 4.8.
2026-09-22 20:43:46 +07:00
3dtours 1d4c6b1d66 web: upscale on a thread pool instead of one core
The super-resolution export ran single-threaded because the site was not
cross-origin isolated and the runtime had no SharedArrayBuffer to spread a tile
over. nginx now sends COOP and COEP — on the document, and on the script
responses a nested worker fetches, which Chromium checks the same way and blocks
as `coep-frame-resource-needs-coep-header` without them — and the loader asks
for `min(8, hardwareConcurrency)` threads whenever the page is isolated, falling
back to one if the headers ever go missing. A worker script is also why the
landing's QR image needed `crossOrigin`: COEP refuses a cross-origin image that
did not opt in with CORS.

The unpack was the other half. Each tile was clamped a channel at a time and
painted whole, padded ring and all; it now writes straight into the
Uint8ClampedArray, which clamps and rounds on assignment, and skips the ring
rather than drawing it and clipping it away.

640x480 to 4096: 39.0s to 16.2s. 1000x750 to 4096: 89.6s to 30.9s. One 256px
tile through the model: 6.8s to 1.8s. Measured on the wasm path — the test
browser has no GPU adapter — so a WebGPU export, still per-tile inference, keeps
its own times.
2026-09-22 20:30:02 +07:00
3dtours c403dd04c8 web: add the B&W HIGH CONTRAST film sim to the PRESETS rail
The chip lands after ACRIPES and is a mono stock of its own, so it gets its own
baseFilter ('mono-high-contrast') rather than borrowing Acros': the PHOTO STYLE
chips are keyed by baseFilter, and the two greys must sit side by side.

The look is the B&W MIX plus a push at both ends. The mix rides the matrix — a
non-BT.709 row set (0.38/0.56/0.06, identical rows, sum 1.00) so a red roof
reads bright, a blue sky deep, and the separation is contrast before any curve.
The push rides FILM_TONE (shadow -0.32, highlight +0.26) so the ends move
without touching the midtones, and the midtone slope is SIM_CONTRAST_BIAS (4
contrast units) next to the sim's own exposure bias. The knobs stay at neutral:
a sim is colour and tone only.

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, R/B fine-tune, chrome, hue
mixer) and the MONO strip label can never half-apply to one of them.
2026-09-22 17:56:00 +07:00
3dtours 80512d11b5 web: print the shared frame's own labels on the QR recipe card
The card carried studio copy — SUNSET GLOW, a made-up warmth/grain/bloom line
and a creator handle borrowed from a testimonial — no matter which still the
arrows landed on. Stepping the frame changed the picture and the code but left
the text behind, so the card described a photo it was not showing.

The card now reads the frame's own labels: the tagline over the still and the
title and ISO/grain line under it are the ones the uploader saved with the
photo, the same three the film strip prints. The fabricated creator/imports
line goes with them, since nothing behind it was real.
2026-09-22 17:22:54 +07:00
3dtours 2ee49cfd41 web: keep the film strip seamless on a wide window
The marquee loops by translating the track by half its width, so the first half
has to be at least as wide as the window. A short reel — seven stills, about
1780px — covered a 1440px window but not a 1920px or 2560px one: the strip ran
out of frames before the loop restarted, and the band on the right stayed blank
until the next pass drifted in.

The track now measures one frame's pitch and repeats the reel until a half
covers the window, re-measuring on resize. One still in the strip is repeated
enough times to loop cleanly on its own; a reel already wide enough is left at
a single copy, so nothing is duplicated without cause.
2026-09-22 17:11:12 +07:00
3dtours e1f6943f8e web: step the creator and QR previews through their tagged stills
The landing's tester preview has had a ‹ › pair for a while; the custom recipe
creator and the QR recipe card still showed one arbitrary frame from whatever
the curator tagged for them. Both now cycle their own slot the same way, from a
random start so the page does not look identical on every load, wrapping at
both ends.

The QR card's payload is not decoration: it is the link of the frame on screen,
so the code under it is redrawn from the same frame the arrows land on. A slot
holding fewer than two stills gets no arrows, since there is nowhere to step.

The tester's arrows move to the shared FrameArrows component, which is what the
two new pairs use — one implementation, three slots.
2026-09-22 17:03:46 +07:00
3dtours bb51b83399 web: the hero stat labels wear the accent as gradient text
COLOR RECIPES, DOWNLOADS and STORE RATING were flat grey under their numbers.
They now sweep from the theme's own accent to the film red and are cut out of
that gradient, the same treatment the headline above them gets. Because the
sweep starts on var(--accent), the colour group in the Theme menu still
retints it; the light theme starts from the darker ink accent so the labels
keep their contrast on white.
2026-09-22 16:47:35 +07:00
3dtours cfe8512636 web: the landing draws only the stills the studio uploaded
Six bundled sample negatives stood behind eight built-in looks, and they were
the only frames on the page nobody had uploaded. They are gone, along with the
REEL table and the SAMPLE() helper that pointed at them.

The film strip is now exactly the photos the curator put in the strip slot,
repeated once for the marquee loop; a section whose slot holds nothing simply
draws no frame instead of falling back to a stock photo. The reel's rating keys
are all photo:<id> now, and the QR card's filter follows the sunset preset it
claims rather than an index that moved.
2026-09-22 16:35:40 +07:00
3dtours 7f2a5b0b5a web: drop the 300 ppi lines from the landing copy
The claim was printed in four places — the RAW feature card, the quality FAQ,
the custom-recipe readout and the Lite plan list. Each now reads as
print-ready instead, and the readout keeps only RECIPE CUSTOM_01 · EXIF KEPT.
The exporter's own JFIF density is untouched, web-smoke still checks it.
2026-09-22 16:04:51 +07:00
3dtours 339d36eb3e web: the preset tester picks its recipe from a grouped dropdown
The row of pills grew as the library did, so the landing's live tester now
offers the stocks through one native select, grouped into Landscape, Portrait
and Streetlife, five stocks each — fifteen in all. Choosing one still lands on
the preview immediately: the frame, the HUD and the spec card all follow the
selection. Stock names stay proper nouns, the group names are translated with
the rest of the page.
2026-09-22 12:49:18 +07:00
3dtours 7dea0f1e7b web: the mixer's card can be dragged off the colour it was read at
The card hangs on the point the eyedropper read, which is exactly where the
user wants to watch the band move — so it covers the patch it is editing. Its
body now takes a drag: the offset is a fraction of the photo, which is the
layer the card lives in, so a zoom keeps it where it was put and a fresh pick
drops it back on its own point. The knobs and the close button keep the pointer
to themselves, and the anchor cannot leave the photo, so the card is always
half in reach of a drag back.
2026-09-22 11:13:21 +07:00
3dtours 88afdf6611 web: compare the same frame rendered twice, whatever its geometry
The split used to paint the photo file beside the render, so it only lined up
while nothing had moved: a turn, a straighten, a printed frame and the halves
were two different pictures. The app now renders the same frame twice — once
through the look, once through the neutral stock — and the left of the bar is
that second copy. Rotation, straighten, crop and frame land on both halves by
construction, so the CSS that tried to map the crop onto the file goes away.

The toggle lives in the app now, which is what knows how to ask for the extra
render; it is only asked for while the split is up. The layer waits for that
copy rather than flashing the raw file, whose geometry is already wrong.
2026-09-22 10:59:37 +07:00
3dtours 1d96269139 web: keep compare on offer, and compare at the cropped size
Choosing a crop ratio used to disable COMPARE outright, because the split
painted the whole original into a box that was now the crop's shape. The
original is now looked at through a window of the render's own shape: with a
crop applied the photo is scaled and slid by the crop rect so the same
rectangle lines up, and the split compares like with like.

The window needs the image free to overflow it, so the inline style lifts the
box clamp that .canvas-wrap img puts on every preview.
2026-09-22 10:45:07 +07:00
3dtours b394bad09e web: a wall frame keeps the crop that was applied
CROP + APPLY then a wall frame handed back the whole photo: the crop block
was skipped outright for both walls, so the artwork hung the original. The
walls' own opening still ignores the aspect chip — that is what the
exclusion was for — but the visitor's crop is theirs to keep.

Measured with a source banded red on top and blue below, cut away by a
16:9 crop: through WALL FRAME and WALL FRAME LANDSCAPE the bands used to
come back (569k and 350k red pixels); both now export clean.
2026-09-22 10:31:27 +07:00
3dtours 711e2fc5d6 web: the history arrows sit with the name they undo
Undo and redo were on the far side of the spacer, past RESET, SAVE PHOTO
and EXPORT. They belong next to what they step through: the header now
reads mark, page name, preset name, the two arrows, then the actions.
2026-09-22 10:14:51 +07:00
3dtours 2f78216b6f web: the upscale drops its tile seams
A tile's destination rectangle was placed at x0 * scale, and that scale
is rarely whole, so every 256px boundary landed on a fraction of a pixel.
The edge was drawn half covered, stayed transparent, and the JPEG export
flattened that transparency onto black: a dark line down each seam.

Snap both destination edges to whole pixels instead, so neighbouring
tiles share the exact same boundary, and make the destination context
opaque so no partly covered pixel can survive as transparency again.

Measured on a 640px source: the seam at 2K was 46 levels darker than its
neighbours (96 at 4K); it is now within one level of them.
2026-09-22 09:56:12 +07:00
3dtours c0c99a9672 web: EXPORT offers a size, and a bigger one is upscaled in the browser
The server still never sees a photo, so the model has to run in the page.
Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded
lazily on the first export that actually needs it; the wasm runtime is
copied next to CanvasKit at build time and stays lazily fetched, cached
for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB
asset lands in the bundle.

UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only
when the request is larger than the photo being edited, otherwise they
resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px
overlap, so memory follows the target size rather than four times it.
2026-09-22 09:40:18 +07:00
3dtours f9a40a9e1c web: the histogram opens top-left, and CLEAR asks before it forgets
The histogram used to park itself in the top-right corner on the first
paint; it now starts at the top-left of the photo and is dragged from
there, the way the rest of the overlay is. Nothing else changed in it —
same drag, same clamping, same resize.

The stage also gains a CLEAR button, sitting before the picker button,
which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not
before asking: SAVE PHOTO files it first and only then clears, EXPORT
IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it
there and then, and CANCEL leaves everything alone. Saving from that
modal resumes the clear once the file has really landed — a guest, a
capped account or a cancelled name prompt never loses the frame.

Clearing forgets the working photo (source, preview, GPS, ISO, and the
IndexedDB copy session.ts now deletes), while the look, the crop and the
undo history stay put, so the next photo opens on the same settings the
way replacing a photo already did.
2026-09-22 08:56:58 +07:00
3dtours 59d90ae068 web: the sim chips keep their legacy names
The name table is a reference for what each sim has to look like, not a
renaming order: the ten PHOTO STYLE chips go back to PROVIPES, VELVIPES,
CLASSIC CHRIPES, CLASSIC VIVIDIPES, CLASSIC NEGIPES, ASTIPES, ETERNIPES,
ACRIPES, LC STREETLIFE CLASSIC and LC STREETLIFE VIVID. The comment above
FILM_SIMS now says so outright — label on the left, the stock's colour and tone
it must match on the right, and neither side moves the other.

The grading is untouched: a sim is still colour and tone only, its `adjustments`
stay neutral, and LC STREETLIFE VIVID keeps its +2 exposure as SIM_EXPOSURE_BIAS
in colorUtils rather than as a knob.
2026-09-22 08:37:19 +07:00
3dtours 428e7fa682 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).
2026-09-22 08:32:28 +07:00
3dtours 52b672deec web: PRO needs a proven address — email verification gates the studio
A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
2026-09-20 07:39:03 +07:00
3dtours efe578f61c web: the mobile chip strip lies down
Tapping a tab on a phone opened a 148px column with the chips stacked one per
line, so the strip read as a ladder down the side of the photo. Android's own
panel runs its chips as a row — see src/components/AdjustmentPanel.tsx — and
that is what the phone now gets: the columns stack into one vertical scroll
and each chip row runs sideways again, wrapping inside the full width.

Desktop and tablet keep the columns and the stacked chips; the change lives in
the <=860px block.
2026-09-20 07:00:55 +07:00
3dtours 6afb7d9fea web: the mobile rail wears Android's pills
On a phone the studio's tabs are now the row the Android app draws: text
pills in uppercase mono, rounded full, amber and a step larger when open, no
glyph, scrolling sideways when the ten tabs outrun the screen.

Desktop keeps its icon-over-label column — the change lives in the <=860px
block, so nothing above that breakpoint moves.
2026-09-20 06:24:27 +07:00
3dtours 15bacacafa web: logging out ends the studio session, not just the cookie
A session followed the browser, not the account: log in, open a frame, log
out, come back as a guest — the same photo stood on the stage, because the
studio's own store (localStorage knobs + the photo in IndexedDB) outlived the
cookie with nothing to clear it.

clearSession() now drops both, and the three log-out buttons call it. The
studio's own button reloads after the delete has committed — a reload mid-
delete aborts the transaction, so the promise resolves on tx.oncomplete, not
on the request. The account's frames are untouched: they reopen from MY
PHOTOS.
2026-09-18 21:48:19 +07:00
3dtours e57444e88b web: draw each landing photo cut to its own box, not to its own shape 2026-09-18 21:24:02 +07:00
3dtours 2da045f232 web: the preset tester steps through every frame in its slot 2026-09-18 21:02:30 +07:00
3dtours d3c5c47973 web: the reel is curated, not crowd-sourced — no CONTRIBUTE button 2026-09-18 21:02:30 +07:00
3dtours d25649a26b web: each PICTURES column is an album shelf, a frame and a strip
Both columns now have the shape the curator asked for: a narrow shelf of
albums down the left — one per account on the uploads side, one per landing
section on the other — the frame that is up in the middle, and the open
album's thumbnails as a strip across the bottom.

The frame keeps its labels, the four section boxes, the look's QR code and
the delete button under the picture, where before they sat beside it. Each
column previews its own frame; both obey the same name, sort and rating
filters.
2026-09-18 20:38:46 +07:00
3dtours f263413313 web: PICTURES back to two album columns, every frame editable in place
The pane is two columns of the same thing: the uploads on the left, one
album per account, and the landing on the right, one album per section —
Film strip, Live preset tester, Custom recipe creator. QR is no longer a
shelf of its own: the code belongs to the frame.

Both columns list their albums the same way and draw the open album's
frames as cards, each with its labels, the four section boxes, its QR
code and the delete button under the picture. Ticking a box files the
frame into that album on the other side straight away.
2026-09-18 20:17:25 +07:00
3dtours 07fbadcdc5 web: star ratings on the reel, and PICTURES becomes an album browser
The landing strip now carries a score: each look and each contributed
frame shows an average, five stars the visitor can press, and how many
votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor
has one vote per key, so pressing a second star moves a score instead of
stacking one. The API is public and rate-limited; look: scores survive a
cleared pool, photo: scores are pruned with their photo.

PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
2026-09-18 19:17:38 +07:00
3dtours 4057566a14 web: the three mixer knobs move the whole image, a frame chip toggles itself, ROTATE lets go when STRAIGHTEN steers
HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.

FRAME loses its NO FRAME chip: pressing the frame already on the photo
takes it off.

ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
2026-09-18 18:45:41 +07:00
3dtours 27035c4acb web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo 2026-09-18 18:27:28 +07:00