diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml index 0afc96b..f7f6e00 100644 --- a/.github/workflows/showcase.yml +++ b/.github/workflows/showcase.yml @@ -13,7 +13,7 @@ # I BANCHI GIRANO PRIMA DEL DEPLOY, non dopo. Un sito pubblicato e poi trovato # rotto e' gia' stato visto da qualcuno. # -# RIFARE I VIDEO E' L'UNICA COSA CHE COSTA (quattro minuti su quattro scene) e +# RIFARE I VIDEO E' L'UNICA COSA CHE COSTA (sette minuti per rapporto) e # quasi ogni push non la richiede: correggere una riga della pagina o del README # non cambia un pixel dei render, che escono identici a quelli di prima. Quindi # i render passano da una cache la cui chiave e' il contenuto di tutto cio' che @@ -27,6 +27,21 @@ # finirebbe in cache, e la run dopo salterebbe i controlli che l'avevano # bocciato e lo pubblicherebbe. +# +# TRE JOB E NON UNO, e il motivo e' un conto. La run di main del 15 settembre 2026 +# durava 21 minuti in un job solo: render 7, misure 5, negativi 4, pagina 4. Con +# i tre rapporti sarebbero stati piu' di cinquanta. Adesso: +# - `pagina` controlla le dimostrazioni e la proiezione, che non leggono i +# render, e parte subito; +# - `formato` gira tre volte in parallelo, una per rapporto: renderizza le +# scene e i provini del tempo di quel rapporto, esegue i controlli generati +# dal manifest (scripts/checks/*.mjs, eseguiti da expect.sh) e verifica che +# ogni banco abbia un negativo che fallisce (bench-coverage.py); +# - `deploy` aspetta tutti e quattro, assembla la pagina coi render del 16:9 e +# pubblica. La pagina resta sul 16:9: i verticali sono negli artefatti. +# Ogni rapporto ha la sua cache: un push che tocca solo la pagina non +# ri-renderizza niente. + name: render e deploy on: @@ -41,260 +56,30 @@ concurrency: cancel-in-progress: true jobs: - showcase: + pagina: runs-on: ubuntu-latest - # IL MARGINE ERA FINITO. La run completa di main del 13 settembre 2026 e' - # durata 23 minuti e 41 secondi contro un limite di 25: un solo banco in piu' - # l'avrebbe fatta scadere, e una run scaduta non pubblica niente. 40 e' il - # margine per i passi che vengono; la cura vera e' dividere il lavoro in job - # paralleli, non alzare ancora questo numero. - timeout-minutes: 40 - + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - name: chiave dei render - id: key - run: | - echo "value=renders-verificati-${{ hashFiles('video/src/**', 'video/package-lock.json', 'video/remotion.config.ts', 'video/tsconfig.json', 'video/eslint.config.mjs', 'scripts/**') }}" >> "$GITHUB_OUTPUT" - - - name: render gia' verificati? - id: cache - uses: actions/cache/restore@v4 - with: - 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. + # Node 22 per il manifest (i casi della proiezione leggono i moduli .ts del + # kit e di Topics), senza dipendenze: quei moduli non importano Remotion. - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - cache-dependency-path: video/package-lock.json - - # ImageMagick e ffmpeg servono ai banchi di misura, non al render. - # Chromium se lo porta Remotion. - - name: strumenti di misura - if: steps.cache.outputs.cache-hit != 'true' - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq imagemagick ffmpeg tesseract-ocr - - - name: dipendenze - if: steps.cache.outputs.cache-hit != 'true' - working-directory: video - run: npm ci - - - name: typecheck e lint - if: steps.cache.outputs.cache-hit != 'true' - working-directory: video - run: npm run lint - # L'elenco delle scene non e' scritto qui: esce da catalog.json, lo stesso - # file da cui Root.tsx dichiara le composition e da cui la pagina prende - # la vetrina. Una scena aggiunta la' viene renderizzata, misurata e - # pubblicata senza toccare questo workflow. - # I comandi si scrivono in un file e si eseguono con lo stdin chiuso, - # invece di finire in `| sh`. Con il pipe, il primo comando che legge lo - # stdin (imagemagick lo fa) si mangia le righe successive dello script: - # nella run 33404856145 fill-measure ha inghiottito mezzo comando e la - # misura dopo e' morta con "cripts/seam.sh: not found". - - name: render - if: steps.cache.outputs.cache-hit != 'true' - working-directory: video - run: | - node ../scripts/catalog.mjs render > /tmp/render.sh - sh -e /tmp/render.sh < /dev/null - - # Le misure che possono fallire. Se una scena e' rotta, il deploy non parte. - # - # 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: | - ./scripts/framelocked-verdict.sh - # E sulla scena che digita, nei frame della digitazione: e' li' che un - # caso non seminato (TXT-01) romperebbe la ripetibilita'. - FRAMES="150 175 200" ./scripts/framelocked-verdict.sh PromptInput - node scripts/catalog.mjs measures > /tmp/misure.sh - sh -e /tmp/misure.sh < /dev/null - ./scripts/handoff-travel.sh - ./scripts/focus-sharpness.sh - ./scripts/rest-point.sh - ./scripts/click-gap.sh - ./scripts/beats.sh - ./scripts/contrast-floor.py - - # CAM-06 FUORI DA TOPICS. Il bersaglio resta sull'origine della - # prospettiva mentre la camera avanza, su due lastre (Topics e la - # lastra sonda, chiara e verticale) e in tre rapporti. E' il primo - # banco che non guarda solo Topics in 16:9. - ./scripts/drift.py - - # LA CAMERA COME DATO. Le tracce di products/topics/tracks.ts sono quelle che - # le scene passano al render: da li' si misura che la camera non torni - # mai indietro in moto (GIU-04) e che la lastra copra i quattro bordi - # del quadro su ogni frame (CAM-01), senza chiederlo ai pixel. - ./scripts/chain-check.py - ./scripts/fill-geom.py - - # IL TEMPO. Le battute interne di una scena sono scritte rispetto a una - # durata di riferimento e `durationInFrames` le scala tutte, quindi - # cambiare la velocita' di una clip e' cambiare un numero in - # catalog.json. E' una promessa che si rompe in silenzio, e non si - # verifica guardando: due render alla stessa velocita' apparente hanno - # durate diverse. - ./scripts/fixture-tempo.sh - ./scripts/tempo.py - - # E i banchi percettivi devono passare ANCHE sul render ritempificato. - # E' il vero controllo di regressione del tempo: se accorciando la - # scena il clic si scollasse dalla sua conseguenza, o un tempo finisse - # oltre l'ultimo fotogramma, si vedrebbe qui e non in montaggio. - ./scripts/click-gap.sh video/out/.fast-prompt-input.mp4 - ./scripts/beats.sh video/out/.fast-prompt-input.mp4 - ./scripts/contrast-floor.py video/out/.fast-prompt-input.mp4 - ./scripts/handoff-travel.sh video/out/.fast-card-handoff.mp4 - - # Il banco nuovo va messo alla prova sul caso che deve bocciare, non solo - # sul render che deve promuovere. Senza questo passo "verde" vorrebbe dire - # soltanto che lo script e' arrivato in fondo. - - name: il banco boccia il suo caso peggiore? - if: steps.cache.outputs.cache-hit != 'true' - working-directory: . + # pillow serve a loop-close.py per decodificare gli screenshot: e' l'unico + # banco che guarda dei pixel del browser invece del DOM. + - name: il browser dei banchi run: | - # framelocked-verdict.sh deve bocciare la sonda con Math.random dentro. - # Fino a settembre 2026 stampava il verdetto ed usciva 0 comunque, e - # una divergenza vera sarebbe passata col verde. - rc=0; ./scripts/framelocked-verdict.sh FrameLockedProbeRandom > /tmp/fv.log 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "framelocked-verdict.sh sulla sonda con Math.random: atteso 1, ottenuto $rc" >&2; cat /tmp/fv.log >&2; exit 1; } - echo "framelocked-verdict.sh boccia la sonda con Math.random, come deve." - - # drift.py deve bocciare i due guasti in TUTTE e sei le varianti, non in - # una: --must-fail esce 0 solo se ognuna esce dalla tolleranza. Senza lo - # spostamento il bersaglio parte gia' fuori posto; con l'origine - # sbagliata parte giusto e scappa mentre la camera avanza, che e' - # l'incidente raccontato dalla grammatica. - for guasto in '{"compensate": false}' '{"originMismatch": true}'; do - rc=0; ./scripts/drift.py --props "$guasto" --must-fail > /tmp/drift.log 2>&1 || rc=$? - [ "$rc" = 0 ] || { echo "drift.py con $guasto: almeno una variante promossa, o misura mancata (rc=$rc)" >&2; cat /tmp/drift.log >&2; exit 1; } - done - echo "drift.py boccia i due guasti in tutte e sei le varianti, come deve." - - # chain-check.py deve accorgersi della stessa catena senza easing, dove - # le giunte non sono piu' ferme e le inversioni diventano in moto. - rc=0; ./scripts/chain-check.py --linear --must-fail > /tmp/cc.log 2>&1 || rc=$? - [ "$rc" = 0 ] || { echo "chain-check.py promuove una catena che si inverte in moto (rc=$rc)" >&2; cat /tmp/cc.log >&2; exit 1; } - echo "chain-check.py boccia la catena senza easing, come deve." - - # fill-geom.py deve bocciare ogni scena con la camera arretrata: e' il - # difetto che fill-measure.sh sui pixel promuoveva. - rc=0; ./scripts/fill-geom.py --push-offset -1500 --must-fail > /tmp/fg.log 2>&1 || rc=$? - [ "$rc" = 0 ] || { echo "fill-geom.py promuove una lastra arretrata (rc=$rc)" >&2; cat /tmp/fg.log >&2; exit 1; } - echo "fill-geom.py boccia la lastra arretrata in ogni scena, come deve." - - ./scripts/fixture-screenshot.sh - if ./scripts/focus-sharpness.sh video/out/.fixture-card-focus-screenshot.mp4 >/dev/null 2>&1; then - echo "focus-sharpness.sh promuove una scena fatta di screenshot: non misura niente." >&2 - exit 1 - fi - echo "focus-sharpness.sh boccia la fixture, come deve." - - # rest-point.sh ha due modi di fallire e li deve distinguere: un - # ritaglio preso dal mezzo di una scena si muove ai bordi (esce 1), un - # fermo immagine non si muove da nessuna parte (esce 2). Se li - # confondesse, o li promuovesse, non misurerebbe niente. - # - # `cmd; rc=$?` QUI NON FUNZIONA. Questo passo gira con `bash -e`: il - # comando esce 1, che e' quello che vogliamo, e la shell se ne va prima - # di assegnare rc. Il controllo non veniva nemmeno eseguito e il passo - # falliva su un esito corretto. La forma `|| rc=$?` e' l'unica che - # sopravvive a -e, ed e' il motivo per cui il controllo di - # focus-sharpness qui sopra e' scritto come un `if`. - ffmpeg -nostdin -v error -ss 2 -t 2 -i video/out/card-focus.mp4 -y /tmp/mosso.mp4 - rc=0; ./scripts/rest-point.sh /tmp/mosso.mp4 > /tmp/rp1.log 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "rest-point.sh su un ritaglio mosso: atteso 1, ottenuto $rc" >&2; cat /tmp/rp1.log >&2; exit 1; } - - ffmpeg -nostdin -v error -i video/out/card-focus.mp4 -frames:v 1 -y /tmp/f.png - ffmpeg -nostdin -v error -loop 1 -i /tmp/f.png -t 3 -r 30 -pix_fmt yuv420p -y /tmp/fermo.mp4 - rc=0; ./scripts/rest-point.sh /tmp/fermo.mp4 > /tmp/rp2.log 2>&1 || rc=$? - [ "$rc" = 2 ] || { echo "rest-point.sh su un fermo immagine: atteso 2, ottenuto $rc" >&2; cat /tmp/rp2.log >&2; exit 1; } - echo "rest-point.sh distingue una scena mossa da un fermo immagine." - - # click-gap.sh su una scena senza clic non deve trovare la coppia - # colpo/conseguenza, e su un ritaglio in cui i fotogrammi fra i due - # sono stati tolti deve accorgersi che si sono fusi. - rc=0; ./scripts/click-gap.sh video/out/card-handoff.mp4 >/dev/null 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "click-gap.sh su card-handoff: atteso 1, ottenuto $rc" >&2; exit 1; } - - ffmpeg -nostdin -v error -i video/out/prompt-input.mp4 \ - -vf "select='lt(n\,271)+gte(n\,276)',setpts=N/FRAME_RATE/TB" \ - -fps_mode passthrough -y /tmp/fuso.mp4 - rc=0; ./scripts/click-gap.sh /tmp/fuso.mp4 > /tmp/cg.log 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "click-gap.sh sul ritaglio fuso: atteso 1, ottenuto $rc" >&2; cat /tmp/cg.log >&2; exit 1; } - echo "click-gap.sh boccia sia una scena senza clic sia i due eventi fusi." - - # beats.sh senza OCR deve dire che non ha potuto misurare, e non - # bocciare la scena. Senza questa distinzione ha passato mesi a - # riportare "la risposta non si vede mai" su una macchina dove - # tesseract non era installato: una diagnosi sulla scena per un - # guasto dello strumento, lo stesso difetto di handoff-travel.sh. - # Si toglie di mezzo il solo tesseract, non tutto il PATH: svuotando - # il PATH sparirebbe anche bash e il verdetto sarebbe 127, cioe' un - # altro guasto, e il controllo non proverebbe la distinzione voluta. - TESS="$(command -v tesseract)" - sudo mv "$TESS" "$TESS.off" - rc=0; ./scripts/beats.sh >/dev/null 2>&1 || rc=$? - sudo mv "$TESS.off" "$TESS" - [ "$rc" = 3 ] || { echo "beats.sh senza tesseract: atteso 3 (non ho potuto misurare), ottenuto $rc" >&2; exit 1; } - echo "beats.sh distingue una scena rotta da uno strumento assente." - - # contrast-floor.py deve bocciare la stessa scena attenuata troppo. - # E' l'unica prova che il pavimento a 0,62 sia un limite e non una - # decorazione: se anche 0,25 passasse, il banco non misurerebbe il - # pavimento, misurerebbe che il testo esiste. - ./scripts/fixture-attenuation.sh - rc=0; ./scripts/contrast-floor.py video/out/.fixture-attn-025.mp4 >/dev/null 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "contrast-floor.py sull'attenuazione a 0,25: atteso 1, ottenuto $rc" >&2; exit 1; } - echo "contrast-floor.py boccia l'attenuazione a 0,25, come deve." - - # tempo.py deve bocciare una scena TAGLIATA invece che ritempificata: - # e' esattamente quello che si otteneva abbassando la durata prima che - # le battute la seguissero, ed e' il difetto che il banco cerca. - ./scripts/fixture-trim.sh - rc=0; ./scripts/tempo.py video/out/card-handoff.mp4 video/out/.fixture-trim.mp4 >/dev/null 2>&1 || rc=$? - [ "$rc" = 1 ] || { echo "tempo.py sul ritaglio non ritempificato: atteso 1, ottenuto $rc" >&2; exit 1; } - echo "tempo.py boccia una scena tagliata invece che ritempificata, come deve." - - - # Da qui in poi i video sono buoni: o li ha appena fatti e misurati questa - # run, o vengono da una che li aveva gia' misurati. Solo adesso si salvano. - - name: salva i render verificati - if: steps.cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: video/out - key: ${{ steps.key.outputs.value }} - - - name: assembla - run: ./scripts/showcase-build.sh - - # La pagina del catalogo cambia a ogni push che tocca una demo, e non - # passa dalla cache dei render: questo passo gira sempre. Misura le - # dimostrazioni, non i video, ed e' l'unico controllo che vede le - # differenze temporali - due eventi sullo stesso frame invece che a - # cinque di distanza - che uno screenshot non puo' vedere. - - name: le dimostrazioni mostrano ancora la loro tesi? - run: | - # 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 + # Le dimostrazioni si misurano sul sorgente della pagina: showcase-build.sh + # la copia com'e' in showcase/dist, quindi il contenuto e' lo stesso, e questo + # job non deve aspettare i render per assemblarla. + - name: le dimostrazioni mostrano ancora la loro tesi? + run: | # 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. @@ -328,8 +113,8 @@ jobs: # dichiara. Se qui passasse, il verde qui sopra vorrebbe dire solo # che lo script e' arrivato in fondo. sed 's/var fn=second?30:35, fp=second?30:41;/var fn=35, fp=41;/' \ - showcase/dist/grammatica.html > /tmp/guasta.html - if cmp -s showcase/dist/grammatica.html /tmp/guasta.html; then + showcase/grammatica.html > /tmp/guasta.html + if cmp -s showcase/grammatica.html /tmp/guasta.html; then echo "la copia guasta e' identica all'originale: la sed non ha trovato niente." >&2 exit 1 fi @@ -347,8 +132,8 @@ jobs: # una sonda diversa: qui si rendono uguali le due soste di TYP-01, cioe' # si toglie il contrasto sul quale la voce si regge. sed 's/var DW = {A:56, B:35, R:22};/var DW = {A:56, B:56, R:22};/' \ - showcase/dist/grammatica.html > /tmp/guasta-typ.html - if cmp -s showcase/dist/grammatica.html /tmp/guasta-typ.html; then + showcase/grammatica.html > /tmp/guasta-typ.html + if cmp -s showcase/grammatica.html /tmp/guasta-typ.html; then echo "la copia guasta tipografica e' identica all'originale: la sed non ha trovato niente." >&2 exit 1 fi @@ -363,8 +148,8 @@ jobs: # 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 + showcase/grammatica.html > /tmp/guasta-loop.html + if cmp -s showcase/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 @@ -379,8 +164,8 @@ jobs: # a ripartire dal vuoto nella seconda meta', cioe' com'era prima # dell'orologio virtuale. E' il difetto che si vedeva scorrendo il sito. sed 's/var second=f>=125, q=vtime(second?f-125:f,125,86,14);/var second=f>=125, q=second?f-125:f;/' \ - showcase/dist/grammatica.html > /tmp/guasta-mezzo.html - if cmp -s showcase/dist/grammatica.html /tmp/guasta-mezzo.html; then + showcase/grammatica.html > /tmp/guasta-mezzo.html + if cmp -s showcase/grammatica.html /tmp/guasta-mezzo.html; then echo "la copia guasta di mezzo e' identica all'originale: la sed non ha trovato niente." >&2 exit 1 fi @@ -402,8 +187,8 @@ jobs: # nominando la voce e il motivo. Ognuna rimette un difetto che la # pagina ha avuto davvero. guasta_tipo() { # nome, sed, voce, larghezza, motivo atteso - sed "$2" showcase/dist/grammatica.html > "/tmp/guasta-$1.html" - if cmp -s showcase/dist/grammatica.html "/tmp/guasta-$1.html"; then + sed "$2" showcase/grammatica.html > "/tmp/guasta-$1.html" + if cmp -s showcase/grammatica.html "/tmp/guasta-$1.html"; then echo "la copia guasta $1 e' identica all'originale: la sed non ha trovato niente." >&2 exit 1 fi @@ -425,14 +210,132 @@ jobs: # col movimento ridotto, il fotogramma fermo a meta' di una dissolvenza guasta_tipo fermo 's/M.mask={dur:MK.D, half:MK.F1, still:60, kind:"type",/M.mask={dur:MK.D, half:MK.F1, still:114, kind:"type",/' TYP-05 1440 'movimento ridotto' + formato: + runs-on: ubuntu-latest + # Il limite che il piano chiede per job. La misura locale sta nel README. + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + ratio: ["16x9", "9x16", "4x5"] + + steps: + - uses: actions/checkout@v4 + + # LA CHIAVE CONTIENE IL RAPPORTO: tre job con la stessa chiave si + # scriverebbero a vicenda cartelle video/out diverse. + - name: chiave dei render + id: key + run: | + echo "value=renders-verificati-${{ matrix.ratio }}-${{ hashFiles('video/src/**', 'video/package-lock.json', 'video/remotion.config.ts', 'video/tsconfig.json', 'video/eslint.config.mjs', 'scripts/**') }}" >> "$GITHUB_OUTPUT" + + - name: render gia' verificati? + id: cache + uses: actions/cache/restore@v4 + with: + path: video/out + key: ${{ steps.key.outputs.value }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: video/package-lock.json + + # ImageMagick, ffmpeg e tesseract servono ai banchi, non al render. + # Chromium se lo porta Remotion. + - name: strumenti di misura + if: steps.cache.outputs.cache-hit != 'true' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq imagemagick ffmpeg tesseract-ocr + + - name: dipendenze + if: steps.cache.outputs.cache-hit != 'true' + working-directory: video + run: npm ci + + # Il typecheck non dipende dal rapporto: basta farlo una volta. + - name: typecheck e lint + if: steps.cache.outputs.cache-hit != 'true' && matrix.ratio == '16x9' + working-directory: video + run: npm run lint + + # L'elenco non e' scritto qui: le scene escono da catalog.json, i provini del + # tempo dalla sua voce tempoFixtures. I comandi si scrivono in un file e si + # eseguono con lo stdin chiuso invece di finire in `| sh`: con il pipe, il + # primo comando che legge lo stdin si mangia le righe successive (run + # 33404856145, "cripts/seam.sh: not found"). + - name: render + if: steps.cache.outputs.cache-hit != 'true' + working-directory: video + run: | + node ../scripts/catalog.mjs render --ratio ${{ matrix.ratio }} > /tmp/render.sh + sh -e /tmp/render.sh < /dev/null + ../scripts/fixture-tempo.sh --ratio ${{ matrix.ratio }} + + # I BANCHI E I LORO NEGATIVI, GENERATI. Stavano scritti qui a mano, novanta + # righe per il solo 16:9: un negativo dimenticato non rompeva niente, e il + # banco restava verde anche quando non poteva piu' fallire. Adesso ogni banco + # dichiara i suoi controlli in scripts/checks/, il manifest li calcola per il + # rapporto, expect.sh li esegue tutti senza fermarsi al primo errore, e + # bench-coverage.py boccia un banco che promuove senza un caso che boccia. + - name: i banchi, e i casi che devono bocciare + if: steps.cache.outputs.cache-hit != 'true' + run: | + node scripts/manifest.mjs checks --ratio ${{ matrix.ratio }} > /tmp/checks.tsv + ./scripts/expect.sh /tmp/checks.tsv video/out/bench-report-${{ matrix.ratio }}.json + ./scripts/bench-coverage.py video/out/bench-report-${{ matrix.ratio }}.json + + # E il controllo della copertura deve accorgersi di un banco senza + # negativo: si toglie dal report quello di seam e deve uscire 1. + rc=0; ./scripts/bench-coverage.py video/out/bench-report-${{ matrix.ratio }}.json --without seam > /tmp/cov.log 2>&1 || rc=$? + [ "$rc" = 1 ] || { echo "bench-coverage.py promuove un report senza il negativo di seam (rc=$rc)" >&2; cat /tmp/cov.log >&2; exit 1; } + grep -q 'seam' /tmp/cov.log || { echo "bench-coverage.py boccia ma non nomina seam" >&2; cat /tmp/cov.log >&2; exit 1; } + echo "bench-coverage.py boccia un report senza il negativo di seam, come deve." + + # Da qui in poi i video sono buoni: o li ha appena fatti e misurati questa + # run, o vengono da una che li aveva gia' misurati. Solo adesso si salvano. + - name: salva i render verificati + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: video/out + key: ${{ steps.key.outputs.value }} + # 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. + # cosi' su una PR si guarda il video invece di fidarsi del diff. Il job di + # deploy prende da qui quelli del 16:9. SEMPRE, anche se un banco boccia: la + # prima run di questo job e' fallita in CI e passava sul Mac, e senza i render + # della CI da scaricare la differenza si poteva solo indovinare. - uses: actions/upload-artifact@v4 + if: always() with: - name: renders - path: showcase/dist/*.mp4 + name: renders-${{ matrix.ratio }} + path: | + video/out/*.mp4 + video/out/bench-report-*.json retention-days: 7 + deploy: + needs: [pagina, formato] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/download-artifact@v4 + with: + name: renders-16x9 + path: video/out + + - name: assembla + run: ./scripts/showcase-build.sh + # Il deploy e' l'unico passo che ha bisogno di una credenziale, quindi e' # l'unico che puo' non essere disponibile. Senza il token il workflow # renderizza, misura e carica gli artefatti lo stesso: una CI che si diff --git a/README.md b/README.md index 61b379c..d8cd225 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ kinds of number in a scene. *Edit tempi* scale — when the hand arrives, how lo the travel runs, when the camera settles: those are rhythm decisions, and rhythm is exactly what you want to change. *Perceptual thresholds* do not. The four frames between a click and its consequence are not rhythm, they are the window -in which the eye ties a gesture to its effect — `click-gap.sh` measures that they +in which the eye ties a gesture to its effect — `click-gap.py` measures that they sit between 1 and 8, and at double speed they would be two, on the edge of disappearing. The three frames the card lags behind the hand are the weight of the object. The caret's fifteen-frame blink is a frequency, not a duration. @@ -361,7 +361,7 @@ One of those got the sign wrong first time round, and only measuring caught it. `cps` — the typing rate — is a *speed*, so it goes as the inverse of the factor: half the duration needs twice the characters per second. Written as a multiplication, a shorter scene got a *slower* typist, the send slid to 83 per -cent of the duration instead of 60, and `beats.sh` found the field still full +cent of the duration instead of 60, and `beats.py` found the field still full where it expected the placeholder. `tempo.py` proves the mechanism the only way it can be proved: if the beats @@ -374,7 +374,7 @@ retimed, which is exactly what lowering the duration produced before any of this, and there the normalised comparison is the wrong one. Every bench with a hardcoded frame number now follows the duration too — -`beats.sh`, `handoff-travel.sh`, `contrast-floor.py` — because a bench that +`beats.py`, `handoff-travel.py`, `contrast-floor.py` — because a bench that looks at frame 430 of a scene that is now 300 frames long is measuring a scene that no longer exists. They all pass on both the film and the retimed fixtures, and that is the real regression guard: retime a scene and the perceptual @@ -457,16 +457,17 @@ 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 +it with Chromium on 42 cases: two slabs, three stages, the seven poses of the +Topics chain in that stage's ratio, 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`, +snippet of their own (`handoff-travel.py`, `focus-sharpness.sh`, `fixture-screenshot.sh`, `contrast-floor.py`) now ask `manifest.mjs`, which -computes it once in `video/src/products/topics/benches.ts`. Their output is identical -line for line, and the screenshot fixture comes out with the same hash. +computes it in one module per bench under `video/src/products/topics/benches/` +(`node scripts/manifest.mjs bench --ratio `). `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. @@ -512,17 +513,101 @@ uncompensated point fell on the origin, the negative could not fail, and Everything that belongs to Topics now sits in one folder, `video/src/products/topics/`: the slab geometry (`geometry.ts`, which was `primitives/slab.ts`), the tokens (`tokens.ts`, which was `theme.ts`), the furniture, the material, the tracks, the -bench geometry (`benches.ts`) and the six scenes. What stays outside is what +bench geometry (`benches/`) and the six scenes. What stays outside is what another product can use as it is: `kit/`, the cursor, the frame-locked helpers. The move changed no number. `geometry-snapshot.mjs` gives the same 52 values as before the kit existed, and every still is identical to `main`. +## Three formats + +Every scene now exists in 16:9, 9:16 and 4:5. `catalog.json` lists the ratios, +`Root.tsx` registers a composition per scene and per ratio (`CardFocus`, +`CardFocus-9x16`, `CardFocus-4x5`), and a scene picks its camera from the size +of its own composition. The 16:9 keeps the ids, the slugs and every pixel: the +published videos are identical to `main`. The page still shows 16:9; the other +two ratios are in the run artifacts until someone has looked at them. + +The portrait poses are derived, not drawn. A 2:1 slab in a 9:16 frame shows a +vertical strip of the board, so each pose keeps the yaw and pitch of 16:9 and +the push ratio between one pose and the next, raises the zoom only as far as +covering the frame needs, and keeps its subject in frame: the column in +`UIMockup`, the delivered card in `CardHandoff`, the tag and title of that card +in `CardFocus`, the thread heading and the prompt in `PromptInput`. In 9:16 the +two columns the card flies between do not fit together, so the camera follows +the card during the drag. The numbers live in `products/topics/poses.ts`. +Measured on the tracks: the slab covers the frame on every frame from 20 per +cent of each scene with at least 2 px to spare (exactly 0 on `PromptInput`, by +construction, as in 16:9), and the camera never reverses while it moves. In 9:16 +a message 1180 px wide ran off the frame mid-line, so the thread now wraps at the +visible edge of the slab: 1180 in 16:9, 825 in 4:5, 468 in 9:16. + +`kit/film.ts` and `kit/SceneWindow.tsx` put the same scenes one after another in +a single composition, `TopicsFilm`, which is how the product films will be +built. Inside a `` a scene reads its own frame and its own duration, +so nothing in the scenes changed, and `film-identity.sh` checks it: the first, +middle and last frame of every window are the same PNG as the scene on its own, +and a window started one frame late is caught in every scene. + +### The benches, per ratio + +The benches no longer list their own negatives in the workflow. Each one declares +its checks in `scripts/checks/.mjs`, the manifest resolves them for a ratio +(`node scripts/manifest.mjs checks --ratio 9x16`), `expect.sh` runs every line +against its expected exit code without stopping at the first failure, and +`bench-coverage.py` fails any bench that passes something without a case that it +fails. The bench geometry for a ratio comes from +`video/src/products/topics/benches/.ts`; the scripts only read it through +the manifest. + +Moving the benches to portrait found faults that 16:9 had been hiding, and each +one was a bench that could pass without measuring: + +- `click-gap` counted changed pixels in the whole frame. In 9:16 and 4:5 the send + button is out of frame, the frames after the click were still, the quiet was + zero, and it passed a two-frame gap whatever the real one was. It now looks at + the composer text and the tail of the thread, projected per ratio: f271 and + f276 in all three, and the fused, slowed and missing clicks exit 1. +- `handoff-travel` measured on screen, where in 9:16 it measured the pan, and a + hard cut passed because half its samples fell before the grab. It now + rectifies every sample of the drag onto the slab with that frame's camera. The + first version of that let a freeze frame "cross" 208 px, because rectifying the + same image with two cameras moves it; the column headers now check the + rectification (zero changed pixels on real renders, 40 to 150 on a freeze). + The card crosses 170, 195 and 183 px of the slab. +- `focus-sharpness`, fed 16:9 geometry on a portrait render, cropped outside the + image and passed with 449159000x. It now checks the frame size and every crop, + and reads sharpness at half a pixel: real renders 2.81x, 1.79x and 2.02x + against 1.09x, 1.14x and 1.06x for the upscaled screenshot, threshold 1.35. +- `contrast-floor` exited 1 on an empty crop, blaming the attenuation floor for a + crop that had landed on background. It exits 3 now, and the fixture is one still + instead of 450 frames. 4.25:1, 4.54:1 and 4.51:1. +- `tempo` compared the top 480x270 bytes of each frame, which in 9:16 is the top + third. On the whole frame the correct 9:16 render read like a trimmed one, + because the card's three-frame lag (which does not scale, on purpose) weighs more + on a card twice the size. The scene now declares that window: outside it the fast + render matches exactly, inside it the residual has to be there. +- `seam` never exited 1: a broken join exited 2, "measurement useless". A reversed + pair exits 1 now in every ratio. The first CI run of this block then showed that + `seam` and `rest-point` did not count the same way on the Mac and in CI: + `compare -fuzz` on ImageMagick 6 found the reversed pair almost identical and + failed the start of `BoardOrbit` in 9:16, which ImageMagick 7 passed. Both now + count with ffmpeg (`_pixeldiff.sh`), which gives the same number in both places. +- `beats` gets its frames from the scene's timeline and leaves out the words the + thread already showed ("solo" was in both). A freeze frame and a covered thread + exit 1. + +Locally the checks take about three minutes per ratio on renders that already +exist. In CI each ratio is its own job (render, fixtures, checks, coverage): the +first run took 14, 15 and 20 minutes against a limit of 25, with the page checks +in a fourth job of 5 minutes from the start, and the deploy waiting for all of +them. + ## Layout | | | |---|---| | `video/` | The Remotion project. The product-independent kit in `video/src/kit/`, one folder per product in `video/src/products/` (`topics/` holds the slab, its tokens, tracks, bench geometry and the six scenes; `probe/` the synthetic slab), shared primitives such as the cursor and the frame-locked helpers in `video/src/primitives/`, the catalogue in `video/src/scenes/catalog.json`, bench specimens in `video/src/specimens/` | -| `scripts/` | The measurements, the review page, the showcase build, and `catalog.mjs`, which is how shell and CI read `catalog.json` without a compiler. See below | +| `scripts/` | The measurements, the review page, the showcase build, and `catalog.mjs`, which is how shell and CI read `catalog.json` without a compiler. `scripts/checks/` holds each bench's checks and negatives, which `manifest.mjs checks` resolves per ratio and `expect.sh` runs. 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 | | `ref/` | Reference commercials and their contact sheets. **Not in git**, see below | @@ -539,34 +624,38 @@ npx remotion render PromptInput out/prompt-input.mp4 # from video/ ./scripts/contact-sheet.sh out/prompt-input-vs-ref.png # composition, frozen ./scripts/review-page.sh # composition + rhythm, moving -./scripts/beats.sh # are all four beats on screen +./scripts/beats.py --ratio R # are all four beats on screen ./scripts/fill-measure.sh video/out/prompt-input.mp4 # border readout only, it cannot fail (see below) ./scripts/legibility.sh # down to what size it reads ./scripts/framelocked-verdict.sh [Composition ...] # is it really frame-locked ./scripts/seam.sh [A.mp4] [B.mp4] # is the join really cutless -./scripts/handoff-travel.sh # does the card actually cross -./scripts/focus-sharpness.sh # does the text survive the push-in -./scripts/rest-point.sh [scene.mp4] # is the scene still at both edges -./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/handoff-travel.py --ratio R # does the card actually cross, measured on the slab +./scripts/focus-sharpness.sh --ratio R # does the text survive the push-in +./scripts/rest-point.sh [--ratio R] [scene.mp4] # is the scene still at both edges +./scripts/click-gap.py --ratio R # does the UI answer the click, or fire with it +./scripts/fixture-screenshot.sh --ratio R # build the scene focus-sharpness must fail +./scripts/film-identity.sh [--ratio R] [--offset N] # is a film frame the same frame as the scene on its own ./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/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/contrast-floor.py --ratio R # is the attenuated content still readable +./scripts/drift.py [--ratio R] [--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/chain-check.py [--ratio R] [--linear] [--must-fail] # does the camera ever reverse while it moves +./scripts/fill-geom.py [--ratio R] [--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/manifest.mjs bench --ratio R # what a bench must find, from the product +node scripts/manifest.mjs checks --ratio R > checks.tsv # every bench and its negatives for a ratio +./scripts/expect.sh checks.tsv report.json # run them all, each against its expected exit code +./scripts/bench-coverage.py report.json # does every bench have a negative that fails node scripts/geometry-snapshot.mjs [geometry.ts] # the geometry as a string, to prove a refactor left it alone -./scripts/tempo.py [long.mp4 short.mp4] # does shortening a scene retime it or just trim it -./scripts/fixture-tempo.sh # render the two retimed fixtures -./scripts/fixture-trim.sh # build the trimmed scene tempo.py must fail -./scripts/fixture-attenuation.sh # build the scene contrast-floor must fail +./scripts/tempo.py long.mp4 short.mp4 [--percettive a-b] # does shortening a scene retime it or just trim it +./scripts/fixture-tempo.sh [--ratio R] # render the retimed fixtures of a ratio +./scripts/fixture-trim.sh # build the trimmed scene tempo.py must fail +./scripts/fixture-attenuation.sh # build the frame contrast-floor must fail ./scripts/showcase-build.sh # assemble showcase/dist for deploy -node scripts/catalog.mjs render # the render command for every scene +node scripts/catalog.mjs render [--ratio R] # the render command for every scene node scripts/catalog.mjs measures # the benches the catalogue implies ``` @@ -576,7 +665,7 @@ node scripts/catalog.mjs measures # the benches the catal to a file to redo every scene from scratch — not straight into `sh`, because a bench that reads standard input will eat the lines it has not run yet. -`beats.sh` is the one that earns its keep. `prompt-input` promises four beats: +`beats.py` is the one that earns its keep. `prompt-input` promises four beats: the cursor arrives, types, sends, and the answer streams in. For a full day the render delivered three. The thread is anchored to the bottom and ran to y=922, the composer is opaque and starts at y=838, so the newest message sat @@ -737,12 +826,12 @@ small fraction of the pixels, the 97th percentile is still measuring background, and the same scene read 2.59:1 or 4.17:1 depending on how much text happened to fall inside the rectangle. -`beats.sh` was not running anywhere, and had not been for months. It is not in +`beats.py` was not running anywhere, and had not been for months. It is not in the workflow's measurement step, and on the machine these scenes are written on `tesseract` was never installed — so every OCR read came back empty, every count came back zero, and the verdict came back "the answer is never visible". A diagnosis about the scene for a missing tool, which is the same shape as the -`handoff-travel.sh` failure below and the reason that one is described at +`handoff-travel.py` failure below and the reason that one is described at length. It now refuses to give a verdict without the OCR: exit 3 means it could not measure, which is a different thing from exit 1, which means it measured and the scene is wrong. It runs in CI, and CI proves both halves — that it passes @@ -750,7 +839,7 @@ with `tesseract` present, and that it exits 3 with only `tesseract` taken away. Taking the whole `PATH` away instead would have proved nothing: the script would have died at 127 for want of a shell. -`click-gap.sh` was repaired after it reported a click, with confidence, ninety +`click-gap.py` was repaired after it reported a click, with confidence, ninety frames before the real one. Its rule was "the hit is the first frame whose changed-pixel count is at least five times the window median", which held while the camera drifted through the whole scene. Once the camera settles before the @@ -771,7 +860,7 @@ tell a locked shot from a freeze frame. That check now runs only against scenes that promised something. A file passed as an argument still counts as a promise, which is what keeps the freeze-frame control failing. -`handoff-travel.sh` needed a crop it did not need before, and this is the cost of +`handoff-travel.py` needed a crop it did not need before, and this is the cost of putting the assistant on the slab. It isolates the travelling card by diffing whole frames, which worked while the bottom half of the slab was empty. With a thread down there — static relative to the slab, but moving with the camera like @@ -781,14 +870,14 @@ for a change in the instrument's surroundings. It now diffs only the board's hal of the frame, and where that half ends is read from `topics/geometry.ts` rather than picked by eye, so it follows if the assistant moves. -`handoff-travel.sh` had never run on macOS. Its centroid step was a heredoc +`handoff-travel.py` had never run on macOS. Its centroid step was a heredoc inside a process substitution, which bash 3.2 cannot parse, so the script died before the first sample and printed "the card does not travel": a diagnosis about the scene for a fault in the equipment. CI runs bash 5 and went green, which is -why it stayed invisible from one side and total from the other. The Python now -lives in `scripts/_centroid.py`. +why it stayed invisible from one side and total from the other. Since block 6 +the whole bench is Python, and the question is gone with the heredoc. -`handoff-travel.sh` checks the thing none of the others look at: whether the +`handoff-travel.py` checks the thing none of the others look at: whether the gesture happens. A scene where the card never moves passes `seam.sh` and `fill-measure.sh` with full marks, because a freeze frame has a perfect join and live edges. So this one tracks the centroid of changed pixels across the travel @@ -876,7 +965,9 @@ The order matters, and step 4 is the one people skip. `CARD_HANDOFF_END_POSE` spent three scenes as three literals inside `CardHandoff.tsx`, which was fine exactly as long as nothing came after it. 1. **Write it in `video/src/products//scenes/`.** Take `progress?: number` and derive - everything else from `useCurrentFrame()`. If you reach for `Date.now()`, + everything else from `useCurrentFrame()`; read the ratio with + `stageFor(width, height)` from `useVideoConfig()` and take the poses of that + ratio. If you reach for `Date.now()`, `Math.random()` or a CSS keyframe, the scene is no longer reproducible and `framelocked-verdict.sh` will say so. 2. **Reuse the kit and the product folder.** `kit/Shot.tsx` draws the slab, @@ -887,14 +978,16 @@ The order matters, and step 4 is the one people skip. the blurb for the page, and `seamAfter` if it follows another scene — then the one line in `COMPONENTS` in `Root.tsx` that binds the id to the import. That entry is what the render step, the generic benches and the showcase - page all read: there is no second list to keep in sync. Leave out the + page all read, in every ratio the catalogue lists: there is no second list to + keep in sync. Leave out the `COMPONENTS` line and the project refuses to load and says which id is unbound, which is the one failure mode a JSON file cannot cover on its own. 4. **Give it a check that can fail.** Every scene here has one bench that - fails when the scene's own promise is broken: `beats.sh` for the four beats, - `handoff-travel.sh` for the card crossing. Write the negative control first, - confirm it exits non-zero on a broken input, and only then trust the pass. - `npm run lint` proves nothing about a video. + fails when the scene's own promise is broken: `beats.py` for the four beats, + `handoff-travel.py` for the card crossing. Declare its checks in + `scripts/checks/.mjs`, write the negative first, and run + `expect.sh` on the three ratios before trusting the pass; `bench-coverage.py` + will refuse a bench without one. `npm run lint` proves nothing about a video. 5. **Push.** CI renders, measures, deploys — the scene is on the site without any of those three files being touched. Rendering only happens when it can change something: the renders are cached under a key made of the scene diff --git a/scripts/_centroid.py b/scripts/_centroid.py deleted file mode 100755 index 9a9dbf8..0000000 --- a/scripts/_centroid.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -""" -Il centroide dei pixel accesi di una maschera, e quanti sono. - -Stava dentro `handoff-travel.sh` come heredoc dentro una process substitution, -`read -r cx n < <( python3 - "$f" <<'PY' ... )`. Bash 3.2, che e' quello che -`/usr/bin/env bash` trova su macOS, non riesce a leggere quella forma: cerca la -parentesi di chiusura prima di consumare il documento, non la trova e muore con -"bad substitution". Lo script non partiva affatto, e il messaggio finale che -stampava era "la card non viaggia", cioe' una diagnosi sulla scena per un guasto -dell'attrezzatura. In CI gira bash 5 e la stessa riga passa, quindi il difetto -era invisibile da un lato e totale dall'altro. - -Sta in un file anche perche' e' la prima misura condivisa: seam.sh conta i -pixel di una maschera, questo ne prende il baricentro, e prima o poi le due -cose devono avere una definizione sola di "pixel cambiato". - -Uso: _centroid.py maschera.png -> " " (oppure "-1 0") -""" -import os -import subprocess -import sys - -img = sys.argv[1] -convert = os.environ.get("IM_CONVERT_CMD", "magick").split() -identify = os.environ.get("IM_IDENTIFY_CMD", "magick identify").split() - -raw = subprocess.run( - convert + [img, "-depth", "8", "gray:-"], capture_output=True, check=True -).stdout - -# `identify -format %w file` e basta: la 7 tollera anche un "info:" in coda, la -# 6 no e esce 1. La forma senza suffisso funziona su entrambe. -w = int( - subprocess.run( - identify + ["-format", "%w", img], capture_output=True, text=True, check=True - ).stdout.strip() -) - -tot = sx = 0 -for i, v in enumerate(raw): - if v > 127: - tot += 1 - sx += i % w - -print(f"{sx / tot:.1f} {tot}" if tot else "-1 0") diff --git a/scripts/_pixeldiff.sh b/scripts/_pixeldiff.sh new file mode 100755 index 0000000..3d25cdf --- /dev/null +++ b/scripts/_pixeldiff.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# Quanti pixel differiscono fra due immagini, in frazione e in numero. +# +# PERCHE' NON IMAGEMAGICK. seam.sh e rest-point.sh contavano con `compare +# -metric AE -fuzz 4%`, e la stessa coppia di immagini dava numeri diversi su +# ImageMagick 7 (macOS) e 6 (Linux della CI): fino a sei volte di piu' su una +# giunta, e su un cambiamento a bassa ampiezza (l'opacita' che risale all'inizio +# di BoardOrbit) abbastanza da far bocciare in CI una scena che sul Mac passava. +# Le soglie dei banchi erano tarate su una piattaforma e decidevano sull'altra. +# ffmpeg invece fa lo stesso conto nei due posti: grigio, differenza assoluta, +# soglia per pixel, media. +# +# Uso: _pixeldiff.sh a.png b.png [soglia 0-255] -> " " +# +# Esce 3 se ffmpeg non legge le immagini o se hanno dimensioni diverse. +set -uo pipefail + +A="${1:?serve la prima immagine}" +B="${2:?serve la seconda immagine}" +SOGLIA="${3:-20}" + +dims() { ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$1" | cut -d, -f1,2; } +da=$(dims "$A"); db=$(dims "$B") +if [ -z "$da" ] || [ "$da" != "$db" ]; then + echo "immagini non confrontabili: '$da' contro '$db'" >&2 + exit 3 +fi + +yavg=$(ffmpeg -nostdin -v error -i "$A" -i "$B" -filter_complex \ + "[0:v]format=gray[a];[1:v]format=gray[b];[a][b]blend=all_mode=difference,lut=c0='if(gt(val\,$SOGLIA)\,255\,0)',signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" \ + -f null - 2>/dev/null | sed -n 's/.*YAVG=\([0-9.]*\).*/\1/p' | head -n 1) +case "${yavg:-}" in + ''|*[!0-9.]*) echo "confronto fallito su $(basename "$A") e $(basename "$B")" >&2; exit 3 ;; +esac + +w=${da%,*}; h=${da#*,} +python3 -c "f = $yavg / 255; print(f'{f:.6f} {round(f * $w * $h)}')" diff --git a/scripts/beats.py b/scripts/beats.py new file mode 100755 index 0000000..906a753 --- /dev/null +++ b/scripts/beats.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""I quattro tempi di PromptInput, verificati sul file finito. + +PERCHE' ESISTE. La scena chiede quattro cose: il cursore entra, digita, invia, +la risposta arriva in streaming. Il render ne ha mostrate tre per un giorno +intero senza che niente protestasse. Il thread e' ancorato in basso e il +composer e' opaco: l'ultimo messaggio finiva sotto, quindi la risposta si +componeva parola per parola dove nessuno poteva vederla. Il codice era giusto, +il layout no, e nessun typecheck vede una cosa del genere. La vede solo +qualcuno che legge il fotogramma. + +COME. Si leggono i fotogrammi con l'OCR e si contano le parole attese. La +condizione non e' "ce ne sono": e' che siano di PIU' ogni volta, nella battitura +e nello streaming. Una soglia fissa la passerebbe anche un fermo immagine con la +risposta gia' stampata; la crescita no, quella la puo' produrre solo un testo +che si compone. + +COSA ARRIVA DAL MANIFEST (`bench beats --ratio R`): i fotogrammi, calcolati +dalla recita della scena alla sua durata; le parole da cercare, tolte quelle che +il thread mostrava gia'; la zona del quadro da leggere, cioe' thread e campo alla +posa ferma del rapporto. Prima erano sei frame e due elenchi di parole copiati +dalla scena, e l'OCR leggeva il quadro intero, board compresa. + +COSA LO FA FALLIRE, che e' la sola cosa che rende un banco un banco. Un fermo +immagine della fine: la battitura non cresce e lo streaming nemmeno. La coda del +thread coperta, cioe' la regressione del composer sopra i messaggi: il messaggio +inviato e la risposta non si vedono. Tutti e due escono 1. + +SENZA L'OCR QUESTO BANCO NON MISURA NIENTE, e deve dirlo invece di bocciare: con +tesseract assente ogni lettura torna vuota, ogni conteggio zero, e il verdetto +diventerebbe "la risposta non si vede mai", una diagnosi sulla scena per un +guasto dello strumento. Esce 3. Il comando si puo' cambiare con TESSERACT, ed e' +cosi' che la CI prova l'uscita 3 senza spostare il binario con sudo. + +Uso: ./scripts/beats.py --ratio R [--scene PromptInput|PromptInputFast] + +Esce 0 se i quattro tempi si vedono, 1 se ne manca uno, 3 se mancano il file, +l'OCR o il manifest, o se quadro e durata non sono quelli del rapporto. +""" +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ap = argparse.ArgumentParser() +ap.add_argument("src") +ap.add_argument("--ratio", default="16x9") +ap.add_argument("--scene", default="PromptInput") +args = ap.parse_args() +SRC = pathlib.Path(args.src) +TESSERACT = os.environ.get("TESSERACT", "tesseract") + +if not shutil.which(TESSERACT): + print("manca l'OCR (%s): senza, questo banco non puo' leggere niente." % TESSERACT, file=sys.stderr) + print(" macOS: brew install tesseract", file=sys.stderr) + print(" debian: sudo apt-get install -y tesseract-ocr", file=sys.stderr) + raise SystemExit(3) +if not SRC.exists(): + print("manca il render: %s" % SRC, file=sys.stderr) + raise SystemExit(3) + +geo = subprocess.run( + ["node", str(ROOT / "scripts/manifest.mjs"), "bench", "beats", "--ratio", args.ratio], + capture_output=True, text=True, +) +if geo.returncode != 0: + print("la geometria non e' arrivata dal manifest:\n" + geo.stderr, file=sys.stderr) + raise SystemExit(3) +g = json.loads(geo.stdout) +v = next((x for x in g["variants"] if x["id"] == args.scene), None) +if v is None: + print("il manifest non conosce la scena %s" % args.scene, file=sys.stderr) + raise SystemExit(3) + +out = subprocess.run( + ["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", + "-show_entries", "stream=width,height,nb_read_frames", "-of", "csv=p=0", str(SRC)], + capture_output=True, text=True, +).stdout.strip() +w, h, n = ([x for x in out.split(",") if x] + ["", "", ""])[:3] +if [w, h] != [str(g["stage"]["w"]), str(g["stage"]["h"])]: + print("il quadro di %s e' %sx%s, lo stage di %s e' %dx%d: geometria di un altro rapporto" + % (SRC.name, w, h, args.ratio, g["stage"]["w"], g["stage"]["h"]), file=sys.stderr) + raise SystemExit(3) +if n != str(v["durationInFrames"]): + print("%s ha %s fotogrammi, %s ne ha %d: non e' il render di quella scena" + % (SRC.name, n, args.scene, v["durationInFrames"]), file=sys.stderr) + raise SystemExit(3) + +# Non sotto /tmp su macOS: e' un symlink e tesseract non lo segue. La cartella e' +# privata di questa esecuzione, quindi due rapporti in parallelo non si +# cancellano i fotogrammi a vicenda come faceva la vecchia out/.beats. +WORK = pathlib.Path(tempfile.mkdtemp(prefix=".beats-", dir=ROOT / "video" / "out" + if (ROOT / "video" / "out").is_dir() else None)) +IM = ["magick"] if shutil.which("magick") else ["convert"] +r = v["read"] + + +def ocr_at(frame): + png = WORK / ("f%d.png" % frame) + gray = WORK / ("g%d.png" % frame) + subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-i", str(SRC), + "-vf", "select=eq(n\\,%d),crop=%d:%d:%d:%d" % (frame, r["w"], r["h"], r["x"], r["y"]), + "-frames:v", "1", "-y", str(png)], + capture_output=True, + ) + if not png.exists() or png.stat().st_size == 0: + print("estrazione del fotogramma %d fallita su %s" % (frame, SRC), file=sys.stderr) + raise SystemExit(3) + subprocess.run(IM + [str(png), "-colorspace", "Gray", "-normalize", str(gray)], capture_output=True) + res = subprocess.run([TESSERACT, str(gray), "stdout", "--psm", "6"], capture_output=True, text=True) + return res.stdout.lower() + + +def hits(text, wanted): + return sum(1 for word in wanted if word in text) + + +try: + f = v["frames"] + fail = [] + print("I quattro tempi di %s (%s), letti nella zona %dx%d+%d+%d." % (SRC.name, args.ratio, r["w"], r["h"], r["x"], r["y"])) + print() + + t1 = hits(ocr_at(f["type1"]), g["promptWords"]) + t2 = hits(ocr_at(f["type2"]), g["promptWords"]) + ok = t2 > t1 + print(" digita f%d: %d parole f%d: %d parole %s" % (f["type1"], t1, f["type2"], t2, "ok" if ok else "FERMO")) + if not ok: + fail.append("digita") + + sent = ocr_at(f["sent"]) + t3 = hits(sent, g["promptWords"]) + ok = t3 >= len(g["promptWords"]) - 1 + print(" invia f%d: %d parole del prompt in quadro %s" % (f["sent"], t3, "ok" if ok else "IL MESSAGGIO NON C'E'")) + if not ok: + fail.append("invia") + ok = "chiedi qualcosa" in sent + print(" il campo %s" % ("e' tornato al segnaposto ok" if ok else "NON si e' svuotato")) + if not ok: + fail.append("campo") + + s = [hits(ocr_at(x), g["responseWords"]) for x in f["stream"]] + if s[2] > s[1] > s[0]: + esito = "ok" + elif s[2] == 0: + esito = "LA RISPOSTA NON SI VEDE MAI" + fail.append("streaming") + else: + esito = "NON CRESCE" + fail.append("streaming") + print(" streaming %s %s" % (" ".join("f%d: %d" % (x, y) for x, y in zip(f["stream"], s)), esito)) + print() +finally: + shutil.rmtree(WORK, ignore_errors=True) + +if fail: + print("VERDETTO: manca un tempo (%s). Vedi sopra quale." % ", ".join(fail)) + raise SystemExit(1) +print("VERDETTO: tutti e quattro i tempi si vedono nel render.") diff --git a/scripts/beats.sh b/scripts/beats.sh deleted file mode 100755 index e2ff713..0000000 --- a/scripts/beats.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# -# I quattro tempi della scena, verificati sul file finito. -# -# PERCHE' ESISTE. La scena chiede quattro cose: il cursore entra, digita, -# invia, la risposta arriva in streaming. Il render ne ha mostrate tre per un -# giorno intero senza che niente protestasse. Il thread e' ancorato in basso e -# arriva a 922, il composer e' opaco e comincia a 838: l'ultimo messaggio -# finiva sotto, quindi la risposta si componeva parola per parola dove nessuno -# poteva vederla. Il codice era giusto, il layout no, e nessun typecheck vede -# una cosa del genere. La vede solo qualcuno che legge il fotogramma. -# -# COME. Si leggono i fotogrammi con l'OCR e si contano le parole attese in tre -# momenti dello streaming. La condizione non e' "ce ne sono": e' che siano di -# PIU' ogni volta. Una soglia fissa la passerebbe anche un fermo immagine con -# la risposta gia' stampata; la crescita no, quella la puo' produrre solo un -# testo che si compone. -# -# COSA LO FA FALLIRE, che e' la sola cosa che rende un banco un banco. La -# regressione del composer sopra fa restare il conteggio a zero in tutti e tre -# i momenti. Un testo che appare tutto insieme fa tre conteggi uguali. Una -# scena che finisce troppo presto fa scendere l'ultimo. -# -# Uso: ./scripts/beats.sh [video.mp4] -set -uo pipefail - -# ImageMagick si chiama `magick` sulla 7 e `convert`/`compare` sulla 6. -. "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SRC="${1:-$ROOT/video/out/prompt-input.mp4}" - -[ -f "$SRC" ] || { echo "manca: $SRC" >&2; exit 1; } - -# Non sotto /tmp: e' un symlink e tesseract non lo segue. -WORK="$ROOT/out/.beats" -rm -rf "$WORK"; mkdir -p "$WORK" -trap 'rm -rf "$WORK"' EXIT - -# SENZA L'OCR QUESTO BANCO NON MISURA NIENTE, e deve dirlo invece di bocciare. -# Con tesseract assente ogni lettura torna vuota, ogni conteggio torna zero, e -# il verdetto diventa "la risposta non si vede mai": una diagnosi sulla scena -# per un guasto dello strumento. E' lo stesso difetto per cui handoff-travel.sh -# ha accusato CardHandoff per mesi su macOS. Esce 3, che vuol dire "non ho -# potuto misurare", e non 1, che vorrebbe dire "ho misurato e non va". -command -v tesseract >/dev/null 2>&1 || { - echo "manca tesseract: senza OCR questo banco non puo' leggere niente." >&2 - echo " macOS: brew install tesseract" >&2 - echo " debian: sudo apt-get install -y tesseract-ocr" >&2 - exit 3 -} - -FPS=30 -FAIL=0 - -# I FOTOGRAMMI DA GUARDARE SI SCALANO CON LA DURATA, e questo pezzo e' nato da -# un fallimento: sul provino della scena a 300 fotogrammi invece di 450 il banco -# cercava la battitura a f170 e lo streaming a f430, cioe' fuori dalla scena, e -# concludeva che i tempi non c'erano. Da quando le battute interne scalano con -# `durationInFrames` (vedi primitives/tempo.ts) un banco con i frame scritti a -# mano misura una scena che non esiste piu'. -BASE=450 -N=$(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 "$SRC" | tr -dc '0-9') -case "${N:-}" in ''|*[!0-9]*) echo "non riesco a contare i fotogrammi di $SRC" >&2; exit 3 ;; esac -sc() { python3 -c "print(round($1 * $N / $BASE))"; } -F_TYPE1=$(sc 170); F_TYPE2=$(sc 210); F_SENT=$(sc 300) -F_S1=$(sc 340); F_S2=$(sc 385); F_S3=$(sc 430) - -# Le parole della risposta che l'OCR regge bene: niente punteggiatura attaccata, -# niente parole di tre lettere che l'OCR pesca ovunque. -RESPONSE_WORDS="trovati punti chiamata server sposto refresh token dentro guard solo" - -# Il prompt, che deve comparire prima nel campo e poi come messaggio inviato. -PROMPT_WORDS="rifai flusso auth apri" - -ocr_at() { - local frame="$1" - local t - t=$(python3 -c "print(f'{$frame / $FPS:.4f}')") - ffmpeg -v error -ss "$t" -i "$SRC" -frames:v 1 -y "$WORK/f.png" 2>/dev/null - "${IM_CONVERT[@]}" "$WORK/f.png" -colorspace Gray -normalize "$WORK/g.png" - tesseract "$WORK/g.png" stdout --psm 6 2>/dev/null | tr '[:upper:]' '[:lower:]' -} - -# Quante delle parole cercate compaiono nel testo letto. -hits() { - local text="$1" wanted="$2" n=0 - for w in $wanted; do - case "$text" in *"$w"*) n=$((n + 1));; esac - done - echo "$n" -} - -echo "I quattro tempi di $(basename "$SRC")" -echo - -# TEMPO 1 e 2: il campo si riempie. Al primo campione il prompt e' cominciato -# ma non finito, al secondo c'e' tutto: se i due conteggi coincidono, non sta -# digitando. -t1=$(hits "$(ocr_at "$F_TYPE1")" "$PROMPT_WORDS") -t2=$(hits "$(ocr_at "$F_TYPE2")" "$PROMPT_WORDS") -printf ' digita frame %s: %s parole frame %s: %s parole' "$F_TYPE1" "$t1" "$F_TYPE2" "$t2" -if [ "$t2" -gt "$t1" ]; then echo " ok"; else echo " FERMO"; FAIL=1; fi - -# TEMPO 3: l'invio. Dopo il click il prompt e' ancora in quadro, ma come -# messaggio, e il campo torna al segnaposto. -sent=$(ocr_at "$F_SENT") -t3=$(hits "$sent" "$PROMPT_WORDS") -printf ' invia frame %s: %s parole del prompt in quadro' "$F_SENT" "$t3" -if [ "$t3" -ge 3 ]; then echo " ok"; else echo " IL MESSAGGIO NON C'E'"; FAIL=1; fi -case "$sent" in - *"chiedi qualcosa"*) echo " il campo e' tornato al segnaposto ok";; - *) echo " il campo NON si e' svuotato sospetto"; FAIL=1;; -esac - -# TEMPO 4: lo streaming. Tre momenti, e devono crescere. -s1=$(hits "$(ocr_at "$F_S1")" "$RESPONSE_WORDS") -s2=$(hits "$(ocr_at "$F_S2")" "$RESPONSE_WORDS") -s3=$(hits "$(ocr_at "$F_S3")" "$RESPONSE_WORDS") -printf ' streaming frame %s: %s frame %s: %s frame %s: %s' "$F_S1" "$s1" "$F_S2" "$s2" "$F_S3" "$s3" -if [ "$s3" -gt "$s2" ] && [ "$s2" -gt "$s1" ]; then - echo " ok" -elif [ "$s3" -eq 0 ]; then - echo " LA RISPOSTA NON SI VEDE MAI"; FAIL=1 -else - echo " NON CRESCE"; FAIL=1 -fi - -echo -if [ "$FAIL" -eq 0 ]; then - echo "VERDETTO: tutti e quattro i tempi si vedono nel render." -else - echo "VERDETTO: un tempo manca. Vedi sopra quale." -fi -exit "$FAIL" diff --git a/scripts/bench-coverage.py b/scripts/bench-coverage.py new file mode 100755 index 0000000..cde261d --- /dev/null +++ b/scripts/bench-coverage.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Ogni banco che promuove un render ha anche un caso che boccia, e lo boccia? + +PERCHE' ESISTE. Un banco verde senza un negativo accanto dice solo che lo script +e' arrivato in fondo. Il repo lo ha scoperto due volte: framelocked-verdict.sh +stampava il verdetto e usciva 0 comunque, fill-measure.sh promuoveva una lastra +arretrata. In entrambi i casi il negativo mancava, e mancava in silenzio. Qui +il silenzio diventa un errore. + +COSA CONTROLLA, sul report di expect.sh: per ogni banco che ha almeno un +positivo, esiste almeno un negativo dello stesso banco uscito col codice +atteso. Il codice atteso di un negativo puo' essere 0 quando il banco ha un +`--must-fail`, che rovescia il verdetto ed esce 0 solo se il difetto e' stato +visto: conta il codice dichiarato, non il numero. Stampa la tabella banco per +banco. + +IL NEGATIVO DI QUESTO BANCO: `--without ` toglie dal report i negativi di +quel banco, cioe' simula un elenco di controlli in cui qualcuno l'ha +dimenticato. Il banco deve uscire 1 e nominarlo. + +Uso: bench-coverage.py [--without BANCO] + +Esce 0 se ogni banco con un positivo ha un negativo passato, 1 altrimenti, 3 se +il report manca o non contiene positivi. +""" +import argparse +import json +import sys + +ap = argparse.ArgumentParser() +ap.add_argument("report") +ap.add_argument("--without", action="append", default=[]) +args = ap.parse_args() + +try: + checks = json.load(open(args.report))["checks"] +except (OSError, ValueError, KeyError) as e: + print(f"report illeggibile: {e}", file=sys.stderr) + sys.exit(3) + +checks = [c for c in checks if not (c["role"] == "negativo" and c["bench"] in args.without)] +positives = {} +negatives = {} +for c in checks: + if c["role"] == "positivo": + positives.setdefault(c["bench"], []).append(c) + elif c["role"] == "negativo": + negatives.setdefault(c["bench"], []).append(c) + +if not positives: + print("nessun positivo nel report: non c'e' niente da coprire", file=sys.stderr) + sys.exit(3) + +scoperti = [] +print("Banco per banco: positivi passati, negativi usciti col codice atteso.") +for bench in sorted(positives): + pos = positives[bench] + neg = [n for n in negatives.get(bench, []) if n["ok"]] + esito = "coperto" if neg else "SCOPERTO" + if not neg: + scoperti.append(bench) + codes = ",".join(sorted({str(n["expected"]) for n in neg})) or "-" + print(f" {bench:22} positivi {sum(p['ok'] for p in pos)}/{len(pos)} " + f"negativi {len(neg)} (rc {codes}) {esito}") + +if scoperti: + print(f"VERDETTO: {len(scoperti)} banchi promuovono senza un caso che boccia: {', '.join(scoperti)}.") + sys.exit(1) +print(f"VERDETTO: tutti i {len(positives)} banchi hanno un negativo che esce col suo codice.") +sys.exit(0) diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index 06150ae..d767d93 100755 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -11,14 +11,46 @@ // node scripts/catalog.mjs slugs i nomi dei file .mp4, senza estensione // node scripts/catalog.mjs measures i comandi dei banchi generici // node scripts/catalog.mjs html la sezione
intera +// node scripts/catalog.mjs ratios i rapporti in cui escono le scene +// node scripts/catalog.mjs fixtures i render dei provini del tempo +// +// render, fixtures, slugs, ids, rest e measures accettano `--ratio 9x16` (o 4x5): stessi +// comandi per le varianti di quel rapporto. Senza, il 16:9, con gli id e gli +// slug di sempre. La pagina resta sul 16:9. import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const { scenes } = JSON.parse( +const catalog = JSON.parse( readFileSync(join(root, "video/src/scenes/catalog.json"), "utf8"), ); +const { STAGES, variantName } = await import( + pathToFileURL(join(root, "video/src/kit/stage.ts")).href +); + +const ratioArg = (() => { + const i = process.argv.indexOf("--ratio"); + return i === -1 ? "16x9" : process.argv[i + 1]; +})(); +if (!catalog.ratios.includes(ratioArg)) { + console.error( + `catalog.json non dichiara il rapporto "${ratioArg}" (ratios: ${catalog.ratios.join(", ")})`, + ); + process.exit(2); +} + +// Le scene nel rapporto chiesto: id e slug col suffisso, dimensioni dallo stage. +const variant = (s, ratio) => ({ + ...s, + id: variantName(s.id, ratio), + slug: variantName(s.slug, ratio), + seamAfter: s.seamAfter && variantName(s.seamAfter, ratio), + width: STAGES[ratio].w, + height: STAGES[ratio].h, +}); +const scenes = catalog.scenes.map((s) => variant(s, ratioArg)); +const pageScenes = catalog.scenes.map((s) => variant(s, "16x9")); const out = (s) => process.stdout.write(s + "\n"); @@ -32,6 +64,17 @@ const commands = { ids: () => scenes.forEach((s) => out(s.id)), + ratios: () => catalog.ratios.forEach((r) => out(r)), + + // I provini del tempo nel rapporto chiesto, come comandi di render: la stessa + // forma di `render`, cosi' fixture-tempo.sh li esegue senza sapere quali sono. + fixtures: () => + catalog.tempoFixtures.forEach((f) => + out( + `npx remotion render ${variantName(f.id, ratioArg)} out/${variantName(f.slug, ratioArg)}.mp4`, + ), + ), + // Le scene che dichiarano di stare ferme sui bordi. rest-point.sh boccia solo // quelle: le altre le misura e basta. rest: () => scenes.filter((s) => s.restAtEdges).forEach((s) => out(s.slug)), @@ -86,7 +129,7 @@ const PLACEHOLDER = ""; function sceneSection() { const esc = (s) => s.replace(/&/g, "&").replace(/ { + const blocks = pageScenes.map((s) => { // Il blurb e' HTML voluto (, ), quindi passa intero; titolo e // dimensioni no, quelli si scappano. const meta = `${esc(s.id)} · ${s.width}×${s.height} · ${s.fps}fps`; diff --git a/scripts/chain-check.py b/scripts/chain-check.py index c1342d3..975497b 100755 --- a/scripts/chain-check.py +++ b/scripts/chain-check.py @@ -22,7 +22,7 @@ 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] +Uso: chain-check.py [--ratio 16x9|9x16|4x5] [--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. @@ -37,10 +37,11 @@ ap = argparse.ArgumentParser() ap.add_argument("--linear", action="store_true") +ap.add_argument("--ratio", default="16x9") ap.add_argument("--must-fail", action="store_true") args = ap.parse_args() -cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "chain"] +cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "chain", "--ratio", args.ratio] if args.linear: cmd.append("--linear") r = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) @@ -49,7 +50,7 @@ sys.exit(3) data = json.loads(r.stdout) -print(f"GIU-04 sulla catena: {' → '.join(data['scenes'])}" +print(f"GIU-04 sulla catena in {args.ratio}: {' → '.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"]: diff --git a/scripts/checks/beats.mjs b/scripts/checks/beats.mjs new file mode 100644 index 0000000..c9f9bb2 --- /dev/null +++ b/scripts/checks/beats.mjs @@ -0,0 +1,55 @@ +// I quattro tempi di PromptInput nel rapporto, sulla scena e sul provino veloce. +// Senza OCR il banco esce 3; un fermo immagine della fine e la coda del thread +// coperta (la regressione del composer sopra i messaggi) escono 1. +export const checks = async ({ ratio, scene, fixtures, variantName, tmp, bench }) => { + const g = await bench("beats"); + const out = []; + for (const v of g.variants) { + const file = v.id === "PromptInput" ? scene("PromptInput").file : fixtures.find((f) => f.id === v.id).file; + out.push({ + rc: 0, + bench: "beats", + target: variantName(v.id), + role: "positivo", + cmd: `./scripts/beats.py ${file} --ratio ${ratio} --scene ${v.id}`, + }); + } + const main = g.variants.find((v) => v.id === "PromptInput"); + const src = scene("PromptInput").file; + const png = `${tmp("beats-fine")}.png`; + const fermo = `${tmp("beats-fermo")}.mp4`; + const coperto = `${tmp("beats-coperto")}.mp4`; + const c = main.cover; + out.push( + { + rc: 3, + bench: "beats", + target: `${variantName("PromptInput")}-senza-ocr`, + role: "negativo", + cmd: `TESSERACT=/nonexistent/tesseract ./scripts/beats.py ${src} --ratio ${ratio}`, + }, + { + run: `ffmpeg -nostdin -v error -sseof -0.1 -i ${src} -frames:v 1 -update 1 -y ${png} && ffmpeg -nostdin -v error -loop 1 -i ${png} -frames:v ${main.durationInFrames} -r ${g.fps} -pix_fmt yuv420p -y ${fermo}`, + label: `beats fermo immagine ${ratio}`, + }, + { + rc: 1, + bench: "beats", + target: `${variantName("PromptInput")}-fermo`, + role: "negativo", + cmd: `./scripts/beats.py ${fermo} --ratio ${ratio}`, + }, + { + run: `ffmpeg -nostdin -v error -i ${src} -vf "drawbox=x=${c.x}:y=${c.y}:w=${c.w}:h=${c.h}:color=0x111214:t=fill" -fps_mode passthrough -y ${coperto}`, + label: `beats coda coperta ${ratio}`, + }, + { + rc: 1, + bench: "beats", + target: `${variantName("PromptInput")}-coperto`, + role: "negativo", + cmd: `./scripts/beats.py ${coperto} --ratio ${ratio}`, + }, + ); + return out; +}; diff --git a/scripts/checks/chain-check.mjs b/scripts/checks/chain-check.mjs new file mode 100644 index 0000000..c998579 --- /dev/null +++ b/scripts/checks/chain-check.mjs @@ -0,0 +1,20 @@ +// GIU-04 in un rapporto: la camera della catena non torna indietro in moto, e +// senza easing il banco se ne accorge. +export const checks = ({ ratio }) => [ + { + rc: 0, + bench: "chain-check", + target: `catena-${ratio}`, + role: "positivo", + cmd: `./scripts/chain-check.py --ratio ${ratio}`, + }, + { + rc: 0, + bench: "chain-check", + target: `catena-${ratio}`, + role: "negativo", + // --must-fail rovescia il verdetto: esce 0 solo se il banco trova le + // inversioni in moto che togliere gli easing produce. + cmd: `./scripts/chain-check.py --ratio ${ratio} --linear --must-fail`, + }, +]; diff --git a/scripts/checks/click-gap.mjs b/scripts/checks/click-gap.mjs new file mode 100644 index 0000000..ff214e5 --- /dev/null +++ b/scripts/checks/click-gap.mjs @@ -0,0 +1,50 @@ +// CUR-03 nel rapporto: il clic d'invio e la bolla stanno a qualche frame di +// distanza sulla scena e sul suo provino veloce. Tre copie guaste della scena, +// tutte della stessa durata, escono 1: eventi fusi (tolti i frame fra clic e +// bolla), interfaccia lenta (sei frame fermi dopo il clic, scarto 11), clic +// mancato (il fotogramma prima del clic tenuto fino alla fine). I frame per +// costruirle vengono dal manifest; il banco non li riceve. +export const checks = async ({ ratio, scene, fixtures, variantName, tmp, bench }) => { + const g = await bench("click-gap"); + const out = []; + for (const v of g.variants) { + const file = v.id === "PromptInput" ? scene("PromptInput").file : fixtures.find((f) => f.id === v.id).file; + out.push({ + rc: 0, + bench: "click-gap", + target: variantName(v.id), + role: "positivo", + cmd: `./scripts/click-gap.py ${file} --ratio ${ratio} --scene ${v.id}`, + }); + } + const main = g.variants.find((v) => v.id === "PromptInput"); + const src = scene("PromptInput").file; + const n = main.durationInFrames; + const click = Math.ceil(main.events.sendClick); + const bubble = Math.ceil(main.events.bubbleAt) + 1; + const guaste = [ + { + name: "fusi", + vf: `select='lt(n\\,${click})+gte(n\\,${bubble})',setpts=N/FRAME_RATE/TB,tpad=stop_mode=clone:stop=${bubble - click}`, + }, + { name: "lenta", vf: `loop=loop=6:size=1:start=${click + 2},setpts=N/FRAME_RATE/TB,trim=end_frame=${n}` }, + { name: "mancato", vf: `trim=end_frame=${click},tpad=stop_mode=clone:stop=${n - click}` }, + ]; + for (const x of guaste) { + const f = `${tmp(`click-${x.name}`)}.mp4`; + out.push( + { + run: `ffmpeg -nostdin -v error -i ${src} -vf "${x.vf}" -fps_mode passthrough -y ${f}`, + label: `click-gap ${x.name} ${ratio}`, + }, + { + rc: 1, + bench: "click-gap", + target: `${variantName("PromptInput")}-${x.name}`, + role: "negativo", + cmd: `./scripts/click-gap.py ${f} --ratio ${ratio}`, + }, + ); + } + return out; +}; diff --git a/scripts/checks/contrast-floor.mjs b/scripts/checks/contrast-floor.mjs new file mode 100644 index 0000000..3c2f49c --- /dev/null +++ b/scripts/checks/contrast-floor.mjs @@ -0,0 +1,42 @@ +// CAM-05 nel rapporto: il contenuto attenuato regge 3:1 sulla scena e sul suo +// provino veloce; la stessa scena attenuata a 0,25 esce 1; un ritaglio sul fondo +// esce 3 invece di dare la colpa al pavimento. +export const checks = async ({ ratio, stage, scene, fixtures, variantName, tmp, bench }) => { + const g = await bench("contrast-floor"); + const out = []; + for (const v of g.variants) { + const file = v.id === "PromptInput" ? scene("PromptInput").file : fixtures.find((f) => f.id === v.id).file; + out.push({ + rc: 0, + bench: "contrast-floor", + target: variantName(v.id), + role: "positivo", + cmd: `./scripts/contrast-floor.py ${file} --ratio ${ratio} --scene ${v.id}`, + }); + } + const main = g.variants.find((v) => v.id === "PromptInput"); + const png = `${tmp("attn-025")}.png`; + const blank = `${tmp("fondo")}.png`; + out.push( + { run: `./scripts/fixture-attenuation.sh ${variantName("PromptInput")} ${main.frame} ${png}`, label: `attenuazione a 0,25 ${ratio}` }, + { + rc: 1, + bench: "contrast-floor", + target: `${variantName("PromptInput")}-attn025`, + role: "negativo", + cmd: `./scripts/contrast-floor.py ${png} --ratio ${ratio} --scene PromptInput`, + }, + { + run: `ffmpeg -nostdin -v error -f lavfi -i color=c=0x16171a:s=${stage.w}x${stage.h} -frames:v 1 -y ${blank}`, + label: `fondo uniforme ${ratio}`, + }, + { + rc: 3, + bench: "contrast-floor", + target: `fondo-${ratio}`, + role: "negativo", + cmd: `./scripts/contrast-floor.py ${blank} --ratio ${ratio} --scene PromptInput`, + }, + ); + return out; +}; diff --git a/scripts/checks/drift.mjs b/scripts/checks/drift.mjs new file mode 100644 index 0000000..c69d1d6 --- /dev/null +++ b/scripts/checks/drift.mjs @@ -0,0 +1,18 @@ +// CAM-06 nel rapporto: il bersaglio resta sull'origine sulle due lastre, e i due +// guasti (niente compensazione, origine incoerente) lo portano via in entrambe. +export const checks = ({ ratio }) => [ + { + rc: 0, + bench: "drift", + target: `specimen-${ratio}`, + role: "positivo", + cmd: `./scripts/drift.py --ratio ${ratio}`, + }, + ...['{"compensate": false}', '{"originMismatch": true}'].map((props) => ({ + rc: 0, + bench: "drift", + target: `specimen-${ratio}`, + role: "negativo", + cmd: `./scripts/drift.py --ratio ${ratio} --props '${props}' --must-fail`, + })), +]; diff --git a/scripts/checks/fill-geom.mjs b/scripts/checks/fill-geom.mjs new file mode 100644 index 0000000..0ff4de5 --- /dev/null +++ b/scripts/checks/fill-geom.mjs @@ -0,0 +1,18 @@ +// CAM-01 in geometria in un rapporto: la lastra copre il quadro, e con la +// camera arretrata non lo copre piu' in nessuna scena. +export const checks = ({ ratio }) => [ + { + rc: 0, + bench: "fill-geom", + target: `catena-${ratio}`, + role: "positivo", + cmd: `./scripts/fill-geom.py --ratio ${ratio}`, + }, + { + rc: 0, + bench: "fill-geom", + target: `catena-${ratio}`, + role: "negativo", + cmd: `./scripts/fill-geom.py --ratio ${ratio} --push-offset -1500 --must-fail`, + }, +]; diff --git a/scripts/checks/film-identity.mjs b/scripts/checks/film-identity.mjs new file mode 100644 index 0000000..e05e6af --- /dev/null +++ b/scripts/checks/film-identity.mjs @@ -0,0 +1,18 @@ +// Il film nel rapporto mostra gli stessi fotogrammi delle scene da sole, e una +// finestra spostata di un frame si vede. +export const checks = ({ ratio }) => [ + { + rc: 0, + bench: "film-identity", + target: `film-${ratio}`, + role: "positivo", + cmd: `./scripts/film-identity.sh --ratio ${ratio}`, + }, + { + rc: 0, + bench: "film-identity", + target: `film-${ratio}`, + role: "negativo", + cmd: `./scripts/film-identity.sh --ratio ${ratio} --offset 1 --must-fail --veloce`, + }, +]; diff --git a/scripts/checks/focus-sharpness.mjs b/scripts/checks/focus-sharpness.mjs new file mode 100644 index 0000000..ee16d51 --- /dev/null +++ b/scripts/checks/focus-sharpness.mjs @@ -0,0 +1,32 @@ +// CAM-04 nel rapporto: il testo regge l'ingrandimento sul render vero; la stessa +// discesa fatta di uno screenshot ingrandito esce 1; il render di questo rapporto +// misurato con la geometria di un altro esce 3 invece di dare un numero. +export const checks = ({ ratio, scene, tmp }) => { + const src = scene("CardFocus"); + const fixture = `${tmp("fixture-screenshot")}.mp4`; + const other = ratio === "16x9" ? "9x16" : "16x9"; + return [ + { + rc: 0, + bench: "focus-sharpness", + target: src.variant, + role: "positivo", + cmd: `./scripts/focus-sharpness.sh ${src.file} --ratio ${ratio}`, + }, + { run: `./scripts/fixture-screenshot.sh ${src.file} ${fixture} --ratio ${ratio}`, label: `screenshot ingrandito ${ratio}` }, + { + rc: 1, + bench: "focus-sharpness", + target: `${src.variant}-screenshot`, + role: "negativo", + cmd: `./scripts/focus-sharpness.sh ${fixture} --ratio ${ratio}`, + }, + { + rc: 3, + bench: "focus-sharpness", + target: `${src.variant}-geometria-${other}`, + role: "negativo", + cmd: `./scripts/focus-sharpness.sh ${src.file} --ratio ${other}`, + }, + ]; +}; diff --git a/scripts/checks/framelocked-verdict.mjs b/scripts/checks/framelocked-verdict.mjs new file mode 100644 index 0000000..ebfb0c9 --- /dev/null +++ b/scripts/checks/framelocked-verdict.mjs @@ -0,0 +1,46 @@ +// Il frame-lock. Le sonde non hanno rapporto: girano una volta sola, nel 16:9, +// con la sonda che ha un Math.random dentro e deve essere bocciata. PromptInput +// si prova in ogni rapporto, nei frame in cui la battitura si vede in quel +// formato (manifest, bench framelocked-verdict). +export const checks = async ({ ratio, variantName, bench }) => { + const g = await bench("framelocked-verdict"); + const out = []; + if (ratio === "16x9") { + out.push( + { + rc: 0, + bench: "framelocked-verdict", + target: "sonde-gsap", + role: "positivo", + cmd: "./scripts/framelocked-verdict.sh", + }, + { + rc: 1, + bench: "framelocked-verdict", + target: "FrameLockedProbeRandom", + role: "negativo", + cmd: "./scripts/framelocked-verdict.sh FrameLockedProbeRandom", + }, + ); + } + out.push( + { + rc: 0, + bench: "framelocked-verdict", + target: variantName(g.composition), + role: "positivo", + cmd: `FRAMES="${g.frames.join(" ")}" ./scripts/framelocked-verdict.sh ${variantName(g.composition)}`, + }, + { + rc: 1, + bench: "framelocked-verdict", + target: variantName(g.composition), + role: "negativo", + // Lo stesso frame due volte nell'elenco: le immagini distinte sono meno dei + // frame chiesti, ed e' la meta' del banco che boccia una timeline ferma. + // Nel rapporto, perche' e' li' che l'inquadratura potrebbe nasconderla. + cmd: `FRAMES="${[g.frames[0], ...g.frames.slice(0, -1)].join(" ")}" ./scripts/framelocked-verdict.sh ${variantName(g.composition)}`, + }, + ); + return out; +}; diff --git a/scripts/checks/handoff-travel.mjs b/scripts/checks/handoff-travel.mjs new file mode 100644 index 0000000..5d61a92 --- /dev/null +++ b/scripts/checks/handoff-travel.mjs @@ -0,0 +1,51 @@ +// CHR-01 nel rapporto: la card attraversa sulla lastra, sulla scena e sul +// provino veloce. Un fermo immagine, la scena al contrario e un taglio secco +// prima del trascinamento escono 1: la camera del render non e' piu' quella del +// manifest, e le intestazioni delle colonne si muovono sulla lastra raddrizzata. +export const checks = ({ ratio, scene, fixtures, variantName, tmp }) => { + const s = scene("CardHandoff"); + const fast = fixtures.find((f) => f.scene === "CardHandoff"); + const n = s.durationInFrames; + const png = `${tmp("travel-f0")}.png`; + const freeze = `${tmp("travel-fermo")}.mp4`; + const reverse = `${tmp("travel-contrario")}.mp4`; + const cut = `${tmp("travel-taglio")}.mp4`; + // Il taglio cade dove la mano afferra la card, a un terzo della scena: da li' + // in poi il fotogramma resta fermo. + const cutAt = Math.round(n * 0.35); + const neg = (target, file) => ({ + rc: 1, + bench: "handoff-travel", + target: `${s.variant}-${target}`, + role: "negativo", + cmd: `./scripts/handoff-travel.py ${file} --ratio ${ratio}`, + }); + return [ + { + rc: 0, + bench: "handoff-travel", + target: s.variant, + role: "positivo", + cmd: `./scripts/handoff-travel.py ${s.file} --ratio ${ratio}`, + }, + { + rc: 0, + bench: "handoff-travel", + target: variantName(fast.id), + role: "positivo", + cmd: `./scripts/handoff-travel.py ${fast.file} --ratio ${ratio} --scene ${fast.id}`, + }, + { + run: `ffmpeg -nostdin -v error -i ${s.file} -frames:v 1 -y ${png} && ffmpeg -nostdin -v error -loop 1 -i ${png} -frames:v ${n} -r ${s.fps} -pix_fmt yuv420p -y ${freeze}`, + label: `handoff fermo immagine ${ratio}`, + }, + neg("fermo", freeze), + { run: `ffmpeg -nostdin -v error -i ${s.file} -vf reverse -fps_mode passthrough -y ${reverse}`, label: `handoff al contrario ${ratio}` }, + neg("contrario", reverse), + { + run: `ffmpeg -nostdin -v error -i ${s.file} -vf "trim=end_frame=${cutAt},tpad=stop_mode=clone:stop=${n - cutAt}" -fps_mode passthrough -y ${cut}`, + label: `handoff taglio secco ${ratio}`, + }, + neg("taglio", cut), + ]; +}; diff --git a/scripts/checks/rest-point.mjs b/scripts/checks/rest-point.mjs new file mode 100644 index 0000000..63b3930 --- /dev/null +++ b/scripts/checks/rest-point.mjs @@ -0,0 +1,41 @@ +// I bordi fermi nel rapporto: le scene che lo dichiarano (restAtEdges) lo sono; +// un ritaglio dal mezzo di una scena si muove ai bordi (1); un fermo immagine +// non si muove da nessuna parte e lascia il banco senza controllo (2). +export const checks = ({ ratio, scene, tmp }) => { + const src = scene("CardFocus"); + const secs = src.durationInFrames / src.fps; + const mosso = `${tmp("rest-mosso")}.mp4`; + const png = `${tmp("rest-fermo")}.png`; + const fermo = `${tmp("rest-fermo")}.mp4`; + return [ + { + rc: 0, + bench: "rest-point", + target: `restAtEdges-${ratio}`, + role: "positivo", + cmd: `./scripts/rest-point.sh --ratio ${ratio}`, + }, + { + run: `ffmpeg -nostdin -v error -ss ${(secs * 0.35).toFixed(3)} -t ${(secs * 0.35).toFixed(3)} -i ${src.file} -y ${mosso}`, + label: `ritaglio mosso da ${src.variant}`, + }, + { + rc: 1, + bench: "rest-point", + target: `${src.variant}-mosso`, + role: "negativo", + cmd: `./scripts/rest-point.sh ${mosso}`, + }, + { + run: `ffmpeg -nostdin -v error -i ${src.file} -frames:v 1 -y ${png} && ffmpeg -nostdin -v error -loop 1 -i ${png} -t 3 -r 30 -pix_fmt yuv420p -y ${fermo}`, + label: `fermo immagine da ${src.variant}`, + }, + { + rc: 2, + bench: "rest-point", + target: `${src.variant}-fermo`, + role: "negativo", + cmd: `./scripts/rest-point.sh ${fermo}`, + }, + ]; +}; diff --git a/scripts/checks/seam.mjs b/scripts/checks/seam.mjs new file mode 100644 index 0000000..606c80d --- /dev/null +++ b/scripts/checks/seam.mjs @@ -0,0 +1,27 @@ +// Le giunte del rapporto: ogni coppia che il catalogo aggancia (seamAfter) e' +// continua, e una coppia al contrario, dove la giunta non esiste, esce 1. +export const checks = ({ ratio, scenes, scene }) => { + const pairs = scenes + .filter((s) => s.seamAfter) + .map((s) => [scene(s.seamAfter), s]); + if (pairs.length === 0) return []; + const [a, b] = pairs[0]; + return [ + ...pairs.map(([p, s]) => ({ + rc: 0, + bench: "seam", + target: `${p.variant}>${s.variant}`, + role: "positivo", + cmd: `./scripts/seam.sh ${p.file} ${s.file}`, + })), + { + rc: 1, + bench: "seam", + target: `${b.variant}>${a.variant}`, + role: "negativo", + // La stessa coppia al contrario: l'ultimo fotogramma di B non e' il primo + // di A, e il banco deve dirlo come salto, non come misura inutile. + cmd: `./scripts/seam.sh ${b.file} ${a.file}`, + }, + ]; +}; diff --git a/scripts/checks/tempo.mjs b/scripts/checks/tempo.mjs new file mode 100644 index 0000000..49c71dd --- /dev/null +++ b/scripts/checks/tempo.mjs @@ -0,0 +1,29 @@ +// Il tempo nel rapporto: il provino a meta' durata di CardHandoff e' la stessa +// scena piu' veloce, e la stessa scena troncata non lo e'. Le finestre in cui le +// soglie percettive non scalano le dichiara la scena (manifest, bench tempo). I provini li rende +// la CI prima dei controlli (fixture-tempo.sh --ratio), come le scene. +export const checks = async ({ ratio, scene, fixtures, tmp, bench }) => { + const g = await bench("tempo"); + const win = `--percettive ${g.perceptual.map(([a, b]) => `${a}-${b}`).join(",")}`; + const long = scene(g.scene).file; + const fast = fixtures.find((f) => f.scene === g.scene); + if (!fast) throw new Error("catalog.json: nessun provino del tempo per CardHandoff"); + const trim = `${tmp("fixture-trim")}.mp4`; + return [ + { + rc: 0, + bench: "tempo", + target: `CardHandoff-${ratio}`, + role: "positivo", + cmd: `./scripts/tempo.py ${long} ${fast.file} ${win}`, + }, + { run: `./scripts/fixture-trim.sh ${long} ${fast.file} ${trim}`, label: `ritaglio non ritempificato ${ratio}` }, + { + rc: 1, + bench: "tempo", + target: `CardHandoff-${ratio}`, + role: "negativo", + cmd: `./scripts/tempo.py ${long} ${trim} ${win}`, + }, + ]; +}; diff --git a/scripts/click-gap.py b/scripts/click-gap.py new file mode 100755 index 0000000..61295fc --- /dev/null +++ b/scripts/click-gap.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Fra il clic e la sua conseguenza passa qualche frame, o succedono insieme? + +E' la promessa di CUR-03. Due o tre frame di scarto bastano perche' il gesto +sembri causare qualcosa; sullo stesso fotogramma il clic legge come finto, +perche' nella vita nessuna interfaccia risponde prima di aver ricevuto. Metterli +insieme e' anche piu' corto da scrivere, quindi e' l'errore che si fa per +distrazione ed e' esattamente per questo che serve un banco. + +COME, e senza farsi dire la risposta. Il banco non legge i frame del clic: li +ritrova nel render. Il manifest (`bench click-gap`) gli da' due zone del quadro +e il tratto a camera ferma in cui cercare, e lui conta frame per frame i pixel +che cambiano in ognuna: + + 1. IL COLPO, nel testo del campo. All'invio il campo si svuota e torna al + segnaposto: migliaia di pixel contro le poche centinaia di una lettera + battuta. E' il cambio PIU' GRANDE della zona nel tratto, e deve superare + COLPO volte il suo novantesimo percentile. Il piu' grande e non il primo: + il primo che supera la soglia e' il segnaposto che sparisce alla prima + lettera (2100 px nel 16:9), mentre lo svuotamento ne cambia 5300, e dopo + l'invio nel campo non succede piu' niente. + 2. LA CONSEGUENZA, nella coda del thread, dove sale la bolla. Dopo il colpo la + zona si calma; la conseguenza e' il primo fotogramma che risale sopra quella + quiete e sopra un pavimento proporzionale alla zona. + +PERCHE' DUE ZONE E NON IL QUADRO. La prima versione contava il fotogramma +intero, e nel 16:9 il clic si vedeva grazie al pulsante che si abbassa e +all'anello del cursore. In 9:16 e 4:5 il pulsante e' fuori quadro: dopo il clic +i fotogrammi erano fermi, la quiete valeva zero, qualunque cosa la superava, e +il banco promuoveva "scarto 2" qualunque fosse lo scarto vero, anche su una +copia in cui la bolla arrivava undici frame dopo. + +NEL FOTOGRAMMA DEL CLIC IL THREAD SALE GIA'. La bolla nuova prende il suo posto +nel layout appena parte l'invio e compare quattro frame dopo, quindi la coda del +thread cambia due volte: insieme al colpo (i messaggi salgono) e dopo (la bolla +entra). La prima e' parte del colpo, e il banco cerca la conseguenza dopo la +quiete che segue. E' un difetto di giunta di stato da sistemare nella scena +(passo C), non qui. + +LO SCARTO VA DA 2 A 8. Uno scarto di un frame non si distingue da due eventi +fusi, perche' l'animazione della bolla continua il fotogramma dopo; sopra otto +diventa software lento. La versione di prima dichiarava 1 ma cercava da 2. + +Uso: ./scripts/click-gap.py --ratio R [--scene PromptInput|PromptInputFast] + +Esce 0 se lo scarto sta nell'intervallo, 1 se il clic non c'e', se non ha +conseguenza o se lo scarto e' fuori, 3 se il file, il quadro, la durata o il +manifest non tornano. +""" +import argparse +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ap = argparse.ArgumentParser() +ap.add_argument("src") +ap.add_argument("--ratio", default="16x9") +ap.add_argument("--scene", default="PromptInput") +args = ap.parse_args() +SRC = pathlib.Path(args.src) + +# Quante volte il novantesimo percentile della zona del campo deve essere superato +# perche' sia il colpo. Misurato: una lettera battuta cambia 130-560 pixel, lo +# svuotamento 5000-12000, nei tre rapporti. +COLPO = 5 +# Quanto la conseguenza deve risalire sopra la quiete che segue il colpo. +RISALITA = 1.4 +# E il pavimento sotto cui non e' una conseguenza: mezzo per cento della zona del +# thread. La bolla al primo fotogramma utile cambia 2300-6000 pixel; il rumore +# fra due fotogrammi fermi, da zero a otto. +PAVIMENTO = 0.005 +MIN, MAX = 2, 8 +# Differenza per pixel, su 255, sotto cui due pixel sono lo stesso pixel. +SOGLIA_PIXEL = 20 + +if not SRC.exists(): + print("manca il render: %s" % SRC, file=sys.stderr) + raise SystemExit(3) + +geo = subprocess.run( + ["node", str(ROOT / "scripts/manifest.mjs"), "bench", "click-gap", "--ratio", args.ratio], + capture_output=True, text=True, +) +if geo.returncode != 0: + print("la geometria non e' arrivata dal manifest:\n" + geo.stderr, file=sys.stderr) + raise SystemExit(3) +g = json.loads(geo.stdout) +v = next((x for x in g["variants"] if x["id"] == args.scene), None) +if v is None: + print("il manifest non conosce la scena %s" % args.scene, file=sys.stderr) + raise SystemExit(3) + + +def probe(entries): + out = subprocess.run( + ["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", + "-show_entries", "stream=" + entries, "-of", "csv=p=0", str(SRC)], + capture_output=True, text=True, + ).stdout.strip() + return [x for x in out.split(",") if x != ""] + + +w, h, n = (probe("width,height,nb_read_frames") + ["", "", ""])[:3] +if [w, h] != [str(g["stage"]["w"]), str(g["stage"]["h"])]: + print("il quadro di %s e' %sx%s, lo stage di %s e' %dx%d: geometria di un altro rapporto" + % (SRC.name, w, h, args.ratio, g["stage"]["w"], g["stage"]["h"]), file=sys.stderr) + raise SystemExit(3) +if n != str(v["durationInFrames"]): + print("%s ha %s fotogrammi, %s ne ha %d: non e' il render di quella scena" + % (SRC.name, n, args.scene, v["durationInFrames"]), file=sys.stderr) + raise SystemExit(3) + +A, B = v["window"] + + +def serie(rect): + """Pixel cambiati nella zona fra ogni fotogramma del tratto e il precedente.""" + vf = ("select='between(n\\,%d\\,%d)',crop=%d:%d:%d:%d,format=gray," + "tblend=all_mode=difference,lut=c0='if(gt(val\\,%d)\\,255\\,0)'," + "signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" + % (A - 1, B, rect["w"], rect["h"], rect["x"], rect["y"], SOGLIA_PIXEL)) + out = subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-i", str(SRC), "-vf", vf, + "-fps_mode", "passthrough", "-f", "null", "-"], + capture_output=True, text=True, + ).stdout + area = rect["w"] * rect["h"] + vals = [float(x) / 255 * area for x in re.findall(r"YAVG=([0-9.]+)", out)] + if len(vals) != B - A + 1: + print("lettura della zona fallita: %d valori invece di %d" % (len(vals), B - A + 1), + file=sys.stderr) + raise SystemExit(3) + return vals + + +# vals[i] e' la differenza fra il fotogramma A+i e il precedente. +campo = serie(v["rects"]["colpo"]) +coda = serie(v["rects"]["conseguenza"]) + +srt = sorted(campo) +p90 = max(srt[int(len(srt) * 0.9)], 1.0) +hit = max(range(len(campo)), key=lambda i: campo[i]) +if campo[hit] < p90 * COLPO: + hit = -1 + +print("Scarto fra il clic d'invio e la sua conseguenza, su %s (%s)." % (SRC.name, args.ratio)) +print("Tratto a camera ferma: fotogrammi %d-%d. Zone dal manifest: campo %s, thread %s." + % (A, B, "%(w)dx%(h)d+%(x)d+%(y)d" % v["rects"]["colpo"], + "%(w)dx%(h)d+%(x)d+%(y)d" % v["rects"]["conseguenza"])) +print() + +if hit < 0: + print(" il colpo: nessun fotogramma supera %dx il p90 del campo (%d px)" % (COLPO, p90)) + print() + print("FALLITO: nel campo non c'e' il clic d'invio: il testo non si svuota mai.", file=sys.stderr) + raise SystemExit(1) + +pavimento = PAVIMENTO * v["rects"]["conseguenza"]["w"] * v["rects"]["conseguenza"]["h"] +quiete = sorted(coda[hit + 1:hit + 4])[1] if len(coda) > hit + 4 else 0 +soglia = max(quiete * RISALITA, pavimento) +cons = next((i for i in range(hit + 1, min(hit + 20, len(coda))) if coda[i] >= soglia), -1) + +print(" %-38s f%d (%d px, p90 del campo %d)" % ("il colpo, nel campo", A + hit, campo[hit], p90)) +print(" %-38s %d px" % ("il thread insieme al colpo", coda[hit])) +print(" %-38s %d px (soglia %d)" % ("la quiete dopo il colpo", quiete, soglia)) +if cons < 0: + print() + print("FALLITO: dopo il clic il thread non risponde entro venti frame, o la sua" , file=sys.stderr) + print("animazione era gia' in corso nel fotogramma del clic, cioe' i due eventi", file=sys.stderr) + print("si sono fusi.", file=sys.stderr) + raise SystemExit(1) + +gap = cons - hit +print(" %-38s f%d (%d px)" % ("la conseguenza, nel thread", A + cons, coda[cons])) +print(" %-38s %d frame" % ("scarto", gap)) +print() + +if not MIN <= gap <= MAX: + print("FALLITO: lo scarto e' di %d frame, fuori dall'intervallo %d-%d." % (gap, MIN, MAX), + file=sys.stderr) + print("Sotto il minimo il nesso fra gesto e risposta sparisce; sopra il massimo non", file=sys.stderr) + print("legge come una conseguenza, legge come software lento.", file=sys.stderr) + raise SystemExit(1) + +print("VERDETTO: il colpo cade a f%d e la conseguenza a f%d, %d frame dopo." % (A + hit, A + cons, gap)) +print("La UI risponde al clic invece di rispondere insieme al clic.") diff --git a/scripts/click-gap.sh b/scripts/click-gap.sh deleted file mode 100755 index e2cb361..0000000 --- a/scripts/click-gap.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -# -# Fra il clic e la sua conseguenza passa qualche frame, o succedono insieme? -# -# E' la promessa di CUR-03. Due o tre frame di scarto bastano perche' il gesto -# sembri causare qualcosa; sullo stesso fotogramma il clic legge come finto, -# perche' nella vita nessuna interfaccia risponde prima di aver ricevuto. -# Metterli insieme e' anche piu' corto da scrivere, quindi e' l'errore che si fa -# per distrazione ed e' esattamente per questo che serve un banco. -# -# COME, e senza farsi dire la risposta. Lo script non legge i tempi dal -# sorgente: li ritrova nel render. Conta, frame per frame, quanti pixel -# cambiano rispetto al precedente, e nella finestra cerca due cose in fila: -# -# 1. IL COLPO. Il fotogramma in cui il conteggio esplode rispetto alla mediana -# della finestra. Quando il messaggio parte, il composer si svuota e il -# testo se ne va: sono decine di migliaia di pixel contro qualche migliaio. -# Si prende il PRIMO che sfonda, non il piu' grande: piu' avanti ce n'e' un -# altro altrettanto violento, ed e' l'indicatore di attesa che compare. -# 2. LA CONSEGUENZA. Dopo il colpo il quadro si calma; la conseguenza e' il -# primo fotogramma in cui il conteggio risale sopra la quiete che lo -# precede. Non e' un'esplosione, e' una bolla che scorre dentro. -# -# TUTTE SOGLIE RELATIVE, e non e' pignoleria: le letture di ImageMagick su Linux -# valgono cinque-dieci volte quelle di macOS sugli stessi render, e un numero -# assoluto tarato da una parte fallisce dall'altra. Qui si confrontano solo -# conteggi della stessa finestra fra loro. -# -# CHE SAPPIA BOCCIARE si dimostra in due modi. Puntato su una scena che quella -# promessa non la fa (ui-mockup, card-handoff, card-focus, card-release) non -# trova nessun colpo ed esce 1. E su un ritaglio in cui i fotogrammi fra colpo e -# conseguenza sono stati tolti, cioe' i due eventi fusi in uno, esce 1 lo stesso. -# -# Su quel secondo caso va detto com'e' andata davvero, perche' la diagnosi non e' -# quella che mi aspettavo: non riporta scarto zero, riporta uno scarto fuori -# intervallo. Fusi i due eventi, la "quiete" contro cui misura la risalita non e' -# piu' il quadro calmo, e' l'animazione della bolla gia' in corso, quindi la -# risalita successiva la trova piu' avanti. Boccia il difetto giusto per una -# strada diversa, e chi legge il verde deve saperlo. -# -# Uso: ./scripts/click-gap.sh [scena.mp4] -set -uo pipefail - -. "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" -export LC_NUMERIC=C - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SRC="${1:-$ROOT/video/out/prompt-input.mp4}" - -# Quante volte la base della finestra deve superare il conteggio perche' sia -# "il colpo". La base e' il novantesimo percentile, non la mediana: vedi sotto. -COLPO=5 -# Quante volte la quiete dopo il colpo deve superare il conteggio perche' sia -# "la conseguenza". Misurato su prompt-input: 1,9. -RISALITA=1.4 -# Lo scarto ammesso, in frame. Sotto 1 il nesso sparisce, sopra 8 diventa -# lentezza del software invece che risposta. -MIN=1 -MAX=8 - -[ -f "$SRC" ] || { echo "manca il render: $SRC" >&2; exit 1; } - -TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT - -N=$(ffprobe -v error -count_frames -select_streams v:0 \ - -show_entries stream=nb_read_frames -of csv=p=0 "$SRC" | tr -dc '0-9') -case "${N:-}" in ''|*[!0-9]*) echo "non riesco a contare i fotogrammi di $SRC" >&2; exit 3 ;; esac - -# La meta' centrale del render: il clic non sta ne' nei primi frame ne' negli -# ultimi, e restringere tiene bassa la mediana su cui si misura il colpo. -DA=$((N * 40 / 100)); A=$((N * 85 / 100)) -ffmpeg -nostdin -v error -i "$SRC" -vf "select='between(n\,$DA\,$A)'" \ - -fps_mode passthrough -y "$TMP/p%04d.png" -ls "$TMP"/p*.png >/dev/null 2>&1 || { echo "estrazione fallita su $SRC" >&2; exit 3; } - -prev=""; : > "$TMP/n.txt" -for f in "$TMP"/p*.png; do - if [ -n "$prev" ]; then - "${IM_CONVERT[@]}" "$prev" "$f" -compose difference -composite \ - -colorspace Gray -threshold 8% -format '%[fx:mean*w*h]' info: >> "$TMP/n.txt" - echo >> "$TMP/n.txt" - fi - prev="$f" -done - -RES=$(python3 - "$TMP/n.txt" "$DA" "$COLPO" "$RISALITA" <<'PYEND' -import sys -vals = [float(x) for x in open(sys.argv[1]) if x.strip()] -base, colpo, risalita = int(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]) -if len(vals) < 20: - print("0 0 0 0 0"); raise SystemExit -srt = sorted(vals) -# LA BASE NON PUO' ESSERE LA MEDIANA, e questo l'ha insegnato un falso positivo. -# Quando la camera sta ferma durante la recita - che e' come va girata, perche' -# non si muove la macchina mentre qualcuno scrive - meta' dei fotogrammi non -# cambia NIENTE. La mediana e' zero, "cinque volte la mediana" diventa "piu' di -# zero", e il primo carattere battuto sfonda la soglia: il banco riportava con -# sicurezza un clic a f181 mentre quello vero stava a f271. Un banco che sbaglia -# in silenzio e' peggio di uno che fallisce. -# -# La base e' il novantesimo percentile della finestra. Resta un rapporto interno -# alla finestra, come tutte le soglie qui, ma non degenera su una scena immobile. -mediana = srt[len(srt) // 2] -base_px = srt[int(len(srt) * 0.90)] -if base_px <= 0: - # Finestra senza movimento: non c'e' niente contro cui misurare un colpo, e - # dichiararne uno vorrebbe dire inventarlo. - print(f"0 0 0 {mediana:.0f} 0"); raise SystemExit -# Il colpo: il primo che sfonda, non il piu' grande. Piu' avanti ce n'e' un -# altro altrettanto violento ed e' un evento diverso. -hit = next((i for i, v in enumerate(vals) if v >= base_px * colpo), -1) -if hit < 0 or hit + 5 >= len(vals): - print(f"0 0 0 {mediana:.0f} 0"); raise SystemExit -# La quiete subito dopo il colpo, e il primo fotogramma che ci risale sopra. -quiete = sorted(vals[hit + 1:hit + 4])[1] -cons = next((i for i in range(hit + 2, min(hit + 20, len(vals))) - if vals[i] >= quiete * risalita), -1) -gap = (cons - hit) if cons > 0 else 0 -print(hit + base + 1, (cons + base + 1) if cons > 0 else 0, gap, - f"{mediana:.0f}", f"{quiete:.0f}") -PYEND -) -set -- $RES -COLPO_F="$1"; CONS_F="$2"; GAP="$3"; MEDIANA="$4"; QUIETE="$5" - -echo "Scarto fra il clic e la sua conseguenza, misurato su $(basename "$SRC")." -echo "Finestra dal fotogramma $DA al $A. Mediana dei pixel cambiati: ${MEDIANA}." -echo "Le soglie sono rapporti dentro questa finestra, non numeri assoluti." -echo -printf ' %-34s %s\n' "il colpo (>= ${COLPO}x il p90)" "${COLPO_F:-nessuno}" -printf ' %-34s %s\n' "la conseguenza (>= ${RISALITA}x la quiete)" "${CONS_F:-nessuna}" -printf ' %-34s %s\n' "quiete fra i due" "${QUIETE} pixel" -printf ' %-34s %s frame\n' "scarto" "$GAP" -echo - -if [ "$COLPO_F" = 0 ] || [ "$CONS_F" = 0 ]; then - echo "FALLITO: non trovo la coppia colpo/conseguenza in questa scena." >&2 - echo "O il clic non c'e', oppure la conseguenza parte insieme al colpo e i due" >&2 - echo "eventi si sono fusi in un fotogramma solo, che e' il difetto cercato." >&2 - exit 1 -fi - -if ! python3 -c "exit(0 if $MIN <= $GAP <= $MAX else 1)"; then - echo "FALLITO: lo scarto e' di $GAP frame, fuori dall'intervallo $MIN-$MAX." >&2 - echo "Sotto il minimo il nesso fra gesto e risposta sparisce; sopra il massimo" >&2 - echo "non legge come una conseguenza, legge come software lento." >&2 - exit 1 -fi - -echo "VERDETTO: il colpo cade a f${COLPO_F} e la conseguenza a f${CONS_F}, ${GAP} frame" -echo "dopo. La UI risponde al clic invece di rispondere insieme al clic." -exit 0 diff --git a/scripts/contrast-floor.py b/scripts/contrast-floor.py index 41ef17c..9cca65c 100755 --- a/scripts/contrast-floor.py +++ b/scripts/contrast-floor.py @@ -33,64 +33,108 @@ poca roba, il novantasettesimo percentile misura ancora il fondo, e la stessa scena leggeva 2,59:1 o 4,17:1 secondo quanto testo capitava dentro il rettangolo. -COME FALLISCE. Sul render fatto con attnFloor a 0,25, cioe' la stessa scena -attenuata troppo, esce rosso. E se il ritaglio non contiene testo - perche' -qualcuno ha cambiato il layout e la fascia adesso e' vuota - le due letture -coincidono, il rapporto va verso 1, e allora esce 3: non ho potuto misurare, che -e' un'altra cosa da "ho misurato e non va". - -Uso: ./scripts/contrast-floor.py [scena.mp4] +COME FALLISCE. Sulla stessa scena resa con attnFloor a 0,25, cioe' attenuata +troppo, esce 1. + +UN RITAGLIO VUOTO ESCE 3, e prima usciva 1. La docstring lo prometteva gia', ma +il controllo guardava solo quanti pixel stavano sopra la soglia del nucleo: su +un fondo uniforme o sul rumore di codifica il massimo coincide col fondo, la +soglia collassa sul fondo, il "nucleo" diventa mezzo ritaglio e il rapporto va a +1,00. Il banco diceva "alza il pavimento" per un ritaglio finito sul vuoto, che +in 9:16 e' esattamente quello che succede se la posa sposta l'intestazione fuori +dal punto calcolato. Adesso, prima di ogni verdetto: se fra fondo e massimo ci +sono meno di SEGNALE_MIN livelli, o se il nucleo occupa piu' di meta' del +ritaglio, non c'e' testo e il banco non ha misurato niente. + +IN OGNI RAPPORTO. Il ritaglio e il fotogramma vengono dal manifest (`bench +contrast-floor --ratio R`), per la scena e per il suo provino veloce. Il banco +controlla che il quadro del file sia lo stage del rapporto: un 9:16 misurato con +la geometria del 16:9 esce 3, non con un numero. + +UN FOTOGRAMMA SOLO BASTA. Il negativo non ha bisogno di rendere 450 fotogrammi +per leggerne uno: con un .png il banco legge quel fotogramma, e la fixture e' un +`remotion still` sul frame giusto. + +Uso: ./scripts/contrast-floor.py --ratio R [--scene PromptInput|PromptInputFast] + +Esce 0 se il contrasto regge, 1 se sta sotto la soglia, 3 se il file manca, il +quadro non e' quello del rapporto, il manifest non risponde o il ritaglio non +contiene testo. """ -import json, pathlib, subprocess, sys +import argparse, json, pathlib, subprocess, sys ROOT = pathlib.Path(__file__).resolve().parent.parent -SRC = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "video/out/prompt-input.mp4" +ap = argparse.ArgumentParser() +ap.add_argument("src", nargs="?", default=str(ROOT / "video/out/prompt-input.mp4")) +ap.add_argument("--ratio", default="16x9") +ap.add_argument("--scene", default="PromptInput") +args = ap.parse_args() +SRC = pathlib.Path(args.src) SOGLIA = 3.0 # Sotto questo numero di pixel il ritaglio non contiene testo, e non c'e' niente # di cui misurare il contrasto. NUCLEO_MINIMO = 120 -# Il fotogramma da guardare, tardi nello streaming, quando l'attenuazione e' a -# regime - espresso alla durata di riferimento della scena e poi scalato. -# -# SEGUE LA DURATA perche' le battute interne la seguono (primitives/tempo.ts): -# su un render ritempificato a due terzi il fotogramma 430 non esiste, e su uno -# a meta' cadrebbe dopo la fine. -BASE_FRAMES = 450 -FRAME_BASE = 430 +# Quanti livelli di grigio servono fra fondo e massimo perche' nel ritaglio ci +# sia testo. Misurato sulla scena attenuata a 0,25, che e' il caso piu' debole +# che il banco deve ancora leggere: fondo 20, massimo 67, cioe' 47 livelli, uguale +# nei tre rapporti. Un fondo uniforme ne da' zero e il rumore di codifica due o +# tre. Venti sta a due volte e mezzo sotto il caso debole e a sette sopra il +# rumore; quaranta, il primo valore provato, lasciava solo sette livelli di +# margine al negativo, e un font di Linux un filo piu' sottile l'avrebbe fatto +# uscire 3 invece di 1. +SEGNALE_MIN = 20 if not SRC.exists(): print("manca il render: %s" % SRC, file=sys.stderr) - raise SystemExit(1) + raise SystemExit(3) -# La geometria viene dal manifest, non da due numeri copiati qui: l'intestazione -# del thread all'ultima posa di PromptInput, proiettata e con dimensioni pari -# (video/src/products/topics/benches.ts spiega perche'). +# La geometria viene dal manifest: l'intestazione del thread all'ultima posa di +# PromptInput nel rapporto, proiettata e con dimensioni pari, e il fotogramma in +# cui leggerla per ogni durata (video/src/products/topics/benches/contrast-floor.ts). geo = subprocess.run( - ["node", "%s/scripts/manifest.mjs" % ROOT, "contrast-crop"], + ["node", "%s/scripts/manifest.mjs" % ROOT, "bench", "contrast-floor", "--ratio", args.ratio], capture_output=True, text=True, ) if geo.returncode != 0: 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: - print("il ritaglio calcolato e' degenere: %s" % r, file=sys.stderr) +g = json.loads(geo.stdout) +r = g["crop"] +variante = next((v for v in g["variants"] if v["id"] == args.scene), None) +if variante is None: + print("il manifest non conosce la scena %s" % args.scene, file=sys.stderr) raise SystemExit(3) -nf = subprocess.run( - ["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", - "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", str(SRC)], +dims = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(SRC)], capture_output=True, text=True, -).stdout -nf = int("".join(c for c in nf if c.isdigit()) or 0) -if nf <= 0: - print("non riesco a contare i fotogrammi di %s" % SRC, file=sys.stderr) +).stdout.strip().split(",")[:2] +if dims != [str(g["stage"]["w"]), str(g["stage"]["h"])]: + print("il quadro di %s e' %s, lo stage di %s e' %dx%d: geometria di un altro rapporto" + % (SRC.name, "x".join(dims), args.ratio, g["stage"]["w"], g["stage"]["h"]), file=sys.stderr) raise SystemExit(3) -FRAME = round(FRAME_BASE * nf / BASE_FRAMES) + +if SRC.suffix.lower() == ".png": + FRAME = variante["frame"] + select = "" +else: + nf = subprocess.run( + ["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", + "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", str(SRC)], + capture_output=True, text=True, + ).stdout + nf = int("".join(c for c in nf if c.isdigit()) or 0) + if nf != variante["durationInFrames"]: + print("%s ha %d fotogrammi, %s ne ha %d: non e' il render di quella scena" + % (SRC.name, nf, args.scene, variante["durationInFrames"]), file=sys.stderr) + raise SystemExit(3) + FRAME = variante["frame"] + select = "select=eq(n\\,%d)," % FRAME raw = subprocess.run( ["ffmpeg", "-nostdin", "-v", "error", "-i", str(SRC), - "-vf", "select=eq(n\\,%d),crop=%d:%d:%d:%d,format=gray" % (FRAME, r["w"], r["h"], r["x"], r["y"]), + "-vf", "%scrop=%d:%d:%d:%d,format=gray" % (select, r["w"], r["h"], r["x"], r["y"]), "-frames:v", "1", "-f", "rawvideo", "-"], capture_output=True, ).stdout @@ -119,6 +163,14 @@ def lum(v255): soglia_nucleo = bg + 0.6 * (mx - bg) nucleo = [v for v in px if v >= soglia_nucleo] +if mx - bg < SEGNALE_MIN or len(nucleo) > len(px) / 2: + print("MISURA INUTILE: fra fondo (%d) e massimo (%d) ci sono %d livelli, e il nucleo" + % (bg, mx, mx - bg), file=sys.stderr) + print("occupa %d pixel su %d. Li' dentro non c'e' testo: il ritaglio e' finito sul" + % (len(nucleo), len(px)), file=sys.stderr) + print("fondo, e un rapporto di contrasto su quello non direbbe niente della scena.", file=sys.stderr) + raise SystemExit(3) + if len(nucleo) < NUCLEO_MINIMO: print("MISURA INUTILE: nel ritaglio ci sono %d pixel di testo, sotto i %d che" % (len(nucleo), NUCLEO_MINIMO), file=sys.stderr) @@ -131,8 +183,8 @@ def lum(v255): ratio = (l1 + 0.05) / (l2 + 0.05) print("Contrasto del contenuto attenuato su %s, fotogramma %d." % (SRC.name, FRAME)) -print("Ritaglio sull'intestazione del thread, proiettato da topics/geometry.ts: %dx%d a (%d,%d)." - % (r["w"], r["h"], r["x"], r["y"])) +print("Ritaglio sull'intestazione del thread in %s, proiettato dal manifest: %dx%d a (%d,%d)." + % (args.ratio, r["w"], r["h"], r["x"], r["y"])) print() print(" fondo (valore piu' frequente) %3d" % bg) print(" testo attenuato (nucleo, %5d px) %5.1f" % (len(nucleo), fg)) diff --git a/scripts/drift.py b/scripts/drift.py index 3d3765c..b48a36a 100755 --- a/scripts/drift.py +++ b/scripts/drift.py @@ -49,16 +49,27 @@ ap = argparse.ArgumentParser() ap.add_argument("--only") +ap.add_argument("--ratio", help="solo le varianti di questo rapporto (16x9, 9x16, 4x5)") 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 +# Un manifest che non risponde e' uno strumento guasto, non un verdetto sulla +# scena: esce 3 come gli altri banchi, invece di un traceback con codice 1 che +# nel report di expect.sh si confonderebbe con una bocciatura. +r = subprocess.run( + ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "cam06"], + 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) +manifest = json.loads(r.stdout) +if args.ratio: + manifest = [v for v in manifest if v["ratio"] == args.ratio] + if not manifest: + print(f"nessuna variante nel rapporto {args.ratio}", file=sys.stderr) + sys.exit(3) if args.only: manifest = [v for v in manifest if v["id"] == args.only] if not manifest: diff --git a/scripts/expect.sh b/scripts/expect.sh new file mode 100755 index 0000000..fa586dc --- /dev/null +++ b/scripts/expect.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Esegue i banchi dichiarati dal manifest e controlla che ognuno esca col codice +# che deve: 0 sui render buoni, il codice del difetto sui casi costruiti apposta. +# +# PERCHE' ESISTE. I controlli negativi erano novanta righe scritte a mano nel +# workflow, una per banco, tutte per il 16:9 di Topics. Con tre rapporti +# sarebbero diventate trecento, e un negativo dimenticato non rompe niente: il +# banco resta verde anche quando non puo' piu' fallire, che e' il difetto che il +# repo ha gia' pagato due volte (framelocked-verdict, fill-measure). Adesso le +# righe le genera `scripts/manifest.mjs checks`, questo script le esegue tutte +# senza fermarsi al primo errore, e scrive un report che `bench-coverage.py` +# legge per dire quale banco non ha un negativo che fallisce. +# +# FORMATO, una riga per controllo, campi separati da tab: +# run preparazione, deve uscire 0 +# expect +# Il comando gira con bash, dalla radice del repo, con lo stdin chiuso, e trova +# in $CHECKS_TMP una cartella sua per i casi costruiti. +# +# `cmd; rc=$?` NON SI USA, per lo stesso motivo scritto nel workflow: sotto +# `bash -e` la shell se ne va prima di assegnare rc. Qui si usa `|| rc=$?`. +# +# Uso: ./scripts/expect.sh +# +# Esce 0 se ogni controllo esce col codice atteso e ogni preparazione riesce, +# 1 altrimenti, 3 se l'elenco dei controlli manca o e' vuoto. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CHECKS="${1:?serve il file dei controlli}" +REPORT="${2:?serve il percorso del report}" + +[ -s "$CHECKS" ] || { echo "nessun controllo in $CHECKS" >&2; exit 3; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +: > "$TMP/results.tsv" +# La cartella dei casi costruiti (ritagli, fermi immagine, copie guaste): i +# controlli la chiamano $CHECKS_TMP, cosi' due rapporti nella stessa macchina +# non si sovrascrivono i file e niente finisce in video/out. +export CHECKS_TMP="$TMP/work" +mkdir -p "$CHECKS_TMP" + +BAD=0 +N=0 +TAB="$(printf '\t')" +while IFS="$TAB" read -r kind a b c d e; do + [ -n "$kind" ] || continue + case "$kind" in \#*) continue ;; esac + N=$((N + 1)) + log="$TMP/$N.log" + start=$(date +%s) + case "$kind" in + run) + label="$a"; cmd="$b" + rc=0; (cd "$ROOT" && bash -o pipefail -c "$cmd") < /dev/null > "$log" 2>&1 || rc=$? + secs=$(( $(date +%s) - start )) + if [ "$rc" = 0 ]; then + printf ' ok %-48s %4ss\n' "prepara: $label" "$secs" + else + printf ' ROTTO %-48s %4ss rc=%s\n' "prepara: $label" "$secs" "$rc" + tail -n 20 "$log" | sed 's/^/ /' + BAD=$((BAD + 1)) + fi + printf 'run\t%s\t-\t-\t0\t%s\t%s\n' "$label" "$rc" "$secs" >> "$TMP/results.tsv" + ;; + expect) + want="$a"; bench="$b"; target="$c"; role="$d"; cmd="$e" + rc=0; (cd "$ROOT" && bash -o pipefail -c "$cmd") < /dev/null > "$log" 2>&1 || rc=$? + secs=$(( $(date +%s) - start )) + if [ "$rc" = "$want" ]; then + printf ' ok %-22s %-22s %-8s rc=%s %4ss\n' "$bench" "$target" "$role" "$rc" "$secs" + else + printf ' ROTTO %-22s %-22s %-8s rc=%s, atteso %s %4ss\n' "$bench" "$target" "$role" "$rc" "$want" "$secs" + tail -n 20 "$log" | sed 's/^/ /' + BAD=$((BAD + 1)) + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$role" "$bench" "$target" "$cmd" "$want" "$rc" "$secs" >> "$TMP/results.tsv" + ;; + *) + echo "riga sconosciuta in $CHECKS: $kind" >&2 + BAD=$((BAD + 1)) + ;; + esac +done < "$CHECKS" + +[ "$N" -gt 0 ] || { echo "nessun controllo in $CHECKS" >&2; exit 3; } + +mkdir -p "$(dirname "$REPORT")" +python3 - "$TMP/results.tsv" "$REPORT" <<'PY' +import json, sys +rows = [] +for line in open(sys.argv[1], encoding="utf-8"): + role, bench, target, cmd, want, rc, secs = line.rstrip("\n").split("\t") + rows.append({"role": role, "bench": bench, "target": target, "command": cmd, + "expected": int(want), "rc": int(rc), "seconds": int(secs), + "ok": int(want) == int(rc)}) +json.dump({"checks": rows}, open(sys.argv[2], "w"), indent=1) +PY + +echo +if [ "$BAD" -gt 0 ]; then + echo "VERDETTO: $BAD controlli su $N non escono col codice atteso. Report: $REPORT" + exit 1 +fi +echo "VERDETTO: tutti i $N controlli escono col codice atteso. Report: $REPORT" +exit 0 diff --git a/scripts/fill-geom.py b/scripts/fill-geom.py index f847152..f42bd60 100755 --- a/scripts/fill-geom.py +++ b/scripts/fill-geom.py @@ -20,7 +20,7 @@ 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] +Uso: fill-geom.py [--ratio 16x9|9x16|4x5] [--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. @@ -36,10 +36,11 @@ ap = argparse.ArgumentParser() ap.add_argument("--push-offset", type=float, default=0) +ap.add_argument("--ratio", default="16x9") ap.add_argument("--must-fail", action="store_true") args = ap.parse_args() -cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "fill"] +cmd = ["node", os.path.join(ROOT, "scripts/manifest.mjs"), "fill", "--ratio", args.ratio] if args.push_offset: cmd += ["--push-offset", str(args.push_offset)] r = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) @@ -51,7 +52,7 @@ 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" +print(f"CAM-01 in geometria, {args.ratio}: 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: diff --git a/scripts/film-identity.sh b/scripts/film-identity.sh new file mode 100755 index 0000000..0a2c19f --- /dev/null +++ b/scripts/film-identity.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Un fotogramma del film e' lo stesso fotogramma della scena presa da sola? +# +# PERCHE' ESISTE. I film di prodotto non sono clip agganciate: sono le stesse +# scene messe una dopo l'altra dentro una composition sola, ognuna nella sua +# finestra (kit/film.ts, kit/SceneWindow.tsx). La finestra funziona solo se +# dentro di lei la scena vede il proprio tempo: frame da zero, durata della +# finestra e non del film. Se una sola di queste due cose sbaglia, la scena nel +# film parte spostata o gira a un'altra velocita', e nessuno dei banchi sulle +# clip se ne accorge, perche' le clip sono giuste. +# +# COME. Per ogni finestra, il primo, quello di mezzo e l'ultimo fotogramma: dalla +# scena da sola al frame f, dal film al frame start+f. Due PNG di `remotion +# still` con lo stesso sha256 sono lo stesso fotogramma (framelocked-verdict.sh +# prova che il PNG e' ripetibile). Il progetto si impacchetta una volta, e ogni +# fotogramma costa un secondo invece di un bundle. +# +# IL CONTROLLO DELLO STRUMENTO. Il primo fotogramma del film si rende due volte: +# se i due hash non coincidono lo strumento non e' ripetibile, e l'uscita e' 2. +# +# IL NEGATIVO. `--offset 1` confronta la scena al frame f col film al frame +# start+f+1, cioe' una finestra che parte con un fotogramma di ritardo. Con +# `--must-fail` il banco esce 0 solo se ogni finestra ha almeno un fotogramma +# diverso: un banco che non vede un frame di scarto non vede nemmeno il difetto. +# Il negativo guarda un fotogramma solo per finestra (`--veloce`), quello in cui +# la camera della scena si muove di piu', che il manifest calcola dalla traccia. +# I primi tentativi hanno scelto a occhio e hanno sbagliato due volte: l'ultimo +# fotogramma di CardFocus e CardRelease e' fermo, quello di mezzo di PromptInput +# cade fra due parole dello streaming, e in 4:5 anche il primo di PromptInput e' +# uguale al secondo, perche' la camera parte da ferma. Dove la camera corre, un +# frame di scarto cambia per forza l'immagine. +# +# Le finestre e gli id non sono scritti qui: li stampa `scripts/manifest.mjs film`. +# +# Uso: ./scripts/film-identity.sh [--ratio 16x9|9x16|4x5] [--offset N] [--must-fail] [--veloce] +# +# Esce 0 se ogni fotogramma coincide (con --must-fail: se ogni finestra ne ha +# uno diverso), 1 altrimenti, 2 se lo strumento non e' ripetibile, 3 se un +# render o il manifest non rispondono. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RATIO=16x9 +OFFSET=0 +MUST_FAIL=0 +VELOCE=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --veloce) VELOCE=1; shift ;; + --ratio) RATIO="$2"; shift 2 ;; + --offset) OFFSET="$2"; shift 2 ;; + --must-fail) MUST_FAIL=1; shift ;; + *) echo "argomento sconosciuto: $1" >&2; exit 3 ;; + esac +done + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +node "$ROOT/scripts/manifest.mjs" film --ratio "$RATIO" > "$TMP/film.json" 2>"$TMP/manifest.err" || { + echo "il manifest non risponde:" >&2; cat "$TMP/manifest.err" >&2; exit 3; } + +# Una riga per finestra: id start frames. +python3 -c " +import json,sys +d=json.load(open(sys.argv[1])) +print(d['film']) +for w in d['windows']: print(w['id'], w['start'], w['frames'], w['moving']) +" "$TMP/film.json" > "$TMP/windows.txt" || { echo "manifest illeggibile" >&2; exit 3; } +FILM=$(head -n 1 "$TMP/windows.txt") +[ -n "$FILM" ] || { echo "il manifest non nomina il film" >&2; exit 3; } +[ "$(wc -l < "$TMP/windows.txt")" -gt 1 ] || { echo "il film non ha finestre" >&2; exit 3; } +TOTAL=$(tail -n 1 "$TMP/windows.txt" | awk '{print $2 + $3}') + +(cd "$ROOT/video" && npx remotion bundle --out-dir "$TMP/bundle" --log=error >/dev/null 2>&1 < /dev/null) \ + || { echo "bundle fallito" >&2; exit 3; } + +still() { # composition frame file + (cd "$ROOT/video" && npx remotion still "$TMP/bundle" "$1" "$3" --frame="$2" --image-format=png --log=error \ + >/dev/null 2>&1 < /dev/null) && [ -s "$3" ] +} +hash() { shasum -a 256 "$1" | cut -c1-16; } + +still "$FILM" 0 "$TMP/rep-a.png" && still "$FILM" 0 "$TMP/rep-b.png" \ + || { echo "render del film fallito" >&2; exit 3; } +if [ "$(hash "$TMP/rep-a.png")" != "$(hash "$TMP/rep-b.png")" ]; then + echo "lo stesso fotogramma del film reso due volte cambia: strumento non ripetibile" >&2 + exit 2 +fi + +echo "Il film $FILM contro le scene da sole$([ "$OFFSET" != 0 ] && echo ", finestre spostate di $OFFSET")." +DIFF=0 +WINDOWS_WITH_DIFF=0 +WINDOWS=0 +while read -r id start frames moving; do + WINDOWS=$((WINDOWS + 1)) + line=" $(printf '%-18s' "$id") da f$start" + here=0 + scelti="0 $((frames / 2)) $((frames - 1))" + [ "$VELOCE" = 1 ] && scelti="$moving" + for f in $scelti; do + ff=$((start + f + OFFSET)) + # Con lo spostamento l'ultimo fotogramma dell'ultima finestra cade fuori dal + # film: non c'e' niente da confrontare, e non e' un render fallito. + if [ "$ff" -lt 0 ] || [ "$ff" -ge "$TOTAL" ]; then + line="$line f$f fuori" + continue + fi + still "$id" "$f" "$TMP/s-$id-$f.png" || { echo "$line f$f: RENDER FALLITO"; exit 3; } + still "$FILM" "$ff" "$TMP/f-$id-$f.png" || { echo "$line film f$ff: RENDER FALLITO"; exit 3; } + if [ "$(hash "$TMP/s-$id-$f.png")" = "$(hash "$TMP/f-$id-$f.png")" ]; then + line="$line f$f=" + else + line="$line f$f≠" + DIFF=$((DIFF + 1)) + here=1 + fi + done + WINDOWS_WITH_DIFF=$((WINDOWS_WITH_DIFF + here)) + echo "$line" +done < <(tail -n +2 "$TMP/windows.txt") + +echo +if [ "$MUST_FAIL" = 1 ]; then + if [ "$WINDOWS_WITH_DIFF" = "$WINDOWS" ]; then + echo "VERDETTO: con le finestre spostate di $OFFSET ognuna delle $WINDOWS scene ha fotogrammi diversi, come deve." + exit 0 + fi + echo "VERDETTO: il banco PROMUOVE $((WINDOWS - WINDOWS_WITH_DIFF)) finestre spostate di $OFFSET." + exit 1 +fi +if [ "$DIFF" -gt 0 ]; then + echo "VERDETTO: $DIFF fotogrammi del film non sono quelli delle scene." + exit 1 +fi +echo "VERDETTO: in tutte le $WINDOWS finestre il film mostra gli stessi fotogrammi delle scene da sole." +exit 0 diff --git a/scripts/fixture-attenuation.sh b/scripts/fixture-attenuation.sh index a10f2dc..2b9e897 100755 --- a/scripts/fixture-attenuation.sh +++ b/scripts/fixture-attenuation.sh @@ -1,21 +1,29 @@ #!/usr/bin/env bash # -# Costruisce la scena che contrast-floor.py deve bocciare. +# Costruisce il fotogramma che contrast-floor.py deve bocciare. # -# NON MISURA NIENTE. Renderizza PromptInput con il pavimento dell'attenuazione a -# 0,25 invece che a 0,62, cioe' la stessa identica scena attenuata troppo. E' il -# motivo per cui `attnFloor` esiste come prop: senza un render sbagliato da dare -# in pasto al banco, il verde del banco vorrebbe dire soltanto che lo script e' -# arrivato in fondo. +# NON MISURA NIENTE. Rende un fotogramma di PromptInput con il pavimento +# dell'attenuazione a 0,25 invece che a 0,62, cioe' la stessa identica scena +# attenuata troppo. E' il motivo per cui `attnFloor` esiste come prop: senza un +# render sbagliato da dare in pasto al banco, il verde del banco vorrebbe dire +# soltanto che lo script e' arrivato in fondo. # -# Uso: ./scripts/fixture-attenuation.sh +# UN FOTOGRAMMA E NON UNA SCENA. La prima versione rendeva tutti i 450 +# fotogrammi, due minuti per rapporto, per un banco che ne legge uno. +# +# Uso: ./scripts/fixture-attenuation.sh set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUT="$ROOT/video/out/.fixture-attn-025.mp4" +COMP="${1:?serve la composition, per esempio PromptInput-9x16}" +FRAME="${2:?serve il fotogramma}" +OUT="${3:?serve il file di uscita .png}" +mkdir -p "$(dirname "$OUT")" +OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")" cd "$ROOT/video" -npx remotion render PromptInput "$OUT" --props='{"attnFloor":0.25}' >/dev/null +npx remotion still "$COMP" "$OUT" --frame="$FRAME" --image-format=png \ + --props='{"attnFloor":0.25}' --log=error >/dev/null < /dev/null -[ -s "$OUT" ] || { echo "il render della fixture non ha prodotto niente" >&2; exit 3; } -echo "fixture: video/out/.fixture-attn-025.mp4 (attenuazione a 0,25 invece di 0,62)" +[ -s "$OUT" ] || { echo "il fotogramma della fixture non e' uscito" >&2; exit 3; } +echo "fixture: $OUT ($COMP al fotogramma $FRAME, attenuazione a 0,25 invece di 0,62)" diff --git a/scripts/fixture-screenshot.sh b/scripts/fixture-screenshot.sh index d429762..22c705d 100755 --- a/scripts/fixture-screenshot.sh +++ b/scripts/fixture-screenshot.sh @@ -8,36 +8,48 @@ # render vero e ingrandirlo fino alla posa finale: e' esattamente quello che # avrebbe prodotto una lastra fatta di screenshot invece che di DOM. # -# Uso: ./scripts/fixture-screenshot.sh [sorgente.mp4] [destinazione.mp4] +# IN OGNI RAPPORTO. La geometria viene dal manifest (`bench focus-sharpness`): la +# zona della card al primo fotogramma, dove finisce all'ultimo, e l'ingrandimento +# K. Il quadro dell'ultimo fotogramma e' lo stage del rapporto; la prima versione +# ritagliava 1920x1080 anche da un 9:16, e ffmpeg poi schiacciava quel +# fotogramma nel quadro verticale. +# +# Uso: ./scripts/fixture-screenshot.sh --ratio R set -uo pipefail . "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" export LC_NUMERIC=C ROOT="$(cd "$(dirname "$0")/.." && pwd)" -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; } - -# 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 +SRC="${1:?serve il render di CardFocus}" +OUT="${2:?serve il file di uscita}" +RATIO=16x9 +[ "${3:-}" = "--ratio" ] && RATIO="${4:?serve il rapporto}" +[ -f "$SRC" ] || { echo "manca il render: $SRC" >&2; exit 3; } T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +if ! node "$ROOT/scripts/manifest.mjs" bench focus-sharpness --ratio "$RATIO" > "$T/g.json" 2> "$T/g.err"; then + echo "la geometria non e' arrivata dal manifest:" >&2; cat "$T/g.err" >&2; exit 3 +fi +read -r SW SH OX OY K < <(python3 -c " +import json; g = json.load(open('$T/g.json')); k = g['k'] +print(g['stage']['w'], g['stage']['h'], + round(g['wide']['x'] * k - g['ours']['x']), round(g['wide']['y'] * k - g['ours']['y']), k)") +case "${K:-}" in ''|*[!0-9.]*) echo "geometria non numerica dal manifest" >&2; exit 3 ;; esac + mkdir -p "$T/f" ffmpeg -nostdin -v error -i "$SRC" -frames:v 1 -y "$T/wide.png" [ -s "$T/wide.png" ] || { echo "estrazione del primo fotogramma fallita" >&2; exit 3; } -# Il campo largo portato alla scala finale, ricentrato sulla card. -ox=$(python3 -c "print(int($WX * $K - $CX))") -oy=$(python3 -c "print(int($WY * $K - $CY))") +# Il campo largo portato alla scala finale e spostato perche' la card cada dove +# cade nell'ultimo fotogramma vero. Dove l'ingrandimento non copre il quadro, nero. "${IM_CONVERT[@]}" "$T/wide.png" -resize "$(python3 -c "print(f'{$K*100:.4f}%')")" \ - -crop "1920x1080+${ox}+${oy}" +repage -background black -flatten "$T/last.png" + -crop "${SW}x${SH}+${OX}+${OY}" +repage -background black -gravity NorthWest \ + -extent "${SW}x${SH}" "$T/last.png" [ -s "$T/last.png" ] || { echo "costruzione dell'ultimo fotogramma fallita" >&2; exit 3; } cp "$T/wide.png" "$T/f/0001.png" for i in $(seq 2 12); do cp "$T/last.png" "$T/f/$(printf '%04d' "$i").png"; done +mkdir -p "$(dirname "$OUT")" ffmpeg -nostdin -v error -framerate 30 -i "$T/f/%04d.png" -pix_fmt yuv420p -y "$OUT" [ -s "$OUT" ] || { echo "codifica della fixture fallita" >&2; exit 3; } -echo "fixture: ${OUT#"$ROOT"/} (primo fotogramma reale, ultimo ingrandito ${K}x)" +echo "fixture: $OUT (primo fotogramma reale, ultimo ingrandito ${K}x, ${SW}x${SH})" diff --git a/scripts/fixture-tempo.sh b/scripts/fixture-tempo.sh index a902227..b0849a0 100755 --- a/scripts/fixture-tempo.sh +++ b/scripts/fixture-tempo.sh @@ -1,19 +1,30 @@ #!/usr/bin/env bash # -# Rende i due provini del tempo: le stesse scene a durata ridotta. +# Rende i provini del tempo di un rapporto: le stesse scene a durata ridotta. # -# NON MISURANO NIENTE da soli. Sono composition dichiarate in Root.tsx e non in -# catalog.json, quindi non finiscono in vetrina e il film non le contiene: sono -# la sola cosa contro cui si puo' provare che accorciare la durata accorcia -# anche le battute interne, invece di tagliare la coda. +# NON MISURANO NIENTE da soli. Sono composition dichiarate in catalog.json sotto +# tempoFixtures e non fra le scene, quindi non finiscono in vetrina e il film non +# le contiene: sono la sola cosa contro cui si puo' provare che accorciare la +# durata accorcia anche le battute interne, invece di tagliare la coda. +# +# L'elenco non e' scritto qui: i comandi li stampa `catalog.mjs fixtures`, come +# quelli delle scene li stampa `catalog.mjs render`. +# +# Uso: ./scripts/fixture-tempo.sh [--ratio 16x9|9x16|4x5] set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT/video" -npx remotion render CardHandoffFast out/.fast-card-handoff.mp4 >/dev/null -npx remotion render PromptInputFast out/.fast-prompt-input.mp4 >/dev/null +RATIO=16x9 +[ "${1:-}" = "--ratio" ] && RATIO="${2:?serve il rapporto}" -for f in out/.fast-card-handoff.mp4 out/.fast-prompt-input.mp4; do - [ -s "$f" ] || { echo "il render del provino $f non ha prodotto niente" >&2; exit 3; } -done -echo "provini del tempo: CardHandoff a 120 fotogrammi (meta'), PromptInput a 300 (due terzi)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +node "$ROOT/scripts/catalog.mjs" fixtures --ratio "$RATIO" > "$TMP/render.sh" +[ -s "$TMP/render.sh" ] || { echo "il catalogo non dichiara provini del tempo" >&2; exit 3; } + +cd "$ROOT/video" +while read -r _npx _remotion _render id out; do + npx remotion render "$id" "$out" >/dev/null < /dev/null + [ -s "$out" ] || { echo "il render del provino $id non ha prodotto niente" >&2; exit 3; } + echo "provino del tempo: $id -> video/$out" +done < "$TMP/render.sh" diff --git a/scripts/fixture-trim.sh b/scripts/fixture-trim.sh index 6172f4c..bd59075 100755 --- a/scripts/fixture-trim.sh +++ b/scripts/fixture-trim.sh @@ -2,23 +2,26 @@ # # Costruisce la scena che tempo.py deve bocciare. # -# NON MISURA NIENTE. Tronca CardHandoff alla durata del provino veloce invece di -# ritempificarla: le battute restano dov'erano e la coda sparisce. E' esattamente -# quello che si otteneva prima di primitives/tempo.ts abbassando un numero in -# catalog.json, ed e' il difetto che il banco esiste per riconoscere. +# NON MISURA NIENTE. Tronca una scena alla durata del suo provino veloce invece +# di ritempificarla: le battute restano dov'erano e la coda sparisce. E' +# esattamente quello che si otteneva prima di primitives/tempo.ts abbassando un +# numero in catalog.json, ed e' il difetto che il banco esiste per riconoscere. +# +# Uso: ./scripts/fixture-trim.sh set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SRC="$ROOT/video/out/card-handoff.mp4" -FAST="$ROOT/video/out/.fast-card-handoff.mp4" -OUT="$ROOT/video/out/.fixture-trim.mp4" +SRC="${1:?serve il render della scena}" +FAST="${2:?serve il provino veloce}" +OUT="${3:?serve il file di uscita}" -[ -f "$SRC" ] || { echo "manca $SRC" >&2; exit 1; } -[ -f "$FAST" ] || { echo "manca $FAST: rendi CardHandoffFast" >&2; exit 1; } +[ -f "$SRC" ] || { echo "manca $SRC" >&2; exit 3; } +[ -f "$FAST" ] || { echo "manca $FAST: rendi i provini con fixture-tempo.sh" >&2; exit 3; } N=$(ffprobe -v error -count_frames -select_streams v:0 \ -show_entries stream=nb_read_frames -of csv=p=0 "$FAST" | tr -dc '0-9') +[ -n "$N" ] || { echo "non riesco a contare i fotogrammi di $FAST" >&2; exit 3; } +mkdir -p "$(dirname "$OUT")" ffmpeg -nostdin -v error -i "$SRC" -frames:v "$N" -fps_mode passthrough -y "$OUT" [ -s "$OUT" ] || { echo "il ritaglio non ha prodotto niente" >&2; exit 3; } -echo "fixture: video/out/.fixture-trim.mp4 ($N fotogrammi, tagliati e non ritempificati)" +echo "fixture: $OUT ($N fotogrammi, tagliati e non ritempificati)" diff --git a/scripts/focus-sharpness.sh b/scripts/focus-sharpness.sh index 853e4c3..13436de 100755 --- a/scripts/focus-sharpness.sh +++ b/scripts/focus-sharpness.sh @@ -26,7 +26,11 @@ # quel punto l'unica differenza rimasta e' dove sono stati rasterizzati. # # COME SI MISURA LA NITIDEZZA. Media della differenza fra l'immagine e la stessa -# immagine sfocata di un pixel, cioe' quanta energia sta nelle alte frequenze. +# immagine sfocata di mezzo pixel, cioe' quanta energia sta nelle frequenze piu' +# alte. Era un pixel intero; mezzo pixel guarda proprio la banda che un +# ingrandimento di K butta via, e a K basso e' quasi l'unica differenza che +# resta: in 9:16 (K 1,31) il render vero passa da 1,57x a 1,79x mentre lo +# screenshot resta a 1,14x. # Non e' una grandezza percettiva ed e' inutile in assoluto: conta solo il # rapporto fra le righe della tabella, che passano tutte per lo stesso # trattamento. @@ -34,16 +38,39 @@ # IL CONTROLLO NEGATIVO E' VERIFICATO, e sta in un file accanto. # `fixture-screenshot.sh` costruisce la stessa discesa fatta di pixel che # esistono solo alla scala del campo largo, cioe' il difetto da intercettare. -# Misurato: il render vero da' 2,09x, la fixture 1,03x. Su quella lo script esce -# 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. +# Misurato su macOS, render vero contro fixture: 2,81x e 1,09x nel 16:9 (K 2,21), +# 1,79x e 1,14x in 9:16 (K 1,31), 2,02x e 1,06x in 4:5 (K 1,90). Sulla fixture lo +# script esce 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. La calcola il manifest -# (scripts/manifest.mjs) da products/topics/geometry.ts, che e' la stessa sorgente da cui -# la scena prende la sua posa finale: una costante ricopiata a mano in bash resta -# giusta solo fino alla prima modifica della lastra. +# (`bench focus-sharpness --ratio R`) dalle tracce della camera del rapporto, +# con la proiezione esatta: la card all'ultimo fotogramma, tagliata sul bordo del +# quadro dove deborda (in 9:16 e 4:5 esce a destra), la stessa zona della lastra +# al primo fotogramma, e K, quanto la camera l'ha ingrandita. # -# Uso: ./scripts/focus-sharpness.sh [card-focus.mp4] +# UNA SOGLIA SOLA, VICINA ALLO SCREENSHOT. Il render vero si allontana da 1 tanto +# piu' quanto piu' la camera ingrandisce, lo screenshot no: resta fra 1,06x e +# 1,14x in tutti e tre i rapporti. La soglia di prima, 1,50 a meta' strada nel +# 16:9, sarebbe caduta troppo vicino al 9:16. Si e' provato anche a ricavarla da +# K col giro di rimpicciolimento e ritorno del ritaglio stesso, ed era di nuovo +# una grandezza relativa a se stessa: sulla fixture, gia' sfocata, la soglia +# calava con lei e il banco usciva 2 invece di 1. 1,35 sta 1,18 volte sopra lo +# screenshot peggiore e 1,33 volte sotto il render vero piu' debole. Sotto K_MIN +# i due casi sono troppo vicini per una soglia sola, e il banco esce 2. +# +# NIENTE VERDETTO SU UN RITAGLIO SBAGLIATO. Con i numeri del 16:9 su un render +# verticale il ritaglio cadeva fuori dall'immagine, ImageMagick restituiva un +# PNG di un pixel e il banco usciva 0 con "449159000x". Adesso il quadro del file +# deve essere lo stage del rapporto, e ogni ritaglio deve uscire della +# dimensione chiesta: altrimenti 3. +# +# Uso: ./scripts/focus-sharpness.sh --ratio R +# +# Esce 0 se il testo regge, 1 se sta sotto la soglia, 2 se lo strumento non +# risponde o l'ingrandimento e' troppo basso per decidere, 3 se il file, il +# quadro o un ritaglio non tornano. set -uo pipefail . "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" @@ -51,64 +78,64 @@ export LC_NUMERIC=C ROOT="$(cd "$(dirname "$0")/.." && pwd)" SRC="${1:-$ROOT/video/out/card-focus.mp4}" +RATIO=16x9 +[ "${2:-}" = "--ratio" ] && RATIO="${3:?serve il rapporto}" -# Quante volte il nostro ritaglio deve essere piu' nitido del controinfattuale. -# Non e' un numero scelto: e' la media geometrica fra le due letture misurate, -# 2,09 sul render vero e 1,03 sulla fixture costruita da fixture-screenshot.sh. -# Sta in mezzo perche' e' li' che separa, e se un giorno il render scendesse -# sotto vorrebbe dire che si sta avvicinando al caso che deve bocciare. -SOGLIA=1.50 +SOGLIA=1.35 +K_MIN=1.2 [ -f "$SRC" ] || { echo "manca il render: ${SRC#"$ROOT"/}" >&2 - echo " cd video && npx remotion render CardFocus out/card-focus.mp4" >&2 - exit 1 + exit 3 } -# La card, in pixel di composizione, all'ultimo fotogramma: dimensioni, centro, -# ingrandimento finale, dove stava nel campo largo e quanto manca da li' alla -# scala finale. Li calcola il manifest (video/src/products/topics/benches.ts) dagli -# stessi numeri che usa la scena. -read -r CW CH CX CY ZOOM WX WY K < <(node "$ROOT/scripts/manifest.mjs" focus-sharpness 2>/dev/null) - -case "${CW:-}|${CH:-}|${ZOOM:-}|${K:-}" in - *'|'|'|'*|'') echo "la geometria non e' arrivata 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 - TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +if ! node "$ROOT/scripts/manifest.mjs" bench focus-sharpness --ratio "$RATIO" > "$TMP/g.json" 2> "$TMP/g.err"; then + echo "la geometria non e' arrivata dal manifest:" >&2; cat "$TMP/g.err" >&2; exit 3 +fi +read -r SW SH iw ih ix iy K sx sy < <(python3 -c " +import json; g = json.load(open('$TMP/g.json')); o = g['ours']; w = g['wide']; k = g['k'] +print(g['stage']['w'], g['stage']['h'], o['w'], o['h'], o['x'], o['y'], k, + round(w['x'] * k), round(w['y'] * k))") +case "$SW$SH$iw$ih$ix$iy$sx$sy" in ''|*[!0-9-]*) echo "geometria non numerica dal manifest" >&2; exit 3 ;; esac + +dims=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$SRC" | cut -d, -f1,2) +if [ "$dims" != "$SW,$SH" ]; then + echo "il quadro di $(basename "$SRC") e' ${dims/,/x}, lo stage di $RATIO e' ${SW}x${SH}: geometria di un altro rapporto" >&2 + exit 3 +fi + +# Un ritaglio deve uscire della dimensione chiesta. ImageMagick tronca in +# silenzio un ritaglio che esce dall'immagine, e se ne esce del tutto restituisce +# un pixel: in entrambi i casi la misura sarebbe su un'altra cosa. +dimensione() { + local got + got=$("${IM_IDENTIFY[@]}" -format '%wx%h' "$1" 2>/dev/null) + [ "$got" = "${iw}x${ih}" ] || { echo "ritaglio $(basename "$1") di $got invece di ${iw}x${ih}" >&2; exit 3; } +} + # L'ultimo fotogramma. `-update 1` riscrive lo stesso file a ogni frame, quindi # alla fine resta l'ultimo senza dover sapere quanti sono. ffmpeg -nostdin -v error -i "$SRC" -fps_mode passthrough -update 1 -y "$TMP/last.png" [ -s "$TMP/last.png" ] || { echo "estrazione dell'ultimo fotogramma fallita" >&2; exit 3; } - -# Il ritaglio: la card meno un margine dell'8%, per restare dentro il bordo e -# non misurare la nitidezza del bordo stesso, che e' un filo da un pixel e -# sopravvive a qualsiasi trattamento. -iw=$(python3 -c "print(int($CW * 0.84))") -ih=$(python3 -c "print(int($CH * 0.84))") -ix=$(python3 -c "print(int($CX - $iw / 2))") -iy=$(python3 -c "print(int($CY - $ih / 2))") "${IM_CONVERT[@]}" "$TMP/last.png" -crop "${iw}x${ih}+${ix}+${iy}" +repage -colorspace Gray "$TMP/ours.png" -[ -s "$TMP/ours.png" ] || { echo "ritaglio fallito: ${iw}x${ih}+${ix}+${iy}" >&2; exit 3; } +dimensione "$TMP/ours.png" # Il controinfattuale: il campo largo portato alla scala finale. Se la lastra # fosse stata uno screenshot, l'ultimo fotogramma sarebbe stato questo. ffmpeg -nostdin -v error -i "$SRC" -frames:v 1 -y "$TMP/wide.png" [ -s "$TMP/wide.png" ] || { echo "estrazione del primo fotogramma fallita" >&2; exit 3; } pc=$(python3 -c "print(f'{$K * 100:.4f}%')") -sx=$(python3 -c "print(int($WX * $K - $iw / 2))") -sy=$(python3 -c "print(int($WY * $K - $ih / 2))") "${IM_CONVERT[@]}" "$TMP/wide.png" -resize "$pc" -crop "${iw}x${ih}+${sx}+${sy}" +repage \ -colorspace Gray "$TMP/mockup.png" -[ -s "$TMP/mockup.png" ] || { echo "costruzione del controinfattuale fallita" >&2; exit 3; } +dimensione "$TMP/mockup.png" -# Nitidezza: quanto si perde sfocando di un pixel. +# Nitidezza: quanto si perde sfocando di mezzo pixel. nitidezza() { local img="$1" - "${IM_CONVERT[@]}" "$img" -blur 0x1 "$TMP/b.png" + "${IM_CONVERT[@]}" "$img" -blur 0x0.5 "$TMP/b.png" local v v=$("${IM_CONVERT[@]}" "$img" "$TMP/b.png" -compose difference -composite \ -colorspace Gray -format '%[fx:mean*255]' info:) @@ -142,7 +169,7 @@ echo "a che scala i pixel sono stati rasterizzati." echo printf ' %-46s %9s\n' "riga" "energia" printf ' %-46s %9.3f\n' "ultimo fotogramma (DOM, alla scala finale)" "$n_ours" -printf ' %-46s %9.3f\n' "primo fotogramma portato a ${ZOOM}x (screenshot)" "$n_mock" +printf ' %-46s %9.3f\n' "primo fotogramma portato a ${K}x (screenshot)" "$n_mock" printf ' %-46s %9.3f\n' " (controllo strumento: il nostro, sfocato)" "$n_prova" echo @@ -155,7 +182,14 @@ if ! python3 -c "exit(0 if $n_prova < $n_ours * 0.9 else 1)"; then exit 2 fi -rapporto=$(python3 -c "print(f'{$n_ours / max($n_mock, 1e-9):.2f}')") +case "$n_mock" in 0|0.0|0.00|0.000) echo "il controinfattuale e' vuoto: nessuna energia da confrontare" >&2; exit 3 ;; esac +if ! python3 -c "exit(0 if $K >= $K_MIN else 1)"; then + echo "MISURA INUTILE: in $RATIO la camera ingrandisce la card di ${K}x, sotto ${K_MIN}x:" >&2 + echo "DOM e screenshot sono troppo vicini per decidere con una soglia sola." >&2 + exit 2 +fi + +rapporto=$(python3 -c "print(f'{$n_ours / $n_mock:.2f}')") if python3 -c "exit(0 if $rapporto >= $SOGLIA else 1)"; then echo "VERDETTO: il testo regge l'ingrandimento. ${rapporto}x piu' nitido dello" echo "stesso contenuto ingrandito dal campo largo (soglia ${SOGLIA}x)." @@ -164,6 +198,6 @@ fi echo "FALLITO: solo ${rapporto}x contro lo screenshot, sotto la soglia di ${SOGLIA}x." >&2 echo "O la lastra ha smesso di essere DOM da qualche parte lungo la catena," >&2 -echo "oppure CARD_FOCUS_ZOOM in products/topics/geometry.ts e' salito oltre quello che" >&2 -echo "la rasterizzazione regge." >&2 +echo "oppure l'ingrandimento di CardFocus in questo rapporto e' salito oltre quello" >&2 +echo "che la rasterizzazione regge." >&2 exit 1 diff --git a/scripts/framelocked-verdict.sh b/scripts/framelocked-verdict.sh index 5613b26..08e6f44 100755 --- a/scripts/framelocked-verdict.sh +++ b/scripts/framelocked-verdict.sh @@ -21,6 +21,11 @@ # 1, e la CI lo prova sulla sonda FrameLockedProbeRandom, che ha un Math.random # dentro e deve essere bocciata. # +# IL PROGETTO SI IMPACCHETTA UNA VOLTA. Ogni `remotion still` rifaceva il bundle +# da capo, e il bundle era meta' del tempo del banco. Le due passate restano due +# processi separati, che e' quello che conta: e' il tempo di orologio fra l'uno +# e l'altro a far emergere una deriva, non il bundle. +# # Uso: # ./scripts/framelocked-verdict.sh i due rami della sonda GSAP # ./scripts/framelocked-verdict.sh PromptInput una o piu' composition @@ -44,6 +49,11 @@ FRAMES=(${FRAMES:-30 61 92}) FAIL=0 NOMEASURE=0 +if ! npx remotion bundle --out-dir "$TMP/bundle" --log=error >/dev/null 2>&1 < /dev/null; then + echo "VERDETTO: nessuno. Il bundle del progetto non e' uscito." + exit 3 +fi + probe() { local comp="$1" label="$2" echo "" @@ -53,8 +63,8 @@ probe() { for f in "${FRAMES[@]}"; do for pass in a b; do - npx remotion still "$comp" "$TMP/$comp-$f-$pass.png" \ - --frame="$f" --image-format=png --log=error >/dev/null 2>&1 + npx remotion still "$TMP/bundle" "$comp" "$TMP/$comp-$f-$pass.png" \ + --frame="$f" --image-format=png --log=error >/dev/null 2>&1 < /dev/null done local ha hb diff --git a/scripts/handoff-travel.py b/scripts/handoff-travel.py new file mode 100755 index 0000000..430bd7a --- /dev/null +++ b/scripts/handoff-travel.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""La card cambia davvero colonna, o si limita a tremare? + +PERCHE' ESISTE. `seam.sh` prova che la giunta non ha tagli e `fill-geom.py` che +il quadro e' pieno: nessuno dei due guarda il gesto. Una scena in cui la card +resta ferma li supererebbe entrambi, perche' un fermo immagine ha la giunta +perfetta e i bordi coperti. Il contenuto della scena e' tutto qui: un oggetto +parte da una colonna e arriva in un'altra. + +COME. Ogni campione del trascinamento si riporta sulla lastra con la +trasformazione prospettica della camera a quel frame (i quattro angoli della +lastra proiettati, dal manifest `bench handoff-travel`), e fra due campioni +raddrizzati si prende la differenza dentro la meta' alta della board, solo dove +la lastra era in quadro in tutti e due. Sulla lastra la board sta ferma: quello +che cambia e' la card che viaggia, la mano e la colonna che si richiude. Del +cambiamento si segue il baricentro orizzontale, e la misura e' che vada avanti +senza tornare indietro e copra almeno meta' della distanza fra le due colonne. + +PERCHE' SULLA LASTRA E NON SULLO SCHERMO. La prima versione differenziava i +fotogrammi dello schermo. In 9:16 e 4:5 la camera segue la card mentre la mano +la trascina, la card sullo schermo si sposta di poche decine di pixel e tutta la +board le scorre sotto: sullo schermo si misurava la panoramica. E i campioni +cominciavano prima della presa, dove camera e cursore si muovono gia', tanto che +un taglio secco a meta' scena passava con quattro campioni buoni. Adesso stanno +tutti dentro il trascinamento. + +IL RADDRIZZAMENTO SI CONTROLLA, e la prima prova l'ha dimostrato. Raddrizzare +la stessa immagine con due camere diverse la fa scorrere sulla lastra: in 9:16 +un fermo immagine, raddrizzato con la panoramica del manifest, "attraversava" +208 px. Il controllo sono le intestazioni delle colonne, che durante il +trascinamento non cambiano: su un render vero, sulla lastra, cambiano di zero +pixel in ogni coppia di campioni in tutti e tre i rapporti; su un fermo immagine +di 40-150. Se cambiano, il render non segue la camera di quel rapporto e il +viaggio misurato non vorrebbe dire niente. + +COSA LO FA FALLIRE: + - fermo immagine, render al contrario, taglio secco prima del trascinamento: + la camera del render non e' quella del manifest e le intestazioni si + muovono sulla lastra. Nel 16:9, dove la camera quasi non si sposta, le + stesse copie falliscono anche sul viaggio (tornano indietro). + - card che non viaggia o torna indietro con la camera giusta: nessun campione + con movimento, o baricentro non monotono. + - card che parte e non arriva: distanza sotto la soglia. + +Uso: ./scripts/handoff-travel.py --ratio R [--scene CardHandoff|CardHandoffFast] + +Esce 0 se la card attraversa, 1 se non viaggia, torna indietro o non arriva, 3 +se file, quadro, durata, manifest o ImageMagick non tornano. +""" +import argparse +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ap = argparse.ArgumentParser() +ap.add_argument("src") +ap.add_argument("--ratio", default="16x9") +ap.add_argument("--scene", default="CardHandoff") +args = ap.parse_args() +SRC = pathlib.Path(args.src) + +# Differenza per pixel, su 255, sotto cui due pixel della lastra raddrizzata sono +# lo stesso pixel: toglie il rumore di codifica e il ricampionamento. +SOGLIA_PIXEL = 30 +# Sotto questa frazione della board un campione non contiene un oggetto in +# movimento. La card sulla lastra raddrizzata occupa circa 263x38 px, il 7% della +# zona; mezzo per cento e' un settimo di card. +PIXEL_MIN = 0.005 +# Passi all'indietro tollerati, in pixel della lastra raddrizzata: la colonna che +# si richiude tira un poco il baricentro verso sinistra alla fine. +INDIETRO = 3 +# Pixel cambiati ammessi nelle intestazioni fra due campioni: il mezzo per cento di +# quelle in quadro, e almeno otto. Misurato zero sui render veri, 40 il minimo su +# un fermo immagine in 9:16. +FERMA_MAX = 0.005 + +IM = ["magick"] if shutil.which("magick") else (["convert"] if shutil.which("convert") else None) +if IM is None: + print("serve ImageMagick: 'magick' (v7) oppure 'convert' (v6)", file=sys.stderr) + raise SystemExit(3) +if not SRC.exists(): + print("manca il render: %s" % SRC, file=sys.stderr) + raise SystemExit(3) + +geo = subprocess.run( + ["node", str(ROOT / "scripts/manifest.mjs"), "bench", "handoff-travel", "--ratio", args.ratio], + capture_output=True, text=True, +) +if geo.returncode != 0: + print("la geometria non e' arrivata dal manifest:\n" + geo.stderr, file=sys.stderr) + raise SystemExit(3) +g = json.loads(geo.stdout) +v = next((x for x in g["variants"] if x["id"] == args.scene), None) +if v is None: + print("il manifest non conosce la scena %s" % args.scene, file=sys.stderr) + raise SystemExit(3) + +out = subprocess.run( + ["ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", + "-show_entries", "stream=width,height,nb_read_frames", "-of", "csv=p=0", str(SRC)], + capture_output=True, text=True, +).stdout.strip() +w, h, n = ([x for x in out.split(",") if x] + ["", "", ""])[:3] +if [w, h] != [str(g["stage"]["w"]), str(g["stage"]["h"])]: + print("il quadro di %s e' %sx%s, lo stage di %s e' %dx%d: geometria di un altro rapporto" + % (SRC.name, w, h, args.ratio, g["stage"]["w"], g["stage"]["h"]), file=sys.stderr) + raise SystemExit(3) +if n != str(v["durationInFrames"]): + print("%s ha %s fotogrammi, %s ne ha %d: non e' il render di quella scena" + % (SRC.name, n, args.scene, v["durationInFrames"]), file=sys.stderr) + raise SystemExit(3) + +SW, SH = g["slab"]["w"], g["slab"]["h"] +b = g["board"] +WORK = pathlib.Path(tempfile.mkdtemp(prefix="travel-")) + + +def run(cmd): + r = subprocess.run(cmd, capture_output=True) + if r.returncode != 0: + print("comando fallito: %s\n%s" % (" ".join(cmd[:3]), r.stderr.decode(errors="replace")), file=sys.stderr) + raise SystemExit(3) + return r.stdout + + +def raddrizza(sample): + """Il fotogramma e la sua maschera di visibilita', riportati sulla lastra.""" + f = sample["frame"] + png = WORK / ("s%d.png" % f) + run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(SRC), "-vf", "select=eq(n\\,%d)" % f, + "-frames:v", "1", "-y", str(png)]) + punti = " ".join("%s,%s %s,%s" % (c["sx"], c["sy"], c["x"], c["y"]) for c in sample["corners"]) + comune = ["-virtual-pixel", "black", "-define", "distort:viewport=%dx%d+0+0" % (SW, SH), + "-distort", "Perspective", punti, + "-crop", "%dx%d+%d+%d" % (b["w"], b["h"], b["x"], b["y"]), "+repage"] + img = run(IM + [str(png), "-colorspace", "Gray"] + comune + ["-depth", "8", "gray:-"]) + mask = run(IM + ["-size", "%sx%s" % (w, h), "xc:white", "-colorspace", "Gray"] + comune + ["-depth", "8", "gray:-"]) + if len(img) != b["w"] * b["h"] or len(mask) != b["w"] * b["h"]: + print("raddrizzamento del fotogramma %d fallito" % f, file=sys.stderr) + raise SystemExit(3) + return img, mask + + +try: + fotogrammi = [raddrizza(s) for s in v["samples"]] + area = b["w"] * b["h"] + ferme = g["stillRows"] * b["w"] + xs = [] + registrate = True + print("Il viaggio della card in %s (%s), sulla lastra raddrizzata (zona board %dx%d)." + % (SRC.name, args.ratio, b["w"], b["h"])) + for (s0, (a, ma)), (s1, (c, mc)) in zip(zip(v["samples"], fotogrammi), zip(v["samples"][1:], fotogrammi[1:])): + tot = sx = visibili = mosse = 0 + for i in range(area): + if ma[i] > 250 and mc[i] > 250: + cambia = abs(a[i] - c[i]) > SOGLIA_PIXEL + if i < ferme: + visibili += 1 + mosse += cambia + elif cambia: + tot += 1 + sx += i % b["w"] + cx = sx / tot if tot else -1 + allineate = mosse <= max(8, FERMA_MAX * visibili) + registrate = registrate and allineate + usato = tot >= PIXEL_MIN * area + print(" f%-4d -> f%-4d x=%7.1f pixel=%6d intestazioni cambiate %4d su %5d %s" + % (s0["frame"], s1["frame"], cx, tot, mosse, visibili, + "NON ALLINEATE" if not allineate else ("" if usato else "(nessun movimento)"))) + if usato: + xs.append(cx) +finally: + shutil.rmtree(WORK, ignore_errors=True) + +print() +if not registrate: + print("FALLITO: sulla lastra raddrizzata le intestazioni delle colonne si muovono, e", file=sys.stderr) + print("durante il trascinamento non devono. Il render non segue la camera di questo", file=sys.stderr) + print("rapporto: un fermo immagine, un render al contrario o tagliato, o una posa", file=sys.stderr) + print("diversa da quella del manifest. Il viaggio misurato non vorrebbe dire niente.", file=sys.stderr) + raise SystemExit(1) +if len(xs) < 4: + print("FALLITO: solo %d campioni con movimento. La card non viaggia, oppure si sposta" + % len(xs), file=sys.stderr) + print("di scatto fuori dal trascinamento, che e' un taglio.", file=sys.stderr) + raise SystemExit(1) + +span = xs[-1] - xs[0] +back = sum(1 for p, q in zip(xs, xs[1:]) if q < p - INDIETRO) +print(" campioni: %d da x=%.0f a x=%.0f spostamento=%.0f px (soglia %.0f)" + % (len(xs), xs[0], xs[-1], span, g["minTravel"])) +print(" passi all'indietro: %d" % back) +print() +if back > 1: + print("FALLITO: il movimento torna indietro %d volte, non e' un tragitto." % back, file=sys.stderr) + raise SystemExit(1) +if span < g["minTravel"]: + print("FALLITO: la card si sposta di %.0f px sulla lastra, sotto i %.0f di meta' strada." + % (span, g["minTravel"]), file=sys.stderr) + raise SystemExit(1) +print("VERDETTO: la card attraversa %.0f px della lastra in avanti, senza tornare indietro." % span) diff --git a/scripts/handoff-travel.sh b/scripts/handoff-travel.sh deleted file mode 100755 index c5096a3..0000000 --- a/scripts/handoff-travel.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# -# La card cambia davvero colonna, o si limita a tremare? -# -# PERCHE' ESISTE. `seam.sh` prova che la giunta non ha tagli, e `fill-measure` -# che il quadro e' pieno: nessuno dei due guarda il gesto. Una scena in cui la -# card resta ferma li supererebbe entrambi a pieni voti, perche' un fermo -# immagine ha la giunta perfetta e i bordi vivi. Il contenuto della scena e' -# tutto qui: un oggetto parte da una colonna e arriva in un'altra. -# -# COME. Si isola il rettangolo della card in viaggio con una differenza fra -# fotogrammi, e si segue il centroide dei pixel che cambiano. La misura non e' -# "si e' mosso": e' che la componente ORIZZONTALE del movimento sia -# monotona e copra la distanza fra due colonne. Un tremolio si muove e non -# arriva; un dissolvenza cambia pixel ovunque e non ha un centroide che -# viaggia. -# -# COSA LO FA FALLIRE, che e' cio' che rende un banco un banco: -# - card ferma -> nessun pixel cambia, zero campioni utili -# - card che torna indietro -> la x non e' monotona -# - taglio secco fra due stati -> un solo campione con movimento, non un arco -# - card che parte e non arriva -> distanza sotto la larghezza di colonna -# -# Uso: ./scripts/handoff-travel.sh [video.mp4] -set -uo pipefail - -# ImageMagick si chiama `magick` sulla 7 e `convert`/`compare` sulla 6. -. "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" -export LC_NUMERIC=C - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SRC="${1:-$ROOT/video/out/card-handoff.mp4}" - -[ -f "$SRC" ] || { echo "manca: $SRC" >&2; exit 1; } - -WORK="$ROOT/out/.travel" -rm -rf "$WORK"; mkdir -p "$WORK" -trap 'rm -rf "$WORK"' EXIT - -# La finestra del viaggio, in secondi, alla durata di riferimento della scena. -# Si campiona dentro, non ai bordi, per non prendere il sollevamento. -# -# I CAMPIONI SEGUONO LA DURATA. Da quando le battute interne sono scritte -# rispetto a una durata di riferimento e `durationInFrames` le scala (vedi -# primitives/tempo.ts), un banco coi secondi scritti a mano guarda una scena che -# non esiste piu': su un render dimezzato questi sette istanti cadrebbero tutti -# dopo l'atterraggio e la card risulterebbe ferma. -BASE_FRAMES=240 -NF=$(ffprobe -v error -count_frames -select_streams v:0 \ - -show_entries stream=nb_read_frames -of csv=p=0 "$SRC" | tr -dc '0-9') -case "${NF:-}" in ''|*[!0-9]*) echo "non riesco a contare i fotogrammi di $SRC" >&2; exit 3 ;; esac -SAMPLES=$(python3 -c " -k = $NF / $BASE_FRAMES -print(' '.join('%.2f' % (t * k) for t in (1.40, 1.80, 2.20, 2.60, 3.00, 3.40, 3.80)))") - -# Larghezza di una colonna in coordinate lastra: (2400-280-420-48-40)/3 = 537, -# che scalata nel quadro renderizzato vale circa 300px. La card deve percorrere -# almeno mezza colonna, altrimenti non ha cambiato posto. -MIN_TRAVEL_PX=120 - -# LA DIFFERENZA SI PRENDE SOLO SULLA META' DELLA BOARD, e questo pezzo e' nato -# da un fallimento. Da quando la lastra porta il pannello assistente nella meta' -# bassa, differenziare il fotogramma intero non isola piu' la card: il pannello -# e' fermo rispetto alla lastra ma si sposta insieme alla camera, e sono -# migliaia di pixel di testo ad alto contrasto che tirano il centroide verso il -# basso. La misura passava da 154px a 83 e inventava un passo all'indietro, -# 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, e lo calcola il manifest da topics/geometry.ts. Se il pannello si sposta, il -# taglio lo segue. -read -r BAND < <(node "$ROOT/scripts/manifest.mjs" handoff-band 2>/dev/null) -case "${BAND:-}" in ''|*[!0-9]*) echo "non riesco a leggere la geometria dal manifest: '${BAND:-}'" >&2; exit 3 ;; esac - -prev="" -xs=() - -for t in $SAMPLES; do - f="$WORK/f$t.png" - ffmpeg -v error -ss "$t" -i "$SRC" -frames:v 1 -vf "scale=960:-2" -y "$f" - [ -s "$f" ] || { echo "estrazione fallita a ${t}s" >&2; exit 3; } - - if [ -n "$prev" ]; then - # I pixel cambiati fra due campioni. La soglia toglie il rumore di encoding - # e le micro-variazioni della camera, che si muove pianissimo. - "${IM_CONVERT[@]}" "$prev" "$f" -compose difference -composite \ - -colorspace Gray -threshold 12% -gravity North -crop "100x${BAND}%+0+0" +repage \ - "$WORK/d$t.png" - - # Il centroide dei pixel accesi, e quanti sono. - # Il baricentro dei pixel accesi, e quanti sono. Il blocco stava qui come - # heredoc dentro la process substitution: bash 3.2 non sa leggerla, quindi - # su macOS lo script non partiva e dava la colpa alla scena. - read -r cx n < <(python3 "$ROOT/scripts/_centroid.py" "$WORK/d$t.png") - # Se la lettura non ha prodotto due numeri, lo strumento non ha risposto: - # senza questo, `n` resta vuoto, il test numerico sotto stampa "integer - # expected" e il campione viene semplicemente saltato. Il verdetto finale - # dice allora "la card non viaggia", che e' una diagnosi falsa per un - # problema di attrezzatura. - case "${cx:-}|${n:-}" in - *'|'|'|'*) echo "lettura del centroide fallita a ${t}s" >&2; exit 3 ;; - esac - case "$n" in ''|*[!0-9]*) echo "conteggio non numerico a ${t}s: '$n'" >&2; exit 3 ;; esac - - printf ' %-6s x=%-8s pixel=%s\n' "${t}s" "$cx" "$n" - # Sotto questa soglia il frame non contiene un oggetto in movimento, solo - # rumore: non e' un campione, va scartato. - if [ "$n" -gt 400 ]; then xs+=("$cx"); fi - fi - prev="$f" -done - -echo -if [ "${#xs[@]}" -lt 4 ]; then - echo "FALLITO: solo ${#xs[@]} campioni con movimento reale. La card non viaggia," >&2 - echo "oppure si sposta di scatto in un frame solo, che e' un taglio." >&2 - exit 1 -fi - -MIN_TRAVEL_PX="$MIN_TRAVEL_PX" python3 - "${xs[@]}" <<'PY' -import os, sys -# La soglia arriva dall'ambiente: scritta due volte, la copia dentro python -# sarebbe rimasta indietro alla prima modifica della costante sopra. -MIN = float(os.environ["MIN_TRAVEL_PX"]) -xs = [float(v) for v in sys.argv[1:]] -span = xs[-1] - xs[0] -# Monotona a meno di un filo di tolleranza: il centroide include anche la coda -# che si richiude, quindi puo' oscillare di pochi pixel. -back = sum(1 for a, b in zip(xs, xs[1:]) if b < a - 8) -print(f" campioni: {len(xs)} da x={xs[0]:.0f} a x={xs[-1]:.0f} spostamento={span:.0f}px") -print(f" passi all'indietro: {back}") -print() -if span < MIN: - print(f"FALLITO: la card si sposta di {span:.0f}px, sotto i {MIN:.0f} richiesti.", file=sys.stderr) - sys.exit(1) -if back > 1: - print(f"FALLITO: il movimento torna indietro {back} volte, non e' un tragitto.", file=sys.stderr) - sys.exit(1) -print(f"VERDETTO: la card attraversa {span:.0f}px in avanti, senza tornare indietro.") -PY diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs index 1812acc..afce3dc 100755 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -10,7 +10,14 @@ // 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 +// node scripts/manifest.mjs cam06 le varianti dello specimen CAM-06, in JSON +// node scripts/manifest.mjs chain|fill --ratio R le tracce della camera in un rapporto +// node scripts/manifest.mjs film --ratio R le finestre del film +// node scripts/manifest.mjs bench NOME --ratio R cosa deve trovare il banco NOME, in JSON +// node scripts/manifest.mjs checks --ratio R i controlli da far girare, per expect.sh +// +// Senza --ratio vale il 16:9. Un rapporto che il catalogo non dichiara esce 2. +import { readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -30,17 +37,73 @@ const catalogScenes = async () => { return c.scenes; }; +const ratioArg = () => argValue("--ratio") ?? "16x9"; + +const catalogJson = async () => { + const { readFile } = await import("node:fs/promises"); + return JSON.parse(await readFile(join(root, "video/src/scenes/catalog.json"), "utf8")); +}; + +// La geometria di un banco sta in un modulo suo, sotto il prodotto: +// video/src/products/topics/benches/.ts, che esporta +// geometry(ratio, { catalog }) e restituisce un oggetto serializzabile. +// Il nome del file e' il nome del banco, quindi aggiungerne uno non tocca +// questo file. +const benchGeometry = async (name, ratio) => { + if (!/^[a-z0-9-]+$/.test(name)) throw new Error(`nome di banco non valido: ${name}`); + const mod = await load(`video/src/products/topics/benches/${name}.ts`); + return mod.geometry(ratio, { catalog: await catalogJson() }); +}; + const commands = { + // Il film di Topics come finestre: per ogni scena, nell'ordine delle giunte, + // l'id della composition da sola, dove comincia nel film e quanto dura. Lo + // legge film-identity.sh per sapere quali fotogrammi confrontare. + film: async () => { + const { chainOrder, filmWindows } = await load("video/src/kit/film.ts"); + const { variantName } = await load("video/src/kit/stage.ts"); + const { poseAt } = await load("video/src/kit/camera.ts"); + const { TOPICS_TRACKS } = await load("video/src/products/topics/tracks.ts"); + const ratio = ratioArg(); + const windows = filmWindows(chainOrder(await catalogScenes())); + // Il fotogramma in cui la camera della scena si muove di piu': li' un frame di + // scarto cambia per forza l'immagine, e il negativo di film-identity lo usa. + // Ai bordi una scena puo' partire o finire ferma, e a meta' PromptInput cade + // fra due parole dello streaming. + const moving = (w) => { + const track = TOPICS_TRACKS[w.id](w.frames, ratio); + let best = 0; + let bestD = -1; + for (let f = 0; f < w.frames - 1; f++) { + const a = poseAt(track, f); + const b = poseAt(track, f + 1); + const d = + Math.abs(b.pushZ - a.pushZ) + Math.abs(b.slideX - a.slideX) + Math.abs(b.slideY - a.slideY) + + 50 * (Math.abs(b.yaw - a.yaw) + Math.abs(b.pitch - a.pitch)); + if (d > bestD) { + bestD = d; + best = f; + } + } + return best; + }; + return { + film: variantName("TopicsFilm", ratio), + windows: windows.map((w) => ({ ...w, id: variantName(w.id, ratio), moving: moving(w) })), + }; + }, + // GIU-04 sulla catena del catalogo: le tracce delle scene, nell'ordine in cui // si agganciano, lette da checkChain. --linear toglie tutti gli easing, ed e' // il controllo negativo: le giunte non sono piu' a riposo. chain: async () => { const { checkChain, linearized } = await load("video/src/kit/camera.ts"); const { TOPICS_TRACKS } = await load("video/src/products/topics/tracks.ts"); + const ratio = ratioArg(); const scenes = (await catalogScenes()).map((s) => { const build = TOPICS_TRACKS[s.id]; if (!build) throw new Error(`nessuna traccia per ${s.id} in products/topics/tracks.ts`); - const track = build(s.durationInFrames); + const track = build(s.durationInFrames, ratio); return { id: s.id, frames: s.durationInFrames, @@ -59,12 +122,14 @@ const commands = { const { project } = await load("video/src/kit/project.ts"); const { TOPICS_TRACKS } = await load("video/src/products/topics/tracks.ts"); const slab = await load("video/src/products/topics/geometry.ts"); + const { STAGES } = await load("video/src/kit/stage.ts"); const offset = Number(argValue("--push-offset") ?? 0); - const stage = slab.TOPICS_STAGE; + const ratio = ratioArg(); + const stage = STAGES[ratio]; const out = []; for (const s of await catalogScenes()) { if (!s.fill) continue; - const track = TOPICS_TRACKS[s.id](s.durationInFrames); + const track = TOPICS_TRACKS[s.id](s.durationInFrames, ratio); const from = Math.floor(s.durationInFrames * 0.2); let first = null; let minMargin = Infinity; @@ -131,48 +196,22 @@ const commands = { return out; }, - // La geometria che i banchi delle scene di Topics chiedevano a topics/geometry.ts con - // uno script node scritto dentro di se'. Stesso formato di uscita di prima, - // cosi' i banchi leggono con lo stesso `read`. - "handoff-band": async () => { - const t = await load("video/src/products/topics/benches.ts"); - return t.handoffBand(); - }, - "focus-sharpness": async () => { - const g = (await load("video/src/products/topics/benches.ts")).cardFocusGeometry(); - return [g.cw, g.ch, g.cx, g.cy, g.zoom, g.wx, g.wy, g.k].join(" "); - }, - "fixture-screenshot": async () => { - const g = (await load("video/src/products/topics/benches.ts")).cardFocusGeometry(); - return [g.cx, g.cy, g.wx, g.wy, g.k].join(" "); - }, - // Su una riga: contrast-floor.py legge l'ultima riga dell'uscita. - "contrast-crop": async () => { - const t = await load("video/src/products/topics/benches.ts"); - return JSON.stringify(t.contrastCrop()); - }, - // I casi su cui project-check.py confronta la proiezione con il DOM: due - // lastre, tre stage, le sei pose della catena di Topics, cinque punti ciascuno - // messi apposta lontano dal centro e fuori dagli assi. + // lastre, tre stage, le sette pose della catena di Topics NEL RAPPORTO DI + // QUELLO STAGE (poses.ts), cinque punti ciascuno messi apposta lontano dal + // centro e fuori dagli assi. "project-cases": async () => { const { STAGES, RATIOS } = await load("video/src/kit/stage.ts"); const { cssPerspectiveOrigin } = await load("video/src/kit/rig.ts"); const { project } = await load("video/src/kit/project.ts"); const slab = await load("video/src/products/topics/geometry.ts"); + const { TOPICS_POSES } = await load("video/src/products/topics/poses.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 poses = Object.keys(TOPICS_POSES["16x9"]); const frac = [ [0.1, 0.1], [0.9, 0.1], @@ -185,7 +224,7 @@ const commands = { const stage = STAGES[ratio]; for (const [name, { rig, size }] of Object.entries(slabs)) { for (const poseName of poses) { - const p = slab[poseName]; + const p = TOPICS_POSES[ratio][poseName]; const pose = { slideY: 0, ...p }; cases.push({ id: `${name}-${ratio}-${poseName}`, @@ -206,6 +245,69 @@ const commands = { return cases; }, + // Cosa deve trovare un banco, per rapporto: ritagli, frame, soglie. Il + // calcolo sta nel modulo del banco sotto il prodotto (vedi benchGeometry). + bench: async () => { + const name = process.argv[3]; + if (!name || name.startsWith("--")) throw new Error("uso: manifest.mjs bench NOME [--ratio R]"); + return benchGeometry(name, ratioArg()); + }, + + // I controlli di un rapporto, uno per riga, nel formato di expect.sh. Ogni + // banco li dichiara in scripts/checks/.mjs, che esporta + // checks(ctx) e restituisce righe; qui si raccolgono in ordine di nome. + // Il contesto da' il rapporto, i nomi delle varianti e la geometria dei + // banchi, cosi' i moduli dei controlli non leggono mai un prodotto da se'. + checks: async () => { + const ratio = ratioArg(); + const { variantName, STAGES } = await load("video/src/kit/stage.ts"); + const catalog = await catalogJson(); + const scenes = catalog.scenes.map((s) => ({ + ...s, + variant: variantName(s.id, ratio), + file: `video/out/${variantName(s.slug, ratio)}.mp4`, + })); + const ctx = { + ratio, + stage: STAGES[ratio], + variantName: (base) => variantName(base, ratio), + scenes, + scene: (id) => { + const s = scenes.find((x) => x.id === id); + if (!s) throw new Error(`scena sconosciuta: ${id}`); + return s; + }, + fixtures: catalog.tempoFixtures.map((f) => ({ + ...f, + variant: variantName(f.id, ratio), + file: `video/out/${variantName(f.slug, ratio)}.mp4`, + })), + bench: (name) => benchGeometry(name, ratio), + tmp: (name) => `$CHECKS_TMP/${variantName(name, ratio)}`, + }; + const dir = join(root, "scripts/checks"); + const lines = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".mjs")).sort()) { + const mod = await import(pathToFileURL(join(dir, file)).href); + for (const c of await mod.checks(ctx)) { + const fields = + c.run !== undefined + ? ["run", c.label, c.run] + : ["expect", String(c.rc), c.bench, c.target, c.role, c.cmd]; + for (const f of fields) { + if (typeof f !== "string" || f === "" || /[\t\n]/.test(f)) { + throw new Error(`${file}: campo non valido in ${JSON.stringify(c)}`); + } + } + if (c.run === undefined && !["positivo", "negativo"].includes(c.role)) { + throw new Error(`${file}: ruolo sconosciuto ${c.role}`); + } + lines.push(fields.join("\t")); + } + } + return lines.join("\n"); + }, + cam06: async () => { const list = await load("video/src/specimens/list.ts"); return list.CAM06_SPECIMENS.map((s) => ({ @@ -229,6 +331,13 @@ const commands = { }; const cmd = process.argv[2]; +if (argValue("--ratio") !== undefined) { + const { ratios } = await catalogJson(); + if (!ratios.includes(argValue("--ratio"))) { + console.error(`catalog.json non dichiara il rapporto "${argValue("--ratio")}" (ratios: ${ratios.join(", ")})`); + process.exit(2); + } +} if (!commands[cmd]) { console.error(`uso: node scripts/manifest.mjs <${Object.keys(commands).join("|")}>`); process.exit(2); diff --git a/scripts/no-product-literals.sh b/scripts/no-product-literals.sh index 44a1ab4..271961b 100755 --- a/scripts/no-product-literals.sh +++ b/scripts/no-product-literals.sh @@ -6,42 +6,48 @@ # PERCHE' ESISTE. Quattro banchi importavano topics/geometry.ts dentro uno script node # scritto nel proprio corpo e rifacevano i conti di ripresa di Topics. Adesso li # chiedono a scripts/manifest.mjs, che e' l'unico posto in cui un banco puo' -# leggere video/src. Senza un controllo, il primo banco nuovo scritto di fretta +# leggere un prodotto. Senza un controllo, il primo banco nuovo scritto di fretta # tornerebbe a importare topics/geometry.ts e a misurare solo Topics, e nessuno se ne # accorgerebbe perche' non fallirebbe. # -# COSA CERCA. Nelle righe di codice degli script (non nei commenti) un percorso -# a un modulo .ts o .tsx di video/src. Sono ammessi solo manifest.mjs, che -# esiste per quello, e geometry-snapshot.mjs, che fotografa topics/geometry.ts apposta per +# COSA CERCA. Nelle righe di codice degli script (non nei commenti), anche nelle +# sottocartelle, un percorso a un modulo .ts o .tsx di un prodotto +# (video/src/products/). Sono ammessi solo manifest.mjs, che esiste per quello, +# e geometry-snapshot.mjs, che fotografa topics/geometry.ts apposta per # confrontarne due versioni. # +# IL KIT NON E' UN PRODOTTO. Leggere video/src/kit/stage.ts per sapere quanto e' +# largo un 9:16 non lega un banco a Topics, ed e' il motivo per cui la regola +# guarda products/ e non tutto video/src. La prima versione vietava ogni +# percorso sotto video/src, e bocciava catalog.mjs per aver chiesto al kit le +# dimensioni di uno stage. +# # Uso: ./scripts/no-product-literals.sh [cartella-degli-script] # -# Esce 0 se nessun banco legge video/src da se', 1 altrimenti. +# Esce 0 se nessun banco legge un prodotto 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 +while IFS= read -r f; do 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" \ + hits=$(grep -n -E 'video/src/products/[^ "'"'"']+\.tsx?' "$f" \ | grep -v -E '^[0-9]+:[[:space:]]*(#|//)' || true) if [ -n "$hits" ]; then - echo " $(basename "$f") legge video/src da se':" + echo " ${f#"$DIR"/} legge un prodotto da se':" echo "$hits" | sed 's/^/ /' FOUND=1 fi -done +done < <(find "$DIR" -type f \( -name '*.sh' -o -name '*.py' -o -name '*.mjs' \) -not -path '*/__pycache__/*' | sort) 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." +echo "VERDETTO: nessun banco legge un prodotto da se'; la geometria passa tutta da manifest.mjs." exit 0 diff --git a/scripts/rest-point.sh b/scripts/rest-point.sh index 22970f6..93e76cb 100755 --- a/scripts/rest-point.sh +++ b/scripts/rest-point.sh @@ -12,13 +12,7 @@ # CONSECUTIVI non si misura niente: le camere qui si muovono di frazioni di # grado al frame, e a meta' di prompt-input, dove si sta digitando, cambia lo # 0,076% dei pixel, cioe' quasi quanto ai bordi. Con cinque frame di distanza il -# segnale si stacca dal rumore. Misurato sulle cinque scene, bordo e mezzo: -# -# prompt-input 0,000% 0,434% -# ui-mockup 0,041% 0,218% -# card-handoff 0,000% 0,329% -# card-focus 0,000% 1,019% -# card-release 0,118% 0,952% +# segnale si stacca dal rumore. # # La tolleranza al rumore di codifica e' la stessa di seam.sh, per lo stesso # motivo: due fotogrammi sopravvivono a una codifica H.264 e non tornano @@ -44,55 +38,69 @@ # ffmpeg -ss 2 -t 2 -i video/out/card-focus.mp4 /tmp/mosso.mp4 # ./scripts/rest-point.sh /tmp/mosso.mp4 # esce 1 # -# Uso: ./scripts/rest-point.sh [scena.mp4] +# I controlli generati (scripts/checks/rest-point.mjs) prendono il ritaglio dal +# 35% al 70% della scena invece che da 2 a 4 secondi, cosi' una CardFocus +# ritempificata non sposta il ritaglio sui bordi rallentati. +# +# Uso: ./scripts/rest-point.sh [--ratio 16x9|9x16|4x5] [scena.mp4] +# +# Esce 0 se le scene dichiarate ferme lo sono, 1 se una non lo e', 2 se il +# mezzo non si muove e manca il controllo, 3 se un render manca o non si legge. set -uo pipefail -. "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" export LC_NUMERIC=C ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# Il rapporto: le scene dichiarate ferme e i loro file sono quelli di quel +# formato (card-release-9x16.mp4). Senza, il 16:9 di sempre. +RATIO=16x9 +if [ "${1:-}" = "--ratio" ]; then + RATIO="${2:?serve il rapporto}" + shift 2 +fi + # Quanti frame di distanza fra i due fotogrammi confrontati. PASSO=5 -# UNA SOGLIA ASSOLUTA QUI NON REGGE, e averla lasciata ha tenuto main rossa. -# 0,30 veniva dalle letture di macOS; su Linux, con ImageMagick 6, le stesse -# scene leggono cinque-dieci volte tanto e tre su cinque la sfondavano: +# UNA SOGLIA ASSOLUTA QUI NON REGGE, e averla lasciata ha tenuto main rossa: le +# letture di ImageMagick 6 in CI valevano cinque-dieci volte quelle della 7 sul +# Mac. Da allora il bordo si confronta col mezzo della sua stessa scena. Poi il +# conto e' passato a ffmpeg (_pixeldiff.sh), che da' lo stesso numero nei due +# posti, e le scene si misurano in tre rapporti. Bordo peggiore su mezzo, per le +# scene che dichiarano i bordi fermi: # -# macOS Linux -# ui-mockup 0,041 / 0,000 0,688 / 0,021 -# card-focus 0,000 / 0,229 0,053 / 2,166 -# card-release 0,118 / 0,043 0,834 / 0,442 +# 16:9 9:16 4:5 +# card-release 0,20 0,19 0,21 +# board-orbit 0,07 0,24 0,16 # -# Le scale non sono confrontabili, i rapporti si'. Un bordo e' fermo se si muove -# molto meno del mezzo della sua stessa scena, che e' poi la cosa che si voleva -# dire. Il rapporto peggiore misurato su una scena che dichiara i bordi fermi e' -# 0,147 (card-release su Linux); su un ritaglio che si muove ovunque vale circa 1. -FERMA_REL=0.30 +# In 9:16 l'inizio di BoardOrbit si muove di piu' perche' la board, attenuata a +# fine PromptInput, risale d'opacita' su una lastra piu' ingrandita. 0,45 sta +# quasi due volte sopra il peggiore; un ritaglio preso dal mezzo di una scena si +# muove ai bordi quanto nel mezzo, cioe' circa 1. +FERMA_REL=0.45 # Il mezzo deve muoversi almeno questo, altrimenti un fermo immagine passerebbe: # tre zeri sono tre letture concordi e non provano niente. Il mezzo piu' fermo -# misurato e' 0,218 (ui-mockup su macOS). +# fra le scene che dichiarano i bordi fermi e' 2,53 (card-release in 9:16). MOTO_MIN=0.12 # E deve muoversi almeno questo PIU' dei bordi, altrimenti l'indice non # distingue una scena ferma da una in movimento. Il rapporto piu' stretto -# misurato e' 2,2 (ui-mockup su Linux). +# misurato e' 4,1 (board-orbit in 9:16). SEPARA=2 TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT # Quanti pixel diversi fra due fotogrammi, in percentuale sul quadro. coppia() { - local a="$1" b="$2" tot - tot=$("${IM_IDENTIFY[@]}" -format "%[fx:w*h]" "$a") - local d - d=$("${IM_COMPARE[@]}" -metric AE -fuzz 4% "$a" "$b" null: 2>&1 || true) - # AE stampa "542.562 (0.000261652)": si tiene l'intero iniziale, e su un - # conteggio esattamente zero la stringa e' "0 (0)", senza punti. - d=$(echo "$d" | tr -d '[:space:]' | sed 's/[^0-9].*$//') - case "${d:-}" in ''|*[!0-9]*) echo "confronto fallito su $(basename "$a")" >&2; exit 3 ;; esac - python3 -c "print(f'{$d / $tot * 100:.3f}')" + # Il conto lo fa ffmpeg (_pixeldiff.sh), uguale su macOS e sulla CI. Con + # `compare -fuzz` di ImageMagick l'inizio di BoardOrbit in 9:16, dove + # l'opacita' della board risale piano, valeva 0,11 del mezzo sul Mac e 0,32 + # in CI: la stessa scena passava da una parte e veniva bocciata dall'altra. + local out + out=$("$(dirname "${BASH_SOURCE[0]}")/_pixeldiff.sh" "$1" "$2" 20) || { + echo "confronto fallito su $(basename "$1")" >&2; exit 3; } + python3 -c "print(f'{${out%% *} * 100:.3f}')" } -# I primi due e gli ultimi due fotogrammi di un render. estremi() { local src="$1" n m i f # csv=p=0 stampa "150," con la virgola in coda, e la guardia numerica sotto @@ -133,19 +141,19 @@ echo printf ' %-16s %10s %10s %12s\n' "scena" "inizio" "fine" "mezzo (ctrl)" # Niente mapfile e niente array associativi: sono bash 4, e `/usr/bin/env bash` -# su macOS trova la 3.2. E' lo stesso inciampo che ha tenuto handoff-travel.sh +# su macOS trova la 3.2. E' lo stesso inciampo che ha tenuto handoff-travel.py # fermo per mesi su questa piattaforma mentre in CI passava. rotte="" -DICHIARANO=$(node "$ROOT/scripts/catalog.mjs" rest | tr '\n' ' ') +DICHIARANO=$(node "$ROOT/scripts/catalog.mjs" rest --ratio "$RATIO" | tr '\n' ' ') # Con un argomento si misura quello e basta: e' cosi' che si prova che il banco # sa uscire rosso. Senza, si misura tutto il catalogo. UNO=no if [ "$#" -gt 0 ]; then - [ -f "$1" ] || { echo "non trovo $1" >&2; exit 1; } + [ -f "$1" ] || { echo "non trovo $1" >&2; exit 3; } echo "$1" > "$TMP/slugs.txt" UNO=si else - node "$ROOT/scripts/catalog.mjs" slugs > "$TMP/slugs.txt" || { + node "$ROOT/scripts/catalog.mjs" slugs --ratio "$RATIO" > "$TMP/slugs.txt" || { echo "il catalogo non ha restituito nessuna scena" >&2; exit 3; } [ -s "$TMP/slugs.txt" ] || { echo "il catalogo non ha restituito nessuna scena" >&2; exit 3; } fi @@ -158,8 +166,8 @@ while IFS= read -r slug; do esac if [ ! -f "$src" ]; then echo "manca il render: video/out/$slug.mp4" >&2 - echo " cd video && node ../scripts/catalog.mjs render" >&2 - exit 1 + echo " cd video && node ../scripts/catalog.mjs render --ratio $RATIO" >&2 + exit 3 fi # `estremi` gira in una sottoshell, quindi un suo exit non ferma questo # ciclo: il risultato va controllato qui. @@ -181,7 +189,7 @@ while IFS= read -r slug; do # bordi e' "sotto FERMA_REL volte il mezzo", e con il mezzo a zero quella # soglia e' zero: qualunque granello di rumore sui bordi la sfonda, e il # banco dichiara mossa una scena che sta ferma. E' la stessa degenerazione - # che click-gap.sh aveva sulla mediana, e va intercettata prima di dare + # che click-gap.py aveva sulla mediana, e va intercettata prima di dare # verdetti, non dopo. Un fermo immagine cade esattamente qui. if [ "$dichiara" = si ] && python3 -c "exit(0 if $mid < $MOTO_MIN else 1)"; then diff --git a/scripts/seam.sh b/scripts/seam.sh index 0584464..e8cf5f3 100755 --- a/scripts/seam.sh +++ b/scripts/seam.sh @@ -21,6 +21,17 @@ # soglia sta sulla frazione di pixel oltre una tolleranza percettiva, non # sull'uguaglianza binaria. # +# L'ORDINE DEI VERDETTI, e l'errore che c'era. Il controllo di separazione +# veniva prima della soglia: su una giunta rotta la giunta e il taglio finto +# danno numeri simili, quindi il banco diceva "misura inutile" (uscita 2) invece +# di "salto" (uscita 1), e non usciva mai 1. Il taglio finto serve a dire se lo +# strumento vede qualcosa, e lo strumento non vede niente solo quando anche il +# taglio finto e' quasi zero, cioe' quando la scena B non si muove. Adesso: se il +# taglio finto sta sotto TAGLIO_MIN la misura e' inutile (2); se la giunta resta +# sotto la soglia e il taglio e' almeno dieci volte peggiore, la giunta e' +# continua (0); altrimenti c'e' un salto (1). Una coppia al contrario +# (card-handoff poi ui-mockup) esce 1 in tutti e tre i rapporti. +# # Uso: ./scripts/seam.sh [scena-A.mp4] [scena-B.mp4] # # I due argomenti sono opzionali e senza di essi la coppia e' quella storica, @@ -31,8 +42,6 @@ # accorge, che e' esattamente il modo in cui una misura smette di misurare. set -uo pipefail -# ImageMagick si chiama `magick` sulla 7 e `convert`/`compare` sulla 6. -. "$(dirname "${BASH_SOURCE[0]}")/_magick.sh" export LC_NUMERIC=C ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -41,14 +50,22 @@ B="${2:-$ROOT/video/out/card-handoff.mp4}" # Oltre questa frazione di pixel diversi, la giunta e' un taglio. SOGLIA=0.02 -# Differenza per canale sotto cui due pixel sono "lo stesso pixel" a occhio. -FUZZ="4%" +# Sotto questa frazione anche il taglio finto e' quasi uguale: la scena B non si +# muove abbastanza da provare che lo strumento veda una differenza. I tagli +# finti delle scene del catalogo stanno fra il 2,8% e l'11% nei tre rapporti; un +# decimo di punto e' sotto tutti con un margine di ventotto volte. +TAGLIO_MIN=0.001 +# Differenza di grigio, su 255, sotto cui due pixel sono "lo stesso pixel": il +# rumore di due codifiche H.264 distinte sta sotto. Misurato con questa soglia: +# le giunte del catalogo cambiano fino allo 0,21% dei pixel, i tagli di controllo +# dal 2,8% all'11%, la coppia al contrario il 41%. +SOGLIA_PIXEL=20 for f in "$A" "$B"; do if [ ! -f "$f" ]; then echo "manca il render: ${f#"$ROOT"/}" >&2 echo " cd video && npx remotion render out/$(basename "$f")" >&2 - exit 1 + exit 3 fi done @@ -59,16 +76,16 @@ trap 'rm -rf "$TMP"' EXIT # quindi al termine resta l'ultimo. Costa una passata sul video e in cambio non # richiede di sapere quanti frame sono: la prima versione cercava con `-sseof` # e usciva a mani vuote, che e' il modo in cui una misura mente senza fallire. -ffmpeg -v error -i "$A" -fps_mode passthrough -update 1 -y "$TMP/a-last.png" +ffmpeg -nostdin -v error -i "$A" -fps_mode passthrough -update 1 -y "$TMP/a-last.png" # Primo fotogramma di B. -ffmpeg -v error -i "$B" -frames:v 1 -y "$TMP/b-first.png" +ffmpeg -nostdin -v error -i "$B" -frames:v 1 -y "$TMP/b-first.png" # Un fotogramma dal mezzo di B: il taglio finto, il controllo negativo. # La meta' si calcola, non si scrive: era 4 secondi, che e' meta' di # card-handoff e non meta' di nient'altro. Su una scena piu' corta quel valore # sarebbe caduto oltre la fine e il confronto avrebbe girato su un fotogramma # vuoto, cioe' su un controllo che boccia sempre e non prova niente. mid=$(python3 -c "print(f'{float('$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$B")') / 2:.3f}')") -ffmpeg -v error -ss "$mid" -i "$B" -frames:v 1 -y "$TMP/b-mid.png" +ffmpeg -nostdin -v error -ss "$mid" -i "$B" -frames:v 1 -y "$TMP/b-mid.png" # Se un'estrazione e' andata a vuoto, fermarsi qui. Senza questo controllo il # confronto gira su file inesistenti e stampa 0.000%, cioe' il numero che si @@ -81,28 +98,16 @@ for f in a-last b-first b-mid; do done misura() { - local x="$1" y="$2" - local diff - # AE conta i pixel che differiscono oltre la fuzz. Su stderr, e con exit 1 - # quando ce ne sono: entrambi previsti. - diff=$("${IM_COMPARE[@]}" -metric AE -fuzz "$FUZZ" "$x" "$y" null: 2>&1 || true) - # AE stampa "542.562 (0.000261652)": si tiene l'intero iniziale. Tagliare dal - # primo punto bastava finche' il conteggio non era esattamente zero, perche' - # allora la stringa e' "0 (0)", non ha punti, e restava "0(0)": il controllo - # numerico sotto la bocciava e lo script usciva 3 dicendo che il confronto era - # fallito. Cioe' proprio su una giunta perfetta. - diff=$(echo "$diff" | tr -d '[:space:]' | sed 's/[^0-9].*$//') - # AE deve dare un intero. Se qui c'e' un messaggio d'errore, il confronto non - # e' avvenuto e proseguire vorrebbe dire stampare un numero inventato. - case "$diff" in - ''|*[!0-9]*) - echo "confronto fallito su $(basename "$x") vs $(basename "$y"): $diff" >&2 - exit 3 - ;; - esac - local tot - tot=$("${IM_IDENTIFY[@]}" -format '%[fx:w*h]' "$x") - python3 -c "print(f'{$diff / $tot:.5f} {$diff}')" + # Il conto lo fa ffmpeg (_pixeldiff.sh), uguale su macOS e sulla CI: con + # `compare -fuzz` di ImageMagick la stessa coppia dava numeri fino a sei volte + # diversi fra la 7 e la 6, e una coppia al contrario che sul Mac differiva del + # 10% in CI ne risultava quasi identica. + local out + out=$("$(dirname "${BASH_SOURCE[0]}")/_pixeldiff.sh" "$1" "$2" "$SOGLIA_PIXEL") || { + echo "confronto fallito su $(basename "$1") vs $(basename "$2")" >&2 + exit 3 + } + echo "$out" } read -r seam_frac seam_px < <(misura "$TMP/a-last.png" "$TMP/b-first.png") @@ -117,22 +122,25 @@ printf ' %-34s %8.3f%% %12s\n' "ultimo A vs meta' B (taglio)" \ "$(python3 -c "print($cut_frac * 100)")" "$cut_px" echo -# Il controllo negativo deve essere almeno 10 volte peggiore, altrimenti la -# misura non separa una giunta da un taglio e non prova niente. Il rapporto si -# calcola sui conteggi di pixel: la frazione e' stampata a cinque decimali, e su -# una giunta quasi perfetta vale 0.00000, quindi dividerci dentro stampava -# numeri a sette cifre al posto di una separazione. -if ! python3 -c "exit(0 if $cut_frac > $seam_frac * 10 else 1)"; then - echo "MISURA INUTILE: giunta e taglio danno numeri simili, la soglia non separa." >&2 +# Lo strumento vede qualcosa? Se anche il taglio finto e' quasi zero, la scena B +# e' ferma e nessuna giunta si puo' giudicare contro di lui. +if ! python3 -c "exit(0 if $cut_frac >= $TAGLIO_MIN else 1)"; then + echo "MISURA INUTILE: anche il taglio di controllo e' quasi uguale (sotto $(python3 -c "print($TAGLIO_MIN*100)")%)," >&2 + echo "la scena B non si muove abbastanza per giudicare la giunta." >&2 exit 2 fi -if python3 -c "exit(0 if $seam_frac <= $SOGLIA else 1)"; then +# La giunta deve stare sotto la soglia E il taglio finto deve essere almeno +# dieci volte peggiore. Il rapporto si calcola sui conteggi di pixel: la +# frazione e' stampata a cinque decimali, e su una giunta quasi perfetta vale +# 0.00000, quindi dividerci dentro stampava numeri a sette cifre. +if python3 -c "exit(0 if $seam_frac <= $SOGLIA and $cut_frac > $seam_frac * 10 else 1)"; then echo "VERDETTO: giunta continua (sotto $(python3 -c "print($SOGLIA*100)")%), e il taglio di controllo e' $(python3 -c "print(f'{$cut_px/max($seam_px,1):.0f}')")x peggiore." exit 0 fi -echo "VERDETTO: c'e' un salto fra $(basename "$A" .mp4) e $(basename "$B" .mp4)." >&2 +echo "VERDETTO: c'e' un salto fra $(basename "$A" .mp4) e $(basename "$B" .mp4): la giunta non e' dieci volte" >&2 +echo "meglio del taglio di controllo, o supera la soglia." >&2 echo "Le due pose non coincidono: la posa di giunzione va letta da" >&2 echo "products/topics/geometry.ts da entrambe le scene, non riscritta in una delle due." >&2 exit 1 diff --git a/scripts/tempo.py b/scripts/tempo.py index 7d28268..4de1427 100755 --- a/scripts/tempo.py +++ b/scripts/tempo.py @@ -26,26 +26,84 @@ invece di ritempificata. Li' il confronto normalizzato e' quello sbagliato e il banco lo dice. -Uso: ./scripts/tempo.py [lungo.mp4 breve.mp4] +IN OGNI RAPPORTO, E SU TUTTO IL QUADRO. La prima versione rimpiccioliva a un +480x270 scritto a mano e teneva i primi 480x270 byte: in 16:9 era il quadro +intero, in 9:16 era il terzo alto e in 4:5 poco meno della meta', e nessuno se +ne accorgeva perche' il banco passava lo stesso. Adesso la scala e' un quarto +del lato del render, qualunque sia, e si confronta tutto il fotogramma. In 16:9 +e' ancora 480x270, quindi i numeri di prima non cambiano. + +LE FINESTRE PERCETTIVE, e perche' il banco le chiede. Il residuo sta tutto +nelle finestre in cui una soglia non scala (in CardHandoff dalla presa alla +posa: la card in ritardo di tre frame sulla mano). In 16:9 pesava poco e la +mediana su tutto il tratto passava con 18x; in 9:16 la card e' grande il doppio +e la camera la segue, e la stessa mediana dava 2,8x su un render giusto. Con +`--percettive a-b` il confronto si fa in due parti: FUORI dalle finestre il +tempo normalizzato deve battere l'altro di VANTAGGIO volte, DENTRO il residuo +deve esserci, altrimenti le soglie hanno scalato anche loro. Le finestre le +dichiara la scena, nel manifest (`bench tempo`): il banco non sa quali sono. + +Uso: ./scripts/tempo.py [lungo.mp4 breve.mp4] [--percettive a-b[,c-d]] + +Esce 0 se il tempo scala, 1 se la scena breve e' un ritaglio o se le soglie +percettive hanno scalato anche loro, 3 se un render manca, i due render hanno +quadri diversi o nel tratto non succede niente. """ +import argparse import pathlib import subprocess import sys ROOT = pathlib.Path(__file__).resolve().parent.parent -LUNGO = pathlib.Path(sys.argv[1]) if len(sys.argv) > 2 else ROOT / "video/out/card-handoff.mp4" -BREVE = pathlib.Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "video/out/.fast-card-handoff.mp4" +ap = argparse.ArgumentParser() +ap.add_argument("lungo", nargs="?", default=str(ROOT / "video/out/card-handoff.mp4")) +ap.add_argument("breve", nargs="?", default=str(ROOT / "video/out/.fast-card-handoff.mp4")) +ap.add_argument("--percettive", default="", help="finestre a-b,c-d in frame del render breve") +args = ap.parse_args() +LUNGO = pathlib.Path(args.lungo) +BREVE = pathlib.Path(args.breve) +try: + FINESTRE = [tuple(int(x) for x in w.split("-")) for w in args.percettive.split(",") if w] +except ValueError: + print("finestre illeggibili: %r" % args.percettive, file=sys.stderr) + raise SystemExit(3) # Quante volte il confronto normalizzato deve battere quello non normalizzato. # Misurato: 19x fra i 240 e i 120 fotogrammi di CardHandoff. VANTAGGIO = 4.0 -W, H = 480, 270 SOGLIA_PIXEL = 26 for p in (LUNGO, BREVE): if not p.exists(): print("manca il render: %s" % p, file=sys.stderr) - raise SystemExit(1) + raise SystemExit(3) + + +def quadro(path): + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, + ).stdout.strip() + # ffprobe in csv puo' lasciare una virgola in coda (dati laterali del flusso): + # si prendono i primi due numeri. + try: + w, h = (int(x) for x in out.split(",")[:2]) + except ValueError: + print("non riesco a leggere le dimensioni di %s: %r" % (path, out), file=sys.stderr) + raise SystemExit(3) + return w, h + + +# Un quarto del lato, arrotondato al pari: 1920x1080 -> 480x270 come prima, +# 1080x1920 -> 270x480, 1080x1350 -> 270x338. La densita' di campionamento e' +# la stessa in ogni rapporto. +if quadro(LUNGO) != quadro(BREVE): + print("i due render hanno quadri diversi: %s contro %s" % (quadro(LUNGO), quadro(BREVE)), + file=sys.stderr) + raise SystemExit(3) +_w, _h = quadro(LUNGO) +W, H = 2 * round(_w / 8), 2 * round(_h / 8) def conta(path): @@ -64,7 +122,7 @@ def conta(path): def frame(path, f): raw = subprocess.run( ["ffmpeg", "-nostdin", "-v", "error", "-i", str(path), - "-vf", "select=eq(n\\,%d),scale=%d:-2,format=gray" % (f, W), + "-vf", "select=eq(n\\,%d),scale=%d:%d,format=gray" % (f, W, H), "-frames:v", "1", "-f", "rawvideo", "-"], capture_output=True, ).stdout @@ -84,14 +142,18 @@ def diff(a, b): raise SystemExit(3) k = n_breve / n_lungo -print("Tempo di %s (%d fotogrammi) contro %s (%d)." - % (LUNGO.name, n_lungo, BREVE.name, n_breve)) +print("Tempo di %s (%d fotogrammi) contro %s (%d), confrontati a %dx%d." + % (LUNGO.name, n_lungo, BREVE.name, n_breve, W, H)) print("Fattore %.3f. Se le battute scalano, il fotogramma f del breve e' il f/%.3f del lungo." % (k, k)) print() -campioni = [f for f in range(10, n_breve - 5, max(6, n_breve // 12))] -norm, gre = [], [] +def dentro(f): + return any(a <= f <= b for a, b in FINESTRE) + + +campioni = [f for f in range(10, n_breve - 5, max(4, n_breve // 30))] +norm, gre, residui = [], [], [] for f in campioni: a = frame(BREVE, f) dn = diff(a, frame(LUNGO, round(f / k))) @@ -99,17 +161,28 @@ def diff(a, b): if dn is None or dg is None: print("estrazione fallita al fotogramma %d" % f, file=sys.stderr) raise SystemExit(3) - norm.append(dn) - gre.append(dg) + if dentro(f): + residui.append(dn) + else: + norm.append(dn) + gre.append(dg) + +if not norm: + print("tutti i campioni cadono nelle finestre percettive: non resta niente da misurare", + file=sys.stderr) + raise SystemExit(3) def mediana(v): s = sorted(v) return s[len(s) // 2] m_norm, m_gre = mediana(norm), mediana(gre) +fuori = " fuori dalle finestre percettive" if FINESTRE else "" +print(" %d campioni%s, %d dentro" % (len(norm), fuori, len(residui))) print(" %-42s %6d px" % ("differenza a tempo normalizzato (mediana)", m_norm)) print(" %-42s %6d px" % ("differenza senza normalizzare (mediana)", m_gre)) -print(" %-42s %6d px" % ("residuo massimo sul normalizzato", max(norm))) +if residui: + print(" %-42s %6d px" % ("residuo massimo nelle finestre percettive", max(residui))) print() if m_norm == 0 and m_gre == 0: @@ -131,7 +204,15 @@ def mediana(v): print("stessa scena piu' veloce, e' la stessa scena con la coda tagliata.", file=sys.stderr) raise SystemExit(1) +if FINESTRE and residui and max(residui) == 0: + print("FALLITO: nelle finestre percettive i due render coincidono a tempo normalizzato.", + file=sys.stderr) + print("Le soglie che non devono scalare (il ritardo della card sulla mano) hanno", file=sys.stderr) + print("scalato anche loro: la scena veloce ha perso il peso degli oggetti.", file=sys.stderr) + raise SystemExit(1) + print("VERDETTO: accorciare la durata accorcia ogni battuta dentro la scena.") -print("Il residuo di %d px sul confronto normalizzato non e' un difetto: sono le" % max(norm)) -print("soglie percettive che di proposito NON scalano. A zero avrebbero scalato") -print("anche loro, e la scena veloce avrebbe perso il peso degli oggetti.") +if residui: + print("Il residuo di %d px nelle finestre percettive non e' un difetto: sono le" % max(residui)) + print("soglie che di proposito NON scalano. A zero avrebbero scalato anche loro,") + print("e la scena veloce avrebbe perso il peso degli oggetti.") diff --git a/showcase/grammatica.html b/showcase/grammatica.html index ae803b0..a75450d 100644 --- a/showcase/grammatica.html +++ b/showcase/grammatica.html @@ -416,14 +416,14 @@

Thirty-six movements,
no cuts.

BenchThe failure it catchesReading on the repo 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 + handoff-travel.pyA 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 itself170-195 px on the slab, three ratios + beats.pyA promised beat that is not on screen. The answer was assembling under the composer2 → 7 → 10 words, three ratios 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 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 + click-gap.pyA 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, three ratios loop-close.pyA demo whose loop tears every pass, or whose second half restarts from scratch instead of continuing the first. Hunting for the mid-loop cut did not work — a cut and a fade look alike to every metric tried — so each two-part demo declares the frame it changes on and the bench looks there, which is the bargain seamAfter already strikes for the scenes11 torn, now 0 type-check.pyType that covers other type or the readout, leaves the frame, snaps while it is visible, or goes away altogether. The first fix of the typography family made sure the frame never went black, and the sentences kept leaving and coming back anyway, in waves — in TYP-01 a fifth of the sentence was on screen for half a second, twice a loop. What reads as the sentence being gone is having nothing whole to read, so that is the rule: on every frame at least one word is on screen in full. And with reduced motion, where a demo shows a single frame, that frame has to be at rest10 of 10 failing on the previous page, 0 now, at four widths 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 @@ -1146,7 +1146,7 @@

Thirty-six movements,
no cuts.

S.adds[1].el.style.top=(S.adds[1].y-step*close)+"px"; 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"); + S.hud("gap f30 · flight f40-126 · close f56 handoff-travel.py wants a monotonic centroid across half a column"); }}; /* --- CHR-02: il ritardo segue la distanza, e per vederlo serve la board intera @@ -1828,7 +1828,7 @@

Thirty-six movements,
no cuts.

a:"The pressed state exists and it arrives before the consequence. Two frames of gap are enough for the gesture to look like it caused something.", b:"Button state and consequence on two different windows. The temptation is to put them on the same frame because it is shorter to write, and that is exactly what makes the click fake.", c:"In the repo: the send lands at f271 and the bubble at f276, five frames apart. Under one the link disappears, over eight it stops reading as a consequence and starts reading as lag.", - d:"click-gap.sh, and it is not told the answer: it finds both events in the render. The send is the first frame whose changed-pixel count blows past the window baseline, because the composer empties and the text leaves; the consequence is the first frame that climbs back above the quiet following it. Every threshold is a ratio inside that window, since the same renders read five to ten times higher on Linux. Measured f271 and f276, which is the f271 and f275 the source declares. It exits 1 on the four scenes with no click at all, and on a clip with the frames between the two events removed. That baseline used to be the median, and the median was wrong: once the camera stops during the performance — which is how it should be shot, nobody moves the camera while someone is typing — half the frames change nothing at all, the median is zero, and five times zero is any flicker. It reported a click at f181, with confidence, ninety frames before the real one. The baseline is the ninetieth percentile now."}, + d:"click-gap.py, and it is not told the answer: it finds both events in the render. It watches two zones the manifest projects for each ratio, the composer text and the tail of the thread, over the stretch where the camera is still. The send is the largest change in the composer, because it empties and the placeholder comes back; the consequence is the first frame in the thread that climbs back above the quiet following it. Until block 6 it watched the whole frame, and in 9:16 and 4:5, where the send button is out of frame, the quiet after the click was zero and it passed a gap of two frames whatever the real gap was. Every threshold is a ratio inside that window, since the same renders read five to ten times higher on Linux. Measured f271 and f276, which is the f271 and f275 the source declares. It exits 1 on three broken copies in every ratio: the two events fused, the interface slowed down to eleven frames, and the click never happening. That baseline used to be the median, and the median was wrong: once the camera stops during the performance — which is how it should be shot, nobody moves the camera while someone is typing — half the frames change nothing at all, the median is zero, and five times zero is any flicker. It reported a click at f181, with confidence, ninety frames before the real one. The baseline is the ninetieth percentile now."}, {fam:"cur",code:"CUR-04",t:"Drag with weight",demo:"drag",src:"topics/scenes/CardHandoff.tsx", a:"The dragged object lags the pointer and tilts into the direction of travel. Without the lag the card looks welded to the cursor.", @@ -1842,11 +1842,11 @@

Thirty-six movements,
no cuts.

c:"13 characters per second as the base, jitter 0.55 to 1.50, a pause after punctuation of 2.2 to 4.0 times the base, bursts at 0.42 with probability 0.34. Seed 20260812.", d:"framelocked-verdict.sh. Two renders of the same frame have to give the same pixels. With Math.random the video still comes out and the reproducibility does not, which is the worst way to break."}, -{fam:"txt",code:"TXT-02",t:"Streaming answer",demo:"stream",src:"beats.sh", +{fam:"txt",code:"TXT-02",t:"Streaming answer",demo:"stream",src:"beats.py", a:"The answer assembles in chunks, with a stall in the middle. And it has to arrive inside the visible area, which is the part everyone forgets.", b:"The text grows by words rather than by characters, with the stall window written out explicitly. Its position is pinned above the composer, which is opaque.", c:"Growth stops between f110 and f130. The block sits above the composer, which is 96 tall and starts 36 from the bottom of the slab.", - d:"beats.sh, the bench that paid for itself. For a full day the render promised four beats and delivered three: the answer was assembling under the opaque composer, and every type check passed. Now the bench requires the word count to GROW across the window, not merely to be non-zero."}, + d:"beats.py, the bench that paid for itself. For a full day the render promised four beats and delivered three: the answer was assembling under the opaque composer, and every type check passed. Now the bench requires the word count to GROW across the window, not merely to be non-zero. Its frames come from the scene's own timeline at the render's duration, the words it looks for leave out the ones the thread already showed, and OCR reads only the thread and the composer. A freeze frame and a covered thread both exit 1 in every ratio."}, {fam:"txt",code:"TXT-03",t:"A number that settles",demo:"metric",src:"tabular-nums", a:"A metric arrives on its value with a small overshoot and stops, instead of scrolling like an odometer.", @@ -1870,7 +1870,7 @@

Thirty-six movements,
no cuts.

a:"An element crosses the layout and the layout absorbs it. The hard part is not the flight: it is the destination column opening the slot first, and the source closing behind.", b:"The card lifts off the plane, travels an arc and lands. The two columns move on their own windows, offset from the flight, and every position comes from cardY.", c:"Gap at f30, flight f40 to f126, close at f56. A step of 123, the active card height plus the gap.", - d:"handoff-travel.sh, the only bench that looks at whether the gesture happens: a freeze frame passes seam.sh and fill-measure.sh with full marks. It tracks the centroid of changed pixels and requires monotonic horizontal motion across at least half a column."}, + d:"handoff-travel.py, the only bench that looks at whether the gesture happens: a freeze frame passes seam.sh and fill-geom.py with full marks. It requires monotonic horizontal motion across at least half a column, measured on the slab: every sample of the drag is rectified with that frame's camera, because in 9:16 the camera follows the card and on screen the bench was measuring the pan. The column headers, which do not move during the drag, check the rectification: zero changed pixels on real renders, forty to a hundred and fifty on a freeze frame, which before that check crossed 208 px."}, {fam:"chr",code:"CHR-02",t:"Reflow by distance",demo:"reflow",src:"a stagger choice", a:"When something changes, the rest settles with a delay proportional to its distance from where it happened, not to its index in the list.", diff --git a/video/src/Root.tsx b/video/src/Root.tsx index 159e420..ad9f8da 100644 --- a/video/src/Root.tsx +++ b/video/src/Root.tsx @@ -10,6 +10,9 @@ import { BoardOrbit } from "./products/topics/scenes/BoardOrbit"; import catalog from "./scenes/catalog.json"; import { SpecimenCam06 } from "./specimens/SpecimenCam06"; import { CAM06_SPECIMENS } from "./specimens/list"; +import { STAGES, variantName, type Ratio } from "./kit/stage"; +import { chainOrder, filmFrames, filmWindows } from "./kit/film"; +import { SceneWindow } from "./kit/SceneWindow"; /** * Le composition della vetrina NON sono scritte qui a mano: escono da @@ -41,19 +44,65 @@ if (missing.length > 0) { ); } +const RATIOS = catalog.ratios as Ratio[]; + +/** + * IL FILM: le stesse scene una dopo l'altra in una composition sola, ognuna + * nella sua finestra. Non va in vetrina; esiste perche' i film di prodotto sono + * fatti cosi', e `film-identity.sh` misura che un fotogramma del film sia lo + * stesso della scena presa da sola. L'ordine e' quello delle giunte + * (`seamAfter`), non quello in cui le scene sono scritte nel catalogo. + */ +const FILM = filmWindows(chainOrder(catalog.scenes)); +const FILM_FPS = catalog.scenes[0]?.fps ?? 30; +if (catalog.scenes.some((s) => s.fps !== FILM_FPS)) { + throw new Error("le scene del film hanno fps diversi: una finestra non puo' cambiare velocita'"); +} + +const TopicsFilm: React.FC = () => ( + <> + {FILM.map((w) => { + const Scene = COMPONENTS[w.id] as React.FC; + return ( + + + + ); + })} + +); + export const RemotionRoot: React.FC = () => { return ( <> - {catalog.scenes.map((scene) => ( + {/* Una composition per scena e per rapporto. Il 16:9 tiene l'id di + sempre; le altre lo portano come suffisso (kit/stage.ts, variantName). + Le dimensioni vengono dallo stage, e la scena sceglie le pose del suo + rapporto leggendole da useVideoConfig. */} + {RATIOS.map((ratio) => + catalog.scenes.map((scene) => ( + + )), + )} + + {RATIOS.map((ratio) => ( ))} @@ -70,29 +119,25 @@ export const RemotionRoot: React.FC = () => { height={1080} defaultProps={{ detachTicker: true }} /> - {/* PROVINI DEL TEMPO, non scene: non stanno in catalog.json, quindi non - vanno in vetrina e nessuno le renderizza per il film. Sono le stesse - due scene a durata dimezzata e a due terzi, e servono a tempo.sh per - provare che accorciare la durata accorcia OGNI battuta dentro la - scena, e che le soglie percettive invece non si muovono. Senza un - render veloce da misurare, "il tempo si puo' cambiare" resterebbe una - frase nel README. */} - - + {/* PROVINI DEL TEMPO, non scene: non vanno in vetrina e nessuno le + renderizza per il film. Sono le stesse scene a durata dimezzata e a + due terzi, in ogni rapporto, e servono a tempo.py per provare che + accorciare la durata accorcia OGNI battuta dentro la scena, e che le + soglie percettive invece non si muovono. L'elenco sta in + catalog.json (tempoFixtures). */} + {RATIOS.map((ratio) => + catalog.tempoFixtures.map((f) => ( + + )), + )} ` e basta, e il motivo per cui basta e' il modo in cui le + * scene leggono il tempo: `useCurrentFrame()` dentro una sequenza parte da zero + * all'inizio della finestra, e `useVideoConfig().durationInFrames` restituisce + * la durata della sequenza e non quella del film (remotion 4, + * use-unsafe-video-config.js). Quindi `tempo()`, le tracce della camera e la + * prop `progress` vedono la scena come se fosse una composition da sola, senza + * che le scene sappiano di stare in un film. + * + * Che il fotogramma del film coincida davvero con quello della scena presa da + * sola non si assume: lo misura `scripts/film-identity.sh`. + */ +export const SceneWindow: React.FC<{ + window: FilmWindow; + children: React.ReactNode; +}> = ({ window, children }) => ( + + {children} + +); diff --git a/video/src/kit/film.ts b/video/src/kit/film.ts new file mode 100644 index 0000000..bf03915 --- /dev/null +++ b/video/src/kit/film.ts @@ -0,0 +1,92 @@ +/** + * Un film come fila di finestre: ogni scena occupa un intervallo di frame del + * film, e dentro quell'intervallo vede il proprio tempo. + * + * PERCHE' ESISTE. Le scene di questo repo sono clip separate che si agganciano + * alla giunta, e `seam.sh` misura che l'ultimo fotogramma di una somigli al + * primo della successiva. Un film di prodotto (Cifra, Zeno) e' invece un file + * solo: le stesse scene, una dopo l'altra, dentro una composition. Se la + * finestra sbaglia l'inizio di un frame, la scena parte con un fotogramma di + * ritardo e la giunta, che nelle clip era esatta, nel film diventa un salto. + * + * La finestra si calcola qui, da durate e ordine, e non la scrive nessuno a + * mano: `SceneWindow.tsx` la usa per il render e `scripts/manifest.mjs` la + * stampa per `film-identity.sh`, che confronta i fotogrammi del film con quelli + * delle scene prese da sole. + * + * Modulo puro, letto da Node. + */ + +export type FilmScene = { id: string; durationInFrames: number }; + +/** L'intervallo di una scena nel film: da `start` compreso per `frames` frame. */ +export type FilmWindow = { id: string; start: number; frames: number }; + +/** Le finestre, una dopo l'altra, nell'ordine dato. */ +export const filmWindows = (scenes: readonly FilmScene[]): FilmWindow[] => { + const out: FilmWindow[] = []; + let start = 0; + for (const s of scenes) { + if (!Number.isInteger(s.durationInFrames) || s.durationInFrames <= 0) { + throw new Error(`${s.id}: durata non valida (${s.durationInFrames})`); + } + out.push({ id: s.id, start, frames: s.durationInFrames }); + start += s.durationInFrames; + } + return out; +}; + +/** La durata del film: la fine dell'ultima finestra. */ +export const filmFrames = (windows: readonly FilmWindow[]): number => { + const last = windows[windows.length - 1]; + return last ? last.start + last.frames : 0; +}; + +/** + * Chi occupa il frame `frame` del film, e a che punto del proprio tempo e'. + * `progress` va da 0 al primo frame della finestra a 1 all'ultimo, come la prop + * `progress` delle scene. + */ +export const windowAt = ( + windows: readonly FilmWindow[], + frame: number, +): { window: FilmWindow; local: number; progress: number } | null => { + for (const w of windows) { + if (frame >= w.start && frame < w.start + w.frames) { + const local = frame - w.start; + return { window: w, local, progress: w.frames > 1 ? local / (w.frames - 1) : 0 }; + } + } + return null; +}; + +/** + * Le scene di un catalogo nell'ordine delle giunte: si parte da quella che non + * dichiara `seamAfter` e si segue la catena. Un catalogo con due teste, un anello + * o una scena che segue un id inesistente non e' un film, e lo si dice. + */ +export const chainOrder = ( + scenes: readonly S[], +): S[] => { + const heads = scenes.filter((s) => !s.seamAfter); + if (heads.length !== 1) { + throw new Error( + `una catena ha una testa sola, qui ne ha ${heads.length}: ${heads.map((s) => s.id).join(", ")}`, + ); + } + const out: S[] = [heads[0] as S]; + for (;;) { + const prev = out[out.length - 1] as S; + const next = scenes.filter((s) => s.seamAfter === prev.id); + if (next.length > 1) { + throw new Error(`${prev.id} ha piu' di un seguito: ${next.map((s) => s.id).join(", ")}`); + } + if (next.length === 0) break; + out.push(next[0] as S); + } + if (out.length !== scenes.length) { + const left = scenes.filter((s) => !out.includes(s)).map((s) => s.id); + throw new Error(`scene fuori dalla catena: ${left.join(", ")}`); + } + return out; +}; diff --git a/video/src/kit/project.ts b/video/src/kit/project.ts index 94a9048..098cc53 100644 --- a/video/src/kit/project.ts +++ b/video/src/kit/project.ts @@ -91,3 +91,31 @@ export const projectRect = ( const y = Math.min(...ys); return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; }; + +/** + * Il contrario di `project` per una posa frontale (yaw e pitch a zero): il punto + * della lastra che finisce nel punto `s` dello schermo. Serve a chiedere cosa + * si vede, per esempio fin dove arriva il bordo destro del quadro sulla lastra. + * Con la camera inclinata il contrario non e' una formula chiusa, e piuttosto + * che un'approssimazione silenziosa si rifiuta. + */ +export const unprojectFrontal = ( + stage: Stage, + rig: Rig, + slab: SlabSize, + pose: Pose, + s: Point, +): Point => { + if (pose.yaw !== 0 || pose.pitch !== 0) { + throw new Error("unprojectFrontal vuole una posa frontale (yaw e pitch a zero)"); + } + const ox = stage.w * rig.originX; + const oy = stage.h * rig.originY; + const k = rig.perspective / (rig.perspective - pose.pushZ); + const X = ox + (s.x - ox) / k; + const Y = oy + (s.y - oy) / k; + return { + x: (X - stage.w / 2 - pose.slideX) / rig.slabScale + slab.w / 2, + y: (Y - stage.h / 2 - pose.slideY) / rig.slabScale + slab.h / 2, + }; +}; diff --git a/video/src/kit/stage.ts b/video/src/kit/stage.ts index 9607388..7de93bf 100644 --- a/video/src/kit/stage.ts +++ b/video/src/kit/stage.ts @@ -25,3 +25,25 @@ export const STAGES: Record = { }; export const RATIOS: readonly Ratio[] = ["16x9", "9x16", "4x5"]; + +/** + * Lo stage di una composition, dalle sue dimensioni. Una scena lo chiede con + * `useVideoConfig()` per scegliere le pose del suo rapporto: la stessa scena + * registrata in tre formati sa in quale sta senza una prop in piu'. Dimensioni + * che non sono uno dei tre stage non hanno pose, e lo si dice invece di + * ripiegare in silenzio sul 16:9. + */ +export const stageFor = (w: number, h: number): Stage => { + const s = RATIOS.map((r) => STAGES[r]).find((x) => x.w === w && x.h === h); + if (!s) throw new Error(`${w}x${h} non e' uno stage del kit (${RATIOS.join(", ")})`); + return s; +}; + +/** + * Il nome di una variante: il 16:9 tiene l'id e lo slug di sempre, gli altri + * rapporti li portano come suffisso (CardFocus-9x16, card-focus-9x16.mp4). + * Cosi' i link pubblicati, i banchi e la pagina che parlano del 16:9 restano + * validi, e un rapporto nuovo si aggiunge accanto invece di rinominare tutto. + */ +export const variantName = (base: string, ratio: Ratio): string => + ratio === "16x9" ? base : `${base}-${ratio}`; diff --git a/video/src/primitives/Cursor.tsx b/video/src/primitives/Cursor.tsx index 589ef7d..d76f8ac 100644 --- a/video/src/primitives/Cursor.tsx +++ b/video/src/primitives/Cursor.tsx @@ -1,5 +1,9 @@ import React from "react"; import { Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; +import { pointOnPath, type Waypoint } from "./path"; + +export { pointOnPath } from "./path"; +export type { Waypoint } from "./path"; /** * Il cursore che recita. @@ -22,44 +26,6 @@ import { Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; * che si espande e svanisce, che e' la parte che l'occhio prende. */ -export type Waypoint = { x: number; y: number; at: number }; - -/** - * Dove sta il puntatore a un dato frame. - * - * ESPORTATA perche' una scena in cui il puntatore TRASCINA qualcosa ha bisogno - * della sua posizione, non solo del suo disegno: la card che segue la mano sta - * dove stava la mano tre frame prima, e l'inclinazione esce dalla differenza - * fra due campioni. Tenuto dentro il componente, quel numero non era - * raggiungibile e la scena avrebbe dovuto ricalcolarsi il percorso per conto - * suo - due copie della stessa traiettoria, uguali finche' nessuno tocca una - * delle due. - */ -export const pointOnPath = ( - path: Waypoint[], - frame: number, -): { x: number; y: number } => { - const first = path[0] as Waypoint; - const last = path[path.length - 1] as Waypoint; - if (frame <= first.at) return { x: first.x, y: first.y }; - for (let i = 0; i < path.length - 1; i++) { - const a = path[i] as Waypoint; - const b = path[i + 1] as Waypoint; - if (frame >= a.at && frame <= b.at) { - const ease = { - easing: Easing.inOut(Easing.cubic), - extrapolateLeft: "clamp" as const, - extrapolateRight: "clamp" as const, - }; - return { - x: interpolate(frame, [a.at, b.at], [a.x, b.x], ease), - y: interpolate(frame, [a.at, b.at], [a.y, b.y], ease), - }; - } - } - return { x: last.x, y: last.y }; -}; - export type CursorProps = { /** I punti da toccare, in coordinate della scena, ciascuno col suo frame. */ path: Waypoint[]; diff --git a/video/src/primitives/path.ts b/video/src/primitives/path.ts new file mode 100644 index 0000000..ab66907 --- /dev/null +++ b/video/src/primitives/path.ts @@ -0,0 +1,47 @@ +import { Easing, interpolate } from "remotion"; + +/** + * Il percorso di una mano come dato: waypoint col frame in cui vanno raggiunti. + * + * Stava dentro Cursor.tsx. E' qui, in un modulo puro, perche' lo leggono anche i + * banchi da Node: handoff-travel deve sapere dove sta la card che la mano + * trascina senza renderizzare, e un .tsx da Node non si importa. + */ + +export type Waypoint = { x: number; y: number; at: number }; + +/** + * Dove sta il puntatore a un dato frame. + * + * ESPORTATA perche' una scena in cui il puntatore TRASCINA qualcosa ha bisogno + * della sua posizione, non solo del suo disegno: la card che segue la mano sta + * dove stava la mano tre frame prima, e l'inclinazione esce dalla differenza + * fra due campioni. Tenuto dentro il componente, quel numero non era + * raggiungibile e la scena avrebbe dovuto ricalcolarsi il percorso per conto + * suo - due copie della stessa traiettoria, uguali finche' nessuno tocca una + * delle due. + */ +export const pointOnPath = ( + path: Waypoint[], + frame: number, +): { x: number; y: number } => { + const first = path[0] as Waypoint; + const last = path[path.length - 1] as Waypoint; + if (frame <= first.at) return { x: first.x, y: first.y }; + for (let i = 0; i < path.length - 1; i++) { + const a = path[i] as Waypoint; + const b = path[i + 1] as Waypoint; + if (frame >= a.at && frame <= b.at) { + const ease = { + easing: Easing.inOut(Easing.cubic), + extrapolateLeft: "clamp" as const, + extrapolateRight: "clamp" as const, + }; + return { + x: interpolate(frame, [a.at, b.at], [a.x, b.x], ease), + y: interpolate(frame, [a.at, b.at], [a.y, b.y], ease), + }; + } + } + return { x: last.x, y: last.y }; +}; diff --git a/video/src/primitives/tempo.ts b/video/src/primitives/tempo.ts index 93f7582..6b2ae64 100644 --- a/video/src/primitives/tempo.ts +++ b/video/src/primitives/tempo.ts @@ -23,7 +23,7 @@ * * LE SOGLIE PERCETTIVE no. I quattro frame fra il clic e la sua conseguenza * non sono ritmo, sono la finestra in cui l'occhio lega un gesto al suo - * effetto: `click-gap.sh` misura che stiano fra 1 e 8, e a velocita' doppia + * effetto: `click-gap.py` misura che stiano fra 2 e 8, e a velocita' doppia * diventerebbero due, cioe' sul bordo di sparire. I tre frame di ritardo * della card sulla mano sono il peso dell'oggetto, non la fretta del * montaggio. Il lampeggio del caret e' una frequenza, non una durata. diff --git a/video/src/products/topics/Assistant.tsx b/video/src/products/topics/Assistant.tsx index 62ce680..393edcf 100644 --- a/video/src/products/topics/Assistant.tsx +++ b/video/src/products/topics/Assistant.tsx @@ -5,13 +5,21 @@ import { COMPOSER_W, COMPOSER_X, COMPOSER_Y, + MSG_AVATAR_W, + MSG_GAP, SEND_H, SEND_W, SIDEBAR_W, THREAD_PAD_BOTTOM, + THREAD_PAD_X, THREAD_TOP, + TOOL_ROW_INDENT, } from "./geometry"; import { app, monoStack, radius } from "./tokens"; +import { topicsLayout } from "./poses"; +import { THREAD_HISTORY } from "./thread"; +import { stageFor } from "../../kit/stage"; +import { useVideoConfig } from "remotion"; /** * Il thread dell'assistente e il composer, nella meta' bassa della lastra. @@ -93,7 +101,7 @@ export const Assistant: React.FC = ({ display: "flex", alignItems: "center", gap: 14, - padding: "0 26px", + padding: `0 ${THREAD_PAD_X}px`, borderBottom: `1px solid ${app.border}`, }} > @@ -120,7 +128,7 @@ export const Assistant: React.FC = ({
= ({ }} >
- - - - - - + {THREAD_HISTORY.map((item, i) => + item.kind === "tool" ? ( + + ) : ( + + ), + )}
{sent ? ( @@ -242,12 +245,14 @@ const Msg: React.FC<{ streaming?: boolean; }> = ({ who, text, streaming }) => { const isUser = who === "user"; + const { width, height } = useVideoConfig(); + const { msgMaxW } = topicsLayout(stageFor(width, height).ratio); return ( -
+
{text} @@ -286,25 +291,29 @@ const Msg: React.FC<{ ); }; -const ToolRow: React.FC<{ file: string }> = ({ file }) => ( -
-
-
Read
-
{file}
-
-); +const ToolRow: React.FC<{ file: string }> = ({ file }) => { + const { width, height } = useVideoConfig(); + const { toolRowMaxW } = topicsLayout(stageFor(width, height).ratio); + return ( +
+
+
Read
+
{file}
+
+ ); +}; const Dots: React.FC<{ frame: number }> = ({ frame }) => (
diff --git a/video/src/products/topics/benches.ts b/video/src/products/topics/benches.ts deleted file mode 100644 index b345568..0000000 --- a/video/src/products/topics/benches.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - CARD_FOCUS_END_POSE, - CARD_HANDOFF_END_POSE, - COMP_H, - COMP_W, - PERSPECTIVE_ORIGIN_Y, - PROMPT_INPUT_END_POSE, - SIDEBAR_W, - SLAB_H, - SLAB_SCALE, - THREAD_TOP, - TOPICS_RIG, - TOPICS_SLAB, - TOPICS_STAGE, - handoffLandedRect, -} from "./geometry.ts"; -import { slabPointOnScreen as kitSlab, zoomForPush as kitZoom } from "../../kit/rig.ts"; - -const slabPointOnScreen = (x: number, y: number): { x: number; y: number } => - kitSlab(TOPICS_STAGE, TOPICS_RIG, TOPICS_SLAB, { x, y }); -const zoomForPush = (z: number): number => kitZoom(TOPICS_RIG, z); - -/** - * Quello che i banchi delle scene di Topics devono sapere della geometria: - * ritagli, bande, ingrandimenti. Calcolato qui, da topics/geometry.ts, e stampato da - * `scripts/manifest.mjs`. - * - * PERCHE' ESISTE. Quattro banchi (handoff-travel, focus-sharpness, - * fixture-screenshot, contrast-floor) importavano topics/geometry.ts dentro uno script - * `node -e` scritto nel proprio corpo, e rifacevano ognuno per conto suo gli - * stessi conti di ripresa: dove sta la card, quanto ingrandisce la spinta, - * dove cade l'intestazione del thread. Quattro copie di un calcolo restano - * uguali finche' nessuno ne tocca una. Qui stanno una volta sola, i banchi - * chiedono il numero, e le loro uscite sono identiche a prima riga per riga. - * - * I conti sono QUELLI DI PRIMA, operazione per operazione, e usano la - * proiezione con yaw e pitch a zero, esatta per le pose frontali che questi - * banchi misurano. Il manifest per un prodotto qualsiasi usera' kit/project.ts. - * - * Modulo puro, letto da Node. - */ - -/** handoff-travel.sh: dove comincia il pannello assistente, in percento dell'altezza. */ -export const handoffBand = (): number => - Math.round((THREAD_TOP / SLAB_H) * 100) - 4; - -/** - * focus-sharpness.sh e fixture-screenshot.sh: la card consegnata all'ultimo - * frame di CardFocus, dove stava nel campo largo, e quanto manca da li' alla - * scala finale. - */ -export const cardFocusGeometry = (): { - cw: number; - ch: number; - cx: number; - cy: number; - zoom: string; - wx: number; - wy: number; - k: string; -} => { - const r = handoffLandedRect(); - const p = slabPointOnScreen(r.x + r.w / 2, r.y + r.h / 2); - const ox = COMP_W / 2; - const oy = COMP_H * PERSPECTIVE_ORIGIN_Y; - const k1 = zoomForPush(CARD_FOCUS_END_POSE.pushZ); - const k0 = zoomForPush(CARD_HANDOFF_END_POSE.pushZ); - const wx = ox + (p.x - ox) * k0; - const wy = oy + (p.y - oy) * k0; - return { - cw: Math.round(r.w * SLAB_SCALE * k1), - ch: Math.round(r.h * SLAB_SCALE * k1), - cx: Math.round(ox), - cy: Math.round(oy), - zoom: k1.toFixed(4), - wx: Math.round(wx), - wy: Math.round(wy), - k: (k1 / k0).toFixed(4), - }; -}; - -/** - * contrast-floor.py: l'intestazione del thread all'ultima posa di PromptInput. - * Contenuto vero, attenuato per costruzione mentre la risposta scorre, e in una - * posizione aritmetica invece che dipendente da come vanno a capo i messaggi. - * Dimensioni pari: su una sorgente yuv420p ffmpeg arrotonda un ritaglio dispari - * al pixel sotto, e il banco scambierebbe una riga in meno per un'estrazione - * fallita. - */ -export const contrastCrop = (): { x: number; y: number; w: number; h: number } => { - const p = PROMPT_INPUT_END_POSE; - const k = zoomForPush(p.pushZ); - const ox = COMP_W / 2; - const oy = COMP_H * PERSPECTIVE_ORIGIN_Y; - const x0 = SIDEBAR_W + 20; - const x1 = SIDEBAR_W + 560; - const y0 = THREAD_TOP + 10; - const y1 = THREAD_TOP + 44; - const P = (x: number, y: number): [number, number] => { - const s = slabPointOnScreen(x, y); - return [ox + (s.x + p.slideX - ox) * k, oy + (s.y + (p.slideY ?? 0) - oy) * k]; - }; - const a = P(x0, y0); - const b = P(x1, y1); - const pari = (v: number): number => 2 * Math.floor(v / 2); - return { - x: pari(Math.max(0, Math.round(a[0]))), - y: pari(Math.round(a[1])), - w: pari(Math.round(b[0] - a[0])), - h: pari(Math.round(b[1] - a[1])), - }; -}; diff --git a/video/src/products/topics/benches/beats.ts b/video/src/products/topics/benches/beats.ts new file mode 100644 index 0000000..89c0f38 --- /dev/null +++ b/video/src/products/topics/benches/beats.ts @@ -0,0 +1,149 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { projectRect } from "../../../kit/project.ts"; +import { poseAt } from "../../../kit/camera.ts"; +import { typedCount } from "../../../primitives/rhythm.ts"; +import { + COMPOSER_Y, + SIDEBAR_W, + SLAB_H, + THREAD_TOP, + TOPICS_RIG, + TOPICS_SLAB, + TOOL_ROW_EXTRA_W, +} from "../geometry.ts"; +import { topicsLayout } from "../poses.ts"; +import { + DEFAULT_PROMPT, + DEFAULT_RESPONSE, + promptInputTimeline, + streamedWordCount, +} from "../promptTiming.ts"; +import { THREAD_HISTORY } from "../thread.ts"; +import { promptInputTrack } from "../tracks.ts"; + +type Catalog = { + scenes: { id: string; durationInFrames: number; fps: number }[]; + tempoFixtures: { id: string; scene: string; durationInFrames: number }[]; +}; +type Rect = { x: number; y: number; w: number; h: number }; + +/** Le parole che l'OCR regge: solo lettere, almeno quattro, minuscole. */ +const readable = (text: string): string[] => + text + .toLowerCase() + .split(/[^a-z]+/) + .filter((w) => w.length >= 4); + +/** + * beats.py: i fotogrammi in cui guardare i quattro tempi di PromptInput, le + * parole da cercare e la zona del quadro da leggere. + * + * I FRAME ESCONO DALLA RECITA, non da una tabella. Erano sei numeri (170, 210, + * 300, 340, 385, 430) scelti guardando la scena a 450 fotogrammi e scalati con + * la durata; il secondo campione della battitura cadeva quattro frame prima + * della fine del prompt, e il commento diceva che li' c'era tutto. Adesso: un + * campione a un terzo della battitura e uno a battitura finita, prima che la mano + * parta per l'invio; uno fra la bolla ormai entrata e l'inizio dello streaming; + * tre nello streaming, al 20, 55 e 90 per cento. Il modulo verifica che fra i due + * della battitura le parole scritte crescano davvero, e cosi' fra i tre dello + * streaming, contando solo le parole che il banco cerca. + * + * LE PAROLE GIA' IN QUADRO NON SI CERCANO. "solo" sta nella risposta e anche + * nello scambio precedente del thread: contarla voleva dire trovare la risposta + * prima che arrivasse. + * + * LA ZONA E' IL THREAD PIU' IL CAMPO, alla posa ferma finale, tagliata sul quadro: + * l'OCR non legge la board, che non c'entra e che in 9:16 riempie meta' quadro. + * La zona coperta per il negativo e' la coda del thread sopra il campo, dove + * arrivano il messaggio e la risposta. + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const stage = STAGES[ratio]; + const scene = catalog.scenes.find((s) => s.id === "PromptInput"); + if (!scene) throw new Error("catalog.json: manca PromptInput"); + const { msgMaxW } = topicsLayout(ratio); + + const history = new Set( + THREAD_HISTORY.flatMap((item) => (item.kind === "msg" ? readable(item.text) : readable(item.file))), + ); + const promptWords = [...new Set(readable(DEFAULT_PROMPT))].filter((w) => !history.has(w)); + // Le parole del prompt restano in quadro nel messaggio inviato, quindi non + // dicono niente sulla risposta: "auth" sta in tutti e due. + const responseWords = [...new Set(readable(DEFAULT_RESPONSE))].filter( + (w) => !history.has(w) && !promptWords.includes(w), + ); + + const clip = (r: Rect, what: string, variant: string, pose: ReturnType): Rect => { + const p = projectRect(stage, TOPICS_RIG, TOPICS_SLAB, pose, r); + const x0 = Math.max(0, Math.floor(p.x)); + const y0 = Math.max(0, Math.floor(p.y)); + const x1 = Math.min(stage.w, Math.ceil(p.x + p.w)); + const y1 = Math.min(stage.h, Math.ceil(p.y + p.h)); + const out = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + if (out.w < 200 || out.h < 100) { + throw new Error(`${variant} in ${ratio}: la zona "${what}" non sta nel quadro (${JSON.stringify(out)})`); + } + return out; + }; + + const variants = [ + { id: "PromptInput", durationInFrames: scene.durationInFrames }, + ...catalog.tempoFixtures + .filter((f) => f.scene === "PromptInput") + .map((f) => ({ id: f.id, durationInFrames: f.durationInFrames })), + ].map((v) => { + const tl = promptInputTimeline(v.durationInFrames, scene.fps); + const visible = (text: string, words: string[]) => { + const seen = new Set(readable(text)); + return words.filter((w) => seen.has(w)).length; + }; + // Una parola del prompt conta come scritta quando e' seguita da uno spazio o + // e' l'ultima e la battitura e' finita. + const typedWords = (f: number) => { + const n = typedCount(tl.schedule, f); + const done = DEFAULT_PROMPT.slice(0, n); + const whole = n >= DEFAULT_PROMPT.length ? done : done.slice(0, done.lastIndexOf(" ") + 1); + return visible(whole, promptWords); + }; + const type1 = Math.round(tl.typeStart + (tl.typeEnd - tl.typeStart) / 3); + const type2 = Math.ceil(tl.typeEnd) + 2; + if (type2 >= tl.sendTravelStart) throw new Error(`${v.id}: nessun frame fra la fine della battitura e l'invio`); + if (typedWords(type2) <= typedWords(type1)) { + throw new Error(`${v.id}: fra f${type1} e f${type2} le parole del prompt non crescono`); + } + const sent = Math.round((tl.bubbleAt + 12 + tl.streamAt) / 2); + if (!(sent > tl.bubbleAt + 12 && sent < tl.streamAt)) { + throw new Error(`${v.id}: nessun frame fra la bolla entrata e lo streaming`); + } + const words = DEFAULT_RESPONSE.split(" "); + const stream = [0.2, 0.55, 0.9].map((q) => Math.round(tl.streamAt + (tl.streamEnd - tl.streamAt) * q)); + const shown = stream.map((f) => visible(words.slice(0, streamedWordCount(tl, f, words.length)).join(" "), responseWords)); + for (let i = 1; i < shown.length; i++) { + if ((shown[i] as number) <= (shown[i - 1] as number)) { + throw new Error(`${v.id}: nei frame ${stream.join(", ")} le parole cercate non crescono (${shown.join(", ")})`); + } + } + const pose = poseAt(promptInputTrack(v.durationInFrames, ratio), v.durationInFrames - 1); + const read = clip( + { + x: SIDEBAR_W, + y: THREAD_TOP + 52, + w: Math.min(1400, msgMaxW + 200 + TOOL_ROW_EXTRA_W), + h: SLAB_H - 20 - (THREAD_TOP + 52), + }, + "lettura", + v.id, + pose, + ); + const cover = clip({ x: 0, y: COMPOSER_Y - 420, w: TOPICS_SLAB.w, h: 420 }, "copertura", v.id, pose); + return { + id: v.id, + durationInFrames: v.durationInFrames, + frames: { type1, type2, sent, stream }, + read, + cover, + }; + }); + return { stage: { w: stage.w, h: stage.h }, fps: scene.fps, promptWords, responseWords, variants }; +}; diff --git a/video/src/products/topics/benches/click-gap.ts b/video/src/products/topics/benches/click-gap.ts new file mode 100644 index 0000000..6ce5ef2 --- /dev/null +++ b/video/src/products/topics/benches/click-gap.ts @@ -0,0 +1,102 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { projectRect } from "../../../kit/project.ts"; +import { poseAt } from "../../../kit/camera.ts"; +import { tempo } from "../../../primitives/tempo.ts"; +import { + COMPOSER_H, + COMPOSER_X, + COMPOSER_Y, + MSG_AVATAR_W, + MSG_GAP, + SIDEBAR_W, + THREAD_PAD_X, + TOPICS_RIG, + TOPICS_SLAB, + USER_BUBBLE_EXTRA_W, +} from "../geometry.ts"; +import { topicsLayout } from "../poses.ts"; +import { DEFAULT_PROMPT, promptInputTimeline } from "../promptTiming.ts"; +import { PROMPT_INPUT_BASE, PROMPT_INPUT_CAM_SETTLE, promptInputTrack } from "../tracks.ts"; + +type Catalog = { + scenes: { id: string; durationInFrames: number; fps: number }[]; + tempoFixtures: { id: string; scene: string; durationInFrames: number }[]; +}; +type Rect = { x: number; y: number; w: number; h: number }; + +/** + * click-gap.py: dove guardare il clic d'invio e la sua conseguenza, e in che + * tratto della scena cercarli. + * + * DUE RETTANGOLI, e non il quadro intero. La prima versione contava i pixel + * cambiati in tutto il fotogramma, e il clic si vedeva perche' nel 16:9 in + * quadro c'erano il pulsante che si abbassa e l'anello del cursore. In 9:16 e + * 4:5 il pulsante e' fuori dal quadro: i fotogrammi dopo il clic erano fermi, la + * "quiete" valeva zero e qualunque cosa la superava, e il banco promuoveva + * scarto 2 qualunque fosse lo scarto vero. Qui il colpo si cerca nel testo del + * campo, che all'invio si svuota e torna al segnaposto, e la conseguenza nella + * coda del thread, dove sale la bolla: due zone che in ogni rapporto stanno nel + * quadro, e che il modulo verifica invece di presumere. + * + * IL TRATTO E' QUELLO A CAMERA FERMA, dalla fine della discesa sul composer + * all'ultimo frame. Il banco cerca gli eventi dentro il tratto: questi numeri + * delimitano la ricerca, non gli dicono la risposta. I frame del clic e della + * bolla escono lo stesso, ma li usano solo i controlli per costruire i casi + * guasti (eventi fusi, interfaccia lenta, clic mancato). + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const stage = STAGES[ratio]; + const scene = catalog.scenes.find((s) => s.id === "PromptInput"); + if (!scene) throw new Error("catalog.json: manca PromptInput"); + const { msgMaxW } = topicsLayout(ratio); + + const slabRects: Record<"colpo" | "conseguenza", Rect> = { + // Il testo del campo, dal padding sinistro per la larghezza del prompt intero. + colpo: { x: COMPOSER_X + 24, y: COMPOSER_Y + COMPOSER_H / 2 - 18, w: DEFAULT_PROMPT.length * 13, h: 36 }, + // La coda del thread sopra il composer: la bolla nuova e i messaggi che salgono. + conseguenza: { + x: SIDEBAR_W + THREAD_PAD_X, + y: COMPOSER_Y - 200, + w: Math.min(900, MSG_AVATAR_W + MSG_GAP + msgMaxW + USER_BUBBLE_EXTRA_W), + h: 192, + }, + }; + + const variants = [ + { id: "PromptInput", durationInFrames: scene.durationInFrames }, + ...catalog.tempoFixtures + .filter((f) => f.scene === "PromptInput") + .map((f) => ({ id: f.id, durationInFrames: f.durationInFrames })), + ].map((v) => { + const tl = promptInputTimeline(v.durationInFrames, scene.fps); + const settle = tempo(v.durationInFrames, PROMPT_INPUT_BASE).at(PROMPT_INPUT_CAM_SETTLE); + const pose = poseAt(promptInputTrack(v.durationInFrames, ratio), v.durationInFrames - 1); + const rects = Object.fromEntries( + Object.entries(slabRects).map(([name, r]) => { + const p = projectRect(stage, TOPICS_RIG, TOPICS_SLAB, pose, r); + const x0 = Math.max(0, Math.ceil(p.x)); + const y0 = Math.max(0, Math.ceil(p.y)); + const x1 = Math.min(stage.w, Math.floor(p.x + p.w)); + const y1 = Math.min(stage.h, Math.floor(p.y + p.h)); + const clipped = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + const kept = (clipped.w * clipped.h) / (p.w * p.h); + if (clipped.w < 80 || clipped.h < 20 || kept < 0.6) { + throw new Error( + `${v.id} in ${ratio}: la zona "${name}" non sta nel quadro (${JSON.stringify(clipped)}, ` + + `${Math.round(kept * 100)}% della zona)`, + ); + } + return [name, clipped]; + }), + ); + return { + id: v.id, + durationInFrames: v.durationInFrames, + window: [Math.ceil(settle) + 1, v.durationInFrames - 1], + rects, + events: { sendClick: tl.sendClick, bubbleAt: tl.bubbleAt, thinkAt: tl.thinkAt }, + }; + }); + return { stage: { w: stage.w, h: stage.h }, variants }; +}; diff --git a/video/src/products/topics/benches/contrast-floor.ts b/video/src/products/topics/benches/contrast-floor.ts new file mode 100644 index 0000000..df5845f --- /dev/null +++ b/video/src/products/topics/benches/contrast-floor.ts @@ -0,0 +1,64 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { project } from "../../../kit/project.ts"; +import { SIDEBAR_W, THREAD_TOP, TOPICS_RIG, TOPICS_SLAB } from "../geometry.ts"; +import { TOPICS_POSES } from "../poses.ts"; +import { promptInputTimeline } from "../promptTiming.ts"; + +type Catalog = { + scenes: { id: string; durationInFrames: number; fps: number }[]; + tempoFixtures: { id: string; scene: string; durationInFrames: number }[]; +}; + +/** + * Il fotogramma da guardare, alla durata di riferimento: tardi nello streaming, + * quando l'attenuazione e' a regime. Segue la durata come le battute. + */ +const FRAME_AT_BASE = 430; + +/** + * contrast-floor.py: l'intestazione del thread all'ultima posa di PromptInput, + * nel rapporto chiesto, e il fotogramma in cui leggerla. + * + * L'INTESTAZIONE STA A THREAD_TOP, che e' una costante, e per questo e' il + * soggetto: i messaggi hanno altezze naturali e scorrono appena le metriche dei + * font cambiano. Il ritaglio va da SIDEBAR_W+20 a SIDEBAR_W+560 in orizzontale e + * da THREAD_TOP+10 a THREAD_TOP+44 in verticale, proiettato con la posa finale + * del rapporto (frontale, quindi la proiezione e' esatta). + * + * NEL 16:9 ESCE {0, 410, 628, 40} COME PRIMA: stesso arrotondamento al pari, + * stessa x portata a zero quando il bordo sinistro cade appena fuori quadro. + * Negli altri rapporti in piu' si taglia a destra sul bordo del quadro, e se + * quello che resta e' troppo poco per leggere un contrasto il modulo lo dice + * invece di restituire un ritaglio vuoto. + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const stage = STAGES[ratio]; + const pose = { slideY: 0, ...TOPICS_POSES[ratio].PROMPT_INPUT_END_POSE }; + const a = project(stage, TOPICS_RIG, TOPICS_SLAB, pose, { x: SIDEBAR_W + 20, y: THREAD_TOP + 10 }); + const b = project(stage, TOPICS_RIG, TOPICS_SLAB, pose, { x: SIDEBAR_W + 560, y: THREAD_TOP + 44 }); + const pari = (v: number): number => 2 * Math.floor(v / 2); + const x = pari(Math.max(0, Math.round(a.x))); + const y = pari(Math.round(a.y)); + const w = pari(Math.min(Math.round(b.x - a.x), stage.w - x)); + const h = pari(Math.min(Math.round(b.y - a.y), stage.h - y)); + if (w < 40 || h < 12 || y < 0) { + throw new Error(`in ${ratio} l'intestazione del thread non sta nel quadro: ${x},${y} ${w}x${h}`); + } + const scene = catalog.scenes.find((s) => s.id === "PromptInput"); + if (!scene) throw new Error("catalog.json: manca PromptInput"); + const variants = [ + { id: "PromptInput", durationInFrames: scene.durationInFrames }, + ...catalog.tempoFixtures + .filter((f) => f.scene === "PromptInput") + .map((f) => ({ id: f.id, durationInFrames: f.durationInFrames })), + ].map((v) => { + const tl = promptInputTimeline(v.durationInFrames, scene.fps); + const frame = Math.round(tl.K.at(FRAME_AT_BASE)); + if (frame < tl.attnTo || frame > tl.last) { + throw new Error(`${v.id}: il frame ${frame} non cade dopo l'attenuazione e prima della fine`); + } + return { ...v, frame }; + }); + return { crop: { x, y, w, h }, stage: { w: stage.w, h: stage.h }, variants }; +}; diff --git a/video/src/products/topics/benches/focus-sharpness.ts b/video/src/products/topics/benches/focus-sharpness.ts new file mode 100644 index 0000000..e73dd4e --- /dev/null +++ b/video/src/products/topics/benches/focus-sharpness.ts @@ -0,0 +1,85 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { projectRect, unprojectFrontal } from "../../../kit/project.ts"; +import { poseAt } from "../../../kit/camera.ts"; +import { TOPICS_RIG, TOPICS_SLAB, handoffLandedRect } from "../geometry.ts"; +import { cardFocusTrack } from "../tracks.ts"; + +type Catalog = { scenes: { id: string; durationInFrames: number; fps: number }[] }; +type Rect = { x: number; y: number; w: number; h: number }; + +/** + * Il margine dentro la card, per lato: si misura la nitidezza del contenuto, non + * del filo del bordo, che e' largo un pixel e sopravvive a qualsiasi trattamento. + */ +const MARGIN = 0.08; + +/** + * focus-sharpness.sh e fixture-screenshot.sh: la card consegnata all'ultimo + * fotogramma di CardFocus, la stessa zona della lastra al primo, e quanto la + * camera l'ha ingrandita fra i due. + * + * PERCHE' COSI'. La versione di prima lavorava col 16:9 in testa: il centro del + * ritaglio era l'origine della prospettiva (giusto solo perche' la posa finale + * centra la card), e il campo largo ignorava yaw -4 e pitch 1,2 della posa di + * partenza, 5,9 px di errore gia' nel 16:9. In 9:16 e 4:5 la card deborda a + * destra all'ultimo fotogramma, quindi il ritaglio e' la parte della card che + * sta nel quadro, e la zona corrispondente del primo fotogramma si trova + * riportando quel rettangolo sulla lastra (la posa finale e' frontale, il + * contrario e' esatto) e riproiettandolo con la posa di partenza. + * + * K e' il rapporto fra le larghezze proiettate: quanto la camera ha ingrandito + * quel contenuto, cioe' di quanto uno screenshot del campo largo andrebbe + * ingrandito per sembrare l'ultimo fotogramma. + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const scene = catalog.scenes.find((s) => s.id === "CardFocus"); + if (!scene) throw new Error("catalog.json: manca CardFocus"); + const stage = STAGES[ratio]; + const track = cardFocusTrack(scene.durationInFrames, ratio); + const first = poseAt(track, 0); + const last = poseAt(track, scene.durationInFrames - 1); + const card = handoffLandedRect(); + + const full = projectRect(stage, TOPICS_RIG, TOPICS_SLAB, last, card); + const mx = full.w * MARGIN; + const my = full.h * MARGIN; + const x0 = Math.max(0, full.x) + mx; + const y0 = Math.max(0, full.y) + my; + const x1 = Math.min(stage.w, full.x + full.w) - mx; + const y1 = Math.min(stage.h, full.y + full.h) - my; + const ours: Rect = { + x: Math.ceil(x0), + y: Math.ceil(y0), + w: Math.floor(x1) - Math.ceil(x0), + h: Math.floor(y1) - Math.ceil(y0), + }; + if (ours.w < 120 || ours.h < 60) { + throw new Error(`in ${ratio} la card all'ultimo fotogramma e' troppo poco nel quadro: ${JSON.stringify(ours)}`); + } + + // La stessa zona della lastra, al primo fotogramma. + const a = unprojectFrontal(stage, TOPICS_RIG, TOPICS_SLAB, last, { x: ours.x, y: ours.y }); + const b = unprojectFrontal(stage, TOPICS_RIG, TOPICS_SLAB, last, { x: ours.x + ours.w, y: ours.y + ours.h }); + const region = { x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }; + const wide = projectRect(stage, TOPICS_RIG, TOPICS_SLAB, first, region); + if (wide.x < 0 || wide.y < 0 || wide.x + wide.w > stage.w || wide.y + wide.h > stage.h) { + throw new Error(`in ${ratio} la card al primo fotogramma esce dal quadro: ${JSON.stringify(wide)}`); + } + const k = ours.w / wide.w; + return { + composition: "CardFocus", + stage: { w: stage.w, h: stage.h }, + frames: scene.durationInFrames, + fps: scene.fps, + ours, + wide: { + x: Number(wide.x.toFixed(2)), + y: Number(wide.y.toFixed(2)), + w: Number(wide.w.toFixed(2)), + h: Number(wide.h.toFixed(2)), + }, + k: Number(k.toFixed(4)), + kh: Number((ours.h / wide.h).toFixed(4)), + }; +}; diff --git a/video/src/products/topics/benches/framelocked-verdict.ts b/video/src/products/topics/benches/framelocked-verdict.ts new file mode 100644 index 0000000..7ab0539 --- /dev/null +++ b/video/src/products/topics/benches/framelocked-verdict.ts @@ -0,0 +1,60 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { projectRect } from "../../../kit/project.ts"; +import { poseAt } from "../../../kit/camera.ts"; +import { COMPOSER_H, COMPOSER_X, COMPOSER_Y, TOPICS_RIG, TOPICS_SLAB } from "../geometry.ts"; +import { promptInputTimeline, DEFAULT_PROMPT } from "../promptTiming.ts"; +import { typedCount } from "../../../primitives/rhythm.ts"; +import { promptInputTrack } from "../tracks.ts"; + +type Catalog = { scenes: { id: string; durationInFrames: number; fps: number }[] }; + +/** + * framelocked-verdict.sh su PromptInput: i fotogrammi in cui la battitura si vede. + * + * PERCHE' ESISTE. I frame erano "150 175 200", scelti guardando il 16:9. Il + * banco chiede due cose a quei frame: che lo stesso frame renderizzato due volte + * dia lo stesso PNG, e che frame diversi diano immagini diverse. La seconda vale + * solo se fra un frame e l'altro qualcosa di visibile cambia: se il testo che + * si scrive cadesse fuori quadro, i tre frame sarebbero uguali e il banco + * direbbe "timeline ferma" dando la colpa al frame-lock invece che + * all'inquadratura. + * + * I frame si prendono al 25, 50 e 75 per cento della battitura, e si + * controlla che le lettere scritte crescano davvero fra l'uno e l'altro e che il + * testo del campo stia nel quadro alla posa di quel frame. Se non ci sta, il + * modulo lo dice invece di restituire frame inutili. + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const scene = catalog.scenes.find((s) => s.id === "PromptInput"); + if (!scene) throw new Error("catalog.json: manca PromptInput"); + const tl = promptInputTimeline(scene.durationInFrames, scene.fps); + const span = tl.typeEnd - tl.typeStart; + const frames = [0.25, 0.5, 0.75].map((q) => Math.round(tl.typeStart + span * q)); + const counts = frames.map((f) => typedCount(tl.schedule, f)); + for (let i = 1; i < counts.length; i++) { + if ((counts[i] as number) <= (counts[i - 1] as number)) { + throw new Error(`fra i frame ${frames.join(", ")} le lettere scritte non crescono: ${counts.join(", ")}`); + } + } + // Il testo del campo: dal padding sinistro del composer per la larghezza del + // prompt scritto per intero (25 px di corpo, circa 13 px a carattere). + const stage = STAGES[ratio]; + const text = { + x: COMPOSER_X + 24, + y: COMPOSER_Y + COMPOSER_H / 2 - 18, + w: DEFAULT_PROMPT.length * 13, + h: 36, + }; + const track = promptInputTrack(scene.durationInFrames, ratio); + for (const f of frames) { + const r = projectRect(stage, TOPICS_RIG, TOPICS_SLAB, poseAt(track, f), text); + if (r.x < 0 || r.y < 0 || r.x + r.w > stage.w || r.y + r.h > stage.h) { + throw new Error( + `al frame ${f} il testo del campo esce dal quadro ${stage.w}x${stage.h}: ` + + `${r.x.toFixed(0)},${r.y.toFixed(0)} ${r.w.toFixed(0)}x${r.h.toFixed(0)}`, + ); + } + } + return { composition: "PromptInput", frames, typed: counts }; +}; diff --git a/video/src/products/topics/benches/handoff-travel.ts b/video/src/products/topics/benches/handoff-travel.ts new file mode 100644 index 0000000..7800e82 --- /dev/null +++ b/video/src/products/topics/benches/handoff-travel.ts @@ -0,0 +1,105 @@ +import { STAGES } from "../../../kit/stage.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { project } from "../../../kit/project.ts"; +import { poseAt } from "../../../kit/camera.ts"; +import { tempo } from "../../../primitives/tempo.ts"; +import { + BOARD_LEFT, + BOARD_TOP, + BOARD_W, + COL_HEADER_H, + HANDOFF_FROM_COL, + HANDOFF_TO_COL, + THREAD_TOP, + TOPICS_RIG, + TOPICS_SLAB, + columnX, +} from "../geometry.ts"; +import { CARD_HANDOFF_BASE, CARD_HANDOFF_DRAG_START, LAG, RELEASE } from "../handoff.ts"; +import { cardHandoffTrack } from "../tracks.ts"; + +type Catalog = { + scenes: { id: string; durationInFrames: number; fps: number }[]; + tempoFixtures: { id: string; scene: string; durationInFrames: number }[]; +}; + +/** La scala dell'immagine della lastra raddrizzata: 2400x1200 diventa 960x480. */ +const SCALE = 0.4; +const SAMPLES = 7; + +/** + * handoff-travel.py: in che fotogrammi guardare il trascinamento di CardHandoff, e + * come riportare ognuno di quei fotogrammi sulla lastra. + * + * PERCHE' SULLA LASTRA. In 9:16 e 4:5 la camera segue la card mentre la mano la + * trascina: sullo schermo la card si sposta di poche decine di pixel e tutta la + * board le scorre sotto. Una differenza fra due fotogrammi dello schermo misura + * la panoramica, non il gesto. Il banco invece raddrizza ogni fotogramma sulla + * lastra con la trasformazione prospettica della camera a quel frame, e li' la + * board sta ferma e si muove solo quello che si muove davvero: la card, la mano, + * la colonna che si richiude. Per farlo gli servono i quattro angoli della + * lastra proiettati a ogni campione, e il modulo glieli da'. Dove sta la card no: + * quella la trova il banco nei pixel. + * + * I CAMPIONI STANNO DENTRO IL TRASCINAMENTO, dalla partenza della mano (piu' il + * ritardo della card) al rilascio. Prima prendevano anche il tratto prima della + * presa, dove camera e cursore si muovono gia', e un taglio secco a meta' scena + * passava con quattro campioni buoni. + * + * LE INTESTAZIONI DELLE COLONNE NON SI MUOVONO durante il trascinamento (i + * contatori cambiano dopo il rilascio), e sono il controllo del raddrizzamento: + * se il render segue davvero la camera del rapporto, sulla lastra restano + * identiche; se no (un fermo immagine, un render al contrario o tagliato, la + * posa di un altro rapporto) si spostano, perche' raddrizzare la stessa immagine + * con due camere diverse la fa scorrere. La prima versione di questo banco non + * lo controllava, e in 9:16 un fermo immagine attraversava 208 px. + * + * LA SOGLIA E' META' DELLA DISTANZA FRA LE DUE COLONNE, sulla lastra: una card + * che non arriva almeno a meta' strada non ha cambiato posto. E' una distanza fra + * slot, uguale in ogni rapporto, e non la posizione della card. + */ +export const geometry = (ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const stage = STAGES[ratio]; + const scene = catalog.scenes.find((s) => s.id === "CardHandoff"); + if (!scene) throw new Error("catalog.json: manca CardHandoff"); + const W = TOPICS_SLAB.w; + const H = TOPICS_SLAB.h; + const variants = [ + { id: "CardHandoff", durationInFrames: scene.durationInFrames }, + ...catalog.tempoFixtures + .filter((f) => f.scene === "CardHandoff") + .map((f) => ({ id: f.id, durationInFrames: f.durationInFrames })), + ].map((v) => { + const T = tempo(v.durationInFrames, CARD_HANDOFF_BASE); + const from = Math.ceil(T.at(CARD_HANDOFF_DRAG_START) + LAG); + const to = Math.floor(T.at(RELEASE)); + const frames = Array.from({ length: SAMPLES }, (_, i) => Math.round(from + ((to - from) * i) / (SAMPLES - 1))); + const track = cardHandoffTrack(v.durationInFrames, ratio); + const samples = frames.map((f) => { + const pose = poseAt(track, f); + const corners = [ + [0, 0], + [W, 0], + [W, H], + [0, H], + ].map(([x, y]) => { + const p = project(stage, TOPICS_RIG, TOPICS_SLAB, pose, { x: x as number, y: y as number }); + return { sx: Number(p.x.toFixed(3)), sy: Number(p.y.toFixed(3)), x: (x as number) * SCALE, y: (y as number) * SCALE }; + }); + return { frame: f, corners }; + }); + return { id: v.id, durationInFrames: v.durationInFrames, samples }; + }); + const s = (n: number) => Math.round(n * SCALE); + return { + stage: { w: stage.w, h: stage.h }, + slab: { w: s(W), h: s(H) }, + // La meta' alta della board, sulla lastra raddrizzata: sotto c'e' il thread, + // che non partecipa al gesto. + board: { x: s(BOARD_LEFT), y: s(BOARD_TOP), w: s(BOARD_W), h: s(THREAD_TOP - 16 - BOARD_TOP) }, + // Le righe della zona board che non devono cambiare: le intestazioni. + stillRows: s(COL_HEADER_H), + minTravel: Number((((columnX(HANDOFF_TO_COL) - columnX(HANDOFF_FROM_COL)) * SCALE) / 2).toFixed(1)), + variants, + }; +}; diff --git a/video/src/products/topics/benches/tempo.ts b/video/src/products/topics/benches/tempo.ts new file mode 100644 index 0000000..e8e57e5 --- /dev/null +++ b/video/src/products/topics/benches/tempo.ts @@ -0,0 +1,36 @@ +import { tempo } from "../../../primitives/tempo.ts"; +import type { Ratio } from "../../../kit/stage.ts"; +import { CARD_HANDOFF_BASE, GRAB, LAG, SETTLE_END } from "../handoff.ts"; + +type Catalog = { + scenes: { id: string; durationInFrames: number }[]; + tempoFixtures: { id: string; scene: string; durationInFrames: number }[]; +}; + +/** + * tempo.py: dove le battute di CardHandoff NON devono scalare. + * + * La card sta dove stava la mano tre frame fa (LAG) e si inclina con la + * differenza fra due campioni: sono soglie percettive, e di proposito restano in + * frame nudi quando la scena accelera. Quindi dalla presa alla fine della posa, + * piu' il ritardo, il provino veloce e il render lungo a tempo normalizzato + * restano diversi, ed e' giusto. Fuori da quella finestra devono coincidere. + * + * La finestra e' in frame DEL PROVINO VELOCE, perche' e' sul provino che il + * banco campiona. Il rapporto non cambia la finestra, cambia quanto pesa: in + * 9:16 la card e' grande il doppio e la camera la segue, e il residuo dentro la + * finestra occupa piu' pixel. Era questo a far sembrare un ritaglio il 9:16 + * quando la mediana si prendeva su tutto il tratto. + */ +export const geometry = (_ratio: Ratio, { catalog }: { catalog: Catalog }) => { + const scene = catalog.scenes.find((s) => s.id === "CardHandoff"); + const fast = catalog.tempoFixtures.find((f) => f.scene === "CardHandoff"); + if (!scene || !fast) throw new Error("catalog.json: manca CardHandoff o il suo provino"); + const T = tempo(fast.durationInFrames, CARD_HANDOFF_BASE); + return { + scene: "CardHandoff", + long: scene.durationInFrames, + fast: fast.durationInFrames, + perceptual: [[Math.floor(T.at(GRAB)), Math.ceil(T.at(SETTLE_END)) + LAG]], + }; +}; diff --git a/video/src/products/topics/geometry.ts b/video/src/products/topics/geometry.ts index 8d3402c..826124d 100644 --- a/video/src/products/topics/geometry.ts +++ b/video/src/products/topics/geometry.ts @@ -237,6 +237,25 @@ export const SEND_Y = COMPOSER_Y + (COMPOSER_H - SEND_H) / 2; /** Dove comincia il thread: sotto le colonne, sopra il composer. */ export const THREAD_TOP = 616; +/** + * Le misure orizzontali del thread: il margine del pannello, il quadratino + * dell'avatar e lo spazio dopo, dove comincia il testo di un messaggio, e le + * larghezze massime di un messaggio e della riga di uno strumento. Stavano + * scritte dentro Assistant.tsx; sono qui perche' nei rapporti verticali la + * larghezza di un messaggio dipende da quanto della lastra si vede (poses.ts). + */ +export const THREAD_PAD_X = 26; +export const MSG_AVATAR_W = 26; +export const MSG_GAP = 14; +export const MSG_TEXT_X = SIDEBAR_W + THREAD_PAD_X + MSG_AVATAR_W + MSG_GAP; +export const MSG_MAX_W = 1180; +/** La bolla dell'utente: padding orizzontale 18 e un filo di bordo per lato. */ +export const USER_BUBBLE_EXTRA_W = 2 * (18 + 1); +export const TOOL_ROW_INDENT = 40; +export const TOOL_ROW_MAX_W = 820; +/** La riga dello strumento: padding orizzontale 16 e un filo di bordo per lato. */ +export const TOOL_ROW_EXTRA_W = 2 * (16 + 1); + /** * Quanto il thread si tiene alla larga dal composer. * @@ -244,7 +263,7 @@ export const THREAD_TOP = 616; * composer e' opaco e disegnato dopo, i messaggi sono ancorati in basso, e con * un margine costante l'ultimo messaggio finiva sotto di lui. La scena * prometteva quattro tempi e ne mostrava tre, con tutti i type check verdi. - * L'ha trovato beats.sh. Un numero costante tornerebbe sbagliato al primo + * L'ha trovato beats.py. Un numero costante tornerebbe sbagliato al primo * ritocco del layout, quindi questo segue il composer. */ export const THREAD_PAD_BOTTOM = SLAB_H - COMPOSER_Y + 24; diff --git a/video/src/products/topics/handoff.ts b/video/src/products/topics/handoff.ts new file mode 100644 index 0000000..ed68aa6 --- /dev/null +++ b/video/src/products/topics/handoff.ts @@ -0,0 +1,206 @@ +import { Easing, interpolate } from "remotion"; +import { + CARD_H, + COLUMNS, + COL_W, + HANDOFF_FROM_COL, + HANDOFF_FROM_IDX, + HANDOFF_TO_COL, + cardY, + columnX, + handoffTargetCards, +} from "./geometry.ts"; +import { pointOnPath } from "../../primitives/path.ts"; +import type { Waypoint } from "../../primitives/path.ts"; +import { tempo } from "../../primitives/tempo.ts"; + +/** + * Il gesto di CardHandoff come dato: i tempi, il percorso della mano e dove sta + * la card a ogni frame. + * + * Stava dentro CardHandoff.tsx. E' qui, in un modulo puro, perche' nei rapporti + * verticali la camera segue la card durante il trascinamento (tracks.ts) e + * handoff-travel.py deve sapere dove la card si trova sulla lastra per + * separare il suo moto da quello della camera. Una seconda copia del percorso + * dentro un banco resterebbe uguale a questa finche' nessuno tocca una delle due. + * + * Modulo puro, letto da Node. + */ + +/** + * La durata di riferimento a cui sono scritti i tempi qui sotto. Cambiare + * `durationInFrames` in catalog.json li scala tutti insieme: e' cosi' che si + * cambia la velocita' della scena senza riscriverne nessuno. + */ +// I tempi. La card non parte al frame 0: prima l'occhio deve riconoscere la +// board come la stessa di prima, poi deve arrivare la mano. Se si muovesse +// subito la giunta sarebbe corretta e illeggibile. +// +// LA MANO C'E' PERCHE' SENZA NON E' UN PRODOTTO. Una card che attraversa da +// sola e' un'animazione; una card che qualcuno prende e sposta e' un software +// che si usa. Erano due voci del catalogo che nessuna scena implementava, CUR-01 +// per l'arrivo in arco e CUR-04 per il peso del trascinamento, e stavano ferme +// li' mentre la scena faceva volare la card con una interpolazione. +export const CARD_HANDOFF_BASE = 240; + +export const GRAB = 78; +export const CARD_HANDOFF_DRAG_START = 84; +export const CARD_HANDOFF_DRAG_END = 176; +export const RELEASE = 178; +export const SETTLE_END = 196; + +/** + * NON SI SCALANO, e i motivi sono diversi fra loro. + * + * LAG e' il peso dell'oggetto: la card sta dove stava la mano tre frame fa + * perche' e' una cosa che ha inerzia, non perche' il montaggio ha quel ritmo. + * A velocita' doppia diventerebbe un frame e mezzo, cioe' la card tornerebbe + * saldata al puntatore, che e' esattamente il difetto che CUR-04 descrive. + * + * TILT_PER_PX non e' nemmeno un tempo: e' gradi per pixel di velocita'. Scala + * da se' quando la corsa si accorcia, perche' la velocita' cresce. + */ +export const LAG = 3; +export const TILT_PER_PX = 0.11; + +/** + * CHR-03, la catena di conseguenze, e i due ritardi che la rendono una catena. + * + * La card si posa, POI il contatore della colonna recepisce, POI la card si + * riscrive l'eta': "12h" diventa "ora", che e' quello che fa una board vera + * quando qualcosa si sposta. Prima i due anelli scattavano tutti e due a meta' + * tragitto, sullo stesso frame: tre cose che cambiano insieme non leggono come + * una causa, leggono come tre cose scollegate che si sono mosse per caso. Il + * ritardo e' l'unica cosa che dice all'occhio quale evento ha provocato + * l'altro, e cinque o sei frame bastano - sotto due spariscono, sopra venti + * diventano lentezza. + * + * IL TERZO ANELLO NON E' IL PANNELLO, ed e' una correzione fatta guardando il + * render. Il catalogo diceva "il pannello cambia stato sei frame dopo", ma a + * questa posa la camera e' gia' abbastanza dentro che il pannello dei dettagli + * esce dal bordo destro: si leggono le etichette e non i valori. Un anello + * della catena fuori quadro non e' un anello. L'eta' della card sta al centro + * dell'inquadratura, e cambia per lo stesso motivo per cui cambierebbe il + * pannello. + */ +export const COUNT_AT = RELEASE + 5; +export const PANEL_AT = RELEASE + 11; + + +/** Le due posizioni di slot fra cui la card vola, e dove la mano la afferra. */ +const slots = () => { + // La colonna di arrivo con la card in coda: e' l'elenco da cui si calcola lo + // slot d'arrivo. + const toWith = handoffTargetCards(); + + const x0 = columnX(HANDOFF_FROM_COL); + const y0 = cardY(COLUMNS[HANDOFF_FROM_COL]!.cards, HANDOFF_FROM_IDX); + const x1 = columnX(HANDOFF_TO_COL); + const y1 = cardY(toWith, toWith.length - 1); + + // Dove la mano afferra la card: non al centro esatto, che legge come un + // bersaglio calcolato, ma sul corpo della card poco sopra la meta'. + const gdx = COL_W * 0.38; + const gdy = CARD_H * 0.42; + + return { x0, y0, x1, y1, gdx, gdy }; +}; + +/** Il percorso della mano, per una durata. */ +export const cardHandoffPath = (durationInFrames: number): Waypoint[] => { + const T = tempo(durationInFrames, CARD_HANDOFF_BASE); + const last = durationInFrames - 1; + const { x0, y0, x1, y1, gdx, gdy } = slots(); + // Il percorso della mano. CUR-01 e' tutto qui dentro: entra da fuori lastra, + // curva - il waypoint di meta' strada sta fuori dall'asse, che e' cio' che + // rende l'arrivo un arco e non una diagonale - supera di poco il bersaglio e + // ci si posa. L'overshoot e' 26 px su 1130 di corsa. + return [ + { x: 2620, y: 1330, at: 0 }, + { x: 2620, y: 1330, at: T.at(14) }, + { x: 1580, y: 700, at: T.at(46) }, + { x: x0 + gdx + 26, y: y0 + gdy - 18, at: T.at(66) }, + { x: x0 + gdx, y: y0 + gdy, at: T.at(76) }, + { x: x0 + gdx, y: y0 + gdy, at: T.at(CARD_HANDOFF_DRAG_START) }, + { x: (x0 + x1) / 2 + gdx, y: Math.min(y0, y1) + gdy - 96, at: T.at(130) }, + { x: x1 + gdx, y: y1 + gdy, at: T.at(CARD_HANDOFF_DRAG_END) }, + { x: x1 + gdx, y: y1 + gdy, at: T.at(186) }, + // La mano se ne va prima della fine, e non e' una gentilezza: la scena dopo + // non ha nessun cursore, quindi se restasse in quadro all'ultimo frame la + // giunta con CardFocus mostrerebbe una freccia che sparisce. + { x: 2620, y: 1330, at: T.at(216) }, + { x: 2620, y: 1330, at: last }, + ]; +}; + +/** Dove sta la card, e in che stato e' il gesto, al frame `frame`. */ +export const cardHandoffMotion = (durationInFrames: number, frame: number) => { + const T = tempo(durationInFrames, CARD_HANDOFF_BASE); + const { x0, y0, x1, y1, gdx, gdy } = slots(); + const path = cardHandoffPath(durationInFrames); + + // CUR-04: la card sta dove stava la mano tre frame fa, e l'inclinazione esce + // dalla differenza fra due campioni. Senza il ritardo la card sembra saldata + // al puntatore; senza l'inclinazione sembra trascinata su un tavolo. + const heldFrame = Math.min(frame, T.at(RELEASE)) - LAG; + const lagged = pointOnPath(path, heldFrame); + const before = pointOnPath(path, heldFrame - 3); + const held = frame >= T.at(GRAB); + + // Dopo il rilascio la card scivola nello slot: la correzione e' piccola, + // perche' la mano ha gia' dimorato sul punto d'arrivo. + const settle = interpolate(frame, [T.at(RELEASE), T.at(SETTLE_END)], [0, 1], { + easing: Easing.inOut(Easing.cubic), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const lift = interpolate( + frame, + [T.at(GRAB), T.at(GRAB + 12), T.at(RELEASE), T.at(SETTLE_END)], + [0, 1, 1, 0], + { + easing: Easing.inOut(Easing.quad), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const dragX = lagged.x - gdx; + const dragY = lagged.y - gdy; + const cardX = !held ? x0 : dragX + (x1 - dragX) * settle; + const cardY_ = !held ? y0 : dragY + (y1 - dragY) * settle; + const tilt = held ? (lagged.x - before.x) * TILT_PER_PX * (1 - settle) : 0; + + + // Quanto del tragitto e' fatto: e' da qui che la board sa quando aggiornare i + // contatori e quando aprire lo slot di destinazione. + const travel = Math.max(0, Math.min(1, (cardX - x0) / (x1 - x0))); + + // Le card sotto risalgono mentre quella sopra si sta gia' posando, non prima. + const closeGap = interpolate(frame, [T.at(CARD_HANDOFF_DRAG_END - 22), T.at(SETTLE_END)], [0, 1], { + easing: Easing.inOut(Easing.cubic), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + + return { + T, + path, + x0, + y0, + x1, + y1, + cardX, + cardY: cardY_, + tilt, + lift, + travel, + closeGap, + held, + /** Il contatore della colonna ha recepito la consegna. */ + handed: frame >= T.at(COUNT_AT), + /** La card si e' riscritta l'eta'. */ + aged: frame >= T.at(PANEL_AT), + }; +}; diff --git a/video/src/products/topics/poses.ts b/video/src/products/topics/poses.ts new file mode 100644 index 0000000..3f071b7 --- /dev/null +++ b/video/src/products/topics/poses.ts @@ -0,0 +1,124 @@ +import { STAGES } from "../../kit/stage.ts"; +import type { Ratio } from "../../kit/stage.ts"; +import { unprojectFrontal } from "../../kit/project.ts"; +import { + BOARD_PAD_X, + MSG_MAX_W, + MSG_TEXT_X, + SIDEBAR_W, + THREAD_PAD_X, + TOOL_ROW_EXTRA_W, + TOOL_ROW_INDENT, + TOOL_ROW_MAX_W, + TOPICS_RIG, + TOPICS_SLAB, + USER_BUBBLE_EXTRA_W, + BOARD_ORBIT_END_POSE, + CARD_FOCUS_END_POSE, + CARD_HANDOFF_END_POSE, + CARD_RELEASE_END_POSE, + PROMPT_INPUT_END_POSE, + UI_MOCKUP_END_POSE, + UI_MOCKUP_START_POSE, +} from "./geometry.ts"; +import type { CameraPose } from "./geometry.ts"; + +/** + * Le sette pose della catena di Topics, per rapporto. + * + * IL 16:9 SONO LE POSE DI SEMPRE, gli stessi oggetti di geometry.ts: i render + * pubblicati non cambiano di un pixel, e still-identity.sh lo misura. + * + * IL 9:16 E IL 4:5 SONO DERIVATI, e sono scritti qui come dati invece di essere + * ricalcolati a ogni render per due motivi. Il calcolo e' una ricerca (una + * scansione sullo zoom e una ricerca a pattern sullo spostamento), e rifarla in + * ogni scheda di Chromium costerebbe secondi e potrebbe dare l'ultima cifra + * diversa da quella che vedono i banchi in Node. E una posa, una volta + * guardata, si puo' voler ritoccare a mano: le pose restano dati del film. + * Da dove vengono lo dice la regola in derive.ts, e `poses-check.py` verifica + * che questa tabella coincida con quello che la regola produce. + * + * LA REGOLA, in breve (derive.ts la scrive per intero): + * - yaw e pitch restano quelli del 16:9, quindi il verso della camera lungo la + * catena non cambia; + * - lo zoom segue lo stesso rapporto di spinta fra una posa e la successiva + * misurato sul 16:9, alzato quanto basta perche' la lastra copra il quadro; + * - il soggetto di ogni posa resta tutto in quadro: la colonna "In corso" alla + * fine di UIMockup, la card consegnata in CardHandoff, la sua parte con tag e + * titolo in CardFocus, intestazione del thread e testo del prompt in + * PromptInput; + * - l'ingresso di UIMockup e il bordo lontano di BoardOrbit cadono alla stessa + * frazione della larghezza del quadro in cui cadono nel 16:9. + */ +export type TopicsPoses = { + UI_MOCKUP_START_POSE: CameraPose; + UI_MOCKUP_END_POSE: CameraPose; + CARD_HANDOFF_END_POSE: CameraPose; + CARD_FOCUS_END_POSE: CameraPose; + CARD_RELEASE_END_POSE: CameraPose; + PROMPT_INPUT_END_POSE: CameraPose; + BOARD_ORBIT_END_POSE: CameraPose; +}; + +export const TOPICS_POSES: Record = { + "16x9": { + UI_MOCKUP_START_POSE, + UI_MOCKUP_END_POSE, + CARD_HANDOFF_END_POSE, + CARD_FOCUS_END_POSE, + CARD_RELEASE_END_POSE, + PROMPT_INPUT_END_POSE, + BOARD_ORBIT_END_POSE, + }, + "9x16": { + UI_MOCKUP_START_POSE: { yaw: -18, pitch: 5, pushZ: 1078.1871018181423, slideX: 1083.8090526268718, slideY: -33.59999999999998 }, + UI_MOCKUP_END_POSE: { yaw: -9, pitch: 2.5, pushZ: 1106.2821091691922, slideX: 47.384363717619365, slideY: -33.59999999999998 }, + CARD_HANDOFF_END_POSE: { yaw: -4, pitch: 1.2, pushZ: 1134.377116520242, slideX: -504.1675631193945, slideY: -33.59999999999998 }, + CARD_FOCUS_END_POSE: { yaw: 0, pitch: 0, pushZ: 1493.6170212765958, slideX: -432.24056737588666, slideY: 126.00000000000004 }, + CARD_RELEASE_END_POSE: { yaw: 0, pitch: 0, pushZ: 913.5145325111718, slideX: 0, slideY: -26.984190912540555 }, + PROMPT_INPUT_END_POSE: { yaw: 0, pitch: 0, pushZ: 1171.1476096614467, slideX: 633.1812068417728, slideY: -131.01763142191854 }, + BOARD_ORBIT_END_POSE: { yaw: -34, pitch: 3.2, pushZ: 741.1729518918756, slideX: 634.420960745726, slideY: -33.59999999999998 }, + }, + "4x5": { + UI_MOCKUP_START_POSE: { yaw: -18, pitch: 5, pushZ: 393.3781826108417, slideX: 1046.7912041341715, slideY: -10.799999999999995 }, + UI_MOCKUP_END_POSE: { yaw: -9, pitch: 2.5, pushZ: 434.1158161626418, slideX: 0, slideY: -10.799999999999995 }, + CARD_HANDOFF_END_POSE: { yaw: -4, pitch: 1.2, pushZ: 474.85344971444135, slideX: -383.40566187013854, slideY: -10.799999999999995 }, + CARD_FOCUS_END_POSE: { yaw: 0, pitch: 0, pushZ: 1493.6170212765958, slideX: -432.24056737588666, slideY: 148.80000000000004 }, + CARD_RELEASE_END_POSE: { yaw: 0, pitch: 0, pushZ: 203.54768581086526, slideX: 0, slideY: -4.228214741439672 }, + PROMPT_INPUT_END_POSE: { yaw: 0, pitch: 0, pushZ: 278.5714285714287, slideX: 447.7999999999999, slideY: -27.107142857142946 }, + BOARD_ORBIT_END_POSE: { yaw: -34, pitch: 3.2, pushZ: -420.0000000000002, slideX: 452.65451461505273, slideY: -10.799999999999995 }, + }, +}; + +/** + * Le larghezze del thread per rapporto. + * + * PERCHE' ESISTE. In 9:16, a PromptInput ferma sul composer, la lastra si vede + * fino a x 876 circa: un messaggio largo 1180 usciva dal quadro a meta' riga, e + * la risposta dell'assistente, che e' la cosa che la scena mostra, si leggeva + * tagliata. Il messaggio va a capo prima, al bordo di quello che si vede, meno + * lo stesso margine della board. Il 16:9 vede la lastra fino a x 1954, quindi + * restano le larghezze di sempre e i render non cambiano. + * + * Il bordo si calcola sulla posa finale di PromptInput, che e' frontale, e vale + * per tutte le scene dello stesso rapporto: il thread deve essere lo stesso in + * ogni scena, altrimenti alla giunta cambierebbe forma. + */ +export type TopicsLayout = { msgMaxW: number; toolRowMaxW: number }; + +export const topicsLayout = (ratio: Ratio): TopicsLayout => { + const right = unprojectFrontal( + STAGES[ratio], + TOPICS_RIG, + TOPICS_SLAB, + { slideY: 0, ...TOPICS_POSES[ratio].PROMPT_INPUT_END_POSE }, + { x: STAGES[ratio].w, y: 0 }, + ).x; + return { + msgMaxW: Math.min(MSG_MAX_W, Math.floor(right - BOARD_PAD_X - MSG_TEXT_X - USER_BUBBLE_EXTRA_W)), + toolRowMaxW: Math.min( + TOOL_ROW_MAX_W, + Math.floor(right - BOARD_PAD_X - (SIDEBAR_W + THREAD_PAD_X + TOOL_ROW_INDENT) - TOOL_ROW_EXTRA_W), + ), + }; +}; diff --git a/video/src/products/topics/promptTiming.ts b/video/src/products/topics/promptTiming.ts new file mode 100644 index 0000000..ec3d556 --- /dev/null +++ b/video/src/products/topics/promptTiming.ts @@ -0,0 +1,175 @@ +import { interpolate } from "remotion"; +import { typingSchedule } from "../../primitives/rhythm.ts"; +import { tempo } from "../../primitives/tempo.ts"; +import type { Waypoint } from "../../primitives/path.ts"; +import { + COMPOSER_H, + COMPOSER_X, + COMPOSER_Y, + SEND_H, + SEND_W, + SEND_X, + SEND_Y, +} from "./geometry.ts"; +import { PROMPT_INPUT_BASE } from "./tracks.ts"; + +/** + * La recita di PromptInput come dato: quando la mano arriva, quando si scrive, + * quando parte l'invio, quando arrivano la bolla, i puntini e la risposta. + * + * Stava dentro PromptInput.tsx. E' qui, in un modulo puro, perche' click-gap.py + * e beats.py devono sapere in che frame cercare il clic e le battute: prima se + * li scrivevano da soli (450, 170, 210, 300...), copiati a mano dalla scena e + * giusti solo alla durata di riferimento e nel 16:9. + * + * Modulo puro, letto da Node. + */ + +// 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 (PROMPT_INPUT_BASE) sta + in products/topics/tracks.ts, perche' la legge anche la traccia della camera. */ + +export const T = { + travelStart: 56, + travelEnd: 114, + clickField: 116, + typeStart: 132, + pauseAfterTyping: 24, + travelToSend: 32, + bubble: 14, + thinking: 26, +} as const; + +/** + * IL BATTITO DEL CARET NON SI SCALA: quindici frame sono una frequenza, non una + * durata. Un cursore che lampeggia al doppio della velocita' non legge come una + * scena piu' rapida, legge come un cursore rotto. + */ +export const CARET_PERIOD = 15; + +/** + * QUATTRO FRAME FRA IL COLPO E LA CONSEGUENZA, e non si scalano nemmeno questi. + * click-gap.py misura che stiano fra 2 e 8: e' la finestra in cui l'occhio lega + * il gesto al suo effetto, non una decisione di ritmo. A velocita' doppia + * diventerebbero due, sul bordo di sparire. + */ +export const BUBBLE_AFTER_CLICK = 4; + +export const DEFAULT_PROMPT = "Rifai il flusso di auth e apri la PR"; + +export const DEFAULT_RESPONSE = + "Trovati tre punti di chiamata in server/auth.ts. Sposto il refresh del token dentro un guard solo, poi apro la PR su topics/auth-refresh."; + + +/** I frame della recita a una durata. `fps` serve alla battitura. */ +export const promptInputTimeline = ( + durationInFrames: number, + fps: number, + prompt: string = DEFAULT_PROMPT, +) => { + const last = durationInFrames - 1; + const K = tempo(durationInFrames, PROMPT_INPUT_BASE); + + /** + * LA BATTITURA SCALA, e SI DIVIDE invece di moltiplicarsi. Tredici caratteri + * al secondo e' un ritmo umano, quindi sarebbe da lasciare fermo; ma una + * scena piu' rapida in cui il testo si scrive alla stessa velocita' non ci + * sta dentro: la spedizione arriva dopo l'ultimo fotogramma e la scena + * finisce a meta' gesto. + * + * `cps` e' una VELOCITA', non una durata, quindi va all'inverso del fattore: + * a meta' durata servono il doppio dei caratteri al secondo. Scrivendolo + * moltiplicato - come era la prima volta - una scena piu' corta si ritrovava + * un dattilografo piu' LENTO, la spedizione slittava all'83 per cento della + * durata invece del 60, e `beats.py` trovava il campo ancora pieno dove si + * aspettava il segnaposto. Sbagliato di un reciproco, e visibile solo + * misurando. + * + * Il limite superiore esiste e non e' misurato: oltre una certa velocita' la + * battitura smette di leggere come una mano e comincia a leggere come un + * incolla. A meta' durata sono 26 caratteri al secondo, ed e' probabilmente + * la' intorno. + */ + const schedule = typingSchedule({ + text: prompt, + startFrame: K.at(T.typeStart), + fps, + cps: 13 / K.k, + }); + + const typeEnd = schedule[schedule.length - 1] ?? T.typeStart; + const sendTravelStart = typeEnd + K.at(T.pauseAfterTyping); + const sendClick = sendTravelStart + K.at(T.travelToSend); + const bubbleAt = sendClick + BUBBLE_AFTER_CLICK; + const thinkAt = bubbleAt + K.at(T.bubble); + const streamAt = thinkAt + K.at(T.thinking); + + return { + K, + last, + schedule, + typeStart: K.at(T.typeStart), + typeEnd, + /** Il clic sul campo, che gli da' il fuoco. */ + fieldClick: K.at(T.clickField), + sendTravelStart, + sendClick, + bubbleAt, + thinkAt, + streamAt, + /** Lo streaming finisce qui: l'ultima parola arriva diciotto frame (a riferimento) prima della fine. */ + streamEnd: last - K.at(18), + /** CAM-05: l'attenuazione scende in questa finestra e poi resta al pavimento. */ + attnFrom: streamAt - K.at(6), + attnTo: streamAt + K.at(26), + }; +}; + +export type PromptInputTimeline = ReturnType; + +/** Quante parole della risposta si vedono al frame `frame`. */ +export const streamedWordCount = ( + tl: PromptInputTimeline, + frame: number, + words: number, +): number => + Math.max( + 0, + Math.min( + words, + Math.floor( + interpolate(frame, [tl.streamAt, tl.streamEnd], [0, words], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ), + ), + ); + +/** + * Il percorso del cursore, in coordinate della lastra condivisa. Le mire sono + * le costanti di topics/geometry.ts, non due numeri copiati dal layout: se il + * composer si sposta il puntatore lo segue. + */ +export const promptInputPath = ( + tl: PromptInputTimeline, + durationInFrames: number, +): Waypoint[] => { + const { K, sendTravelStart, sendClick, streamAt } = tl; + return [ + { x: 2620, y: 1330, at: 0 }, + { x: 2620, y: 1330, at: K.at(T.travelStart) }, + { x: 1760, y: 1214, at: K.at(T.travelStart + 22) }, + { x: COMPOSER_X + 110, y: COMPOSER_Y + COMPOSER_H / 2, at: K.at(T.travelEnd) }, + { x: COMPOSER_X + 110, y: COMPOSER_Y + COMPOSER_H / 2, at: sendTravelStart }, + { x: SEND_X + SEND_W / 2, y: SEND_Y + SEND_H / 2, at: sendClick }, + // La mano si ritira mentre la risposta scorre. Non e' una gentilezza: al + // suo posto resterebbe una freccia sull'ultimo fotogramma, e la scena dopo + // un cursore non ce l'ha, quindi la giunta la mostrerebbe sparire. + { x: SEND_X + SEND_W / 2, y: SEND_Y + SEND_H / 2, at: streamAt + K.at(16) }, + { x: 2620, y: 1330, at: streamAt + K.at(58) }, + { x: 2620, y: 1330, at: durationInFrames }, + ]; +}; diff --git a/video/src/products/topics/scenes/BoardOrbit.tsx b/video/src/products/topics/scenes/BoardOrbit.tsx index 1c88c25..691f316 100644 --- a/video/src/products/topics/scenes/BoardOrbit.tsx +++ b/video/src/products/topics/scenes/BoardOrbit.tsx @@ -15,6 +15,7 @@ import { TOPICS_SLAB, } from "../geometry"; import { poseAt } from "../../../kit/camera"; +import { stageFor } from "../../../kit/stage"; import { BOARD_ORBIT_BASE as BASE, BOARD_ORBIT_SETTLE as SETTLE, @@ -60,7 +61,8 @@ export type BoardOrbitProps = { export const BoardOrbit: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); + const { durationInFrames, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); @@ -74,7 +76,7 @@ export const BoardOrbit: React.FC = ({ progress }) => { // La camera sta in products/topics/tracks.ts, con la stessa curva e la stessa // finestra: `at` resta qui per l'attenuazione, che non e' camera. - const pose = poseAt(boardOrbitTrack(durationInFrames), frame); + const pose = poseAt(boardOrbitTrack(durationInFrames, ratio), frame); // Il quadro si riapre: 0,62 e' dove PromptInput ha lasciato l'attenuazione. const attn = at(0.62, 1); diff --git a/video/src/products/topics/scenes/CardFocus.tsx b/video/src/products/topics/scenes/CardFocus.tsx index a1909d2..22e997f 100644 --- a/video/src/products/topics/scenes/CardFocus.tsx +++ b/video/src/products/topics/scenes/CardFocus.tsx @@ -13,6 +13,7 @@ import { handoffLandedRect, } from "../geometry"; import { poseAt } from "../../../kit/camera"; +import { stageFor } from "../../../kit/stage"; import { cardFocusTrack } from "../tracks"; import { Board } from "../Board"; import { Shot } from "../../../kit/Shot"; @@ -52,7 +53,8 @@ export type CardFocusProps = { export const CardFocus: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); + const { durationInFrames, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); @@ -60,7 +62,7 @@ export const CardFocus: React.FC = ({ progress }) => { // agli estremi con derivata nulla: a sinistra si aggancia alla fine di // CardHandoff, che e' ferma, a destra lascia una scena che si puo' mettere // prima di qualunque altra. La curva sta in products/topics/tracks.ts. - const pose = poseAt(cardFocusTrack(durationInFrames), frame); + const pose = poseAt(cardFocusTrack(durationInFrames, ratio), frame); // La board a consegna avvenuta: sono i tre valori che CardHandoff raggiunge // al suo ultimo frame, e la card sta dove dice `handoffLandedRect`. diff --git a/video/src/products/topics/scenes/CardHandoff.tsx b/video/src/products/topics/scenes/CardHandoff.tsx index 97e0d69..3abf6a1 100644 --- a/video/src/products/topics/scenes/CardHandoff.tsx +++ b/video/src/products/topics/scenes/CardHandoff.tsx @@ -1,31 +1,21 @@ import React from "react"; +import { useCurrentFrame, useVideoConfig } from "remotion"; import { - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; -import { - CARD_H, COLUMNS, - COL_W, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, - HANDOFF_TO_COL, - cardY, - columnX, handoffCard, - handoffTargetCards, TOPICS_RIG, TOPICS_SLAB, } from "../geometry"; import { poseAt } from "../../../kit/camera"; import { cardHandoffTrack } from "../tracks"; +import { GRAB, RELEASE, cardHandoffMotion } from "../handoff"; +import { stageFor } from "../../../kit/stage"; import { Shot } from "../../../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../material"; import { Board } from "../Board"; -import { Cursor, pointOnPath, type Waypoint } from "../../../primitives/Cursor"; -import { tempo } from "../../../primitives/tempo"; +import { Cursor } from "../../../primitives/Cursor"; /** * CardHandoff: la terza scena, e quella che rende dimostrabile la regola @@ -61,161 +51,34 @@ export type CardHandoffProps = { progress?: number; }; -// I tempi. La card non parte al frame 0: prima l'occhio deve riconoscere la -// board come la stessa di prima, poi deve arrivare la mano. Se si muovesse -// subito la giunta sarebbe corretta e illeggibile. -// -// LA MANO C'E' PERCHE' SENZA NON E' UN PRODOTTO. Una card che attraversa da -// sola e' un'animazione; una card che qualcuno prende e sposta e' un software -// che si usa. Erano due voci del catalogo che nessuna scena implementava, CUR-01 -// per l'arrivo in arco e CUR-04 per il peso del trascinamento, e stavano ferme -// li' mentre la scena faceva volare la card con una interpolazione. -/** - * La durata di riferimento a cui sono scritti i tempi qui sotto. Cambiare - * `durationInFrames` in catalog.json li scala tutti insieme: e' cosi' che si - * cambia la velocita' della scena senza riscriverne nessuno. - */ -const BASE = 240; - -const GRAB = 78; -const DRAG_START = 84; -const DRAG_END = 176; -const RELEASE = 178; -const SETTLE_END = 196; - -/** - * NON SI SCALANO, e i motivi sono diversi fra loro. - * - * LAG e' il peso dell'oggetto: la card sta dove stava la mano tre frame fa - * perche' e' una cosa che ha inerzia, non perche' il montaggio ha quel ritmo. - * A velocita' doppia diventerebbe un frame e mezzo, cioe' la card tornerebbe - * saldata al puntatore, che e' esattamente il difetto che CUR-04 descrive. - * - * TILT_PER_PX non e' nemmeno un tempo: e' gradi per pixel di velocita'. Scala - * da se' quando la corsa si accorcia, perche' la velocita' cresce. - */ -const LAG = 3; -const TILT_PER_PX = 0.11; - -/** - * CHR-03, la catena di conseguenze, e i due ritardi che la rendono una catena. - * - * La card si posa, POI il contatore della colonna recepisce, POI la card si - * riscrive l'eta': "12h" diventa "ora", che e' quello che fa una board vera - * quando qualcosa si sposta. Prima i due anelli scattavano tutti e due a meta' - * tragitto, sullo stesso frame: tre cose che cambiano insieme non leggono come - * una causa, leggono come tre cose scollegate che si sono mosse per caso. Il - * ritardo e' l'unica cosa che dice all'occhio quale evento ha provocato - * l'altro, e cinque o sei frame bastano - sotto due spariscono, sopra venti - * diventano lentezza. - * - * IL TERZO ANELLO NON E' IL PANNELLO, ed e' una correzione fatta guardando il - * render. Il catalogo diceva "il pannello cambia stato sei frame dopo", ma a - * questa posa la camera e' gia' abbastanza dentro che il pannello dei dettagli - * esce dal bordo destro: si leggono le etichette e non i valori. Un anello - * della catena fuori quadro non e' un anello. L'eta' della card sta al centro - * dell'inquadratura, e cambia per lo stesso motivo per cui cambierebbe il - * pannello. - */ -const COUNT_AT = RELEASE + 5; -const PANEL_AT = RELEASE + 11; +// I tempi, il percorso della mano e dove sta la card a ogni frame sono in +// products/topics/handoff.ts: li legge anche handoff-travel.py, e nei rapporti +// verticali la traccia della camera segue la card nella finestra del +// trascinamento. export const CardHandoff: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); + const { durationInFrames, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); - const last = durationInFrames - 1; - const T = tempo(durationInFrames, BASE); // La camera continua l'arco di UIMockup: stessa direzione, stessa curva // (products/topics/tracks.ts). - const pose = poseAt(cardHandoffTrack(durationInFrames), frame); - + const pose = poseAt(cardHandoffTrack(durationInFrames, ratio), frame); const moving = handoffCard(); - // Colonna di partenza senza la card che vola, colonna di arrivo con la card - // in coda: sono gli elenchi da cui si calcolano le due posizioni di slot. + // Colonna di partenza senza la card che vola: l'elenco da cui la board + // ricalcola gli slot che restano. const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter((_, i) => i !== HANDOFF_FROM_IDX); - const toWith = handoffTargetCards(); - - const x0 = columnX(HANDOFF_FROM_COL); - const y0 = cardY(COLUMNS[HANDOFF_FROM_COL]!.cards, HANDOFF_FROM_IDX); - const x1 = columnX(HANDOFF_TO_COL); - const y1 = cardY(toWith, toWith.length - 1); - - // Dove la mano afferra la card: non al centro esatto, che legge come un - // bersaglio calcolato, ma sul corpo della card poco sopra la meta'. - const gdx = COL_W * 0.38; - const gdy = CARD_H * 0.42; - - // Il percorso della mano. CUR-01 e' tutto qui dentro: entra da fuori lastra, - // curva - il waypoint di meta' strada sta fuori dall'asse, che e' cio' che - // rende l'arrivo un arco e non una diagonale - supera di poco il bersaglio e - // ci si posa. L'overshoot e' 26 px su 1130 di corsa. - const path: Waypoint[] = [ - { x: 2620, y: 1330, at: 0 }, - { x: 2620, y: 1330, at: T.at(14) }, - { x: 1580, y: 700, at: T.at(46) }, - { x: x0 + gdx + 26, y: y0 + gdy - 18, at: T.at(66) }, - { x: x0 + gdx, y: y0 + gdy, at: T.at(76) }, - { x: x0 + gdx, y: y0 + gdy, at: T.at(DRAG_START) }, - { x: (x0 + x1) / 2 + gdx, y: Math.min(y0, y1) + gdy - 96, at: T.at(130) }, - { x: x1 + gdx, y: y1 + gdy, at: T.at(DRAG_END) }, - { x: x1 + gdx, y: y1 + gdy, at: T.at(186) }, - // La mano se ne va prima della fine, e non e' una gentilezza: la scena dopo - // non ha nessun cursore, quindi se restasse in quadro all'ultimo frame la - // giunta con CardFocus mostrerebbe una freccia che sparisce. - { x: 2620, y: 1330, at: T.at(216) }, - { x: 2620, y: 1330, at: last }, - ]; - // CUR-04: la card sta dove stava la mano tre frame fa, e l'inclinazione esce - // dalla differenza fra due campioni. Senza il ritardo la card sembra saldata - // al puntatore; senza l'inclinazione sembra trascinata su un tavolo. - const heldFrame = Math.min(frame, T.at(RELEASE)) - LAG; - const lagged = pointOnPath(path, heldFrame); - const before = pointOnPath(path, heldFrame - 3); - const held = frame >= T.at(GRAB); - - // Dopo il rilascio la card scivola nello slot: la correzione e' piccola, - // perche' la mano ha gia' dimorato sul punto d'arrivo. - const settle = interpolate(frame, [T.at(RELEASE), T.at(SETTLE_END)], [0, 1], { - easing: Easing.inOut(Easing.cubic), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - const lift = interpolate( - frame, - [T.at(GRAB), T.at(GRAB + 12), T.at(RELEASE), T.at(SETTLE_END)], - [0, 1, 1, 0], - { - easing: Easing.inOut(Easing.quad), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - const dragX = lagged.x - gdx; - const dragY = lagged.y - gdy; - const cardX = !held ? x0 : dragX + (x1 - dragX) * settle; - const cardY_ = !held ? y0 : dragY + (y1 - dragY) * settle; - const tilt = held ? (lagged.x - before.x) * TILT_PER_PX * (1 - settle) : 0; + const m = cardHandoffMotion(durationInFrames, frame); + const { path, cardX, closeGap, lift, tilt, travel } = m; + const cardY_ = m.cardY; // Il terzo anello della catena: la card si riscrive l'eta'. - const movingNow = frame >= T.at(PANEL_AT) ? { ...moving, age: "ora" } : moving; - - // Quanto del tragitto e' fatto: e' da qui che la board sa quando aggiornare i - // contatori e quando aprire lo slot di destinazione. - const travel = Math.max(0, Math.min(1, (cardX - x0) / (x1 - x0))); - - // Le card sotto risalgono mentre quella sopra si sta gia' posando, non prima. - const closeGap = interpolate(frame, [T.at(DRAG_END - 22), T.at(SETTLE_END)], [0, 1], { - easing: Easing.inOut(Easing.cubic), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); + const movingNow = m.aged ? { ...moving, age: "ora" } : moving; return ( = ({ progress }) => { moving={movingNow} fromRest={fromRest} tilt={tilt} - handed={frame >= T.at(COUNT_AT) ? 1 : 0} - statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} + handed={m.handed ? 1 : 0} + statusChanged={m.aged ? 1 : 0} dimmed /> } @@ -248,15 +111,15 @@ export const CardHandoff: React.FC = ({ progress }) => { moving={movingNow} fromRest={fromRest} tilt={tilt} - handed={frame >= T.at(COUNT_AT) ? 1 : 0} - statusChanged={frame >= T.at(PANEL_AT) ? 1 : 0} + handed={m.handed ? 1 : 0} + statusChanged={m.aged ? 1 : 0} /> {/* La mano sta DENTRO la lastra, quindi prende la stessa prospettiva e appoggia sul piano. Al primo e all'ultimo frame sta fuori dai 2400x1200 e l'overflow la taglia: e' cosi' che le due giunte restano identiche a scene che un cursore non ce l'hanno. */} - + ); }; diff --git a/video/src/products/topics/scenes/CardRelease.tsx b/video/src/products/topics/scenes/CardRelease.tsx index e1c5a16..e52a170 100644 --- a/video/src/products/topics/scenes/CardRelease.tsx +++ b/video/src/products/topics/scenes/CardRelease.tsx @@ -13,6 +13,7 @@ import { TOPICS_SLAB, } from "../geometry"; import { poseAt } from "../../../kit/camera"; +import { stageFor } from "../../../kit/stage"; import { cardReleaseTrack } from "../tracks"; import { Shot } from "../../../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../material"; @@ -47,15 +48,15 @@ export type CardReleaseProps = { export const CardRelease: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); + const { durationInFrames, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); // Una curva sola, inOut: derivata nulla a sinistra per agganciarsi alla fine // di CardFocus, derivata nulla a destra perche' e' l'ultimo frame del film. // La curva sta in products/topics/tracks.ts. - const pose = poseAt(cardReleaseTrack(durationInFrames), frame); - + const pose = poseAt(cardReleaseTrack(durationInFrames, ratio), frame); const moving = handoffCard(); const fromRest = COLUMNS[HANDOFF_FROM_COL]!.cards.filter( diff --git a/video/src/products/topics/scenes/PromptInput.tsx b/video/src/products/topics/scenes/PromptInput.tsx index b1a4299..82141f3 100644 --- a/video/src/products/topics/scenes/PromptInput.tsx +++ b/video/src/products/topics/scenes/PromptInput.tsx @@ -7,30 +7,28 @@ import { } from "remotion"; import { Board } from "../Board"; import { bubbleCurve } from "../Assistant"; -import { Cursor, type Waypoint } from "../../../primitives/Cursor"; -import { typedCount, typingSchedule } from "../../../primitives/rhythm"; -import { tempo } from "../../../primitives/tempo"; +import { Cursor } from "../../../primitives/Cursor"; +import { typedCount } from "../../../primitives/rhythm"; +import { + CARET_PERIOD, + DEFAULT_PROMPT, + DEFAULT_RESPONSE, + promptInputPath, + promptInputTimeline, + streamedWordCount, +} from "../promptTiming"; import { COLUMNS, - COMPOSER_H, - COMPOSER_X, - COMPOSER_Y, HANDOFF_FROM_COL, HANDOFF_FROM_IDX, - SEND_H, - SEND_W, - SEND_X, - SEND_Y, handoffCard, handoffLandedRect, TOPICS_RIG, TOPICS_SLAB, } from "../geometry"; import { poseAt } from "../../../kit/camera"; -import { - PROMPT_INPUT_BASE as BASE, - promptInputTrack, -} from "../tracks"; +import { stageFor } from "../../../kit/stage"; +import { promptInputTrack } from "../tracks"; import { Shot } from "../../../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../material"; @@ -75,42 +73,8 @@ export type PromptInputProps = { attnFloor?: number; }; -// 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 (BASE) sta in - products/topics/tracks.ts, perche' la legge anche la traccia della camera. */ - -const T = { - travelStart: 56, - travelEnd: 114, - clickField: 116, - typeStart: 132, - pauseAfterTyping: 24, - travelToSend: 32, - bubble: 14, - thinking: 26, -} as const; - -/** - * IL BATTITO DEL CARET NON SI SCALA: quindici frame sono una frequenza, non una - * durata. Un cursore che lampeggia al doppio della velocita' non legge come una - * scena piu' rapida, legge come un cursore rotto. - */ -const CARET_PERIOD = 15; - -/** - * QUATTRO FRAME FRA IL COLPO E LA CONSEGUENZA, e non si scalano nemmeno questi. - * click-gap.sh misura che stiano fra 1 e 8: e' la finestra in cui l'occhio lega - * il gesto al suo effetto, non una decisione di ritmo. A velocita' doppia - * diventerebbero due, sul bordo di sparire. - */ -const BUBBLE_AFTER_CLICK = 4; - -const DEFAULT_PROMPT = "Rifai il flusso di auth e apri la PR"; - -const DEFAULT_RESPONSE = - "Trovati tre punti di chiamata in server/auth.ts. Sposto il refresh del token dentro un guard solo, poi apro la PR su topics/auth-refresh."; +// I frame della recita, la battitura, i testi e il percorso del cursore sono +// in products/topics/promptTiming.ts: li leggono anche click-gap.py e beats.py. export const PromptInput: React.FC = ({ prompt = DEFAULT_PROMPT, @@ -120,55 +84,22 @@ export const PromptInput: React.FC = ({ attnFloor = 0.62, }) => { const localFrame = useCurrentFrame(); - const { durationInFrames, fps } = useVideoConfig(); + const { durationInFrames, fps, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); - const last = durationInFrames - 1; - const K = tempo(durationInFrames, BASE); - - /** - * LA BATTITURA SCALA, e SI DIVIDE invece di moltiplicarsi. Tredici caratteri - * al secondo e' un ritmo umano, quindi sarebbe da lasciare fermo; ma una - * scena piu' rapida in cui il testo si scrive alla stessa velocita' non ci - * sta dentro: la spedizione arriva dopo l'ultimo fotogramma e la scena - * finisce a meta' gesto. - * - * `cps` e' una VELOCITA', non una durata, quindi va all'inverso del fattore: - * a meta' durata servono il doppio dei caratteri al secondo. Scrivendolo - * moltiplicato - come era la prima volta - una scena piu' corta si ritrovava - * un dattilografo piu' LENTO, la spedizione slittava all'83 per cento della - * durata invece del 60, e `beats.sh` trovava il campo ancora pieno dove si - * aspettava il segnaposto. Sbagliato di un reciproco, e visibile solo - * misurando. - * - * Il limite superiore esiste e non e' misurato: oltre una certa velocita' la - * battitura smette di leggere come una mano e comincia a leggere come un - * incolla. A meta' durata sono 26 caratteri al secondo, ed e' probabilmente - * la' intorno. - */ - const schedule = typingSchedule({ - text: prompt, - startFrame: K.at(T.typeStart), - fps, - cps: 13 / K.k, - }); - const typeEnd = schedule[schedule.length - 1] ?? T.typeStart; + const tl = promptInputTimeline(durationInFrames, fps, prompt); + const { schedule, sendClick, bubbleAt, thinkAt, streamAt } = tl; const nTyped = typedCount(schedule, frame); const typed = prompt.slice(0, nTyped); - const sendTravelStart = typeEnd + K.at(T.pauseAfterTyping); - const sendClick = sendTravelStart + K.at(T.travelToSend); - const bubbleAt = sendClick + BUBBLE_AFTER_CLICK; - const thinkAt = bubbleAt + K.at(T.bubble); - const streamAt = thinkAt + K.at(T.thinking); - const sent = frame >= sendClick; /** * LA CAMERA SI FERMA A f132, e non alla fine della scena. * - * Non e' una scelta di gusto, e' venuta da un banco. `click-gap.sh` trova il + * Non e' una scelta di gusto, e' venuta da un banco. `click-gap.py` trova il * colpo e la conseguenza nel render cercando il fotogramma il cui conto di * pixel cambiati sfonda la mediana della finestra. Con la camera che scivola * per tutti i 450 frame, ogni fotogramma cambia molto e il clic non sfonda @@ -184,28 +115,16 @@ export const PromptInput: React.FC = ({ // 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. const words = response.split(" "); - const streamed = Math.max( - 0, - Math.min( - words.length, - Math.floor( - interpolate(frame, [streamAt, last - K.at(18)], [0, words.length], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }), - ), - ), - ); + const streamed = streamedWordCount(tl, frame, words.length); const answer = words.slice(0, streamed).join(" "); // 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. La curva, e la finestra di 132 frame // spiegata qui sopra, stanno in products/topics/tracks.ts. - const pose = poseAt(promptInputTrack(durationInFrames), frame); - + const pose = poseAt(promptInputTrack(durationInFrames, ratio), frame); - const focused = frame >= K.at(T.clickField); + const focused = frame >= tl.fieldClick; // Il caret lampeggia a 15 frame, e il calcolo e' sul frame: nessun keyframe CSS. const caretOn = focused && !sent && Math.floor(frame / CARET_PERIOD) % 2 === 0; @@ -220,7 +139,7 @@ export const PromptInput: React.FC = ({ * pavimento: sotto, il contenuto attenuato scende sotto 3:1 una volta * renderizzato e legge come sporco sul fondo invece che come un piano dietro. */ - const attn = interpolate(frame, [streamAt - K.at(6), streamAt + K.at(26)], [1, attnFloor], { + const attn = interpolate(frame, [tl.attnFrom, tl.attnTo], [1, attnFloor], { easing: Easing.inOut(Easing.cubic), extrapolateLeft: "clamp", extrapolateRight: "clamp", @@ -233,23 +152,7 @@ export const PromptInput: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, ); - // Il percorso del cursore, in coordinate della lastra condivisa. Le mire sono - // le costanti di topics/geometry.ts, non due numeri copiati dal layout: se il composer - // si sposta il puntatore lo segue. - const path: Waypoint[] = [ - { x: 2620, y: 1330, at: 0 }, - { x: 2620, y: 1330, at: K.at(T.travelStart) }, - { x: 1760, y: 1214, at: K.at(T.travelStart + 22) }, - { x: COMPOSER_X + 110, y: COMPOSER_Y + COMPOSER_H / 2, at: K.at(T.travelEnd) }, - { x: COMPOSER_X + 110, y: COMPOSER_Y + COMPOSER_H / 2, at: sendTravelStart }, - { x: SEND_X + SEND_W / 2, y: SEND_Y + SEND_H / 2, at: sendClick }, - // La mano si ritira mentre la risposta scorre. Non e' una gentilezza: al - // suo posto resterebbe una freccia sull'ultimo fotogramma, e la scena dopo - // un cursore non ce l'ha, quindi la giunta la mostrerebbe sparire. - { x: SEND_X + SEND_W / 2, y: SEND_Y + SEND_H / 2, at: streamAt + K.at(16) }, - { x: 2620, y: 1330, at: streamAt + K.at(58) }, - { x: 2620, y: 1330, at: durationInFrames }, - ]; + const path = promptInputPath(tl, durationInFrames); // La board sta come l'ha lasciata CardRelease: consegna avvenuta, niente in // volo. E' lo stesso componente con gli stessi valori, quindi il primo frame @@ -299,7 +202,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/products/topics/scenes/UIMockup.tsx b/video/src/products/topics/scenes/UIMockup.tsx index 08216e5..4772d58 100644 --- a/video/src/products/topics/scenes/UIMockup.tsx +++ b/video/src/products/topics/scenes/UIMockup.tsx @@ -16,6 +16,7 @@ import { TOPICS_SLAB, } from "../geometry"; import { poseAt } from "../../../kit/camera"; +import { stageFor } from "../../../kit/stage"; import { uiMockupTrack } from "../tracks"; import { Shot } from "../../../kit/Shot"; import { TOPICS_SHOT_MATERIAL } from "../material"; @@ -66,7 +67,8 @@ export type UIMockupProps = { export const UIMockup: React.FC = ({ progress }) => { const localFrame = useCurrentFrame(); - const { durationInFrames } = useVideoConfig(); + const { durationInFrames, width, height } = useVideoConfig(); + const { ratio } = stageFor(width, height); const frame = progress === undefined ? localFrame : progress * (durationInFrames - 1); @@ -75,7 +77,7 @@ export const UIMockup: React.FC = ({ progress }) => { // per tutta la scena, finendo piu' frontale di come e' partita, cosi' la scena // successiva puo' partire da qui. La curva sta in products/topics/tracks.ts, la // stessa che leggono i banchi. - const pose = poseAt(uiMockupTrack(durationInFrames), frame); + const pose = poseAt(uiMockupTrack(durationInFrames, ratio), frame); const fadeIn = interpolate(frame, [0, 20], [0, 1], { extrapolateLeft: "clamp", diff --git a/video/src/products/topics/thread.ts b/video/src/products/topics/thread.ts new file mode 100644 index 0000000..c9bb3ce --- /dev/null +++ b/video/src/products/topics/thread.ts @@ -0,0 +1,30 @@ +/** + * Lo scambio che il thread dell'assistente mostra gia' prima che la scena + * cominci: due domande, due risposte, e la lettura di un file. + * + * Stava scritto dentro Assistant.tsx. E' qui, in un modulo puro, perche' beats.py + * legge il render con l'OCR e deve sapere quali parole c'erano gia' in quadro: + * "solo" sta nella risposta che arriva in streaming e anche in questa storia, e + * contarla voleva dire trovare la risposta prima che arrivasse. + * + * Modulo puro, letto da Node. + */ +export type ThreadItem = + | { kind: "msg"; who: "user" | "assistant"; text: string } + | { kind: "tool"; file: string }; + +export const THREAD_HISTORY: ThreadItem[] = [ + { kind: "msg", who: "user", text: "Prendi i quattro commercial e dimmi cosa fanno davvero." }, + { + kind: "msg", + who: "assistant", + text: "Su quattro, solo i due Linear sono motion graphics. Cursor e Raycast sono girati con una camera: attore, luce calda, mani vere. Quelli non si replicano in codice.", + }, + { kind: "tool", file: "ref/sheet_ovxL42LkKNg.jpg" }, + { kind: "msg", who: "user", text: "Fammi vedere la board con la card attiva." }, + { + kind: "msg", + who: "assistant", + text: "Fatto. Kanban aperto, la card attiva e' UIMockup: piano 3D piu' parallasse, e il pannello mostra branch e assegnatario.", + }, +]; diff --git a/video/src/products/topics/tracks.ts b/video/src/products/topics/tracks.ts index ea3a518..ee55330 100644 --- a/video/src/products/topics/tracks.ts +++ b/video/src/products/topics/tracks.ts @@ -1,13 +1,7 @@ -import type { Track } from "../../kit/camera.ts"; -import { - BOARD_ORBIT_END_POSE, - CARD_FOCUS_END_POSE, - CARD_HANDOFF_END_POSE, - CARD_RELEASE_END_POSE, - PROMPT_INPUT_END_POSE, - UI_MOCKUP_END_POSE, - UI_MOCKUP_START_POSE, -} from "./geometry.ts"; +import type { AxisCurve, Ease, Track } from "../../kit/camera.ts"; +import type { Ratio } from "../../kit/stage.ts"; +import { TOPICS_POSES } from "./poses.ts"; +import { CARD_HANDOFF_BASE, CARD_HANDOFF_DRAG_END, CARD_HANDOFF_DRAG_START } from "./handoff.ts"; import { tempo } from "../../primitives/tempo.ts"; /** @@ -19,12 +13,26 @@ import { tempo } from "../../primitives/tempo.ts"; * scala con lo stesso `tempo()`. Le scene leggono la loro traccia con `poseAt`; * chain-check e fill-geom leggono le stesse tracce senza renderizzare. * + * OGNI TRACCIA PRENDE IL RAPPORTO, e ne legge le pose da poses.ts. Nel 16:9 le + * pose sono quelle di sempre e le tracce escono identiche; negli altri rapporti + * cambiano solo i numeri, non la forma. L'unica aggiunta e' lo spostamento + * della camera durante il trascinamento di CardHandoff, che nel 16:9 non esiste + * (partenza e arrivo coincidono, quindi l'asse resta un numero). + * * Modulo puro, letto da Node. */ +/** + * Un asse fermo resta un numero, e non una curva da un valore a se stesso: e' + * cosi' che il 16:9 continua a passare a `poseAt` gli stessi dati di prima. + */ +const still = (from: number, to: number, start: number, end: number, ease: Ease): AxisCurve | number => + from === to ? from : { from, to, start, end, ease }; + /** 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 => { +export const uiMockupTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => { + const { UI_MOCKUP_START_POSE, UI_MOCKUP_END_POSE } = TOPICS_POSES[ratio]; const last = durationInFrames - 1; return { yaw: { from: UI_MOCKUP_START_POSE.yaw, to: UI_MOCKUP_END_POSE.yaw, start: 0, end: last, ease: "inOutQuad" }, @@ -37,19 +45,29 @@ export const uiMockupTrack = (durationInFrames: number): Track => { end: UI_MOCKUP_SLIDE_FRAMES, ease: { bezier: [0.16, 1, 0.3, 1] }, }, - slideY: 0, + slideY: still(UI_MOCKUP_START_POSE.slideY ?? 0, UI_MOCKUP_END_POSE.slideY ?? 0, 0, last, "inOutQuad"), }; }; -/** CardHandoff: continua l'arco di UIMockup con la stessa curva, senza spostamenti. */ -export const cardHandoffTrack = (durationInFrames: number): Track => { +/** + * CardHandoff: continua l'arco di UIMockup con la stessa curva. Nel 16:9 la + * camera non si sposta. In 9:16 e 4:5 le due colonne fra cui la card vola non + * stanno in quadro insieme, quindi la camera si sposta mentre la mano + * trascina: la colonna di partenza e' tutta in quadro fino alla presa, quella + * d'arrivo dal rilascio in poi. + */ +export const cardHandoffTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => { + const { UI_MOCKUP_END_POSE, CARD_HANDOFF_END_POSE } = TOPICS_POSES[ratio]; const last = durationInFrames - 1; + const T = tempo(durationInFrames, CARD_HANDOFF_BASE); + const a = T.at(CARD_HANDOFF_DRAG_START); + const b = T.at(CARD_HANDOFF_DRAG_END); 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, + slideX: still(UI_MOCKUP_END_POSE.slideX, CARD_HANDOFF_END_POSE.slideX, a, b, "inOutQuad"), + slideY: still(UI_MOCKUP_END_POSE.slideY ?? 0, CARD_HANDOFF_END_POSE.slideY ?? 0, a, b, "inOutQuad"), }; }; @@ -66,35 +84,35 @@ const fiveAxes = ( }); /** 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); +export const cardFocusTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => + fiveAxes(TOPICS_POSES[ratio].CARD_HANDOFF_END_POSE, TOPICS_POSES[ratio].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); +export const cardReleaseTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => + fiveAxes(TOPICS_POSES[ratio].CARD_FOCUS_END_POSE, TOPICS_POSES[ratio].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 => +export const promptInputTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => fiveAxes( - CARD_RELEASE_END_POSE, - PROMPT_INPUT_END_POSE, + TOPICS_POSES[ratio].CARD_RELEASE_END_POSE, + TOPICS_POSES[ratio].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 => +export const boardOrbitTrack = (durationInFrames: number, ratio: Ratio = "16x9"): Track => fiveAxes( - PROMPT_INPUT_END_POSE, - BOARD_ORBIT_END_POSE, + TOPICS_POSES[ratio].PROMPT_INPUT_END_POSE, + TOPICS_POSES[ratio].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> = { +export const TOPICS_TRACKS: Record Track> = { UIMockup: uiMockupTrack, CardHandoff: cardHandoffTrack, CardFocus: cardFocusTrack, diff --git a/video/src/scenes/catalog.json b/video/src/scenes/catalog.json index 872e277..aa99281 100644 --- a/video/src/scenes/catalog.json +++ b/video/src/scenes/catalog.json @@ -32,7 +32,24 @@ "e' cambiare un numero qui. Le soglie percettive - i quattro frame fra il clic e", "la sua conseguenza, i tre di ritardo della card sulla mano, il periodo del", "caret - restano ferme di proposito: vedi primitives/tempo.ts. Lo verifica", - "tempo.py, che boccia una scena tagliata invece che ritempificata." + "tempo.py, che boccia una scena tagliata invece che ritempificata.", + "", + "ratios = i formati in cui ogni scena esce. Larghezza e altezza non stanno", + "piu' nelle scene: le da' lo stage del kit (video/src/kit/stage.ts), e il", + "16:9 tiene id e slug di sempre mentre gli altri li portano come suffisso", + "(CardFocus-9x16, card-focus-9x16.mp4). Root.tsx registra una composition", + "per scena e per rapporto, il workflow ne renderizza e misura un rapporto", + "per job.", + "", + "tempoFixtures = provini del tempo, non scene: la stessa scena a durata", + "dimezzata o a due terzi, in ogni rapporto. Non vanno in vetrina; servono a", + "tempo.py per provare che accorciare la durata accorcia ogni battuta, e ai", + "banchi percettivi per ripetersi sul render ritempificato." + ], + "ratios": ["16x9", "9x16", "4x5"], + "tempoFixtures": [ + { "id": "CardHandoffFast", "scene": "CardHandoff", "slug": ".fast-card-handoff", "durationInFrames": 120 }, + { "id": "PromptInputFast", "scene": "PromptInput", "slug": ".fast-prompt-input", "durationInFrames": 300 } ], "scenes": [ { @@ -41,8 +58,6 @@ "title": "UI mockup", "durationInFrames": 300, "fps": 30, - "width": 1920, - "height": 1080, "fill": true, "blurb": "A slab of real product UI enters from off-frame onto an inclined 3D plane, and the camera rotates toward frontal while the board fills in. It ends more frontal than it started, so the next scene can enter from this final state instead of cutting to it." }, @@ -52,8 +67,6 @@ "title": "Card handoff", "durationInFrames": 240, "fps": 30, - "width": 1920, - "height": 1080, "fill": true, "seamAfter": "UIMockup", "blurb": "This one starts where the last one stopped. Not roughly: both scenes read the same camera pose from the same module, so the first frame here is the last frame above, and seam.sh proves it by diffing the two — the join comes out 32× closer than a deliberate cut taken from the middle of this same scene, which is the control that stops the number from being decoration. The card lifts, crosses in an arc and lands, and the column below only closes up while it is landing. Snapping the gap shut on the frame it leaves would be a cut wearing an animation costume." @@ -64,8 +77,6 @@ "title": "Card focus", "durationInFrames": 170, "fps": 30, - "width": 1920, - "height": 1080, "fill": true, "seamAfter": "CardHandoff", "blurb": "The third link. It enters from the pose CardHandoff stops in and descends onto the card that scene has just delivered, magnifying it 2.35 times. One measured join made the no-cuts rule true of a pair; two make it a property of the sequence, and seam.sh now takes the pair as arguments because a join exists for every adjacent couple. Across the four scenes yaw runs -18, -9, -4, 0 and pitch 5, 2.5, 1.2, 0 without ever reversing: a join reads as a cut when the derivative flips, even when the pixels match. The text holds up under the push-in because the slab is DOM and gets rasterised at the final size, and focus-sharpness.sh measures that at 2.09× against the same content magnified from the wide shot, 1.03× on a fixture built to be exactly the defect it looks for." @@ -76,8 +87,6 @@ "title": "Card release", "durationInFrames": 150, "fps": 30, - "width": 1920, - "height": 1080, "fill": true, "seamAfter": "CardFocus", "blurb": "The camera lets go of the card and comes back wide and frontal, with the board readable again, because the shot has to breathe before it goes anywhere else, and finishing on a detail leaves the piece open. It is the one scene in the chain that reverses the push, and it is allowed to: a reversal reads as a cut where the join is moving, and both sides of this one are at rest. rest-point.sh measures that instead of taking it on trust, and it fails on CardHandoff, which deliberately does not start at rest.", @@ -89,8 +98,6 @@ "title": "Prompt input", "durationInFrames": 450, "fps": 30, - "width": 1920, - "height": 1080, "fill": true, "blurb": "The fifth link, and the one that used to be homeless. Four beats in one unbroken shot: the cursor comes down onto the composer, the prompt is typed with uneven human rhythm, it is sent after a pause, and the answer streams back word by word. It used to draw a slab of its own — 2200 wide at perspective 2800, its own chrome, its own sidebar — which made it a second screen, and between two screens the passage is a cut whatever you do with it. Now it is the same slab as the other four, with the assistant living in the bottom half the board never used, and the move from the board to the composer is a camera descending. seam.sh measures the join.", "seamAfter": "CardRelease" @@ -101,8 +108,6 @@ "title": "Board orbit", "durationInFrames": 150, "fps": 30, - "width": 1920, - "height": 1080, "fill": false, "seamAfter": "PromptInput", "restAtEdges": true,