// Finds where each slide is in motion, so steps start and end only at rest. // A moment is "moving" when a finite CSS animation is running, a looping one is running // that won't still be running at the slide's end (a spinner, typing dots), or the slide's // DOM changes between samples (JS-driven motion). Ambient loops, like a Live ping that runs // to the end of the slide, are ignored. // Usage: node scripts/find-steps.mjs [url] [--why] [--write] // Prints each slide's steps (--why adds what moves in each). --write saves them as // data-steps in src/slides/. // Needs `playwright` resolvable from here; set CHROMIUM_PATH to pick a binary. import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { chromium } from 'playwright' const args = process.argv.slice(2) const write = args.includes('--write') const verbose = args.includes('--why') const url = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:4310/packages/invisible-software/demo' const DT = 0.05 // Motion runs closer together than this are one step; the eye reads them as one movement. const JOIN_GAP = 0.2 // A step shorter than this is a blip (a label swap, a badge flip), so it joins the step before it. const MIN_STEP = 0.4 const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }) const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }) await page.goto(url) await page.waitForFunction(() => window.__slideshow?.state.ready, null, { timeout: 60000 }) const slides = await page.evaluate((DT) => window.__slideshow.SLIDES.map((s, si) => { const n = Math.round(s.duration / DT) const samples = [] for (let i = 0; i <= n; i++) samples.push(window.__slideshow.probe(si, +(i * DT).toFixed(3))) return { label: s.label, duration: s.duration, samples } }), DT) await browser.close() const round = (t) => +t.toFixed(2) const result = slides.map(({ label, duration, samples }) => { const ambient = new Set(samples.at(-1).looping) const why = samples.map((s, i) => [ ...s.active, ...s.looping.filter((n) => !ambient.has(n)), ...(i + 1 < samples.length && s.sig !== samples[i + 1].sig ? ['dom'] : []), ]) const moving = why.map((w) => w.length > 0) const runs = [] for (let i = 0; i < moving.length; i++) { if (!moving[i]) continue let j = i while (j + 1 < moving.length && moving[j + 1]) j++ // The run starts from the last still frame before it and ends on the first still frame after it. runs.push([round(Math.max(0, i - 1) * DT), round(Math.min(j + 1, moving.length - 1) * DT)]) i = j } const steps = [] for (const r of runs) { const last = steps.at(-1) if (last && r[0] - last[1] < JOIN_GAP) last[1] = r[1] else steps.push([...r]) } for (let i = steps.length - 1; i > 0; i--) { if (steps[i][1] - steps[i][0] < MIN_STEP) { steps[i - 1][1] = steps[i][1]; steps.splice(i, 1) } } if (steps.length > 1 && steps[0][1] - steps[0][0] < MIN_STEP) { steps[1][0] = steps[0][0]; steps.shift() } const reasons = steps.map(([a, b]) => { const count = {} why.slice(Math.round(a / DT), Math.round(b / DT)).flat().forEach((n) => (count[n] = (count[n] ?? 0) + 1)) return Object.entries(count).sort((x, y) => y[1] - x[1]).slice(0, 4).map(([n]) => n).join(', ') }) const tail = moving.at(-1) return { label, duration, steps, reasons, endsMoving: tail } }) for (const r of result) { console.log(`slide ${r.label} (${r.duration}s): ${r.steps.length} steps${r.endsMoving ? ' WARNING: still moving at the end' : ''}`) r.steps.forEach(([a, b], i) => console.log(` ${r.label}.${i + 1} ${a}-${b} ${verbose ? r.reasons[i] : ''}`)) } if (write) { const dir = new URL('../src/slides/', import.meta.url).pathname for (const file of readdirSync(dir).filter((f) => f.endsWith('.html'))) { const path = join(dir, file) const src = readFileSync(path, 'utf8') const label = src.match(/data-label="([^"]+)"/)[1] const r = result.find((x) => x.label === label) writeFileSync(path, src.replace(/data-steps="[^"]*"/, `data-steps="${JSON.stringify(r.steps)}"`)) } console.log('Wrote data-steps to src/slides/. Run npm run build.') }