Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/showcase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 20 additions & 9 deletions scripts/demo-check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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])]
Expand Down Expand Up @@ -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))
Expand Down
142 changes: 142 additions & 0 deletions scripts/loop-close.py
Original file line number Diff line number Diff line change
@@ -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.")
Loading
Loading