Canvas raster fallback, CDP rest-state screenshots, lottie overlay containment

- Visible canvases (>=48px) are captured as PNG stills at capture time
  (toDataURL, element-screenshot fallback for tainted/webgl) and emitted as
  cid-carrying <img> elements filling the captured box, so canvas-driven
  bands no longer render blank
- Source AND clone full-page screenshots now capture via CDP
  Page.captureScreenshot with captureBeyondViewport: no scroll-stitching,
  no scroll events, so scroll-linked animations can't smear mid-scrub
  states into perceptual evidence (pixel-identical to the old path on
  static pages; Playwright fallback retained)
- DittoLottie's runtime overlay stays position:absolute/inset:0 for its
  whole life and the host carries data-ditto-lottie with a scoped
  svg/canvas fit rule, so the runtime animation always fills the pinned
  per-viewport box instead of escaping to aspect height

259 tests pass (12 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 00:36:08 -07:00
co-authored by Claude Fable 5
parent e33354cfb5
commit 15d1e7a9e3
9 changed files with 426 additions and 8 deletions
+10
View File
@@ -391,6 +391,10 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
// A canvas emitted as its raster still (<img> — see resolveTag) is decorative
// painted surface with no captured alt text; emit alt="" so the img is valid.
if (node.tag === "canvas" && node.attrs.src && !props.some(([k]) => k === "alt")) props.push(["alt", JSON.stringify("")]);
if (node.rawHTML && node.tag === "svg") {
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
// elements: it's internal instrumentation, render-inert, and would otherwise
@@ -557,6 +561,12 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
// children inside <iframe> are unrendered fallback content, so emit a <div> container
// carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical).
if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div";
// A canvas is runtime-drawn surface the clone cannot reproduce; when capture
// rasterized it (canvas-still synthetic URL stamped as `src` — see
// captureCanvasStillsInPage) emit the still as an <img> filling the canvas's box
// (CSS is keyed by cid, so the geometry is identical). A canvas with no still
// keeps rendering as an empty <canvas> box, same as before.
if (tag === "canvas" && node.attrs.src) tag = "img";
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
if (violatesContentModel(node, tag)) tag = "div";
return tag;
+5
View File
@@ -32,6 +32,11 @@ button, input, select, textarea, optgroup { font: inherit; color: inherit; backg
button { cursor: pointer; }
table { border-collapse: separate; border-spacing: 0; }
img, picture, video, canvas, svg { display: inline-block; }
/* Lottie runtime fit: the player re-mounts its svg/canvas into an absolute overlay that fills
the host's captured (per-viewport, definite) box. Force that runtime media to fit the box so
an aspect-mismatched viewBox (e.g. a portrait animation in a shorter, letterboxed source box)
can't inflate past the pinned height. Scoped to the runtime-marked host only. */
[data-ditto-lottie] > div > svg, [data-ditto-lottie] > div > canvas { width: 100%; height: 100%; display: block; }
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; }
/* Neutralize UA text defaults to inherit so the generator's default-skipping is
correct: a property equal to its inherited value is simply not emitted. */
+10 -2
View File
@@ -138,12 +138,19 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
// Mount the live animation into an OVERLAY child, keeping the captured placeholder
// frame in the DOM until lottie signals a successful load — a failed load (bad JSON,
// network) then leaves the placeholder intact instead of erasing the container to blank.
// The overlay stays position:absolute;inset:0 for its WHOLE life (not just pre-swap):
// the host box carries the captured per-viewport height, and only an absolutely-filled
// overlay inherits that definite box. A static-flow overlay has indefinite height, so
// the player's svg (style height:100%) collapses to auto and inflates to the source
// aspect (a portrait viewBox then oversizes far past the captured, letterboxed box).
const mount = document.createElement("div");
mount.style.position = "absolute";
mount.style.inset = "0";
mount.style.opacity = "0";
const cs = getComputedStyle(el);
if (cs.position === "static") el.style.position = "relative";
// Mark the host so emitted CSS can force the runtime svg/canvas to fit the pinned box.
el.setAttribute("data-ditto-lottie", "");
el.appendChild(mount);
const anim = lottie.loadAnimation({
container: mount,
@@ -171,8 +178,9 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
const rendered = mount.querySelector("svg, canvas");
if (rendered) rendered.setAttribute("data-cid", placeholderCid);
}
mount.style.position = "";
mount.style.inset = "";
// Do NOT clear position/inset: the overlay must keep filling the host's pinned box
// (see above). Stripping them reverts it to static flow → indefinite height → the
// player's height:100% svg inflates to its aspect and escapes the captured bbox.
};
// DOMLoaded fires only after the JSON parsed and the first frame rendered — the
// right moment to reveal the live render and drop the placeholder. data_failed