diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index 338b0b7..0afc96b 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -65,8 +65,10 @@ jobs: path: video/out key: ${{ steps.key.outputs.value }} + # Node 22 serve anche quando i render arrivano dalla cache: il passo della + # pagina chiede al manifest i casi della proiezione, e il manifest legge i + # moduli .ts del kit, che il Node preinstallato sul runner non sa leggere. - uses: actions/setup-node@v4 - if: steps.cache.outputs.cache-hit != 'true' with: node-version: 22 cache: npm @@ -108,9 +110,9 @@ jobs: # Le misure che possono fallire. Se una scena e' rotta, il deploy non parte. # - # Le generiche (riempimento di ogni scena, giunta di ogni coppia adiacente) - # arrivano dal catalogo. Le altre sono legate a una scena sola e restano - # scritte qui: derivarle da un elenco nasconderebbe che esistono. + # Le generiche (la giunta di ogni coppia adiacente) arrivano dal catalogo. + # Le altre sono legate a una scena sola e restano scritte qui: derivarle da + # un elenco nasconderebbe che esistono. - name: misure if: steps.cache.outputs.cache-hit != 'true' run: | @@ -127,6 +129,19 @@ jobs: ./scripts/beats.sh ./scripts/contrast-floor.py + # CAM-06 FUORI DA TOPICS. Il bersaglio resta sull'origine della + # prospettiva mentre la camera avanza, su due lastre (Topics e la + # lastra sonda, chiara e verticale) e in tre rapporti. E' il primo + # banco che non guarda solo Topics in 16:9. + ./scripts/drift.py + + # LA CAMERA COME DATO. Le tracce di products/topics/tracks.ts sono quelle che + # le scene passano al render: da li' si misura che la camera non torni + # mai indietro in moto (GIU-04) e che la lastra copra i quattro bordi + # del quadro su ogni frame (CAM-01), senza chiederlo ai pixel. + ./scripts/chain-check.py + ./scripts/fill-geom.py + # IL TEMPO. Le battute interne di una scena sono scritte rispetto a una # durata di riferimento e `durationInFrames` le scala tutte, quindi # cambiare la velocita' di una clip e' cambiare un numero in @@ -159,6 +174,29 @@ jobs: [ "$rc" = 1 ] || { echo "framelocked-verdict.sh sulla sonda con Math.random: atteso 1, ottenuto $rc" >&2; cat /tmp/fv.log >&2; exit 1; } echo "framelocked-verdict.sh boccia la sonda con Math.random, come deve." + # drift.py deve bocciare i due guasti in TUTTE e sei le varianti, non in + # una: --must-fail esce 0 solo se ognuna esce dalla tolleranza. Senza lo + # spostamento il bersaglio parte gia' fuori posto; con l'origine + # sbagliata parte giusto e scappa mentre la camera avanza, che e' + # l'incidente raccontato dalla grammatica. + for guasto in '{"compensate": false}' '{"originMismatch": true}'; do + rc=0; ./scripts/drift.py --props "$guasto" --must-fail > /tmp/drift.log 2>&1 || rc=$? + [ "$rc" = 0 ] || { echo "drift.py con $guasto: almeno una variante promossa, o misura mancata (rc=$rc)" >&2; cat /tmp/drift.log >&2; exit 1; } + done + echo "drift.py boccia i due guasti in tutte e sei le varianti, come deve." + + # chain-check.py deve accorgersi della stessa catena senza easing, dove + # le giunte non sono piu' ferme e le inversioni diventano in moto. + rc=0; ./scripts/chain-check.py --linear --must-fail > /tmp/cc.log 2>&1 || rc=$? + [ "$rc" = 0 ] || { echo "chain-check.py promuove una catena che si inverte in moto (rc=$rc)" >&2; cat /tmp/cc.log >&2; exit 1; } + echo "chain-check.py boccia la catena senza easing, come deve." + + # fill-geom.py deve bocciare ogni scena con la camera arretrata: e' il + # difetto che fill-measure.sh sui pixel promuoveva. + rc=0; ./scripts/fill-geom.py --push-offset -1500 --must-fail > /tmp/fg.log 2>&1 || rc=$? + [ "$rc" = 0 ] || { echo "fill-geom.py promuove una lastra arretrata (rc=$rc)" >&2; cat /tmp/fg.log >&2; exit 1; } + echo "fill-geom.py boccia la lastra arretrata in ogni scena, come deve." + ./scripts/fixture-screenshot.sh if ./scripts/focus-sharpness.sh video/out/.fixture-card-focus-screenshot.mp4 >/dev/null 2>&1; then echo "focus-sharpness.sh promuove una scena fatta di screenshot: non misura niente." >&2 @@ -256,6 +294,25 @@ jobs: # banco che guarda dei pixel del browser invece del DOM. python3 -m pip install --quiet --disable-pip-version-check playwright pillow python3 -m playwright install --with-deps chromium + + # La proiezione del kit (kit/project.ts) contro Chromium: due lastre, + # tre stage, le sei pose della catena, cinque punti ciascuno. E deve + # accorgersi di un'origine della prospettiva sbagliata nel CSS. + ./scripts/project-check.py + rc=0; ./scripts/project-check.py --origin-mismatch --must-fail > /tmp/pc.log 2>&1 || rc=$? + [ "$rc" = 0 ] || { echo "project-check.py non si accorge dell'origine sbagliata (rc=$rc)" >&2; cat /tmp/pc.log >&2; exit 1; } + echo "project-check.py boccia l'origine sbagliata, come deve." + + # Nessun banco si calcola da se' la geometria di un prodotto: la chiede + # al manifest. E il controllo deve bocciare una copia dei banchi in cui + # uno torna a importare topics/geometry.ts. + ./scripts/no-product-literals.sh + mkdir -p /tmp/banchi-guasti && cp scripts/*.sh scripts/*.py scripts/*.mjs /tmp/banchi-guasti/ + printf '%s\n' "read -r X < <(node --input-type=module -e 'const m = await import(\"\$ROOT/video/src/products/topics/geometry.ts\"); console.log(m.THREAD_TOP)')" >> /tmp/banchi-guasti/handoff-travel.sh + rc=0; ./scripts/no-product-literals.sh /tmp/banchi-guasti > /tmp/npl.log 2>&1 || rc=$? + [ "$rc" = 1 ] || { echo "no-product-literals.sh promuove un banco che importa topics/geometry.ts (rc=$rc)" >&2; cat /tmp/npl.log >&2; exit 1; } + echo "no-product-literals.sh boccia un banco che importa topics/geometry.ts, come deve." + ./scripts/demo-check.py # E i cicli si devono chiudere. Le demo girano in loop: se l'ultimo diff --git a/README.md b/README.md index 8f55f83..61b379c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ not a stylistic preference. That rule was an assertion until one scene had to enter from another. Two clips that both start and end at rest can be placed in any order without anyone seeing a cut, because there is no motion to break. `CardHandoff` starts from the pose -`UIMockup` stops in, both reading it from `primitives/slab.ts`, and `seam.sh` +`UIMockup` stops in, both reading it from `products/topics/geometry.ts`, and `seam.sh` diffs the two frames to prove it. One measured join makes the rule true of a pair. `CardFocus` is the third link: @@ -55,7 +55,7 @@ while the other twenty-eight were joined. Putting it in was not a swap of constants. The board only ever used the top half of the slab; the bottom half was empty, which is also why the frames read as a mockup rather than as a screen in use. The assistant thread and the composer now -live down there, in `primitives/Assistant.tsx`, drawn by every scene — if only +live down there, in `products/topics/Assistant.tsx`, drawn by every scene — if only one scene drew them, the join before it would show half a screen appearing out of nothing, and `seam.sh` would call that a cut, correctly. With one screen the move from the board to the composer stops being a change of screen and becomes a @@ -99,8 +99,8 @@ cent of its width and the titles stop being titles, and it holds still for the last 32 frames so the piece finishes on a pose rather than on an interrupted move. -The thickness it shows — `SlabEdge` — is drawn by all six scenes and visible in -exactly one, because at small yaw angles it sits precisely behind the slab. It +The thickness it shows, the edge that `Shot` draws, is there in all six scenes and +visible in exactly one, because at small yaw angles it sits precisely behind the slab. It is a *sibling* of the slab and not a child, and that is not a detail: the slab clips, any clipping flattens `preserve-3d`, and a child at `translateZ(-30)` would be squashed onto its parent's plane and never stick out. The same fact is @@ -384,11 +384,144 @@ measurements still hold. planes, with the product's own tokens, radii and system font stack. A mockup that is 3px off reads as a mockup. +## The kit, starting with the geometry + +Every measurement in this repo used to be taken on one slab: dark, 2400 by 1200, +in 16:9. `slabPointOnScreen` and `centreOn` had 1920 and 1080 written inside +them, so the maths a product film needs in 9:16 and 4:5 did not exist, and no +bench would have noticed, because no bench looked anywhere else. + +`video/src/kit/` is where the registry becomes usable for any product. It starts +with two pure modules, which Node reads directly the way the benches already read +`topics/geometry.ts`: `stage.ts`, the three stages, and `rig.ts`, with perspective, origin +and slab scale as parameters and `slabPointOnScreen`, `centreOn`, `zoomForPush`, +`pushForZoom` and `pushForFill` written against them. `pushForFill` exists +because push numbers do not travel between formats: the same push gives the same +magnification, and in a narrow frame the subject fills it much sooner, so the +fill is the decision and the push follows from it. `topics/geometry.ts` keeps every export +and calls the kit with the stage and rig of Topics. `geometry-snapshot.mjs` +photographs its geometry (38 constants, seven poses, the functions on sample +points) and the versions before and after the move are the same string. + +The origin deserves a sentence, because the direction documents of the first +product films got it wrong. They said the 46 per cent "is not a fraction of the +frame but the result of the slab geometry and the pitch". It is a choice, written +in the CSS and read back by the maths. What changes between formats is the slide +that brings the subject onto it. + +`drift.py` proves it on renders rather than on the catalogue page. It runs +`CAM-06` as six specimens, in `video/src/specimens/`: the delivered Topics card, +and the card of a probe slab, in 16:9, 9:16 and 4:5. The probe slab is a product +that does not exist and imitates none. It is light where Topics is dark, vertical +where Topics is wide, at scale 1 where Topics is at 1.04, so a number copied from +Topics does not come out right on it. Each specimen slides the slab once with +`centreOn` and pushes until the subject fills 90 per cent of the frame, and a +magenta ring on the subject has to stay within 4 px of the origin on the first +and the last frame. The worst reading is 2.03 px, on Topics in 16:9, where the card +sits on fractional coordinates and is magnified three times; the probe slab stays +under 0.71. The negative controls have to fail in all six: once without the +slide, once with the slide worked out for 46 per cent while the CSS says 50. The +second is the subtle one. On the first frame the ring is exactly where it should +be, and it drifts 39.5 to 99.8 px only as the camera pushes in, which is +(0.50 − 0.46) × height × (zoom − 1), the figure the manifest predicts. A bench +that looked at one frame would pass it. + +The bench had two defects of its own before it could be trusted. The first mask +turned the magenta white and then blacked out everything that was not white, so +the white surfaces of the probe slab stayed in the mask and the centroid landed +two hundred pixels from the ring: the bench failed the correct case, and against +a bench that fails everything the negative controls read as green. And on Topics +the ring was invisible, under the delivered card, which lives at `zIndex` 10. + +The second piece of the kit is `kit/Shot.tsx`, and it is a deletion more than an +addition. The block that films a slab (the attenuated plane behind, the edge, +the slab, the light on the frame) was copied by hand into all six scenes, with +perspective 2600, origin "50% 46%", `(1920 - SLAB_W) / 2`, `scale(1.04)` and the +seven parallax numbers of the plane behind written into each copy, while the +benches measured the constants in `topics/geometry.ts` that no scene read. Now every scene +hands `Shot` a pose and its content, and the numbers that make the shot look like +Topics live once, in `products/topics/material.ts`. The stage comes from the +composition rather than from 1920 and 1080. The copies differed in exactly three +ways (a fade on the whole frame in `UIMockup`, a cursor inside the slab in +`CardHandoff` and `PromptInput`, and a light edge along the bottom of the slab +that `PromptInput` and `BoardOrbit` do not have), and `Shot` takes each one as a +prop. `still-identity.sh` renders the first, second, middle, second-to-last and +last frame of every scene from the previous commit and from the working tree: +thirty of thirty identical, with a repeat of one frame per scene to show the +instrument is repeatable, and a pose moved by one pixel makes it fail. + +The third piece is `kit/project.ts`, which says where a point of the slab lands on +screen with the camera in any pose, tilted included. `centreOn` is exact only +with yaw and pitch at zero, and a bench that watches a moving camera, which is +what almost every product film has, needs to know where an element is at a +tilted pose rather than guess a crop by eye. It redoes the CSS chain of `Shot` +in arithmetic (scale, then rotateX, then rotateY, then the push, then the +container's perspective around the rig origin), and `project-check.py` compares +it with Chromium on 36 cases: two slabs, three stages, the six poses of the +Topics chain, five points each, off-centre and off-axis on purpose. The largest +error is 0.040 px. With the CSS origin set to 50% 50% instead of the rig's, the +error reaches 103.7 px and the check says so. + +The four benches that worked out the geometry of Topics inside a `node -e` +snippet of their own (`handoff-travel.sh`, `focus-sharpness.sh`, +`fixture-screenshot.sh`, `contrast-floor.py`) now ask `manifest.mjs`, which +computes it once in `video/src/products/topics/benches.ts`. Their output is identical +line for line, and the screenshot fixture comes out with the same hash. +`no-product-literals.sh` keeps it that way: a bench that reads `video/src` by +itself fails it, and pointed at the benches of the commit before this one it +names exactly those four. + +The fourth piece takes the camera out of the scenes. Each scene used to compute +its own camera inside the component, five `interpolate` calls written by hand, +which meant that only the render could say what the pose was at frame 137, so +no bench could ask whether the camera reverses in motion or whether the slab +leaves an edge of the frame uncovered. `kit/camera.ts` makes the camera data: a +track is one curve per axis (from, to, a window, an easing) and `poseAt` reads it +with Remotion's own `interpolate` and `Easing`, called with the same arguments +the scenes used. The six tracks live in `products/topics/tracks.ts`, and the scenes +read them. `still-identity.sh` now takes the frames to compare, because the five +default ones miss the windows that matter: frames 20 to 81 of `UIMockup`, where +the slab slides in, and the frames either side of where `PromptInput` and +`BoardOrbit` settle. All of them are identical to the previous commit. The cursor +also takes the scene's frame now: it used to read its own clock, so in a scene +driven by `progress` the hand would have gone its own way. + +Two benches that the catalogue named and nobody had written read those tracks. +`chain-check.py` is `GIU-04`: along the six scenes, in the order they join, no +axis changes direction inside a scene, every join is continuous, and a reversal +at a join is allowed only where the camera is still on both sides. It finds +seven, all at rest, with boundary velocities under a thousandth of the peak; with +the easing taken out of every track the same seven become reversals in motion +and it fails. `fill-geom.py` is `CAM-01`: from 20 per cent of each scene to its +last frame, the four corners of the frame have to fall inside the projected slab. +Its first run failed `BoardOrbit`, by up to 260 px from frame 30, and it is right: +the orbit exists to show the vertical edges of the slab, so the edges of the +frame show the background on purpose. The scene had declared `fill` anyway, and +the pixel bench had passed it. It does not declare it any more. The other five +scenes are covered on every frame with at least 5.4 px to spare, and +`PromptInput` sits at exactly zero, because its final pose puts the bottom edge +of the slab on the bottom edge of the frame by construction. Pushed back by 1500, +all five fail. + +`scripts/manifest.mjs` is where a bench asks what it should find. It prints, from +the same modules that produce the render, the variants, their frames, the origin, +where the subject would sit without compensation, and the tolerance. If the +uncompensated point fell on the origin, the negative could not fail, and +`drift.py` exits 2 and says so. + +Everything that belongs to Topics now sits in one folder, `video/src/products/topics/`: +the slab geometry (`geometry.ts`, which was `primitives/slab.ts`), the tokens +(`tokens.ts`, which was `theme.ts`), the furniture, the material, the tracks, the +bench geometry (`benches.ts`) and the six scenes. What stays outside is what +another product can use as it is: `kit/`, the cursor, the frame-locked helpers. +The move changed no number. `geometry-snapshot.mjs` gives the same 52 values as +before the kit existed, and every still is identical to `main`. + ## Layout | | | |---|---| -| `video/` | The Remotion project. Scenes in `video/src/scenes/`, primitives in `video/src/primitives/` | +| `video/` | The Remotion project. The product-independent kit in `video/src/kit/`, one folder per product in `video/src/products/` (`topics/` holds the slab, its tokens, tracks, bench geometry and the six scenes; `probe/` the synthetic slab), shared primitives such as the cursor and the frame-locked helpers in `video/src/primitives/`, the catalogue in `video/src/scenes/catalog.json`, bench specimens in `video/src/specimens/` | | `scripts/` | The measurements, the review page, the showcase build, and `catalog.mjs`, which is how shell and CI read `catalog.json` without a compiler. See below | | `showcase/` | The public pages. `index.template.html` and `grammatica.html` are committed; the scene section and the renders are not, `showcase-build.sh` generates the first from `catalog.json` and copies the second into `showcase/dist/` | | `CATALOG.md` | The surveyed libraries with verified licenses, the 81 templates grouped, the market gap | @@ -420,6 +553,13 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./scripts/loop-close.py [page.html] # does every demo loop close, or tear every pass ./scripts/type-check.py [page.html] # does the type cover, leave the frame, vanish, snap, or stop mid-move ./scripts/contrast-floor.py [scene.mp4] # is the attenuated content still readable +./scripts/drift.py [--props JSON] [--must-fail] # does the subject stay on the origin, on two slabs and three formats +./scripts/project-check.py [--origin-mismatch] # does the kit's projection agree with Chromium +./scripts/chain-check.py [--linear] [--must-fail] # does the camera ever reverse while it moves +./scripts/fill-geom.py [--push-offset N] [--must-fail] # does the slab cover the four edges of the frame +./scripts/no-product-literals.sh [scripts-dir] # does any bench read product geometry by itself +node scripts/manifest.mjs cam06 # what the benches must find, from the kit +node scripts/geometry-snapshot.mjs [geometry.ts] # the geometry as a string, to prove a refactor left it alone ./scripts/tempo.py [long.mp4 short.mp4] # does shortening a scene retime it or just trim it ./scripts/fixture-tempo.sh # render the two retimed fixtures ./scripts/fixture-trim.sh # build the trimmed scene tempo.py must fail @@ -467,10 +607,9 @@ between the local variance of a border strip and of the centre falls to 0.014 on `card-release` with the slab in frame and rises to 0.043 on `board-orbit`, where the borders show the background on purpose, because the attenuated plane behind the slab is the board drawn a second time and any pixel measure reads it as -content. So the script is kept as a readout, it is out of the CI measurements, -and `CAM-01` on the catalogue page is grey. Whether a slab covers the frame is a -question for the geometry: project the slab at the sampled frames and require it -to cover all four edges. +content. So the script is kept as a readout and is out of the CI measurements. +Whether a slab covers the frame is a question for the geometry, and +`fill-geom.py` asks it on the camera tracks (see the kit, below). `framelocked-verdict.sh` had the opposite problem. It measured the right thing and then exited 0 whatever it found, so a real divergence would have scrolled past @@ -569,7 +708,7 @@ entry, or its green means only that it reached the end. catalogue says the attenuation floor is 0.62 *because* below it the attenuated content falls under 3:1 once rendered — and nobody had ever rendered it and looked. It now reads the WCAG ratio between the attenuated thread heading and its -background on a real frame, with the crop projected out of `slab.ts` instead of +background on a real frame, with the crop projected out of `topics/geometry.ts` instead of picked by eye, and gets 4.17:1 here — 3.84:1 in CI, because Linux renders the same text with different fonts. Same verdict, and a reminder of why the number is a floor and not an equality. The same scene rendered at 0.25, which is what @@ -588,7 +727,7 @@ Linux in CI — everything shifts and a fixed crop lands on empty background. It exited 3 there, "could not measure", which was at least the honest answer rather than a verdict about a scene that was fine. The heading it reads instead sits at `THREAD_TOP`, which is a constant, so its position is arithmetic. The same -lesson `slab.ts` already records about card heights, learned again one floor +lesson `topics/geometry.ts` already records about card heights, learned again one floor down. How it reads matters too: background is the modal value of the crop, foreground @@ -639,7 +778,7 @@ thread down there — static relative to the slab, but moving with the camera li everything else — thousands of high-contrast text pixels drag the centroid down. The reading fell from 154px to 83 and invented a backward step: the scene blamed for a change in the instrument's surroundings. It now diffs only the board's half -of the frame, and where that half ends is read from `slab.ts` rather than picked +of the frame, and where that half ends is read from `topics/geometry.ts` rather than picked by eye, so it follows if the assistant moves. `handoff-travel.sh` had never run on macOS. Its centroid step was a heredoc @@ -676,9 +815,9 @@ playing `BoardOrbit`, in the order they join), the four rules, the license note. Beside it, `showcase/grammatica.html` is the catalogue: thirty-six movements with a live demo each, the numbers they start from, and the bench that -can fail them. Seven of those benches are printed in grey, because six have not -been written and one needs material that is not in git; the page counts them from -its own data, since the hand-written count said thirty-two when it was twenty-nine. The demos are browser re-creations of the slab, not the renders, +can fail them. The ones without a bench that runs and can fail are printed in grey, +and the page counts them from its own data: the hand-written count once said +thirty-two when it was twenty-nine. The demos are browser re-creations of the slab, not the renders, which is the point of keeping them next to the renders rather than instead of them. Neither page carries a build step or a dependency, so what you open locally is what ships. @@ -733,15 +872,16 @@ from a fresh one, which is the whole argument for letting CI do it. The order matters, and step 4 is the one people skip. 0. **Decide the pose it enters from and the pose it leaves in**, and put both in - `primitives/slab.ts`. `CARD_HANDOFF_END_POSE` spent three scenes as three - literals inside `CardHandoff.tsx`, which was fine exactly as long as nothing - came after it. -1. **Write it in `video/src/scenes/`.** Take `progress?: number` and derive + the product's `geometry.ts`, `products/topics/geometry.ts` for Topics. + `CARD_HANDOFF_END_POSE` spent three scenes as three literals inside + `CardHandoff.tsx`, which was fine exactly as long as nothing came after it. +1. **Write it in `video/src/products//scenes/`.** Take `progress?: number` and derive everything else from `useCurrentFrame()`. If you reach for `Date.now()`, `Math.random()` or a CSS keyframe, the scene is no longer reproducible and `framelocked-verdict.sh` will say so. -2. **Reuse `primitives/`.** `slab.ts` holds the geometry and the camera poses, - `SlabChrome.tsx` the app furniture. A scene that redraws its own sidebar can +2. **Reuse the kit and the product folder.** `kit/Shot.tsx` draws the slab, + `kit/camera.ts` turns a track into a pose; the product's `geometry.ts` holds + the poses, `tracks.ts` the camera, `SlabChrome.tsx` the app furniture. A scene that redraws its own sidebar can only stay aligned with the others by hand, and it will not. 3. **Add one entry to `video/src/scenes/catalog.json`** — id, slug, duration, the blurb for the page, and `seamAfter` if it follows another scene — then @@ -763,8 +903,8 @@ The order matters, and step 4 is the one people skip. to the deploy. Change a scene or a bench and the full run comes back. If your scene is meant to follow another without a cut, read the previous -scene's end pose from `slab.ts` rather than retyping the numbers, and name that -scene in `seamAfter`. Two copies of the same pose stay equal exactly as long as nobody +scene's end pose from the product's `geometry.ts` rather than retyping the +numbers, and name that scene in `seamAfter`. Two copies of the same pose stay equal exactly as long as nobody edits one of them. ## Licensing, which has two halves diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index 3dff143..06150ae 100755 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -42,9 +42,8 @@ const commands = { // elenco, e fingere il contrario nasconderebbe che esistono. // // `fill` NON GENERA PIU' UN COMANDO. fill-measure.sh promuove anche una lastra - // arretrata (vedi la sua intestazione), quindi in questo elenco sarebbe un - // verde che non dice niente. Il campo resta nel catalogo: dichiara la promessa - // che il banco geometrico del riempimento verifichera'. + // arretrata (vedi la sua intestazione). Il campo lo legge fill-geom.py, che + // verifica il riempimento in geometria sulle tracce della camera. measures: () => { for (const s of scenes) { if (s.seamAfter) { diff --git a/scripts/chain-check.py b/scripts/chain-check.py new file mode 100755 index 0000000..c1342d3 --- /dev/null +++ b/scripts/chain-check.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +GIU-04: la camera della catena non torna mai indietro mentre si muove. + +PERCHE' ESISTE. La voce GIU-04 della grammatica diceva "l'occhio segue la +derivata, e un'inversione a una giunta si legge come uno stacco anche quando i +pixel coincidono", e nominava un banco che non esisteva. seam.sh misura che due +fotogrammi si somigliano; non puo' vedere il verso del movimento. Questo lo +misura sulle tracce della camera (products/topics/tracks.ts), cioe' sugli stessi +dati che le scene passano al render. + +COSA CONTROLLA, su yaw, pitch, spinta e spostamenti, lungo tutte le scene del +catalogo nell'ordine in cui si agganciano: + - dentro una scena la derivata non cambia segno; + - a una giunta l'ultima posa di una scena e' la prima della successiva; + - a una giunta il verso cambia solo se la camera e' ferma da tutte e due le + parti (velocita' di confine sotto il 5% della massima di quell'asse). +Le inversioni a riposo si stampano senza bocciarle: sono ammesse perche' dove i +due lati sono fermi non c'e' una derivata da rovesciare. + +IL NEGATIVO: --linear toglie gli easing a tutte le tracce. Le giunte smettono di +essere a riposo e le stesse inversioni diventano inversioni in moto. Con +--must-fail il banco esce 0 solo se se ne accorge. + +Uso: chain-check.py [--linear] [--must-fail] + +Esce 0 se la catena non ha salti ne' inversioni in moto (con --must-fail: se ne +ha), 1 altrimenti, 3 se il manifest non risponde. +""" +import argparse +import json +import os +import subprocess +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +ap = argparse.ArgumentParser() +ap.add_argument("--linear", action="store_true") +ap.add_argument("--must-fail", action="store_true") +args = ap.parse_args() + +cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "chain"] +if args.linear: + cmd.append("--linear") +r = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) +if r.returncode != 0: + print("il manifest non risponde:\n" + r.stderr, file=sys.stderr) + sys.exit(3) +data = json.loads(r.stdout) + +print(f"GIU-04 sulla catena: {' → '.join(data['scenes'])}" + f"{' (easing tolti)' if args.linear else ''}") +gravi = [f for f in data["findings"] if f["kind"] != "inversione a riposo"] +for f in data["findings"]: + print(f" {f['kind']:20} {f['axis']:7} {f['where']:34} {f['detail']}") +if not data["findings"]: + print(" nessuna inversione e nessun salto") + +if args.must_fail: + if gravi: + print(f"VERDETTO: il banco trova {len(gravi)} inversioni in moto o salti, come deve.") + sys.exit(0) + print("VERDETTO: il banco PROMUOVE una catena che si inverte in moto.") + sys.exit(1) + +if gravi: + print(f"VERDETTO: {len(gravi)} fra inversioni in moto e salti. La camera torna indietro mentre si muove.") + sys.exit(1) +riposo = len(data["findings"]) +print(f"VERDETTO: la camera non torna mai indietro in moto; {riposo} inversioni, tutte a giunte ferme.") +sys.exit(0) diff --git a/scripts/contrast-floor.py b/scripts/contrast-floor.py index d10358a..41ef17c 100755 --- a/scripts/contrast-floor.py +++ b/scripts/contrast-floor.py @@ -10,7 +10,7 @@ COSA MISURA. Il rapporto di contrasto WCAG fra il testo attenuato e il suo fondo, su un fotogramma vero, mentre la risposta scorre. Il ritaglio non e' scelto a occhio: e' la fascia di intestazione del thread, e la sua posizione -esce da slab.ts proiettata con la posa finale di PromptInput. Quella posa sta a +esce da topics/geometry.ts proiettata con la posa finale di PromptInput. Quella posa sta a yaw e pitch zero, quindi la proiezione e' esatta. IL RITAGLIO STA SU UNA POSIZIONE ARITMETICA, e la prima versione no. Puntava la @@ -62,37 +62,15 @@ print("manca il render: %s" % SRC, file=sys.stderr) raise SystemExit(1) -# La geometria viene dal modulo, non da due numeri copiati qui. +# La geometria viene dal manifest, non da due numeri copiati qui: l'intestazione +# del thread all'ultima posa di PromptInput, proiettata e con dimensioni pari +# (video/src/products/topics/benches.ts spiega perche'). geo = subprocess.run( - ["node", "--input-type=module", "-e", """ -const m = await import("%s/video/src/primitives/slab.ts"); -const p = m.PROMPT_INPUT_END_POSE; -const k = m.zoomForPush(p.pushZ); -const ox = m.COMP_W / 2, oy = m.COMP_H * m.PERSPECTIVE_ORIGIN_Y; -// L'intestazione del thread: contenuto vero, attenuato per costruzione mentre -// la risposta scorre, e in una posizione che e' aritmetica invece che -// dipendente da come vanno a capo i messaggi. -const x0 = m.SIDEBAR_W + 20, x1 = m.SIDEBAR_W + 560; -const y0 = m.THREAD_TOP + 10, y1 = m.THREAD_TOP + 44; -const P = (x, y) => { - const s = m.slabPointOnScreen(x, y); - return [ox + (s.x + p.slideX - ox) * k, oy + (s.y + (p.slideY ?? 0) - oy) * k]; -}; -const a = P(x0, y0), b = P(x1, y1); -// Dimensioni PARI: su una sorgente yuv420p ffmpeg arrotonda un ritaglio -// dispari al pixel sotto, restituisce una riga in meno di quella chiesta, e il -// controllo sulla lunghezza del buffer scatta dicendo che l'estrazione e' -// fallita quando invece e' solo diversa di uno. -const pari = (v) => 2 * Math.floor(v / 2); -console.log(JSON.stringify({ - x: pari(Math.max(0, Math.round(a[0]))), y: pari(Math.round(a[1])), - w: pari(Math.round(b[0] - a[0])), h: pari(Math.round(b[1] - a[1])), -})); -""" % ROOT], + ["node", "%s/scripts/manifest.mjs" % ROOT, "contrast-crop"], capture_output=True, text=True, ) if geo.returncode != 0: - print("non riesco a leggere la geometria da slab.ts:\n" + geo.stderr, file=sys.stderr) + print("non riesco a leggere la geometria dal manifest:\n" + geo.stderr, file=sys.stderr) raise SystemExit(3) r = json.loads(geo.stdout.strip().splitlines()[-1]) if r["w"] < 40 or r["h"] < 12: @@ -153,7 +131,7 @@ def lum(v255): ratio = (l1 + 0.05) / (l2 + 0.05) print("Contrasto del contenuto attenuato su %s, fotogramma %d." % (SRC.name, FRAME)) -print("Ritaglio sull'intestazione del thread, proiettato da slab.ts: %dx%d a (%d,%d)." +print("Ritaglio sull'intestazione del thread, proiettato da topics/geometry.ts: %dx%d a (%d,%d)." % (r["w"], r["h"], r["x"], r["y"])) print() print(" fondo (valore piu' frequente) %3d" % bg) diff --git a/scripts/drift.py b/scripts/drift.py new file mode 100755 index 0000000..3d3765c --- /dev/null +++ b/scripts/drift.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +CAM-06 sul render: il bersaglio resta sull'origine della prospettiva mentre la +camera avanza, su due lastre e in tre rapporti. + +PERCHE' ESISTE. CAM-06 era dimostrata solo sulla pagina del catalogo, in un +palco 16:9 con la geometria di Topics ricopiata in JavaScript. I documenti di +regia dei primi film di prodotto ne avevano tratto una regola sbagliata ("il +46% non e' una frazione del quadro, e' il risultato della geometria"), e +nessun banco poteva smentirli perche' nessun banco guardava un altro rapporto. +Questo guarda sei varianti: Topics e la lastra sonda, in 16:9, 9:16 e 4:5. + +COSA MISURA. Ogni variante e' uno specimen (video/src/specimens/) in cui la +lastra viene spostata una volta con centreOn e poi spinta in Z fino a far +occupare al bersaglio il 90% del quadro. Sul bersaglio c'e' un anello magenta: +il banco ne prende il baricentro al primo e all'ultimo frame e lo confronta con +l'origine che il manifest calcola dallo stesso codice del render. Entro 2 px su +entrambi i frame, la variante passa. + +I DUE NEGATIVI, e il banco deve bocciarli in OGNI variante (--must-fail): + --props '{"compensate": false}' senza lo spostamento il bersaglio scappa + --props '{"originMismatch": true}' spostamento calcolato sul 46%, CSS al 50% +Il secondo e' il piu' sottile: al primo frame il segno e' esattamente dove il +manifest lo aspetta, e se ne va solo mentre la camera avanza. Un banco che +guardasse un frame solo lo promuoverebbe. + +Il segno fuori quadro, o assente, conta come difetto della variante: e' quello +che succede senza compensazione quando il bersaglio esce dal bordo. + +Uso: + drift.py [--only ID] [--props JSON] [--must-fail] + +Esce 0 se ogni variante sta entro la tolleranza (con --must-fail: se ognuna ne +esce), 1 altrimenti, 2 se il manifest non separa il caso giusto da quello +sbagliato, 3 se un render non e' uscito. +""" +import argparse +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +VIDEO = os.path.join(ROOT, "video") + +ap = argparse.ArgumentParser() +ap.add_argument("--only") +ap.add_argument("--props") +ap.add_argument("--must-fail", action="store_true") +args = ap.parse_args() + +manifest = json.loads( + subprocess.run( + ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "cam06"], + capture_output=True, text=True, check=True, cwd=ROOT, + ).stdout +) +if args.only: + manifest = [v for v in manifest if v["id"] == args.only] + if not manifest: + print(f"nessuna variante {args.only} nel manifest", file=sys.stderr) + sys.exit(2) + +# ImageMagick si chiama `magick` sulla 7 e `convert` sulla 6, che e' quella di CI. +CONVERT = ["magick"] if shutil.which("magick") else ["convert"] +MAGENTA = re.compile(rb"[\x80-\xff]") + + +def render(vid, frame, out): + cmd = ["npx", "remotion", "still", vid, out, f"--frame={frame}", + "--image-format=png", "--log=error"] + if args.props: + cmd.append(f"--props={args.props}") + r = subprocess.run(cmd, cwd=VIDEO, capture_output=True, text=True, + stdin=subprocess.DEVNULL) + return r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0 + + +def marker(png, w): + """Baricentro dei pixel magenta, e quanti sono. + + L'ORDINE DELLE DUE SOSTITUZIONI CONTA. La prima versione colorava di bianco + il magenta e poi metteva a nero tutto cio' che non era bianco: i pixel gia' + bianchi della lastra restavano nella maschera, e il baricentro finiva a + duecento pixel dal segno. Il banco bocciava anche il caso giusto, e con un + negativo che boccia tutto quello che si legge e' un verde. Prima si spegne + tutto cio' che non e' magenta, poi si accende il magenta. + """ + mask = subprocess.run( + CONVERT + [png, "-fuzz", "25%", "-fill", "black", "+opaque", "#ff00ff", + "-fill", "white", "-opaque", "#ff00ff", "-depth", "8", "gray:-"], + capture_output=True, check=True, + ).stdout + n = sx = sy = 0 + for m in MAGENTA.finditer(mask): + i = m.start() + sx += i % w + sy += i // w + n += 1 + # Il pixel i copre [i, i+1): il suo centro e' i + 0,5. Senza questo mezzo + # pixel ogni misura usciva spostata in alto a sinistra di 0,7 px. + return (sx / n + 0.5, sy / n + 0.5, n) if n else (None, None, 0) + + +tmp = tempfile.mkdtemp() +esito = {} +separa = True +print(f"CAM-06 sul render: il bersaglio sta sull'origine per tutta la spinta." + f"{' props ' + args.props if args.props else ''}") +print(f" {'variante':28} {'origine':>15} {'frame':>6} {'segno':>17} {'scarto':>8}") + +for v in manifest: + ox, oy = v["origin"]["x"], v["origin"]["y"] + # Se il punto non compensato cadesse gia' sull'origine, togliere la + # compensazione non cambierebbe niente e il negativo non potrebbe fallire. + ux, uy = v["uncompensated"]["x"], v["uncompensated"]["y"] + if math.hypot(ux - ox, uy - oy) < 10 * v["tolPx"]: + separa = False + print(f" {v['id']:28} il bersaglio non compensato e' gia' sull'origine: il negativo non separa") + peggiore = 0.0 + for f in v["frames"]: + out = os.path.join(tmp, f"{v['id']}-{f}.png") + if not render(v["id"], f, out): + print(f" {v['id']:28} frame {f}: RENDER FALLITO") + sys.exit(3) + mx, my, n = marker(out, v["width"]) + if n == 0: + print(f" {v['id']:28} {ox:7.1f},{oy:7.1f} {f:6d} {'fuori quadro':>17} {'inf':>8}") + peggiore = math.inf + continue + d = math.hypot(mx - ox, my - oy) + peggiore = max(peggiore, d) + print(f" {v['id']:28} {ox:7.1f},{oy:7.1f} {f:6d} {mx:8.1f},{my:8.1f} {d:8.2f}") + esito[v["id"]] = peggiore <= v["tolPx"] + +shutil.rmtree(tmp, ignore_errors=True) +print("") + +if not separa: + print("VERDETTO: nessuno. Almeno una variante non separa il caso giusto da quello sbagliato.") + sys.exit(2) + +dentro = [k for k, ok in esito.items() if ok] +fuori = [k for k, ok in esito.items() if not ok] + +if args.must_fail: + if dentro: + print(f"VERDETTO: il banco PROMUOVE un caso rotto in {len(dentro)} varianti su " + f"{len(esito)}: {', '.join(dentro)}. Non misura quello che dice.") + sys.exit(1) + print(f"VERDETTO: il caso rotto esce dalla tolleranza in tutte le {len(esito)} varianti, come deve.") + sys.exit(0) + +if fuori: + print(f"VERDETTO: il bersaglio scappa dall'origine in {len(fuori)} varianti su " + f"{len(esito)}: {', '.join(fuori)}.") + sys.exit(1) +print(f"VERDETTO: il bersaglio resta entro {manifest[0]['tolPx']} px dall'origine in tutte " + f"le {len(esito)} varianti, dal primo all'ultimo frame.") +sys.exit(0) diff --git a/scripts/fill-geom.py b/scripts/fill-geom.py new file mode 100755 index 0000000..f847152 --- /dev/null +++ b/scripts/fill-geom.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +CAM-01 in geometria: la lastra copre i quattro bordi del quadro. + +PERCHE' ESISTE. fill-measure.sh dava 20 su 20 anche su una lastra arretrata: +il fondale delle scene e' luminoso quanto la sua soglia, e il piano sfocato +dietro la lastra inganna qualunque misura sui pixel. Il riempimento e' una +domanda di geometria, e con le tracce della camera (products/topics/tracks.ts) e la +proiezione del kit (kit/project.ts, verificata contro Chromium) la si puo' fare +esattamente, su ogni fotogramma, senza renderizzare. + +COSA MISURA. Per ogni scena che dichiara `fill` in catalog.json, dal 20% della +durata all'ultimo frame, i quattro angoli della lastra si proiettano con la posa +di quel frame, e i quattro angoli del quadro devono cadere dentro. Il margine +e' la distanza dal lato piu' vicino: positivo coperto, negativo scoperto. Mezzo +pixel di tolleranza, perche' PromptInput porta per costruzione il bordo basso +della lastra esattamente sul bordo del quadro, e zero in virgola mobile puo' +uscire -1e-13. + +IL NEGATIVO: --push-offset -1500 arretra la camera su ogni posa. Con --must-fail +il banco esce 0 solo se tutte le scene risultano scoperte. + +Uso: fill-geom.py [--push-offset N] [--must-fail] + +Esce 0 se ogni scena che dichiara `fill` copre il quadro (con --must-fail: se +nessuna lo copre), 1 altrimenti, 3 se il manifest non risponde. +""" +import argparse +import json +import os +import subprocess +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +TOL = 0.5 + +ap = argparse.ArgumentParser() +ap.add_argument("--push-offset", type=float, default=0) +ap.add_argument("--must-fail", action="store_true") +args = ap.parse_args() + +cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "fill"] +if args.push_offset: + cmd += ["--push-offset", str(args.push_offset)] +r = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) +if r.returncode != 0: + print("il manifest non risponde:\n" + r.stderr, file=sys.stderr) + sys.exit(3) +scenes = json.loads(r.stdout) +if not scenes: + print("nessuna scena dichiara fill: non c'e' niente da misurare", file=sys.stderr) + sys.exit(3) + +print("CAM-01 in geometria: i quattro angoli del quadro dentro la lastra proiettata" + f"{f', camera arretrata di {-args.push_offset:g}' if args.push_offset else ''}.") +scoperte = [] +for s in scenes: + ok = s["minMargin"] >= -TOL + esito = "coperto" if ok else "SCOPERTO" + extra = "" + if not ok: + scoperte.append(s["id"]) + first = next((x for x in [s["first"]] if x), None) + if first: + extra = f" dal frame {first['frame']}, angolo {first['corner']}" + print(f" {s['id']:12} f{s['from']}-f{s['to']} margine minimo {s['minMargin']:8.1f} px {esito}{extra}") + +if args.must_fail: + coperte = [s["id"] for s in scenes if s["id"] not in scoperte] + if coperte: + print(f"VERDETTO: il banco PROMUOVE una lastra arretrata in {', '.join(coperte)}.") + sys.exit(1) + print(f"VERDETTO: con la camera arretrata tutte le {len(scenes)} scene risultano scoperte, come deve.") + sys.exit(0) + +if scoperte: + print(f"VERDETTO: {len(scoperte)} scene lasciano scoperto un bordo del quadro: {', '.join(scoperte)}.") + sys.exit(1) +print(f"VERDETTO: tutte le {len(scenes)} scene che dichiarano fill coprono i quattro bordi, " + f"su ogni frame dal 20% della durata.") +sys.exit(0) diff --git a/scripts/fill-measure.sh b/scripts/fill-measure.sh index 5c1e4ee..fb95637 100755 --- a/scripts/fill-measure.sh +++ b/scripts/fill-measure.sh @@ -37,9 +37,9 @@ # # Quindi il verdetto sul riempimento non si prende dai pixel: si prende dalla # geometria, proiettando la lastra ai frame campionati e chiedendo che copra i -# quattro bordi del quadro. Finche' quel banco non esiste lo script resta una -# lettura, esce dalle misure della CI, e CAM-01 sulla pagina e' in grigio. Un -# banco che non puo' fallire non va lasciato in un elenco di banchi che possono. +# quattro bordi del quadro. E' fill-geom.py, e gira in CI al posto di questo, che +# resta una lettura. Un banco che non puo' fallire non va lasciato in un elenco +# di banchi che possono. # # Uso: ./scripts/fill-measure.sh set -uo pipefail diff --git a/scripts/fixture-screenshot.sh b/scripts/fixture-screenshot.sh index 7a9e819..d429762 100755 --- a/scripts/fixture-screenshot.sh +++ b/scripts/fixture-screenshot.sh @@ -18,18 +18,11 @@ SRC="${1:-$ROOT/video/out/card-focus.mp4}" OUT="${2:-$ROOT/video/out/.fixture-card-focus-screenshot.mp4}" [ -f "$SRC" ] || { echo "manca il render: $SRC" >&2; exit 1; } -read -r CX CY WX WY K < <(node --input-type=module -e ' -const m = await import("'"$ROOT"'/video/src/primitives/slab.ts"); -const r = m.handoffLandedRect(); -const p = m.slabPointOnScreen(r.x + r.w / 2, r.y + r.h / 2); -const ox = m.COMP_W / 2, oy = m.COMP_H * m.PERSPECTIVE_ORIGIN_Y; -const k1 = m.zoomForPush(m.CARD_FOCUS_END_POSE.pushZ); -const k0 = m.zoomForPush(m.CARD_HANDOFF_END_POSE.pushZ); -console.log(Math.round(ox), Math.round(oy), - Math.round(ox + (p.x - ox) * k0), Math.round(oy + (p.y - oy) * k0), - (k1 / k0).toFixed(4)); -' 2>/dev/null) -case "${K:-}" in ''|*[!0-9.]*) echo "geometria non arrivata da slab.ts" >&2; exit 3 ;; esac +# La stessa geometria di focus-sharpness.sh, dallo stesso manifest: il centro +# della composizione, dove sta la card nel campo largo, e l'ingrandimento che +# manca per arrivare alla scala finale. +read -r CX CY WX WY K < <(node "$ROOT/scripts/manifest.mjs" fixture-screenshot 2>/dev/null) +case "${K:-}" in ''|*[!0-9.]*) echo "geometria non arrivata dal manifest" >&2; exit 3 ;; esac T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT mkdir -p "$T/f" diff --git a/scripts/focus-sharpness.sh b/scripts/focus-sharpness.sh index a67cd9b..853e4c3 100755 --- a/scripts/focus-sharpness.sh +++ b/scripts/focus-sharpness.sh @@ -38,10 +38,10 @@ # 1. E' la condizione che il README chiede prima di fidarsi del verde, e la # prima versione di questo banco non la superava: promuoveva la fixture. # -# La geometria del ritaglio non e' scritta qui. Viene da primitives/slab.ts, -# perche' e' la stessa sorgente da cui la scena calcola la sua posa finale: una -# costante ricopiata a mano in bash resta giusta solo fino alla prima modifica -# della lastra. +# La geometria del ritaglio non e' scritta qui. La calcola il manifest +# (scripts/manifest.mjs) da products/topics/geometry.ts, che e' la stessa sorgente da cui +# la scena prende la sua posa finale: una costante ricopiata a mano in bash resta +# giusta solo fino alla prima modifica della lastra. # # Uso: ./scripts/focus-sharpness.sh [card-focus.mp4] set -uo pipefail @@ -65,26 +65,14 @@ SOGLIA=1.50 exit 1 } -# La card, in pixel di composizione, all'ultimo fotogramma. Node legge il modulo -# della lastra direttamente: gli stessi numeri che usa la scena. -read -r CW CH CX CY ZOOM WX WY K < <(node --input-type=module -e ' -const m = await import("'"$ROOT"'/video/src/primitives/slab.ts"); -const r = m.handoffLandedRect(); -const p = m.slabPointOnScreen(r.x + r.w / 2, r.y + r.h / 2); -const ox = m.COMP_W / 2, oy = m.COMP_H * m.PERSPECTIVE_ORIGIN_Y; -const k1 = m.zoomForPush(m.CARD_FOCUS_END_POSE.pushZ); // ingrandimento finale -const k0 = m.zoomForPush(m.CARD_HANDOFF_END_POSE.pushZ); // ingrandimento al primo fotogramma -// Dove sta la card nel campo largo, e quanto manca da li alla scala finale. -const wx = ox + (p.x - ox) * k0; -const wy = oy + (p.y - oy) * k0; -console.log( - Math.round(r.w * m.SLAB_SCALE * k1), Math.round(r.h * m.SLAB_SCALE * k1), - Math.round(ox), Math.round(oy), k1.toFixed(4), - Math.round(wx), Math.round(wy), (k1 / k0).toFixed(4)); -' 2>/dev/null) +# La card, in pixel di composizione, all'ultimo fotogramma: dimensioni, centro, +# ingrandimento finale, dove stava nel campo largo e quanto manca da li' alla +# scala finale. Li calcola il manifest (video/src/products/topics/benches.ts) dagli +# stessi numeri che usa la scena. +read -r CW CH CX CY ZOOM WX WY K < <(node "$ROOT/scripts/manifest.mjs" focus-sharpness 2>/dev/null) case "${CW:-}|${CH:-}|${ZOOM:-}|${K:-}" in - *'|'|'|'*|'') echo "la geometria non e' arrivata da slab.ts: '$CW' '$CH' '$ZOOM'" >&2; exit 3 ;; + *'|'|'|'*|'') echo "la geometria non e' arrivata dal manifest: '$CW' '$CH' '$ZOOM'" >&2; exit 3 ;; esac case "$CW$CH" in ''|*[!0-9]*) echo "geometria non numerica: '$CW' '$CH'" >&2; exit 3 ;; esac @@ -176,6 +164,6 @@ fi echo "FALLITO: solo ${rapporto}x contro lo screenshot, sotto la soglia di ${SOGLIA}x." >&2 echo "O la lastra ha smesso di essere DOM da qualche parte lungo la catena," >&2 -echo "oppure CARD_FOCUS_ZOOM in primitives/slab.ts e' salito oltre quello che" >&2 +echo "oppure CARD_FOCUS_ZOOM in products/topics/geometry.ts e' salito oltre quello che" >&2 echo "la rasterizzazione regge." >&2 exit 1 diff --git a/scripts/geometry-snapshot.mjs b/scripts/geometry-snapshot.mjs new file mode 100755 index 0000000..6fb4a07 --- /dev/null +++ b/scripts/geometry-snapshot.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// +// La geometria di topics/geometry.ts fotografata in un JSON, per dimostrare che un +// refactor non l'ha toccata. +// +// PERCHE' ESISTE. Spostare la matematica della ripresa in `kit/` doveva lasciare +// le pose di Topics identiche AL BIT, non "uguali a occhio": una posa diversa +// nell'ultima cifra sposta un pixel in un punto qualsiasi del film, e una +// giunta che era esatta smette di esserlo senza che nessuno la tocchi. Il +// confronto e' fra due stringhe, e JSON.stringify scrive ogni numero con le +// cifre che servono a ricostruirlo esattamente, quindi due stringhe uguali +// sono due geometrie uguali. +// +// Uso: +// node scripts/geometry-snapshot.mjs [percorso/di/slab.ts] > geometria.json +// +// Il percorso serve a fotografare anche la versione di un altro commit, estratta +// con `git worktree add`. +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const here = fileURLToPath(new URL(".", import.meta.url)); +const target = resolve( + process.argv[2] ?? `${here}/../video/src/products/topics/geometry.ts`, +); +const m = await import(pathToFileURL(target).href); + +// Le funzioni di ripresa di Topics sono state prima esportate dal modulo della +// lastra e poi tolte a favore del kit. Per confrontare un commit di prima con uno +// di dopo, lo strumento usa quelle esportate se ci sono, altrimenti il kit con +// lo stage e il rig di Topics: gli stessi numeri, se il refactor e' onesto. +const kitPath = ["../../kit/rig.ts", "../kit/rig.ts"] + .map((rel) => resolve(target, "..", rel)) + .find((p) => existsSync(p)); +const kit = + typeof m.slabPointOnScreen === "function" || !kitPath + ? null + : await import(pathToFileURL(kitPath).href); +const fn = kit + ? { + slabPointOnScreen: (x, y) => + kit.slabPointOnScreen(m.TOPICS_STAGE, m.TOPICS_RIG, m.TOPICS_SLAB, { x, y }), + centreOn: (x, y) => kit.centreOn(m.TOPICS_STAGE, m.TOPICS_RIG, m.TOPICS_SLAB, { x, y }), + zoomForPush: (z) => kit.zoomForPush(m.TOPICS_RIG, z), + pushForZoom: (k) => kit.pushForZoom(m.TOPICS_RIG, k), + } + : m; + +const out = { constants: {}, poses: {}, functions: {} }; + +for (const [k, v] of Object.entries(m).sort(([a], [b]) => a.localeCompare(b))) { + if (typeof v === "number") out.constants[k] = v; + else if (v && typeof v === "object" && "yaw" in v && "pushZ" in v) out.poses[k] = v; +} +out.constants.COLUMNS = m.COLUMNS; + +// Punti campione: gli angoli e il centro della lastra, piu' due punti qualsiasi +// non allineati a niente, perche' un errore d'ordine nelle operazioni si vede +// dove i numeri non sono tondi. +const pts = [ + [0, 0], + [m.SLAB_W, m.SLAB_H], + [m.SLAB_W / 2, m.SLAB_H / 2], + [417.3, 918.61], + [2011.07, 131.9], +]; +out.functions.slabPointOnScreen = pts.map(([x, y]) => fn.slabPointOnScreen(x, y)); +out.functions.centreOn = pts.map(([x, y]) => fn.centreOn(x, y)); +out.functions.zoomForPush = [0, 48, 96, 1180, 1493.6, -140, -420].map((z) => + fn.zoomForPush(z), +); +out.functions.pushForZoom = [1, 1.12, 2.35, 0.9].map((k) => fn.pushForZoom(k)); +out.functions.handoffLandedRect = m.handoffLandedRect(); +out.functions.cardY = m.COLUMNS.map((c) => + c.cards.map((_, i) => m.cardY(c.cards, i)), +); +out.functions.addCardY = m.COLUMNS.map((c) => m.addCardY(c.cards)); + +process.stdout.write(JSON.stringify(out, null, 1) + "\n"); diff --git a/scripts/handoff-travel.sh b/scripts/handoff-travel.sh index df9fd17..c5096a3 100755 --- a/scripts/handoff-travel.sh +++ b/scripts/handoff-travel.sh @@ -67,12 +67,10 @@ MIN_TRAVEL_PX=120 # cioe' dava la colpa alla scena per un difetto dello strumento. # # Il taglio non e' un numero a occhio: e' dove comincia il pannello sulla -# lastra, letto da slab.ts. Se il pannello si sposta, il taglio lo segue. -read -r BAND < <(node --input-type=module -e ' -const m = await import("'"$ROOT"'/video/src/primitives/slab.ts"); -console.log(Math.round((m.THREAD_TOP / m.SLAB_H) * 100) - 4); -') -[ -n "${BAND:-}" ] || { echo "non riesco a leggere la geometria da slab.ts" >&2; exit 3; } +# lastra, e lo calcola il manifest da topics/geometry.ts. Se il pannello si sposta, il +# taglio lo segue. +read -r BAND < <(node "$ROOT/scripts/manifest.mjs" handoff-band 2>/dev/null) +case "${BAND:-}" in ''|*[!0-9]*) echo "non riesco a leggere la geometria dal manifest: '${BAND:-}'" >&2; exit 3 ;; esac prev="" xs=() diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs new file mode 100755 index 0000000..1812acc --- /dev/null +++ b/scripts/manifest.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// +// Il manifest dei banchi: cosa renderizzare e cosa deve risultare sul render, +// calcolato dai moduli puri del kit e non riscritto dentro gli script. +// +// PERCHE' ESISTE. I banchi di questo repo leggevano la geometria importando +// topics/geometry.ts, cioe' le costanti di Topics: su un'altra lastra o in un altro +// rapporto non misuravano niente, e nessuno se ne sarebbe accorto perche' non +// fallivano. Qui un banco chiede "cosa devo trovare, e dove" per ogni variante, +// e la risposta viene dallo stesso codice che produce il render. +// +// Uso: +// node scripts/manifest.mjs cam06 le varianti dello specimen CAM-06, in JSON +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const load = (rel) => import(pathToFileURL(join(root, rel)).href); + +const argValue = (name) => { + const i = process.argv.indexOf(name); + return i === -1 ? undefined : process.argv[i + 1]; +}; + +const catalogScenes = async () => { + const { readFile } = await import("node:fs/promises"); + const c = JSON.parse( + await readFile(join(root, "video/src/scenes/catalog.json"), "utf8"), + ); + return c.scenes; +}; + +const commands = { + // GIU-04 sulla catena del catalogo: le tracce delle scene, nell'ordine in cui + // si agganciano, lette da checkChain. --linear toglie tutti gli easing, ed e' + // il controllo negativo: le giunte non sono piu' a riposo. + chain: async () => { + const { checkChain, linearized } = await load("video/src/kit/camera.ts"); + const { TOPICS_TRACKS } = await load("video/src/products/topics/tracks.ts"); + const scenes = (await catalogScenes()).map((s) => { + const build = TOPICS_TRACKS[s.id]; + if (!build) throw new Error(`nessuna traccia per ${s.id} in products/topics/tracks.ts`); + const track = build(s.durationInFrames); + return { + id: s.id, + frames: s.durationInFrames, + track: process.argv.includes("--linear") ? linearized(track) : track, + }; + }); + return { scenes: scenes.map((s) => s.id), findings: checkChain(scenes) }; + }, + + // CAM-01 in geometria: per ogni scena che dichiara `fill`, dal 20% della durata + // all'ultimo frame, i quattro angoli del quadro devono cadere dentro la lastra + // proiettata. --push-offset N sposta indietro la camera di N su ogni posa, ed e' + // il controllo negativo: la lastra arretrata. + fill: async () => { + const { poseAt } = await load("video/src/kit/camera.ts"); + const { project } = await load("video/src/kit/project.ts"); + const { TOPICS_TRACKS } = await load("video/src/products/topics/tracks.ts"); + const slab = await load("video/src/products/topics/geometry.ts"); + const offset = Number(argValue("--push-offset") ?? 0); + const stage = slab.TOPICS_STAGE; + const out = []; + for (const s of await catalogScenes()) { + if (!s.fill) continue; + const track = TOPICS_TRACKS[s.id](s.durationInFrames); + const from = Math.floor(s.durationInFrames * 0.2); + let first = null; + let minMargin = Infinity; + for (let f = from; f < s.durationInFrames; f++) { + const pose = poseAt(track, f); + pose.pushZ += offset; + const W = slab.TOPICS_SLAB.w; + const H = slab.TOPICS_SLAB.h; + const q = [ + [0, 0], + [W, 0], + [W, H], + [0, H], + ].map(([x, y]) => project(stage, slab.TOPICS_RIG, slab.TOPICS_SLAB, pose, { x, y })); + // Distanza con segno di un punto dai lati del quadrilatero convesso, + // positiva dentro. IL SEGNO NON SI ASSUME: dipende dal verso in cui si + // percorrono gli angoli, e con l'asse y che scende la prima versione lo + // aveva al contrario e dava ogni scena scoperta di 2300 px. Si prende dal + // centro della lastra, che dentro ci sta per forza. + const raw = (px, py) => + Math.min( + ...q.map((a, i) => { + const b = q[(i + 1) % 4]; + const ex = b.x - a.x; + const ey = b.y - a.y; + return ((px - a.x) * ey - (py - a.y) * ex) / Math.hypot(ex, ey); + }), + ); + const cx = (q[0].x + q[1].x + q[2].x + q[3].x) / 4; + const cy = (q[0].y + q[1].y + q[2].y + q[3].y) / 4; + const inward = q + .map((a, i) => { + const b = q[(i + 1) % 4]; + return (cx - a.x) * (b.y - a.y) - (cy - a.y) * (b.x - a.x); + }) + .every((v) => v > 0) + ? 1 + : -1; + const margin = (px, py) => + inward === 1 + ? raw(px, py) + : Math.min( + ...q.map((a, i) => { + const b = q[(i + 1) % 4]; + const ex = b.x - a.x; + const ey = b.y - a.y; + return -((px - a.x) * ey - (py - a.y) * ex) / Math.hypot(ex, ey); + }), + ); + const corners = [ + ["alto a sinistra", 0, 0], + ["alto a destra", stage.w, 0], + ["basso a destra", stage.w, stage.h], + ["basso a sinistra", 0, stage.h], + ]; + for (const [name, x, y] of corners) { + const m = margin(x, y); + minMargin = Math.min(minMargin, m); + if (m < 0 && !first) first = { frame: f, corner: name, margin: m }; + } + } + out.push({ id: s.id, from, to: s.durationInFrames - 1, first, minMargin }); + } + return out; + }, + + // La geometria che i banchi delle scene di Topics chiedevano a topics/geometry.ts con + // uno script node scritto dentro di se'. Stesso formato di uscita di prima, + // cosi' i banchi leggono con lo stesso `read`. + "handoff-band": async () => { + const t = await load("video/src/products/topics/benches.ts"); + return t.handoffBand(); + }, + "focus-sharpness": async () => { + const g = (await load("video/src/products/topics/benches.ts")).cardFocusGeometry(); + return [g.cw, g.ch, g.cx, g.cy, g.zoom, g.wx, g.wy, g.k].join(" "); + }, + "fixture-screenshot": async () => { + const g = (await load("video/src/products/topics/benches.ts")).cardFocusGeometry(); + return [g.cx, g.cy, g.wx, g.wy, g.k].join(" "); + }, + // Su una riga: contrast-floor.py legge l'ultima riga dell'uscita. + "contrast-crop": async () => { + const t = await load("video/src/products/topics/benches.ts"); + return JSON.stringify(t.contrastCrop()); + }, + + // I casi su cui project-check.py confronta la proiezione con il DOM: due + // lastre, tre stage, le sei pose della catena di Topics, cinque punti ciascuno + // messi apposta lontano dal centro e fuori dagli assi. + "project-cases": async () => { + const { STAGES, RATIOS } = await load("video/src/kit/stage.ts"); + const { cssPerspectiveOrigin } = await load("video/src/kit/rig.ts"); + const { project } = await load("video/src/kit/project.ts"); + const slab = await load("video/src/products/topics/geometry.ts"); + const probe = await load("video/src/products/probe/geometry.ts"); + const slabs = { + topics: { rig: slab.TOPICS_RIG, size: slab.TOPICS_SLAB }, + probe: { rig: probe.PROBE_RIG, size: probe.PROBE_SLAB }, + }; + const poses = [ + "UI_MOCKUP_START_POSE", + "UI_MOCKUP_END_POSE", + "CARD_HANDOFF_END_POSE", + "CARD_FOCUS_END_POSE", + "PROMPT_INPUT_END_POSE", + "BOARD_ORBIT_END_POSE", + ]; + const frac = [ + [0.1, 0.1], + [0.9, 0.1], + [0.5, 0.5], + [0.1, 0.9], + [0.83, 0.37], + ]; + const cases = []; + for (const ratio of RATIOS) { + const stage = STAGES[ratio]; + for (const [name, { rig, size }] of Object.entries(slabs)) { + for (const poseName of poses) { + const p = slab[poseName]; + const pose = { slideY: 0, ...p }; + cases.push({ + id: `${name}-${ratio}-${poseName}`, + stage, + rig, + origin: cssPerspectiveOrigin(rig), + slab: size, + pose, + points: frac.map(([fx, fy]) => { + const pt = { x: size.w * fx, y: size.h * fy }; + const s = project(stage, rig, size, pose, pt); + return { ...pt, sx: s.x, sy: s.y }; + }), + }); + } + } + } + return cases; + }, + + cam06: async () => { + const list = await load("video/src/specimens/list.ts"); + return list.CAM06_SPECIMENS.map((s) => ({ + id: s.id, + product: s.product, + ratio: s.ratio, + width: s.width, + height: s.height, + frames: [0, s.durationInFrames - 1], + // QUATTRO PIXEL, E NON I "DUE" DEI DOCUMENTI DI REGIA. Misurato: la card di + // Topics sta su coordinate frazionarie, la lastra e' scalata 1,04 e la + // spinta la ingrandisce tre volte, e la rasterizzazione sposta l'anello + // fino a 2,2 px all'ultimo frame del 16:9, contro 0,6 della lastra sonda, + // che ha coordinate intere e scala 1. I due negativi scappano di almeno + // 40 px in ogni variante: 4 sta dieci volte sotto il caso rotto e lascia + // spazio alla differenza di rasterizzazione fra macOS e il Linux della CI. + tolPx: 4, + ...list.cam06Expectation(s.product, s.ratio), + })); + }, +}; + +const cmd = process.argv[2]; +if (!commands[cmd]) { + console.error(`uso: node scripts/manifest.mjs <${Object.keys(commands).join("|")}>`); + process.exit(2); +} +const result = await commands[cmd](); +// Le righe gia' formattate escono cosi' come sono, per i `read` della shell; +// il resto esce in JSON. +process.stdout.write( + (typeof result === "string" || typeof result === "number" + ? String(result) + : JSON.stringify(result, null, 1)) + "\n", +); diff --git a/scripts/no-product-literals.sh b/scripts/no-product-literals.sh new file mode 100755 index 0000000..44a1ab4 --- /dev/null +++ b/scripts/no-product-literals.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Qualche banco si calcola ancora la geometria da solo, importando i moduli di +# un prodotto? +# +# PERCHE' ESISTE. Quattro banchi importavano topics/geometry.ts dentro uno script node +# scritto nel proprio corpo e rifacevano i conti di ripresa di Topics. Adesso li +# chiedono a scripts/manifest.mjs, che e' l'unico posto in cui un banco puo' +# leggere video/src. Senza un controllo, il primo banco nuovo scritto di fretta +# tornerebbe a importare topics/geometry.ts e a misurare solo Topics, e nessuno se ne +# accorgerebbe perche' non fallirebbe. +# +# COSA CERCA. Nelle righe di codice degli script (non nei commenti) un percorso +# a un modulo .ts o .tsx di video/src. Sono ammessi solo manifest.mjs, che +# esiste per quello, e geometry-snapshot.mjs, che fotografa topics/geometry.ts apposta per +# confrontarne due versioni. +# +# Uso: ./scripts/no-product-literals.sh [cartella-degli-script] +# +# Esce 0 se nessun banco legge video/src da se', 1 altrimenti. +set -uo pipefail + +DIR="${1:-$(cd "$(dirname "$0")" && pwd)}" +FOUND=0 + +for f in "$DIR"/*.sh "$DIR"/*.py "$DIR"/*.mjs; do + [ -f "$f" ] || continue + case "$(basename "$f")" in + manifest.mjs | geometry-snapshot.mjs | no-product-literals.sh) continue ;; + esac + # Le righe di commento (# in shell e python, // in js) non contano: ci si + # scrive da dove viene un numero, ed e' giusto che lo si scriva. + hits=$(grep -n -E 'video/src/[^ "'"'"']+\.tsx?' "$f" \ + | grep -v -E '^[0-9]+:[[:space:]]*(#|//)' || true) + if [ -n "$hits" ]; then + echo " $(basename "$f") legge video/src da se':" + echo "$hits" | sed 's/^/ /' + FOUND=1 + fi +done + +if [ "$FOUND" -eq 1 ]; then + echo "VERDETTO: almeno un banco si calcola la geometria di un prodotto invece di chiederla al manifest." + exit 1 +fi +echo "VERDETTO: nessun banco legge video/src da se'; la geometria passa tutta da manifest.mjs." +exit 0 diff --git a/scripts/project-check.py b/scripts/project-check.py new file mode 100755 index 0000000..65c7f00 --- /dev/null +++ b/scripts/project-check.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +La proiezione del kit coincide con quella di Chrome? + +PERCHE' ESISTE. `kit/project.ts` rifa in aritmetica la catena CSS del blocco di +ripresa: posizione, rotazioni, spinta, prospettiva con la sua origine. Serve ai +banchi che devono sapere dove sta un elemento con la camera inclinata e in +movimento, cioe' a quasi tutti quelli che verranno. Un'aritmetica che sbaglia +un segno di rotazione produce ritagli spostati di cento pixel e verdetti su +parti di quadro in cui l'elemento non c'e', quindi prima di usarla la si +confronta con il motore che disegna davvero. + +COSA MISURA. Per ogni caso (due lastre, tre stage, le sei pose della catena di +Topics) costruisce in Chromium lo stesso contenitore prospettico e la stessa +lastra trasformata, mette un segno di 2x2 px su cinque punti della lastra e +legge il centro di `getBoundingClientRect`. Lo confronta con il punto che il +manifest calcola con `project()`. Passa sotto mezzo pixel. + +IL NEGATIVO: `--origin-mismatch` mette nel CSS l'origine 50% 50% invece di +quella del rig. Con --must-fail il banco esce 0 solo se l'errore massimo supera +20 px, cioe' se si accorge dell'origine sbagliata. + +Uso: project-check.py [--origin-mismatch] [--must-fail] + +Esce 0 se ogni punto coincide (con --must-fail: se l'errore massimo supera +20 px), 1 altrimenti, 3 se Chromium non parte. +""" +import argparse +import json +import math +import os +import subprocess +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +ap = argparse.ArgumentParser() +ap.add_argument("--origin-mismatch", action="store_true") +ap.add_argument("--must-fail", action="store_true") +args = ap.parse_args() + +TOL = 0.5 +NEG = 20.0 + +cases = json.loads( + subprocess.run( + ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "project-cases"], + capture_output=True, text=True, check=True, cwd=ROOT, + ).stdout +) + +try: + from playwright.sync_api import sync_playwright +except ImportError: + print("playwright non e' installato: non ho misurato niente", file=sys.stderr) + sys.exit(3) + +PAGE = """ +
+
+{markers} +
""" + +peggiore = 0.0 +peggiore_caso = "" +fuori = 0 + +with sync_playwright() as pw: + # Come gli altri banchi della pagina: in CI il chromium lo porta playwright, + # su un portatile spesso c'e' solo Chrome installato. + try: + browser = pw.chromium.launch(headless=True) + except Exception: # noqa: BLE001 + try: + browser = pw.chromium.launch(headless=True, channel="chrome") + except Exception as e: # noqa: BLE001 + print(f"Chromium non parte: {e}", file=sys.stderr) + sys.exit(3) + page = browser.new_page(device_scale_factor=1) + for c in cases: + st, rig, sl, po = c["stage"], c["rig"], c["slab"], c["pose"] + page.set_viewport_size({"width": st["w"], "height": st["h"]}) + markers = "".join( + f'' + for i, p in enumerate(c["points"]) + ) + page.set_content(PAGE.format( + w=st["w"], h=st["h"], P=rig["perspective"], + origin="50% 50%" if args.origin_mismatch else c["origin"], + left=(st["w"] - sl["w"]) / 2 + po["slideX"], + top=(st["h"] - sl["h"]) / 2 + po["slideY"], + sw=sl["w"], sh=sl["h"], push=po["pushZ"], yaw=po["yaw"], + pitch=po["pitch"], s=rig["slabScale"], markers=markers, + )) + rects = page.evaluate( + "n => Array.from({length: n}, (_, i) => {" + " const r = document.getElementById('m' + i).getBoundingClientRect();" + " return [r.left + r.width / 2, r.top + r.height / 2]; })", + len(c["points"]), + ) + err = max(math.hypot(rx - p["sx"], ry - p["sy"]) for (rx, ry), p in zip(rects, c["points"])) + if err > TOL: + fuori += 1 + if err > peggiore: + peggiore, peggiore_caso = err, c["id"] + browser.close() + +print(f"Proiezione del kit contro Chromium: {len(cases)} casi, 5 punti ciascuno" + f"{', origine sbagliata nel CSS' if args.origin_mismatch else ''}.") +print(f" errore massimo {peggiore:.3f} px ({peggiore_caso}), casi oltre {TOL} px: {fuori}") + +if args.must_fail: + if peggiore > NEG: + print(f"VERDETTO: con l'origine sbagliata l'errore arriva a {peggiore:.1f} px, e il banco se ne accorge.") + sys.exit(0) + print(f"VERDETTO: il banco PROMUOVE un'origine sbagliata: errore massimo {peggiore:.1f} px, sotto {NEG}.") + sys.exit(1) + +if fuori: + print(f"VERDETTO: in {fuori} casi la proiezione del kit sbaglia di piu' di {TOL} px.") + sys.exit(1) +print(f"VERDETTO: la proiezione del kit coincide con Chromium entro {TOL} px in tutti i {len(cases)} casi.") +sys.exit(0) diff --git a/scripts/seam.sh b/scripts/seam.sh index f98fd14..0584464 100755 --- a/scripts/seam.sh +++ b/scripts/seam.sh @@ -134,5 +134,5 @@ fi echo "VERDETTO: c'e' un salto fra $(basename "$A" .mp4) e $(basename "$B" .mp4)." >&2 echo "Le due pose non coincidono: la posa di giunzione va letta da" >&2 -echo "primitives/slab.ts da entrambe le scene, non riscritta in una delle due." >&2 +echo "products/topics/geometry.ts da entrambe le scene, non riscritta in una delle due." >&2 exit 1 diff --git a/scripts/still-identity.sh b/scripts/still-identity.sh new file mode 100755 index 0000000..254130e --- /dev/null +++ b/scripts/still-identity.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# I fotogrammi delle scene sono identici a quelli di un altro commit? +# +# PERCHE' ESISTE. Il kit sposta codice che disegna: la geometria prima, poi il +# blocco di ripresa che sei scene ricopiavano a mano. Quei passi promettono di +# non cambiare un pixel, e una promessa cosi' non si verifica guardando i video: +# due render della stessa sorgente, codificati in H.264, differiscono gia' di +# qualche migliaio di pixel per il rumore del codificatore. I PNG di +# `remotion still` invece sono ripetibili (lo prova framelocked-verdict.sh), +# quindi due hash uguali sono due fotogrammi uguali. +# +# COME. Il commit di base si estrae in un worktree temporaneo che usa gli stessi +# node_modules, si rendono gli stessi fotogrammi da li' e dal working tree, e si +# confrontano gli sha256. Per ogni scena: il primo, il secondo, quello di mezzo, +# il penultimo e l'ultimo. +# +# IL CONTROLLO DELLO STRUMENTO. Un fotogramma per scena si rende due volte dal +# working tree: se quei due hash non coincidono, lo strumento non e' ripetibile +# e un "diverso" non vorrebbe dire niente. Il controllo negativo lo fa la CI o +# chi lo lancia: spostare di un pixel una posa e vedere uscire 1. +# +# Uso: ./scripts/still-identity.sh [Composition ...] +# senza composition, tutte quelle di catalog.json +# FRAMES="20 40 80" ./scripts/still-identity.sh main UIMockup +# per scegliere i fotogrammi: servono quando un refactor tocca una finestra +# che i cinque di default non attraversano, come i primi 80 frame di UIMockup +# +# Esce 0 se tutti i fotogrammi coincidono, 1 se almeno uno cambia, 2 se lo +# strumento non e' ripetibile, 3 se un render non esce. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE_REF="${1:?serve il commit di base, per esempio origin/main}" +shift + +TMP="$(mktemp -d)" +cleanup() { + git -C "$ROOT" worktree remove --force "$TMP/base" >/dev/null 2>&1 + rm -rf "$TMP" +} +trap cleanup EXIT + +if [ "$#" -gt 0 ]; then + COMPS=("$@") +else + # shellcheck disable=SC2207 + COMPS=($(node "$ROOT/scripts/catalog.mjs" ids)) +fi + +if ! git -C "$ROOT" worktree add --detach "$TMP/base" "$BASE_REF" >/dev/null 2>&1; then + echo "non riesco a estrarre $BASE_REF in un worktree" >&2 + exit 3 +fi +ln -s "$ROOT/video/node_modules" "$TMP/base/video/node_modules" + +duration() { # composition -> durata, letta dal catalogo del working tree + python3 -c " +import json,sys +c=json.load(open('$ROOT/video/src/scenes/catalog.json')) +d={s['id']:s['durationInFrames'] for s in c['scenes']} +print(d.get(sys.argv[1], 0))" "$1" +} + +still() { # cartella-video composition frame file + (cd "$1" && npx remotion still "$2" "$4" --frame="$3" --image-format=png --log=error \ + >/dev/null 2>&1 < /dev/null) && [ -s "$4" ] +} + +hash() { shasum -a 256 "$1" | cut -c1-16; } + +echo "Fotogrammi di $BASE_REF contro il working tree." +DIFF=0 +for comp in "${COMPS[@]}"; do + n=$(duration "$comp") + if [ "$n" -le 0 ]; then + echo " $comp: durata sconosciuta, non e' nel catalogo" >&2 + exit 3 + fi + mid=$((n / 2)) + frames="${FRAMES:-0 1 $mid $((n - 2)) $((n - 1))}" + line=" $(printf '%-13s' "$comp")" + for f in $frames; do + still "$TMP/base/video" "$comp" "$f" "$TMP/b-$comp-$f.png" || { echo "$line base f$f: RENDER FALLITO"; exit 3; } + still "$ROOT/video" "$comp" "$f" "$TMP/h-$comp-$f.png" || { echo "$line f$f: RENDER FALLITO"; exit 3; } + if [ "$(hash "$TMP/b-$comp-$f.png")" = "$(hash "$TMP/h-$comp-$f.png")" ]; then + line="$line f$f=" + else + line="$line f$f≠" + DIFF=$((DIFF + 1)) + fi + done + # Il controllo dello strumento: lo stesso fotogramma, due volte dal working + # tree. Il primo dell'elenco, che esiste anche quando i fotogrammi li sceglie + # chi lancia il banco: la prima versione ripeteva sempre quello di mezzo, e con + # FRAMES lo confrontava con un file mai renderizzato. + set -- $frames + rep="$1" + still "$ROOT/video" "$comp" "$rep" "$TMP/r-$comp.png" || { echo "$line ripetizione: RENDER FALLITO"; exit 3; } + if [ "$(hash "$TMP/r-$comp.png")" != "$(hash "$TMP/h-$comp-$rep.png")" ]; then + echo "$line (lo stesso fotogramma reso due volte cambia: strumento non ripetibile)" + exit 2 + fi + echo "$line" +done + +echo "" +if [ "$DIFF" -gt 0 ]; then + echo "VERDETTO: $DIFF fotogrammi cambiano rispetto a $BASE_REF." + exit 1 +fi +echo "VERDETTO: tutti i fotogrammi sono identici a $BASE_REF, e lo strumento e' ripetibile." +exit 0 diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 0a00e22..ae803b0 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -221,7 +221,7 @@ border: 1px solid hsl(220 11% 26%); border-radius: 6px; } /* ================= LA LASTRA ================= - Geometria da video/src/primitives/slab.ts, token da video/src/theme.ts, + Geometria da video/src/products/topics/geometry.ts, token da video/src/products/topics/tokens.ts, contenuto da SlabChrome.tsx. Font di sistema, raggi 8/6/4. */ .slab{ position:absolute;left:-1200px;top:-370px;width:2400px;height:1200px; @@ -323,8 +323,8 @@

Thirty-six movements,
no cuts.

is the film.

- Twenty-nine of the thirty-six name a bench that exists, runs in CI and - can fail. The other seven are printed in grey: six name a bench nobody + Thirty-one of the thirty-six name a bench that exists, runs in CI and + can fail. The other five are printed in grey: four name a bench nobody has written yet, and one names a bench that exists but cannot run in CI. A measurement nobody runs is a proposal, and this page would be doing the thing it warns against if it let the two look alike. @@ -418,7 +418,7 @@

Thirty-six movements,
no cuts.

seam.shA join that is really a cut in costume29 px against 59,895, 2065× handoff-travel.shA card that does not actually cross. A freeze frame passes the other checks with full marks, and for months this one did not run at all on macOS, printing a diagnosis about the scene for a fault in itself210 px, monotonic centroid beats.shA promised beat that is not on screen. The answer was assembling under the composer2 → 8 → 10 words streamed - fill-measure.shA frame that lets its edges die, which it cannot catch. The scenes sit on a background as bright as its threshold, so a slab shrunk to 60 per cent with the real background around it reads the same as a full frame. Kept as a readout and taken out of CI until the check is geometric20 of 20, on the defect too + fill-measure.shA frame that lets its edges die, which it cannot catch. The scenes sit on a background as bright as its threshold, so a slab shrunk to 60 per cent with the real background around it reads the same as a full frame. Kept as a readout; fill-geom.py does the job in CI, in geometry20 of 20, on the defect too legibility.shText that does not survive being scaled down. It reads the reference films beside the render, and those are not in git, so it runs on a laptop and not in CIholds under 640 px wide framelocked-verdict.shA scene that is not reproducible: it still renders, with different pixels. For months it printed its verdict and exited 0 either way, so it now has to fail a probe with a Math.random inside before its green countssame pixels every render, the probe fails focus-sharpness.shA slab that stopped being DOM: the text falls apart once the camera is on top of it2.09× against 1.03× on the fixture @@ -429,6 +429,11 @@

Thirty-six movements,
no cuts.

tempo.pyA scene that was shortened rather than sped up. If the beats scale, frame f of the short render is frame f/k of the long one; compared that way the two differ by 287 px against 5379 without normalising. The residual is not slop — it is the signature of the thresholds that deliberately did not scale18.7× advantage, 4 required fixture-trim.shMeasures nothing. Truncates a scene to the short duration instead of retiming it, which is what lowering the duration produced before any of this existed120 frames, beats left in place contrast-floor.pyContent attenuated so far that it stops being a plane behind and becomes dirt on the background. Two wrong versions before this one: the first measured the composer placeholder, which is deliberately faint and sits at 2.93:1 before any attenuation at all; the second measured the right content in a place whose position depends on how the messages wrap, and on Linux the crop landed on empty background4.17:1, 3.84 on Linux, against 1.71 + drift.pyA descent that slides past its subject, measured away from Topics: two slabs, one of them light and vertical, in three formats. Its second broken case starts exactly in place and only drifts while the camera pushes in, and the first version of the bench failed the correct case too, because the white of the probe slab ended up in the magenta mask2.03 px at worst of six, broken cases 39.5 to 99.8 + chain-check.pyA camera that reverses while it is moving, which reads as a cut even when the two frames match. It reads the tracks the scenes render from, so it needs no render, and the version of the chain with the easing taken out is its broken case7 reversals, all at rest + fill-geom.pyA slab that leaves an edge of the frame uncovered, checked on every frame from the camera tracks and the kit projection instead of from pixels. Its first run said BoardOrbit fails, and it does, on purpose: the orbit shows the edge5.4 px of cover at worst, all five fail pushed back + project-check.pyA projection that gets a rotation sign wrong. The kit works out where a point of the slab lands at any pose, tilted included, and every bench that watches a moving camera will crop by it, so it is checked against the engine that draws: Chromium, two slabs, three stages, the six poses of the chain0.040 px at worst, 103.7 with the origin wrong + no-product-literals.shA bench that works out the geometry of one product by itself instead of asking the manifest. Four benches did, which is how a bench ends up measuring Topics and nothing else without ever failing4 on the previous commit, 0 now demo-check.pyA demonstration on this page that has stopped demonstrating its own entry. The difference between showing a thesis and not showing it is almost always temporal, and a screenshot cannot see it, so this one scrubs25 entries, frame by frame fixture-screenshot.shMeasures nothing. It builds the scene the bench above has to fail, and without it that bench is a promisefirst frame magnified 2.26× @@ -464,7 +469,7 @@

Thirty-six movements,
no cuts.

"use strict"; /* ============================================================ - Geometria e contenuto: valori presi da video/src/primitives/slab.ts + Geometria e contenuto: valori presi da video/src/products/topics/geometry.ts e da SlabChrome.tsx. Se cambiano lì, cambiano qui. ============================================================ */ var SLAB_W=2400, SLAB_H=1200, SIDEBAR_W=280, HEADER_H=58, PANEL_W=420; @@ -491,7 +496,7 @@

Thirty-six movements,
no cuts.

]; var TAGC={feature:"#4d94ff",bugfix:"#f87171",chore:"#7d838d"}; -/* Le due pose che il repo tiene in slab.ts, non riscritte a occhio. */ +/* Le due pose che il repo tiene in topics/geometry.ts, non riscritte a occhio. */ var START={yaw:-18,pitch:5,z:0,x:900,y:0}; var END={yaw:-9,pitch:2.5,z:48,x:0,y:0}; @@ -1771,11 +1776,11 @@

Thirty-six movements,
no cuts.

]; var CAT=[ -{fam:"cam",code:"CAM-01",p:1,t:"Straightening push-in",demo:"push",src:"slab.ts · START/END_POSE", +{fam:"cam",code:"CAM-01",t:"Straightening push-in",demo:"push",src:"topics/geometry.ts · START/END_POSE", a:"The slab enters from off-frame at an angle and arrives at rest, more frontal than it started. The closing yaw is how you say it can be read now.", - b:"Two poses in slab.ts and one interpolate between them. The final pose lives in the module rather than in the scene, because it is where the next one attaches.", + b:"Two poses in topics/geometry.ts and one interpolate between them. The final pose lives in the module rather than in the scene, because it is where the next one attaches.", c:"yaw -18 to -9, pitch 5 to 2.5, pushZ 0 to 48, slideX 900 to 0. 108 frames of inOut cubic, then 42 at rest.", - d:"A geometric fill check: project the slab at the sampled frames and require it to cover all four edges of the frame. fill-measure.sh was that bench and could not fail: it reads 20 samples out of 20 on a slab shrunk to 60 per cent with the real background around it, because the background is as bright as its threshold and the blurred plane behind the slab looks like content to any pixel measure."}, + d:"fill-geom.py. From 20 per cent of the duration to the last frame, the four corners of the slab are projected with the camera of that frame, and the four corners of the frame have to fall inside: five scenes, every frame, at least 5.4 px of cover. With the camera pushed back it fails all five. The pixel version, fill-measure.sh, could not fail at all: a slab shrunk to 60 per cent reads 20 of 20, and it passed BoardOrbit, whose orbit leaves the edges of the frame uncovered on purpose, so that scene no longer declares fill."}, {fam:"cam",code:"CAM-02",t:"Truck with parallax",demo:"truck",src:"translateZ per layer", a:"The camera slides sideways and sidebar, board and panel separate because they sit at different depths. It is what tells a slab apart from a photograph being panned.", @@ -1783,29 +1788,29 @@

Thirty-six movements,
no cuts.

c:"header 6, sidebar 10, panel 18, board 26, active card 40, and the truck runs pushed in at 780 rather than from the wide pose. That is not a taste: at the wide pose those same offsets buy four pixels of shear over the whole move, which is real and invisible. Close in they buy twenty, and the readout measures them rather than claiming them.", d:"demo-check.py. Panel and board travel different pixels even flat, because they sit at different points on an inclined plane, so the differential travel on its own measures nothing. What measures the depth is how much that difference changes when the z offsets come off: nineteen pixels, and the readout takes them off the rendering rather than from this sentence."}, -{fam:"cam",code:"CAM-03",p:2,t:"Orbit that shows the edge",demo:"orbit",src:"scenes/BoardOrbit.tsx", +{fam:"cam",code:"CAM-03",p:2,t:"Orbit that shows the edge",demo:"orbit",src:"topics/scenes/BoardOrbit.tsx", a:"The camera goes around the slab far enough to show it has an edge. It establishes the thing as an object, and a film does that once.", b:"A second plane behind the first, slightly larger, with the contact shadow. Past forty degrees of yaw the text stops reading, so the orbit stops before that.", c:"In the film: from frontal out to -34 over 118 frames, then 32 frames of stillness, so the piece ends on a pose and not on an interrupted move. Thirty-four is where the board still reads. In the demo: yaw +10 to -36, then out to -56 and back. The overshoot is there so the limit is something you see rather than something you are told: past forty degrees the board foreshortens to 59 per cent of its width and the titles stop being titles. Edge 30, shadow 90 offset and 180 blur.", d:"legibility.sh. A wide orbit drops the text under the legibility threshold, and the bench says down to what size it survives instead of leaving it to taste. It reads the reference films beside the render, and those are not in git, so it cannot run in CI."}, -{fam:"cam",code:"CAM-04",t:"Descent to macro",demo:"macro",src:"scenes/CardFocus.tsx", +{fam:"cam",code:"CAM-04",t:"Descent to macro",demo:"macro",src:"topics/scenes/CardFocus.tsx", a:"The camera carries on past the readable pose and into a single element, which fills the frame and stays sharp.", b:"A growing translateZ plus a translation that puts the element centre on the perspective origin. The sharpness is not the move doing something: it is why the slab has to be DOM.", c:"In the repo: pushZ 96 to 1493.6 over 169 frames of inOut cubic, 2.35 times, with the translation coming out of centreOn. The demo above takes its translation from the same formula measured on its own stage, and it has to: with the repo numbers hardcoded on a stage of a different width the camera goes past the card and the frame ends black.", d:"focus-sharpness.sh. It compares the high-frequency energy of the card crop against the same content magnified from the wide shot: 2.09× on the real render, 1.03× on the fixture. Its first version compared the crop to a blurred copy of itself, scored the fixture 4.14× and passed it."}, -{fam:"cam",code:"CAM-05",t:"Attention by opacity",demo:"attn",src:"scenes/PromptInput.tsx", +{fam:"cam",code:"CAM-05",t:"Attention by opacity",demo:"attn",src:"topics/scenes/PromptInput.tsx", a:"Instead of blurring the rest, the rest drops in opacity. The eye goes where the contrast is full and no pixel becomes unreadable.", - b:"One value applied to every layer that is not the subject. It used to live in theme.ts as BACKDROP_OPACITY and go unread by every scene, so it was deleted; a number no scene uses is a proposal wearing the clothes of a fact. It is back now because a scene finally does the thing: while the answer streams, the board above it, the earlier messages and the composer all go to 0.62, and the answer alone keeps full contrast.", + b:"One value applied to every layer that is not the subject. It used to live in topics/tokens.ts as BACKDROP_OPACITY and go unread by every scene, so it was deleted; a number no scene uses is a proposal wearing the clothes of a fact. It is back now because a scene finally does the thing: while the answer streams, the board above it, the earlier messages and the composer all go to 0.62, and the answer alone keeps full contrast.", c:"0.62. The demo goes down to it and then carries on to 0.25, because a floor you never touch is a number somebody wrote down: at 0.25 the attenuated content is under 3:1 and reads as dirt on the background rather than as a plane behind.", - d:"contrast-floor.py, and the number the 0.62 was supposed to come from turns out to be 4.17:1 here and 3.84:1 in CI, because Linux renders the same text with different fonts — same verdict, and a reminder of why the threshold is a floor and not an equality. It reads the WCAG ratio between the attenuated thread heading and its background on frame 430 of the real render, with the crop projected out of slab.ts rather than picked by eye, and it fails the same scene rendered at 0.25, where the ratio collapses to 1.71. It took two wrong versions to get there, and both are worth keeping. The first measured the composer placeholder and failed the scene at 1.76:1 — correct reading, wrong subject: that placeholder is deliberately faint and sits at 2.93:1 with no attenuation at all, so the bench was failing CAM-05 for a decision about the input field. The second measured real content in a place that moves: the messages have natural heights and the thread is anchored to the bottom, so the moment the font metrics differ — which is exactly what happens between a Mac and the Linux in CI — everything shifts and the crop lands on empty background. It exits 3 there, not 1, which at least was the honest answer. The heading sits at THREAD_TOP, which is a constant."}, + d:"contrast-floor.py, and the number the 0.62 was supposed to come from turns out to be 4.17:1 here and 3.84:1 in CI, because Linux renders the same text with different fonts — same verdict, and a reminder of why the threshold is a floor and not an equality. It reads the WCAG ratio between the attenuated thread heading and its background on frame 430 of the real render, with the crop projected out of topics/geometry.ts rather than picked by eye, and it fails the same scene rendered at 0.25, where the ratio collapses to 1.71. It took two wrong versions to get there, and both are worth keeping. The first measured the composer placeholder and failed the scene at 1.76:1 — correct reading, wrong subject: that placeholder is deliberately faint and sits at 2.93:1 with no attenuation at all, so the bench was failing CAM-05 for a decision about the input field. The second measured real content in a place that moves: the messages have natural heights and the thread is anchored to the bottom, so the moment the font metrics differ — which is exactly what happens between a Mac and the Linux in CI — everything shifts and the crop lands on empty background. It exits 3 there, not 1, which at least was the honest answer. The heading sits at THREAD_TOP, which is a constant."}, -{fam:"cam",code:"CAM-06",t:"The point that does not run",demo:"centre",src:"slab.ts · centreOn", +{fam:"cam",code:"CAM-06",t:"The point that does not run",demo:"centre",src:"kit/rig.ts · centreOn", a:"Under a push in Z there is exactly one point of the frame that stays put, the perspective origin. Everything else runs for the edge, faster the further out it sits.", - b:"Work out where the subject centre lands on screen with yaw and pitch at zero, then translate the slab by enough to put it on the origin. From there the push magnifies it without moving it. In slab.ts that is slabPointOnScreen and centreOn, and the final pose of CardFocus comes out of them instead of out of numbers nudged by eye.", - c:"Origin at (960, 496.8), that is 50% and 46% of 1920 by 1080. The 46 is the term people drop: centring vertically is not half the slab minus the subject, and with the wrong formula the subject sits forty pixels off. The demo measures the drift on the rendering every frame: at the same push it is 477 px without compensation and 0 with.", - d:"demo-check.py measures the distance between the subject centre and the origin on the last frame of each half: 477 px uncompensated, 0 with. If it is not within a couple of pixels the descent is sliding past the subject rather than into it, and you only notice at the end."}, + b:"Work out where the subject centre lands on screen with yaw and pitch at zero, then translate the slab by enough to put it on the origin. From there the push magnifies it without moving it. kit/rig.ts does that for any stage and any slab, topics/geometry.ts calls it with the numbers of Topics, and the final pose of CardFocus comes out of it instead of out of numbers nudged by eye.", + c:"The origin is a choice written into the rig and read by both the CSS and the maths: 50% and 46% of the stage, so (960, 496.8) in 16:9, (540, 883.2) in 9:16 and (540, 621) in 4:5. It is not a result of the slab or of the pitch, and it does not change between formats; the slide that brings the subject onto it does. The 46 is the term people drop, and with the wrong formula the subject sits forty pixels off. The demo measures the drift on the rendering every frame: at the same push it is 477 px without compensation and 0 with.", + d:"demo-check.py on this page, and drift.py on the renders: six specimens, the delivered Topics card and the card of a light, vertical probe slab, in 16:9, 9:16 and 4:5, with the marked subject within 4 px of the origin on the first and the last frame (2.03 at worst). It fails all six with the slide removed, and all six with the slide worked out for 46 per cent while the CSS says 50: there the subject starts exactly in place and drifts 40 to 100 px as the camera pushes in, so a bench that looked at one frame would pass it."}, {fam:"cur",code:"CUR-01",t:"Arc and overshoot",demo:"arc",src:"primitives/Cursor.tsx", a:"The pointer arrives on a curve, overshoots the target slightly and settles on it. In red, over the same time, the straight line at constant speed.", @@ -1825,7 +1830,7 @@

Thirty-six movements,
no cuts.

c:"In the repo: the send lands at f271 and the bubble at f276, five frames apart. Under one the link disappears, over eight it stops reading as a consequence and starts reading as lag.", d:"click-gap.sh, and it is not told the answer: it finds both events in the render. The send is the first frame whose changed-pixel count blows past the window baseline, because the composer empties and the text leaves; the consequence is the first frame that climbs back above the quiet following it. Every threshold is a ratio inside that window, since the same renders read five to ten times higher on Linux. Measured f271 and f276, which is the f271 and f275 the source declares. It exits 1 on the four scenes with no click at all, and on a clip with the frames between the two events removed. That baseline used to be the median, and the median was wrong: once the camera stops during the performance — which is how it should be shot, nobody moves the camera while someone is typing — half the frames change nothing at all, the median is zero, and five times zero is any flicker. It reported a click at f181, with confidence, ninety frames before the real one. The baseline is the ninetieth percentile now."}, -{fam:"cur",code:"CUR-04",t:"Drag with weight",demo:"drag",src:"scenes/CardHandoff.tsx", +{fam:"cur",code:"CUR-04",t:"Drag with weight",demo:"drag",src:"topics/scenes/CardHandoff.tsx", a:"The dragged object lags the pointer and tilts into the direction of travel. Without the lag the card looks welded to the cursor.", b:"The card position is the cursor position three frames earlier: arithmetic on the frame, no stored state. The tilt comes from the difference between two samples. Getting this into a scene needed the pointer's position, not just its drawing, so Cursor.tsx now exports pointOnPath and keeps the drawing to itself — otherwise the scene would carry a second copy of the trajectory, equal to the first exactly as long as nobody touched one of them.", c:"3 frames of lag, 0.11 degrees per pixel of speed, shadow from 18/30 to 44/74, border lit from the lift onward. In the film the grab is at f78 and the release at f178, and the card is not on a scripted path: it sits where the hand was three frames ago, so the trajectory belongs to the pointer and the card only follows it.", @@ -1861,7 +1866,7 @@

Thirty-six movements,
no cuts.

c:"Typing at 13 characters per second, 0.43 per frame. Deletion at 1.6 per frame, the cadence of a held key, nearly four times faster. It stops at 22 characters, the nearest word boundary.", d:"The ratio between deletion speed and typing speed. Near one, the deletion reads as a second typing pass in reverse, which nobody has ever watched happen."}, -{fam:"chr",code:"CHR-01",t:"Handoff",demo:"handoff",src:"scenes/CardHandoff.tsx", +{fam:"chr",code:"CHR-01",t:"Handoff",demo:"handoff",src:"topics/scenes/CardHandoff.tsx", a:"An element crosses the layout and the layout absorbs it. The hard part is not the flight: it is the destination column opening the slot first, and the source closing behind.", b:"The card lifts off the plane, travels an arc and lands. The two columns move on their own windows, offset from the flight, and every position comes from cardY.", c:"Gap at f30, flight f40 to f126, close at f56. A step of 123, the active card height plus the gap.", @@ -1873,7 +1878,7 @@

Thirty-six movements,
no cuts.

c:"Delay equals distance over 1400, times 22 frames.", d:"demo-check.py ranks the cards by the frame they start on and by their distance from the change: the correlation is 1.00 in the first half and 0.43 in the second, over the same 24 frames of spread. Same duration, same easing, only the order changes, and the eye reads one as a space reacting and the other as a list scrolling."}, -{fam:"chr",code:"CHR-03",t:"State propagating",demo:"prop",src:"scenes/CardHandoff.tsx", +{fam:"chr",code:"CHR-03",t:"State propagating",demo:"prop",src:"topics/scenes/CardHandoff.tsx", a:"A change in one place causes another somewhere else, after a delay. It is the delay that reads as cause.", b:"Three cascading windows on the same timeline. No events and no state: three different frame thresholds, so it stays a function of the frame.", c:"In the demo: card at f30, count at f35, panel at f41. In the film the third link is not the panel, and the reason came from looking at the render rather than at the source. At CardHandoff's pose the camera is already close enough that the detail panel runs off the right edge — the labels read and the values do not — and a link of the chain outside the frame is not a link. So the card lands at f178, the destination count moves at f183, and at f189 the card rewrites its own age from 12h to ora, which is what a real board does to something that just moved. Five and six frames, on an element that sits in the middle of the shot.", @@ -1885,7 +1890,7 @@

Thirty-six movements,
no cuts.

c:"First card at f52, second at f88, metric from 128 to 64 ms between f120 and f200. The camera is not still at any of those.", d:"The frame a count changes on must not coincide with a frame where the camera is still. If they coincide, what you made is a cut with the camera parked over it."}, -{fam:"mat",code:"MAT-01",t:"Edge and contact shadow",demo:"thick",src:"SlabChrome · SlabEdge", +{fam:"mat",code:"MAT-01",t:"Edge and contact shadow",demo:"thick",src:"kit/Shot.tsx · edge", a:"The slab has an edge and it rests on something. In the second half of the demo the edge is switched off, to see what goes.", b:"A second plane behind the first, slightly larger, with a deep shadow. It costs one div, and it decides whether the object reads as an object. In the repo it is a SIBLING of the slab and not a child, which is not a detail: the slab clips, and any clipping flattens preserve-3d, so a child at translateZ(-30) would be squashed onto its parent's plane and never stick out. As a sibling inside the same perspective its Z is real.", c:"Edge 30 behind the plane, 6 px of overhang per side, shadow 90 offset and 180 blur at 75 per cent. All six scenes draw it and only the last one shows it: at small yaw angles it sits exactly behind the slab, which is why it went unwritten for so long — nothing in the film turned far enough to need it.", @@ -1897,7 +1902,7 @@

Thirty-six movements,
no cuts.

c:"Opacity between 0.72 and 0.98, blur 22, laid at 76 degrees with its origin on the top edge, pinned to the bottom edge of the slab.", d:"The glow tint against the frame dominant tint. If they do not move together, the glow is decoration."}, -{fam:"mat",code:"MAT-03",p:1,t:"The attenuated plane behind",demo:"backdrop",src:"theme.ts · SLAB_BACKDROP", +{fam:"mat",code:"MAT-03",p:1,t:"The attenuated plane behind",demo:"backdrop",src:"topics/tokens.ts · SLAB_BACKDROP", a:"Behind the slab sits a second copy, smaller and turned further, at reduced opacity. Switch it off and the frame under the board goes empty and the slab floats in black.", b:"It is the same board drawn a second time, not a blurred image: an image falls behind the moment the content changes, and in a single take the content always changes. It costs a clone and a negative translateZ.", c:"0.45 opacity with a 14 px blur, 60 back, eight more degrees of yaw and four of pitch, at 0.92 scale. All five scenes read it from SLAB_BACKDROP now. They did not always: three wrote the four numbers out by hand and a fourth read a different constant called BACKDROP_OPACITY, worth 0.62, which described a backdrop only PromptInput had. Copies of a number nobody named stay equal exactly as long as nobody touches one of them.", @@ -1981,11 +1986,11 @@

Thirty-six movements,
no cuts.

c:"yaw -30 closing to -19, pitch 3, inside the range the slab's own poses cover — UIMockup starts at -18. At the slab's closing angles the foreshortening is real and invisible: the two ends of the line differ by a couple of per cent, which measures but does not read. The second half lays the plane flat, which is what type does when nobody puts it anywhere, and turns it back before the loop closes; the sentence never leaves.", d:"demo-check.py compares the rendered height of the first word against the last: 0.909 on the plane, because perspective makes the near end larger, and 1.000 flat. It is the cheapest test for whether type is in the room or on the glass."}, -{fam:"giu",code:"GIU-04",p:1,t:"Monotonic derivative",demo:"chain",src:"the three boundary poses", +{fam:"giu",code:"GIU-04",t:"Monotonic derivative",demo:"chain",src:"the three boundary poses", a:"Across the whole chain yaw, pitch and push always move the same way. Not fussiness: the eye follows the derivative of the movement, and a reversal at a join reads as a cut even when the pixels of the two frames match.", b:"The boundary poses live in one module, so the monotonicity is checked by reading three constants instead of opening three scenes. The graph in the corner is exactly that: the three quantities across the chain, with the two joins marked.", c:"yaw -18, -9, -4, 0. pitch 5, 2.5, 1.2, 0. push 0, 48, 96, 1493.6. None of the three ever goes back.", - d:"The sign of the difference between consecutive frames across each join. seam.sh says the pixels match; this says the movement did not reverse. They are different defects, and the first bench does not see the second."} + d:"chain-check.py, on the camera tracks the scenes hand to the render: inside a scene the derivative never changes sign, at a join the last pose is the next scene's first, and a reversal is allowed only where the camera is still on both sides. The Topics chain has seven, all at rest. With the easing taken out of every track the same seven become reversals in motion and it fails. seam.sh says the pixels match; this says the movement did not reverse."} ]; /* ============================================================ costruzione */ diff --git a/video/src/Root.tsx b/video/src/Root.tsx index 46a45a6..159e420 100644 --- a/video/src/Root.tsx +++ b/video/src/Root.tsx @@ -1,13 +1,15 @@ import "./index.css"; import { Composition } from "remotion"; -import { PromptInput } from "./scenes/PromptInput"; +import { PromptInput } from "./products/topics/scenes/PromptInput"; import { FrameLockedProbe } from "./scenes/FrameLockedProbe"; -import { UIMockup } from "./scenes/UIMockup"; -import { CardHandoff } from "./scenes/CardHandoff"; -import { CardFocus } from "./scenes/CardFocus"; -import { CardRelease } from "./scenes/CardRelease"; -import { BoardOrbit } from "./scenes/BoardOrbit"; +import { UIMockup } from "./products/topics/scenes/UIMockup"; +import { CardHandoff } from "./products/topics/scenes/CardHandoff"; +import { CardFocus } from "./products/topics/scenes/CardFocus"; +import { CardRelease } from "./products/topics/scenes/CardRelease"; +import { BoardOrbit } from "./products/topics/scenes/BoardOrbit"; import catalog from "./scenes/catalog.json"; +import { SpecimenCam06 } from "./specimens/SpecimenCam06"; +import { CAM06_SPECIMENS } from "./specimens/list"; /** * Le composition della vetrina NON sono scritte qui a mano: escono da @@ -114,6 +116,21 @@ export const RemotionRoot: React.FC = () => { height={1080} defaultProps={{ detachTicker: true, jitter: true }} /> + {/* GLI SPECIMEN: una voce del registro su piu' lastre e piu' rapporti, + per i banchi e non per la vetrina. L'elenco sta in specimens/list.ts, + lo stesso che legge il manifest di drift.py. */} + {CAM06_SPECIMENS.map((s) => ( + + ))} ); }; diff --git a/video/src/kit/Shot.tsx b/video/src/kit/Shot.tsx new file mode 100644 index 0000000..ec1dc38 --- /dev/null +++ b/video/src/kit/Shot.tsx @@ -0,0 +1,226 @@ +import React from "react"; +import { AbsoluteFill, useVideoConfig } from "remotion"; +import { cssPerspectiveOrigin } from "./rig"; +import type { Rig, SlabSize } from "./rig"; + +/** + * La ripresa di una lastra: il piano attenuato dietro, lo spessore, la lastra + * nitida e la luce sul quadro. + * + * PERCHE' ESISTE. Fino a settembre 2026 questo blocco stava ricopiato a mano in + * sei scene: prospettiva 2600, origine "50% 46%", `(1920 - SLAB_W) / 2`, + * `scale(1.04)`, il piano dietro con i suoi sette numeri di parallasse. Sei + * copie restano uguali finche' nessuno ne tocca una, e i banchi misuravano su + * slab.ts mentre il render usava le copie. Adesso c'e' un componente solo, e le + * scene gli passano la posa e il contenuto. + * + * NON CAMBIA UN PIXEL, e il motivo e' scritto nel modo in cui e' fatto: stessi + * elementi, stesso ordine, stessi numeri calcolati con le stesse operazioni. + * Dove una scena ometteva uno spostamento, qui vale zero, e sommare zero in + * virgola mobile non cambia il risultato. `still-identity.sh` lo misura su + * cinque fotogrammi di ogni scena contro il commit di prima. + * + * LO STAGE VIENE DALLA COMPOSITION (`useVideoConfig`), non da 1920 e 1080: la + * stessa scena girata in 9:16 centra la lastra nel suo quadro. + * + * LO SPESSORE E' FRATELLO DELLA LASTRA, non figlio, e non e' un dettaglio: la + * lastra ritaglia (`overflow: hidden`), e qualunque ritaglio appiattisce + * `preserve-3d`. Un figlio a translateZ(-30) finirebbe schiacciato sul piano del + * padre; da fratello, dentro la stessa prospettiva, il suo Z e' vero. (MAT-01) + */ + +export type ShotPose = { + yaw: number; + pitch: number; + pushZ: number; + slideX: number; + slideY: number; +}; + +/** L'aspetto della ripresa, separato dalla geometria: ogni prodotto porta il suo. */ +export type ShotMaterial = { + stage: { background: string; fontFamily: string }; + slab: { + background: string; + radius: number; + border: string; + shadow: string; + /** Il filo di luce sul bordo basso: dice "oggetto fisico". */ + highlight: string; + }; + /** MAT-01: stacco dietro il piano e debordo per lato, in pixel della lastra. */ + edge: { + background: string; + radius: number; + shadow: string; + depth: number; + overhang: number; + }; + /** + * MAT-03: la stessa UI disegnata una seconda volta, sfocata e attenuata. + * Segue la camera piu' lentamente della lastra (yawFollow, slideFollow), ed e' + * inclinata di qualche grado in piu', cosi' non si legge come una copia + * incollata. + */ + backdrop: { + perspective: number; + perspectiveOrigin: string; + opacity: number; + blur: number; + offsetX: number; + offsetY: number; + yawFollow: number; + slideFollow: number; + yawOffset: number; + pitchOffset: number; + scale: number; + background: string; + border: string; + radius: number; + }; + lighting: { vignette: string; sheen: string }; +}; + +export type ShotProps = { + rig: Rig; + slab: SlabSize; + material: ShotMaterial; + pose: ShotPose; + /** Il contenuto della lastra nitida. */ + children: React.ReactNode; + /** Il contenuto del piano dietro: la stessa UI, di solito in versione attenuata. */ + backdrop: React.ReactNode; + /** Il filo di luce sul bordo basso della lastra. PromptInput e BoardOrbit non lo hanno. */ + highlight?: boolean; + /** Opacita' del quadro intero: UIMockup ci entra in dissolvenza. */ + opacity?: number; +}; + +export const Shot: React.FC = ({ + rig, + slab, + material: m, + pose, + children, + backdrop, + highlight = true, + opacity, +}) => { + const { width, height } = useVideoConfig(); + const { yaw, pitch, pushZ, slideX, slideY } = pose; + + const bgYaw = yaw * m.backdrop.yawFollow; + const bgSlideX = slideX * m.backdrop.slideFollow; + const bgSlideY = slideY * m.backdrop.slideFollow; + + const left = (width - slab.w) / 2 + slideX; + const top = (height - slab.h) / 2 + slideY; + + return ( + + +
+ {backdrop} +
+
+ + +
+
+ {children} + + {highlight ? ( +
+ ) : null} +
+ + +
+
+ + ); +}; diff --git a/video/src/kit/camera.ts b/video/src/kit/camera.ts new file mode 100644 index 0000000..250d94d --- /dev/null +++ b/video/src/kit/camera.ts @@ -0,0 +1,167 @@ +import { Easing, interpolate } from "remotion"; +import type { Pose } from "./project.ts"; + +/** + * La camera di una scena come dato: una curva per asse. + * + * PERCHE' ESISTE. Ogni scena calcolava la propria camera dentro il componente, + * con cinque `interpolate` scritti a mano. Il render li leggeva, e nessun altro + * poteva: per sapere la posa al fotogramma 137 bisognava renderizzare. Quindi + * nessun banco poteva chiedersi se la derivata della camera si inverte a meta' + * movimento (GIU-04), o se a un certo frame la lastra lascia scoperto un bordo + * del quadro (CAM-01): erano domande che si potevano fare solo ai pixel, e i + * pixel, in questo repo, hanno gia' risposto male una volta. + * + * Una traccia e' un dato che scene e banchi leggono allo stesso modo. `poseAt` + * usa `interpolate` e `Easing` di Remotion con gli stessi argomenti che le scene + * passavano, quindi le pose sono le stesse al bit e i render non cambiano. + * + * Modulo puro rispetto a React: lo leggono anche i banchi, da Node. + */ + +export type Ease = + | "linear" + | "inOutQuad" + | "inOutCubic" + | { bezier: [number, number, number, number] }; + +/** Un asse che si muove da `from` a `to` fra i frame `start` ed `end`, poi resta fermo. */ +export type AxisCurve = { + from: number; + to: number; + start: number; + end: number; + ease: Ease; +}; + +/** Un asse e' una curva o un valore fisso. */ +export type Track = { [K in keyof Pose]: AxisCurve | number }; + +const easingOf = (e: Ease): ((t: number) => number) | undefined => { + if (e === "linear") return undefined; + if (e === "inOutQuad") return Easing.inOut(Easing.quad); + if (e === "inOutCubic") return Easing.inOut(Easing.cubic); + return Easing.bezier(...e.bezier); +}; + +const axisAt = (c: AxisCurve | number, frame: number): number => + typeof c === "number" + ? c + : interpolate(frame, [c.start, c.end], [c.from, c.to], { + easing: easingOf(c.ease), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + +export const poseAt = (track: Track, frame: number): Pose => ({ + yaw: axisAt(track.yaw, frame), + pitch: axisAt(track.pitch, frame), + pushZ: axisAt(track.pushZ, frame), + slideX: axisAt(track.slideX, frame), + slideY: axisAt(track.slideY, frame), +}); + +/** Tutti gli easing linearizzati: il controllo negativo di checkChain. */ +export const linearized = (track: Track): Track => { + const out = { ...track }; + for (const k of Object.keys(out) as (keyof Pose)[]) { + const c = out[k]; + if (typeof c !== "number") out[k] = { ...c, ease: "linear" }; + } + return out; +}; + +export type ChainScene = { id: string; track: Track; frames: number }; + +export type ChainFinding = { + kind: "salto" | "inversione in moto" | "inversione a riposo"; + axis: keyof Pose; + where: string; + detail: string; +}; + +const AXES: (keyof Pose)[] = ["yaw", "pitch", "pushZ", "slideX", "slideY"]; + +/** + * GIU-04 sulla catena intera: la camera non torna indietro mentre si muove. + * + * Tre controlli, su ogni asse: + * - dentro una scena, il segno della derivata non si inverte mai; + * - a una giunta, l'ultima posa di una scena e' la prima della successiva; + * - a una giunta il verso puo' invertirsi solo se la camera e' ferma da tutte + * e due le parti. La velocita' di confine si confronta con la velocita' + * massima dello stesso asse nella stessa scena: sotto il 5% e' riposo. Un + * inOut arriva ai capi sotto l'1%, un lineare no. + * + * Le inversioni a riposo si riportano senza bocciarle: sono quelle che il + * README racconta per CardRelease e BoardOrbit, e il motivo per cui sono + * ammesse e' che dove i due lati sono fermi non c'e' una derivata da rovesciare. + */ +export const checkChain = ( + scenes: ChainScene[], + restRatio = 0.05, +): ChainFinding[] => { + const findings: ChainFinding[] = []; + const sampled = scenes.map((s) => ({ + id: s.id, + poses: Array.from({ length: s.frames }, (_, f) => poseAt(s.track, f)), + })); + + const sign = (v: number): number => (Math.abs(v) < 1e-9 ? 0 : Math.sign(v)); + + for (const axis of AXES) { + const info = sampled.map(({ id, poses }) => { + const d = poses.slice(1).map((p, i) => p[axis] - poses[i]![axis]); + const peak = Math.max(0, ...d.map(Math.abs)); + const nz = d.filter((v) => sign(v) !== 0); + // Dentro la scena: il primo cambio di segno e' un'inversione in moto. + for (let i = 1; i < d.length; i++) { + const prev = d.slice(0, i).reverse().find((v) => sign(v) !== 0); + if (prev !== undefined && sign(d[i]!) !== 0 && sign(d[i]!) !== sign(prev)) { + findings.push({ + kind: "inversione in moto", + axis, + where: `${id} f${i}`, + detail: `da ${sign(prev) > 0 ? "crescente" : "calante"} a ${sign(d[i]!) > 0 ? "crescente" : "calante"}`, + }); + break; + } + } + return { + id, + first: poses[0]![axis], + last: poses[poses.length - 1]![axis], + dirStart: nz.length ? sign(nz[0]!) : 0, + dirEnd: nz.length ? sign(nz[nz.length - 1]!) : 0, + vStart: Math.abs(d[0] ?? 0), + vEnd: Math.abs(d[d.length - 1] ?? 0), + peak, + }; + }); + + for (let i = 1; i < info.length; i++) { + const a = info[i - 1]!; + const b = info[i]!; + const where = `${a.id} → ${b.id}`; + if (Math.abs(a.last - b.first) > 1e-6) { + findings.push({ + kind: "salto", + axis, + where, + detail: `${a.last} contro ${b.first}`, + }); + } + if (a.dirEnd !== 0 && b.dirStart !== 0 && a.dirEnd !== b.dirStart) { + const restA = a.peak === 0 || a.vEnd <= restRatio * a.peak; + const restB = b.peak === 0 || b.vStart <= restRatio * b.peak; + findings.push({ + kind: restA && restB ? "inversione a riposo" : "inversione in moto", + axis, + where, + detail: `velocita' di confine ${(a.vEnd / (a.peak || 1)).toFixed(3)} e ${(b.vStart / (b.peak || 1)).toFixed(3)} del massimo`, + }); + } + } + } + return findings; +}; diff --git a/video/src/kit/project.ts b/video/src/kit/project.ts new file mode 100644 index 0000000..94a9048 --- /dev/null +++ b/video/src/kit/project.ts @@ -0,0 +1,93 @@ +import type { Stage } from "./stage.ts"; +import type { Point, Rig, SlabSize } from "./rig.ts"; + +/** + * Dove finisce sullo schermo un punto della lastra, con la camera in qualunque + * posa: la catena CSS del blocco di ripresa rifatta in aritmetica. + * + * PERCHE' ESISTE. `centreOn` e `slabPointOnScreen` sono esatti solo con yaw e + * pitch a zero, ed e' il motivo per cui le pose di macro finiscono frontali. + * Un banco che guarda la camera mentre si muove, cioe' quasi tutti i film di + * prodotto, deve sapere dove sta un elemento a una posa inclinata: proiettato, + * non indovinato con un ritaglio scelto a occhio. + * + * LA CATENA E' QUELLA DI `kit/Shot.tsx`, nello stesso ordine: + * - la lastra e' posizionata a ((stage - lastra) / 2 + slide), quindi il suo + * centro sta a (stage / 2 + slide); + * - `transform: translateZ(push) rotateY(yaw) rotateX(pitch) scale(s)` attorno + * al proprio centro, che per un punto vuol dire: prima la scala, poi rotateX, + * poi rotateY, poi la spinta; + * - la prospettiva del contenitore, con l'origine del rig: il punto si allontana + * dall'origine di P / (P - z). + * Le matrici di rotazione sono quelle della specifica CSS (rotate3d), con l'asse + * y che scende. + * + * `scripts/project-check.py` la confronta con `getBoundingClientRect` di Chrome + * su due lastre, tre stage e le pose della catena di Topics. + */ + +export type Pose = { + yaw: number; + pitch: number; + pushZ: number; + slideX: number; + slideY: number; +}; + +const rad = (deg: number): number => (deg * Math.PI) / 180; + +export const project = ( + stage: Stage, + rig: Rig, + slab: SlabSize, + pose: Pose, + p: Point, +): { x: number; y: number; z: number } => { + // Nel sistema della lastra, centrato sul suo centro, dopo la scala. + const x0 = (p.x - slab.w / 2) * rig.slabScale; + const y0 = (p.y - slab.h / 2) * rig.slabScale; + + // rotateX(pitch): y' = y cos - z sin, z' = y sin + z cos (z parte da 0). + const cp = Math.cos(rad(pose.pitch)); + const sp = Math.sin(rad(pose.pitch)); + const y1 = y0 * cp; + const z1 = y0 * sp; + + // rotateY(yaw): x' = x cos + z sin, z' = -x sin + z cos. + const cy = Math.cos(rad(pose.yaw)); + const sy = Math.sin(rad(pose.yaw)); + const x2 = x0 * cy + z1 * sy; + const z2 = -x0 * sy + z1 * cy; + + // translateZ(push), poi nel contenitore. + const z = z2 + pose.pushZ; + const X = stage.w / 2 + pose.slideX + x2; + const Y = stage.h / 2 + pose.slideY + y1; + + // La prospettiva, attorno all'origine del rig. + const ox = stage.w * rig.originX; + const oy = stage.h * rig.originY; + const k = rig.perspective / (rig.perspective - z); + return { x: ox + (X - ox) * k, y: oy + (Y - oy) * k, z }; +}; + +/** Il riquadro allineato agli assi che contiene un rettangolo della lastra proiettato. */ +export const projectRect = ( + stage: Stage, + rig: Rig, + slab: SlabSize, + pose: Pose, + r: { x: number; y: number; w: number; h: number }, +): { x: number; y: number; w: number; h: number } => { + const corners = [ + project(stage, rig, slab, pose, { x: r.x, y: r.y }), + project(stage, rig, slab, pose, { x: r.x + r.w, y: r.y }), + project(stage, rig, slab, pose, { x: r.x, y: r.y + r.h }), + project(stage, rig, slab, pose, { x: r.x + r.w, y: r.y + r.h }), + ]; + const xs = corners.map((c) => c.x); + const ys = corners.map((c) => c.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +}; diff --git a/video/src/kit/rig.ts b/video/src/kit/rig.ts new file mode 100644 index 0000000..1e1924b --- /dev/null +++ b/video/src/kit/rig.ts @@ -0,0 +1,110 @@ +import type { Stage } from "./stage.ts"; + +/** + * L'impianto di ripresa: la prospettiva, la sua origine e la scala della lastra. + * + * L'ORIGINE E' UNA SCELTA, NON UN RISULTATO. I documenti di regia dei primi + * film dicevano che il 46% di Topics "non e' una frazione del quadro ma il + * risultato della geometria della lastra e del pitch". Non e' cosi': e' il + * valore scritto nel CSS (`perspective-origin: 50% 46%`) e riletto dalla + * matematica. Qui e' un parametro del rig. Quello che cambia davvero da un + * rapporto all'altro e' lo spostamento che porta il soggetto sull'origine, e + * lo calcola `centreOn` a partire dallo stage. + * + * `drift.py` misura sul render che il soggetto resti sull'origine per tutta la + * spinta, in tre rapporti e su due lastre diverse. + */ + +export type Rig = { + perspective: number; + /** Frazioni dello stage, non percentuali: 0.5 e 0.46 per Topics. */ + originX: number; + originY: number; + slabScale: number; +}; + +export type SlabSize = { w: number; h: number }; +export type Point = { x: number; y: number }; +export type Rect = { x: number; y: number; w: number; h: number }; + +/** + * Dove cade un punto della lastra con yaw e pitch a zero, prima della spinta in + * Z e senza spostamento: la lastra centrata nello stage e scalata attorno al + * proprio centro. + * + * L'ordine delle operazioni e' lo stesso della versione che stava in slab.ts, e + * non per scrupolo: in virgola mobile un ordine diverso cambia l'ultima cifra, + * e le pose di Topics devono restare identiche al bit. + */ +export const slabPointOnScreen = ( + stage: Stage, + rig: Rig, + slab: SlabSize, + p: Point, +): Point => ({ + x: (stage.w - slab.w) / 2 + slab.w / 2 + (p.x - slab.w / 2) * rig.slabScale, + y: (stage.h - slab.h) / 2 + slab.h / 2 + (p.y - slab.h / 2) * rig.slabScale, +}); + +/** Il solo punto del quadro che non si sposta sotto una spinta in Z. */ +export const originOnScreen = (stage: Stage, rig: Rig): Point => ({ + x: stage.w * rig.originX, + y: stage.h * rig.originY, +}); + +/** + * Lo spostamento che porta un punto della lastra sull'origine della prospettiva. + * + * E' la condizione perche' una discesa al macro resti puntata sul soggetto: + * qualsiasi altro punto del quadro scappa verso il bordo mentre la camera + * avanza. Esatto con yaw e pitch a zero; con la camera inclinata e' + * un'approssimazione, ed e' il motivo per cui le pose di macro finiscono + * frontali. + */ +export const centreOn = ( + stage: Stage, + rig: Rig, + slab: SlabSize, + p: Point, +): { slideX: number; slideY: number } => { + const s = slabPointOnScreen(stage, rig, slab, p); + const o = originOnScreen(stage, rig); + return { slideX: o.x - s.x, slideY: o.y - s.y }; +}; + +/** L'ingrandimento che produce una spinta in Z, e la spinta che serve per un ingrandimento. */ +export const zoomForPush = (rig: Rig, z: number): number => + rig.perspective / (rig.perspective - z); +export const pushForZoom = (rig: Rig, k: number): number => + rig.perspective * (1 - 1 / k); + +/** + * La spinta che fa occupare a un rettangolo della lastra una frazione dello + * stage, sulla dimensione che arriva prima al limite. + * + * Esiste perche' i numeri di spinta non si portano da un formato all'altro: la + * stessa spinta da' lo stesso ingrandimento, ma in un quadro stretto il + * soggetto lo riempie molto prima. Si decide il riempimento, e la spinta ne + * discende. + */ +export const pushForFill = ( + stage: Stage, + rig: Rig, + rect: { w: number; h: number }, + fill: number, +): number => { + const k = Math.min( + (fill * stage.w) / (rect.w * rig.slabScale), + (fill * stage.h) / (rect.h * rig.slabScale), + ); + return pushForZoom(rig, k); +}; + +/** + * L'origine scritta come la vuole il CSS. `0.46 * 100` in virgola mobile vale + * 46.00000000000001: arrotondato qui, lo stile resta "50% 46%" come nelle scene. + */ +export const cssPerspectiveOrigin = (rig: Rig): string => + `${pct(rig.originX)} ${pct(rig.originY)}`; + +const pct = (v: number): string => `${Number((v * 100).toFixed(4))}%`; diff --git a/video/src/kit/stage.ts b/video/src/kit/stage.ts new file mode 100644 index 0000000..9607388 --- /dev/null +++ b/video/src/kit/stage.ts @@ -0,0 +1,27 @@ +/** + * Lo stage: il quadro in cui finisce la lastra, in pixel. + * + * PERCHE' ESISTE. Fino a settembre 2026 il quadro era scritto dentro la + * geometria: `slabPointOnScreen` e `centreOn` usavano 1920 e 1080 come + * costanti, quindi valevano solo in 16:9. Un film di prodotto esce in tre + * rapporti e ognuno si ri-renderizza con la sua camera, non si ritaglia: lo + * stage e' un parametro, non un'assunzione. + * + * E' UN MODULO PURO, e tutti i file di `kit/` che non finiscono in `.tsx` lo + * sono: niente React, niente Remotion, import con l'estensione `.ts` scritta. + * Li leggono anche i banchi, da Node, senza passare dal bundler, cosi' la + * geometria che il render usa e quella che il banco misura sono lo stesso + * codice e non due copie. + */ + +export type Ratio = "16x9" | "9x16" | "4x5"; + +export type Stage = { ratio: Ratio; w: number; h: number }; + +export const STAGES: Record = { + "16x9": { ratio: "16x9", w: 1920, h: 1080 }, + "9x16": { ratio: "9x16", w: 1080, h: 1920 }, + "4x5": { ratio: "4x5", w: 1080, h: 1350 }, +}; + +export const RATIOS: readonly Ratio[] = ["16x9", "9x16", "4x5"]; diff --git a/video/src/primitives/Cursor.tsx b/video/src/primitives/Cursor.tsx index eb4dd86..589ef7d 100644 --- a/video/src/primitives/Cursor.tsx +++ b/video/src/primitives/Cursor.tsx @@ -70,6 +70,13 @@ export type CursorProps = { * una timeline padre puo' guidarla. Se manca, il frame se lo prende da se'. */ progress?: number; + /** + * Il frame della scena. Se c'e' vince su tutto: e' il modo in cui una scena + * guidata da `progress` tiene la mano sincronizzata con la camera. Prima il + * cursore leggeva sempre il proprio orologio, e con una scena pilotata da una + * timeline padre la mano andava per conto suo. + */ + frame?: number; size?: number; }; @@ -79,12 +86,14 @@ export const Cursor: React.FC = ({ path, clicks = [], progress, + frame: sceneFrame, size = 34, }) => { const localFrame = useCurrentFrame(); const { durationInFrames } = useVideoConfig(); const frame = - progress === undefined ? localFrame : progress * (durationInFrames - 1); + sceneFrame ?? + (progress === undefined ? localFrame : progress * (durationInFrames - 1)); if (path.length === 0) return null; diff --git a/video/src/products/probe/Slab.tsx b/video/src/products/probe/Slab.tsx new file mode 100644 index 0000000..ee7eaa8 --- /dev/null +++ b/video/src/products/probe/Slab.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { + PROBE_ANCHORS, + PROBE_HEADER_H, + PROBE_PAD, + PROBE_ROW_GAP, + PROBE_ROW_H, + PROBE_SLAB, +} from "./geometry"; + +/** + * Il contenuto della lastra sonda: una lista generica su fondo chiaro. + * + * Abbastanza ricca da avere testo, fili e superfici diverse, cioe' le cose su + * cui un banco puo' sbagliarsi, e abbastanza anonima da non somigliare a un + * prodotto. Il font e' quello di sistema, come nelle scene di Topics. + */ + +export const probeTokens = { + bg: "#f4f3ef", + surface: "#ffffff", + line: "#dedbd3", + text: "#1d1d22", + muted: "#77757d", + faint: "#b9b6ae", + accent: "#2f6fdb", +} as const; + +const fontStack = + '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif'; + +const ROWS = [ + "Rinnovo contratto fornitori", + "Revisione listino autunno", + "Onboarding nuovo cliente", + "Report trimestrale", + "Aggiornamento policy dati", + "Pianificazione ferie team", + "Migrazione archivio", + "Verifica fatture aperte", + "Presentazione board", + "Chiusura progetto pilota", + "Formazione sicurezza", +]; + +export const ProbeSlab: React.FC<{ marker?: boolean }> = ({ marker = false }) => { + const t = PROBE_ANCHORS.target; + return ( +
+
+ Attività + + 11 aperte + +
+ + {ROWS.map((label, i) => { + const y = PROBE_HEADER_H + PROBE_PAD + i * (PROBE_ROW_H + PROBE_ROW_GAP); + return ( +
+
{label}
+
+ {`${3 + ((i * 5) % 9)} voci · aggiornata ${1 + ((i * 7) % 12)}h fa`} +
+
+ ); + })} + + {/* Il bersaglio: una card accentata esattamente sull'ancora. */} +
+
Card bersaglio
+
+ ancora `target` · 520 x 220 +
+
+ + {marker ? : null} +
+ ); +}; + +/** + * Il segno che i banchi cercano nei pixel: un anello magenta pieno centrato sul + * punto. Magenta perche' non compare in nessuna delle due lastre, quindi la + * maschera prende solo lui; un anello e non un punto perche' resta simmetrico + * anche quando la spinta lo ingrandisce, e il suo baricentro e' il centro. + * + * Sta sopra tutto (zIndex): sulla board di Topics la card in viaggio vive a + * zIndex 10, e il primo render di questo specimen aveva il segno nascosto + * proprio sotto la card che doveva marcare. + */ +export const AnchorMarker: React.FC<{ x: number; y: number }> = ({ x, y }) => ( +
+); diff --git a/video/src/products/probe/geometry.ts b/video/src/products/probe/geometry.ts new file mode 100644 index 0000000..87d5252 --- /dev/null +++ b/video/src/products/probe/geometry.ts @@ -0,0 +1,52 @@ +import type { Rect, Rig, SlabSize } from "../../kit/rig.ts"; + +/** + * La lastra sonda: un prodotto che non esiste, fatto per rompere le assunzioni + * di Topics. + * + * PERCHE' ESISTE. Tutte le misure di questo repo erano state prese su una lastra + * sola: scura, orizzontale (2400x1200), in 16:9. Una voce del registro che + * funziona solo li' non e' una voce del registro, e' un dettaglio di Topics. + * Questa lastra e' il contrario su ogni asse che conta: CHIARA, quindi i conti + * di contrasto non possono piu' dare per scontato il tema scuro; VERTICALE + * (1200x1800), quindi un centraggio che scambia larghezza e altezza si vede; + * con una scala diversa, quindi un numero di Topics ricopiato non torna. + * + * NON IMITA NESSUN PRODOTTO VERO. E' un repo pubblico: niente interfacce, token + * o font di prodotti privati, qui dentro. + * + * Modulo puro: lo leggono il manifest e i banchi, da Node. + */ + +export const PROBE_SLAB: SlabSize = { w: 1200, h: 1800 }; + +export const PROBE_RIG: Rig = { + perspective: 2600, + originX: 0.5, + originY: 0.46, + slabScale: 1, +}; + +export const PROBE_HEADER_H = 96; +export const PROBE_ROW_H = 132; +export const PROBE_ROW_GAP = 16; +export const PROBE_PAD = 40; + +/** + * Le ancore: i rettangoli, in coordinate lastra, delle cose che una camera + * inquadra o un cursore tocca. Aritmetica pura, come le card di Topics: una + * posizione che si conosce solo dopo il layout non si puo' interpolare. + * + * `target` sta di proposito lontano dal centro e in basso a sinistra: centrato, + * lo spostamento di `centreOn` sarebbe quasi zero e il banco non proverebbe + * niente. + */ +export const PROBE_ANCHORS: Record<"target", Rect> = { + target: { x: 120, y: 1180, w: 520, h: 220 }, +}; + +/** Il centro di un rettangolo. */ +export const rectCentre = (r: Rect): { x: number; y: number } => ({ + x: r.x + r.w / 2, + y: r.y + r.h / 2, +}); diff --git a/video/src/primitives/Assistant.tsx b/video/src/products/topics/Assistant.tsx similarity index 99% rename from video/src/primitives/Assistant.tsx rename to video/src/products/topics/Assistant.tsx index 8d42972..62ce680 100644 --- a/video/src/primitives/Assistant.tsx +++ b/video/src/products/topics/Assistant.tsx @@ -10,8 +10,8 @@ import { SIDEBAR_W, THREAD_PAD_BOTTOM, THREAD_TOP, -} from "./slab"; -import { app, monoStack, radius } from "../theme"; +} from "./geometry"; +import { app, monoStack, radius } from "./tokens"; /** * Il thread dell'assistente e il composer, nella meta' bassa della lastra. diff --git a/video/src/primitives/Board.tsx b/video/src/products/topics/Board.tsx similarity index 98% rename from video/src/primitives/Board.tsx rename to video/src/products/topics/Board.tsx index 4396e7a..5e98cc5 100644 --- a/video/src/primitives/Board.tsx +++ b/video/src/products/topics/Board.tsx @@ -11,7 +11,7 @@ import { addCardY, cardY, columnX, -} from "./slab"; +} from "./geometry"; import { AddCard, AppChrome, @@ -29,7 +29,7 @@ import { * si ferma, quindi deve disegnare la board ESATTAMENTE come la lascia: non * "uguale", la stessa. Copiarla avrebbe reso la giunta vera solo finche' * qualcuno teneva allineate due copie a mano, che e' precisamente la cosa che - * `primitives/slab.ts` esiste per impedire. + * `products/topics/geometry.ts` esiste per impedire. * * I parametri sono lo stato del gesto, non il gesto: passando travel 1, lift 0 * e closeGap 1 si ottiene la board a consegna avvenuta, che e' il primo frame diff --git a/video/src/primitives/SlabChrome.tsx b/video/src/products/topics/SlabChrome.tsx similarity index 85% rename from video/src/primitives/SlabChrome.tsx rename to video/src/products/topics/SlabChrome.tsx index 5995fa2..0d06d61 100644 --- a/video/src/primitives/SlabChrome.tsx +++ b/video/src/products/topics/SlabChrome.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { app, fontStack, monoStack, radius } from "../theme"; +import { app, fontStack, monoStack, radius } from "./tokens"; import { BOARD_TOP, COL_HEADER_H, @@ -9,11 +9,9 @@ import { HEADER_H, PANEL_W, SIDEBAR_W, - SLAB_H, - SLAB_W, cardHeight, columnX, -} from "./slab"; +} from "./geometry"; /** * Il contenuto della lastra, disegnato una volta sola per tutte le scene. @@ -490,70 +488,12 @@ export const DetailPanel: React.FC<{
); -/** Vignettatura + riflesso: la stessa luce in tutte le scene. */ -/** - * Lo spessore della lastra: il piano dietro il piano. - * - * PERCHE' ESISTE. Frontale non si vede, ed e' il motivo per cui e' mancato - * finora: a yaw piccoli sta esattamente dietro la lastra e non sporge da - * nessuna parte. Si vede quando la camera gira, ed e' li' che decide se quello - * che si sta guardando e' un OGGETTO o una carta da parati incollata sul fondo. - * Senza, un'orbita mostra un rettangolo che ruota; con, mostra una cosa che ha - * un dietro. - * - * E' UN FRATELLO DELLA LASTRA, non un suo figlio, e non e' un dettaglio: la - * lastra ritaglia (`overflow: hidden`, per via degli angoli arrotondati e del - * contenuto che deborda) e QUALUNQUE ritaglio appiattisce `preserve-3d`. Un - * figlio a translateZ(-30) verrebbe schiacciato sul piano del padre e non - * sporgerebbe mai. Da fratello, dentro la stessa prospettiva, il suo Z e' vero. - * - * I 30 di stacco e i 6 px di debordo per lato sono la stessa coppia di numeri - * che il catalogo dichiara in MAT-01. +/* + * Lo spessore della lastra (SlabEdge) e la luce sul quadro (SlabLighting) + * stavano qui, e ogni scena li montava dentro la propria copia del blocco di + * ripresa. Adesso li disegna `kit/Shot.tsx`, con i numeri di Topics in + * `products/topics/material.ts`: lo spessore resta fratello della lastra e non + * figlio, per la ragione scritta li'. */ -export const SlabEdge: React.FC<{ - left: number; - top: number; - pushZ: number; - yaw: number; - pitch: number; -}> = ({ left, top, pushZ, yaw, pitch }) => ( -
-); - -export const SlabLighting: React.FC = () => ( - <> -
-
- -); export type { Card, Column }; diff --git a/video/src/products/topics/benches.ts b/video/src/products/topics/benches.ts new file mode 100644 index 0000000..b345568 --- /dev/null +++ b/video/src/products/topics/benches.ts @@ -0,0 +1,112 @@ +import { + CARD_FOCUS_END_POSE, + CARD_HANDOFF_END_POSE, + COMP_H, + COMP_W, + PERSPECTIVE_ORIGIN_Y, + PROMPT_INPUT_END_POSE, + SIDEBAR_W, + SLAB_H, + SLAB_SCALE, + THREAD_TOP, + TOPICS_RIG, + TOPICS_SLAB, + TOPICS_STAGE, + handoffLandedRect, +} from "./geometry.ts"; +import { slabPointOnScreen as kitSlab, zoomForPush as kitZoom } from "../../kit/rig.ts"; + +const slabPointOnScreen = (x: number, y: number): { x: number; y: number } => + kitSlab(TOPICS_STAGE, TOPICS_RIG, TOPICS_SLAB, { x, y }); +const zoomForPush = (z: number): number => kitZoom(TOPICS_RIG, z); + +/** + * Quello che i banchi delle scene di Topics devono sapere della geometria: + * ritagli, bande, ingrandimenti. Calcolato qui, da topics/geometry.ts, e stampato da + * `scripts/manifest.mjs`. + * + * PERCHE' ESISTE. Quattro banchi (handoff-travel, focus-sharpness, + * fixture-screenshot, contrast-floor) importavano topics/geometry.ts dentro uno script + * `node -e` scritto nel proprio corpo, e rifacevano ognuno per conto suo gli + * stessi conti di ripresa: dove sta la card, quanto ingrandisce la spinta, + * dove cade l'intestazione del thread. Quattro copie di un calcolo restano + * uguali finche' nessuno ne tocca una. Qui stanno una volta sola, i banchi + * chiedono il numero, e le loro uscite sono identiche a prima riga per riga. + * + * I conti sono QUELLI DI PRIMA, operazione per operazione, e usano la + * proiezione con yaw e pitch a zero, esatta per le pose frontali che questi + * banchi misurano. Il manifest per un prodotto qualsiasi usera' kit/project.ts. + * + * Modulo puro, letto da Node. + */ + +/** handoff-travel.sh: dove comincia il pannello assistente, in percento dell'altezza. */ +export const handoffBand = (): number => + Math.round((THREAD_TOP / SLAB_H) * 100) - 4; + +/** + * focus-sharpness.sh e fixture-screenshot.sh: la card consegnata all'ultimo + * frame di CardFocus, dove stava nel campo largo, e quanto manca da li' alla + * scala finale. + */ +export const cardFocusGeometry = (): { + cw: number; + ch: number; + cx: number; + cy: number; + zoom: string; + wx: number; + wy: number; + k: string; +} => { + const r = handoffLandedRect(); + const p = slabPointOnScreen(r.x + r.w / 2, r.y + r.h / 2); + const ox = COMP_W / 2; + const oy = COMP_H * PERSPECTIVE_ORIGIN_Y; + const k1 = zoomForPush(CARD_FOCUS_END_POSE.pushZ); + const k0 = zoomForPush(CARD_HANDOFF_END_POSE.pushZ); + const wx = ox + (p.x - ox) * k0; + const wy = oy + (p.y - oy) * k0; + return { + cw: Math.round(r.w * SLAB_SCALE * k1), + ch: Math.round(r.h * SLAB_SCALE * k1), + cx: Math.round(ox), + cy: Math.round(oy), + zoom: k1.toFixed(4), + wx: Math.round(wx), + wy: Math.round(wy), + k: (k1 / k0).toFixed(4), + }; +}; + +/** + * contrast-floor.py: l'intestazione del thread all'ultima posa di PromptInput. + * Contenuto vero, attenuato per costruzione mentre la risposta scorre, e in una + * posizione aritmetica invece che dipendente da come vanno a capo i messaggi. + * Dimensioni pari: su una sorgente yuv420p ffmpeg arrotonda un ritaglio dispari + * al pixel sotto, e il banco scambierebbe una riga in meno per un'estrazione + * fallita. + */ +export const contrastCrop = (): { x: number; y: number; w: number; h: number } => { + const p = PROMPT_INPUT_END_POSE; + const k = zoomForPush(p.pushZ); + const ox = COMP_W / 2; + const oy = COMP_H * PERSPECTIVE_ORIGIN_Y; + const x0 = SIDEBAR_W + 20; + const x1 = SIDEBAR_W + 560; + const y0 = THREAD_TOP + 10; + const y1 = THREAD_TOP + 44; + const P = (x: number, y: number): [number, number] => { + const s = slabPointOnScreen(x, y); + return [ox + (s.x + p.slideX - ox) * k, oy + (s.y + (p.slideY ?? 0) - oy) * k]; + }; + const a = P(x0, y0); + const b = P(x1, y1); + const pari = (v: number): number => 2 * Math.floor(v / 2); + return { + x: pari(Math.max(0, Math.round(a[0]))), + y: pari(Math.round(a[1])), + w: pari(Math.round(b[0] - a[0])), + h: pari(Math.round(b[1] - a[1])), + }; +}; diff --git a/video/src/primitives/slab.ts b/video/src/products/topics/geometry.ts similarity index 88% rename from video/src/primitives/slab.ts rename to video/src/products/topics/geometry.ts index e82e6ab..8d3402c 100644 --- a/video/src/primitives/slab.ts +++ b/video/src/products/topics/geometry.ts @@ -17,6 +17,17 @@ * aritmetica pura e la si puo' interpolare. */ +// Import con l'estensione scritta e i tipi in un import a parte: questo file lo +// leggono anche i banchi, da Node, che toglie i tipi senza compilare e non +// risolve un import senza estensione. +import { STAGES } from "../../kit/stage.ts"; +import type { Stage } from "../../kit/stage.ts"; +import { + centreOn as kitCentreOn, + pushForZoom as kitPushForZoom, +} from "../../kit/rig.ts"; +import type { Rig, SlabSize } from "../../kit/rig.ts"; + export const SLAB_W = 2400; export const SLAB_H = 1200; @@ -251,43 +262,34 @@ export const THREAD_PAD_BOTTOM = SLAB_H - COMPOSER_Y + 24; * a occhio spostando numeri finche' sembra giusto: si fa una volta e si * verifica sul render. */ -export const COMP_W = 1920; -export const COMP_H = 1080; +export const COMP_W = STAGES["16x9"].w; +export const COMP_H = STAGES["16x9"].h; export const SLAB_SCALE = 1.04; export const PERSPECTIVE = 2600; export const PERSPECTIVE_ORIGIN_Y = 0.46; -/** Dove cade un punto della lastra, con yaw e pitch a zero e prima della spinta in Z. */ -export const slabPointOnScreen = ( - x: number, - y: number, -): { x: number; y: number } => ({ - x: (COMP_W - SLAB_W) / 2 + SLAB_W / 2 + (x - SLAB_W / 2) * SLAB_SCALE, - y: (COMP_H - SLAB_H) / 2 + SLAB_H / 2 + (y - SLAB_H / 2) * SLAB_SCALE, -}); - /** - * Lo scostamento che porta quel punto sull'origine della prospettiva. + * Topics nei termini del kit: lo stage, il rig e la dimensione della lastra che + * le sei scene usano. Chi deve sapere dove cade un punto chiama `kit/rig.ts` + * con questi tre. * - * Serve perche' l'origine della prospettiva e' l'unico punto del quadro che non - * si sposta mentre la camera avanza in Z: qualsiasi altro punto scappa verso il - * bordo. Centrare li' l'oggetto e' la condizione perche' una discesa al macro - * resti puntata su di lui invece di scivolargli accanto. + * Qui c'erano slabPointOnScreen, centreOn, zoomForPush e pushForZoom scritti con + * 1920 e 1080 dentro, che valevano solo in 16:9. Sono stati prima un ponte verso + * il kit, identico al bit (scripts/geometry-snapshot.mjs), poi sono spariti: + * adesso le pose qui sotto e il manifest dei banchi chiamano il kit direttamente. */ -export const centreOn = ( - x: number, - y: number, -): { slideX: number; slideY: number } => { - const p = slabPointOnScreen(x, y); - return { - slideX: COMP_W / 2 - p.x, - slideY: COMP_H * PERSPECTIVE_ORIGIN_Y - p.y, - }; +export const TOPICS_STAGE: Stage = STAGES["16x9"]; +export const TOPICS_RIG: Rig = { + perspective: PERSPECTIVE, + originX: 0.5, + originY: PERSPECTIVE_ORIGIN_Y, + slabScale: SLAB_SCALE, }; +export const TOPICS_SLAB: SlabSize = { w: SLAB_W, h: SLAB_H }; -/** L'ingrandimento che produce una spinta in Z, e la spinta che serve per un ingrandimento. */ -export const zoomForPush = (z: number): number => PERSPECTIVE / (PERSPECTIVE - z); -export const pushForZoom = (k: number): number => PERSPECTIVE * (1 - 1 / k); +/** Il centraggio di un punto della lastra di Topics: `kit/rig.ts` con i numeri qui sopra. */ +const centreTopics = (x: number, y: number): { slideX: number; slideY: number } => + kitCentreOn(TOPICS_STAGE, TOPICS_RIG, TOPICS_SLAB, { x, y }); /** Quanto ingrandisce la discesa di CardFocus. Oltre 2,6 la card esce dal quadro. */ export const CARD_FOCUS_ZOOM = 2.35; @@ -298,11 +300,11 @@ export const CARD_FOCUS_ZOOM = 2.35; */ export const CARD_FOCUS_END_POSE: CameraPose = (() => { const r = handoffLandedRect(); - const c = centreOn(r.x + r.w / 2, r.y + r.h / 2); + const c = centreTopics(r.x + r.w / 2, r.y + r.h / 2); return { yaw: 0, pitch: 0, - pushZ: pushForZoom(CARD_FOCUS_ZOOM), + pushZ: kitPushForZoom(TOPICS_RIG, CARD_FOCUS_ZOOM), slideX: c.slideX, slideY: c.slideY, }; @@ -328,11 +330,11 @@ export const PROMPT_INPUT_END_POSE: CameraPose = (() => { const k = PROMPT_INPUT_ZOOM; const visibleH = COMP_H / (k * SLAB_SCALE); const cy = SLAB_H - visibleH * (1 - PERSPECTIVE_ORIGIN_Y); - const c = centreOn(COMPOSER_X + COMPOSER_W / 2, cy); + const c = centreTopics(COMPOSER_X + COMPOSER_W / 2, cy); return { yaw: 0, pitch: 0, - pushZ: pushForZoom(k), + pushZ: kitPushForZoom(TOPICS_RIG, k), slideX: c.slideX, slideY: c.slideY, }; diff --git a/video/src/products/topics/material.ts b/video/src/products/topics/material.ts new file mode 100644 index 0000000..e1a4833 --- /dev/null +++ b/video/src/products/topics/material.ts @@ -0,0 +1,53 @@ +import type { ShotMaterial } from "../../kit/Shot"; +import { SLAB_BACKDROP, app, fontStack } from "./tokens"; + +/** + * L'aspetto della ripresa di Topics: i numeri che le sei scene scrivevano a mano + * dentro il proprio blocco di ripresa, adesso in un posto solo. + * + * I valori sono gli stessi, carattere per carattere: raggi 18, 20 e 22, lo + * spessore a 30 dietro il piano con 6 px di debordo, il piano attenuato spostato + * di -180 e -80, girato di 8 e 4 gradi in piu', scalato a 0,92, che segue lo yaw + * al 60% e lo spostamento al 45%. Cambiarne uno qui cambia tutte le scene, che e' + * esattamente il punto. + */ +export const TOPICS_SHOT_MATERIAL: ShotMaterial = { + stage: { background: app.bg, fontFamily: fontStack }, + slab: { + background: app.bg, + radius: 18, + border: `1px solid ${app.borderLight}`, + shadow: + "0 80px 160px rgba(0,0,0,0.78), 0 0 0 1px rgba(255,255,255,0.05) inset", + highlight: + "linear-gradient(to right, transparent, rgba(255,255,255,0.12) 20%, rgba(255,255,255,0.18) 50%, rgba(255,255,255,0.12) 80%, transparent)", + }, + edge: { + background: "#05060a", + radius: 22, + shadow: "0 90px 180px rgba(0,0,0,0.75)", + depth: 30, + overhang: 6, + }, + backdrop: { + perspective: SLAB_BACKDROP.perspective, + perspectiveOrigin: SLAB_BACKDROP.perspectiveOrigin, + opacity: SLAB_BACKDROP.opacity, + blur: SLAB_BACKDROP.blur, + offsetX: -180, + offsetY: -80, + yawFollow: 0.6, + slideFollow: 0.45, + yawOffset: 8, + pitchOffset: 4, + scale: 0.92, + background: app.surface, + border: `1px solid ${app.border}`, + radius: 20, + }, + lighting: { + vignette: + "radial-gradient(130% 90% at 50% 52%, rgba(0,0,0,0) 38%, rgba(0,0,0,0.62) 100%)", + sheen: "linear-gradient(to bottom, rgba(255,255,255,0.04) 0%, transparent 8%)", + }, +}; diff --git a/video/src/products/topics/scenes/BoardOrbit.tsx b/video/src/products/topics/scenes/BoardOrbit.tsx new file mode 100644 index 0000000..1c88c25 --- /dev/null +++ b/video/src/products/topics/scenes/BoardOrbit.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { + Easing, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { + COLUMNS, + HANDOFF_FROM_COL, + HANDOFF_FROM_IDX, + handoffCard, + handoffLandedRect, + TOPICS_RIG, + TOPICS_SLAB, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { + BOARD_ORBIT_BASE as BASE, + BOARD_ORBIT_SETTLE as SETTLE, + boardOrbitTrack, +} from "../tracks"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; +import { Board } from "../Board"; +import { tempo } from "../../../primitives/tempo"; + +/** + * BoardOrbit: il sesto anello, e la fine del film. + * + * La camera lascia il composer e gira attorno alla lastra quanto basta a + * mostrarle un bordo. Serve perche' un pezzo che finisce addosso a un dettaglio + * resta aperto, e perche' finora la lastra non aveva mai dichiarato di essere + * un oggetto: frontale, un piano inclinato con dentro della UI e' + * indistinguibile da una carta da parati incollata sul fondo. E' l'unica cosa + * che un film di prodotto dice una volta sola, all'inizio o alla fine. + * + * LO SPESSORE ESISTE SOLO PERCHE' LA CAMERA GIRA. Lo disegna `kit/Shot.tsx` in tutte e + * cinque le scene precedenti, e in nessuna si vede: a yaw piccoli sta esattamente + * dietro la lastra. Qui sporge, ed e' il motivo per cui e' stato scritto. + * + * L'ATTENUAZIONE SI RIAPRE. Il primo fotogramma la trova a 0,62, che e' dove + * PromptInput l'ha lasciata, e la riporta a 1 lungo l'orbita: la risposta e' + * finita, l'attenzione torna all'oggetto intero. Partire da 1 avrebbe rotto la + * giunta di piu' di quanto qualunque posa possa fare. + * + * LA CAMERA SI FERMA PRIMA DELLA FINE, a f118 su 150. Gli ultimi trenta + * fotogrammi sono fermi, cosi' il pezzo finisce su una posa e non su un + * movimento interrotto, e un'altra scena potrebbe attaccarsi qui. + * + * Frame-locked: ogni valore viene da useCurrentFrame(). + */ + +export type BoardOrbitProps = { + progress?: number; +}; + +/* BASE, la durata di riferimento, e SETTLE, il frame in cui la camera arriva e + si ferma, stanno in products/topics/tracks.ts: li legge anche la traccia. */ + +export const BoardOrbit: React.FC = ({ progress }) => { + const localFrame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const frame = + progress === undefined ? localFrame : progress * (durationInFrames - 1); + + const T = tempo(durationInFrames, BASE); + + const at = (from: number, to: number): number => + interpolate(frame, [0, T.at(SETTLE)], [from, to], { + easing: Easing.inOut(Easing.cubic), + extrapolateRight: "clamp", + }); + + // La camera sta in products/topics/tracks.ts, con la stessa curva e la stessa + // finestra: `at` resta qui per l'attenuazione, che non e' camera. + const pose = poseAt(boardOrbitTrack(durationInFrames), frame); + + // Il quadro si riapre: 0,62 e' dove PromptInput ha lasciato l'attenuazione. + const attn = at(0.62, 1); + + + // La board sta come l'hanno lasciata le scene prima: consegna avvenuta, e il + // thread con la risposta gia' arrivata per intero. + const moving = handoffCard(); + const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( + (_, i) => i !== HANDOFF_FROM_IDX, + ); + const landed = handoffLandedRect(); + + const board = { + closeGap: 1, + travel: 1, + lift: 0, + cardX: landed.x, + cardY: landed.y, + moving: { ...moving, age: "ora" }, + fromRest, + }; + + const assistant = { + sent: true, + sentPrompt: "Rifai il flusso di auth e apri la PR", + answer: + "Trovati tre punti di chiamata in server/auth.ts. Sposto il refresh del token dentro un guard solo, poi apro la PR su topics/auth-refresh.", + frame, + attn, + }; + + + return ( + } + > + + + ); +}; diff --git a/video/src/products/topics/scenes/CardFocus.tsx b/video/src/products/topics/scenes/CardFocus.tsx new file mode 100644 index 0000000..a1909d2 --- /dev/null +++ b/video/src/products/topics/scenes/CardFocus.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { + COLUMNS, + HANDOFF_FROM_COL, + HANDOFF_FROM_IDX, + TOPICS_RIG, + TOPICS_SLAB, + handoffCard, + handoffLandedRect, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { cardFocusTrack } from "../tracks"; +import { Board } from "../Board"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; + +/** + * CardFocus: la quarta scena, e il terzo anello della catena. + * + * Con tre scene la regola "niente tagli" era una proprieta' di UNA coppia. Con + * quattro diventa una proprieta' della sequenza, che e' una cosa diversa: una + * catena si rompe al primo anello che nessuno misura, e finora l'unico anello + * misurato era UIMockup -> CardHandoff. + * + * ENTRA DALLA POSA IN CUI CARDHANDOFF SI FERMA, e disegna la board nello stato + * in cui quella la lascia: consegna avvenuta, colonna richiusa. Non la ridisegna + * per conto suo, passa `travel: 1, lift: 0, closeGap: 1` allo STESSO componente + * `Board`. Il primo frame di questa e' l'ultimo di quella per costruzione, e + * `seam.sh` lo misura invece di dare per buono che lo sia. + * + * LA CAMERA NON INVERTE MAI IL VERSO. Lungo le quattro scene lo yaw va da -18 a + * -9 a -4 a 0, il pitch da 5 a 2,5 a 1,2 a 0, la spinta da 0 a 48 a 96 a 1494. + * Tutte e tre le grandezze sono monotone, ed e' la condizione che rende una + * giunta invisibile anche quando i pixel coincidono: l'occhio segue la derivata + * del movimento, e un'inversione la legge come uno stacco. + * + * IL MACRO E' IL MOTIVO PER CUI LA LASTRA DEVE ESSERE DOM. La camera arriva + * addosso alla card ingrandendola di due volte e un terzo. Uno screenshot a + * 1920 a quella scala si sfalda; qui il testo tiene perche' viene rasterizzato + * alla dimensione finale. Non e' un'opinione, la misura `focus-sharpness.sh`. + * + * Frame-locked: ogni valore viene da useCurrentFrame(). + */ + +export type CardFocusProps = { + progress?: number; +}; + +export const CardFocus: React.FC = ({ progress }) => { + const localFrame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const frame = + progress === undefined ? localFrame : progress * (durationInFrames - 1); + + // Una curva sola per tutta la scena, come nelle due precedenti. inOut arriva + // agli estremi con derivata nulla: a sinistra si aggancia alla fine di + // CardHandoff, che e' ferma, a destra lascia una scena che si puo' mettere + // prima di qualunque altra. La curva sta in products/topics/tracks.ts. + const pose = poseAt(cardFocusTrack(durationInFrames), frame); + + // La board a consegna avvenuta: sono i tre valori che CardHandoff raggiunge + // al suo ultimo frame, e la card sta dove dice `handoffLandedRect`. + const moving = handoffCard(); + const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( + (_, i) => i !== HANDOFF_FROM_IDX, + ); + const landed = handoffLandedRect(); + + const board = { + closeGap: 1, + travel: 1, + lift: 0, + cardX: landed.x, + cardY: landed.y, + moving, + fromRest, + }; + + // Il piano attenuato dietro segue la camera piu' lentamente della lastra: i + // fattori stanno nel materiale di Topics, uguali per tutte le scene. + return ( + } + > + + + ); +}; diff --git a/video/src/scenes/CardHandoff.tsx b/video/src/products/topics/scenes/CardHandoff.tsx similarity index 65% rename from video/src/scenes/CardHandoff.tsx rename to video/src/products/topics/scenes/CardHandoff.tsx index fa23dcd..97e0d69 100644 --- a/video/src/scenes/CardHandoff.tsx +++ b/video/src/products/topics/scenes/CardHandoff.tsx @@ -1,20 +1,14 @@ import React from "react"; import { - AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig, } from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; import { - CARD_HANDOFF_END_POSE, CARD_H, COLUMNS, COL_W, - SLAB_H, - SLAB_W, - UI_MOCKUP_END_POSE, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, HANDOFF_TO_COL, @@ -22,11 +16,16 @@ import { columnX, handoffCard, handoffTargetCards, -} from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; -import { Board } from "../primitives/Board"; -import { Cursor, pointOnPath, type Waypoint } from "../primitives/Cursor"; -import { tempo } from "../primitives/tempo"; + TOPICS_RIG, + TOPICS_SLAB, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { cardHandoffTrack } from "../tracks"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; +import { Board } from "../Board"; +import { Cursor, pointOnPath, type Waypoint } from "../../../primitives/Cursor"; +import { tempo } from "../../../primitives/tempo"; /** * CardHandoff: la terza scena, e quella che rende dimostrabile la regola @@ -129,23 +128,10 @@ export const CardHandoff: React.FC = ({ progress }) => { const last = durationInFrames - 1; const T = tempo(durationInFrames, BASE); - // La camera continua l'arco di UIMockup: stessa direzione, stessa curva. - const yaw = interpolate(frame, [0, last], [UI_MOCKUP_END_POSE.yaw, CARD_HANDOFF_END_POSE.yaw], { - easing: Easing.inOut(Easing.quad), - extrapolateRight: "clamp", - }); - const pitch = interpolate(frame, [0, last], [UI_MOCKUP_END_POSE.pitch, CARD_HANDOFF_END_POSE.pitch], { - easing: Easing.inOut(Easing.quad), - extrapolateRight: "clamp", - }); - const pushZ = interpolate(frame, [0, last], [UI_MOCKUP_END_POSE.pushZ, CARD_HANDOFF_END_POSE.pushZ], { - easing: Easing.inOut(Easing.quad), - extrapolateRight: "clamp", - }); + // La camera continua l'arco di UIMockup: stessa direzione, stessa curva + // (products/topics/tracks.ts). + const pose = poseAt(cardHandoffTrack(durationInFrames), frame); - // NESSUN fade-in. Un fade da nero all'inizio sarebbe un taglio con le buone - // maniere: il primo frame deve essere gia' pieno, identico all'ultimo di prima. - const bgYaw = yaw * 0.6; const moving = handoffCard(); @@ -232,109 +218,46 @@ export const CardHandoff: React.FC = ({ progress }) => { }); return ( - - -
- = T.at(COUNT_AT) ? 1 : 0} - statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} - dimmed - /> -
-
- - - {/* Lo spessore, dietro. Fratello e non figlio: la lastra ritaglia, e - qualunque ritaglio appiattisce il 3D dei suoi figli. */} - = T.at(COUNT_AT) ? 1 : 0} + statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} + dimmed /> -
- = T.at(COUNT_AT) ? 1 : 0} - statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} - /> - - {/* La mano sta DENTRO la lastra, quindi prende la stessa prospettiva - e appoggia sul piano. Al primo e all'ultimo frame sta fuori dai - 2400x1200 e l'overflow la taglia: e' cosi' che le due giunte - restano identiche a scene che un cursore non ce l'hanno. */} - - -
-
- + } + > + = T.at(COUNT_AT) ? 1 : 0} + statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} + /> - - + {/* La mano sta DENTRO la lastra, quindi prende la stessa prospettiva + e appoggia sul piano. Al primo e all'ultimo frame sta fuori dai + 2400x1200 e l'overflow la taglia: e' cosi' che le due giunte + restano identiche a scene che un cursore non ce l'hanno. */} + + ); }; diff --git a/video/src/products/topics/scenes/CardRelease.tsx b/video/src/products/topics/scenes/CardRelease.tsx new file mode 100644 index 0000000..e1c5a16 --- /dev/null +++ b/video/src/products/topics/scenes/CardRelease.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { + COLUMNS, + HANDOFF_FROM_COL, + HANDOFF_FROM_IDX, + handoffCard, + handoffLandedRect, + TOPICS_RIG, + TOPICS_SLAB, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { cardReleaseTrack } from "../tracks"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; +import { Board } from "../Board"; + +/** + * CardRelease: il quarto anello, e la fine del film. + * + * La camera si stacca dalla card e torna larga e frontale, con la board di + * nuovo tutta leggibile. Serve perche' un piano sequenza deve finire da + * qualche parte, e finire addosso a un dettaglio lascia il pezzo aperto. + * + * QUESTA SCENA INVERTE LA SPINTA, ed e' l'unica. Altrove il repo sostiene che + * un'inversione a una giunta si legge come uno stacco anche a pixel + * coincidenti, il che e' vero dove la giunta e' in MOVIMENTO: li' l'occhio + * segue la derivata. Qui i due lati sono fermi, CardFocus arriva a derivata + * nulla e questa riparte da derivata nulla, quindi non c'e' nessuna derivata da + * rovesciare. E' la stessa ragione per cui due scene ferme agli estremi si + * possono mettere in qualunque ordine, e adesso `rest-point.sh` la misura + * invece di lasciarla scritta. + * + * LA BOARD E' QUELLA A CONSEGNA AVVENUTA, come in CardFocus e per lo stesso + * motivo: e' lo stesso componente con gli stessi valori, quindi il primo frame + * di questa e' l'ultimo di quella per costruzione. + * + * Frame-locked: ogni valore viene da useCurrentFrame(). + */ + +export type CardReleaseProps = { + progress?: number; +}; + +export const CardRelease: React.FC = ({ progress }) => { + const localFrame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const frame = + progress === undefined ? localFrame : progress * (durationInFrames - 1); + + // Una curva sola, inOut: derivata nulla a sinistra per agganciarsi alla fine + // di CardFocus, derivata nulla a destra perche' e' l'ultimo frame del film. + // La curva sta in products/topics/tracks.ts. + const pose = poseAt(cardReleaseTrack(durationInFrames), frame); + + + const moving = handoffCard(); + const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( + (_, i) => i !== HANDOFF_FROM_IDX, + ); + const landed = handoffLandedRect(); + + const board = { + closeGap: 1, + travel: 1, + lift: 0, + cardX: landed.x, + cardY: landed.y, + moving, + fromRest, + }; + + return ( + } + > + + + ); +}; diff --git a/video/src/scenes/PromptInput.tsx b/video/src/products/topics/scenes/PromptInput.tsx similarity index 71% rename from video/src/scenes/PromptInput.tsx rename to video/src/products/topics/scenes/PromptInput.tsx index e80d2fb..b1a4299 100644 --- a/video/src/scenes/PromptInput.tsx +++ b/video/src/products/topics/scenes/PromptInput.tsx @@ -1,36 +1,38 @@ import React from "react"; import { - AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig, } from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; -import { Board } from "../primitives/Board"; -import { bubbleCurve } from "../primitives/Assistant"; -import { Cursor, type Waypoint } from "../primitives/Cursor"; -import { typedCount, typingSchedule } from "../primitives/rhythm"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; -import { tempo } from "../primitives/tempo"; +import { Board } from "../Board"; +import { bubbleCurve } from "../Assistant"; +import { Cursor, type Waypoint } from "../../../primitives/Cursor"; +import { typedCount, typingSchedule } from "../../../primitives/rhythm"; +import { tempo } from "../../../primitives/tempo"; import { - CARD_RELEASE_END_POSE, COLUMNS, COMPOSER_H, COMPOSER_X, COMPOSER_Y, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, - PROMPT_INPUT_END_POSE, SEND_H, SEND_W, SEND_X, SEND_Y, - SLAB_H, - SLAB_W, handoffCard, handoffLandedRect, -} from "../primitives/slab"; + TOPICS_RIG, + TOPICS_SLAB, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { + PROMPT_INPUT_BASE as BASE, + promptInputTrack, +} from "../tracks"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; /** * PromptInput: il quinto anello. Il cursore scende sul composer, scrive, invia, @@ -76,8 +78,8 @@ export type PromptInputProps = { // I frame della recita. La pausa prima dell'invio e' la parte che la rende // credibile: senza, l'invio parte insieme all'ultimo tasto e legge come uno // script che esegue, non come qualcuno che rilegge. -/** La durata di riferimento a cui sono scritti i tempi. Vedi primitives/tempo.ts. */ -const BASE = 450; +/* La durata di riferimento a cui sono scritti i tempi (BASE) sta in + products/topics/tracks.ts, perche' la legge anche la traccia della camera. */ const T = { travelStart: 56, @@ -177,7 +179,7 @@ export const PromptInput: React.FC = ({ * poi ci si ferma: nessuno muove la macchina mentre qualcuno scrive e legge, * perche' l'inquadratura in cui si legge deve stare ferma. */ - const CAM_SETTLE = K.at(132); + // La finestra e' PROMPT_INPUT_CAM_SETTLE in products/topics/tracks.ts. // Lo streaming va a blocchi di parole, non a caratteri. Un LLM non scrive // lettera per lettera: arriva a token, e l'occhio lo riconosce. @@ -198,25 +200,10 @@ export const PromptInput: React.FC = ({ // La camera: una curva sola, inOut, derivata nulla ai due capi. A sinistra // per agganciarsi alla fine di CardRelease, a destra perche' la scena si - // ferma e un'altra ci si possa attaccare. - const at = (from: number, to: number): number => - interpolate(frame, [0, CAM_SETTLE], [from, to], { - easing: Easing.inOut(Easing.cubic), - extrapolateRight: "clamp", - }); - - const yaw = at(CARD_RELEASE_END_POSE.yaw, PROMPT_INPUT_END_POSE.yaw); - const pitch = at(CARD_RELEASE_END_POSE.pitch, PROMPT_INPUT_END_POSE.pitch); - const pushZ = at(CARD_RELEASE_END_POSE.pushZ, PROMPT_INPUT_END_POSE.pushZ); - const slideX = at(CARD_RELEASE_END_POSE.slideX, PROMPT_INPUT_END_POSE.slideX); - const slideY = at( - CARD_RELEASE_END_POSE.slideY ?? 0, - PROMPT_INPUT_END_POSE.slideY ?? 0, - ); + // ferma e un'altra ci si possa attaccare. La curva, e la finestra di 132 frame + // spiegata qui sopra, stanno in products/topics/tracks.ts. + const pose = poseAt(promptInputTrack(durationInFrames), frame); - const bgYaw = yaw * 0.6; - const bgSlideX = slideX * 0.45; - const bgSlideY = slideY * 0.45; const focused = frame >= K.at(T.clickField); // Il caret lampeggia a 15 frame, e il calcolo e' sul frame: nessun keyframe CSS. @@ -247,7 +234,7 @@ export const PromptInput: React.FC = ({ ); // Il percorso del cursore, in coordinate della lastra condivisa. Le mire sono - // le costanti di slab.ts, non due numeri copiati dal layout: se il composer + // le costanti di topics/geometry.ts, non due numeri copiati dal layout: se il composer // si sposta il puntatore lo segue. const path: Waypoint[] = [ { x: 2620, y: 1330, at: 0 }, @@ -299,72 +286,20 @@ export const PromptInput: React.FC = ({ }; return ( - - -
- -
-
- - - {/* Lo spessore, dietro. Fratello e non figlio: la lastra ritaglia, e - qualunque ritaglio appiattisce il 3D dei suoi figli. */} - -
- - - {/* Il cursore sta DENTRO la lastra, quindi prende la stessa - prospettiva e appoggia sul piano. Uno disegnato sopra il quadro, - dritto, tradisce subito che la lastra e' un'immagine. */} - -
-
+ } + > + - -
+ {/* Il cursore sta DENTRO la lastra, quindi prende la stessa + prospettiva e appoggia sul piano. Uno disegnato sopra il quadro, + dritto, tradisce subito che la lastra e' un'immagine. */} + + ); }; diff --git a/video/src/products/topics/scenes/UIMockup.tsx b/video/src/products/topics/scenes/UIMockup.tsx new file mode 100644 index 0000000..08216e5 --- /dev/null +++ b/video/src/products/topics/scenes/UIMockup.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { Assistant } from "../Assistant"; +import { + Easing, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { + COLUMNS, + addCardY, + cardY, + columnX, + COL_W, + TOPICS_RIG, + TOPICS_SLAB, +} from "../geometry"; +import { poseAt } from "../../../kit/camera"; +import { uiMockupTrack } from "../tracks"; +import { Shot } from "../../../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../material"; +import { + AddCard, + AppChrome, + AppSidebar, + CardBox, + ColumnHeader, + DetailPanel, +} from "../SlabChrome"; + +/** + * UIMockup: app window su piano CSS 3D inclinato, con parallasse su camera move. + * + * La grammatica e' quella dei Linear Diffs: una lastra di UI VERA che riempie il + * quadro, entra da fuori frame, e si stabilizza senza mai tagliare. Il "vera" + * significa token da topics/tokens.ts, raggi 8/6/4, font di sistema, non un wireframe. + * + * Tre decisioni strutturali: + * + * LA LASTRA ENTRA DA FUORI FRAME. La scena comincia con la finestra a destra del + * quadro e scivola in posizione con un easing in-out. Cosi' il primo frame non e' + * una composizione statica: c'e' gia' movimento, e il movimento porta l'occhio. + * + * IL PIANO HA SPESSORE. `transformStyle: preserve-3d` + un bordo posteriore + * simulato con un pseudo-bordo assoluto dietro la lastra. Chi ha visto i Linear + * commercials sa che e' il bordo inferiore che dice "questo e' un oggetto fisico". + * + * LA PROFONDITA' DI CAMPO STACCA I PIANI. Il layer di sfondo e' sfocato e + * attenuato, la lastra principale e' nitida: la differenza di fuoco dice all'occhio + * qual e' il piano principale, senza mai spiegarlo. + * + * LA POSA FINALE NON STA PIU' QUI. Le due pose vivono in `products/topics/geometry.ts` + * perche' `CardHandoff` deve partire esattamente da dove questa finisce, e due + * copie dello stesso numero restano uguali solo finche' nessuno tocca una delle + * due. Ora la giunta e' verificabile: `seam.sh` confronta i pixel dell'ultimo + * frame di questa con il primo frame di quella. + * + * Frame-locked: ogni valore deriva da useCurrentFrame(). Nessun CSS keyframe, + * nessun requestAnimationFrame. + */ + +export type UIMockupProps = { + /** Via di fuga: 0 a 1, per farsi pilotare da una timeline padre. */ + progress?: number; +}; + +export const UIMockup: React.FC = ({ progress }) => { + const localFrame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const frame = + progress === undefined ? localFrame : progress * (durationInFrames - 1); + + + // La camera: la lastra entra da destra in 80 frame e la camera si raddrizza + // per tutta la scena, finendo piu' frontale di come e' partita, cosi' la scena + // successiva puo' partire da qui. La curva sta in products/topics/tracks.ts, la + // stessa che leggono i banchi. + const pose = poseAt(uiMockupTrack(durationInFrames), frame); + + const fadeIn = interpolate(frame, [0, 20], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + + const cardRevealStart = 75; + + return ( + } + > + + + ); +}; + +/** + * Il corpo della lastra. Le card entrano con stagger: ogni colonna 12 frame + * dopo la precedente, ogni card 8 dopo quella sopra. + */ +const SlabBody: React.FC<{ + frame: number; + cardRevealStart: number; + dimmed?: boolean; +}> = ({ frame, cardRevealStart, dimmed = false }) => ( + <> + + + + {COLUMNS.map((col, colIdx) => ( + + + + {col.cards.map((card, cardIdx) => { + const start = cardRevealStart + colIdx * 12 + cardIdx * 8; + const p = interpolate(frame, [start, start + 22], [0, 1], { + easing: Easing.out(Easing.cubic), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return ( +
+ +
+ ); + })} + + +
+ ))} + + + + +); diff --git a/video/src/theme.ts b/video/src/products/topics/tokens.ts similarity index 88% rename from video/src/theme.ts rename to video/src/products/topics/tokens.ts index 47a3606..7a5aadd 100644 --- a/video/src/theme.ts +++ b/video/src/products/topics/tokens.ts @@ -47,12 +47,14 @@ export const monoStack = * perche' il contenuto attenuato scende sotto 3:1 da renderizzato. */ /** - * Il piano attenuato dietro la lastra, in tutte e cinque le scene. + * Il piano attenuato dietro la lastra, in tutte le scene. * * Erano quattro numeri scritti a mano in UIMockup, CardHandoff e CardFocus, * identici in tutti e tre i file e senza un nome. Tre copie di una costante * restano uguali finche' nessuno tocca una delle tre, che e' lo stesso motivo - * per cui le pose di camera vivono in slab.ts. + * per cui le pose di camera vivono in topics/geometry.ts. Li legge `products/topics/material.ts`, + * che li passa a `kit/Shot.tsx` insieme agli altri numeri del piano dietro + * (spostamento, inclinazione in piu', scala, inseguimento della camera). * * C'ERA ANCHE UN `BACKDROP_OPACITY` a 0,62, e non c'e' piu'. Serviva a * PromptInput, che aveva un fondo suo dietro una lastra sua; da quando anche diff --git a/video/src/products/topics/tracks.ts b/video/src/products/topics/tracks.ts new file mode 100644 index 0000000..ea3a518 --- /dev/null +++ b/video/src/products/topics/tracks.ts @@ -0,0 +1,104 @@ +import type { Track } from "../../kit/camera.ts"; +import { + BOARD_ORBIT_END_POSE, + CARD_FOCUS_END_POSE, + CARD_HANDOFF_END_POSE, + CARD_RELEASE_END_POSE, + PROMPT_INPUT_END_POSE, + UI_MOCKUP_END_POSE, + UI_MOCKUP_START_POSE, +} from "./geometry.ts"; +import { tempo } from "../../primitives/tempo.ts"; + +/** + * Le camere delle sei scene di Topics, come tracce. + * + * Erano cinque `interpolate` dentro ogni componente. I numeri e le curve sono + * gli stessi, scena per scena: stesse pose di partenza e d'arrivo da topics/geometry.ts, + * stessi easing, stesse finestre, e dove una finestra dipende dalla durata la + * scala con lo stesso `tempo()`. Le scene leggono la loro traccia con `poseAt`; + * chain-check e fill-geom leggono le stesse tracce senza renderizzare. + * + * Modulo puro, letto da Node. + */ + +/** UIMockup: la lastra entra da destra in 80 frame e la camera si raddrizza per tutta la scena. */ +export const UI_MOCKUP_SLIDE_FRAMES = 80; +export const uiMockupTrack = (durationInFrames: number): Track => { + const last = durationInFrames - 1; + return { + yaw: { from: UI_MOCKUP_START_POSE.yaw, to: UI_MOCKUP_END_POSE.yaw, start: 0, end: last, ease: "inOutQuad" }, + pitch: { from: UI_MOCKUP_START_POSE.pitch, to: UI_MOCKUP_END_POSE.pitch, start: 0, end: last, ease: "inOutQuad" }, + pushZ: { from: UI_MOCKUP_START_POSE.pushZ, to: UI_MOCKUP_END_POSE.pushZ, start: 0, end: last, ease: "inOutQuad" }, + slideX: { + from: UI_MOCKUP_START_POSE.slideX, + to: UI_MOCKUP_END_POSE.slideX, + start: 0, + end: UI_MOCKUP_SLIDE_FRAMES, + ease: { bezier: [0.16, 1, 0.3, 1] }, + }, + slideY: 0, + }; +}; + +/** CardHandoff: continua l'arco di UIMockup con la stessa curva, senza spostamenti. */ +export const cardHandoffTrack = (durationInFrames: number): Track => { + const last = durationInFrames - 1; + return { + yaw: { from: UI_MOCKUP_END_POSE.yaw, to: CARD_HANDOFF_END_POSE.yaw, start: 0, end: last, ease: "inOutQuad" }, + pitch: { from: UI_MOCKUP_END_POSE.pitch, to: CARD_HANDOFF_END_POSE.pitch, start: 0, end: last, ease: "inOutQuad" }, + pushZ: { from: UI_MOCKUP_END_POSE.pushZ, to: CARD_HANDOFF_END_POSE.pushZ, start: 0, end: last, ease: "inOutQuad" }, + slideX: 0, + slideY: 0, + }; +}; + +const fiveAxes = ( + from: { yaw: number; pitch: number; pushZ: number; slideX: number; slideY?: number }, + to: { yaw: number; pitch: number; pushZ: number; slideX: number; slideY?: number }, + end: number, +): Track => ({ + yaw: { from: from.yaw, to: to.yaw, start: 0, end, ease: "inOutCubic" }, + pitch: { from: from.pitch, to: to.pitch, start: 0, end, ease: "inOutCubic" }, + pushZ: { from: from.pushZ, to: to.pushZ, start: 0, end, ease: "inOutCubic" }, + slideX: { from: from.slideX, to: to.slideX, start: 0, end, ease: "inOutCubic" }, + slideY: { from: from.slideY ?? 0, to: to.slideY ?? 0, start: 0, end, ease: "inOutCubic" }, +}); + +/** CardFocus: la discesa al macro sulla card consegnata, per tutta la scena. */ +export const cardFocusTrack = (durationInFrames: number): Track => + fiveAxes(CARD_HANDOFF_END_POSE, CARD_FOCUS_END_POSE, durationInFrames - 1); + +/** CardRelease: la camera lascia la card e torna larga, per tutta la scena. */ +export const cardReleaseTrack = (durationInFrames: number): Track => + fiveAxes(CARD_FOCUS_END_POSE, CARD_RELEASE_END_POSE, durationInFrames - 1); + +/** PromptInput: la camera si posa sul composer in 132 frame (alla durata di riferimento) e poi sta ferma. */ +export const PROMPT_INPUT_BASE = 450; +export const PROMPT_INPUT_CAM_SETTLE = 132; +export const promptInputTrack = (durationInFrames: number): Track => + fiveAxes( + CARD_RELEASE_END_POSE, + PROMPT_INPUT_END_POSE, + tempo(durationInFrames, PROMPT_INPUT_BASE).at(PROMPT_INPUT_CAM_SETTLE), + ); + +/** BoardOrbit: l'orbita arriva a -34 gradi in 118 frame (alla durata di riferimento) e si ferma. */ +export const BOARD_ORBIT_BASE = 150; +export const BOARD_ORBIT_SETTLE = 118; +export const boardOrbitTrack = (durationInFrames: number): Track => + fiveAxes( + PROMPT_INPUT_END_POSE, + BOARD_ORBIT_END_POSE, + tempo(durationInFrames, BOARD_ORBIT_BASE).at(BOARD_ORBIT_SETTLE), + ); + +/** Le tracce per id di composition, nell'ordine in cui le leggono i banchi. */ +export const TOPICS_TRACKS: Record Track> = { + UIMockup: uiMockupTrack, + CardHandoff: cardHandoffTrack, + CardFocus: cardFocusTrack, + CardRelease: cardReleaseTrack, + PromptInput: promptInputTrack, + BoardOrbit: boardOrbitTrack, +}; diff --git a/video/src/scenes/BoardOrbit.tsx b/video/src/scenes/BoardOrbit.tsx deleted file mode 100644 index ef39efc..0000000 --- a/video/src/scenes/BoardOrbit.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; -import { - BOARD_ORBIT_END_POSE, - COLUMNS, - HANDOFF_FROM_COL, - HANDOFF_FROM_IDX, - PROMPT_INPUT_END_POSE, - SLAB_H, - SLAB_W, - handoffCard, - handoffLandedRect, -} from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; -import { Board } from "../primitives/Board"; -import { tempo } from "../primitives/tempo"; - -/** - * BoardOrbit: il sesto anello, e la fine del film. - * - * La camera lascia il composer e gira attorno alla lastra quanto basta a - * mostrarle un bordo. Serve perche' un pezzo che finisce addosso a un dettaglio - * resta aperto, e perche' finora la lastra non aveva mai dichiarato di essere - * un oggetto: frontale, un piano inclinato con dentro della UI e' - * indistinguibile da una carta da parati incollata sul fondo. E' l'unica cosa - * che un film di prodotto dice una volta sola, all'inizio o alla fine. - * - * LO SPESSORE ESISTE SOLO PERCHE' LA CAMERA GIRA. `SlabEdge` sta in tutte e - * cinque le scene precedenti e in nessuna si vede: a yaw piccoli sta esattamente - * dietro la lastra. Qui sporge, ed e' il motivo per cui e' stato scritto. - * - * L'ATTENUAZIONE SI RIAPRE. Il primo fotogramma la trova a 0,62, che e' dove - * PromptInput l'ha lasciata, e la riporta a 1 lungo l'orbita: la risposta e' - * finita, l'attenzione torna all'oggetto intero. Partire da 1 avrebbe rotto la - * giunta di piu' di quanto qualunque posa possa fare. - * - * LA CAMERA SI FERMA PRIMA DELLA FINE, a f118 su 150. Gli ultimi trenta - * fotogrammi sono fermi, cosi' il pezzo finisce su una posa e non su un - * movimento interrotto, e un'altra scena potrebbe attaccarsi qui. - * - * Frame-locked: ogni valore viene da useCurrentFrame(). - */ - -export type BoardOrbitProps = { - progress?: number; -}; - -/** La durata di riferimento a cui e' scritto SETTLE. Vedi primitives/tempo.ts. */ -const BASE = 150; - -/** Il frame in cui la camera arriva e si ferma. */ -const SETTLE = 118; - -export const BoardOrbit: React.FC = ({ progress }) => { - const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); - const frame = - progress === undefined ? localFrame : progress * (durationInFrames - 1); - - const T = tempo(durationInFrames, BASE); - - const at = (from: number, to: number): number => - interpolate(frame, [0, T.at(SETTLE)], [from, to], { - easing: Easing.inOut(Easing.cubic), - extrapolateRight: "clamp", - }); - - const yaw = at(PROMPT_INPUT_END_POSE.yaw, BOARD_ORBIT_END_POSE.yaw); - const pitch = at(PROMPT_INPUT_END_POSE.pitch, BOARD_ORBIT_END_POSE.pitch); - const pushZ = at(PROMPT_INPUT_END_POSE.pushZ, BOARD_ORBIT_END_POSE.pushZ); - const slideX = at(PROMPT_INPUT_END_POSE.slideX, BOARD_ORBIT_END_POSE.slideX); - const slideY = at( - PROMPT_INPUT_END_POSE.slideY ?? 0, - BOARD_ORBIT_END_POSE.slideY ?? 0, - ); - - // Il quadro si riapre: 0,62 e' dove PromptInput ha lasciato l'attenuazione. - const attn = at(0.62, 1); - - const bgYaw = yaw * 0.6; - const bgSlideX = slideX * 0.45; - const bgSlideY = slideY * 0.45; - - // La board sta come l'hanno lasciata le scene prima: consegna avvenuta, e il - // thread con la risposta gia' arrivata per intero. - const moving = handoffCard(); - const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( - (_, i) => i !== HANDOFF_FROM_IDX, - ); - const landed = handoffLandedRect(); - - const board = { - closeGap: 1, - travel: 1, - lift: 0, - cardX: landed.x, - cardY: landed.y, - moving: { ...moving, age: "ora" }, - fromRest, - }; - - const assistant = { - sent: true, - sentPrompt: "Rifai il flusso di auth e apri la PR", - answer: - "Trovati tre punti di chiamata in server/auth.ts. Sposto il refresh del token dentro un guard solo, poi apro la PR su topics/auth-refresh.", - frame, - attn, - }; - - const left = (1920 - SLAB_W) / 2 + slideX; - const top = (1080 - SLAB_H) / 2 + slideY; - - return ( - - -
- -
-
- - - - -
- -
-
- - -
- ); -}; diff --git a/video/src/scenes/CardFocus.tsx b/video/src/scenes/CardFocus.tsx deleted file mode 100644 index b922b6e..0000000 --- a/video/src/scenes/CardFocus.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; -import { - CARD_FOCUS_END_POSE, - CARD_HANDOFF_END_POSE, - COLUMNS, - HANDOFF_FROM_COL, - HANDOFF_FROM_IDX, - SLAB_H, - SLAB_W, - handoffCard, - handoffLandedRect, -} from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; -import { Board } from "../primitives/Board"; - -/** - * CardFocus: la quarta scena, e il terzo anello della catena. - * - * Con tre scene la regola "niente tagli" era una proprieta' di UNA coppia. Con - * quattro diventa una proprieta' della sequenza, che e' una cosa diversa: una - * catena si rompe al primo anello che nessuno misura, e finora l'unico anello - * misurato era UIMockup -> CardHandoff. - * - * ENTRA DALLA POSA IN CUI CARDHANDOFF SI FERMA, e disegna la board nello stato - * in cui quella la lascia: consegna avvenuta, colonna richiusa. Non la ridisegna - * per conto suo, passa `travel: 1, lift: 0, closeGap: 1` allo STESSO componente - * `Board`. Il primo frame di questa e' l'ultimo di quella per costruzione, e - * `seam.sh` lo misura invece di dare per buono che lo sia. - * - * LA CAMERA NON INVERTE MAI IL VERSO. Lungo le quattro scene lo yaw va da -18 a - * -9 a -4 a 0, il pitch da 5 a 2,5 a 1,2 a 0, la spinta da 0 a 48 a 96 a 1494. - * Tutte e tre le grandezze sono monotone, ed e' la condizione che rende una - * giunta invisibile anche quando i pixel coincidono: l'occhio segue la derivata - * del movimento, e un'inversione la legge come uno stacco. - * - * IL MACRO E' IL MOTIVO PER CUI LA LASTRA DEVE ESSERE DOM. La camera arriva - * addosso alla card ingrandendola di due volte e un terzo. Uno screenshot a - * 1920 a quella scala si sfalda; qui il testo tiene perche' viene rasterizzato - * alla dimensione finale. Non e' un'opinione, la misura `focus-sharpness.sh`. - * - * Frame-locked: ogni valore viene da useCurrentFrame(). - */ - -export type CardFocusProps = { - progress?: number; -}; - -export const CardFocus: React.FC = ({ progress }) => { - const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); - const frame = - progress === undefined ? localFrame : progress * (durationInFrames - 1); - const last = durationInFrames - 1; - - // Una curva sola per tutta la scena, come nelle due precedenti. inOut arriva - // agli estremi con derivata nulla: a sinistra si aggancia alla fine di - // CardHandoff, che e' ferma, a destra lascia una scena che si puo' mettere - // prima di qualunque altra. - const at = (from: number, to: number): number => - interpolate(frame, [0, last], [from, to], { - easing: Easing.inOut(Easing.cubic), - extrapolateRight: "clamp", - }); - - const yaw = at(CARD_HANDOFF_END_POSE.yaw, CARD_FOCUS_END_POSE.yaw); - const pitch = at(CARD_HANDOFF_END_POSE.pitch, CARD_FOCUS_END_POSE.pitch); - const pushZ = at(CARD_HANDOFF_END_POSE.pushZ, CARD_FOCUS_END_POSE.pushZ); - const slideX = at(CARD_HANDOFF_END_POSE.slideX, CARD_FOCUS_END_POSE.slideX); - const slideY = at( - CARD_HANDOFF_END_POSE.slideY ?? 0, - CARD_FOCUS_END_POSE.slideY ?? 0, - ); - - // Il piano attenuato dietro segue al 45%, come in UIMockup: al 100% sarebbe - // una copia incollata, fermo si staccherebbe. - const bgYaw = yaw * 0.6; - const bgSlideX = slideX * 0.45; - const bgSlideY = slideY * 0.45; - - // La board a consegna avvenuta: sono i tre valori che CardHandoff raggiunge - // al suo ultimo frame, e la card sta dove dice `handoffLandedRect`. - const moving = handoffCard(); - const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( - (_, i) => i !== HANDOFF_FROM_IDX, - ); - const landed = handoffLandedRect(); - - const board = { - closeGap: 1, - travel: 1, - lift: 0, - cardX: landed.x, - cardY: landed.y, - moving, - fromRest, - }; - - return ( - - -
- -
-
- - - {/* Lo spessore, dietro. Fratello e non figlio: la lastra ritaglia, e - qualunque ritaglio appiattisce il 3D dei suoi figli. */} - -
- - -
-
- - - - - ); -}; diff --git a/video/src/scenes/CardRelease.tsx b/video/src/scenes/CardRelease.tsx deleted file mode 100644 index 49addd0..0000000 --- a/video/src/scenes/CardRelease.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; -import { - CARD_FOCUS_END_POSE, - CARD_RELEASE_END_POSE, - COLUMNS, - HANDOFF_FROM_COL, - HANDOFF_FROM_IDX, - SLAB_H, - SLAB_W, - handoffCard, - handoffLandedRect, -} from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; -import { Board } from "../primitives/Board"; - -/** - * CardRelease: il quarto anello, e la fine del film. - * - * La camera si stacca dalla card e torna larga e frontale, con la board di - * nuovo tutta leggibile. Serve perche' un piano sequenza deve finire da - * qualche parte, e finire addosso a un dettaglio lascia il pezzo aperto. - * - * QUESTA SCENA INVERTE LA SPINTA, ed e' l'unica. Altrove il repo sostiene che - * un'inversione a una giunta si legge come uno stacco anche a pixel - * coincidenti, il che e' vero dove la giunta e' in MOVIMENTO: li' l'occhio - * segue la derivata. Qui i due lati sono fermi, CardFocus arriva a derivata - * nulla e questa riparte da derivata nulla, quindi non c'e' nessuna derivata da - * rovesciare. E' la stessa ragione per cui due scene ferme agli estremi si - * possono mettere in qualunque ordine, e adesso `rest-point.sh` la misura - * invece di lasciarla scritta. - * - * LA BOARD E' QUELLA A CONSEGNA AVVENUTA, come in CardFocus e per lo stesso - * motivo: e' lo stesso componente con gli stessi valori, quindi il primo frame - * di questa e' l'ultimo di quella per costruzione. - * - * Frame-locked: ogni valore viene da useCurrentFrame(). - */ - -export type CardReleaseProps = { - progress?: number; -}; - -export const CardRelease: React.FC = ({ progress }) => { - const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); - const frame = - progress === undefined ? localFrame : progress * (durationInFrames - 1); - const last = durationInFrames - 1; - - // Una curva sola, inOut: derivata nulla a sinistra per agganciarsi alla fine - // di CardFocus, derivata nulla a destra perche' e' l'ultimo frame del film. - const at = (from: number, to: number): number => - interpolate(frame, [0, last], [from, to], { - easing: Easing.inOut(Easing.cubic), - extrapolateRight: "clamp", - }); - - const yaw = at(CARD_FOCUS_END_POSE.yaw, CARD_RELEASE_END_POSE.yaw); - const pitch = at(CARD_FOCUS_END_POSE.pitch, CARD_RELEASE_END_POSE.pitch); - const pushZ = at(CARD_FOCUS_END_POSE.pushZ, CARD_RELEASE_END_POSE.pushZ); - const slideX = at(CARD_FOCUS_END_POSE.slideX, CARD_RELEASE_END_POSE.slideX); - const slideY = at( - CARD_FOCUS_END_POSE.slideY ?? 0, - CARD_RELEASE_END_POSE.slideY ?? 0, - ); - - const bgYaw = yaw * 0.6; - const bgSlideX = slideX * 0.45; - const bgSlideY = slideY * 0.45; - - const moving = handoffCard(); - const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( - (_, i) => i !== HANDOFF_FROM_IDX, - ); - const landed = handoffLandedRect(); - - const board = { - closeGap: 1, - travel: 1, - lift: 0, - cardX: landed.x, - cardY: landed.y, - moving, - fromRest, - }; - - return ( - - -
- -
-
- - - {/* Lo spessore, dietro. Fratello e non figlio: la lastra ritaglia, e - qualunque ritaglio appiattisce il 3D dei suoi figli. */} - -
- - -
-
- - - - - ); -}; diff --git a/video/src/scenes/FrameLockedProbe.tsx b/video/src/scenes/FrameLockedProbe.tsx index c99e00b..b4bdf15 100644 --- a/video/src/scenes/FrameLockedProbe.tsx +++ b/video/src/scenes/FrameLockedProbe.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef } from "react"; import { AbsoluteFill } from "remotion"; import gsap from "gsap"; import { FrameLocked } from "../primitives/FrameLocked"; -import { app, fontStack, monoStack, radius } from "../theme"; +import { app, fontStack, monoStack, radius } from "../products/topics/tokens"; /** * Il banco di prova di FrameLocked, non una scena. diff --git a/video/src/scenes/UIMockup.tsx b/video/src/scenes/UIMockup.tsx deleted file mode 100644 index 2bcb5b3..0000000 --- a/video/src/scenes/UIMockup.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import React from "react"; -import { Assistant } from "../primitives/Assistant"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; -import { SLAB_BACKDROP, app, fontStack } from "../theme"; -import { - COLUMNS, - SLAB_H, - SLAB_W, - UI_MOCKUP_END_POSE, - UI_MOCKUP_START_POSE, - addCardY, - cardY, - columnX, - COL_W, -} from "../primitives/slab"; -import { - AddCard, - AppChrome, - AppSidebar, - CardBox, - ColumnHeader, - DetailPanel, - SlabEdge, - SlabLighting, -} from "../primitives/SlabChrome"; - -/** - * UIMockup: app window su piano CSS 3D inclinato, con parallasse su camera move. - * - * La grammatica e' quella dei Linear Diffs: una lastra di UI VERA che riempie il - * quadro, entra da fuori frame, e si stabilizza senza mai tagliare. Il "vera" - * significa token da theme.ts, raggi 8/6/4, font di sistema, non un wireframe. - * - * Tre decisioni strutturali: - * - * LA LASTRA ENTRA DA FUORI FRAME. La scena comincia con la finestra a destra del - * quadro e scivola in posizione con un easing in-out. Cosi' il primo frame non e' - * una composizione statica: c'e' gia' movimento, e il movimento porta l'occhio. - * - * IL PIANO HA SPESSORE. `transformStyle: preserve-3d` + un bordo posteriore - * simulato con un pseudo-bordo assoluto dietro la lastra. Chi ha visto i Linear - * commercials sa che e' il bordo inferiore che dice "questo e' un oggetto fisico". - * - * LA PROFONDITA' DI CAMPO STACCA I PIANI. Il layer di sfondo e' sfocato e - * attenuato, la lastra principale e' nitida: la differenza di fuoco dice all'occhio - * qual e' il piano principale, senza mai spiegarlo. - * - * LA POSA FINALE NON STA PIU' QUI. Le due pose vivono in `primitives/slab.ts` - * perche' `CardHandoff` deve partire esattamente da dove questa finisce, e due - * copie dello stesso numero restano uguali solo finche' nessuno tocca una delle - * due. Ora la giunta e' verificabile: `seam.sh` confronta i pixel dell'ultimo - * frame di questa con il primo frame di quella. - * - * Frame-locked: ogni valore deriva da useCurrentFrame(). Nessun CSS keyframe, - * nessun requestAnimationFrame. - */ - -export type UIMockupProps = { - /** Via di fuga: 0 a 1, per farsi pilotare da una timeline padre. */ - progress?: number; -}; - -export const UIMockup: React.FC = ({ progress }) => { - const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); - const frame = - progress === undefined ? localFrame : progress * (durationInFrames - 1); - - const last = durationInFrames - 1; - - // Entrata: la lastra scivola da destra dentro il quadro. - const slideProgress = interpolate(frame, [0, 80], [0, 1], { - easing: Easing.bezier(0.16, 1, 0.3, 1), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const slideX = interpolate( - slideProgress, - [0, 1], - [UI_MOCKUP_START_POSE.slideX, UI_MOCKUP_END_POSE.slideX], - ); - - // La camera ruota lentamente: da piu' inclinata a piu' frontale. Finisce - // frontale cosi' la scena successiva puo' partire da qui. - const yaw = interpolate( - frame, - [0, last], - [UI_MOCKUP_START_POSE.yaw, UI_MOCKUP_END_POSE.yaw], - { easing: Easing.inOut(Easing.quad), extrapolateRight: "clamp" }, - ); - const pitch = interpolate( - frame, - [0, last], - [UI_MOCKUP_START_POSE.pitch, UI_MOCKUP_END_POSE.pitch], - { easing: Easing.inOut(Easing.quad), extrapolateRight: "clamp" }, - ); - const pushZ = interpolate( - frame, - [0, last], - [UI_MOCKUP_START_POSE.pushZ, UI_MOCKUP_END_POSE.pushZ], - { easing: Easing.inOut(Easing.quad), extrapolateRight: "clamp" }, - ); - - const fadeIn = interpolate(frame, [0, 20], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - // Il layer di sfondo ha un parallasse piu' lento: si muove meno della lastra. - const bgYaw = yaw * 0.6; - const bgSlideX = slideX * 0.45; - - const cardRevealStart = 75; - - return ( - - {/* Layer di sfondo: un'altra istanza della stessa UI, sfocata e attenuata. */} - -
- -
-
- - {/* La lastra principale: nitida, frontale, e' il soggetto della scena. */} - - {/* Lo spessore, dietro. Fratello e non figlio: la lastra ritaglia, e - qualunque ritaglio appiattisce il 3D dei suoi figli. */} - -
- - - {/* Spessore sul bordo inferiore: dice "oggetto fisico". */} -
-
- - - - - ); -}; - -/** - * Il corpo della lastra. Le card entrano con stagger: ogni colonna 12 frame - * dopo la precedente, ogni card 8 dopo quella sopra. - */ -const SlabBody: React.FC<{ - frame: number; - cardRevealStart: number; - dimmed?: boolean; -}> = ({ frame, cardRevealStart, dimmed = false }) => ( - <> - - - - {COLUMNS.map((col, colIdx) => ( - - - - {col.cards.map((card, cardIdx) => { - const start = cardRevealStart + colIdx * 12 + cardIdx * 8; - const p = interpolate(frame, [start, start + 22], [0, 1], { - easing: Easing.out(Easing.cubic), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - return ( -
- -
- ); - })} - - -
- ))} - - - - -); diff --git a/video/src/scenes/catalog.json b/video/src/scenes/catalog.json index 78a6b02..872e277 100644 --- a/video/src/scenes/catalog.json +++ b/video/src/scenes/catalog.json @@ -12,9 +12,12 @@ "", "CAMPI. id = composition Remotion. slug = nome del file .mp4 e sorgente del", "