Skip to content

Feat/scene camera - #83

Merged
davicorrea0 merged 26 commits into
appariciojunior:mainfrom
davicorrea0:feat/scene-camera
Sep 21, 2026
Merged

davicorrea0 merged 26 commits into
appariciojunior:mainfrom
davicorrea0:feat/scene-camera

Conversation

@davicorrea0

@davicorrea0 davicorrea0 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

A scene can now be filmed: framed from somewhere other than straight on, and
moved over the clip. Three composes ship that use it.

Picking a move, not building a path

The camera is a choice, not an authoring surface. Push in · Pull back ·
Cross · Drift · Survey, each with at most three knobs: how far, which way, and
how many places it settles at.

That shape came from reading the reference tool rather than from taste. Its
store carries 204 families and the section titles across all of them are
Scene, Animation, Physics, Style, Proximity, Effects and Path — and that Path
is pathStretchX/Y/Tilt, the cards' path, not the camera's. Its entire
camera surface is static sliders folded into each template: perspective in 99
families, offsetX/Y in 137, distance in 78, rotationX/Y/Z in 62, orbitRadius in
41, zoom in 4. Where a different viewpoint matters it is named and one
click
: cameraView, a toggle between side and down, in 9 families.

They never ask anyone to author a camera path. An earlier revision of this
branch did, three times over, and each one was reported as confusing.

A move is a recipe, not a second runtime: choosing one writes the same
_camStopN keys hand-editing writes, so the engine underneath is untouched and
the path stays editable. Touch it by hand and the move becomes Custom,
because it has stopped being a true description of the stops.

Hand-editing lives behind the panel's own advanced disclosure: pins on a grid
of 35 cells
, with + Pin and Reset. Cells rather than a continuous pad
because a continuous surface asks you to aim, and the grid takes the tightest
range that holds every pin — 10, 25, 50 or 100 per cent — so a small move
spreads out instead of piling into the middle.

What the motion is worth

Measured against a reference clip tracked frame by frame:

Clip Ours
Frames that sit still 0 of 209 0
Slowest frame vs fastest 22× 18× single-leg, 32× on a six-stop tour
Fastest moment of a leg 50% of it 50%
Leg duration vs distance corr. 0.87 proportional

A leg gets time in proportion to how far it goes, so the camera holds one speed
across the path. Easing is smoothstep backed off by 7% at the ends, so a stop is
a slowdown rather than a dead halt — pure smoothstep made a three-leg clip stop
88× slower than its fastest frame.

One trap worth keeping: that ratio depends on how densely you sample it. The
same Survey reads 69× at 120 samples and 32× at 210. The suite samples at 210,
the clip's own frame count.

The bug worth reading

The 2D renderer culls a repeating motif's offscreen copies, correctly, but it
compared each card against the canvas. With a camera the frame stops being
the canvas, so pulling back culled every copy the camera had just moved to look
at. Measured at 50% zoom: 625 cards laid out across 5282px, container bounds
covering the whole screen, alpha 1 on all of them — and renderable false on
every card past the canvas edge. Five columns in a sea of background.

The wall appeared to shrink instead of opening up, which is exactly what too
small a wall looks like, and is why the layout got blamed for a long time. The
cull asks sceneCameraFrameRect now. At neutral that returns {0, 0, width/2, height/2} — the literal expression it replaced — and there is an assertion for
it, so nothing already shipped moves.

Opt-in, and consistent with the app

A scene has no camera until you add one. Whether it has one is a stored fact
(_camOn), not something inferred from the values: a camera has to start
exactly where the default one stands, so no reading of the numbers can tell
"just added" from "never added" — that bug made the Add button appear dead.

Only what is not already there: 67 of the 82 webgl presets declare their own
zoom, 61 their own offset, 31 their own yaw. A house control appears only where
no visible template offers the same move — 260 control/preset pairs hidden as
duplicates, 150 offered.

Every row is the panel's own ControlRow. A revision of this branch invented a
button grid, a chip strip, a disclosure and two pads — all lookalikes of things
the panel already had, which is what made the camera read as if it came from a
different program. 6.3KB of bespoke CSS was deleted getting back to them.

Composes

The Composes tab could only ever be empty on a fresh install: it read
localStorage and nothing else. Three ship in the repo as ordinary presets with a
reserved id prefix, and carry the shot, the path and the ground they were
composed against — the gutter between cards is the background. Contact Sheet
is the reference clip's own path, its stops negated from the measurement because
tracking gives you how the picture moved while the pad says where the camera
points.

What is asserted

  • 2847 framing cases: neutral is exact, a dolly keeps its aim, an orbit keeps
    its radius, a pan never moves the camera.
  • Every move at 1, 2, 3 and its ceiling: the stop count is honoured exactly, it
    survives a save, and no stop sits on top of the one before it.
  • 17075 cards of Frames against a committed baseline — 7 presets, 3 scenes, 5
    frames — so the family provably did not drift.
  • The full existing suite: 3,506,182 assertions. tsc --noEmit clean.

Two controls were built for this branch and then removed: reading the reference
wall as a collage of different sizes was wrong twice over. It is one size, and
the variety is the camera. templates/frames.ts is identical to main.

🤖 Generated with Claude Code

Davi Correa and others added 26 commits September 10, 2026 12:09
A webgl template may pose the camera through `camera(values, ctx)`, and 72 of
the 82 webgl presets in the catalogue do. What nobody could do was move the
camera THEMSELVES: the pose is a function of the template's own controls, so
every preset had exactly one framing. `zoom` existed in this app only for media
crop, the Mockup screen and the WebStage's own UI chrome — nothing aimed at the
scene.

Five controls, in a new "Camera" section, composed ON TOP of whatever pose the
template asked for rather than replacing it:

  Zoom      a dolly — divides the camera's distance to its target, so the
            subject fills more of the frame at the SAME fov. Widening the lens
            is a different move and already belongs to each template's own
            `perspective`.
  Pan X/Y   trucks camera and target by the same vector, so the view direction
            never changes. On a scene with depth this is not the picture you get
            by sliding the layer: near cards shift more than far ones.
  Orbit X/Y swings the camera around the target on a sphere, pitch then yaw.

No roll, deliberately: rolling a pinhole camera about its optical axis gives
exactly the image you get by rotating the finished frame, and the track
transform already has `rotation`.

Neutral is neutral to the bit. Every default reads back as the identity, the
composition is skipped entirely when nothing was touched, and the far plane only
widens for a shot that actually moved — so an untouched scene keeps the same
depth precision it had before this commit.

Storage is `_cam*` keys inside the track's own `values`: no change to
MotionTrack, no migration, and a saved scene without them reads as neutral.
The section is gated on `meta.engine === 'webgl'` — a 2D track is composited
through an orthographic view where none of these moves would do anything, and a
control that does nothing is worse than a missing one.

Measured, not assumed (scripts/_probe_scene_camera.cjs, real Chrome, stage
pixels, playback paused so every reading comes from one frame):

  Spinner 01     silhouette px    box        centroid
  neutral        37.865           172x308    410,544
  Zoom 200%      148.806          382x626    419,553
  Zoom 50%       10.571            82x155    406,541
  Pan X +30%     39.330           202x308    656,544   <- +246px, 30.4% of 810
  Pan Y +30%     40.414           172x331    410,868   <- +324px, 30.0% of 1080
  Orbit Y 45     47.435           343x316    405,544
  Orbit X 40     32.183           172x318    408,538
  neutral again  37.865           172x308    410,544   <- identical, to the digit

Ticker Tilt (one of the 10 webgl presets with no `camera()` of its own) responds
the same way. arc-01, a 2D preset, shows no Camera section at all.

scripts/verify-scene-camera.cjs: 1.800 framing cases — neutral is exact, a dolly
never moves the aim, an orbit never changes the radius, a pan never changes the
view direction and lands within 1e-9 of the requested fraction of the frame.
`npm test` green, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bug found by measuring the multi-layer case, which the first commit never
tested. The camera values lived in the active track's `values`, so a scene with
two layers was reframed one layer at a time:

  two Spinner layers, Zoom 200%     silhouette      box
  active layer   before -> after    13.789 -> 33.993   105x182 -> 136x363
  other layer    before -> after    13.828 -> 13.828   104x182 -> 104x182

The other layer did not move by a single pixel, and the project autosave read
`t0:{_camZoom:200} | t1:{}`. Two layers composited from two different camera
positions are not one picture — a camera is a property of the SHOT.

So the shot moved to the scene: `sceneCamera` on SceneState, persisted with the
document, and read once per frame by every track's camera. The panel section
moved out of the per-layer block (which is titled "Editing the motion of
<layer>") and became a scene-level section of its own, between Scene and Layer,
with a Reset button. It is shown when ANY visible track is webgl — the same
condition PreviewStage uses to pick the webgl engine, so the control is present
exactly when it does something.

Same measurement after the fix — both layers now move, and identically:

  active layer   13.206 -> 32.262   103x184 -> 134x366
  other layer    13.181 -> 29.694   103x184 -> 134x366
  autosave       cena:{"_camZoom":200,...} | camadas: t0=0, t1=0

(The remaining difference in pixel count is the right-hand layer running off the
canvas edge at 200%, not a difference in framing: the boxes are identical.)

Two more things this buys, both from `sanitizeSceneCamera`:

  · A project saved before the shot existed opens NEUTRAL instead of inheriting
    whatever framing the previously open project had — hydrate rebuilds the
    field rather than letting `...partial` leave the old one in place.
  · Every value is clamped to its own control's range on the way in. Not
    hypothetical: a slider in this app once stored 3405 on a control whose max
    was 360.

Also fixed in passing: the framing survives a template switch now (it is no
longer part of the track's `values`, which `setActiveTemplate` replaces), which
is the better behaviour — same shot, different motion.

scripts/_probe_camera_layers.cjs is the probe that found this, kept so the
multi-layer case stays covered. verify-scene-camera.cjs grew the storage half:
garbage and stale keys read neutral, out-of-range values clamp, and the
sanitizer hands out a fresh object every call — the autosave decides a document
changed by comparing these fields by identity.

`npm test` green (1.800 framing cases in this suite), `tsc --noEmit` clean, and
the single-layer probe re-run unchanged: Pan X +30% still moves the centroid
+246px on an 810px canvas, and neutral still returns to the digit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read how the reference actually does this instead of guessing. Its scene camera
exposes `distance` (camera z), `perspective` (a focal length in percent, turned
into fov by 2*atan(h/2/focal)), `rotationX/Y/Z` — which rotate the RIG, not the
camera — and `offsetX/offsetY`, which it implements as

    camera.setViewOffset(w, h, -(offsetX/100)*w, -(offsetY/100)*h, w, h)

That is a lens shift: the film gate slides across the projection and the camera
never moves. My first pass trucked the camera instead, and the measurement says
the reference is right for a control whose job is REFRAMING.

Spinner 01, Pan X +30%, silhouette box:

  neutral                       168 x 326    (37.191 px)
  truck (what this replaces)    201 x 312    (30.674 px)   <- reshaped
  lens shift (this commit)      168 x 326    (37.192 px)   <- translated

Both edges of the box moved by exactly +243px, 30.0% of an 810px canvas, and
neither dimension changed. The truck widened the box by 29px because moving a
camera off axis adds keystone — it re-shapes the picture when the person asked
to re-centre it. A truck also risks a template's authored composition, which
matters across 82 presets, and it cost a basis + frame-size calculation that is
now gone: `frameSceneCamera` no longer needs the fov or the aspect at all.

What the reference does NOT have, and this keeps: a relative zoom. Its
`distance` is an absolute camera z authored per template, which is right for
authoring a preset and useless as a house control on top of 82 of them. Ours is
a multiplier on whatever distance the template chose, so it composes.

Two more things read out of that bundle, recorded here because they answer
questions we had rather than because they change code:

  · Orbit, over there, is rig rotation (`applyAuthoredRotation` on a `rotator`
    node), not a camera orbit. For cards centred on the origin the two agree;
    ours orbits the camera, which is also what keeps `lookAt` pointed at the
    template's own target when it declares one.
  · A camera PATH exists in exactly one of their families (`tour` interpolates
    a camera position per frame), and their device mockup rig is keyframed
    pose-to-pose (camDistance/camOrbit/camRoll/camElevation/fov, lerped with
    the scene easing). So an animated camera is per-family there too, not a
    house feature — which is the shape the keyframed-camera item on our list
    should take.

Checked while reading: nothing in our renderer3d derives shading from
`camera.position` — `dim` and the backface come from the template's own pose —
so orbiting the camera cannot corrupt depth fade or backface culling the way it
would in a renderer that measures distance to the camera per card.

`sceneLensShift` returns null when there is no pan so the caller CLEARS the
offset: these cameras live for the whole session, and a view offset left behind
would outlast the value that set it. Proven by the probe — Pan Y's reading has x
back at 321, and neutral returns to 37.191 px on the digit.

`npm test` green, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The complaint was that these controls read as fiddly adjustments rather than as
a camera, and the count says why. Measured over the 82 webgl presets:

  house Zoom      duplicated in 67   (`zoom` 35, `distance` 32)
  house Pan X/Y   duplicated in 61   (`offset` xypad 50, `offsetX/Y` 11)
  house Orbit Y   duplicated in 47   (`ringYaw` 20, `tilt` 16, `rotationY` 11)
  house Orbit X   duplicated in 31   (`tiltX` 20, `rotationX` 11, `cameraView` 9)
  lens            every one of the 82 declares `perspective`

Spinner 01's own `offset` is documented in its source as "pans the CAMERA —
position and lookAt together". So three of five sliders were a second way to do
what the panel already did, which is the trap this feature's own comment warns
about: a control that duplicates an existing one is worse than a missing one.

Now a house control appears only where no visible layer declares that move.
Verified in the running editor, not just in the unit sweep:

  spinner-01     Orbit Y, Orbit X          (its zoom and offset cover the rest)
  poster-01      all five                  (declares no camera control at all)
  orbit-3d-01    the section is gone       (zoom + offset + tiltX + ringYaw)

Across the catalogue that lands as: 31 presets with no section, 10 with one
control, 17 with two, 15 with three, and 9 — the six Poster and three Stickers —
with all five.

`cardRotation`, `cardTilt`, `fanRotation`, `dragRotation` and `motionRotation`
are deliberately NOT duplicates: they turn the cards, which is a different move
from turning the camera.

The panel and the renderer share one function, so a hidden control is inert and
not merely invisible — a scene saved while a control was on offer cannot keep
steering the camera from behind a panel that no longer shows it. The renderer
memoises the gate on the set of visible webgl template ids, so this costs one
comparison per frame rather than a catalogue scan.

The suite grew the half that keeps this honest, now loading the REAL catalogue:
every key the gate names must still exist on some preset (a template renaming
`zoom` would otherwise silently stop the gating), and for all 82 presets the
shown set must equal the not-duplicated set and the hidden axes must read
neutral. 2.296 cases, 276 control/preset pairs hidden and 134 offered.

`npm test` green, `tsc --noEmit` clean.

Second cause of the same complaint, worth recording: the demo presets were flat.
Spinner and Ticker are belts in nearly one plane, where an orbit can only
foreshorten — Orbit X 40 moved the silhouette box 168x326 -> 172x302, 7%. On a
ring the same code is drastic: Orbit X 50 on Ring Stream puts the camera looking
INTO the ring and the frame is unrecognisable. Same control, and the honest
answer to "is it effective" depends on whether the composition has volume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…abel

Two defects found reviewing the branch, both confirmed by reading the code path
end to end.

1. UNDO WIPED THE CAMERA. `store/useHistoryStore` enumerates the undoable slice
   in its own KEYS list, and `sceneCamera` was not in it. Its `apply()` restores
   a snapshot through `hydrate`, and hydrate REBUILDS the field from the partial
   it is handed — so a snapshot without the key did not leave the camera alone,
   it reset it to neutral. Set Zoom 200 on a Poster, nudge any unrelated
   control, press undo: the framing was gone, and redo could not bring it back
   because it had never been captured. 'sceneCamera' added to KEYS.

2. `tilt` WAS NOT A YAW. The duplicate map gated Orbit Y on `tilt` because ONE
   preset labels it "Rotation Y" (ticker-02). The other fifteen that declare it
   — Deck 04 variants, Box, Card Tunnel, Depth Stack, Surface — use it as a ROLL
   or a lean: box.ts comments "rolls the whole prism in the view plane", deck.ts
   applies it as `rotation`, premium3d.ts as `lean`. So Orbit Y was hidden on 15
   presets that duplicate nothing, and `gateSceneCamera` rewrote a stored value
   to zero there. Dropped from the map; the gate now hides 260 control/preset
   pairs instead of 276 and offers 150 instead of 134.

   The root cause is worth the comment it now carries: the map was built from
   control LABELS seen once, not from what each key does. Audited the rest one by
   one against the code — `zoom`/`distance` are camera distance in all seven
   files that declare them (magazine.ts even says "Dolly the book away from the
   lens"), `offsetX/Y` are shift, `rotationX/Y` are pitch/yaw, `tiltX`/`ringYaw`
   turn the ring rig, `cameraView` is side/down. Those stay.

And a tripwire for the shape of defect 1: a field on SceneState has to appear in
three lists — the persisted partial, the autosave document keys, and the undo
snapshot — and missing one is silent. None of the three is exported, so the
suite asserts textually that both files still mention the field. It cannot prove
the lists are right; it does refuse to let the next person fill in two of three.

`npm test` green (2.298 cases), `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third finding from the review, and the last of the three. `updateTrackCamera`
returned at the top of its orthographic branch, which is the camera every
non-webgl track gets inside renderer3d — and renderer3d composites both kinds,
because PreviewStage picks the webgl engine as soon as ANY visible track is
webgl. So a mixed stack was framed two different ways at once, which is the
same defect that moving the camera off the track already fixed for two webgl
layers.

Measured on a mixed scene, Poster 01 (webgl) on the left and Arc (2D) on the
right, at Zoom 200%:

  layer            box before   box after
  poster-01        339x438      405x891
  arc-01 (2D)      405x269      405x504

The 2D half could not move at all before this — the branch returned before the
camera was ever consulted. (Both boxes clamp at 405, half the 810 canvas, which
is the measurement window, not the layer.)

A dolly on an orthographic camera is a frustum scale: halving the extent doubles
the subject, so it divides by the same `zoom` the perspective path applies to
its distance. The pan is the identical lens shift, `setViewOffset` working the
same on both camera kinds.

An ORBIT is deliberately dropped here. There is no perspective in an ortho
projection, so swinging this camera around a flat track would only squash it —
that is a squash, not a point of view, and offering it would be a control that
lies about what it does.

The duplicate gate still reads only the webgl templates' controls. A 2D
template's own `offset` moves its own cards rather than the camera, and the
scene has one camera; narrowing the gate on 2D controls would take the shot away
from mixed scenes to prevent a duplication that is not one.

`npm test` green, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 of the 248 catalogue presets are drawn by Pixi, and none of them had a
scene camera. In 2D a dolly IS a scale and a pan IS a translation, so the whole
shot lands on the artwork container and comes out to the same picture the webgl
path gives on the z=0 plane — one control, one meaning, two engines.

Measured in the running editor, playback paused:

  carousel (2D), which offers Zoom     box            silhouette
    neutral                            810x408        254.634
    Zoom 200%                          652x814        526.902   height x2.00
    Zoom 50%                           497x204         79.615   height x0.50

  arc-01 (2D), which offers Pan X/Y    profile lag
    Pan X +30%                         243px right    = 30.0% of 810
    Pan Y +30%                         324px down     = 30.0% of 1080
    neutral again                      0, 0           identical to the digit

The lag is a new instrument in the probe and it exists because the old one was
lying: with artwork that covers the frame, what leaves by one edge drags the
CENTROID the opposite way from the pan — arc-01 read cx 403 -> 384 for a pan to
the right. A 1D correlation of the column/row profile against the neutral frame
measures the translation directly, and it is what produced the two exact numbers
above.

Three decisions inside this:

  · The camera lands on `motion` (the cards) and NOT on `content` (which holds
    the background), so the background stays put exactly as it does in the webgl
    path, where it is a full-frame pass behind the 3D content.
  · Orbit is not offered in a 2D-only scene. There is no perspective to swing
    there, so turning a flat track would squash it, and a control that squashes
    while claiming to orbit is worse than a missing one. One webgl layer in the
    stack brings the orbit back for the whole scene.
  · The gate now reads EVERY visible layer, not only the webgl ones, and both
    renderers ask it the same question. Without that the panel and renderer3d
    could disagree — a control hidden in the panel while still steering the
    camera.

Where that lands for the 166 2D presets: 131 get Zoom (they declare their own
`offset`, so Pan is theirs already), 11 get Pan X/Y (they declare their own
`zoom`), and 24 get nothing at all because they declare both.

One honest non-result. `filterArea` is local to the container, so a zoomed
artwork needs the inverse of the shot for that rect to still describe the
canvas, and `sceneCameraFilterRect` provides it. I then tried to MEASURE the
difference by breaking the compensation on purpose, with Halftone at artwork
scope and Zoom 200%: the two agreed — box 810x1076 either way. That is
consistent with what this repo already knew, that Pixi's filterArea does not
clip; the compensation stays because it keeps the rect meaning what the code
says it means, but it is not fixing a defect anyone could see.

`npm test` green (2.365 cases), `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until here the camera stood somewhere for the whole clip: `camera(values, ctx)`
takes no frame, so across all 248 presets the framing is the same at 0:00 and at
0:08. This is the half that makes it a camera.

Where it ends up is a DELTA from where it starts — a Travel pad and a Hold —
rather than a second full pose. Ten sliders where there were five is what reads
as fiddly; a delta reads as a sentence: half a frame to the left, over this clip,
sitting still at both ends.

It is also deliberately not a path editor. A grid of numbered pins the camera
tours is a fine way to do this and it is not ours: nothing else in this app is a
map you drop markers on. Every family here says its motion as a named move plus
an amount plus a rhythm — `weave`/`sweep`/`hold` on the wall, direction and speed
on the ticker — and the camera now says it the same way.

Measured on the running stage, with the wall FROZEN (`speed: 0`) so the only
motion in frame is the camera's, sampling the stage through playback and
correlating each sample against the first:

  clip position     displacement
  0% .. 21%         0px            parked
  21% .. 45%        5 → 365px      travelling
  45% .. 87%        parked at the far end
  wraps             back to 0px

That flat-ramp-flat is `Hold` doing exactly what it says. (The magnitude itself
was pinned earlier: a pan of 30% moves the frame 243px on an 810px canvas, to
the pixel. Past ~400px this instrument loses the peak, because the Frames wall
is periodic and the correlation finds the wrong cell — a limit of the
measurement, not of the move.)

Both renderers take the scene's own clock, never the track's: one camera for the
picture means one timeline for it, or two layers on different windows would be
filmed from two places at once.

A preset can now bring a shot. `meta.sceneCamera` carries the house camera the
same way values are carried, because a composition whose whole idea IS the
camera move would otherwise arrive without its motion. Picking any other preset
leaves the camera alone, so a shot someone set survives browsing the catalogue.

Frames 11 and 12 are the first two: the wall barely moves and the camera travels
across it, which is a different thing to watch — the picture stays put and your
view of it changes. Their numbers come off a reference clip that was measured,
not eyeballed: its rows drift in OPPOSITE directions at their own rates (over one
3,5s shot the top band went -74px, the middle +156, the bottom -66), so the weave
stays on at a slow speed. A wall frozen solid under a moving camera reads as a
photograph being scanned, not as a wall.

The catalogue suite caught the first numbers I picked for Frames 11: at card
shape 16:9 the lattice came out with a 5.406px gutter across and -279px down.
The pair it ships with is one the sweep already covers.

Also fixed, because it blocked this: `genExportSources` bundled with esbuild and,
on Windows, fell back to PRESERVING the manifest already in the file — so on the
platform this whole team works on, a NEW preset never entered the manifest and
the only symptom was `npm test` telling you to run the script you had just run.
Two families were already special-cased by hand for that reason. It reads the
ids in-process through sucrase now: same TypeScript, no bundle, same behaviour on
every platform. Verified the manifest only GAINED the two new ids — 332 before,
334 after, none lost.

`npm test` green (2.950 framing cases; catalogue sweep 7.558.978 assertions
across 334 templates x 6 canvas aspects x 7 card shapes), `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass gave the camera one destination and a hold. That is not enough to
say what a shot actually does: you need to name the POINTS it visits, and at
each one whether it is closer or further away. So the move is a list of stops.

The Shot is stop 1. Every stop after it is a pad (where the frame sits) and a
Zoom (how close it is there), and the clip is split equally between the legs.
Hold is taken off the FRONT of each leg, so the camera arrives, sits, and only
then leaves — the rhythm a camera has when it is looking at something rather
than sweeping past it.

A list, not a map. The reference does this with a grid you drop numbered pins
into, and that is a fine tool and not this one: nothing in this app is a canvas
you place markers on, while lists of things you add and remove are everywhere
already — layers, effects, assets. A stop is a row, `+ Stop` adds one, `− Stop`
drops the last, and a new stop starts exactly where the shot already is, so
adding one changes nothing until you drag it. Four stops is the ceiling, and not
a technical one: past four legs a clip of a few seconds gives each under a
second, which is a camera that never settles anywhere.

Measured on the stage, wall frozen (`speed: 0`) so the camera is the only thing
moving, sampling through playback and correlating each sample against the clip
head — one stop 60% to the right, Hold 50%:

  0:00 .. 0:04   0px            parked, correlation 1.00
  0:04 .. 0:07   14 → 349px     eased travel
  wraps          back to 0px    exactly

That flat half is the Hold, and it is half the clip because one leg is the whole
clip. (Past ~350px the correlation drops: the poster is leaving the frame, a
limit of the instrument, not of the move. The magnitude itself was pinned
earlier — a pan of 30% moves the frame 243px on an 810px canvas, to the pixel.)

Stops are CONTIGUOUS by construction: `readSceneCameraPath` stops at the first
gap, so a half-written stop ends the path instead of leaving a hole the renderer
has to guess about. The panel adds and removes both keys of a stop together
through one store action, for the same reason.

And a saved custom preset now carries the shot. A composition built around a
camera move, replayed from wherever the camera happened to be standing, is not
the same composition — so `CustomPreset` gained `sceneCamera`, saving captures
it, and applying restores it. A preset saved before the field existed leaves the
camera alone rather than resetting it, so nothing anyone already saved changes.

Frames 11 and 12 say their shots this way now: 11 sits left at 105%, crosses
right and pushes to 135%; 12 starts low at 130%, climbs, and pulls back to 100%
at the top.

`npm test` green (2.959 framing cases), `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ir own tab

Three corrections, all from watching it get used.

1. A STOP MUST NOT COST TWO ROWS.

The previous shape spent a pad and a zoom per stop, so four stops was nine rows
of camera and ten stops would have been pages. How many stops a path has must
not decide how tall the panel is.

Every stop now lives inside one control (components/CameraPathPad): the pad is
the frame at ±100% on each axis — the unit Pan already uses — each stop is a dot
in it, a dot's SIZE is its zoom, and dashed legs join them in order. One row
under the pad edits whichever stop is selected. Three rows at one stop, three
rows at four.

Dragging a dot moves that stop; clicking an empty patch of pad adds one where
you pointed, at the zoom the camera already has, and selects it. There is no
Add button to find. Stop 1 is the Shot and is drawn as a hollow anchor rather
than a handle: where the camera starts is said in Shot above, and a second
handle for it would be two controls for one value. Only the LAST stop can be
removed — dropping one from the middle would renumber every stop after it.

Verified in the running editor: three stops seeded, the pad drew dots at 37.5%
/ 67.5% / 80% across (panX -25, stops at 35 and 60 — the exact mapping), sizes
10.5 / 12.5 / 10px for zooms 110 / 150 / 100, two legs, and the Move block held
exactly ONE row. Clicking dot 2 added a "Stop 2 zoom" row; clicking empty pad at
15%/85% wrote `_camStop4: {x:-70, y:70}` and selected it.

That is the same compactness a grid-of-pins gets, reached with a pad — a control
this app already has — instead of with a map you drop markers on, which it does
not.

2. THE STANDARD FRAMES DO NOT CARRY A CAMERA.

Frames 11 and 12 are gone, and so is `meta.sceneCamera` with them. A catalogue
preset is a motion; the camera is something a person adds when they are building
something of their own. Shipping two presets that arrive with a camera made the
camera look like a property of the wall, which it is not.

3. WHAT YOU SAVE IS A COMPOSE, AND IT LIVES IN ITS OWN TAB.

The Custom tab is now Composes, "Save as custom" is "Save as compose", and the
empty state says what a compose is: a scene, with a camera, saved. The camera
already travelled with a saved preset (previous commit); this is the naming
catching up with it.

Also renamed the camera's Hold to SETTLE. The Frames wall already has a control
called Hold, and two rows labelled Hold in one panel is a puzzle rather than a
control.

`npm test` green (2.959 framing cases, no failures), `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting the clip equally between the legs was the first version, and
building a real path with it showed why it is wrong. Measured on the
example path -- shot at -25, stop 2 at 35, stop 3 at 60 -- leg 1 covered
60 units and leg 2 covered 25, both in 4s, so leg 2 travelled at 42% of
leg 1's speed. A camera that changes pace for no reason reads as a
mistake, not as a choice.

So a leg's share of the clip is now its own length over the path's total,
where length is the distance across the frame plus the change in zoom: a
leg that only pushes in still takes time to happen, and counting zoom is
what gives it any. A path whose stops sit on top of each other falls back
to an equal split rather than dividing by zero.

The suite gains the property this is for -- walking a three-leg path of
60, 25 and 60 units and requiring under 2% spread between the legs'
average speeds -- plus the degenerate zero-distance path. The old
assertion that half the clip is stop 2 is gone; it asserted the defect.

Pad dots also grow a little (14px base, wider zoom spread) so a stop's
zoom is legible at a glance instead of a 2px difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects, both found by tracking the 7-second reference clip frame by
frame (210 frames, 360x408) instead of watching it: per-frame scale and
translation estimated by coarse-to-fine search against the previous
frame, then segmented at the velocity minima.

1. THE CAMERA WAS ON EVERYTHING. The section rendered unconditionally,
   so every finished preset in the catalogue grew a block of camera
   controls under it. The camera is for building a compose, not
   something every scene carries. A scene with no camera now shows one
   row offering to add one, and only then do Shot and Move appear;
   the panel goes from 774px to 125px on a preset nobody has filmed.

   Worth stating because it was the other half of the report: the
   RENDER of those presets never changed. A scene with no camera reads
   as neutral, isNeutralSceneCamera is true, sceneLensShift returns
   null, and the ortho frustum scales by 1 -- the path is a real no-op,
   and templates/frames.ts differs from origin/main by one blank line.

2. A STOP WAS A FREEZE. Settle took a share off the front of every leg
   -- half of it, by default -- and parked the camera there. The
   reference does not do this in a single frame: of its 209 transitions
   not one sits still, the slowest being 0.58 px/frame against a peak
   of 12.77. The stop is a minimum of speed, not a pause. Settle is
   gone.

   Removing it was not enough on its own. Smoothstep has zero slope at
   both ends, so three legs came to a dead halt twice in the middle:
   measured 88x between the slowest and fastest frame, against the
   reference's 22x. Backing the ease off to 0.93 of smoothstep puts the
   leg ends at 1/21st of the peak, and the same clip now measures 23.2x
   with no frame below 1/50th of its peak.

Also measured: six stops in seven seconds, which a ceiling of four
could not have expressed, so MAX_CAMERA_STOPS goes 4 -> 8. And leg
duration correlates 0.87 with leg distance, which is what the previous
commit's distance weighting already does -- that part was right.

The suite keeps the properties rather than the numbers: no frame of a
move may be a freeze, the speed ratio is bracketed on both sides (a
camera on rails fails it too), a leg is under way from its first moment,
and a scene saved with the old _camHold does not carry it forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Having a camera and the camera doing something are different questions,
and the panel asked the second one to answer the first: `hasCamera` was
`!isNeutralSceneCamera(...) || stops.length > 0`, and Add wrote zoom 100,
pan 0, 0 -- which IS the neutral camera. So the click stored exactly what
the check read as no-camera, the section never opened, and the button
appeared dead. Reported as "não estou conseguindo adicionar".

Whether a scene has a camera is a stored fact now (`_camOn`), not
something inferred from the values. A camera has to start where the
default one stands -- the picture must not jump the moment you ask for
one -- so no reading of the values can tell "just added" from "never
added". `sceneHasCamera` still recognises a scene saved before the key by
what it carries, so nothing already saved loses its camera.

The flag is not a control, so `sanitizeSceneCamera`'s loop over the
declared controls never sees it and it has to be carried by hand;
without that line the camera would vanish on the next save. There is an
assertion for exactly that round trip.

Measured in the browser rather than reasoned about: the Camera section
goes 105px -> 603px on the click, the badge turns Remove, the pad
appears, a click on the pad adds stop 2 with its zoom row and Remove
stop, and all of it is still there after a reload (it persists into the
project key, which is where scenes are actually saved).

scripts/_probe_camera_add.cjs is that measurement. Note its first run
lied: it clicked the pad without scrolling it into view, reported no
stop, and looked like a second bug in the app. Mouse coordinates are
viewport space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Composes tab could only ever be empty on a fresh install: it read
localStorage and nothing else, so the answer to "where is the compose?"
was that there wasn't one. Three now ship in the repo as ordinary
CustomPresets with a reserved id prefix -- same shape, same apply path,
no delete button because a delete that comes back is not a delete.

`Contact Sheet` is the reference clip rather than something resembling
it: 210 frames tracked for scale and translation, cut at the six moments
the camera slows, and the seven stops are where it actually was, in
per-cent of a frame, with the zoom it had got to.

The signs are negated from that measurement, and finding out why is the
part worth keeping: tracking a clip gives you how the PICTURE moved,
while the pad says where the CAMERA points, and those are opposite.
Rendering the compose and measuring it the same way caught it -- x was
out by +22 on average, and by -8 with the sign flipped. After flipping,
stop 2 lands at (-33, 14, 122) against the clip's (-37, 16, 123).

WHEN THE CAMERA PULLS BACK, THE WALL HAS TO BE THERE. A lattice sized to
the canvas is the right lattice until something backs away from it, and
then its edge is in shot. Templates now receive `ctx.coverage` -- how
much wider than the canvas the scene must be built, at the worst moment
of the whole path, because the wall is built once.

Deliberately NOT expressed by inflating ctx.width: that field also sets
the authored scale, so growing it would make the cards bigger instead of
making more of them. Measured: card 188x250 either way, wall 15x15 ->
25x25.

Measured honestly, it earns its place only at the extremes. The lattice
already over-builds: at 810 wide with 250px cards it reaches 1586px from
centre, so a camera at 70% zoom panning a third of a frame (reaching
1007px) was always covered. At 35% zoom it reaches 1620px and was not.
Coverage is what makes 35% and 25% work, and it is inert above ~40% and
absent entirely without a camera.

Also here: probes, and what they got wrong. _probe_compose_camera seeded
a scene with no assets, which starves the card pool, which makes
solveLattice take its fixedCount branch and rewrite cols/rows -- the wall
became a 5x4 patch and looked exactly like a camera overshooting it. And
_probe_wall_leak counted loose background pixels, which the GAP between
cards also is, so a full wall measured 84% "leak". It measures the
contiguous empty margin at each edge now. Both mistakes are written down
in the files, because both read as app bugs.

Still open, and not fixed here: on screen the shipped compose shows a
20.4% contiguous empty margin at one moment of the clip. It is not the
wall's extent -- the layout math clears the view by 1973px horizontally
and 2276px vertically at the clip's worst frame -- so the cause is
somewhere in the Pixi shot path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is why pulling back looked broken. The 2D renderer culls the
offscreen copies of a repeating motif -- correctly, they cost draw calls
and text rasterization -- but it asked whether each card was inside the
CANVAS. With a camera the frame stops being the canvas, so the moment the
camera pulled back it culled every copy it had just moved to look at.

Measured at 50% zoom on the Frames wall: 625 cards laid out across
5282px, container bounds covering the whole screen, alpha 1 on every one
-- and `renderable` false on every card past the canvas edge. Five
columns painted in a sea of background. The wall appeared to shrink
rather than open up, which is exactly what it looks like when a wall is
too small, and is why I spent so long looking at the wrong thing: the
layout was right the whole time.

Finding it needed the renderer itself instrumented. Positions, counts,
coverage, global bounds and screen size all came back correct, so the
question became not "where are the cards" but "why do the ones that are
there not paint" -- and the answer was one boolean.

`sceneCameraFrameRect` now says what the camera can see, in the
coordinates templates lay cards out in, and the cull asks that. It lives
in lib/sceneCamera next to the shot it derives from, so it is testable
and so the next thing that needs the frame does not re-derive it.

Neutral is exact and asserted: with no camera the rect is {0, 0,
width/2, height/2}, the literal expression the cull used before, so
nothing that has already shipped moves. Also asserted against the
transform that actually draws -- a card at the frame's edge lands on the
canvas edge, swept over four zooms and three pans -- and the -0 that
`sceneLensShift` already normalises is normalised here too.

Zoom ladder on a stopped wall, magenta background, before and after:
  before  4 columns at every zoom, the same cards shrinking
  after   4 columns at 100%, 6 at 70%, 8 at 50%, 11 at 35%
And the shipped compose's worst contiguous empty margin goes from 20.4%
of a side to 1.3%, which is the open defect from the previous commit,
closed.

The three-dimensional renderer has no equivalent: its cards are meshes
and three culls them against the real camera frustum, which is already
the right question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Put ours beside the reference clip frame for frame and the camera was
never the problem: the clip puts two to three cards across the frame,
each one a readable page, and ours put four to five. At that size a wall
of typography reads as noise, and no amount of camera work fixes it.

320 with a gap of 30 is a pitch of 270 on an 810 stage -- three across --
and the gap is 11% of the pitch, which is roughly the breathing the clip
has between pages. Push In and Pull Back move the same way.

What this does NOT close, and cannot: the reference wall is a collage of
differently sized cards -- a wide spread beside a tall page beside a
small square, with cream showing through an irregular skyline. Frames is
a uniform lattice by construction; every cell is the same size and the
same shape. Nothing in the catalogue does varied sizes in a wall either:
of the families that touch card size, only Drift Scatter has minSize and
maxSize, and that is scattered placement rather than a wall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beside the reference clip the remaining difference was never the camera:
its wall is a collage -- a wide spread next to a tall page next to a
small square, with background showing through an uneven edge -- and ours
was a grid where every cell is identical. A wall like that reads as a
texture however well it is filmed, and nothing in the catalogue could do
otherwise: of the families that touch card size, only Drift Scatter has
minSize and maxSize, and that is scattered placement, not a wall.

`Size Variation` is a Layout control, default 0.

It only ever SHRINKS a print inside its own cell. Growing one past its
cell would overlap its neighbours, and the lattice, the loop and the
media identity are all one card per cell. Shrinking keeps every one of
those and produces what an uneven wall actually looks like: an irregular
edge with the background coming through where the smaller prints are.

The variation belongs to the MOTIF cell, not to the card index, because
the wall is a torus -- a card leaving one edge re-enters at the other,
and a size that came from its copy would change as it wrapped. The hash
is integer mixing rather than the usual sin-based one, so the same scene
exports the same frames on every machine.

Default-off is proved, not promised. scripts/fixtures/frames-baseline.json
is the output of the Frames transform as it was BEFORE this control
existed -- 7 presets x 3 scenes x 5 frames, every card's x, y, scale,
rotation, alpha and depth, 17075 of them. Generated from the code at HEAD,
then regenerated after the change: identical byte for byte, 635368 either
way. verify-frames-baseline.cjs now holds that, runs in npm test, and also
asserts the opposite direction -- with the control up the wall really does
get more than three distinct sizes, none bigger than before, none smaller
than the control asks -- so the equality above cannot pass for the wrong
reason. It also refuses to let any shipped preset default it on.

Contact Sheet uses it at 38.

Not addressed, and deliberately: the clip's background is cream and shows
between its pages, ours is dark. That is scene colour, which is a separate
piece of work with its own design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Looking at the reference wall at twenty frames instead of eight showed
the last attempt was solving the wrong problem. Its prints are not all
the same SHAPE: rows keep one height, the widths inside them vary, and
everything is flush against everything else with one thin gutter. My
first control shrank prints inside their own cell, which produces the
exact opposite -- a hole around every small print, a wall that reads as
moth-eaten rather than collaged.

`Mixed Sizes` replaces it. A cell chosen by the control hangs a landscape
spread across itself and its right neighbour, and the neighbour is not
drawn. Spacing never changes; only the shapes do.

A spread gets its shape by being drawn BIGGER and clipped back to one
cell of height, so the picture is cover-cropped to a landscape the way
the crop pipeline would do it. Stretching it with scaleX would distort
every face on the wall. The factor is measured, not guessed: a card fills
89% of its cell, and two cells minus the gutter is 2 + gap/cardWidth of
one card, which is what the clip window is derived from.

Greedy left to right so two spreads can never claim the same cell, and
never starting on the last column of the motif -- a spread crossing the
motif boundary would be cut in half when the wall wraps, since the wall
is a torus.

The baseline still holds: 17075 cards across 7 presets, 3 scenes and 5
frames, identical with the control at its default of 0. The suite also
asserts the new mechanism does what it says -- spreads appear, exactly as
many cells are swallowed as there are spreads, every spread is a clip and
never a scaleX, and the window is a horizontal band of the print itself.

Contact Sheet runs it at 55, with cards at 380 so the wall shows three
across at rest, which is what the clip does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gutter between the pages IS the background, so a wall of documents on
white and the same wall on black are not one composition in two moods --
they are two compositions. Ours was landing on whatever dark ground the
project happened to have, and beside the reference that alone was most of
what still looked wrong once the shapes were right.

Sampled, not picked: over the clip's 210 frames its two commonest colours
are #f8f8f8 and #f0e8e8, and together they are 26% of every pixel in it.
The three composes ship #f4efec.

`CustomPreset.background` is optional and merged over the current scene,
so a compose naming only a colour cannot silently drop a blur or an image
the scene already had, and anything saved before this leaves the
background alone. Saving a compose now captures it, the same way saving
already captured the shot.

To be clear about which decision this is NOT: this is the scene
background, content the user already sets in the app. The app's own
palette is untouched and stays its own piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrected by the person who knows the reference: its wall LOOKS like a
collage of differently sized pages and is not. One size throughout. What
varies is what the camera does to it -- pushing in until a page fills the
frame, pulling back until a dozen fit -- and that half is now right.

So both controls I built for this come out. The first shrank prints
inside their cell, which opened a hole around every small one. The second
hung landscape spreads across two cells, which was closer to what the
frames look like and still wrong about why. templates/frames.ts is back
to identical with origin/main, and the three composes carry one card size
each.

What stays is the baseline suite that made the revert safe to do
quickly: 17075 cards across 7 presets, 3 scenes and 5 frames, asserting
the family sits exactly where it sat. It was written to prove a control
changed nothing; it is more useful now as the thing that proves the
control is gone.

The lesson is cheap to write down and was not cheap to learn: I read a
static property into a wall whose variety came from the move over it, and
then changed a consolidated family twice to chase it. Measure what the
camera is doing before concluding anything about what it is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "ficou confuso de como eu clicar", and looking at the panel
that is fair — every instruction it had was outside the thing it was
about.

  - Nothing said the pad was clickable. The only hint was a grey line of
    text under it. An empty pad now says so along its own bottom edge,
    and there is an Add stop button too, because a gesture nobody can see
    is not a feature. The prompt sits at the bottom and not in the middle
    because the middle is where Start usually stands: the first version
    printed the two straight through each other.
  - Stop 1 was the Shot. It looked like the other dots and could not be
    dragged, so the natural first thing to try did nothing. It is a ring
    labelled Start now, and its tooltip says it moves with Zoom and Pan.
  - Because the Shot was 1, the first stop you added was "Stop 2". Stops
    start at 1.
  - The selected stop was a 2px outline, which at 14px is easy to miss.
    It carries a halo, and the row that edits it is headed with its name,
    so the dot and the row read as one thing.
  - The pad was a grey box. It has the frame's corners now, so the stops
    read as sitting inside a picture.
  - How many stops you have and how many you may have was nowhere. It is
    on the Path title row, next to the button.
  - Remove named the stop it removes.

The empty CAMERA section also grows a real button. The badge in the
header is where you turn a camera off once you have one; it is too quiet
to be how you find out you can have one.

Nothing in the camera's behaviour changed: the same stops, the same path,
the same shot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"O design esta horrivel esse das cameras, porque e dificil de utilizar e
confuso" — and fixing affordances was never going to answer it, because
the problem was what the control ASKED FOR. The pad wanted coordinates
over time: positions, zooms and an order. Nothing else in this app asks
for that, and it is the hardest thing in it.

Reading the reference tool settled it. Its store carries 204 families and
the section titles across ALL of them are Scene, Animation, Physics,
Style, Proximity, Effects and Path — and that Path is pathStretchX/Y and
pathTilt, the CARDS' path, not the camera's. Its entire camera surface is
static sliders folded into each template: perspective in 99 families,
offsetX/Y in 137, distance in 78, rotationX/Y/Z in 62, orbitRadius in 41,
zoom in 4. Where a different viewpoint matters it is NAMED and one click:
`cameraView`, a toggle between side and down, in 9 of them.

They never ask anyone to author a camera path. So:

MOVES YOU PICK. Push in · Pull back · Cross · Drift · Survey, each with
at most two knobs — how far, and which way. The stops, the timing and the
easing are authored in lib/cameraMoves from the measurements already
taken off the reference clip. Survey is that clip's own six stops.

A move is a recipe, not a second runtime: choosing one writes the same
_camStopN keys the pad writes, so the engine is untouched and the path
stays editable afterwards. Touch it by hand and the move becomes Custom,
because it has stopped being a true description of the stops.

THE PAD DRAWS FRAMES. A stop is a framing — where the camera points AND
how much it takes in — so a dot could only ever say half of it, with the
other half in a slider underneath and nothing tying them together. Worse,
the dot grew as the shot got CLOSER, backwards from how anyone reads a
frame. A stop is now the rectangle the camera sees: drag it to aim, drag
its corner to zoom, and a small rectangle is a close shot because it is
literally less of the scene.

The pad also fits itself to the path. At a fixed ±100 a real move covers
a third of a frame and the frames pile up concentric in the middle; it
frames what is there, with room around it. Only the stop in focus is
drawn solid — seven rectangles at full weight is a thicket.

And it is folded away. Picking a move is the front door; placing a stop
exactly where you want it is the 5% case, and it was 100% of the door.

Measured, and one trap worth keeping: the slowest-to-fastest frame ratio
depends on how densely you sample it. The same Survey reads 69x at 120
samples and 32x at 210, purely because at 120 one of its six legs is
sampled nearer its own boundary. The suite samples at 210, the clip's own
frame count, where the single-leg moves land at 18x against the clip's
22x.

81 new cases: ids unique, never more than two knobs, every move actually
moves at every amount, no frame of any move is a freeze, Amount scales
the travel, and a one-stop move chosen after a six-stop one leaves
exactly one stop rather than inheriting the tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"nao gostei ficou pior ainda o pad" — correct, and worse than the version
before it. Looking at the shot honestly: six nested outlines and a dashed
seventh inside 250 pixels is a thicket, not a diagram.

Both attempts failed for the same underlying reason by different routes.
Dots could only ever say WHERE a stop was, never how much it took in, so
its zoom lived in a slider elsewhere with nothing tying them together.
Rectangles said both — and then piled up, because stops differ mostly in
ZOOM, which makes their frames concentric. Nesting is the one arrangement
where more information reads as less.

So the pad shows ONE.

The strip along the top is the path as what it actually is: an ordered
list. Start, 1, 2, 3, and a + at the end. Clicking a chip steps to that
stop, which is also how you see the order at a glance without drawing it.

Underneath, that stop is a single frame you click or drag to aim, with
the previous stop behind it as a dashed ghost and a line between them, so
you can see where the camera is coming from without everything else
crowding in.

Each question is asked once now. Which stop — the strip. Pointing where —
the pad. How close — the row underneath. Nothing overlaps, and the height
no longer grows with the number of stops.

Adding moved to the + on the strip, so one gesture does one thing: a
click in the pad aims the chosen stop instead of sometimes adding another.
The corner grip is gone with it — at this size a 9px handle on a frame
that may itself be 14px was a target nobody could hit, and the zoom row
below is both clearer and already there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things fixed here, and the second is the one that mattered.

FIRST: the panel is built from the controls this app already has. The
version before it invented four of its own — a button grid, a chip strip,
a disclosure and two generations of a pad — every one a lookalike of
something the panel already had. That is what made the camera read as if
it came from a different program, reported as "sem design com cara de
amador". The Move chooser is now a select (six names do not fit across a
300px panel as pills: they overlapped into "Push InPull BackCross"), the
knobs are ControlRows, and hand-editing sits behind the panel's own
.ctl-advanced disclosure. 6.3KB of bespoke CSS deleted.

SECOND: the pad. "A primeira versão do pad estava boa, o problema era
aquelas linhas agressivas e faltava quadrados para separar os espaços."
Exactly right, and it matches how the reference does it — pins on a
lattice, with + Pin and Reset in the header.

So: numbered pins on a grid of 35 cells. The cells are the thing the
first version was missing; the dashed legs between stops are gone, which
is what made it aggressive. Snapping to cells also removes the aiming a
continuous pad demanded — you cannot land a pixel off when there are
thirty-five places a stop can be.

Adding is two deliberate parts, as the reference does it: click a cell,
press + Pin. A click never creates something you did not ask for, which
the earlier pads did on any click that missed a handle.

Three layout traps, all measured rather than guessed:

  - `repeat(7, 1fr)` with aspect-ratio children blows the grid out of the
    panel: the automatic minimum of a 1fr track is min-content, and a
    square child makes that large. minmax(0, 1fr).
  - Setting grid-auto-rows as well then fights the cells' own aspect and
    opens grey bands between the rows. The squares size the rows.
  - A pin laid out in flow grows its own cell when two share one, which
    opened a band across the whole lattice. Pins are absolute.

And a probe trap worth keeping: the first screenshots showed giant cells
while the computed style said 35px, because puppeteer's `clip` is
DOCUMENT space and the panel scrolls inside a container — the shot was of
somewhere else on the page. It screenshots the element now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"As câmeras que têm várias no mesmo ponto" — five of the Survey's six
stops landed in one cell, fanned on top of each other and impossible to
tell apart.

The cause was a grid fixed at the full reach. The clip that move is taken
from only travels 37% of a frame across and 29% down, so at ±100 every
one of its stops sits within two cells of the middle. Seven columns
spread over a range nothing uses is six columns wasted.

The grid now takes the tightest range that still holds every pin — 10,
25, 50 or 100 per cent — and says which in its header, so a cell never
silently changes meaning. Measured across the shipped moves:

  Survey   ±25%   6 cells
  Cross    ±25%   2 cells
  Drift    ±10%   2 cells

Drift is why the ladder starts at 10: its whole excursion is under four
per cent, and at ±25 its two pins still shared a cell.

Push in at `centre` still shows one cell, and that is correct rather than
a miss — it does not pan at all, only zoom, so both pins genuinely are in
the same place. The fan keeps them both clickable and the zoom row below
is where that move is actually read.

The Shot joins the fan too. It was drawn before the stops and outside
their collision check, so where the camera starts in the middle — which
is most moves — S was hidden underneath stop 3 entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"O Contact Sheet permite colocar várias câmeras numa parte só, deixe eu
fazer isso em outros." Measured before changing anything: Survey shipped
six stops and Push in, Pull back, Cross and Drift shipped one each. A
staged push through three framings was reachable only by hand-placing
pins, which turned the named move into Custom on the first drag — so the
thing Contact Sheet does was the one thing you could not ask any other
move for.

Every move now takes a Stops count and builds that many, spaced evenly
along its own shape. Push in at 4 gives 119%, 137%, 156%, 174% — four
framings, each closer than the last, instead of one jump.

This is a THIRD knob, and it breaks the two-knob rule I set for these
moves on purpose. That rule existed to stop a move turning back into the
authoring surface it replaced; how many times the camera settles is not
authoring, it is the shape of the move. The suite's assertion moved with
it, and says why.

Survey keeps a ceiling of six, declared per move rather than shared: its
stops are read off the reference clip's own footage, and asking it for
eight would mean inventing two. Its slider stops at six too, rather than
promising a stop it cannot make.

Asserted across all five moves at 1, 2, 3 and the ceiling: the count is
honoured exactly, it survives a save, and no stop sits on top of the one
before it — a count that repeats the same framing n times is a longer
list, not a longer move.

Verified through the UI, not just the library: the app's slider is a drag
track with tabindex rather than an input[type=range], so the probe drives
it by keyboard. Push in, four presses of ArrowRight, four stops in the
panel and four pins on the grid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@davicorrea0
davicorrea0 merged commit 3af3418 into appariciojunior:main Sep 21, 2026
1 check passed
@davicorrea0

Copy link
Copy Markdown
Collaborator Author

@quefreen @appariciojunior Testem na aba compose e me diga o que acham da funcionalidade? Algumas pessoas tinham solicitado para ter um movimento de camera em alguns cards, tentei criar, ainda não está perfeito

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant