diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index 28c5785..5bd19a7 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -237,10 +237,19 @@ jobs: # cinque di distanza - che uno screenshot non puo' vedere. - name: le dimostrazioni mostrano ancora la loro tesi? run: | - python3 -m pip install --quiet --disable-pip-version-check playwright + # pillow serve a loop-close.py per decodificare gli screenshot: e' l'unico + # 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 ./scripts/demo-check.py + # E i cicli si devono chiudere. Le demo girano in loop: se l'ultimo + # fotogramma non somiglia al primo, ogni giro ha uno stacco secco, e a + # chi guarda non arriva "questa riparte", arriva "il sito scatta". + # Dieci voci su trenta lo facevano, su una pagina che spiega perche' + # gli stacchi non si fanno. + ./scripts/loop-close.py + # E il banco deve bocciare il suo caso peggiore: una copia della # pagina in cui CHR-03 fa scattare i tre eventi a frame distinti in # entrambe le meta', cioe' senza piu' il contrasto che la voce @@ -278,6 +287,22 @@ jobs: cat /tmp/gt.log >&2; exit 1; } echo "demo-check.py boccia anche una voce tipografica senza contrasto." + # loop-close.py deve bocciare un ciclo che non si chiude: si rimette + # CAM-01 a senso unico, cioe' com'era prima di avere la gamba di + # rientro, e il banco deve accorgersene e nominarla. + sed 's/var t=trip(f,170,108,26), p=mix(START,END,t)/var t=E.inout(cl((f-6)\/108)), p=mix(START,END,t)/' \ + showcase/dist/grammatica.html > /tmp/guasta-loop.html + if cmp -s showcase/dist/grammatica.html /tmp/guasta-loop.html; then + echo "la copia guasta del ciclo e' identica all'originale: la sed non ha trovato niente." >&2 + exit 1 + fi + rc=0; ./scripts/loop-close.py /tmp/guasta-loop.html > /tmp/gl.log 2>&1 || rc=$? + [ "$rc" != 0 ] || { echo "loop-close.py promuove una demo a senso unico." >&2; cat /tmp/gl.log >&2; exit 1; } + grep -q 'CAM-01' /tmp/gl.log || { + echo "loop-close.py boccia la copia guasta ma non nomina CAM-01:" >&2 + cat /tmp/gl.log >&2; exit 1; } + echo "loop-close.py boccia un ciclo che non si chiude, come deve." + # I render restano scaricabili dalla run anche quando il deploy non parte, # cosi' su una PR si guarda il video invece di fidarsi del diff. - uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index f27b48c..3ee3c13 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,51 @@ not an addition. Six scenes now, 1460 frames, 48.7 seconds, five measured joins: 557, 64, 55, 29 and 15 pixels. +## Ten of thirty demos tore on every pass + +The page's demos run on a loop. Ten of them were one-way animations — a camera +that pushes in and stays in, a graph that draws itself and stays drawn, a board +that fills and stays full — so the last frame had nothing to do with the first +and every pass ended in a hard cut. On a page whose entire subject is why you do +not cut. `GIU-04` changed 28 per cent of the frame at the join. + +Nobody reads that as "the demo restarted". It reads as the site stuttering, +which is exactly how it was reported. + +The fix is one shared idea rather than ten patches: a round-trip profile, and a +virtual clock for the demos whose state is a function of a running frame rather +than of a 0-to-1 parameter — a board filling in has no `t` to invert, it has a +clock to run backwards. The return leg is not part of the movement and the +readout says so: better a declared leg home than a tear every pass. Two demos +did not need a rewind and got something truer instead: the streaming answer +scrolls up and out the way the app would, and the shared-word sentence swaps back +in the opposite direction, so the loop is `join → pose → join`. + +`loop-close.py` measures it, and finding the right question took three wrong +ones. Against the demo's *typical* motion, the demos that sit still between +discrete beats have a median of zero and every join looks infinite. Against the +demo's *largest* step, a demo that also cuts internally takes its own cut as the +reference and absolves itself — `CHR-02` scored 652 against 652, the same defect +counted twice. The question that works is simpler: **is the last frame one step +away from the first, or somewhere else entirely?** Compare the wrap against a +normal one-frame step taken at the same place in the loop. + +It measures the wrap and *not* the cuts in the middle, and that is a real limit +rather than an oversight. No metric I tried separates a cut from a **fade**. +Counting pixels over a threshold, a linear fade spikes halfway through — that is +where most pixels cross at once — so the bench flagged a line of text leaving the +frame, which is the most continuous thing on the page. With a mean difference the +spike goes away and the measure becomes so sensitive it flags everything. The +mid-loop cut in `CHR-02` was found by looking, not by measuring. + +One more defect, and it was the one that showed. `TYP-02` grew its keyword by +changing its font size inside a single flex line — and changing the size changes +the box, so the line recomposed, the wrap point moved, and halfway through the +growth the whole sentence jumped 147 pixels. Exactly the opposite of what the +entry claims. The keyword now has a row of its own and grows with a `scale()`, +which does not touch layout, so the other words move 0.00 px — measured, not +asserted. It is also closer to the references, where the enormous word is alone. + ## Typography, where the sentence is the film Four entries with no interface on screen at all. They come from three reference @@ -247,6 +292,7 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./scripts/click-gap.sh [scene.mp4] # does the UI answer the click, or fire with it ./scripts/fixture-screenshot.sh # build the scene focus-sharpness must fail ./scripts/demo-check.py [page.html] # do the catalogue demos still show their thesis +./scripts/loop-close.py [page.html] # does every demo loop close, or tear every pass ./scripts/contrast-floor.py [scene.mp4] # is the attenuated content still readable ./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 diff --git a/scripts/demo-check.py b/scripts/demo-check.py index 5f4b809..5aca09b 100755 --- a/scripts/demo-check.py +++ b/scripts/demo-check.py @@ -154,7 +154,9 @@ def span(f): # CAM-04 - la discesa deve ingrandire la card e tenerla nel quadro. # Caso peggiore: la camera passa accanto al soggetto e il fondo resta nero. - a0, a1 = at("CAM-04", 4), at("CAM-04", dur("CAM-04") - 1) + # Il culmine, non l'ultimo fotogramma: da quando la demo torna indietro per + # chiudere il ciclo, l'ultimo fotogramma e' di nuovo il campo largo. + a0, a1 = at("CAM-04", 4), at("CAM-04", 132) c1 = a1["active"] inside = abs(c1["x"] - a1["sw"] / 2) < a1["sw"] / 2 and abs(c1["y"] - a1["sh"] * 0.46) < a1["sh"] / 2 check("CAM-04", a1["active"]["w"] > a0["active"]["w"] * 2 and inside, @@ -170,8 +172,8 @@ def span(f): def drift(f): s = at("CAM-06", f) return ((s["mk"]["x"] - s["sw"] / 2) ** 2 + (s["mk"]["y"] - s["sh"] * 0.46) ** 2) ** 0.5 - check("CAM-06", drift(103) > 60 and drift(d - 3) < 4, - "deriva del soggetto dall'origine: %d px senza compensazione, %d px con" % (drift(103), drift(d - 3))) + check("CAM-06", drift(64) > 60 and drift(105 + 64) < 4, + "deriva del soggetto dall'origine: %d px senza compensazione, %d px con" % (drift(64), drift(105 + 64))) # CUR-03 - la seconda meta' e' lo stesso clic senza risposta. p1 = any(at("CUR-03", f).get("press") for f in range(40, 70)) @@ -209,13 +211,18 @@ def lead(base): # Ogni card e' a posto quando ha finito di salire: l'ordine di arrivo e' la # regola di scaglionamento, e le due meta' devono avere ordini diversi. def arrival(base): - end = {(c["col"], c["idx"]): c["y"] for c in at("CHR-02", base + 118)["cards"]} + # LE PARTENZE, non gli arrivi. La meta' adesso e' andata e ritorno, e + # vicino all'inversione l'orologio decelera: misurando l'arrivo, le card + # si ammucchiavano tutte negli ultimi fotogrammi e l'ordine si + # appiattiva. Il fotogramma in cui una card SI STACCA dalla posizione di + # partenza non ha quel problema. + start = {(c["col"], c["idx"]): c["y"] for c in at("CHR-02", base)["cards"]} out = {} - for f in range(base + 118, base, -1): + for f in range(base, base + 86): for c in at("CHR-02", f)["cards"]: k = (c["col"], c["idx"]) - if abs(c["y"] - end[k]) > 1.5: - out.setdefault(k, f - base) + if k not in out and abs(c["y"] - start[k]) > 1.5: + out[k] = f - base return out a1, a2 = arrival(0), arrival(125) ord1 = [k for k, v in sorted(a1.items(), key=lambda kv: kv[1])] @@ -341,9 +348,13 @@ def dwell(code, lo, hi): # TYP-02 - una parola sola, enorme. def hratio(code, f): + # L'ALTEZZA RESA, non il corpo dichiarato. Da quando la parola chiave + # cresce con una scale() invece che col font-size - che era il difetto: + # cambiare il corpo ricomponeva la riga - il font-size resta 100% e + # solo il rettangolo cresce. sp = ty(code, f)["spans"] - key = sp[0]["size"] - rest = max(s["size"] for s in sp[1:]) + key = sp[0]["h"] + rest = max(s["h"] for s in sp[1:]) return key / rest r1 = max(hratio("TYP-02", f) for f in range(60, 100, 6)) r2 = max(hratio("TYP-02", f) for f in range(170, 210, 6)) diff --git a/scripts/loop-close.py b/scripts/loop-close.py new file mode 100755 index 0000000..61f21f3 --- /dev/null +++ b/scripts/loop-close.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Il ciclo di ogni dimostrazione si chiude, o strappa a ogni giro? + +PERCHE' ESISTE. Le demo della pagina girano in ciclo continuo. Se l'ultimo +fotogramma non somiglia al primo, ogni giro ha uno stacco secco - e a chi +guarda non arriva "questa e' una demo che riparte", arriva "il sito scatta". +Il repo intero sta in piedi su una regola che dice che due stati adiacenti si +devono attaccare senza che si veda; la pagina che quella regola la spiega la +violava trenta volte al minuto. + +Undici voci su trenta strappavano: le animazioni a senso unico - una camera che +entra e resta dentro, un testo che si compone e resta scritto, un grafico che si +disegna e resta disegnato - finiscono lontanissimo da dove sono partite. GIU-04 +cambiava il 28 per cento del quadro nel punto di ricongiunzione. + +COME MISURA. La domanda e' se l'ultimo fotogramma sia A UN PASSO dal primo o +altrove. Si confronta la differenza fra ultimo e primo con la differenza fra il +primo e quello subito dopo, misurata quindi NELLO STESSO PUNTO del ciclo: se il +ciclo si chiude le due sono confrontabili, perche' sono entrambe un fotogramma +di distanza. Se strappa, la prima e' quella fra due stati che non si sono mai +toccati. + +Due riferimenti sbagliati prima di questo, e vale la pena tenerli scritti. + +Il novantesimo percentile del movimento non funzionava: le demo che stanno ferme +e cambiano a scatti - MAT-02 accende una scheda ogni 74 fotogrammi e nel mezzo +non succede niente - hanno un percentile a zero, quindi qualunque giunta +risultava infinita e il banco le bocciava per un cambio di colore che e' la loro +battuta. + +Il PASSO PIU' GRANDE della demo era peggio, perche' si contamina: una demo che +stacca anche al suo interno si ritrova quello stacco come riferimento e si +autoassolve sulla giunta del ciclo. CHR-02 misurava passo massimo 652 e giunta +652, cioe' 1,0x, e i due numeri erano lo stesso difetto due volte. + +E c'e' un pavimento in pixel assoluti, perche' un cambio di stato dell'interfaccia +- un contatore che passa da 2 a 3, una scheda che si accende - e' istantaneo per +natura e cambia poca roba: e' una battuta, non uno strappo. Sotto il due per +cento del quadro non si guarda il rapporto. + +Il paragone e' interno alla demo perche' le demo si muovono in modo diverso fra +loro: una camera che scorre cambia migliaia di pixel per fotogramma, una parola +che si accende poche decine. Una soglia in pixel assoluti boccerebbe le prime e +promuoverebbe le seconde. + +QUESTO BANCO MISURA SOLO LA GIUNTA DEL CICLO, e non gli stacchi in mezzo. Ci ho +provato, e non sono riuscito a tarare la cosa: uno stacco a meta' ciclo e' lo +stesso difetto in un altro punto - CHR-02 faceva sparire una board intera in un +fotogramma - ma nessuna delle misure che ho provato distingue uno stacco da una +DISSOLVENZA. Contando i pixel sopra una soglia, una dissolvenza lineare produce +un picco a meta' strada, dove la maggior parte dei pixel attraversa la soglia +tutti insieme, e il banco segnalava come stacco l'uscita di campo di una riga di +testo, che e' la cosa piu' continua che ci sia. Con la differenza media il picco +sparisce ma la misura diventa cosi' sensibile che segnala tutto, e il pavimento +che separerebbe le due cose non l'ho trovato. + +Quindi la giunta del ciclo la misura, e gli stacchi in mezzo restano da trovare +a mano. E' meno di quello che serve, ed e' meglio di un verdetto che non regge: +lo stacco interno di CHR-02 e' stato trovato guardando, non misurando. + +Uso: ./scripts/loop-close.py [pagina.html] +""" +import io +import pathlib +import statistics +import sys + +from PIL import Image +from playwright.sync_api import sync_playwright + +# Niente numpy: sul runner della CI non c'e', e per confrontare due immagini da +# 200x112 in scala di grigi bastano i byte. E' la stessa scelta di +# contrast-floor.py, e vale un pacchetto in meno da installare. + +ROOT = pathlib.Path(__file__).resolve().parent.parent +if len(sys.argv) > 1: + PAGE = pathlib.Path(sys.argv[1]).resolve() +else: + PAGE = ROOT / "showcase" / "dist" / "grammatica.html" + if not PAGE.exists(): + PAGE = ROOT / "showcase" / "grammatica.html" + +# Quante volte la giunta puo' superare un passo normale preso nello stesso punto. +SOGLIA = 3.0 +# Il pavimento: il due per cento del quadro campionato. Sotto, e' una battuta. +IGNORA = 448 +W, H = 200, 112 +DIFF = 18 + +fails = [] + +with sync_playwright() as pw: + try: + br = pw.chromium.launch(headless=True) + except Exception: + br = pw.chromium.launch(headless=True, channel="chrome") + pg = br.new_context(viewport={"width": 1200, "height": 800}).new_page() + pg.goto(PAGE.as_uri(), wait_until="load") + pg.wait_for_timeout(1600) + + codes = pg.eval_on_selector_all(".mvhead .code", "e=>e.map(x=>x.textContent.trim())") + stages = pg.query_selector_all(".stage") + + def shot(st, i, f): + pg.evaluate( + "([i,f])=>{const it=document.querySelectorAll('.stage')[i].__it;" + " it.manual=f; it.last=-1; it.mv.draw(f,it.S);}", [i, f]) + im = Image.open(io.BytesIO(st.screenshot())).convert("L").resize((W, H)) + return im.tobytes() + + def d(a, b): + return sum(1 for x, y in zip(a, b) if abs(x - y) > DIFF) + + print("%-8s %6s %10s %10s %9s" % ("voce", "dur", "passo ai capi", "giunta", "rapporto")) + for i, c in enumerate(codes): + st = stages[i] + st.scroll_into_view_if_needed() + pg.wait_for_timeout(90) + dur = pg.evaluate("(i)=>document.querySelectorAll('.stage')[i].__it.mv.dur", i) + f0, f1, f2 = shot(st, i, 0), shot(st, i, 1), shot(st, i, 2) + fl, fp = shot(st, i, dur - 1), shot(st, i, dur - 2) + # Un passo normale preso ai due capi del ciclo, cioe' dove sta la giunta. + passo = max(d(f0, f1), d(f1, f2), d(fp, fl)) + wrap = d(fl, f0) + ratio = wrap / passo if passo else (999.0 if wrap > IGNORA else 0.0) + + bad = wrap > IGNORA and ratio > SOGLIA + print("%-8s %6d %10d %10d %8.1fx%s" % (c, dur, passo, wrap, ratio, " STRAPPA" if bad else "")) + if bad: + fails.append((c, wrap, ratio)) + + br.close() + +print() +if fails: + print("cicli che strappano a ogni giro:", ", ".join(c for c, _, _ in fails), file=sys.stderr) + print("L'ultimo fotogramma di queste demo non somiglia al primo, quindi a ogni", file=sys.stderr) + print("giro c'e' uno stacco. Non legge come una demo che riparte, legge come", file=sys.stderr) + print("un sito che scatta.", file=sys.stderr) + raise SystemExit(1) +print("VERDETTO: tutte le dimostrazioni chiudono il proprio ciclo.") +print("L'ultimo fotogramma vale quanto un fotogramma qualsiasi rispetto al primo.") diff --git a/showcase/grammatica.html b/showcase/grammatica.html index 4971e52..051441b 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -382,6 +382,7 @@

Thirty movements,
no cuts.

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 rest-point.shA scene still moving where the next one has to attach. Its thresholds were absolute to begin with, tuned on this machine, and every one of them failed the move to Linuxedges under 30% of mid-scene click-gap.shA press and its consequence fused onto one frame, or so far apart that it stops reading as a consequence and starts reading as lagf271 and f276, five frames + loop-close.pyA demo whose loop tears every pass. Ten of thirty were one-way animations, so the last frame had nothing to do with the first — on a page about not cutting. It measures the wrap and not the cuts in the middle, because no metric tried could separate a cut from a fade10 torn, now 0 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 @@ -745,6 +746,31 @@

Thirty movements,
no cuts.

/* ============================================================ i movimenti */ function C(S,col,idx){ for(var i=0;iup+hold; } +/* Un TEMPO VIRTUALE che va avanti fino a `up`, sosta, e torna a zero. Serve + dove lo stato non e' una funzione di un parametro 0..1 ma di un frame che + scorre - una board che si popola: li' non c'e' un `t` da invertire, c'e' un + orologio da far tornare indietro. */ +function vtime(q,H,up,hold){ + if(q<=up) return q; + if(q<=up+hold) return up; + return up*(1-E.inout(cl((q-up-hold)/Math.max(1,H-1-up-hold)))); +} +var RET=" ← the return leg: this is the loop closing, not the movement"; + function bez(A,Cp,B,u){ var m=1-u; return {x:m*m*A.x+2*m*u*Cp.x+u*u*B.x, y:m*m*A.y+2*m*u*Cp.y+u*u*B.y}; } function depth(S){ S.hdr.style.transform="translateZ(6px)"; @@ -769,9 +795,10 @@

Thirty movements,
no cuts.

var M={}; -M.push={dur:150,draw:function(f,S){ - var t=E.inout(cl((f-6)/108)), p=mix(START,END,t); pose(S,p); - S.hud("yaw "+p.yaw.toFixed(1)+" pitch "+p.pitch.toFixed(1)+" pushZ "+Math.round(p.z)+" slideX "+Math.round(p.x)); +M.push={dur:170,draw:function(f,S){ + var t=trip(f,170,108,26), p=mix(START,END,t); pose(S,p); + S.hud("yaw "+p.yaw.toFixed(1)+" pitch "+p.pitch.toFixed(1)+" pushZ "+Math.round(p.z)+" slideX "+Math.round(p.x) + +(back(f,108,26)?RET:"")); }}; /* --- CAM-02: la parallasse alla distanza a cui si vede ----------------- @@ -823,14 +850,15 @@

Thirty movements,
no cuts.

M.macro={dur:210, init:function(S,stage){ depth(S); S.stageEl=stage; }, draw:function(f,S){ - var t=E.inout(cl((f-10)/150)), z=48+1180*t; + var t=trip(f,210,132,26), z=48+1180*t; /* La traslazione esce da centreOn come in CAM-06: coi numeri fissi della repo, su un palco di larghezza diversa, la camera passa accanto alla card. */ var r=S.stageEl.getBoundingClientRect(), base=(r.width/1920)*1.04; var cx=colX(1)+COL_W/2, cy=cardY(COLUMNS[1].cards,0)+ACTIVE_CARD_H/2; var tx=1200-cx, ty=(0.46-0.5)*r.height/base+370-cy; pose(S,{yaw:-9+9*t,pitch:2.5-2.5*t,z:z,x:tx*t,y:ty*t}); - S.hud("pushZ "+Math.round(z)+" focus-sharpness.sh reads 2.09× against the same content magnified from the wide shot"); + S.hud("pushZ "+Math.round(z)+" focus-sharpness.sh reads 2.09× against the same content magnified from the wide shot" + +(back(f,132,26)?RET:"")); }}; M.attn={dur:250,draw:function(f,S){ @@ -943,8 +971,19 @@

Thirty movements,
no cuts.

var prog=cl(ip(f,[24,110],[0,0.42],E.out)+(f>130?ip(f,[130,214],[0,0.58],E.lin):0)); var n=Math.floor(prog*S.words.length); S.stream.textContent=S.words.slice(0,n).join(" "); + /* A risposta finita il blocco esce dal campo e il thread torna vuoto, che e' + quello che fa l'app. Il ciclo si chiude su un composer vuoto come quello da + cui e' partito, invece di ripartire cancellando di colpo una risposta + scritta. E non e' un riavvolgimento: nessuno vede il testo scriversi + all'indietro. */ + var exit=E.inout(cl((f-214)/30)); + var gone=f>=246; + S.stream.textContent=gone?"":S.words.slice(0,n).join(" "); + S.stream.style.transform=gone?"none":"translateY("+(-exit*46).toFixed(1)+"px)"; + S.stream.style.opacity=gone?"1":(1-exit).toFixed(3); var stall=f>=110&&f<=130; - S.hud("words "+n+"/"+S.words.length+(stall?" stall":"")+" the count must GROW across the window, not merely be non-zero"); + S.hud("words "+n+"/"+S.words.length+(stall?" stall":"")+(f>=214&&!gone?" the thread moves on":"") + +" the count must GROW across the window, not merely be non-zero"); }}; M.metric={dur:170,draw:function(f,S){ @@ -978,12 +1017,14 @@

Thirty movements,
no cuts.

: "gap at f20, landing at f38: 18 frames of lead, so the space opens before the row gets there"); }}; -M.handoff={dur:250, +M.handoff={dur:310, init:function(S){ svgPath(S,"M1130 196 Q1409 78 1687 196","rgba(77,148,255,.38)","9 12"); }, draw:function(f,S){ pose(S,{yaw:-9,pitch:2.5,z:60,x:0,y:0}); - var lift=E.out(cl((f-14)/26)), fly=E.inout(cl((f-40)/86)), - open=E.out(cl((f-30)/40)), close=E.out(cl((f-56)/44)); + /* Andata e ritorno: la card attraversa, si posa, e poi il gesto si annulla + e la board torna nello stato in cui il ciclo l'aveva trovata. */ + var lift=trip(f,310,40,120)*1, fly=trip(f,310,126,60), + open=trip(f,310,70,110), close=trip(f,310,100,80); var card=C(S,1,0), sx=colX(1), dx=colX(2), step=ACTIVE_CARD_H+CARD_GAP; card.el.style.left=(sx+(dx-sx)*fly)+"px"; card.el.style.top=(141-Math.sin(Math.PI*fly)*104)+"px"; @@ -995,7 +1036,7 @@

Thirty movements,
no cuts.

var c2=C(S,1,1); c2.el.style.top=(c2.y-step*close)+"px"; S.adds[1].el.style.top=(S.adds[1].y-step*close)+"px"; - S.cols[1].cnt.textContent=f>=100?"1":"2"; + S.cols[1].cnt.textContent=(fly>=0.5)?"1":"2"; S.cols[2].cnt.textContent=f>=100?"3":"2"; S.hud("gap f30 · flight f40-126 · close f56 handoff-travel.sh wants a monotonic centroid across half a column"); }}; @@ -1031,7 +1072,10 @@

Thirty movements,
no cuts.

}, draw:function(f,S){ pose(S,{yaw:-9,pitch:2.4,z:120,x:0,y:0}); - var second=f>=125, q=second?f-125:f; + /* Il tempo della meta' va avanti e poi torna: senza, la seconda meta' + trovava la board gia' posata e ripartiva dal vuoto, cioe' uno stacco secco + a meta' ciclo - il difetto piu' visibile di tutta la pagina. */ + var second=f>=125, q=vtime(second?f-125:f,125,86,14); /* Il cambiamento: la card attiva si posa. Tutto il resto data da qui. */ var land=E.out(cl((q-8)/30)); S.act.el.style.transform="translateZ(40px) translateY("+((1-land)*-58).toFixed(1)+"px)"; @@ -1079,12 +1123,15 @@

Thirty movements,
no cuts.

S.hud("the colour spilling onto the floor is the screen dominant one on that frame, not a light chosen by hand"); }}; -M.seam={dur:240,draw:function(f,S){ - var p=f<120?mix(START,END,E.inout(cl(f/108))) - :mix(END,{yaw:-9,pitch:2.5,z:440,x:-170,y:70},E.inout(cl((f-120)/112))); +M.seam={dur:300,draw:function(f,S){ + var B={yaw:-9,pitch:2.5,z:440,x:-170,y:70}; + var p = f<120 ? mix(START,END,E.inout(cl(f/108))) + : f<210 ? mix(END,B,E.inout(cl((f-120)/86))) + : mix(B,START,E.inout(cl((f-214)/84))); pose(S,p); var at=f>=117&&f<=123; - S.hud((f<120?"UIMockup":"CardHandoff")+" join at f120"+(at?" ← now":"")+" seam.sh: 557 px against 37,103 for the control cut, 67×"); + S.hud((f<120?"UIMockup":(f<210?"CardHandoff":"back to the first pose"))+" join at f120"+(at?" ← now":"") + +" seam.sh: 557 px against 37,103 for the control cut, 67×"+(f>=210?RET:"")); }}; M.rest={dur:260,draw:function(f,S){ @@ -1097,10 +1144,11 @@

Thirty movements,
no cuts.

: " eased: it goes to zero at the far pose, and that frame is where the next scene attaches")); }}; -M.through={dur:240,draw:function(f,S){ - var t=f/239, at=Math.abs(f-120)<4; +M.through={dur:280,draw:function(f,S){ + var t=trip(f,280,190,20), at=Math.abs(f-95)<4; pose(S,{yaw:-22+26*t,pitch:4.2-2.2*t,z:340*t,x:660-660*t,y:0}); - S.hud("a normal edit would put the cut at f120"+(at?" ← passing now":"")+". The camera does not stop, so there is nothing to hide"); + S.hud("a normal edit would put the cut at f95"+(at?" ← passing now":"")+". The camera does not stop, so there is nothing to hide" + +(back(f,190,20)?RET:"")); }}; /* --- CAM-06: il solo punto del quadro che non scappa sotto una spinta in Z --- */ @@ -1116,8 +1164,8 @@

Thirty movements,
no cuts.

}, draw:function(f,S){ /* Prima meta': si spinge e basta. Seconda: si spinge compensando. */ - var second=f>=105, q=(second?f-105:f)/98; - var t=E.inout(cl((q-0.06)/0.82)), z=48+1180*t; + var second=f>=105, qf=second?f-105:f; + var t=trip(qf,105,64,12), z=48+1180*t; var comp=second?t:0; /* La compensazione si calcola, e il termine che tutti dimenticano e' il secondo: l'origine della prospettiva sta al 46% dell'altezza, non al 50%, @@ -1184,22 +1232,33 @@

Thirty movements,
no cuts.

var TCLAIM = ["One", "shot.", "Forty-eight", "seconds."]; M.big={dur:220, kind:"type", - init:function(T){ T.words(TCLAIM); }, + init:function(T){ + T.words(TCLAIM); + /* LA PAROLA CHIAVE STA SU UNA RIGA SUA, e cresce con una TRASFORMAZIONE. + Prima cresceva cambiandole il corpo dentro una riga sola, e cambiare il + corpo cambia la larghezza della casella: la riga flex si ricomponeva, il + punto di a capo si spostava, e a meta' crescita tutta la frase saltava di + centoquarantasette pixel. Era il difetto piu' visibile della famiglia, ed + era esattamente il contrario di quello che la voce dichiara. + Una scale() non tocca il layout, quindi le altre parole non hanno proprio + modo di muoversi; e la riga propria e' anche piu' vicina alle reference, + dove la parola enorme sta da sola. */ + T.spans[0].style.flexBasis="100%"; + T.spans[0].style.textAlign="center"; + T.spans[0].style.transformOrigin="50% 60%"; + }, draw:function(f,T){ var second=f>=110, q=second?f-110:f; var inP=E.out(cl(q/26)), grow=E.inout(cl((q-30)/44)), out=E.inout(cl((q-96)/14)); + var k=second?1:1+1.6*grow; T.spans.forEach(function(sp,i){ - var key=i===0; - /* Nella seconda meta' la parola chiave non cresce: la frase resta tutta - allo stesso corpo, che e' il caso da guardare. */ - var k=(key&&!second)?1+1.6*grow:1; sp.style.opacity=(inP*(1-out)).toFixed(3); - sp.style.fontSize=(k*100).toFixed(1)+"%"; - sp.style.transform="translateY("+((1-inP)*0.22).toFixed(3)+"em)"; + sp.style.transform=(i===0?"scale("+k.toFixed(3)+") ":"") + +"translateY("+((1-inP)*0.22).toFixed(3)+"em)"; }); T.hud(second ? "every word at the same size: nothing in the line is the subject" - : "the keyword at "+(1+1.6*grow).toFixed(2)+"× the rest one word per beat becomes enormous, and the others stay where they were"); + : "the keyword at "+k.toFixed(2)+"× the rest it scales with a transform, so the others cannot move even if they wanted to"); }}; /* --- TYP-03: colore solo sulla parola chiave --- */ @@ -1270,8 +1329,12 @@

Thirty movements,
no cuts.

}); if(second){ - T.oldw.style.opacity=sw<0.5?"1":"0"; - T.neww.style.opacity=sw<0.5?"0":"1"; + /* La seconda meta' va nel VERSO OPPOSTO: la prima sostituisce join con + pose, questa rimette join al suo posto. Cosi' il ciclo torna dove e' + partito invece di riportare indietro la parola di scatto all'ultimo + fotogramma. */ + T.oldw.style.opacity=sw<0.5?"0":"1"; + T.neww.style.opacity=sw<0.5?"1":"0"; T.oldw.style.transform="none"; T.neww.style.transform="none"; } else { /* Solo la parola che cambia cambia: la vecchia sale e se ne va, poi la @@ -1295,12 +1358,14 @@

Thirty movements,
no cuts.

{n:"CardHandoff", from:END, to:{yaw:-4,pitch:1.2,z:96,x:0,y:0}}, {n:"CardFocus", from:{yaw:-4,pitch:1.2,z:96,x:0,y:0},to:{yaw:0,pitch:0,z:1180,x:70,y:173.5}} ]; -M.chain={dur:330, +M.chain={dur:430, init:function(S,stage){ var cv=el("canvas","graph"); cv.width=580; cv.height=290; stage.appendChild(cv); S.g=cv; }, draw:function(f,S){ - var seg=f<110?0:(f<220?1:2), local=(f-seg*110)/109; + /* La catena si percorre in avanti e poi si torna al principio: il grafico + si ridisegna all'indietro invece di sparire di colpo a fine ciclo. */ + var g=trip(f,430,330,20)*3, seg=Math.min(2,Math.floor(g)), local=g-seg; var t=E.inout(cl(local)), L=CHAIN[seg]; var p=mix(L.from,L.to,t); pose(S,p); /* Il grafico: tre curve sull'intera catena piu' la testina. */ @@ -1345,11 +1410,14 @@

Thirty movements,
no cuts.

}}; /* --- CHR-04: prima e dopo senza divisorio --- */ -M.ba={dur:250,init:depth,draw:function(f,S){ - pose(S,{yaw:-10+3*E.sine(cl(f/249)),pitch:2.4,z:40+60*E.sine(cl(f/249)),x:120-240*E.sine(cl(f/249)),y:0}); +M.ba={dur:300,init:depth,draw:function(f,S){ + var w=Math.sin(Math.PI*cl(f/299)); + pose(S,{yaw:-10+3*w,pitch:2.4,z:40+60*w,x:120-240*w,y:0}); /* Due card passano da Backlog a In review, sfalsate. Nessun wipe, nessuno slider: lo stato cambia sotto una camera che non si ferma. */ - var m1=E.inout(cl((f-52)/70)), m2=E.inout(cl((f-88)/70)); + /* Le due card passano di colonna e poi tornano: senza il rientro l'ultimo + fotogramma trovava la board in uno stato che il primo non ha mai avuto. */ + var m1=trip(f,300,122,50), m2=trip(f,300,158,26); var a=C(S,0,2), b=C(S,0,1), dst=colX(2), step=CARD_H+CARD_GAP; a.el.style.left=(a.x+(dst-a.x)*m1)+"px"; a.el.style.top=(a.y+(357-a.y)*m1-Math.sin(Math.PI*m1)*70)+"px"; @@ -1359,7 +1427,7 @@

Thirty movements,
no cuts.

b.el.style.transform="translateZ("+(26+90*Math.sin(Math.PI*m2))+"px)"; S.cols[0].cnt.textContent=String(3-(m1>=.5?1:0)-(m2>=.5?1:0)); S.cols[2].cnt.textContent=String(2+(m1>=.5?1:0)+(m2>=.5?1:0)); - var mt=E.out(cl((f-120)/80)); + var mt=trip(f,300,200,20); S.metric.textContent=Math.round(128-64*mt)+" ms"; S.metric.style.color=mt>0.9?"#34d399":"#e6e8ec"; S.hud("no divider and no slider: the before and the after are the same shot at two different moments"); @@ -1563,8 +1631,8 @@

Thirty movements,
no cuts.

{fam:"typ",code:"TYP-02",t:"One word, enormous",demo:"big",src:"scale contrast", a:"One word per beat becomes enormous and the rest of the line stays where it was. The size difference is what says which word the sentence is about.", - b:"A scale factor on one span, not a font size on the line. The others keep their box, so the line does not reflow and nothing else moves while the keyword grows.", - c:"2.6 times the rest, over 44 frames, after the line has composed. The second half of the demo runs at 1.0, which is the case worth looking at.", + b:"A transform on one span, not a font size. That distinction is the entry: a font size changes the element's box and the line recomposes around it, a scale does not, and only one of the two leaves the other words where they were.", + c:"2.6 times the rest, over 44 frames, after the line has composed. The second half runs at 1.0, which is the case worth looking at. The keyword has a row of its own and grows with a transform, and both of those are repairs: it used to grow by changing its font size inside one line, which changes the box, which recomposes the line — halfway through the growth the whole sentence jumped 147 pixels, the exact opposite of what this entry claims. A scale() does not touch layout, so the others now move 0.00 px.", d:"demo-check.py compares the rendered height of the keyword against the others: 2.6 times in the first half, 1.0 in the second. When every word is the same size no word is the subject, and that is not a matter of taste — it is what the frame is failing to say."}, {fam:"typ",code:"TYP-03",t:"Colour on one word only",demo:"only",src:"the accent as a pointer",