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 @@