videoskilletnotes Open the app ↗
On this page

Curating looks

This page is about which looks are worth keeping, and how anyone knows. The preset table is past 150 entries and the roll draws from all of them, so a preset that is not worth clicking is also a preset dragging every surprise toward mush. Same question one level down for the 285 controls — which are looks, and which are trims that belong behind fine: true.

Most of this is a working record rather than a conclusion. The screening harness, its blind spots, what the surveys found and what the eye added are the live half. A labelling system was also built to answer the same question with a model; it has barely been used, and the last section is what it would take to pick that up.

Measuring: the survey harness#

scripts/gpuprof/survey.ts runs the real pass graph headless in Deno and reads pixels back. No browser, so it costs minutes and nobody's screen.

deno run -A --config scripts/gpuprof/deno.json scripts/gpuprof/survey.ts \
  presets --source=detail
  sliders --base=vhs

Read the columns together#

Departure is not quality. It says a patch went somewhere, never that somewhere is worth going. Every list below is a shortlist to look at.

What the harness cannot see#

Four blind spots, each of which makes a real preset score like a dead one:

What the surveys found#

Presets that barely leave clean#

survey presets --frames=200, run twice — once on flat bars, once on --source=detail. A preset is only weak if it is weak on both; anything that moves one and not the other is telling you which kind of picture it needs.

presetgroupdep barsdep detailp99 detailmotion
vhsTape wear4.22.010.12.5
punchInCircuit bent4.33.111.01.0
fmFoldTape wear3.07.060.32.2
adjacentChannelRF / Broadcast8.93.413.31.9
stickyShedTape wear9.54.518.06.3
tapeCaptureTape wear8.76.827.58.1

Those six are the whole of it: every other preset clears dep 12 on at least one of the two sources. Four sit just over the line on bars alone and are worth a second look — tiredAmplifier (16.7 / 4.1), cbBreakthrough (12.8 / 6.4), colourLate (12.5 / 9.1) and broadcast (12.5 / 11.2).

Not all are candidates. punchIn is designed to sit still until you hit ⚡ and the harness never fires it. broadcast is a near-clean baseline on purpose. mixerLoop (8.4 on detail) is not in the list but would be on a third source: it is the README's hero shot, and it scores low only because a loop over a frozen frame converges.

What is left is one cluster: the quiet end of Tape wear. Four of the twelve entries in that family — vhs itself among them — move the picture less than anything else in the table.

fmFold and colourLate are the two to look at rather than cut. Both carry a p99 far above their dep: they do something violent to a narrow part of the picture and nothing to the rest, which is the signature of an effect that works and is scoped too small.

One caution on the detail source: the clean NTSC path already turns its multiburst and text bands to cross-colour mush, so a preset whose whole job is softening has less left to soften. Two disagreeing sources are a reason to look at the frame.

Presets that depart into nothing#

Caught by mean/sd rather than by dep — these leave clean decisively and arrive at a blank frame:

presetdepmeansdwhat it renders
chromaOnly98.90.10.3pure black
reversePolarity98.01.15.5all but black

Both render black on bars and on the detail chart, so it is not the source. chromaOnly promises "burst-locked color glowing on black" and renders black with no colour in it. reversePolarity sits next to negative, which inverts properly (mean 170). Both wanted a look before they wanted removing: a preset that renders black is as likely to be a bug in the path it turns on as a bad idea. (reversePolarity was the bug — ADR 0009 is the fix, and signalAndGroundSwapped is the look that came out of it.)

Near-duplicates#

Control-space distance (scripts/gpuprof/list.ts), normalized per slider travel. Closest pairs:

pairdistance
vhs / pictureSearch0.076
dirtyMix / dirtyDissolve0.090
strobeTrails / ladderClimb0.123
fbBloom / woundSpiral0.133
neonTube / blackRestore0.139

keyLoop and shadowLadder move the same five controls exactly (Jaccard 1.00) at different values, and render 3 dep apart. Distance in control space under-weights a slider with a huge range, so treat this as pairs to look at side by side rather than a similarity score to sort by.

The control table#

fmOverdev — the question that started this — measures dep 1.55, p99 71.2 at full travel on bars, which puts its mean departure below lumaMHz and its 99th percentile above most of the table. The frame says the same: at maximum it is a hairline of noisy black on the leading edge of a bright bar, a few pixels wide. The help text promises "a black comet" that "smears rightward for about a microsecond" and the streak's decay is ~10 samples, so the mechanism does what it says — the scale is what the prose oversells.

So it is a trim and is not flagged as one. Two ways to go, and they are different products: mark it fine: true and pull the prose back to what a few pixels of edge fringe is, or give the fold enough travel to earn the description (the ceiling only slides to 112 IRE at full knob).

A dep-only sweep put 40-odd controls under dep 2 from stock, almost all of them dependency-gated rather than weak — nothing in the camera loop moves until fbGain is up, and fmStreakUs cannot smear a fold fmOverdev is not making. That is what --base= is for, and the gated ones need re-running from a base that opens their path before any of them is called a trim.

One control whose top half is a black frame#

demodMHz runs to 6 MHz and stops producing a picture at about 3.3, from stock, on the default Y/C separation. Bisected on --source=detail:

demodMHz2.02.52.83.03.23.54.0
mean95.995.593.986.064.03.70.0
sd49.446.443.839.730.64.20.0

The trap is what eats it, and the ablation is clean: at demodMHz 4 the frame is mean 0.0, with the trap's subtraction zeroed (svideoBleed 0.5) it is 97.0, and with a 2-line comb doing the separation instead it is 96.6. At 6 MHz on a 3-line comb it is 96.2, so the whole range is fine wherever the trap is out of circuit.

The mechanism is honest as far as it goes. decode demodulates csrc(), which at combMode 0 is the raw composite with no bandpass ahead of it, so demodMHz is the only thing band-limiting the chroma path and past fsc/2 the demodulated "chroma" carries luma DC. The trap then subtracts the picture from itself. A real set has a chroma bandpass in front of its demodulator and a post-demod lowpass of about 0.5 MHz, which is the modelling gap: one control is doing both jobs.

Three ways out, and they are different products. End the slider where the mechanism stops (~3.5) and every saved board past that moves. Keep the range and say in the help what the top of it does — the help currently promises rainbows up there, and the frames show darkening rather than rainbowing from about 2.8. Or put a bandpass ahead of the demodulator, which is the real fix and moves every look in the library.

What the eye added to the numbers#

Screening rounds went in front of Colin as contact sheets, each candidate a playable strip rather than a still. These verdicts are worth more than the measurements, because they are the only signal about quality rather than about movement.

Round one, the cut list. Cut outright: chromaOnly and reversePolarity, the two that render black, and the quiet end of Tape wear — "many of the vhs settings are quite similar", "I don't want so many subtle things". Nine presets went. vhs stayed: it is the canonical one, and reading subtle is an argument for making it less subtle.

Six of fourteen random rolls were rejected as well, and the composition of the rejects says something the preset table alone does not: pastTheYoke is in three of the six and in none of the fourteen keepers, transmissionFault in two. Neither is subtle — they are patches that flatten whatever they are blended with. That is a roll-blending problem rather than a preset problem, and it is still open.

One piece closed later from the other end. pastTheYoke was the only preset holding bendShape on ripple — a sine down the whole frame at a wavelength nothing in the picture sets, which reads as a grating laid over the raster rather than as a scan going wrong. It bows now, and ROLL_NEVER_LANDS (mutate.ts) keeps every roll off that shape unless the board is already on it. What made the blend worse than the preset: bendShape is an enum key, so a follower at weight 0.25 handed the shape over whole while the amplitude around it scaled down.

The same session turned up the neighbouring one. Every preset using the HV tank was authored between 0.8 and 0.9 on hvRing, and the dial is steep up there: damping ratio 0.66 at 0.5, where a bright edge overshoots once and settles within seven lines, against 0.32 at 0.9, where the wobble is still going half a cycle later and the next line of content kicks it again. Stacked under a roll it stopped reading as a supply under load and started reading as the picture sliding about. The four presets carrying it as texture under something else came down to a light application; the three it is about kept their tuning, and ROLL_STAYS_UNDER holds a roll to 0.6 ring and 12us sag.

Round two, the loops. Seven of fourteen feedback candidates kept: zoom bloom, tunnel out, spiral, subcarrier comb, ring loop, servo warp, both loops. The pattern in what survived is sharper than any number here produced. Geometry that accumulates and colour arithmetic were kept; ringing, blur, texture and stutter were not. Both keyed resonators went, and they were the strongest prior going in. both loops was kept at dep 26, the lowest departure in its sheet, while iris hunt was cut at 106. Departure did not order these at all.

So propose looks that change where the picture is (zoom, rotation, timebase pull) or what colour it is by arithmetic (subcarrier delay, ring modulation). Do not propose looks whose content is texture — a blur, a ring, a grain, a stutter.

Round three, the controls nobody had used. Counting control names across the table found seventy-five appearing in no preset: the whole chyron and caption family, the PiP inset, every per-feed cable fault, tint, the hard polarity flip, Y/C delay, adjacent-channel leak, the raster underscan. scripts/candidates.round3.mjs screened one candidate per family and retuned a dozen; fourteen shipped, under a new Switcher group for the four that are about a box at the switcher rather than the mixer.

Three things that round established about the harness itself. The scorer was blind to hue — a tint walked round the whole wheel scored still — so it now carries a colour-motion term beside the luma one. Under Chrome it read a black canvas at half its checkpoints until it stepped and read in one task. And a preset that turns on the caption decoder or the chyron does nothing on a bare board, because the caption is blank until somebody types, so those chips seed the teletype default when the caption is empty.

One retirement: dirtyDissolve was dirtyMix with A pulled halfway down, and cleanDissolve already owns the dissolve. pictureSearch stays despite the 0.076 distance from vhs — the distance under-weights shuttleX's range, and cueing at 5x is a different mechanism rather than a retune.

Six findings that generalise#

Ring modulation does not make rainbows#

Worth writing down because the name promises otherwise, and because six candidates were built on the assumption before anything was rendered.

cfbRing multiplies the loop bus against the live program. Both signals carry their subcarrier on the same crystal, so the products land at the sum (7.16 MHz, above the chroma passband) and at the difference (DC, which is luma). The chroma filter discards the first, and the second is brightness. Six candidates varying the detune, the line offset, the chroma trap, the demodulator axis and the comb rendered within a point of each other as the same desaturated grey-blue wash — and pulling the crystal 60 kHz did not change it, because 60 kHz off 3.58 MHz is still DC as far as the passband is concerned.

Three routes do make colour by multiplication, by putting the terms at genuinely different frequencies so the difference lands back inside the chroma band. bRing with bDetuneHz — B's subcarrier against A's, kilohertz apart — gives saturated bands, though at bGain 0.55 it renders at mean 209, which is blown out. The synth oscillator near 3.58 MHz over the picture translates luma up into the chroma band, so brightness arrives as hue; strong, but one hue at a time. And cfbRingSrc, which is the finding above turned into a control: the ring modulator had one input on the loop and the other wired permanently to the program, and the program is the one signal guaranteed to be on the same crystal as the return. Put an oscillator on that input, detune it with cfbCarrierKHz, and the bridge is an encoder's chroma modulator — the return's brightness translates up into the chroma band and its colour translates down into brightness, so a lap swaps the two.

Measured with scripts/colourcheck.mjs on clip-haunted-house, a 1929 film, so a clean arm reads sat 0.018 and any hue on screen was manufactured by the chain:

armsathuescolour%
clean0.01800.0
loop, no ring0.02000.1
ring on program0.00800.4
ring on oscillator0.265351.6
oscillator +12kHz0.3771049.7
oscillator +120kHz0.2821246.1

Against the program the ring mod takes away the little colour the chain had (0.020 down to 0.008). sat and hues disagree on which detune is best: on frequency the invented colour lands on one phase, which is why three sectors hold it, and the detune is what turns it into a wheel.

A colour claim measured on a saturated source measures nothing. The same sheet on clip-test puts every one of those arms between 0.29 and 0.39 against a clean 0.487, so the mechanism that makes colour out of nothing reads as one that slightly reduces it. This section fell into that trap once already.

The finding survives the rest of the rack. A later round (scripts/gpuprof/candidates.ringloop.ts) put the multiplier beside each of the four other boxes on the mixer-loop card — the Y/C separator on the return, the read clock, the resonant network and the varactor — on the theory that one of them would give the program-side multiply something to work with. It does not matter what else is patched: the two arms that kept cfbRingSrc on the program rendered at csd 2.5 and 7.3 where the five that shipped read 16 to 42, and one of them is the fine grey mesh with the picture gone that round one cut on sight. The crystal is the whole of it. Both inputs on one and the products land where the chroma filter throws them away; put the box's own oscillator on the second input and the same patch manufactures more colour than anything already in the group.

Read csd and not sat for this now — looplock.ts prints it beside sd, and it is the number that separates a loop drawing in hue from a loop drawing in grey without needing a monochrome source to do it.

Chaotic is not the same as wild#

The correction that cost the most to learn. Three rounds came back "very subtle", so the next round stacked everything: both ring modulators, both loops run hard, sync marginal, deflection past the supply. Those scored motion 105 — five times anything kept before — and the verdict was "too chaotic ... they need work".

What survived instead: runaway at motion 33, sync in the loop at 14, lorenz loop at 14, strobe bloom at 14. Everything at 55 or above was cut. So the axis is not amount of movement and certainly not entropy: what reads as wild is large, coherent structure that evolves — a loop crossing unity and bleeding back, a stack of roll seams at different ages, an echo whose spacing never repeats. High-entropy hash reads as noise however energetic the numbers say it is.

Read motion as a rank within one sheet, never as a threshold. The absolute figures scale with frame spacing (--video reads adjacent frames, a stills strip reads every fourth) and with the source; what survives re-measurement is the ordering. And it is measured between adjacent frames, so a slow sweep reports as motionless: a hue rotation at 0.2 Hz moves 1.2° a frame and scores under 3 while cycling the whole wheel in five seconds.

The routings were never applied#

The largest correction here, and it invalidates numbers in the tables above rather than adding to them.

sheet.ts grew the ability to drive the modulation bay, and every part of the plumbing landed except one line: the candidate loader built its items without copying mod off the spec, and its inline cast did not mention the field, so tsc had nothing to object to. The local name for the imported spec module was mod, which is how it went unseen. Presets went the same way, and that is the more expensive half: 23 of the 84 carry a routing, including every feedback look in the table above.

survey.ts had the same hole and is now wired the same way, which matters more — it is the script the cut lists were drawn from. Driving the routings moves 21 of the 84 presets:

presetdep restingdep driven
sync in the loop22.1567.24
spiral25.4768.24
subcarrier comb13.2043.21
both loops11.0329.05

both loops at 11.03 sits below the dep 12 line this page uses to call a preset weak, and subcarrier comb at 13.20 sits just over it; both are in the thirties and forties with the LFO they ship with running. A resting-frame survey systematically under-reports exactly the family whose movement lives in the routing, which is the family the last two rounds were curating. runaway moves the other way — its whole description is a gain walked past unity by an LFO, and driving that LFO drops its motion from 37.7 to 4.9, because the sweep spends half its time below unity.

The cut list already applied is unaffected: the six presets on it carry no routing. --nomod renders any sheet at its resting frame, which is both the ablation for "is this the patch or the LFO" and the way to reproduce anything measured before the fix.

A routing based at a slider end does nothing#

The bay's LFOs are bipolar: a routing swings ±depth × travel around wherever the control rests. So a control resting at its own minimum loses half its excursion to the clamp, and a slow routing loses all of it — the triangle is 1 - 4|ph - 0.5|, which starts at −1 and takes most of a four-second clip to climb back to zero.

synthHueDeg runs 0–360 and defaults to 0. A candidate routed a 0.06 Hz triangle onto it at full depth and rendered identically, to two decimal places, with the routing driven and with it off: the control sat clamped at 0 for 96% of the run. As shipped the picture held R170 G199 B15 from the first frame to the last; based at 180 the same patch walks green → cyan → blue. This was previously written up as a level problem — the field was blown to near-white, and rotating the hue of near-white does nothing — and that was wrong. The hue was not rotating at all.

Runner.run now warns when a routing spends more than a quarter of the run against an end, naming the base and the range. It catches bDetuneHz based at 1500 in a ±3000 span as well, at 28%. None of the 23 presets trip it.

The two failures of a feedback look#

"Not dramatic enough" and "too chaotic" were reported about the same family within a minute of each other, and they turned out to be one setting apart in opposite directions with almost nothing in between. Measured with scripts/gpuprof/looplock.ts, which reads the sync separator's own per-line verdict beside the picture — the number a contact sheet cannot show, because a frame torn into displaced slabs and a frame full of coherent structure both score high on departure. Across the 44 looks in the group: 16 had the separator finding under 30% of lines (motion 35–112, raster rolling), 15 had the loop contributing under 32 (motion 5–11, the source slightly soft), 13 sat between.

The mixer loop crossfaded the sync tip. fb_composite faded the loop return over every sample of the raster, blanking interval included. So a delay of a microsecond put the previous frame's sync tip a dozen samples inside the line, the live tip was faded 85% toward whatever active video the return carried there, and sync_measure stopped finding a falling edge in its hunt window. The flywheel free-ran, its phase noise grew with the age of the last real edge, and the structure the loop had spent a second building was thrown across a raster no longer under it. Nothing about that came from the loop's own settings. The rack's answer is a frame synchronizer: a store genlocked to house reference writes its own sync and burst on the way out. That is cfbGenlock, and with it at 1 every look in the group returns lock 99.8, age 0, vroll 0 — which is what then allows cfbMix 0.9+ and multi-microsecond delays. meltdown keeps the bare cable, because losing the raster is what it is for.

The camera loop's round trip is the mix times the gain. fbGain's help called unity "the knife edge where patterns persist indefinitely". It is not: compose crossfades, so the round trip is fbMix × fbGain, and every camera-loop preset was authored against the wrong number. zoomBloom at mix 0.62 and gain 1.07 ran a round trip of 0.66 — a three-frame smear rather than a loop. Swept at fixed mix, its loop contribution goes 22 → 35 → 89 as the product crosses 1, with lock untouched. The camera loop sits ahead of the encoder and cannot reach the sync path at all, which makes it the half of the family that was free to be pushed.

The gain only pays off inward. Above unity the direction of the transport decides whether there is a picture:

zoomround tripresult
1.021.08white-out, mean 242 sd 8
1.081.08white-out, mean 243 sd 8
1.0451.00white-out, mean 224 sd 22
0.931.13tunnel, mean 66 sd 71
0.9551.10spiral, mean 81 sd 80

An expanding loop spreads what it gains over the whole raster and pins it; a collapsing one concentrates it into a shrinking core while the surround is refreshed from the live picture every lap, so it holds a high-contrast frame well past unity. tunnelOut and spiral are now that. Everything else that expands stays under unity, and crtCutoff in a camera loop is not an option at all — 0.22 of it takes the loop to black in under a second.

Three cautions on reading that group's numbers:

The group boundary is not where the loops are. The data test written to hold this line caught six more presets running the same displacing mixer loop from other groups. Three keep the raster without help — keyIntoTheLoop at lock 100, bentEnhancer 99.8, howlroundLoom 99.1. Three do not, and two say so in their own blurbs. The sixth, twoMultipliers, did not: its blurb is about two balanced bridges in series and a spectrum reorganising itself, and it was running at lock 0, motion 82, deep in the band this page cut as chaos. Genlocked it holds the raster at motion 8.8 with the highest chroma spread of anything measured that afternoon. The list of presets allowed to run a bare cable is now explicit in presets.test.ts, split into the ones that tear on purpose and the ones measured to hold without it.

The camera loop is a whole generation, and a tape fault does not belong in the mixer loop#

Two results from one round of screening, and they are the same result read from both ends. The round started from a coverage count rather than a hunch: sixty of the 285 controls appear in no preset at all and eighty in exactly one, and almost the whole of the unused set is the receiver and the screen. So the question was which of them is worth putting inside a loop.

Where the two loops tap decides which half of the rack is inside them. The mixer loop stores comp at the end of the frame and injects it at fbComposite, so its round trip is the channel block, the timebase and the outboard enhancer — the tape and RF faults. The camera loop's return re-enters at compose, ahead of the encoder, so its round trip is the encoder, the channel, the receiver and the tube face: a complete encode/decode generation, with every fault between those two points applied once per lap.

Nine arms put a channel fault inside the mixer loop — sticky-shed shear, picture search, the dropout compensator, the tracking servo, multiplying hum, ingress, the enhancer's own resonator, a clogged head, the colour killer's threshold. All nine came back grey: csd 4.9 to 13 against 28 to 49 for the camera-loop arms rendered beside them, and the frames are the fine mesh and the soft grey bands round one of the feedback screening already cut on sight. Several were also washed out (mean 109 to 127), because additive damage inside a loop is something the loop averages rather than something it develops.

That is the same shape as the cfbRing-on-the-program finding above, and it has the same cause: the mixer loop's colour comes from its own card — the delay's rotation, the read clock, the modulator's oscillator, the Y/C split — and that card has been mined. A tape fault dropped into the loop beside it adds noise to a mechanism that was already the whole look.

The camera loop had eight presets against the mixer loop's thirty-five and is where the room was. Measured over 240 frames on bars, against the group's existing keepers:

lookloopgrowsdcsd
colourKeepsWalking (Y/C delay)93.355.998.517.3
encoderWiredBackwards (invert)85.838.051.14.6
beamBendsItsOwnScan (HV sag)83.434.229.85.6
flagOnEveryLap (scan flag)80.539.449.511.5
handOnTheChassis (paperclip)73.660.071.218.6
shearedEveryGeneration (demod axis)70.063.874.019.5
wheelBehindTheSubject (tint)41.781.567.234.2
spiral, for scale90.257.488.119.3
tunnelOut, for scale67.674.789.928.7

Seven shipped. All seven hold lock 99.8, age 0 and vroll 0 — the camera loop sits ahead of the encoder and cannot reach the sync path, which is what lets it be pushed where the mixer loop needs cfbGenlock first.

Those are the numbers after the retune below, and the retune is the part of this round worth reading.

A chroma trim in a camera loop is a chroma trim per generation. All eight shipped with chromaGain 1.2-1.6 and five with crtSat 1.2 on top, and the verdict on them was that they were garish — "somewhat absurd chroma colorings ... just too extreme". They were. decode and crt_face are both inside the lap, so those two knobs apply once per generation: 1.6 over five laps is a chroma gain of ten, everything walks to the primaries and sits there. In the mixer loop the same knobs run once, outside the lap, which is why ringLoop, theWrongClock and colourInTheDark carry 1.2 to 2.2 safely — and copying that house style across was the whole mistake. The library's own optical loops already knew: spiral, tunnelOut, zoomBloom, fbBloom and woundSpiral set no chromaGain at all, and 1.3 is the highest on any of them.

Three things fell out of taking all three trims off:

The screening ranked on csd, which is the quantity the error inflated. csd is in looplock to stop a colour look being cut for reading flat on a luma-only sd. It is a rescue, not a score. Ranking on it sorted the arms with the worst case of the per-lap gain to the top, and they were then tuned further toward it. The same sentence this page already carries about dep — departure is not quality — applies to every column in it, csd included.

An affine transport gives a tunnel; a picture-dependent one gives shapes. The sharper half of the round, and it is about the geometry rather than the fault. Zoom, rotation and shift are the only transport a camera loop here had ever been given, and all three are affine, so a generation lands scaled and turned and the fixed point is a tunnel or a spiral. Seven arms with seven different faults in them all rendered as the same saturated radial starburst, because seven arms ran the same collapse at zoom 0.955-0.97. Vary the transport and the mechanism becomes visible again: hvSagUs bends the scan by an amount the picture sets, so the displacement field is the picture one lap back and the loop finds ribbons that wander; bendUs on the flag shape adds a skew where a zoom would have multiplied one, and builds an arch. Neither is reachable from the fb controls at any setting.

Two traps in measuring that family:

What was cut, and why, since these are the near misses:

A follow-up round on the self-bending half returned one preset out of nine, and its negatives are worth more than its positive. The property being chased is narrow: beamBendsItsOwnScan is interesting because the displacement field is the picture one lap back, where flagOnEveryLap beside it is a fixed shape that merely accumulates. Nine arms tried to find more of that.

What shipped is supplyWiredBackwards: the same tank with hvSagUs negated. The sag is a horizontal displacement per raster line, so the sign flip mirrors the field, which on a still picture is the same bend reflected. In a loop it changes what the field does to what it built — the structure changes axis, from horizontal ribbons to a curtain of vertical columns, at loop 92.7 against the forward bend's 83.4. The columns need the depth: at hvSagUs -42 they dissolve into soft horizontal bands, so the light it loses is bought back with gain (1.36 against the sibling's 1.28) rather than with a shallower bend.

Both harnesses grew what this round needed. looplock.ts --spec= reads a candidate file in sheet.ts's format, so the two columns that decide a feedback look can be read before it is authored rather than after. And Runner.run drives the paperclip (signal/clip.ts), which lives in Engine.applyClip and so had never reached the headless graph — a look that rests just short of trouble and is thrown by a contact rendered as the board it rests on.

A camera loop at unity zoom wants a gain of about one#

The camera page wanted loops with almost no geometry: a zoom within a percent of unity and under a degree of turn. The table had none, and scaling a dramatic one down walls out to white, because its gain was tuned for a zoom that spreads the light each lap. aimedAHairOff, turnedAHair and theIrisHoldsTheEdge came out of four sweeps on the camera page, with public/sample.jpg panned slowly as the camera. Motion is the input that matters here, because at this geometry a loop only shows where the picture moves.

A still pixel settles at (1 − fbMix) / (1 − fbMix × fbGain) of its own level. At mix 0.65 and a round trip of 0.85 that is 2.3, and every white in the frame clipped. At gain 1.0 it is 1, less what the black cut, the vignette and the lens take each lap, which left the frame at a mean of 91 to 102 against 111 unlooped. At 1.04 to 1.06 the loop holds within ten percent of the picture's own exposure. The mix then sets only how long a moving edge's echoes last. At 0.9 the live picture is a tenth of each frame and the subject smears into the loop; 0.7 to 0.8 keeps a face readable. The lens at fbFocus 0 keeps the echoes as copies, where the default 0.7 softens them into glow.

The auto-iris is a second way past unity. An expanding loop over unity walks to white, and presets.test.ts forbids one. With fbIris 0.5 metering it, a static round trip of 1.05 at zoom 1.01 held a mean of 145 to 148 for twenty seconds, with the contrast intact. The iris closes as the picture brightens, which puts its own gain inside the round trip. The test now lets a loop past unity when an iris meters it.

Labelling: the collectors#

Barely used, and kept as reference. The idea was to answer "which settings are cool" with a model rather than with surprise's uniform roll, which needs labels. Three collectors were built and the dataset never got past a few sessions, so nothing here has been fitted. What follows is enough to pick it back up or to delete it on purpose.

All three write into one dataset with a provenance field, so a fit reads them together and can slice any of them back out. Nothing leaves the browser until somebody signs in; a signed-out session queues to localStorage.

Four rules that would still apply to any replacement.

The search space is a recipe, not a board: a sparse weighting over the ~70 authored presets that blendPresets expands into the full ~215 controls — the same space randomPresetMix samples. That is what makes it tractable, since no preference model fits 215 free dimensions from a few hundred votes. Sampling is seeded, so a label can be re-rendered.

Two collections, the shape a Bradley–Terry fit wants:

/candidates/{id}   { v, id, seed, kind: 'mix'|'anchor', weights, query, by, sat }
/votes/{auto}      { v, a, b, choice: 'a'|'b'|'skip'|'neither', ms, seed, source, at, by, sat }
/ratings/{auto}    { v, tagSet, look, query, weights, preset, provenance, tags, cool, ms, source, at, by, sat }

look is a hash of the resolved board rather than of a recipe, because a look dialled in by hand has no recipe and both collectors have to land in one key space. query is the packed #p= form, so prefixing the origin makes any row openable in the instrument. a/b are in the order they were on screen, so a left-hand bias is measurable after the fact; ms is deliberation time; by and sat are pinned by the rules, so a client cannot forge who voted or when.

Export is an admin job, because the rules allow get and not list — no signed-in client can enumerate these collections.

node scripts/labels.mjs [outDir=labels]

It writes the three collections as JSONL plus ratings.csv (tags as one-hot columns) and ratings_weights.csv (long form, one row per rating × preset × weight — the design matrix that separates "worn tape is dreamy" from "the look it happened to appear in was dreamy"). It authenticates from GOOGLE_APPLICATION_CREDENTIALS or from whatever firebase login left on this machine, and FIRESTORE_EMULATOR_HOST points it at the emulator, which is how the flattening is tested. pnpm test:rules exercises the rules boundary.

How many labels would be enough, from node scripts/affinity.mjs simulate — it generates ratings from a known affinity, fits, and measures how much came back:

ratings   tag r    cool r
    100    0.23     0.65
    200    0.25     0.80
    400    0.39     0.87
    800    0.49     0.92
   1600    0.66     0.96

cool is usable at ~200 ratings; the tags need something like 1600, because cool is 1-5 and every rating carries it where a tag is one noisy bit that is mostly absent. An earlier estimate of "about 150 for everything" was wrong by roughly 10x for the tags. So surprise: cooler is an evening or two of rating away and random → dreamy is a much longer haul, and a small, high-prevalence tag vocabulary is worth more than a broad one. Coverage binds before volume: each roll names 2-3 presets, so ~150 rolls gets each of the ~70 presets seen about five times.

The fitter is verified — against a noiseless target it returns r = 1.000 at every sample size, so those numbers measure noise rather than bias — and that check is what caught its one bug: it centered the target but not the columns of the design, leaving no intercept to absorb the base rate. It did not look like a bug; it looked like a hard problem needing more data.

If anyone picks this up, the shape it was aimed at: a heuristic viability filter on image statistics (black, blown out, frozen, flat) as the baseline the learned model has to beat and the pre-filter that stops humans voting on garbage; a warm start with no votes at all, since the 70 authored presets are "cool" by construction and turbo-mutate rolls mostly are not; a Bradley–Terry head over the preset-weight vector plus frozen vision features; active learning on the pairs the model is least sure about; and CMA-ES over preset weights with the model as fitness, which would be the new surprise button.