Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring
Capture: - Preserve real asset extensions (.lottie no longer truncated); extract the default animation JSON from dotLottie ZIP containers at materialization (minimal built-in ZIP reader, no new deps) - Reset scroll + settle before every viewport snapshot so scroll-linked styles can't bake into captured computed values - Marquee detection now requires content overflow (>1.35x), sustained velocity across separated windows, scroll independence (jiggle test), and genuine content repetition — kills scroll-settle-lerp false positives - Sizing probe trusts authored explicit heights (px/vh/calc) over circular parent/child reflow verdicts, fixing dropped 100vh heroes Normalize/generate: - Canonicalize identity transforms to "none" per viewport; emit explicit transform resets across bands when any band has a real transform - DittoLottie keeps the captured placeholder until DOMLoaded; failed loads no longer blank the container - mx-auto only for true auto-margin centering; literal per-band margins are preserved (fixes full-bleed regressions) - Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit whitespace-nowrap for wrap-vulnerable single-line text leaves - Grid recipes no longer override captured track counts or column spans; responsive re-flow plans apply only when geometry agrees Quality scoring: - Rewritten 6-dimension rubric (payload, decomposition, duplication, semantics, hygiene, runtime) with catastrophe caps and per-dimension reporting; thresholds are documented calibration guides 218 tests pass (74 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
77be868ee3
commit
d3ec154bae
@@ -14,6 +14,7 @@ import {
|
||||
import { discoverBreakpoints } from "./breakpoints.js";
|
||||
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
|
||||
import { sha1_12, round } from "../util/canonical.js";
|
||||
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
|
||||
|
||||
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
|
||||
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
|
||||
@@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo
|
||||
return status >= 500 || status === 429;
|
||||
}
|
||||
|
||||
function extFromUrl(url: string): string {
|
||||
// Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap
|
||||
// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`),
|
||||
// which then mis-materializes the asset. Keep the guard generous enough for the longest
|
||||
// real extensions and reject anything absurdly long (a dotted path segment, not an ext).
|
||||
const MAX_EXT_LEN = 12;
|
||||
|
||||
export function extFromUrl(url: string): string {
|
||||
try {
|
||||
const p = new URL(url).pathname;
|
||||
const dot = p.lastIndexOf(".");
|
||||
if (dot >= 0 && dot > p.lastIndexOf("/")) {
|
||||
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
|
||||
if (/^[a-z0-9]+$/.test(ext)) return ext;
|
||||
const ext = p.slice(dot + 1).toLowerCase();
|
||||
if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return "";
|
||||
@@ -209,6 +216,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2 — scroll-state reset immediately before a per-viewport snapshot. The motion /
|
||||
* dwell-scroll / carousel / element-screenshot passes above all leave the page scrolled or
|
||||
* mid-transition; scroll-linked styles (Webflow scroll-state transforms, position:sticky
|
||||
* offsets) then get baked into the captured computed styles for THAT viewport only, so a
|
||||
* scroll-driven translateY leaks into one band and cascades. Reset window + inner scrollers
|
||||
* to the top, wait for scroll-linked effects to settle across a few rAF ticks plus a short
|
||||
* quiescence window, THEN let the caller snapshot. Bounded and deterministic.
|
||||
*/
|
||||
async function settleScrollTopBeforeSnapshot(page: import("playwright").Page): Promise<void> {
|
||||
try {
|
||||
await Promise.race([
|
||||
page.evaluate(async () => {
|
||||
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
const resetAll = () => {
|
||||
window.scrollTo(0, 0);
|
||||
for (const el of Array.from(document.querySelectorAll("*"))) {
|
||||
if (el.scrollLeft) el.scrollLeft = 0;
|
||||
if (el.scrollTop) el.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
resetAll();
|
||||
// Let scroll-linked effects (scroll-state classes, sticky offsets, JS scroll handlers)
|
||||
// recompute at scroll 0 over several frames, re-asserting the top position each tick in
|
||||
// case a handler nudged it, then hold briefly for quiescence.
|
||||
for (let i = 0; i < 6; i++) { await raf(); resetAll(); }
|
||||
await new Promise<void>((r) => setTimeout(r, 120));
|
||||
resetAll();
|
||||
await raf();
|
||||
}),
|
||||
new Promise<void>((r) => setTimeout(r, 4000)),
|
||||
]);
|
||||
} catch { /* ignore — a best-effort reset never blocks the snapshot */ }
|
||||
}
|
||||
|
||||
export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean };
|
||||
|
||||
/**
|
||||
@@ -579,7 +621,18 @@ export async function captureSite(opts: {
|
||||
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
||||
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
||||
if (a.storedAs) return;
|
||||
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
|
||||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
|
||||
// `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So
|
||||
// unwrap it here: extract the default animation JSON and store THAT, materializing every
|
||||
// lottie source as plain JSON regardless of the container it arrived in.
|
||||
let extOverride: string | null = null;
|
||||
if (type === "lottie" && isZipArchive(bytes)) {
|
||||
const json = extractDotLottieJson(bytes);
|
||||
if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP
|
||||
bytes = json;
|
||||
extOverride = "json";
|
||||
}
|
||||
const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) ||
|
||||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
|
||||
type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
|
||||
const name = `${sha1_12(url)}.${ext}`;
|
||||
@@ -973,6 +1026,12 @@ export async function captureSite(opts: {
|
||||
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
|
||||
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
|
||||
|
||||
// Final scroll-state reset immediately before the walk: every preceding pass (dwell
|
||||
// scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes
|
||||
// scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and
|
||||
// let scroll-linked effects settle so the snapshot records the genuine at-rest values.
|
||||
await settleScrollTopBeforeSnapshot(page);
|
||||
|
||||
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
|
||||
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
|
||||
const snapshot: PageSnapshot = await Promise.race([
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
|
||||
/**
|
||||
* dotLottie (.lottie) extraction.
|
||||
*
|
||||
* A `.lottie` file is NOT bare lottie-web JSON — it is a ZIP archive (the "dotLottie"
|
||||
* container) holding `manifest.json` plus one or more `animations/*.json` documents. Feeding
|
||||
* the raw ZIP bytes to lottie-web via `path:` makes it try to JSON.parse a ZIP, which throws
|
||||
* an InvalidStateError at runtime and leaves the container blank. So at materialization time
|
||||
* we detect the ZIP, pick the default/first animation, and rewrite the stored asset as plain
|
||||
* lottie JSON that lottie-web can parse directly.
|
||||
*
|
||||
* Node ships `zlib` (raw DEFLATE) but no ZIP reader, and pulling a zip dependency for this
|
||||
* one shape is overkill — so this implements the minimal slice of the ZIP spec needed:
|
||||
* walking the End-Of-Central-Directory + central directory to locate entries, then reading
|
||||
* each local file header to decompress a stored (method 0) or deflated (method 8) entry.
|
||||
* Deterministic: no timestamps, no randomness, entries resolved by name.
|
||||
*/
|
||||
|
||||
/** ZIP local file header signature (`PK\x03\x04`). dotLottie archives always start with it. */
|
||||
export function isZipArchive(bytes: Buffer): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||
}
|
||||
|
||||
const EOCD_SIG = 0x06054b50; // End Of Central Directory
|
||||
const CEN_SIG = 0x02014b50; // Central directory file header
|
||||
const LOC_SIG = 0x04034b50; // Local file header
|
||||
|
||||
type ZipEntry = { name: string; method: number; compSize: number; uncompSize: number; localOffset: number };
|
||||
|
||||
/** Locate + read the End-Of-Central-Directory record, then walk the central directory. */
|
||||
function readCentralDirectory(buf: Buffer): ZipEntry[] {
|
||||
// The EOCD lives at the tail; it is >=22 bytes and may carry a trailing comment, so scan
|
||||
// backwards for its signature within the max comment window (64KiB) + record size.
|
||||
const minEocd = 22;
|
||||
if (buf.length < minEocd) return [];
|
||||
let eocd = -1;
|
||||
const scanFrom = Math.max(0, buf.length - (0xffff + minEocd));
|
||||
for (let i = buf.length - minEocd; i >= scanFrom; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) return [];
|
||||
|
||||
const entryCount = buf.readUInt16LE(eocd + 10);
|
||||
let ptr = buf.readUInt32LE(eocd + 16); // central directory offset
|
||||
const entries: ZipEntry[] = [];
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== CEN_SIG) break;
|
||||
const method = buf.readUInt16LE(ptr + 10);
|
||||
const compSize = buf.readUInt32LE(ptr + 20);
|
||||
const uncompSize = buf.readUInt32LE(ptr + 24);
|
||||
const nameLen = buf.readUInt16LE(ptr + 28);
|
||||
const extraLen = buf.readUInt16LE(ptr + 30);
|
||||
const commentLen = buf.readUInt16LE(ptr + 32);
|
||||
const localOffset = buf.readUInt32LE(ptr + 42);
|
||||
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
|
||||
entries.push({ name, method, compSize, uncompSize, localOffset });
|
||||
ptr += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Decompress one central-directory entry by reading its local header for the data offset. */
|
||||
function readEntry(buf: Buffer, e: ZipEntry): Buffer | null {
|
||||
const p = e.localOffset;
|
||||
if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) return null;
|
||||
// The central directory's name/extra lengths can differ from the local header's, so read the
|
||||
// local header's own lengths to find where the file data begins.
|
||||
const nameLen = buf.readUInt16LE(p + 26);
|
||||
const extraLen = buf.readUInt16LE(p + 28);
|
||||
const dataStart = p + 30 + nameLen + extraLen;
|
||||
const dataEnd = dataStart + e.compSize;
|
||||
if (dataEnd > buf.length) return null;
|
||||
const comp = buf.subarray(dataStart, dataEnd);
|
||||
if (e.method === 0) return Buffer.from(comp); // stored
|
||||
if (e.method === 8) {
|
||||
try { return inflateRawSync(comp); } catch { return null; }
|
||||
}
|
||||
return null; // unsupported compression method
|
||||
}
|
||||
|
||||
/** Read a named entry (exact path match) from a ZIP buffer, or null if absent/unreadable. */
|
||||
export function readZipEntry(buf: Buffer, name: string): Buffer | null {
|
||||
const entry = readCentralDirectory(buf).find((e) => e.name === name);
|
||||
return entry ? readEntry(buf, entry) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lottie animation JSON from a dotLottie ZIP. Picks the manifest's first/default
|
||||
* animation (falling back to the first `animations/*.json` entry), returning it as a Buffer of
|
||||
* plain lottie JSON ready to hand to lottie-web via `path:`. Returns null when the bytes are
|
||||
* not a dotLottie ZIP or no animation JSON is present.
|
||||
*/
|
||||
export function extractDotLottieJson(bytes: Buffer): Buffer | null {
|
||||
if (!isZipArchive(bytes)) return null;
|
||||
const entries = readCentralDirectory(bytes);
|
||||
if (!entries.length) return null;
|
||||
|
||||
const animEntries = entries
|
||||
.filter((e) => /^animations\/.+\.json$/i.test(e.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)); // deterministic ordering
|
||||
if (!animEntries.length) return null;
|
||||
|
||||
// Prefer the animation the manifest names first (its default), when the manifest is readable.
|
||||
let chosen = animEntries[0]!;
|
||||
const manifestBuf = readZipEntry(bytes, "manifest.json");
|
||||
if (manifestBuf) {
|
||||
try {
|
||||
const manifest = JSON.parse(manifestBuf.toString("utf8")) as { animations?: Array<{ id?: string }> };
|
||||
const firstId = manifest.animations?.[0]?.id;
|
||||
if (firstId) {
|
||||
const match = animEntries.find((e) => e.name === `animations/${firstId}.json`);
|
||||
if (match) chosen = match;
|
||||
}
|
||||
} catch { /* manifest unreadable — keep the name-sorted first animation */ }
|
||||
}
|
||||
|
||||
const json = readEntry(bytes, chosen);
|
||||
if (!json) return null;
|
||||
// Validate it parses as JSON (lottie-web's `path:` loader does a bare JSON.parse).
|
||||
try { JSON.parse(json.toString("utf8")); } catch { return null; }
|
||||
return json;
|
||||
}
|
||||
+190
-13
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
|
||||
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
|
||||
};
|
||||
|
||||
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
|
||||
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
|
||||
// module-scope functions do NOT cross the serialization boundary — so any change here must
|
||||
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
|
||||
|
||||
/**
|
||||
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
|
||||
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
|
||||
* is a large jump opposite the travel direction when the track seamlessly restarts.
|
||||
*/
|
||||
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
|
||||
if (!deltas.length || sampleMs <= 0) return 0;
|
||||
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
|
||||
return Math.round((med / sampleMs) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
|
||||
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
|
||||
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
|
||||
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
|
||||
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
|
||||
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
|
||||
*
|
||||
* @param window1Deltas per-sample translateX deltas from the first observation window
|
||||
* @param window2Deltas per-sample translateX deltas from the second (later) window
|
||||
* @param sampleMs cadence between samples within a window
|
||||
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
|
||||
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
|
||||
*/
|
||||
export function classifyVelocitySamples(
|
||||
window1Deltas: number[],
|
||||
window2Deltas: number[],
|
||||
sampleMs: number,
|
||||
minPxPerSec = 4,
|
||||
relTol = 0.5,
|
||||
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
|
||||
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
|
||||
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
|
||||
// The reported velocity is the first window's (measured closest to a clean, post-settle
|
||||
// marquee; also what the old code reported), kept for output determinism vs. the old path.
|
||||
const pxPerSec = v1;
|
||||
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
|
||||
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
|
||||
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
|
||||
return { isMarquee: sustained, pxPerSec, v1, v2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
|
||||
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
|
||||
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
|
||||
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
|
||||
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
|
||||
* so hashes differ but the geometry repeats).
|
||||
*
|
||||
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
|
||||
* @param widths per-child rounded offsetWidth
|
||||
*/
|
||||
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
|
||||
const n = Math.min(hashes.length, widths.length);
|
||||
if (n < 2) return false;
|
||||
// (a) two consecutive children with an identical hash — a literal cloned copy.
|
||||
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
|
||||
// (b) structural fallback: the first half's width sequence repeats in the second half
|
||||
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
|
||||
// width so a row of zero-width nodes can't spuriously "repeat".
|
||||
if (n >= 4 && n % 2 === 0) {
|
||||
const half = n / 2;
|
||||
let repeats = true;
|
||||
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
|
||||
if (repeats) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type MotionCapture = {
|
||||
waapi: WaapiAnim[];
|
||||
rotators: RotatorSpec[];
|
||||
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
|
||||
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
|
||||
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
|
||||
* scroll to top); does not touch the captured snapshot/IR.
|
||||
*
|
||||
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
|
||||
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
|
||||
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
|
||||
* which reads as a constant velocity over a single short window):
|
||||
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
|
||||
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
|
||||
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
|
||||
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
|
||||
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
|
||||
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
|
||||
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
|
||||
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
|
||||
*
|
||||
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
|
||||
* few candidates that already pass the cheap synchronous gates (overflow + translated +
|
||||
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
|
||||
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
|
||||
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
|
||||
*/
|
||||
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
|
||||
try {
|
||||
@@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
|
||||
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
|
||||
return false;
|
||||
};
|
||||
// cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test.
|
||||
const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; };
|
||||
|
||||
// ---- Pure discriminators (mirror of the exported module-scope functions; kept inline
|
||||
// because module scope does not cross the page.evaluate serialization boundary). ----
|
||||
const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => {
|
||||
if (!deltas.length || sampleMs <= 0) return 0;
|
||||
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
|
||||
return Math.round((med / sampleMs) * 1000);
|
||||
};
|
||||
const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => {
|
||||
const v1 = medianVelocityPxPerSec(w1, sampleMs);
|
||||
const v2 = medianVelocityPxPerSec(w2, sampleMs);
|
||||
const pxPerSec = v1;
|
||||
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
|
||||
return { isMarquee: sustained, pxPerSec, v1, v2 };
|
||||
};
|
||||
const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => {
|
||||
const n = Math.min(hashes.length, widths.length);
|
||||
if (n < 2) return false;
|
||||
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
|
||||
if (n >= 4 && n % 2 === 0) {
|
||||
const half = n / 2;
|
||||
let repeats = true;
|
||||
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
|
||||
if (repeats) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// sample per-child outerHTML hash + width for the duplication test.
|
||||
const childSignature = (el: Element): { hashes: number[]; widths: number[] } => {
|
||||
const hashes: number[] = [], widths: number[] = [];
|
||||
const kids = Array.from(el.children).slice(0, 24) as HTMLElement[];
|
||||
for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); }
|
||||
return { hashes, widths };
|
||||
};
|
||||
// one observation window: `count` deltas at `sampleMs` cadence.
|
||||
const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise<number[]> => {
|
||||
const xs: number[] = [txOf(el)];
|
||||
for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); }
|
||||
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
|
||||
return dxs;
|
||||
};
|
||||
|
||||
const SAMPLE_MS = 120;
|
||||
const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window
|
||||
const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2)
|
||||
const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3)
|
||||
|
||||
// Candidates: tagged, already translated (paused tickers keep their offset), with
|
||||
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
|
||||
// GENUINE duplicated content inside an overflow-clip viewport, AND real content
|
||||
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
|
||||
// so the expensive async windows only run for the few survivors.
|
||||
const cand: Element[] = [];
|
||||
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
|
||||
if (Math.abs(txOf(el)) <= 0.5) continue;
|
||||
if (el.children.length < 2) continue;
|
||||
if (!inClip(el)) continue;
|
||||
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
|
||||
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
|
||||
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
|
||||
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
|
||||
const sig = childSignature(el);
|
||||
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
|
||||
cand.push(el);
|
||||
if (cand.length >= 16) break;
|
||||
if (cand.length >= 8) break;
|
||||
}
|
||||
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const el of cand) {
|
||||
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
|
||||
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
|
||||
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
|
||||
const xs: number[] = [];
|
||||
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
|
||||
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
|
||||
if (dxs.length < 3) continue;
|
||||
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
|
||||
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
|
||||
const pxPerSec = Math.round((med / 120) * 1000);
|
||||
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
|
||||
// direction must be consistent in all but (at most) the one wrap step.
|
||||
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
|
||||
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
|
||||
|
||||
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
|
||||
// a real marquee holds constant velocity.
|
||||
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
await sleep(INTER_WINDOW_MS);
|
||||
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
if (w1.length < 3 || w2.length < 3) continue;
|
||||
// direction must be consistent within window 1, but for (at most) the one wrap step.
|
||||
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
|
||||
if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
|
||||
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
|
||||
if (!cls.isMarquee) continue;
|
||||
const pxPerSec = cls.pxPerSec;
|
||||
|
||||
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
|
||||
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
|
||||
const baseY = window.scrollY;
|
||||
const jiggle = async (dy: number): Promise<number> => {
|
||||
try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ }
|
||||
await sleep(250); // let any scroll-linked easing react and settle at the new offset
|
||||
const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
return medianVelocityPxPerSec(dxs, SAMPLE_MS);
|
||||
};
|
||||
const vDown = await jiggle(JIGGLE_PX);
|
||||
const vUp = await jiggle(-JIGGLE_PX);
|
||||
try { window.scrollTo(0, baseY); } catch { /* ignore */ }
|
||||
// Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as
|
||||
// it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges.
|
||||
const stableUnderJiggle = (v: number): boolean =>
|
||||
Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2;
|
||||
if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue;
|
||||
|
||||
const periodPx = Math.round(el.scrollWidth / 2);
|
||||
if (periodPx < 40) continue;
|
||||
seen.add(cap);
|
||||
|
||||
@@ -380,9 +380,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
const hf = el.getBoundingClientRect().height;
|
||||
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
|
||||
// so a drop can't accumulate a visible shift across many elements (favours fidelity).
|
||||
let hAuto = Math.abs(ha - r.height) <= 0.5;
|
||||
let hFill = Math.abs(hf - r.height) <= 0.5;
|
||||
// Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH
|
||||
// `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) —
|
||||
// even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both
|
||||
// sides then wait on each other and the box collapses. When this element's own cascade/inline
|
||||
// style declares an explicit definite height, trust that declaration: the height is authored,
|
||||
// so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto
|
||||
// actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false.
|
||||
if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) {
|
||||
hAuto = false;
|
||||
hFill = false;
|
||||
}
|
||||
sizing = {
|
||||
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
|
||||
hFill: Math.abs(hf - r.height) <= 0.5,
|
||||
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
|
||||
hFill,
|
||||
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
|
||||
};
|
||||
} finally {
|
||||
@@ -492,6 +505,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
const cssUrlSet = new Set<string>();
|
||||
const keyframes: string[] = [];
|
||||
const cssVars: Record<string, string> = {};
|
||||
// Selectors that AUTHOR an explicit, definite height/min-height (px/rem/em/vh/vw/…,
|
||||
// NOT auto, NOT a percentage, NOT a keyword). Harvested once from the cascade below and
|
||||
// consulted by the sizing probe to break circular parent/child height verdicts: when a box
|
||||
// and its fill child mutually justify each other's height, `height:auto` reproduces the box
|
||||
// for a reason that is NOT "content-sized", so the probe alone reads hAuto:true and the
|
||||
// authored dimension gets dropped downstream. A declared explicit length is ground truth that
|
||||
// the height is load-bearing, so we trust the declaration over the reflow verdict.
|
||||
const explicitHeightSelectors: string[] = [];
|
||||
|
||||
const absUrl = (u: string): string => {
|
||||
try { return new URL(u, document.baseURI).href; } catch { return u; }
|
||||
@@ -507,6 +528,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
}
|
||||
};
|
||||
|
||||
// True when a `height`/`min-height` value is an explicit, definite length the browser resolves
|
||||
// to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for
|
||||
// `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the
|
||||
// hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite
|
||||
// authored height is load-bearing and must survive even when the reflow probe reads hAuto:true.
|
||||
const isExplicitHeight = (raw: string): boolean => {
|
||||
const v = (raw || "").trim().toLowerCase();
|
||||
if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false;
|
||||
if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" ||
|
||||
v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false;
|
||||
// A bare percentage resolves against the parent (fill), not an authored definite length.
|
||||
if (/^[\d.]+%$/.test(v)) return false;
|
||||
// A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box.
|
||||
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
|
||||
};
|
||||
|
||||
// Does this element author an explicit definite height — via its own inline style (passed as
|
||||
// `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into
|
||||
// `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a
|
||||
// plain number and is indistinguishable from a content height there); the specified value is only
|
||||
// recoverable from the inline declaration and the cascade, which is why we consult both.
|
||||
const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => {
|
||||
if (isExplicitHeight(inlineHeight)) return true;
|
||||
try {
|
||||
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || "";
|
||||
if (isExplicitHeight(inlineMin)) return true;
|
||||
} catch { /* ignore */ }
|
||||
for (const sel of explicitHeightSelectors) {
|
||||
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const readRules = (rules: CSSRuleList): void => {
|
||||
for (const rule of Array.from(rules)) {
|
||||
const type = rule.constructor.name;
|
||||
@@ -532,6 +586,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
} else if (type === "CSSStyleRule") {
|
||||
const r = rule as CSSStyleRule;
|
||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
|
||||
if (r.selectorText && r.style &&
|
||||
(isExplicitHeight(r.style.getPropertyValue("height")) ||
|
||||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
||||
explicitHeightSelectors.push(r.selectorText);
|
||||
}
|
||||
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
||||
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
||||
} else if (type === "CSSImportRule") {
|
||||
|
||||
Reference in New Issue
Block a user