From 1149c0780985f481fc5e6a0b155ed5a84c74bac8 Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Tue, 15 Sep 2026 12:17:18 +0200 Subject: [PATCH 1/6] La geometria prende lo stage come parametro, e CAM-06 regge su due lastre e tre rapporti slabPointOnScreen e centreOn avevano 1920 e 1080 scritti dentro: valevano solo in 16:9. Adesso stanno in video/src/kit/rig.ts con stage, rig e lastra come parametri, insieme a zoomForPush, pushForZoom e pushForFill (la spinta che fa occupare a un rettangolo una frazione del quadro). slab.ts tiene tutti i suoi export e chiama il kit con i numeri di Topics: geometry-snapshot.mjs fotografa 38 costanti, sette pose e le funzioni su punti campione, e prima e dopo sono la stessa stringa. still-identity.sh rende cinque fotogrammi di ogni scena da main e da qui: trenta su trenta identici, e uno spostamento di un pixel lo boccia. L'origine della prospettiva e' una scelta del rig, non un risultato della geometria. drift.py lo prova sul render con sei specimen di CAM-06: la card di Topics e quella di una lastra sonda (chiara, verticale, scala 1, un prodotto che non esiste) in 16:9, 9:16 e 4:5. Il bersaglio resta entro 4 px dall'origine dal primo all'ultimo frame, 2,03 al peggio. Boccia in tutte e sei le varianti la spinta senza compensazione, e quella con lo spostamento calcolato sul 46% e il CSS al 50%, che parte esatta e scappa di 39,5-99,8 px come prevede la formula. scripts/manifest.mjs calcola dal kit cosa i banchi devono trovare. Co-Authored-By: Claude Opus 5 --- .github/workflows/showcase.yml | 17 +++ README.md | 60 ++++++++- scripts/drift.py | 164 +++++++++++++++++++++++++ scripts/geometry-snapshot.mjs | 58 +++++++++ scripts/manifest.mjs | 48 ++++++++ scripts/still-identity.sh | 105 ++++++++++++++++ showcase/grammatica.html | 9 +- video/src/Root.tsx | 17 +++ video/src/kit/rig.ts | 110 +++++++++++++++++ video/src/kit/stage.ts | 27 +++++ video/src/primitives/slab.ts | 55 ++++++--- video/src/products/probe/Slab.tsx | 168 ++++++++++++++++++++++++++ video/src/products/probe/geometry.ts | 52 ++++++++ video/src/specimens/SpecimenCam06.tsx | 116 ++++++++++++++++++ video/src/specimens/list.ts | 129 ++++++++++++++++++++ video/tsconfig.json | 1 + 16 files changed, 1116 insertions(+), 20 deletions(-) create mode 100755 scripts/drift.py create mode 100755 scripts/geometry-snapshot.mjs create mode 100755 scripts/manifest.mjs create mode 100755 scripts/still-identity.sh create mode 100644 video/src/kit/rig.ts create mode 100644 video/src/kit/stage.ts create mode 100644 video/src/products/probe/Slab.tsx create mode 100644 video/src/products/probe/geometry.ts create mode 100644 video/src/specimens/SpecimenCam06.tsx create mode 100644 video/src/specimens/list.ts diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index 338b0b7..e5b6675 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -127,6 +127,12 @@ 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 + # 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 +165,17 @@ 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." + ./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 diff --git a/README.md b/README.md index 8f55f83..ed8a8a6 100644 --- a/README.md +++ b/README.md @@ -384,11 +384,66 @@ 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 +`slab.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. `slab.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. + +`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. + ## Layout | | | |---|---| -| `video/` | The Remotion project. Scenes in `video/src/scenes/`, primitives in `video/src/primitives/` | +| `video/` | The Remotion project. Scenes in `video/src/scenes/`, primitives in `video/src/primitives/`, the product-independent kit in `video/src/kit/`, the probe slab in `video/src/products/probe/`, 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 +475,9 @@ 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 +node scripts/manifest.mjs cam06 # what the benches must find, from the kit +node scripts/geometry-snapshot.mjs [slab.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 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/geometry-snapshot.mjs b/scripts/geometry-snapshot.mjs new file mode 100755 index 0000000..49ee93b --- /dev/null +++ b/scripts/geometry-snapshot.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// +// La geometria di slab.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 { 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/primitives/slab.ts`, +); +const m = await import(pathToFileURL(target).href); + +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]) => m.slabPointOnScreen(x, y)); +out.functions.centreOn = pts.map(([x, y]) => m.centreOn(x, y)); +out.functions.zoomForPush = [0, 48, 96, 1180, 1493.6, -140, -420].map((z) => + m.zoomForPush(z), +); +out.functions.pushForZoom = [1, 1.12, 2.35, 0.9].map((k) => m.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/manifest.mjs b/scripts/manifest.mjs new file mode 100755 index 0000000..171d85f --- /dev/null +++ b/scripts/manifest.mjs @@ -0,0 +1,48 @@ +#!/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 +// slab.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 commands = { + 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); +} +process.stdout.write(JSON.stringify(await commands[cmd](), null, 1) + "\n"); diff --git a/scripts/still-identity.sh b/scripts/still-identity.sh new file mode 100755 index 0000000..e03ecae --- /dev/null +++ b/scripts/still-identity.sh @@ -0,0 +1,105 @@ +#!/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 +# +# 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="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. + still "$ROOT/video" "$comp" "$mid" "$TMP/r-$comp.png" || { echo "$line ripetizione: RENDER FALLITO"; exit 3; } + if [ "$(hash "$TMP/r-$comp.png")" != "$(hash "$TMP/h-$comp-$mid.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..f2f511f 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -429,6 +429,7 @@

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 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× @@ -1801,11 +1802,11 @@

Thirty-six movements,
no cuts.

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."}, -{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, slab.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.", diff --git a/video/src/Root.tsx b/video/src/Root.tsx index 46a45a6..2fb9d3d 100644 --- a/video/src/Root.tsx +++ b/video/src/Root.tsx @@ -8,6 +8,8 @@ import { CardFocus } from "./scenes/CardFocus"; import { CardRelease } from "./scenes/CardRelease"; import { BoardOrbit } from "./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/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/slab.ts b/video/src/primitives/slab.ts index e82e6ab..d97fab1 100644 --- a/video/src/primitives/slab.ts +++ b/video/src/primitives/slab.ts @@ -17,6 +17,19 @@ * 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, + slabPointOnScreen as kitSlabPointOnScreen, + zoomForPush as kitZoomForPush, +} from "../kit/rig.ts"; +import type { Rig, SlabSize } from "../kit/rig.ts"; + export const SLAB_W = 2400; export const SLAB_H = 1200; @@ -251,20 +264,37 @@ 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; +/** + * Topics nei termini del kit. Lo stage, il rig e la dimensione della lastra + * che le sei scene usano, passati alle funzioni di `kit/rig.ts`. + * + * LE QUATTRO FUNZIONI QUI SOTTO SONO UN PONTE. Stavano scritte qui con 1920 e + * 1080 dentro, quindi valevano solo in 16:9; adesso chiamano il kit con lo + * stage di Topics e restituiscono gli stessi numeri al bit, verificato da + * `scripts/geometry-snapshot.mjs` sul commit di prima e su questo. Restano + * finche' scene e banchi non leggono il kit direttamente. + */ +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 }; + /** 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, -}); +): { x: number; y: number } => + kitSlabPointOnScreen(TOPICS_STAGE, TOPICS_RIG, TOPICS_SLAB, { x, y }); /** * Lo scostamento che porta quel punto sull'origine della prospettiva. @@ -277,17 +307,12 @@ export const slabPointOnScreen = ( 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, - }; -}; +): { slideX: number; slideY: number } => + kitCentreOn(TOPICS_STAGE, TOPICS_RIG, TOPICS_SLAB, { x, y }); /** 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); +export const zoomForPush = (z: number): number => kitZoomForPush(TOPICS_RIG, z); +export const pushForZoom = (k: number): number => kitPushForZoom(TOPICS_RIG, k); /** Quanto ingrandisce la discesa di CardFocus. Oltre 2,6 la card esce dal quadro. */ export const CARD_FOCUS_ZOOM = 2.35; 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/specimens/SpecimenCam06.tsx b/video/src/specimens/SpecimenCam06.tsx new file mode 100644 index 0000000..3fcd34d --- /dev/null +++ b/video/src/specimens/SpecimenCam06.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { + AbsoluteFill, + Easing, + interpolate, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { cssPerspectiveOrigin } from "../kit/rig"; +import type { Ratio } from "../kit/stage"; +import { cam06Expectation, specimenGeometry } from "./list"; +import type { SpecimenProduct } from "./list"; +import { AnchorMarker, ProbeSlab } from "../products/probe/Slab"; +import { Board } from "../primitives/Board"; +import { + COLUMNS, + HANDOFF_FROM_COL, + HANDOFF_FROM_IDX, + handoffCard, + handoffLandedRect, +} from "../primitives/slab"; +import { app, fontStack } from "../theme"; + +/** + * CAM-06 fuori da Topics: il punto che non scappa, su due lastre e tre rapporti. + * + * La camera sta frontale (yaw e pitch a zero, dove centreOn e' esatto), la + * lastra e' spostata una volta sola perche' il centro del bersaglio cada + * sull'origine della prospettiva, e poi la spinta in Z la ingrandisce. Se la + * geometria e' giusta il segno magenta sul bersaglio non si muove di un pixel + * mentre tutto il resto scappa verso i bordi. `drift.py` lo misura sul render. + * + * DUE PROPS PER I CONTROLLI NEGATIVI, e servono proprio perche' il banco li + * deve bocciare in ogni variante: + * - `compensate: false` toglie lo spostamento, e il bersaglio parte gia' fuori + * posto e scappa; + * - `originMismatch: true` lascia lo spostamento calcolato sul 46% ma mette nel + * CSS 50% 50%. E' l'incidente che la grammatica racconta: numeri giusti per + * un'origine, applicati a un'altra. + * + * Specimen, non scena: niente piano dietro, niente bordo, niente luce. C'e' + * solo la geometria che la voce afferma, cosi' se il banco boccia non ci sono + * altri sospettati. + */ + +export type SpecimenCam06Props = { + product: SpecimenProduct; + ratio: Ratio; + compensate?: boolean; + originMismatch?: boolean; +}; + +export const SpecimenCam06: React.FC = ({ + product, + ratio, + compensate = true, + originMismatch = false, +}) => { + const frame = useCurrentFrame(); + const { durationInFrames } = useVideoConfig(); + const g = specimenGeometry(product, ratio); + const e = cam06Expectation(product, ratio); + + const push = interpolate(frame, [0, durationInFrames - 1], [0, e.pushEnd], { + easing: Easing.inOut(Easing.cubic), + extrapolateRight: "clamp", + }); + const slide = compensate ? e.slide : { slideX: 0, slideY: 0 }; + const origin = originMismatch ? "50% 50%" : cssPerspectiveOrigin(g.rig); + const cx = g.anchor.x + g.anchor.w / 2; + const cy = g.anchor.y + g.anchor.h / 2; + + return ( + + +
+ {product === "topics" ? : } + +
+
+
+ ); +}; + +/** La board di Topics a consegna avvenuta: lo stato in cui CardFocus la inquadra. */ +const TopicsDelivered: React.FC = () => { + const landed = handoffLandedRect(); + return ( + i !== HANDOFF_FROM_IDX, + )} + /> + ); +}; diff --git a/video/src/specimens/list.ts b/video/src/specimens/list.ts new file mode 100644 index 0000000..4783922 --- /dev/null +++ b/video/src/specimens/list.ts @@ -0,0 +1,129 @@ +import { RATIOS, STAGES } from "../kit/stage.ts"; +import type { Ratio, Stage } from "../kit/stage.ts"; +import { + centreOn, + originOnScreen, + pushForFill, + slabPointOnScreen, +} from "../kit/rig.ts"; +import type { Point, Rect, Rig, SlabSize } from "../kit/rig.ts"; +import { + PROBE_ANCHORS, + PROBE_RIG, + PROBE_SLAB, + rectCentre, +} from "../products/probe/geometry.ts"; +import { + TOPICS_RIG, + TOPICS_SLAB, + handoffLandedRect, +} from "../primitives/slab.ts"; + +/** + * Gli specimen: una voce del registro girata su piu' lastre e in piu' rapporti. + * + * NON SONO SCENE DELLA VETRINA, e per questo non stanno in catalog.json: non + * vanno sul sito, esistono perche' un banco possa dire che la voce funziona + * anche lontano da Topics. Il primo e' CAM-06, il punto che non scappa: la + * geometria su cui poggiano tutte le discese al macro. + * + * UNA SOLA SORGENTE. Root.tsx registra le composition da qui, e il manifest + * che legge drift.py viene da qui: aggiungere un rapporto o una lastra e' + * aggiungere una riga, e render e banco lo seguono senza essere toccati. + * + * Modulo puro, letto anche da Node. + */ + +export type SpecimenProduct = "topics" | "probe"; + +export const SPECIMEN_PRODUCTS: readonly SpecimenProduct[] = ["topics", "probe"]; + +/** + * Durata della discesa, e riempimento del bersaglio all'ultimo frame. + * + * IL 90% NON E' ESTETICA. Il controllo negativo sull'origine sbagliata sposta + * il segno di (0,50 - 0,46) x altezza x (ingrandimento - 1). Al 55% la card di + * Topics in 9:16 si ingrandiva solo 1,06 volte, perche' e' larga e il quadro + * stretto, e lo scarto era 4,8 px contro una tolleranza di 2: un negativo che + * boccia per un soffio non prova niente. Al 90% l'ingrandimento minimo sulle sei + * varianti e' 1,74 e lo scarto minimo supera i 40 px. + */ +export const CAM06_FRAMES = 90; +export const CAM06_FILL = 0.9; + +export type SpecimenGeometry = { + product: SpecimenProduct; + ratio: Ratio; + stage: Stage; + rig: Rig; + slab: SlabSize; + anchor: Rect; +}; + +export const specimenGeometry = ( + product: SpecimenProduct, + ratio: Ratio, +): SpecimenGeometry => { + const stage = STAGES[ratio]; + if (product === "topics") { + return { + product, + ratio, + stage, + rig: TOPICS_RIG, + slab: TOPICS_SLAB, + anchor: handoffLandedRect(), + }; + } + return { + product, + ratio, + stage, + rig: PROBE_RIG, + slab: PROBE_SLAB, + anchor: PROBE_ANCHORS.target, + }; +}; + +export const cam06Id = (product: SpecimenProduct, ratio: Ratio): string => + `SpecimenCAM06-${product}-${ratio}`; + +/** Le sei varianti: due lastre per tre rapporti. */ +export const CAM06_SPECIMENS = SPECIMEN_PRODUCTS.flatMap((product) => + RATIOS.map((ratio) => ({ + id: cam06Id(product, ratio), + product, + ratio, + width: STAGES[ratio].w, + height: STAGES[ratio].h, + fps: 30, + durationInFrames: CAM06_FRAMES, + })), +); + +/** + * Cosa il banco deve trovare sul render, calcolato qui e non nel banco. + * + * `origin` e' dove deve stare il segno per tutta la discesa. `uncompensated` e' + * dove starebbe senza lo spostamento di centreOn prima della spinta: se i due + * punti coincidessero, il controllo negativo non potrebbe fallire e il banco + * non proverebbe niente, quindi il manifest lo dice. + */ +export const cam06Expectation = ( + product: SpecimenProduct, + ratio: Ratio, +): { + origin: Point; + uncompensated: Point; + slide: { slideX: number; slideY: number }; + pushEnd: number; +} => { + const g = specimenGeometry(product, ratio); + const c = rectCentre(g.anchor); + return { + origin: originOnScreen(g.stage, g.rig), + uncompensated: slabPointOnScreen(g.stage, g.rig, g.slab, c), + slide: centreOn(g.stage, g.rig, g.slab, c), + pushEnd: pushForFill(g.stage, g.rig, g.anchor, CAM06_FILL), + }; +}; diff --git a/video/tsconfig.json b/video/tsconfig.json index 05577e1..dab45b1 100644 --- a/video/tsconfig.json +++ b/video/tsconfig.json @@ -6,6 +6,7 @@ "jsx": "react-jsx", "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "lib": ["es2015"], "esModuleInterop": true, "resolveJsonModule": true, From dee5edfe659a372010c9b36423da34d480c368b6 Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Tue, 15 Sep 2026 12:35:42 +0200 Subject: [PATCH 2/6] Un solo blocco di ripresa al posto di sei copie, senza cambiare un pixel Il piano attenuato dietro, lo spessore, la lastra e la luce sul quadro stavano ricopiati a mano in tutte e sei le scene, con prospettiva 2600, origine "50% 46%", (1920 - SLAB_W) / 2, scale(1.04) e i sette numeri di parallasse del piano dietro scritti in ogni copia, mentre i banchi misuravano costanti di slab.ts che nessuna scena leggeva. Adesso le scene passano a kit/Shot.tsx la posa e il contenuto, i numeri dell'aspetto di Topics stanno una volta sola in primitives/material.ts, e lo stage viene dalla composition. Le sei copie differivano in tre punti, che Shot prende come props: la dissolvenza del quadro in UIMockup, il cursore dentro la lastra in CardHandoff e PromptInput, il filo di luce sul bordo basso che PromptInput e BoardOrbit non hanno. SlabEdge e SlabLighting escono da SlabChrome, dove nessuno li usa piu'. still-identity.sh contro main: cinque fotogrammi per scena, trenta su trenta identici, strumento ripetibile. Co-Authored-By: Claude Opus 5 --- README.md | 17 +++ showcase/grammatica.html | 2 +- video/src/kit/Shot.tsx | 226 ++++++++++++++++++++++++++++ video/src/primitives/SlabChrome.tsx | 72 +-------- video/src/primitives/material.ts | 53 +++++++ video/src/scenes/BoardOrbit.tsx | 83 ++-------- video/src/scenes/CardFocus.tsx | 100 ++---------- video/src/scenes/CardHandoff.tsx | 151 ++++++------------- video/src/scenes/CardRelease.tsx | 95 ++---------- video/src/scenes/PromptInput.tsx | 92 +++-------- video/src/scenes/UIMockup.tsx | 100 ++---------- video/src/theme.ts | 6 +- 12 files changed, 424 insertions(+), 573 deletions(-) create mode 100644 video/src/kit/Shot.tsx create mode 100644 video/src/primitives/material.ts diff --git a/README.md b/README.md index ed8a8a6..db16ce6 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,23 @@ 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 `slab.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 `primitives/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. + `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 diff --git a/showcase/grammatica.html b/showcase/grammatica.html index f2f511f..76042fd 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -1886,7 +1886,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.", 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/primitives/SlabChrome.tsx b/video/src/primitives/SlabChrome.tsx index 5995fa2..aa8529c 100644 --- a/video/src/primitives/SlabChrome.tsx +++ b/video/src/primitives/SlabChrome.tsx @@ -9,8 +9,6 @@ import { HEADER_H, PANEL_W, SIDEBAR_W, - SLAB_H, - SLAB_W, cardHeight, columnX, } from "./slab"; @@ -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 + * `primitives/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/primitives/material.ts b/video/src/primitives/material.ts new file mode 100644 index 0000000..8a840e8 --- /dev/null +++ b/video/src/primitives/material.ts @@ -0,0 +1,53 @@ +import type { ShotMaterial } from "../kit/Shot"; +import { SLAB_BACKDROP, app, fontStack } from "../theme"; + +/** + * 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/scenes/BoardOrbit.tsx b/video/src/scenes/BoardOrbit.tsx index ef39efc..6bfa975 100644 --- a/video/src/scenes/BoardOrbit.tsx +++ b/video/src/scenes/BoardOrbit.tsx @@ -1,24 +1,23 @@ 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, + TOPICS_RIG, + TOPICS_SLAB, } from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; import { tempo } from "../primitives/tempo"; @@ -32,8 +31,8 @@ import { tempo } from "../primitives/tempo"; * 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 + * 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 @@ -84,9 +83,6 @@ export const BoardOrbit: React.FC = ({ progress }) => { // 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. @@ -115,64 +111,17 @@ export const BoardOrbit: React.FC = ({ progress }) => { 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 index b922b6e..28fc992 100644 --- a/video/src/scenes/CardFocus.tsx +++ b/video/src/scenes/CardFocus.tsx @@ -1,25 +1,24 @@ 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, + TOPICS_RIG, + TOPICS_SLAB, handoffCard, handoffLandedRect, } from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; import { Board } from "../primitives/Board"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; /** * CardFocus: la quarta scena, e il terzo anello della catena. @@ -79,12 +78,6 @@ export const CardFocus: React.FC = ({ progress }) => { 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(); @@ -103,80 +96,17 @@ export const CardFocus: React.FC = ({ progress }) => { 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 ( - - -
- -
-
- - - {/* 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/CardHandoff.tsx b/video/src/scenes/CardHandoff.tsx index fa23dcd..759cae8 100644 --- a/video/src/scenes/CardHandoff.tsx +++ b/video/src/scenes/CardHandoff.tsx @@ -1,19 +1,15 @@ 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, @@ -22,8 +18,11 @@ import { columnX, handoffCard, handoffTargetCards, + TOPICS_RIG, + TOPICS_SLAB, } from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; import { Cursor, pointOnPath, type Waypoint } from "../primitives/Cursor"; import { tempo } from "../primitives/tempo"; @@ -143,9 +142,6 @@ export const CardHandoff: React.FC = ({ progress }) => { extrapolateRight: "clamp", }); - // 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 +228,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/scenes/CardRelease.tsx b/video/src/scenes/CardRelease.tsx index 49addd0..92c14ec 100644 --- a/video/src/scenes/CardRelease.tsx +++ b/video/src/scenes/CardRelease.tsx @@ -1,24 +1,23 @@ 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, + TOPICS_RIG, + TOPICS_SLAB, } from "../primitives/slab"; -import { SlabEdge, SlabLighting } from "../primitives/SlabChrome"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; /** @@ -72,9 +71,6 @@ export const CardRelease: React.FC = ({ progress }) => { 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( @@ -93,79 +89,14 @@ export const CardRelease: React.FC = ({ progress }) => { }; 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/PromptInput.tsx b/video/src/scenes/PromptInput.tsx index e80d2fb..e1c7f24 100644 --- a/video/src/scenes/PromptInput.tsx +++ b/video/src/scenes/PromptInput.tsx @@ -1,17 +1,14 @@ 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 { CARD_RELEASE_END_POSE, @@ -26,11 +23,13 @@ import { SEND_W, SEND_X, SEND_Y, - SLAB_H, - SLAB_W, handoffCard, handoffLandedRect, + TOPICS_RIG, + TOPICS_SLAB, } from "../primitives/slab"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; /** * PromptInput: il quinto anello. Il cursore scende sul composer, scrive, invia, @@ -214,9 +213,6 @@ export const PromptInput: React.FC = ({ PROMPT_INPUT_END_POSE.slideY ?? 0, ); - 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. @@ -299,72 +295,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/scenes/UIMockup.tsx b/video/src/scenes/UIMockup.tsx index 2bcb5b3..69df948 100644 --- a/video/src/scenes/UIMockup.tsx +++ b/video/src/scenes/UIMockup.tsx @@ -1,24 +1,24 @@ 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, + TOPICS_RIG, + TOPICS_SLAB, } from "../primitives/slab"; +import { Shot } from "../kit/Shot"; +import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { AddCard, AppChrome, @@ -26,8 +26,6 @@ import { CardBox, ColumnHeader, DetailPanel, - SlabEdge, - SlabLighting, } from "../primitives/SlabChrome"; /** @@ -112,92 +110,20 @@ export const UIMockup: React.FC = ({ progress }) => { 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". */} -
-
- - - - + + ); }; diff --git a/video/src/theme.ts b/video/src/theme.ts index 47a3606..1b1065e 100644 --- a/video/src/theme.ts +++ b/video/src/theme.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 slab.ts. Li legge `primitives/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 From b5294df35d39a73559546ff1629662ca8c0381b6 Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Tue, 15 Sep 2026 12:44:14 +0200 Subject: [PATCH 3/6] La proiezione esatta con la camera inclinata, e i banchi chiedono la geometria al manifest kit/project.ts dice dove finisce sullo schermo un punto della lastra a una posa qualsiasi, rifacendo in aritmetica la catena CSS di Shot: scala, rotateX, rotateY, spinta, prospettiva attorno all'origine del rig. project-check.py la confronta con getBoundingClientRect di Chromium su 36 casi (due lastre, tre stage, le sei pose della catena di Topics, cinque punti ciascuno): errore massimo 0,040 px, e con l'origine sbagliata nel CSS arriva a 103,7 px e il banco se ne accorge. handoff-travel, focus-sharpness, fixture-screenshot e contrast-floor si calcolavano la geometria di Topics in uno script node dentro di se'. Adesso la chiedono a manifest.mjs, che la calcola una volta in video/src/manifest/topics.ts: uscite identiche riga per riga, fixture con lo stesso hash. no-product-literals.sh boccia un banco che torna a leggere video/src da se', e sui banchi del commit prima segnala esattamente quei quattro. In CI setup-node gira sempre, perche' il passo della pagina legge i moduli .ts del kit anche quando i render arrivano dalla cache. Co-Authored-By: Claude Opus 5 --- .github/workflows/showcase.yml | 29 ++++++-- README.md | 23 ++++++ scripts/contrast-floor.py | 32 ++------- scripts/fixture-screenshot.sh | 17 ++--- scripts/focus-sharpness.sh | 32 +++------ scripts/handoff-travel.sh | 10 ++- scripts/manifest.mjs | 84 +++++++++++++++++++++- scripts/no-product-literals.sh | 47 +++++++++++++ scripts/project-check.py | 124 +++++++++++++++++++++++++++++++++ showcase/grammatica.html | 2 + video/src/kit/project.ts | 93 +++++++++++++++++++++++++ video/src/manifest/topics.ts | 106 ++++++++++++++++++++++++++++ 12 files changed, 527 insertions(+), 72 deletions(-) create mode 100755 scripts/no-product-literals.sh create mode 100755 scripts/project-check.py create mode 100644 video/src/kit/project.ts create mode 100644 video/src/manifest/topics.ts diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index e5b6675..bd81569 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: | @@ -273,6 +275,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 slab.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/primitives/slab.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 slab.ts (rc=$rc)" >&2; cat /tmp/npl.log >&2; exit 1; } + echo "no-product-literals.sh boccia un banco che importa slab.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 db16ce6..eba7200 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,27 @@ 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/manifest/topics.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. + `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 @@ -493,6 +514,8 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./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/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 [slab.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 diff --git a/scripts/contrast-floor.py b/scripts/contrast-floor.py index d10358a..31db952 100755 --- a/scripts/contrast-floor.py +++ b/scripts/contrast-floor.py @@ -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/manifest/topics.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: 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..87acf94 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 primitives/slab.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/manifest/topics.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 diff --git a/scripts/handoff-travel.sh b/scripts/handoff-travel.sh index df9fd17..aeae208 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 slab.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 index 171d85f..0d1ae6c 100755 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -18,6 +18,81 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const load = (rel) => import(pathToFileURL(join(root, rel)).href); const commands = { + // La geometria che i banchi delle scene di Topics chiedevano a slab.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/manifest/topics.ts"); + return t.handoffBand(); + }, + "focus-sharpness": async () => { + const g = (await load("video/src/manifest/topics.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/manifest/topics.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/manifest/topics.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/primitives/slab.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) => ({ @@ -45,4 +120,11 @@ if (!commands[cmd]) { console.error(`uso: node scripts/manifest.mjs <${Object.keys(commands).join("|")}>`); process.exit(2); } -process.stdout.write(JSON.stringify(await commands[cmd](), null, 1) + "\n"); +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..e2fe20d --- /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 slab.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 slab.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 slab.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/showcase/grammatica.html b/showcase/grammatica.html index 76042fd..4a160bb 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -430,6 +430,8 @@

Thirty-six movements,
no cuts.

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 + 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× 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/manifest/topics.ts b/video/src/manifest/topics.ts new file mode 100644 index 0000000..ffde7c3 --- /dev/null +++ b/video/src/manifest/topics.ts @@ -0,0 +1,106 @@ +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, + handoffLandedRect, + slabPointOnScreen, + zoomForPush, +} from "../primitives/slab.ts"; + +/** + * Quello che i banchi delle scene di Topics devono sapere della geometria: + * ritagli, bande, ingrandimenti. Calcolato qui, da slab.ts, e stampato da + * `scripts/manifest.mjs`. + * + * PERCHE' ESISTE. Quattro banchi (handoff-travel, focus-sharpness, + * fixture-screenshot, contrast-floor) importavano slab.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])), + }; +}; From 924c8c59b24b41e472d338c25bf5dda8979e1828 Mon Sep 17 00:00:00 2001 From: Antonino Cuzzola Date: Tue, 15 Sep 2026 12:58:11 +0200 Subject: [PATCH 4/6] La camera diventa un dato, e GIU-04 e CAM-01 hanno finalmente un banco Ogni scena calcolava la camera dentro il componente: solo il render poteva dire la posa a un frame qualsiasi. kit/camera.ts la rende una traccia (una curva per asse) che poseAt legge con interpolate ed Easing di Remotion, con gli stessi argomenti delle scene. Le sei tracce stanno in primitives/tracks.ts e le scene le leggono. still-identity.sh accetta i fotogrammi da confrontare: identici a main anche nei frame 20-81 di UIMockup e attorno all'assestamento di PromptInput e BoardOrbit. Il cursore prende il frame della scena invece del proprio orologio. chain-check.py (GIU-04) legge le tracce lungo la catena: nessuna inversione in moto, sette inversioni tutte a giunte ferme; senza easing diventano in moto e il banco boccia. fill-geom.py (CAM-01) proietta la lastra con la posa di ogni frame: cinque scene coprono i quattro bordi con almeno 5,4 px, arretrate di 1500 le boccia tutte. Al primo giro ha bocciato BoardOrbit, e aveva ragione: l'orbita lascia scoperti i bordi apposta. La scena dichiarava fill e il vecchio banco sui pixel la promuoveva; ora non lo dichiara piu'. Co-Authored-By: Claude Opus 5 --- .github/workflows/showcase.yml | 19 ++++ README.md | 51 ++++++++-- scripts/catalog.mjs | 5 +- scripts/chain-check.py | 72 +++++++++++++ scripts/fill-geom.py | 81 +++++++++++++++ scripts/fill-measure.sh | 6 +- scripts/manifest.mjs | 113 +++++++++++++++++++++ scripts/still-identity.sh | 16 ++- showcase/grammatica.html | 12 ++- video/src/kit/camera.ts | 167 +++++++++++++++++++++++++++++++ video/src/primitives/Cursor.tsx | 11 +- video/src/primitives/tracks.ts | 104 +++++++++++++++++++ video/src/scenes/BoardOrbit.tsx | 28 +++--- video/src/scenes/CardFocus.tsx | 26 +---- video/src/scenes/CardHandoff.tsx | 24 ++--- video/src/scenes/CardRelease.tsx | 25 +---- video/src/scenes/PromptInput.tsx | 35 +++---- video/src/scenes/UIMockup.tsx | 44 ++------ video/src/scenes/catalog.json | 11 +- 19 files changed, 689 insertions(+), 161 deletions(-) create mode 100755 scripts/chain-check.py create mode 100755 scripts/fill-geom.py create mode 100644 video/src/kit/camera.ts create mode 100644 video/src/primitives/tracks.ts diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index bd81569..6ddff39 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -135,6 +135,13 @@ jobs: # banco che non guarda solo Topics in 16:9. ./scripts/drift.py + # LA CAMERA COME DATO. Le tracce di primitives/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 @@ -178,6 +185,18 @@ jobs: 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 diff --git a/README.md b/README.md index eba7200..de032dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -471,6 +471,38 @@ line for line, and the screenshot fixture comes out with the same hash. 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 `primitives/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 @@ -515,6 +547,8 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./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 [slab.ts] # the geometry as a string, to prove a refactor left it alone @@ -565,10 +599,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 @@ -774,9 +807,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. 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..4745ae0 --- /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 (primitives/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/fill-geom.py b/scripts/fill-geom.py new file mode 100755 index 0000000..12d3241 --- /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 (primitives/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/manifest.mjs b/scripts/manifest.mjs index 0d1ae6c..566e82e 100755 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -17,7 +17,120 @@ 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/primitives/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 primitives/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/primitives/tracks.ts"); + const slab = await load("video/src/primitives/slab.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 slab.ts con // uno script node scritto dentro di se'. Stesso formato di uscita di prima, // cosi' i banchi leggono con lo stesso `read`. diff --git a/scripts/still-identity.sh b/scripts/still-identity.sh index e03ecae..254130e 100755 --- a/scripts/still-identity.sh +++ b/scripts/still-identity.sh @@ -22,6 +22,9 @@ # # 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. @@ -75,7 +78,7 @@ for comp in "${COMPS[@]}"; do exit 3 fi mid=$((n / 2)) - frames="0 1 $mid $((n - 2)) $((n - 1))" + 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; } @@ -87,9 +90,14 @@ for comp in "${COMPS[@]}"; do DIFF=$((DIFF + 1)) fi done - # Il controllo dello strumento: lo stesso fotogramma, due volte dal working tree. - still "$ROOT/video" "$comp" "$mid" "$TMP/r-$comp.png" || { echo "$line ripetizione: RENDER FALLITO"; exit 3; } - if [ "$(hash "$TMP/r-$comp.png")" != "$(hash "$TMP/h-$comp-$mid.png")" ]; then + # 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 diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 4a160bb..0984d86 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -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 @@ -430,6 +430,8 @@

Thirty-six movements,
no cuts.

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 @@ -1774,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:"slab.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.", 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.", @@ -1984,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/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/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/primitives/tracks.ts b/video/src/primitives/tracks.ts new file mode 100644 index 0000000..2d946ec --- /dev/null +++ b/video/src/primitives/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 "./slab.ts"; +import { tempo } from "./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 slab.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 index 6bfa975..857160c 100644 --- a/video/src/scenes/BoardOrbit.tsx +++ b/video/src/scenes/BoardOrbit.tsx @@ -6,16 +6,20 @@ import { useVideoConfig, } from "remotion"; import { - BOARD_ORBIT_END_POSE, COLUMNS, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, - PROMPT_INPUT_END_POSE, handoffCard, handoffLandedRect, TOPICS_RIG, TOPICS_SLAB, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { + BOARD_ORBIT_BASE as BASE, + BOARD_ORBIT_SETTLE as SETTLE, + boardOrbitTrack, +} from "../primitives/tracks"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; @@ -51,11 +55,8 @@ 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; +/* BASE, la durata di riferimento, e SETTLE, il frame in cui la camera arriva e + si ferma, stanno in primitives/tracks.ts: li legge anche la traccia. */ export const BoardOrbit: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); @@ -71,14 +72,9 @@ export const BoardOrbit: React.FC = ({ progress }) => { 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, - ); + // La camera sta in primitives/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); @@ -117,7 +113,7 @@ export const BoardOrbit: React.FC = ({ progress }) => { rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX, slideY }} + pose={pose} highlight={false} backdrop={} > diff --git a/video/src/scenes/CardFocus.tsx b/video/src/scenes/CardFocus.tsx index 28fc992..ceeb47e 100644 --- a/video/src/scenes/CardFocus.tsx +++ b/video/src/scenes/CardFocus.tsx @@ -1,13 +1,9 @@ import React from "react"; import { - Easing, - interpolate, useCurrentFrame, useVideoConfig, } from "remotion"; import { - CARD_FOCUS_END_POSE, - CARD_HANDOFF_END_POSE, COLUMNS, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, @@ -16,6 +12,8 @@ import { handoffCard, handoffLandedRect, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { cardFocusTrack } from "../primitives/tracks"; import { Board } from "../primitives/Board"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; @@ -57,26 +55,12 @@ export const CardFocus: React.FC = ({ progress }) => { 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, - ); + // prima di qualunque altra. La curva sta in primitives/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`. @@ -103,7 +87,7 @@ export const CardFocus: React.FC = ({ progress }) => { rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX, slideY }} + pose={pose} backdrop={} > diff --git a/video/src/scenes/CardHandoff.tsx b/video/src/scenes/CardHandoff.tsx index 759cae8..d22e001 100644 --- a/video/src/scenes/CardHandoff.tsx +++ b/video/src/scenes/CardHandoff.tsx @@ -6,11 +6,9 @@ import { useVideoConfig, } from "remotion"; import { - CARD_HANDOFF_END_POSE, CARD_H, COLUMNS, COL_W, - UI_MOCKUP_END_POSE, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, HANDOFF_TO_COL, @@ -21,6 +19,8 @@ import { TOPICS_RIG, TOPICS_SLAB, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { cardHandoffTrack } from "../primitives/tracks"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; @@ -128,19 +128,9 @@ 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 + // (primitives/tracks.ts). + const pose = poseAt(cardHandoffTrack(durationInFrames), frame); const moving = handoffCard(); @@ -232,7 +222,7 @@ export const CardHandoff: React.FC = ({ progress }) => { rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX: 0, slideY: 0 }} + pose={pose} backdrop={ = ({ progress }) => { 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/scenes/CardRelease.tsx b/video/src/scenes/CardRelease.tsx index 92c14ec..f2fa010 100644 --- a/video/src/scenes/CardRelease.tsx +++ b/video/src/scenes/CardRelease.tsx @@ -1,13 +1,9 @@ import React from "react"; import { - Easing, - interpolate, useCurrentFrame, useVideoConfig, } from "remotion"; import { - CARD_FOCUS_END_POSE, - CARD_RELEASE_END_POSE, COLUMNS, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, @@ -16,6 +12,8 @@ import { TOPICS_RIG, TOPICS_SLAB, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { cardReleaseTrack } from "../primitives/tracks"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { Board } from "../primitives/Board"; @@ -52,24 +50,11 @@ export const CardRelease: React.FC = ({ progress }) => { 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, - ); + // La curva sta in primitives/tracks.ts. + const pose = poseAt(cardReleaseTrack(durationInFrames), frame); const moving = handoffCard(); @@ -93,7 +78,7 @@ export const CardRelease: React.FC = ({ progress }) => { rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX, slideY }} + pose={pose} backdrop={} > diff --git a/video/src/scenes/PromptInput.tsx b/video/src/scenes/PromptInput.tsx index e1c7f24..280b379 100644 --- a/video/src/scenes/PromptInput.tsx +++ b/video/src/scenes/PromptInput.tsx @@ -11,14 +11,12 @@ 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, @@ -28,6 +26,11 @@ import { TOPICS_RIG, TOPICS_SLAB, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { + PROMPT_INPUT_BASE as BASE, + promptInputTrack, +} from "../primitives/tracks"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; @@ -75,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 + primitives/tracks.ts, perche' la legge anche la traccia della camera. */ const T = { travelStart: 56, @@ -176,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 primitives/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. @@ -197,21 +200,9 @@ 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 primitives/tracks.ts. + const pose = poseAt(promptInputTrack(durationInFrames), frame); const focused = frame >= K.at(T.clickField); @@ -299,7 +290,7 @@ export const PromptInput: React.FC = ({ rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX, slideY }} + pose={pose} highlight={false} backdrop={} > @@ -308,7 +299,7 @@ export const PromptInput: React.FC = ({ {/* 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/scenes/UIMockup.tsx b/video/src/scenes/UIMockup.tsx index 69df948..c1c8bb0 100644 --- a/video/src/scenes/UIMockup.tsx +++ b/video/src/scenes/UIMockup.tsx @@ -8,8 +8,6 @@ import { } from "remotion"; import { COLUMNS, - UI_MOCKUP_END_POSE, - UI_MOCKUP_START_POSE, addCardY, cardY, columnX, @@ -17,6 +15,8 @@ import { TOPICS_RIG, TOPICS_SLAB, } from "../primitives/slab"; +import { poseAt } from "../kit/camera"; +import { uiMockupTrack } from "../primitives/tracks"; import { Shot } from "../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../primitives/material"; import { @@ -70,40 +70,12 @@ export const UIMockup: React.FC = ({ progress }) => { 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" }, - ); + // 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 primitives/tracks.ts, la + // stessa che leggono i banchi. + const pose = poseAt(uiMockupTrack(durationInFrames), frame); const fadeIn = interpolate(frame, [0, 20], [0, 1], { extrapolateLeft: "clamp", @@ -118,7 +90,7 @@ export const UIMockup: React.FC = ({ progress }) => { rig={TOPICS_RIG} slab={TOPICS_SLAB} material={TOPICS_SHOT_MATERIAL} - pose={{ yaw, pitch, pushZ, slideX, slideY: 0 }} + pose={pose} opacity={fadeIn} backdrop={} > 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", "