Repository navigation
Feat/scene camera - #83
Merged
davicorrea0 merged 26 commits intoSep 21, 2026
Merged
Conversation
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>
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 entirecamera 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 betweensideanddown, 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
_camStopNkeys hand-editing writes, so the engine underneath is untouched andthe 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
+ PinandReset. Cells rather than a continuous padbecause 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:
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
renderablefalse onevery 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
sceneCameraFrameRectnow. At neutral that returns{0, 0, width/2, height/2}— the literal expression it replaced — and there is an assertion forit, 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 startexactly 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 abutton 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 Sheetis 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
its radius, a pan never moves the camera.
survives a save, and no stop sits on top of the one before it.
frames — so the family provably did not drift.
tsc --noEmitclean.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.tsis identical to main.🤖 Generated with Claude Code