2e1feb195a00a074839cfd0b38076485a37efd64
427 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c70edce8c1 |
web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates. |
||
|
|
9d7a5beec2 |
web: say the pricing promise plainly on the landing page
The pricing head asked the reader to start free and go Pro when the grain
matters, which is our joke rather than their question. It now says what
the plans actually are: free to use, forever, and Pro only when they're
ready for more.
The Vietnamese line moves with it — "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn." — so the two languages still promise the same thing.
ponytail: one string on each side of the existing c({ en, vi }) pair, no
new copy key and no layout change.
Verified:
- npx tsc --noEmit clean.
- Against the rebuilt production bundle on :8090, a probe reads #pricing
h2 in both languages: EN "Free to use, forever. Upgrade to Pro only
when you're ready for more.", VI "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn.", VI overflow 0px — 3/3.
- landing-test, lp-probe, subnav-anchor-probe, pro-gate-test all rc=0
fails=0; screenshot of the section in both languages shows the head
wrapping over two lines above the plan cards, nothing clipped.
|
||
|
|
08a4570b2d |
web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a shape sat side by side, and it lied about what the tools are: HEAL and MOSAIC only paint, LINEAR and RADIAL only make a mask. The row now carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and each one opens its own strip holding the tools that belong to it — turning amber when it has a spot or a mask to show for itself, so the state still reads from the row without opening anything. The two brushes moved into the FIX strip with the header FIX above them, the two shapes into the GRADIENT MASK strip under the header GRADIENT MASK, and each CLEAR moved in with the tool it clears instead of sitting at the end of the row. Opening FIX still arms the brush the same way — the strip only changes where the chip lives, not what clicking it does. The mask column (shine, bearing, feather, and the rest) still hangs off the shape you pick, so it now stands right after the options column: the strip that brought it out comes first, then the column it belongs to. Nothing else in the column order moved. ponytail: the two strips ride the existing openGroup and toggleGroup, so a strip key is just a widened GroupKey rather than new state to keep in sync; the option-strip body itself stayed where it was, keeping the diff to the chips that moved. Verified: - npx tsc --noEmit clean. - Frontend probes against the built production bundle on :8090 and the dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe 23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch 33/0, and landing, pro-gate, award-column, otp-code, tone-curve, hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0. - Backend npm test 180/0. - panel-test keeps exactly its four pre-existing failures (rail labels, WB swatch); they reproduce on the commit before this one. |
||
|
|
cca6fc46f7 |
web: turn a mask by the turn the hand makes
A press anywhere on a mask's outline takes hold of it to turn it, and the turn it asked for was read as an absolute bearing: the direction from the shape's pin to wherever the finger now happened to be. The outline is a grip a hand lands on wherever it likes — a ramp's line runs the whole way across the photo — so the moment a press was set down on it the shape swung round to face that finger. A press on the edge of a ramp lying across the photo stood it upright on a ten-pixel move, and a shape already turned snapped to the angle of the press before it had been dragged at all. The turn is now what the hand turns: the change in bearing about the pin since the press, which on the first move is the press itself. A finger that lands on the outline and stays there leaves the angle exactly as it found it; one that carries the shape round by a quarter turn turns it by a quarter turn, whatever angle it was at to begin with. An ellipse adds that turn to the angle it stores, a ramp spins its two ends about its own middle, and both are measured in the photo's own pixels so the two bearings are the same kind of thing. ponytail: The turn is read against the previous pointer position the drag already keeps, so it costs one subtraction and no state. A pointer that leaves the photo mid-turn keeps the angle it stood at — the rule the brush already follows — and the next move on the picture resumes from the last position that was on it. Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a quarter turn of the hand on the edge turns the ramp a quarter of the way round (ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw); the rim of an ellipse still turns with the hand on it (1 pin); another chip still leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). Against the code before this change the same probe fails 9, both angle checks among them — the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was. brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/ award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/ slider-reset/temp-swatch all ALL PASS, backend 180/0. |
||
|
|
1f6c97be62 |
web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the pin of a linear mask sits on the middle of its own line, so the press a hand aims at "that line" was the press that moved the shape. What it moved by was worse: a ramp is stored as the two points it falls between, and 'move' measured the hand against the shape's first point, so the first move of a drag put that end under the pointer and threw the rest of the ramp sideways by half its length — the shape landed somewhere off to the side of the hand instead of under it. The move now takes its delta from the pin the press landed on, which is what the press wanted, and which for an ellipse was already the centre it moved by. The drawn outline is the shape's own handle. A press on the line a ramp falls across — any of the three, anywhere along it — or on an ellipse's rim, turns the shape; the nodes that resize it sit on that same outline and are drawn over it, so a press on one of those is still a resize. That is the Lightroom gesture: the edge is what you drag to aim a gradient, and the ends and the axes are what you drag to lay it out again. Until now a press on the edge was a press on the layer, which read it as the start of the next shape and laid a second mask down while the first was being turned. The band is sixteen screen pixels wide and does not scale with the zoom, so a finger finds it at any size of photo. A mask is chosen by pressing it on the picture, so the keys the hand is on are DELETE, which is what the chip beside the photo already says. And the column the chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the mask tool: another chip puts that tool down and takes the column with it, because a photo still armed to draw shapes is not what a hand reaching for a knob is asking for. The shape stays chosen and stays live in the render; the mask chip brings its column straight back. ponytail: Backspace is read as DELETE as well, since that is the key the label sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still only be chosen while a mask tool is armed — the shapes answer the pointer through the layer that only exists then — so the column coming back with the mask chip is also the way back to a shape drawn a moment ago. Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp lands with its pin on its middle, dragging its own edge turns it (ends at 0.200 and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/ hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail exactly as they do on the build before this one, on their own tabs. |
||
|
|
b7ff298789 |
web: give a mask its knobs as rulers and let the ramp be turned
A mask's knobs were a card of small sliders side by side, three abreast under the colour they were moving, which is the shape the mixer needs because its whole point is reading three bands at once. A mask has nothing to read against: its knobs are a list, and the strip beside the photo has always had the shape for a list — the ruler, one parameter to a row, with its own name, its own value and the width to aim with. So the card goes and the rows come: EXPOSURE, CONTRAST, SATURATION, and FEATHER below them for the shape that fades over one, each the same ruler the FX panel opens, stacked in the mask's own column next to the chip that says which shape is chosen. Only the ramp could be moved and not aimed. An ellipse is stored as an angle, so turning it is writing a new one; a line is stored as the two points it falls between, and turning one is moving both of them about their own middle by the same turn — which is the point of doing it that way: the length survives, the middle survives, and only the direction the gradient falls in changes. The turn handle a chosen ramp now wears hangs clear of its middle along the ramp's own normal, so it never sits on the pin the shape is dragged by, and the angle the hand asks for is measured in the photo's own pixels, so a quarter turn of the hand is a quarter turn of the ramp however the photo is shaped or zoomed. Both handles for both kinds now come out of one list and one map, which is how a ramp and an ellipse ended up wearing the same class, the round one that says "this turns me". ponytail: the ruler row takes no data-key of its own — the parameter's key is already on the chip that opened it, and the panel's fourth column opens a ruler under the same name a strip chip answers to, so a second element carrying it would make an existing selector ambiguous. The mask's rows are found by the label they print; if a probe ever needs to hook a row directly, the key belongs on the row and the panel's ruler needs its own name first. Highlights/Shadows and per-mask invert and range are still out, as before. Verified: tsc clean; mask-probe 42/0 on the dev server and again on 8090 — the three rows share an edge and stack, a linear mask carries no feather row, a radial one carries it fourth, and dragging the ramp's turn handle stands the gradient up the photo: 61 at the top and 244 at the bottom where it was 61 -> 244 across — brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/ award-column/otp-code/tone-curve all ALL PASS, backend 180/0. |
||
|
|
b568fa3fdc |
web: let FX carry Lightroom's two gradient masks, and grade inside them
FX had two tools that change the photo where it is — HEAL repairs a speck, MOSAIC hides a patch — and every knob that graded the frame graded all of it. The scratchpad's gradient_mask.md asks for the two local adjustments the phone's own editor has and Lightroom made familiar: a linear gradient and a radial one. This is that spec, written for the renderer this app actually has. A mask is a SHAPE rather than a value, so it is dragged rather than turned: the LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at the press, one at the release, the spec's own convention, which is what makes the same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre is the press, whose semi-axes are the drag's own distance and whose axis lies along the direction the hand went, so the circle a drag describes is the circle the mask starts life as. Both shapes keep a pin (the whole shape travels by it) and, while chosen, the handles that move the ends or the axes and the one that turns the ellipse; what is drawn is the shape the render will read, so the ramp and the rim are visible before a knob is moved. Inside the shape, three knobs grade in the spec's own order and its own maths: exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle, saturation as a mix away from the pixel's own REC-709 luma — the mixer's -10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades over, which is the fraction of its own axis the alpha holds full before it dies at the rim. Several masks run in the order they were drawn, each reading what the one before it left, which is what a stack of local adjustments is. The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL runtime effects), so it is ported stage for stage: one pass, after the frame-wide grade and the vignette and before HEAL, because a local adjustment is part of the look and not a repair — the pixels a repair borrows are then meant to carry the mask's light already. Preview and export both come through renderPhoto, so the file carries the masks the stage is showing by construction, and the shape and the knobs ride in the recipe's own JSON, which is what makes them survive a save. The chips sit with HEAL and MOSAIC because all four take the pointer on the photo, and they are exclusive with every other armed tool, the eyedropper included — while a mask tool is armed the layer takes the photo, so a drag means "draw the next shape" and a press on a pin means "take hold of this one", which is why the shapes already laid are answered through their pin and handles alone. A knob drag on a mask is one undo step, a shape drag is one more, a press that only chose a mask records nothing at all, and RESET is the way back with the whole frame as it was imported. ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is Lightroom's per-mask invert and colour/tone range. The three knobs are what "gradient mask" means until a photo shows a sky that has to be rescued apart from the grass under it; the md itself calls it an upgrade, not the feature. Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two chips, both shapes drawn and moved and turned, the ramp read off the pixels — 61 -> 244 at the release and 61 at the press — the feather read off the rings, DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/ tone-curve all ALL PASS, backend 180/0. |
||
|
|
01af863fd8 |
web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's own size at the moment it was laid — so a photo with two repairs has two rings and a photo with twenty has twenty. Each one is loud enough to be the loudest thing on the picture, and none of them is the one the user is looking for: the speck that was mended, and the patch borrowed to mend it, are both inside the ring that covers them. What the ring is for is the spot that is about to be taken hold of, and there is only ever one of those. A repair now wears its circle only while it is the spot in hand — the one the pointer is over, the one just laid, which is the one the user is watching, or the one a press chose, which is the one wearing the ×. The moment the pointer walks elsewhere the ring goes, and what is left is the pair the renderer works with: the patch it borrowed, still dashed, and a soft print of the place it mended. The ring comes back under the pointer, which is where the spot is taken hold of again; the grab was always the geometry — the circle plus a few pixels of slop — and never depended on the ring being drawn, so an idle spot is as easy to move as a ringed one (measured: a 60,40px drag on an idle spot moved it 970.2,390.7 -> 1030.3,430.7). The run a stroke left is the mark of that stroke, so the spots the band stands for keep their boxes and give up their own edges as before, and no print is laid under the band: a row of blurred discs under one translucent band is a second, blurrier band. The one spot of the run that is in hand wears its circle again over the band, because that is the spot a press would take hold of. MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the circle is the only thing that says where the cover is, and the cover is the edit. ponytail: the print is a blurred translucent disc (14% grey, a soft dark shadow, 1px of blur) rather than a tint read off the pixels under it, so it reads on a photo of any tone without a second pass over the render — the upgrade path, if a print that sits on the repaired pixels themselves is wanted, is the shader the repair already runs through. The ring under the pointer is reported from the pointer's own move events rather than from a hit test per frame, so the one case it does not cover is the pointer that has not moved since the repair landed; that case is covered by the spot just laid being in hand, and a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's only: MOSAIC's spots keep the border they always had, which is the asymmetry this tool set already had about moving them. Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on :8090 after deploy — a press on the speck lays one repair and that repair is in hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the pointer; a press chooses it, puts its × on the photo and the × stays while the pointer walks off; laying the next repair takes the choice and the × off the first and gives its ring up; hovering the second leaves the first idle; a drag still takes hold of an idle spot; a run of 13 spots shows the band, no print under it and no ring while the pointer is away, and the spot of the run under the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors. brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now reads "but the one in hand", which is the rule this commit adds, and its "a spot with no neighbour keeps its own circle" passes off the repair just laid being in hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9, heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49, heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0, award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0. Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean. |
||
|
|
53554ce42a |
web: let the photo be zoomed and moved under the brush, and keep its × out of the hand
With HEAL armed the wheel was the brush's size and nothing else. A repair is aimed at a detail — a scratch, a speck on a face — and the detail is usually smaller than the photo, so the one gesture the stage uses to bring it closer was the one gesture the brush had taken: a user could size the spot they were about to lay and could not zoom the photo they were laying it on. The same layer took the pointer the stage pans with, so a zoomed-in photo could not be moved out of the way either, and it swallowed the double-click the img answers with. A tool that covers the surface has to hand back the gestures it does not use. The wheel over the brush is the stage's own now, exactly as it is everywhere else in the app: one notch at the pointer, the point under the cursor held still, the same arithmetic the wrap's listener has always used (lifted out of that listener as zoomAt, so the two callers cannot drift). The brush's size — the one knob this tool has — is that same wheel with a modifier held: Alt, or Ctrl/Meta, which is also what a trackpad sends for a pinch, so pinching still sizes the spot without reaching for a key. The photo can be moved out from under the brush as well: the middle button, or the space bar held, drags the photo while a tool is up, at a zoom, which is the only place a pan means anything — the listener is on the layer, so the key is watched on the window and read from a ref. A pan drag paints nothing, and the modifier wheel does not touch the zoom: measured, 24.744px -> 29.6239px of brush across while the photo stayed at 1568px. The × a chosen spot wears is now drawn for the screen rather than the layer: it keeps 18px and a 5px gap at any zoom, scaled back out of the layer's own transform. It was scaling with the photo — 18px of badge is 27.9px at ×1.52 — and at that size its corner sat over the circle it belongs to, so a press meant to take hold of a repair landed on the × and deleted it instead. 5px of daylight at the fit and at ×1.52, measured both. The histogram goes off the photo while a brush is up. It is a panel over the photo's top-left corner with the pointer on it, and the corner is where a user paints first: a drag under it painted nothing, and the wheel over it belonged to the panel rather than to the photo. The crop frame already had it hidden for the same reason. ponytail: the pan is bound to the middle button and the space bar rather than to a second pointer, so a trackpad-only user has the zoom (two fingers) and the brush's own size (pinch) but no one-finger pan while a tool is up; a modifier plus drag, or a small hand tool on the toolbar, is the upgrade path. The brush size now lives behind a modifier that nothing on screen advertises — the chip's percentage is the only hint — so a stepper in the brush chip is the next thing to add if that turns out to be a wall. Double-click to zoom is deliberately not wired up: the layer swallows the press, so the first half of the double-click lays a spot, and a zoom that leaves a stray repair behind is worse than no zoom. The middle-button pan only engages past the fit, where the drag is free rather than a scroll, matching what the stage already did. Verified: heal-zoom-drag-probe.cjs 28 PASS / 0 FAIL on :5199 and on :8090 after deploy — the brush arms as a layer, the histogram is gone from the corner and the circle follows the pointer there, the wheel zooms (1031px -> 1185px) and leaves the brush its size (24.744px -> 24.744px), Alt+wheel sizes the brush (24.744 -> 29.62) without moving the zoom, the modified wheel over the brush sizes it and leaves the window's own frame alone (1440 CSS px, dpr 1, either side of the notch, so the modified notch is not handed on to the browser's page zoom), a plain drag paints 0 -> 7 spots, the middle button moves the photo 215,34 -> 269,66 without painting, space+drag moves it 269,66 -> 179,6 without painting, and taking hold of a repair moves it from the centre, from 0.8 inside its edge and from its other side at both the fit and ×1.52; the × has 5.0px of daylight and is 18px across at both zooms; 0 console errors. brush-edit-probe.cjs 33/0, heal-blotch-lab 12, heal-edge-lab 9, heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49, heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all green, and heal-probe.cjs and mosaic-probe.cjs had their wheelTo helper moved to Alt+wheel because the plain wheel now zooms the photo, which is the contract they were testing. Regression on :8090: landing-test 172/0, pro-gate-test 27/0, award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0. Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean. |
||
|
|
abfc6595e7 |
web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.
HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.
The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.
Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:
- The × first sat on the spot's corner at the brush's own radius. The default
brush is 6px wide and the badge is 18px across, so the badge covered the
circle: the next press on the repair — a user putting the spot down again —
deleted it. Measured: after undo/redo, a press at the spot's centre left one
spot instead of two. The badge now sits on the top-right diagonal at the
circle's edge plus a badge's radius, so it can never take a press meant for
the spot.
- The hit test first took the hole before the patch. On the default brush the
patch the search borrows sits about 8px from the hole it fills — inside any
reach a pointer can use — so dragging the patch's own centre grabbed the hole
and the patch never moved (measured: dragging the patch from (0.331, 0.300)
to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
instead). The nearest circle now wins, and the hole wins a tie with its own
patch.
- The reach was first the circle plus 8px, for a brush turned down to a few
pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
12.8px apart were meant to lay eight repairs and four of them landed, because
four were within 8px of a patch circle and grabbed the spot instead. The
reach is now the circle plus 4px: the same probe is 49/0 and the patch's
centre is still 0px from the pointer that grabs it.
MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.
Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):
brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
brush's width through all 15 spots of a drag, its length inside 2px of the
polyline the spots stand for, every joined spot's border transparent and a
lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
and forth in one step each; dragging the patch onto the neighbouring speck
puts the speck back into the repair (level 5 on a field of 151) and UNDO
returns it; a press chooses a spot and shows the ×, that press records no
step (the next UNDO still takes the last repair back), the × deletes that
spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
are one band and every cell in the run joins it, painting across mosaic
already laid down paints more cells, and no mosaic spot is ever offered an ×.
Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL.
Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
tsc --noEmit clean.
ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
|
||
|
|
88d6d0e648 |
web: read the ring's light by direction, and off the dust's soft edge
The last commit pasted the borrowed patch at the light of the place it lands in,
and read that light as one number per spot: the mean of the ring around the dust
minus the mean of the same ring around the patch. That number is a light the
place has when the ring is all one thing. It is not a light the place has when
the ring is not. A twig, a hairline, the edge of a table under the brush, and a
minority of the taps stand on the thing rather than on the ground: the mean then
follows the minority — it is a colour the place never had — and the patch is
pasted in it. The donor was of exactly the right light, the search had gated it
at LIGHT_GATE, and the repair still lands as a dark blotch. A mean is the wrong
estimator for a ring that is not one thing; the search already knew that, and
reads its own ring as a median for the same reason.
So the light is read once PER DIRECTION. Each of the sixteen taps is a pair of
readings — the place's ring and the patch's ring at the same sixteen places —
and a pixel takes the correction of the two readings it lies between,
interpolated by its own angle around the spot, in the same single draw. The rim
then meets the place all the way round instead of on average: a tap that landed
on the twig bends the part of the rim near the twig, and the far side of the
circle is left where it was. Sixteen taps rather than eight because a tap's
influence reaches only as far as the next tap, so the finer the ring, the less
of the rim one hard pixel of the photo can drag with it.
The second half of the change came out of the app, not out of the lab. With the
per-direction reading and no other change, heal-probe.cjs went from 49 PASS to
41 PASS / 8 FAIL: the repair's own centre came out 15-18 levels dark on a flat
field, with the frame around it clean. The reason is the estimator again, from
the other end — one direction is one pair of pixels and carries no averaging, so
whatever the ring reads at that direction, the patch gets in full. And the ring
at RING_R alone is not clear of the dust: a speck spreads about a pixel past
where it is drawn in the pixels the shader samples, so the nearest taps sit
inside the dust's own soft edge and read the dust's light. The mean had been
hiding it: one contaminated tap in eight is a level off; the same tap read whole
is the blotch. The ring is now a pixel further out again (RING_PAD), in the same
pixels the sampler works in — a fraction of the radius would be nothing at all at
the sensor-dust end of the brush, which is where this tool is aimed — and the
probe is back to 49 PASS / 0 FAIL.
Measured on a sweep of the two ways of reading it (heal-ring-sweep.cjs, CanvasKit,
three scenes, the step the eye reads at the rim plus the level of the patch's own
middle against the ground it landed in, levels out of 255):
scene shipped mean 8@1.15 this: 16 taps, per direction
uniform light difference step 0, centre 0 step 0, centre 0
twig across the ring step 53 (mean 16.4), step 56 (mean 2.4),
centre 34 dark centre 0
brush fits the speck centre 5 dark centre 0
The worst step on the twig scene is unchanged — that is the twig's own edge
crossing the rim, which no level can meet, and the floor the copy set at 85. What
moved is the average (16.4 levels to 2.4) and the level of the patch's middle,
which is the blotch: 34 levels of a place that never had them, down to none.
No new dependency. cv.seamlessClone is the same thing this shader already does —
the membrane half of a Poisson edit — and OpenCV.js would be 5-10 MB off a CDN,
solved on the CPU per spot, outside the one draw the preview, the recipe and the
export all read from: the correction is recomputed from the snapshot on every
render, which is why the preview and the exported file agree by construction and
why a saved photo opens onto the same repair. It also cannot run in the worker
the brush paints in or against the fractions the recipe stores.
Verified:
heal-blotch-lab.cjs (scratchpad, CanvasKit, no browser) — new, 12 PASS / 0
FAIL, and 8 PASS / 4 FAIL against the bundle built from
|
||
|
|
57ade27eee |
web: paste the borrowed patch at the light of the place it lands in
HEAL borrows a patch of the photo and copies it over the dust. The copy brings
the patch's texture — which is the point, the repair is the same picture rather
than a blur over the speck — and it also brings the LIGHT the patch was
photographed in, which is not the point at all. The search already refuses a
donor from another light: LIGHT_GATE is 20 levels, and a candidate past that is
not scored. But inside the gate a patch can still be 20 levels off, and 20 levels
is a soft blotch of its own at the rim of the circle — a mark where the dust
used to be, which is what the user is complaining about when they say the repair
is visible. On skin, sky and sand the dust is not the problem the eye finds; the
step the paste puts down is.
So the paste is now the patch's gradients worn at the destination's level: the
shift is the mean of the ring the spot sits in minus the mean of the same ring
around the patch it borrowed, and what lands is the borrowed pixels plus that.
It is the membrane half of a Poisson edit — keep the texture, adopt the level —
and it is eight taps per spot inside the shader that was already running. No
solve, no ping-pong, no extra pass: the correction is recomputed from the
snapshot inside the shader on every render, so the preview and the export agree
by construction and the recipe carries nothing new. The same spot in a saved
photo opens onto the same repair, because nothing about the correction is stored.
Where the ring is measured turned out to be the whole of the change. The first
cut read it at the feather line, 0.85 of the radius, which is where the pasted
patch is still at full strength and therefore looks like the natural place to
compare — but that ring sits just inside the circle, and when the brush fits the
speck snugly, which is exactly how a dust brush is used, it reads the speck: the
light the repair is measured against is then the dust's own, and the patch gets
shifted onto the very dark it exists to erase. It also reversed the smoothstep
edges the moment the ring was pushed outside the brush (a radius past rad, edges
the wrong way round, and the pass quietly drew nothing). The ring now sits just
OUTSIDE the brush, at RING_R of the radius — the same radius the search reads a
spot's light at. Outside, both sides are photographs: the ground the repair has
to sit in, and the ground the patch came from. That the two are the same
measurement is the point: a donor that passed the gate was already within
LIGHT_GATE of this ring, so the shift it now receives is bounded by the gate. The
decision to borrow and the correction to the borrow stopped being two different
opinions about the same pixel.
Eight taps at the same angles on both sides is what makes the difference read as
light rather than as texture: the grain, the detail and the neighbouring specks
that differ between two patches are averaged out by sampling both rings at the
same places, and what is left is the level. The rim, measured as the level inside
the circle against the level of the ground outside it, drops from 20 levels to 0
on a scene built for it, while the borrowed contrast stays at 40 — the level
moved and the gradients did not. That is the line between this and a blur, and it
is the line the lab holds it to.
Verified:
heal-seam-lab.cjs (scratchpad, CanvasKit, no browser) — 10 PASS, 0 FAIL: one
scene, the speck on the grey ground with every reachable patch inside a block
20 levels darker, run twice through the real pipeline — the paste the branch
shipped before this change (the copy, kept inline in the lab as the "before")
against healSkSL from the bundled heal.ts. The copy puts the block's own
level down at the rim: inner 100/110/120 against outer 120/130/140, rim step
20.0 levels, contrast 40. The shift lands the borrowed texture on the
ground's level: inner 120/130/140 against outer 120/130/140, rim step 0.0
levels, contrast 40 — the borrowed feature is still pasted at the strength it
was borrowed at, the hole reads as the ground it sits in, the block the patch
came from is untouched, and the frame away from the repair is the photo.
heal-skia-lab.cjs 28 PASS / 0 FAIL against the bundled module: the pass still
runs, the uniform block is the size its shader declares, and the paste is
still an exact copy of the source pixels — the lab's paste scene now borrows
from the SAME light (a white pixel at the middle of the borrowed patch, so a
copy and a blur of the dust cannot be confused), and its forty-spot and
three-spot runs still draw every spot in order. The scene where the two
lights differ is the seam lab's.
heal-search-lab.cjs 15, heal-probe.cjs 49, heal-zoom-geom.cjs 5,
heal-zoom-probe.cjs 8, mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0
FAIL, against the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend).
Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
tsc --noEmit clean.
ponytail: the shift is one number per spot, measured over the rim, so a border
the two patches disagree about along its length is only matched on average — a
repair laid across a hard edge keeps a faint step where the edge crosses its rim,
and the other half of the Poisson solve (a correction that bends inside the
circle, a Jacobi solve over the spot's own box, a ping-pong pass per spot) lands
only when a real photo shows that step and the eye can find it. The gate is still
needed and still refuses: a shift corrects a light, it cannot invent a patch
where no patch of that light exists, so a speck surrounded by dust from another
light is left alone rather than covered with a guess. The source is still found
by the ring search — eight directions at three distances, each mirrored — and not
by PatchMatch: the search already refuses dust and wrong light, and PatchMatch
lands when a real photo shows the search picking a bad donor. The run is still
drawn spot by spot, with no stroke id in the recipe, so a long drag is a row of
circles rather than one region.
|
||
|
|
f1385d8a08 |
web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.
What a mosaic spot does is destroy what it covers rather than replace it. The
frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the
probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the
colour found at that cell's own middle, read with img.eval so the block is the
snapshot's bilinear tap and not a neighbour's cell. What is under the circle is
still a picture of that place, at a resolution nothing can be read out of. A blur
was never in the running: it leaves the SHAPE of what it hides — a face under a
blur is still a face, a plate still a plate — and the arrangement is exactly what
the user is asking to keep to themselves. Cells coarse enough to lose the
arrangement are what "do not show this to anyone" needs, and the blockiness is
the price of it.
The cells are one grid over the whole frame, not one grid per spot: a pixel's
cell comes from its own position, and every block reads the snapshot rather than
the output, so two overlapping spots never pixelate a pixelation and a run lays
one band with no seam where its circles cross. The rim is hard for the same
reason in reverse — a feather would mix the cells back into the sharp photo along
the edge, which is a half-hidden thing leaking the arrangement it exists to hide.
A mosaic spot borrows nothing, so the layer draws no donor circle beside the
cursor: the second circle appears only when a spot has a source ('sx' in it),
which is the one place the two tools' DOM parts company. Each tool keeps its own
brush size, and each CLEAR chip clears only its own list, because the size a
dust speck is healed at is never the size a face is hidden at.
The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions
HEAL stores, and readMosaic guards them the same way — and the renderer builds
one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the
pass sitting right after the heal pass so a repair made on the same photo ends up
underneath the cells that hide the rest of it. The backend needed nothing: a
recipe is spread through as it stands, so a saved photo keeps its mosaic and a
shared one opens with it.
Verified:
mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27
passed, 0 failed: the cell rides in the frame block in the render's own
pixels and is a fraction of the WIDTH, so it is square on any shape; 4912
cells inside a spot each carry one colour, and 164/164 of them carry the
colour at their own middle; the 2px white dot on the dark square reads
250 -> 20; nothing outside the circle changed (0 stray pixels) while the
cells reach the rim (852 pixels at the edge); a spot wider than the frame
still runs; overlapping spots share one grid over 6335 pixels with 0
differing between them (no cascade); readMosaic refuses a zero radius, an
off-photo spot, junk and a missing list, and keeps a forty-spot list whole.
mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL,
no page errors: FX offers a MOSAIC chip that arms the same brush layer and
says which tool it is painting for; the wheel sizes each tool on its own
(8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot
with no borrowed patch beside it; the pixels of the cell are one colour (0
levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing
outside the circle changed (0 pixels, worst 0) and the cells are not the
photo that was there (221/509 pixels changed); UNDO gives the photo back
exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide
as the brush, standing for 5 points of travel and laying 5 spots that leave
0 pixels outside them changed, with the step within a cell 3.43 levels
against 21.25 between cells (635 + 157 pairs) — the cells are flat and their
borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC
hand the pointer over and back with each tool's spots intact; CLEAR hands the
photo back pixel for pixel and leaves no chip behind.
The probe's own reading is deliberately a shape, not a colour: the app's
preview is the engine's render at preview scale with a JPEG on top (and its
auto dynamic range), so a cell's colour read back from the base would be two
encodings apart. The exact cell colour is the Skia lab's claim, where no
encoder sits between the shader and the reading.
heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15,
heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush
HEAL paints with is the one MOSAIC now paints with.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
tsc --noEmit clean.
ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
|
||
|
|
b795517d9f |
web: read a patch's light before pasting it, and paint with the brush
The brush was not healing: clicking a speck deleted one black spot and made
another, and the borrowed patch landed in a light the spot was not in, so the
repair read as a mark of its own. The circle the brush draws also slid off to
the side of the pointer as soon as the photo was zoomed in, and a drag showed
itself as a row of overlapping circles rather than as a brush being drawn.
The search was comparing the wrong thing. findHealSource scored a candidate
against the spot's own PATCH_TAPS — the centre and a ring at half the radius,
which is INSIDE the brush, where the dust is. The patch that matches a speck
best is then the one carrying a speck of its own, which is exactly how "heal a
spot" became "move it a few pixels": with a neighbour sitting at the 2.6r ring
the search itself prefers, the winner was that neighbour, 26 dark pixels pasted
where the repair was meant to be.
The taps are split now, by what they are for. The light a repair has to sit in
is read off the spot's RING — twelve taps at 1.15r, just outside the dust, the
scale the eye reads a spot's surroundings at — and taken as their MEDIAN,
because the ring can only be a little way out: some of its taps land on the
speck's own softened edge, and a mean drags the whole light down by them (the
eight-tap mean read 84 where the ground was 150, and with the gate below that
refused every candidate on the frame). What a candidate would actually paste is
the mean of its own inside taps, now including the ring at HEAL_FEATHER of the
radius — the circle is copied at full strength out to there, so that is where a
neighbour's dust leaking into the patch shows up and the middle of the patch
would never see it — and its cleanliness is how much those taps spread around
their own mean: dust is an outlier in its own neighbourhood, grain is not.
A candidate from another light is not scored at all. Past LIGHT_GATE (20 levels
of the 0-255 the sampler answers in) the patch IS the mark the user is
complaining about, so the search returns null rather than sending a wrong clone
and the caller leaves the speck alone. Within the gate the score is light * 3 +
cleanliness, so the light decides and cleanliness breaks the ties the eye would
not see. A spot the search refuses is not laid down at all — healUp skips it
instead of recording a self-patch, which was a repair that changed nothing —
and a stroke that is refused end to end reports no spots, which addHealSpots
already treats as nothing to do: no step in the history, no spot on the photo.
The ring had to be a fraction, not an offset. healPos was the pointer's pixels
inside the layer, and the layer carries the stage's transform, so a zoom scaled
that offset a second time: at 1:1 the pointer sat at screen x 846.5 and the
ring was drawn at 1288 — 442px away, the same distance the user sees as "the
circle is in the wrong place when I zoom in". The pointer is stored as a
fraction of the photo now — healPoint already answers one for the spot it lays
— and drawn as a percentage of the layer, so the layer's own transform scales it
once; off the photo there is no ring. The eyedropper's icon had the same shape
of bug (its sample was always right — pickAt reads the photo's own rect) and got
the same fix in the same file, since it was two lines.
The stroke is one mark of the brush. The trail was a circle per point of travel,
laid one HEAL_SPACING (0.6) radii apart, which is what a row of beads looks
like; it is one SVG path with round caps and round joins now, its width the
brush's own diameter and its colour the accent at 45%, so what the pointer draws
reads as the band it is about to lay down. The count of travel is kept on the
element (data-points) so the probe can still hold the run it becomes to the run
it showed.
Verified:
heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS,
0 FAIL: one speck alone is repaired, from a patch that is clean field, and
its light is 0.0 levels off the spot's own; a speck with a neighbour exactly
at the search's first ring borrows from the far side with 0 dark pixels
pasted; a speck ringed with dust in all eight directions skips past the ring
(0 pasted); a speck in the corner stays inside the frame; a speck at the lip
of a shadow, where every reachable patch is 60 against a ground of 150, is
refused (null); ground with a dark edge through it is not a refusal — the
repair comes from the light side and its light is 0.0 levels off.
heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in
everything the search is not asked here).
heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL,
no page errors: the circle rides the pointer at the size the chip reads; one
click heals a speck to 151 with its four neighbours field; the borrowed
patch is a real distance away and is clean field; a drag shows ONE mark,
6.1px wide against a 6.1px brush, standing for 15 points of travel, lays
exactly 15 spots, clears on release, and UNDO takes the whole stroke back;
25 spots carried with the first healed speck still first; everything gone
after a reload; CLEAR brings it all back.
heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen
centre is the pointer (846.5,452.5 both times, against 1288 before), the
ring keeps the brush's size on screen, and a repair made at a zoom lands
under the pointer.
heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the
speck goes, the donor is at least a ring away, the patched circle is within
1.07 levels of the ground it landed on, the donor's own circle is drawn on
the pixels it borrowed; on a navy field with three specks, two repairs land
0.0 levels from their ground.
heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to
paste its neighbour and read min 3 inside the healed circle — the pasted
dust — and reads 151 now, the untouched second speck alone in the frame;
the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all,
which is the refusal working: the speck is left alone instead of smeared.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: a refusal leaves the speck on the photo and nothing on the screen —
the user closes the brush in a size that covers it and clicks again — which is
the honest half of the trade the user asked for, but it is silent; a hint would
mean a toast or a shake, and neither is worth a component. The gate is a
flat 20 levels, not a percentage of the local contrast, so a photo with a hard
edge through the brush's own ring reads as one light and can still take a donor
from the other side of it. The search reads the preview JPEG rather than the
original, so a patch near the preview's own edges is chosen from the pixels the
user is looking at, not from the ones the export will print. And the run a
stroke leaves behind is still drawn as its spots, circle by circle, because each
one is a repair with a borrowed patch of its own — drawing the laid run as one
band would need the recipe to remember the gesture (a stroke id on the spots),
which is a recipe change and not what was asked.
|
||
|
|
88cff5ca87 |
web: draw the dust brush into strokes, size it by the wheel, uncap the list
A speck of dust is small and there is never only one, so the brush had three
things wrong with it: the list stopped at sixteen and the seventeenth repair
pushed the first one out of the shader, the size was a choice of three buttons,
and one gesture laid exactly one spot — a scratch across a hundred pixels was a
dozen clicks.
The cap is gone rather than raised. SkSL indexes a uniform array by a constant
only (the trick the tone curve's mixer already uses), so HEAL_SKSL carried
sixteen unrolled blocks and the list was trimmed to fit them. The shader is now
built for the count it is handed — healSkSL(n), with healUniforms returning
(n * 2 + 1) * 4 floats, the same declaration order for any n — and the renderer
caches one compiled effect per count (exportEngine's healEffectFor). readHeal
no longer slices and the app appends whatever a gesture reported. No repair is
dropped to make room for a later one: the speck healed first is the speck that
stays healed.
The wheel is the size now. wheelHealR multiplies the radius by
exp(-deltaY * 0.0015), so a trackpad's small deltas and a mouse's 100px notch
are the same gesture at two speeds, bounded at 0.3% and 25% of the photo's
width — below the first a spot is finer than the pixels it is drawn on, past
the second it would borrow its patch from off the frame. S, M and L are gone,
and because there is nothing left to point at, the HEAL chip's own readout is
the size: the number the brush is set to is the number on the chip.
The pointer paints. Down starts a stroke, move adds a point every HEAL_SPACING
(0.6) radii of travel, and up turns the whole run into spots in one report — so
a stroke is one undo step however long it was, and the trail drawn while the
pointer is down is a preview of that run, in the accent, cleared the moment the
spots land. The part of a stroke that leaves the photo lays nothing down, and
the pointer is captured so a stroke that runs past the edge ends where the
pointer does rather than leaving a spot hanging at the frame.
The wheel had to be stopped, not merely claimed. The heal layer is a child of
the stage, and the stage has its own wheel listener that zooms the photo, so a
wheel over the brush grew the brush AND zoomed the view: the probe caught it as
a cursor circle 15% wider than the readout it was drawing. The layer's listener
(native, because React's own onWheel is passive) now stops propagation — while
the brush is up, the wheel sizes the brush and nothing else.
One number moved that none of the three asks mentioned, and it is what the
probe's remaining failure was about. The feather band was 45% of the radius,
and that band is the only place the pixels being repaired are mixed back into
the patch, so with the default 6px brush it left a ring of the speck's own edge
one pixel inside the circle (115 in a field of 150) — which the preview's own
JPEG then rang around, reading 177 a pixel off the centre of a repair that
should be flat. Narrowing the band to the outer 15% copies the patch over
everything inside 0.85r: sub-pixel at the default brush, still a soft edge at a
big one, and that pixel now reads 151.
Verified:
heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 27 PASS,
0 FAIL: the shader for a count compiles through RuntimeEffect.Make and its
uniform block is (n * 2 + 1) * 4 floats (n=1 -> 12, n=40 -> 324); a single
spot copies the donor exactly and leaves the rest of the frame untouched,
pixel for pixel; forty spots are carried whole with the first and the last
both drawn; three spots in one run each borrow their own patch; readHeal
clamps and drops zero-radius spots and no longer trims the list;
wheelHealR grows, shrinks and clamps at both ends (0.3% and 25%); the
search finds a patch and still refuses a brush that covers the frame.
heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
48 PASS, 0 FAIL, no page errors: the circle under the cursor is exactly
the size the chip reads, before and after a wheel, and the wheel grows,
shrinks, stops at 25% and at 0.3% and returns to where it started; there
are no size chips left; one click is one spot, the speck reads 151 at its
centre and its four neighbours are field too; a drag shows at least three
trail circles, lays exactly that many spots, clears the trail on release,
and UNDO takes the whole stroke back at once while leaving the repair made
before it alone; REDO repaints it; a bigger brush takes a ten-pixel blob;
twenty-five spots are carried with the first healed speck still first and
still healed; every speck is gone after a reload; CLEAR brings them all
back and lays no spot of its own; the chip goes amber only while spots are
on the photo.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: a stroke's repairs land when the pointer comes up, not under it as
they are painted — a live repair would mean recompiling the pass and re-cutting
the preview per point mid-gesture; the trail is what the pointer has drawn, and
it is drawn in the accent so the difference reads. The list is uncapped, so a
runaway stroke pays one shader compile per distinct count it reaches, cached
for the rest of the session: a ceiling would have to come back with the trim.
The search still has no colour-matching term, so the donor is chosen by
resemblance alone, and the spots still live in the rendered photo's
coordinates, so re-cropping or re-rotating after healing slides them.
|
||
|
|
3ee0137d0d |
web: repair dust with a brush that borrows a patch of the same photo
A sensor speck is not a filter: it is a small lie in one place, and every
slider in the panel is global, so there was no way to say "here, and only
here". The FX row now has a HEAL chip. Arming it turns the pointer into a
circle you can size S, M or L, and every click on a speck covers it with a
patch of skin borrowed from a few radii away — the repaired sites persist in
the recipe like any other edit, and UNDO takes them back one click at a time.
The spot is stored in the rendered photo's fractions, not in the preview's
pixels: x, y and a radius that is a fraction of the photo's WIDTH, so the
circle stays round on a tall or a square frame and the same recipe heals at
preview resolution and at export resolution without a second code path.
`readHeal` is the only door in, and it validates, clamps and drops the spots
with no radius before anything downstream sees them.
The source patch is searched for, not asked for. `findHealSource` walks eight
directions at three distances — 2.6r, 4.2r, 6.5r — and each candidate's mirror
through the spot as well, scores every one with a nine-tap comparison of the
neighbourhood, and hands back the first that actually resembles the ring around
the speck. When nothing fits — a brush wide enough to swallow the whole frame —
it returns null and the click is refused rather than smearing a wrong colour
over it. There is no colour-matching model here and no second draggable source
circle: Lightroom lets you place the donor, this finds one.
The pass runs last on the photo's own pixels. It is inserted after the grade,
the curve and the grain and before the frame, so the patch it pastes is copied
from pixels that have already been graded and grained — it matches by
construction, with no second copy of the pipeline to keep in step — and the
frame, the card and the watermarks are drawn over the result, so healing can
never erase the furniture of the render. The brush is a feathered circle at
0.55r, which is what keeps a repair from reading as a sticker.
SkSL indexes a uniform array by a constant only, so the shader is the block
unrolled HEAL_MAX = 16 times, the same trick the tone curve's mixer already
uses. Sixteen is the ceiling and the oldest spot falls out when the
seventeenth arrives. CLEAR drops the whole field — turning the chip off keeps
the repairs, which is the distinction between disarming the brush and undoing
the work.
Verified:
heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 15 PASS,
0 FAIL: HEAL_SKSL compiles through RuntimeEffect.Make and
makeShaderWithChildren; the uniform block is 132 floats in declaration
order (16 spots + 16 sources + size, w/h/feather); a dust speck pinned on
the canvas comes back as the borrowed patch while the rest of the frame is
untouched, pixel for pixel; readHeal clamps, drops zero-radius spots and
caps the list at 16; the search finds a valid donor and returns null for a
brush that covers everything.
heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
29 PASS, 0 FAIL, no page errors: the cursor circle is 2 x 0.012 x width and
centred on the pointer, L is visibly bigger, S and L are exclusive; one
click is one spot; a speck at 151 reads 154 at its centre after the heal
and the photo's other specks and empty skin are unchanged; the spot and its
borrowed source are both drawn; the chip goes amber; CLEAR appears and
restores everything; UNDO (the TopBar button) brings the dust back and REDO
heals it again; three specks and one L-sized blob all go; the repairs
survive a reload.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: spots live in the rendered photo's coordinates, so re-cropping or
re-rotating after healing slides them — re-heal or CLEAR when that matters; a
coordinate space pinned to the sensor would need the crop and rotation to carry
the spots through. No live brush-size gesture and no colour-matching term: the
donor is chosen by resemblance alone, add a colour term if skin tones ever
mismatch. The list is capped at 16 with oldest-out rather than refusing the
seventeenth click.
|
||
|
|
b24bd78ddd |
web: draw the picture's own distribution behind the tone curve, and let the card be dragged
Setting a point on the curve was guesswork: the graph showed the mapping but
nothing about the picture it was mapping, so you placed a point where the tones
"probably" were. The graph now draws the picture's own histogram behind the
grid, and the panel can be dragged off the photo it is editing — the two halves
of the same complaint, that the card was describing a picture you could not look
at while you used it.
The histogram is not a second measurement. `ToneCurvePanel` takes the same
`previewUrl` the stage already renders and reads it through `readHistogram`, the
function the HISTOGRAM overlay beside it uses: one 320px sample, luminance bins
on the RGB tab and the channel's own bins on an R, G or B tab, so the shape
follows the tab the way the line does. The bins become one filled path in the
graph's own square, scaled to its own tallest bucket and closed along the floor,
and it is the SVG's first child — grid and curve draw over it, so the graph
reads as curve on distribution rather than two lines crossing. Nothing new is
rendered, sampled or cached: the panel reads the frame that is already there.
It is read from the render, which is post-curve, so the band shifts as the curve
moves. That is Lightroom's behaviour, not an accident, and it is the honest one:
the point of the picture is what you are looking at. A percentile or log scale
would show a shadow-heavy frame better than a linear max does, and the overlay
beside it does not have one either, so the two agree.
The drag is the panel's own head. `pos` is the card's position in the layer's
coordinates (null until first moved), and the first position is materialised
from `offsetLeft/offsetTop`, which is exactly the CSS bottom-left the card sits
at before anyone touches it — so the default layout costs no code and the card
carries no second positioning system. It is bounded by the STAGE, not the photo:
the card may sit off the photo, that is the point of moving it, but never off
the canvas the stage clips at 8px. Window `resize` and a `ResizeObserver` on the
stage re-clamp an existing position, because the stage can shrink under a parked
card and `overflow: hidden` would hide it with no way to reach it.
One real bug, found by the probe rather than by reading: with the head as the
handle, `setPointerCapture` retargets the click that follows, so the close
button in that same head never fired — pressing it started a drag and swallowed
the click. `panStart` now returns early when the pointer went down on a button.
The pre-existing `Histogram` overlay carries the same latent pattern; it has no
interactive children in its chrome, so it was left alone.
Verified:
tone-curve-probe.cjs (extended, scratchpad) — the rebuilt app at
http://localhost:8090, 42 PASS, 0 FAIL, no page errors. New checks: the
graph draws the picture's own distribution and it is the graph's first child
(`curve-hist`); the drawn band matches a histogram binned independently in
the page (256 buckets, worst deviation 0.00px); the distribution piles where
the curve put the tones (peak 128/255 after the black lift, against 9-246
before it); the card is dragged by its head (729,280 -> 689,190, the exact
delta); the drag bends no curve and drops no point; the card cannot be
dragged out of the stage (clamped to stage bounds); it is pulled back in
when the viewport shrinks to 900x640 (card 636,239 240x291 inside stage
269,109 615x429); the close button still takes the graph off the photo.
tone-curve-math.cjs — unchanged, 11/11.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10;
backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: the histogram is read from the render, so it is post-curve and the
band moves with the curve; read it pre-curve by exposing pass 3e's input if the
feedback ever misleads. The card's position is component state, so it resets to
bottom-left when the panel closes — persisting it across a close is a key on the
recipe, add it when someone asks for the card to stay put. Percentile and log
scaling are not implemented: linear max, the same as the overlay beside it.
|
||
|
|
e021f7d1ff |
web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.
Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.
`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.
The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.
On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.
The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.
Verified:
backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
drives the route end to end against the source: signup leaves a six-digit
code beside the link, a wrong code verifies nothing and leaves the account
unproven, a five-digit body is refused, a signed-out caller cannot type one,
the mailed code verifies, spending it spends the link, five wrong guesses
lock the code out and the right code then does not help, a resent letter
hands out a fresh code that is not locked out by the old guesses, and a code
aged past its quarter hour is refused.
otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
against the rebuilt app and api, no page errors: a fresh signup lands on the
verify face with the box ready, a wrong code says so and the account stays a
guest, the mailed code unlocks PRO, and a proven address is not asked again
on the next login.
Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.
ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
|
||
|
|
56d4b9df67 |
web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.
A recipe carries the curve as `adjustments.toneCurve`, an optional map from
channel to point list, `Partial<Record<'rgb'|'r'|'g'|'b', [number, number][]>>`.
The field is optional and the API stores the recipe JSON opaquely, so every
recipe and session written before this commit loads unchanged and simply has no
curve; nothing on the API or in the database moved.
The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into
a 256-entry table per channel and the shader looks the table up in a 256x1
texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup
has to come from a texture, and a table is the cheaper shape anyway — one
`lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between
points is a monotone cubic (Fritsch–Carlson) rather than a natural spline,
because a spline overshoots between two close points and that overshoot is the
classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic
through the points bends through them and never turns back on itself. The table
is built per channel and then composited through the master, the order the graph
draws it in, so an R point in the shadows survives an RGB contrast S and both
land where the lines say.
Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last —
after the stock, the matrix, the mixer and the seasonal grade, so a point placed
on the graph is the last word on that pixel. Preview and export both call
`renderPhoto`, so the two agree by construction rather than by two matching
implementations. The pass wraps whatever shader the pipeline had built
(`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts
towards `graded` for the same reason the tone shader does: the curve reads the
matrix's output, so when there is a matrix it has to be in the pixels the curve
samples. Turning the curve on costs one extra render pass and nothing else; off,
`curveIsActive` is false and the pass is not built at all.
That pass is also where this spent its time being invisible. The curve data
reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not
exist in the shim, so the call threw a TypeError inside the render, the preview
effect's catch swallowed it into `setError('err.generic')`, and the chip, the
graph and the recipe all looked healthy while the canvas kept the old frame. The
fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`)
and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists
on an explicit `colorSpace` where RN Skia's does not — everything this pipeline
builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape.
Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call
throws, the translated one returns a 256x1 image.
`ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no
zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost
behind a channel line so a channel edit is still visible against the other
three. Ends are pinned to x 0 and 1, a point cannot be dragged past its
neighbours (2% of the axis is the closest they may sit) and cannot be dragged
out of the square, so the graph can never describe a curve the renderer cannot
apply. One pointerdown grabs the nearest point inside 11px or adds one on the
line under the cursor and keeps dragging, so a click is a point and a drag is a
bend. Deleting a point is the graph's own double-click, not the circle's, and it
has to be: grabbing a point takes pointer capture, so the click that follows is
delivered to the SVG rather than the circle under the cursor.
RESET clears the whole graph, all four channels, and hands back an empty object
that `App.tsx` maps to `undefined` so the recipe drops the field rather than
keeping a `toneCurve: {}` — the field's presence is what "this picture has a
curve" means, and an empty map that means the same as no map is a state two
pieces of code would eventually disagree about. One undo step per visit to the
graph, the rule the ruler and the watermark box already ride: a drag is one
edit, not one per pointer move.
No new i18n keys: the chip and the panel labels are literal uppercase, the same
as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT
control like the rest of the tab.
Verified:
tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to
http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL,
no page errors. The ramp is a ramp before (9..246), a flat curve is two
points and no pass, the graph is drawn on the photo (graph 729,280 240x291
against photo 719,325 256x256), every stop of the ramp lands on the drawn
curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252,
a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the
R tab takes the graph over while the composite stays visible behind it and R
drives red at black to 255 with G and B still on the composite (133,132
against 132), the recipe carries toneCurve, it survives a reload (254 -> 254,
chip still amber), a click adds a point and a double-click removes it again,
RESET returns the ramp to its start (worst 0) and drops the field, and close
takes the graph off the photo.
tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own
arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the
graph says, a steeper segment never turns back on itself, a channel curve
runs before the composite, a click lands on the line, two points cannot
share a spot, an end cannot leave the axis, and the two ends survive a
delete where a middle point does not.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10.
web tsc --noEmit clean.
ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
|
||
|
|
cd9dd28adc |
web: widen the award column and bring it in beside the copy
The award card was a 340px column with a 46px gap to the copy, which left the photograph the whole point of the card small and the two halves of the hero visibly apart rather than one composition. The column is now 440px and the gap 24px, so the card is a third again as wide and sits closer to the headline. Measured on the built app at 1280px: .lp-award 440px wide (was 340), and the photo box inside it 410px (was 310) — 440 less the 1px border and 14px padding on each side. At 4:5 the image goes 387.5px tall to 512.5px. The copy's right edge is now 824 against the card's left edge 848, where before it was 902 against 948: the same 24px of air between them, but the copy's right edge moved 78px left and the card 100px left, so the hero reaches further across and the extra width is photograph rather than margin. The stacking breakpoint below 980px carried the same 340px cap, which would have made the card narrower than the column it replaces; raised to 440px to match. Measured at 900px: the card stacks under the copy at 440px wide. Verified: award-column-probe.cjs (scratchpad) — 18/18 on http://localhost:8090, with the width check now 440 and the geometry line reading copy.r=824 card.l=848. landing-test.cjs clean, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21, landing-photo-guard-test.cjs 21/21 — 0 fail against the rebuilt app. web tsc --noEmit clean. Dark and light themes both eyeballed on the built app (award-hero-dark.png, award-hero-light.png). ponytail: the width is a literal in two places — the grid column and the 980px cap — rather than a custom property, because the two are the same value today and a token would need the media query to read it too; when a third width shows up, lift both to --lp-award-w. The light-theme box-shadow is still the dark card's rgba(0,0,0,0.35); widening the card makes it no more or less wrong, so it is left as it was. |
||
|
|
3f5d2cd017 |
web: give the landing hero a column of the day's best-rated recipes
The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.
The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.
Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.
The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.
Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.
The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.
Verified:
award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
opacities, the 5s auto step, the manual step's clock restart, the one-frame
and no-frame windows. 18/18 on http://localhost:8090.
backend npm test — 166 passed, 0 failed, with four new checks in the ratings
section: a vote lands in today's and this week's window, a look: subject is
never an award, and a photo drops off the awards once deleted. The ratings
and photos suites cover the joins the new query leans on.
landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
web tsc --noEmit clean. Dark and light themes both eyeballed on the built
app (award-hero-dark.png, award-hero-light.png).
ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
|
||
|
|
81ed53cf78 |
web: anchor the watermark box to the photo's layout box, not its rect
Two zoom-only defects in the FRAME watermark boxes, both of them units mistakes
between what the stage measures and what the engine draws.
measure() read the box off img.getBoundingClientRect(). Zoom is a TRANSFORM on
the photo, and every layer above it (wm, crop, pick, compare, straighten)
carries that same transform itself, so a rect read off the transformed element
comes back already scaled and is then scaled again by the layer. It only shows
once measure() runs while the stage is zoomed, which is exactly what the zoom
itself causes: onStageZoom re-cuts the preview copy, the new src fires onLoad,
and measure() re-runs on the transformed photo. Measured on the built app (a
2560x1706 upload, a 955px stage, four wheel notches -> 1.749x): the layer came
out at -651,-796 2921x1947 against the photo's own 43,-241 1670x1113 — the zoom
printed twice — with the box at 809,951 while the mark sits at 878,757 and the
baked text at 882,765. The user's report exactly: the textbox not anchored where
the mark was placed, and its frame not drawn where the text is.
The box is now the photo's LAYOUT box (offsetLeft/offsetTop/offsetWidth/
offsetHeight, whose offsetParent is the relative-positioned .canvas-wrap), which
no transform can touch. Same run after: layer 43,-242 1670x1114 against the
photo 43,-241 1670x1113, box x 878 against the mark's own 0.5 x 1670 = 835 + the
handle, and the box at 878,757 against the text at 882,765.
The corner handle mixed the other way round. It read the pointer's travel as
(e.clientX - d.left) / d.width — a SCREEN distance over an UNSCALED width, and
with d.left the mark's own anchor rather than where the handle was grabbed. At
scale 1 the handle sits exactly one box width from that anchor, so f came out 1
and the drag read true; zoomed, the same pull arrives s times larger and the box
it grows was itself s times too wide. Measured: an 80px pull at 1.749x reported
size 1.503 and left the box 163 -> 420px where 163 -> 241px was asked for. The
handlers now take the photo's unscaled width and height plus the scale it is
drawn at (rect.width / offsetWidth) and divide the pointer's screen delta by
both, so a pull reads the same at any zoom, and the face size and the y
compensation it feeds are computed from the photo's own width — that is the width
the engine draws the fraction against. Same pull after: 163 -> 241px, the
top-left corner left where it was.
Verified:
wm-zoom-probe.cjs (new, scratchpad) — 2560x1706 upload, four notches of wheel
held on the handle's own corner: layer vs photo box, box vs the baked white
text, the box's growth against the zoom, then an 80px handle pull. 9/9 on
http://localhost:8090 (built asset index-BOwzIcQD.js). On the previous build
the same probe is 5/9 — the layer, anchoring and size checks above.
wm-test.cjs 23, wm-font-test.cjs 38, wm-font-registry-test.cjs 26,
wm-gps-test.cjs 27, wm-gps-date-test.cjs 18, crop-frame-test.cjs,
compare-test.cjs 25, compare-crop-test.cjs, zoom-test.cjs 25 — 0 fail at
scale 1, where the old arithmetic happened to be right.
web tsc --noEmit clean.
ponytail: offsetWidth/offsetHeight round to whole CSS pixels, so the box can sit
half a pixel off the photo's own box — under a box whose tolerance is the
dashed hairline, not worth un-applying the transform by hand; the day something
reads those pixels, compute the fit instead, as baseRect already does.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
438788e031 |
web (engine): print the grain from the phone's own module
The web port carried a third copy of the grain shader, so it would have kept printing the old static, unseeded field after the phone stopped. It now imports GRAIN_SKSL and grainCell from src/utils/grainShader.ts, exactly like the phone's export, so there is one shader and one size rule for the whole pipeline. The floor is one output pixel here, which is the whole story on this path: a CanvasKit surface is rasterised in the pixels it is given, so no dp-to-device correction is needed the way the phone preview needs one. Typechecked only — no harness in the tree runs this file; the deployed app (docker/frontend) has its own engine and already prints the new grain. |
||
|
|
3c8a1acc69 |
Give the grain one roll of film, sized to the picture
The camera preview, the library preview and the exported file each carried their own copy of the grain shader, and each sized it off the rect it happened to be drawing on. Two things were wrong with that. The field was a single value hash, so it looked like digital static rather than film: measured on the noise plane, neighbour correlation was flat and the lattice sat on the picture's own axes; and it was the same field in every session, so two photos from two launches printed identical grain. The size rule was wrong the other way round: canvas units in Skia are dp, so on a 3x phone the floor of one dp pinned a 390dp-wide preview to a THREE device-pixel cell while the file printed a 1080th of its width — 2.77x coarser than the picture it was previewing, which is the preview-vs-file mismatch all over again, just the other direction. Grain now lives in one module, src/utils/grainShader.ts, imported by the preview (both surfaces), the phone's export and the web engine, so the three cannot drift. The noise is three octaves of value noise over a density that is the mean of three hashes — a bell, the way an emulsion's density swings — turned 20 degrees off the axes, seeded once per app launch, so a session prints one roll and the next launch prints another. Only coarser octaves: a finer one lands under the pixel and the clumping is lost outright. The cell is one 1/1080th of the PICTURE's width, floored at one pixel OF THE TARGET — an export's own output pixel, a preview's device pixel (1 / PixelRatio.get()) instead of a whole dp. Measured on the plane itself with the real module (grainSize harness): the file at 4000px and a 390dp camera preview at 3x agree on the same fractional lag, rho 0.017 against 0.011, while the floor this replaces reads 0.329 — visibly coarser, as claimed; both keep the spread the AMOUNT knob was tuned on (sigma 57.8 / 57.9) and still clump (rho(1) 0.91 / 0.39); R, G and B never move apart, split 0. GRAIN_SKSL compiles on the Skia runtime, and the compile is now swallowed into null at module scope like the other three effects, so a shader this app cannot compile costs the grain rather than a launch. Not ported: the Kotlin grainOverlay probe (per-pixel, path is off by default). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
a5d195c5d2 |
Describe the app and the two builds it ships from
The repository root had an empty README: anyone landing on it saw the Expo project and the docker/ web build side by side with nothing saying they are the same product. This is the English description of RecipesCam — what the recipes are, the looks and adjustments, the geometry and finishing tools, and the on-device upscale — plus how the two builds relate and how to run each. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
3d273a3188 |
Add the B&W HIGH CONTRAST film sim next to ACRIPES
A second black-and-white stock, so it gets its own baseFilter rather than
borrowing Acros': the PHOTO STYLE chips are keyed by baseFilter and the two
greys have to 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). Every knob still opens 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, the R/B fine-tune) and the
chrome/hue-mixer gate can never half-apply to one of them.
Mirrors the web app's rail (
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |