56cc2bd814
Optional skill: scroll-as-timeline landing pages on a deterministic CSS/JS engine, with interview → page grammar → signature move workflow and screenshot-based scroll verification. Engine and scripts vendored verbatim; asset generation re-anchored on image_generate with the upstream kie.ai flow kept as an optional path.
645 lines
32 KiB
JavaScript
645 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* scrollcraft verification harness: shoot the page's own scroll.
|
|
*
|
|
* Walks the page in N evenly spaced scroll positions, waits for the scrub video
|
|
* to actually settle at each one, screenshots it, and reports what the engine
|
|
* thinks is on screen. Then tiles the frames into one contact sheet, because a
|
|
* dead middle only shows up in contiguous frames: any single screenshot is a
|
|
* frame the transition may not survive.
|
|
*
|
|
* node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 12
|
|
* node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile
|
|
* node shoot.mjs --url ... --reduced-motion --out lab/reduced
|
|
*
|
|
* Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the
|
|
* h264 decoder, so every scrub clip would silently fail to paint and the run
|
|
* would "pass" against posters.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createRequire } from "node:module";
|
|
|
|
// The skill lives outside the project it is building, so resolve playwright
|
|
// from the BUILD project's node_modules (cwd), not from next to this file.
|
|
// Run `npm i playwright-core` in the build project once.
|
|
let chromium;
|
|
try {
|
|
({ chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core"));
|
|
} catch {
|
|
console.error("playwright-core not found. Run this in the build project after:\n npm i playwright-core");
|
|
process.exit(1);
|
|
}
|
|
|
|
const argv = process.argv.slice(2);
|
|
const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
|
|
const has = (n) => argv.includes(n);
|
|
|
|
const URL = arg("--url", "http://localhost:4500");
|
|
const OUT = path.resolve(arg("--out", "lab/shots"));
|
|
const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10); // samples PER ACT
|
|
const W = parseInt(arg("--width", "1440"), 10);
|
|
const H = parseInt(arg("--height", "900"), 10);
|
|
const REDUCED = has("--reduced-motion");
|
|
|
|
const CHROME = [
|
|
process.env.SCROLLCRAFT_CHROME,
|
|
// Windows
|
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
// macOS
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
// Linux
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
"/snap/bin/chromium",
|
|
].find((p) => p && fs.existsSync(p));
|
|
|
|
if (!CHROME) {
|
|
console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");
|
|
process.exit(1);
|
|
}
|
|
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
|
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
|
const page = await browser.newPage({
|
|
viewport: { width: W, height: H },
|
|
deviceScaleFactor: 2,
|
|
reducedMotion: REDUCED ? "reduce" : "no-preference",
|
|
});
|
|
|
|
const consoleErrors = [];
|
|
page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
|
|
page.on("pageerror", (e) => consoleErrors.push(String(e)));
|
|
const failed = [];
|
|
page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`));
|
|
|
|
// Not networkidle: the engine keeps clips in flight as you scroll, and a
|
|
// webfont connection can stay open, so idle may never arrive. Wait for the
|
|
// engine's own ready signal and for the faces to land, since line splitting
|
|
// measures real line boxes and is wrong before the real face is applied.
|
|
await page.goto(URL, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector("html.sc-ready", { timeout: 15000 });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await page.waitForTimeout(700);
|
|
|
|
const doc = await page.evaluate(() => {
|
|
const world = document.querySelector('[data-sc-mode="worldflight"]');
|
|
return {
|
|
height: document.body.scrollHeight,
|
|
vh: innerHeight,
|
|
acts: [...document.querySelectorAll("[data-sc-act]")].map((a) => a.dataset.scAct),
|
|
world: world
|
|
? {
|
|
seam: parseFloat(world.dataset.scSeam) || 0.12,
|
|
segs: [...world.querySelectorAll("[data-sc-segment]")].map((s) => ({
|
|
w: parseFloat(s.dataset.scW) || 1.3,
|
|
linger: parseFloat(s.dataset.scLinger) || 0,
|
|
label: s.dataset.scWaypoint || "",
|
|
})),
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
const WORLD = doc.world;
|
|
const maxScroll = doc.height - doc.vh;
|
|
|
|
if (WORLD) {
|
|
const total = WORLD.segs.reduce((s, g) => s + g.w, 0);
|
|
console.log(`page: worldflight, ${WORLD.segs.length} legs over ${total.toFixed(2)}vh ` +
|
|
`(track ${(doc.height / doc.vh).toFixed(1)} viewport-heights), seam ${WORLD.seam}vh`);
|
|
console.log(` legs: ${WORLD.segs.map((g, i) => `${i}:${g.label || "-"}@${g.w}vh`).join(" ")}`);
|
|
} else {
|
|
console.log(`page: ${(doc.height / doc.vh).toFixed(1)} viewport-heights, acts: ${doc.acts.join(" > ")}`);
|
|
}
|
|
|
|
// Wait for the playhead to ARRIVE, not merely to stop seeking. The engine lerps
|
|
// currentTime toward a target on its own rAF loop, so after any scroll jump
|
|
// there is a stretch of ~15 frames during which every clip on the page is
|
|
// somewhere it will never be again. Screenshot in that window and the sheet is a
|
|
// set of frames the reader is never shown, the dead-scroll comparison runs on
|
|
// mid-lerp noise, and the whole run is unrepeatable.
|
|
async function settle(timeout = 4000) {
|
|
const t0 = Date.now();
|
|
let last = null;
|
|
for (;;) {
|
|
const now = await page.evaluate(() => {
|
|
const insts = (window.ScrollCraft && window.ScrollCraft.instances) || [];
|
|
const clips = [].concat(...insts.map((i) => i.clips || [])).filter((c) => c.ready);
|
|
// Pages on an older engine expose no instances; fall back to watching
|
|
// currentTime go quiet, which reaches the same state more slowly.
|
|
if (clips.length) {
|
|
const arrived = clips.every((c) => Math.abs(c.cur - c.target) < 0.002 && !c.el.seeking);
|
|
return arrived ? "arrived" : "moving";
|
|
}
|
|
return [...document.querySelectorAll("video[data-sc-scrub]")]
|
|
.map((v) => (v.seeking ? "seeking" : v.currentTime.toFixed(3))).join("|");
|
|
});
|
|
if (now === "arrived") return true;
|
|
if (now !== "moving" && now === last && !now.includes("seeking")) return true;
|
|
if (Date.now() - t0 > timeout) return false;
|
|
last = now;
|
|
await page.waitForTimeout(60);
|
|
}
|
|
}
|
|
|
|
// Sample WITHIN each act, not uniformly down the document. Uniform sampling
|
|
// distributes positions by page length, so a short act gets one sample that
|
|
// lands wherever it lands, and adding a section elsewhere silently moves every
|
|
// sample. That produces "this cue never reaches full opacity" reports that come
|
|
// and go with unrelated edits. Per-act sampling hits the same fractions of
|
|
// every act every run, so the findings mean something.
|
|
//
|
|
// A worldflight has no acts to sample within; its unit is the leg, and its
|
|
// geometry lives entirely in the weights, so positions are computed from the
|
|
// track rather than measured off the DOM. Both sides of every seam are added on
|
|
// top: the crossfade is the frame this mode is judged on, and it occupies about
|
|
// a tenth of a viewport, so uniform sampling steps straight over it.
|
|
const positions = WORLD ? await page.evaluate((perSeg) => {
|
|
const root = document.querySelector('[data-sc-mode="worldflight"]');
|
|
const segs = [...root.querySelectorAll("[data-sc-segment]")];
|
|
const top = root.getBoundingClientRect().top + scrollY;
|
|
const seam = parseFloat(root.dataset.scSeam) || 0.12;
|
|
const fracs = Array.from({ length: perSeg }, (_, i) => (perSeg === 1 ? 0.5 : i / (perSeg - 1)));
|
|
const out = [];
|
|
let c = 0;
|
|
segs.forEach((s, i) => {
|
|
const w = parseFloat(s.dataset.scW) || 1.3;
|
|
fracs.forEach((f) => {
|
|
const p = 0.02 + f * 0.96;
|
|
out.push(Math.round(top + (c + w * p) * innerHeight));
|
|
});
|
|
c += w;
|
|
if (i < segs.length - 1) {
|
|
[-0.5, -0.2, 0.2, 0.5].forEach((k) => out.push(Math.round(top + (c + k * seam) * innerHeight)));
|
|
}
|
|
});
|
|
const max = document.body.scrollHeight - innerHeight;
|
|
out.push(0, max);
|
|
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
|
|
}, Math.max(2, Math.round(STEPS))) : await page.evaluate((perAct) => {
|
|
const out = [];
|
|
const fracs = Array.from({ length: perAct }, (_, i) => (perAct === 1 ? 0.5 : i / (perAct - 1)));
|
|
document.querySelectorAll("[data-sc-act]").forEach((el) => {
|
|
const top = el.getBoundingClientRect().top + scrollY;
|
|
const h = el.offsetHeight;
|
|
const pinned = ["scrub", "pin", "pan"].includes(el.dataset.scAct);
|
|
fracs.forEach((f) => {
|
|
// Nudge off the exact endpoints: p=0 and p=1 sit on the seam between two
|
|
// acts, where which one you are "in" is ambiguous.
|
|
const p = 0.02 + f * 0.96;
|
|
out.push(Math.round(pinned ? top + (h - innerHeight) * p : top - innerHeight + (h + innerHeight) * p));
|
|
});
|
|
// A pinned stage is on screen for a viewport BEFORE its pinned travel begins
|
|
// and a viewport AFTER it ends, and the loop above samples only inside the
|
|
// travel. Those two slides are exactly where a clip mapped to pinned
|
|
// progress sits frozen on its first or last frame, so not sampling them is
|
|
// why a frozen clip could pass this harness. Sample them.
|
|
if (el.dataset.scAct === "scrub") {
|
|
// `v` is the fraction of the viewport the stage covers at that position.
|
|
// Sample the part of each slide where the stage is still MOSTLY on screen,
|
|
// because that is where a frozen frame is conspicuous, and because the
|
|
// frozen-clip check needs consecutive samples that are both well past its
|
|
// visibility gate before it will call anything.
|
|
[0.6, 0.75, 0.9].forEach((v) => {
|
|
out.push(Math.round(top - innerHeight * (1 - v))); // sliding in
|
|
out.push(Math.round(top + h - innerHeight * v)); // sliding out
|
|
});
|
|
}
|
|
});
|
|
const max = document.body.scrollHeight - innerHeight;
|
|
out.push(max);
|
|
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
|
|
}, Math.max(2, Math.round(STEPS)));
|
|
|
|
const report = [];
|
|
for (let i = 0; i < positions.length; i++) {
|
|
const y = positions[i];
|
|
const p = maxScroll ? y / maxScroll : 0;
|
|
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
|
|
await page.waitForTimeout(180);
|
|
const settled = await settle();
|
|
|
|
const state = await page.evaluate(() => {
|
|
// A kinetic heading carries its real opacity on the split line units; the
|
|
// engine forces the element itself to 1. Reading the element therefore
|
|
// reports every kinetic headline as fully present, including on frames
|
|
// where every one of its lines is at 0. Take the strongest line instead:
|
|
// the heading is "peaked" when at least one unit has arrived.
|
|
const cueOpacity = (el) => {
|
|
const o = parseFloat(getComputedStyle(el).opacity) || 0;
|
|
const units = el.querySelectorAll(".sc-split__i");
|
|
if (!units.length) return o;
|
|
let m = 0;
|
|
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
|
|
return o * m;
|
|
};
|
|
const vis = [];
|
|
// A worldflight's copy blocks are windowed against the whole track rather
|
|
// than an act's progress, but they are the same thing to a reader: type that
|
|
// has to arrive, hold, and leave. Grade them identically.
|
|
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
|
|
const o = cueOpacity(el);
|
|
if (o <= 0.02) return;
|
|
// On screen, not merely non-transparent. An element parked off-viewport
|
|
// at opacity 1 is not a visible cue, and counting it produces phantom
|
|
// findings that send you chasing a bug the reader never sees.
|
|
const r = el.getBoundingClientRect();
|
|
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) return;
|
|
vis.push({ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 46), o: +o.toFixed(2) });
|
|
});
|
|
const clips = [...document.querySelectorAll("video[data-sc-scrub]")].map((v) => ({
|
|
// A continuous world legitimately keeps its clip chain outside the act
|
|
// stack, driven by the page's own scroll value rather than by an act's
|
|
// progress. Falling back to the clip's own class keeps that case
|
|
// reporting instead of taking the whole run down before it writes
|
|
// anything.
|
|
painted: (v.closest("[data-sc-act]") ?? v).classList.contains("sc-has-clip"),
|
|
t: +(v.currentTime || 0).toFixed(2),
|
|
dur: +(v.duration || 0).toFixed(2),
|
|
// How much of the viewport this clip's stage actually covers. A frozen
|
|
// playhead only matters while the reader can see the stage.
|
|
vis: (() => {
|
|
const st = v.closest("[data-sc-stage]") || v.parentElement;
|
|
if (!st) return 0;
|
|
const b = st.getBoundingClientRect();
|
|
return +(Math.max(0, Math.min(b.bottom, innerHeight) - Math.max(b.top, 0)) / innerHeight).toFixed(3);
|
|
})(),
|
|
}));
|
|
// Rails and wipes move without changing any cue or clip time, so without
|
|
// these a panning section reads as dead scroll.
|
|
const rails = [...document.querySelectorAll("[data-sc-pan]")]
|
|
.map((r) => Math.round(new DOMMatrixReadOnly(getComputedStyle(r).transform).m41));
|
|
const wipes = [...document.querySelectorAll("[data-sc-reveal]")]
|
|
.map((r) => getComputedStyle(r).clipPath);
|
|
// Which act owns the middle of the viewport right now.
|
|
let act = "-";
|
|
document.querySelectorAll("[data-sc-act]").forEach((a) => {
|
|
const r = a.getBoundingClientRect();
|
|
if (r.top <= innerHeight / 2 && r.bottom >= innerHeight / 2) act = a.dataset.scAct;
|
|
});
|
|
// Where each pinned stage physically sits. Before an act reaches its pin
|
|
// point the stage slides up the screen while its progress is still clamped
|
|
// to 0, so the clip and cues are frozen and yet the view is very much
|
|
// moving. Without this the run-up to every pinned act reads as dead scroll.
|
|
const stages = [...document.querySelectorAll("[data-sc-stage]")]
|
|
.map((s) => Math.round(s.getBoundingClientRect().top));
|
|
// Worldflight legs. Opacity IS the crossfade, so it is state, not styling:
|
|
// two samples with the same clip times but different leg opacities are a
|
|
// dissolve in progress, not dead scroll.
|
|
const segs = [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => {
|
|
const v = el.querySelector("video");
|
|
return {
|
|
i, label: el.dataset.scWaypoint || "",
|
|
op: +(parseFloat(getComputedStyle(el).opacity) || 0).toFixed(3),
|
|
painted: el.classList.contains("sc-has-clip"),
|
|
t: v ? +(v.currentTime || 0).toFixed(3) : null,
|
|
};
|
|
});
|
|
const world = document.querySelector('[data-sc-mode="worldflight"]');
|
|
// Bespoke fixed stages can use flow markers for document travel while all
|
|
// visible motion happens outside the engine's pin/scrub devices. Those
|
|
// pages publish a compact representation of their actual visual state so
|
|
// dead-scroll verification does not silently skip the whole experience.
|
|
const customEls = [...document.querySelectorAll("[data-sc-verify-state]")];
|
|
const custom = customEls.map((el) => el.getAttribute("data-sc-verify-state") || "");
|
|
const customHold = customEls.some((el) => el.getAttribute("data-sc-verify-hold") === "true");
|
|
return {
|
|
cues: vis, clips, rails, wipes, act, stages, segs, custom, customHold,
|
|
seg: world ? +(world.style.getPropertyValue("--sc-seg") || -1) : null,
|
|
segp: world ? +(world.style.getPropertyValue("--sc-segp") || 0) : null,
|
|
bg: getComputedStyle(document.documentElement).getPropertyValue("--sc-canvas").trim(),
|
|
};
|
|
});
|
|
|
|
// Flat NN.png so ffmpeg can read the set as a numbered sequence for the
|
|
// contact sheet. The scroll offset lives in report.json, not the filename.
|
|
const name = `${String(i).padStart(2, "0")}.png`;
|
|
await page.screenshot({ path: path.join(OUT, name) });
|
|
|
|
// Contrast, measured on the COMPOSITED page rather than on the source media.
|
|
// Sampling the video directly ignores every scrim, gradient and blend on top
|
|
// of it, so a page can read as failing while looking fine, or the reverse.
|
|
// Hide the text, shoot the same frame, hand the pixels back to the page, and
|
|
// sample the real background under each line. Text over a scrubbing clip is
|
|
// the one contrast case a static audit cannot cover: the frame beneath a
|
|
// headline changes as you scroll, so it can pass on the poster and fail three
|
|
// hundred pixels later. The direction is picked per line: light type fails on
|
|
// the brightest patch, dark type on the darkest one.
|
|
//
|
|
// Fixed chrome is hidden along with the text. A fixed bar paints in FRONT of
|
|
// whatever scrolls under it, so its own mark is not the background behind a
|
|
// headline passing beneath it, and leaving it in reports a spurious failure
|
|
// on an act that is fine.
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll("body *").forEach((el) => {
|
|
if (getComputedStyle(el).position !== "fixed") return;
|
|
// A worldflight's stage and copy layer are fixed too, and they are the
|
|
// exact opposite case: the stage IS the background behind every line, and
|
|
// the copy layer carries the scrim that makes the line legible. Hiding
|
|
// them samples the page ground instead of the film and reports the whole
|
|
// page as failing while it looks fine.
|
|
if (el.closest("[data-sc-world],[data-sc-world-copy]")) return;
|
|
el.setAttribute("data-sc-shot-fixed", "");
|
|
});
|
|
});
|
|
await page.addStyleTag({
|
|
content: "[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *," +
|
|
"[data-sc-shot-fixed]{visibility:hidden!important}",
|
|
});
|
|
const bare = (await page.screenshot({ type: "jpeg", quality: 80 })).toString("base64");
|
|
const contrast = await page.evaluate(async ({ b64, dpr }) => {
|
|
const img = new Image();
|
|
img.src = "data:image/jpeg;base64," + b64;
|
|
await img.decode();
|
|
const c = document.createElement("canvas");
|
|
const g = c.getContext("2d", { willReadFrequently: true });
|
|
const lum = (r, gr, b) => {
|
|
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
return 0.2126 * f(r) + 0.7152 * f(gr) + 0.0722 * f(b);
|
|
};
|
|
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
const cueOpacity = (el) => {
|
|
const o = parseFloat(getComputedStyle(el).opacity) || 0;
|
|
const units = el.querySelectorAll(".sc-split__i");
|
|
if (!units.length) return o;
|
|
let m = 0;
|
|
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
|
|
return o * m;
|
|
};
|
|
const out = [];
|
|
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
|
|
if (cueOpacity(el) < 0.85) return;
|
|
if (!(el.textContent || "").trim()) return;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 8 || r.height < 8 || r.bottom < 0 || r.top > innerHeight) return;
|
|
// Clamp the sampled rect to the viewport. The part of a pinned act's copy
|
|
// that has scrolled above the fold is not on screen, so whatever sits in
|
|
// those pixels is not the background behind anything the reader can see.
|
|
const vl = Math.max(0, r.left), vt = Math.max(0, r.top);
|
|
const vr = Math.min(innerWidth, r.right), vb = Math.min(innerHeight, r.bottom);
|
|
if (vr - vl < 8 || vb - vt < 8) return;
|
|
const x = vl * dpr, y2 = vt * dpr;
|
|
const w = Math.min((vr - vl) * dpr, img.width - x), h = Math.min((vb - vt) * dpr, img.height - y2);
|
|
if (w < 2 || h < 2) return;
|
|
c.width = 32; c.height = 16;
|
|
g.drawImage(img, x, y2, w, h, 0, 0, 32, 16);
|
|
const d = g.getImageData(0, 0, 32, 16).data;
|
|
let maxL = 0, minL = 1, sum = 0, n = 0;
|
|
for (let k = 0; k < d.length; k += 4) {
|
|
const L = lum(d[k], d[k + 1], d[k + 2]);
|
|
if (L > maxL) maxL = L;
|
|
if (L < minL) minL = L;
|
|
sum += L; n++;
|
|
}
|
|
const cs = getComputedStyle(el);
|
|
const fg = cs.color.match(/[\d.]+/g).map(Number);
|
|
const fl = lum(fg[0], fg[1], fg[2]);
|
|
// An element that paints its own opaque background (a button, a chip) is
|
|
// an ordinary static contrast case: grade its text against that fill, not
|
|
// against whatever the page happens to show behind it. Hiding the element
|
|
// to sample the backdrop necessarily hides its background too, so without
|
|
// this every solid CTA reports a spurious failure.
|
|
const bg = (cs.backgroundColor.match(/[\d.]+/g) || []).map(Number);
|
|
const opaqueBg = bg.length >= 3 && (bg.length < 4 || bg[3] > 0.5);
|
|
if (opaqueBg) {
|
|
const bl = lum(bg[0], bg[1], bg[2]);
|
|
out.push({
|
|
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
|
|
dir: "own-fill",
|
|
worst: +ratio(fl, bl).toFixed(2), mean: +ratio(fl, bl).toFixed(2),
|
|
});
|
|
return;
|
|
}
|
|
// Pick the direction from the foreground. Light type on a dark page fails
|
|
// on the brightest patch under it; dark type on a light page (a high-key
|
|
// world, ink over media) fails on the DARKEST patch, and grading that
|
|
// against maxL is the most lenient reading available, so a page can report
|
|
// clean over text that is failing. Compare the ink to the mean background
|
|
// and grade against whichever extreme is on the ink's own side.
|
|
const meanL = sum / n;
|
|
const dark = fl < meanL;
|
|
out.push({
|
|
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
|
|
dir: dark ? "dark-on-light" : "light-on-dark",
|
|
worst: +ratio(fl, dark ? minL : maxL).toFixed(2),
|
|
mean: +ratio(fl, meanL).toFixed(2),
|
|
});
|
|
});
|
|
return out;
|
|
}, { b64: bare, dpr: 2 });
|
|
await page.evaluate(() => {
|
|
const t = [...document.querySelectorAll("style")].pop();
|
|
if (t && t.textContent.includes("data-sc-cue")) t.remove();
|
|
document.querySelectorAll("[data-sc-shot-fixed]").forEach((el) => el.removeAttribute("data-sc-shot-fixed"));
|
|
});
|
|
|
|
report.push({ i, y, pct: +(p * 100).toFixed(0), settled, contrast, ...state });
|
|
if (WORLD) {
|
|
console.log(` ${name} settled=${settled} copy=${state.cues.length} leg=${state.seg}@${state.segp} ` +
|
|
`legs=${state.segs.map((g) => `${g.op > 0.002 ? (g.painted ? g.t : "poster") : "-"}${g.op > 0.002 && g.op < 0.998 ? "*" + g.op : ""}`).join(",")}`);
|
|
} else {
|
|
console.log(` ${name} settled=${settled} cues=${state.cues.length} clips=${state.clips.map((c) => (c.painted ? c.t : "poster")).join(",")}`);
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2));
|
|
|
|
if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n " + consoleErrors.join("\n "));
|
|
if (failed.length) console.log("\nFAILED REQUESTS:\n " + failed.join("\n "));
|
|
|
|
// Dead-scroll detector: consecutive positions where nothing visibly changed.
|
|
// This is the failure the eye misses and the reason to shoot contiguously.
|
|
// Opacity counts as change: a cue mid-fade is motion, and comparing only which
|
|
// cues exist would call a crossfade dead.
|
|
const sig = (s) => JSON.stringify([
|
|
s.cues.map((c) => c.t + ":" + c.o),
|
|
s.clips.map((c) => c.t),
|
|
s.rails,
|
|
s.wipes,
|
|
s.stages,
|
|
s.custom || [],
|
|
]);
|
|
// Only inside pinned acts. A flow section or a footer that holds still across
|
|
// two sample positions is a page behaving correctly, not dead scroll, and
|
|
// flagging it trains you to ignore the signal.
|
|
const PINNED = new Set(["scrub", "pin", "pan"]);
|
|
const dead = [];
|
|
if (WORLD) {
|
|
// Every pixel of a worldflight track is pinned by construction, so there is
|
|
// no "correctly still" region to exclude and the whole page is fair game.
|
|
// Three independent things can carry the motion: the film advancing, a leg
|
|
// dissolving into the next, and a copy window opening or closing. Dead scroll
|
|
// is all three holding at once.
|
|
const wsig = (s) => JSON.stringify([
|
|
s.segs.map((g) => g.t),
|
|
s.segs.map((g) => g.op),
|
|
s.cues.map((c) => c.t + ":" + c.o),
|
|
]);
|
|
// Not under reduced motion. There the film is deliberately never fetched, so
|
|
// the middle of a leg holds a single still frame and every pair of samples in
|
|
// it is identical BY DESIGN. Flagging that reports the accessibility path as
|
|
// broken every single run, which is how a real finding gets ignored. What
|
|
// matters here is whether the story still reads, and the copy-window and
|
|
// contrast passes below answer that.
|
|
for (let i = 1; !REDUCED && i < report.length; i++) {
|
|
const a = report[i - 1], b = report[i];
|
|
// Tighter than the act gate: a leg is about one viewport of scroll, so a
|
|
// quarter-viewport window would only ever compare four points per leg.
|
|
if (b.y - a.y < doc.vh * 0.12) continue;
|
|
if (wsig(a) === wsig(b)) {
|
|
dead.push(`${a.pct}% -> ${b.pct}% (leg ${a.seg} > ${b.seg})`);
|
|
}
|
|
}
|
|
} else {
|
|
for (let i = 1; i < report.length; i++) {
|
|
const a = report[i - 1], b = report[i];
|
|
const hasCustomState = (a.custom?.length || 0) > 0 || (b.custom?.length || 0) > 0;
|
|
if (!PINNED.has(a.act) && !PINNED.has(b.act) && !hasCustomState) continue;
|
|
// A page may explicitly declare an authored hold, such as a resolved close
|
|
// or the stable accessibility frame under reduced motion. It has to be
|
|
// declared by the visible stage; ordinary flow content stays exempt as it
|
|
// was before this custom-state path existed.
|
|
if (a.customHold && b.customHold) continue;
|
|
// Two samples a few dozen pixels apart SHOULD look the same. Only flag a gap
|
|
// wide enough that a reader would notice nothing happening in it.
|
|
if (b.y - a.y < doc.vh * 0.25) continue;
|
|
if (sig(a) === sig(b)) dead.push(`${a.pct}% -> ${b.pct}% (${a.act} > ${b.act})`);
|
|
}
|
|
}
|
|
console.log(dead.length ? `\nDEAD SCROLL between: ${dead.join(", ")}`
|
|
: WORLD && REDUCED ? "\ndead-scroll check skipped: reduced motion holds each leg on one still frame by design"
|
|
: "\nno dead scroll detected");
|
|
|
|
// FROZEN CLIP. The reader is scrolling, a scrub stage is on screen, and its
|
|
// playhead is not moving: a still photograph sliding up the page. Dead scroll
|
|
// cannot see this, because the stage IS moving, which is the whole problem.
|
|
//
|
|
// A hold on the first or last frame is always a defect. A hold in the middle
|
|
// can be an intentional `dwell` settle, so it only counts once it outlasts one.
|
|
// Skipped under reduced motion, where no clip is ever fetched on purpose.
|
|
if (!REDUCED) {
|
|
const nClips = report[0]?.clips?.length || 0;
|
|
const VIS = 0.55, EPS = 0.012, MIN = doc.vh * 0.15;
|
|
const frozen = [];
|
|
for (let c = 0; c < nClips; c++) {
|
|
let run = null;
|
|
const flush = () => {
|
|
if (!run) return;
|
|
const kind = run.t < 0.05 ? "entry" : (run.dur && run.t > run.dur - 0.08 ? "exit" : "mid");
|
|
const need = kind === "mid" ? doc.vh * 0.5 : MIN;
|
|
if (run.to - run.from >= need) frozen.push({ c, kind, ...run });
|
|
run = null;
|
|
};
|
|
for (let i = 1; i < report.length; i++) {
|
|
const a = report[i - 1].clips?.[c], b = report[i].clips?.[c];
|
|
if (!a || !b) { flush(); continue; }
|
|
const seen = a.vis >= VIS && b.vis >= VIS;
|
|
const stuck = Math.abs(b.t - a.t) < EPS;
|
|
if (seen && stuck && b.painted) {
|
|
if (!run) run = { from: report[i - 1].y, to: report[i].y, t: b.t, dur: b.dur };
|
|
else run.to = report[i].y;
|
|
} else flush();
|
|
}
|
|
flush();
|
|
}
|
|
if (frozen.length) {
|
|
console.log("\nFROZEN CLIP (still image while the page moves):\n " + frozen.map((f) => {
|
|
const px = f.to - f.from;
|
|
const where = f.kind === "entry" ? "held on its FIRST frame while the stage slides in"
|
|
: f.kind === "exit" ? "held on its LAST frame while the stage slides out"
|
|
: `held mid-clip at ${f.t.toFixed(2)}s, longer than a dwell settle`;
|
|
return `clip ${f.c}: ${px}px (${(px / doc.vh).toFixed(2)} viewports) ${where}`;
|
|
}).join("\n ") + "\n Fix: let the clip map across the stage's whole visible life. That is the\n engine default; data-sc-clip-map=\"travel\" turns it off. See devices.md.");
|
|
} else if (nClips) {
|
|
console.log(`all ${nClips} scrub clip(s) keep moving whenever they are on screen`);
|
|
}
|
|
}
|
|
|
|
// Worldflight findings. A leg that never reaches full opacity is a weight or a
|
|
// seam that is wrong: the reader is shown a permanent dissolve between two
|
|
// clips and never the leg itself. A leg stuck on its poster is a clip that
|
|
// never loaded or never decoded, and it passes every other check on this page
|
|
// because a poster looks exactly like a paused film.
|
|
if (WORLD) {
|
|
const segPeak = {};
|
|
report.forEach((s) => (s.segs || []).forEach((g) => {
|
|
const k = `${g.i}${g.label ? ' "' + g.label + '"' : ""}`;
|
|
segPeak[k] = segPeak[k] || { op: 0, painted: false, hasClip: g.t !== null };
|
|
segPeak[k].op = Math.max(segPeak[k].op, g.op);
|
|
segPeak[k].painted = segPeak[k].painted || g.painted;
|
|
}));
|
|
const faint = Object.entries(segPeak).filter(([, v]) => v.op < 0.99);
|
|
// Under reduced motion no clip is ever fetched, on purpose. Every leg is
|
|
// legitimately on its poster, and reporting that as a fault buries the one
|
|
// finding this pass exists for: whether the story still reads without motion.
|
|
const posters = REDUCED ? [] : Object.entries(segPeak).filter(([, v]) => v.hasClip && !v.painted);
|
|
if (faint.length) console.log("\nLEGS THAT NEVER REACH FULL OPACITY:\n " +
|
|
faint.map(([k, v]) => `${v.op.toFixed(2)} leg ${k}`).join("\n "));
|
|
if (posters.length) console.log("\nLEGS STUCK ON POSTER (clip never painted):\n " +
|
|
posters.map(([k]) => `leg ${k}`).join("\n "));
|
|
if (!faint.length && !posters.length)
|
|
console.log(`all ${Object.keys(segPeak).length} legs reach full opacity` +
|
|
(REDUCED ? " (posters only, as reduced motion requires)" : " and paint a real frame"));
|
|
}
|
|
|
|
// Cues that never reach full strength anywhere on the page. A headline peaking
|
|
// at 0.6 is a mis-set cue window, and it is invisible as a bug because the
|
|
// element IS there, just never quite arriving.
|
|
const peak = {};
|
|
report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));
|
|
const weak = Object.entries(peak).filter(([, o]) => o < 0.8);
|
|
if (weak.length) console.log("\nCUES THAT NEVER PEAK:\n " + weak.map(([t, o]) => `${o} "${t}"`).join("\n "));
|
|
|
|
// Contrast over media, graded at the worst frame each line is ever shown on.
|
|
const worstBy = {};
|
|
report.forEach((s) => (s.contrast || []).forEach((c) => {
|
|
if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;
|
|
}));
|
|
const fails = Object.values(worstBy).filter((c) => c.worst < 3);
|
|
const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);
|
|
if (fails.length) console.log("\nCONTRAST FAIL (worst frame < 3:1):\n " +
|
|
fails.map((c) => `${c.worst}:1 (mean ${c.mean}) "${c.t}"`).join("\n "));
|
|
if (thin.length) console.log("\nCONTRAST THIN (3:1 to 4.5:1, ok for large display type only):\n " +
|
|
thin.map((c) => `${c.worst}:1 "${c.t}"`).join("\n "));
|
|
if (!fails.length && !thin.length && Object.keys(worstBy).length)
|
|
console.log("\ncontrast over media: all cues clear 4.5:1 at their worst frame");
|
|
|
|
await browser.close();
|
|
|
|
// Contact sheet. The point of shooting contiguously is to look at the frames
|
|
// side by side; a folder of 20 PNGs does not get looked at that way.
|
|
const FFMPEG = [
|
|
process.env.SCROLLCRAFT_FFMPEG,
|
|
...(fs.existsSync(path.join(process.env.HOME || "", "AppData/Local/Microsoft/WinGet/Packages"))
|
|
? fs.readdirSync(path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages"))
|
|
.filter((d) => d.startsWith("Gyan.FFmpeg"))
|
|
.flatMap((d) => {
|
|
const base = path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages", d);
|
|
return fs.readdirSync(base).map((v) => path.join(base, v, "bin/ffmpeg.exe"));
|
|
})
|
|
: []),
|
|
"/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg",
|
|
].find((p) => p && (p === "ffmpeg" || fs.existsSync(p)));
|
|
|
|
if (FFMPEG) {
|
|
const cols = Math.min(5, report.length);
|
|
const rows = Math.ceil(report.length / cols);
|
|
const { spawnSync } = await import("node:child_process");
|
|
const r = spawnSync(FFMPEG, [
|
|
"-y", "-v", "error", "-i", path.join(OUT, "%02d.png"),
|
|
"-vf", `scale=520:-1,tile=${cols}x${rows}`, "-frames:v", "1",
|
|
path.join(OUT, "sheet.png"),
|
|
]);
|
|
if (r.status === 0) console.log(`contact sheet: ${path.join(OUT, "sheet.png")}`);
|
|
else console.log("contact sheet skipped (needs a full ffmpeg build; scale/tile are missing from stripped ones)");
|
|
}
|
|
|
|
console.log(`\nshots + report.json in ${OUT}`);
|