Skip to content

fix(cli): sweep_static geometry fingerprint now covers SVG stroke/dash motion - #3914

Closed
miga-heygen wants to merge 6 commits into
mainfrom
fix/sweep-static-svg-dash
Closed

miga-heygen wants to merge 6 commits into
mainfrom
fix/sweep-static-svg-dash

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Read after #3707 — this PR is stacked on magi/fix-sweep-static-text-fingerprint and adds one channel to the motion classifier that PR introduces.

Summary

hyperframes check's seek-audit fires sweep_static ("Timeline did not advance under seek") when the per-sample motion signature is byte-identical across the whole run. An SVG stroke-dasharray / stroke-dashoffset "draw the line in" entrance — the standard way to animate a connector or diagram line — moves no bounding box and no opacity, so a genuinely animating composition using it was flagged as a frozen timeline.

With #3707 the signature has a single owner (packages/cli/src/commands/motion-signature.browser.js, ELEMENT_CHANNELS), shared by the frozen-sweep guard and keepsMoving liveness. This PR:

  • Appends strokeDashChannel to ELEMENT_CHANNELS. Hashes the computed dash pattern and offset for SVG geometry elements (SVGGeometryElement: path/circle/ellipse/rect/line/polyline/polygon) with a painted stroke. none and all-zero patterns (which render solid), unpainted strokes (none, transparent, zero width, stroke-opacity: 0) and non-SVG elements contribute "", so ordinary compositions gain no payload.
  • Widens isVisibleElement for stroked geometry with one degenerate bbox axis. Chromium's getBoundingClientRect for SVG shapes is the object bounding box without the stroke: a straight horizontal or vertical connector reports 0 height or 0 width regardless of stroke-width, so the box gate dropped exactly the most common shape for this animation. A stroked shape with one non-degenerate axis now counts as visible. As a consequence motion-sample's visible bit for such connectors is corrected too (it previously reported them invisible).
  • Treats <defs>, <clipPath>, <mask>, <pattern>, <marker> and <symbol> as never-painted subtrees in the existing hidden-subtree walk (the same container list layout-audit.browser.js skips for connectors). display is not inherited — a child of a display:none parent computes display: block — so descendants are excluded only through the ancestor walk, which now also starts at these containers rather than relying on one engine's empty-box behaviour for their descendants. The match is namespaced (instanceof SVGElement), so an HTML element that merely shares a name is not pruned.

Test plan

  • motion-signature.browser.test.ts (happy-dom, 43 passing): dash-offset moves on a 290×0 connector change the signature, and so does a stroke-dasharray change at a fixed offset (both the exact sweep and the quantized liveness signature); identical scene hashes identically; dash motion under a display:none ancestor and inside <defs> / <clipPath> / <mask> / <pattern> / <marker> / <symbol> is ignored; one table row per channel guard — stroke-opacity: 0, stroke: rgba(0, 0, 0, 0), stroke-width: 0, stroke-dasharray: 0 and 0 0 (solid) — leaves the signature unchanged under a moving offset; a dashed stroke inherited by a <g> / <text> (not SVGGeometryElement) is not signed; a fade inside an HTML element named <defs> still changes the signature. A source-parity test extracts UNPAINTED_SVG_CONTAINERS and layout-audit's CONNECTOR_SKIP_CONTAINERS from the two files and asserts the name sets are equal (case-insensitive), so the hand-kept copy cannot drift silently. Fixtures use the real Chromium bbox of a horizontal stroked path (height 0) so they pin the widened gate, not the pre-existing box path.
  • motion-signature.browser.chromium.test.ts (real Chromium, 44 passing): a stroke-dashoffset draw-in and a fixed-offset stroke-dasharray change on a straight connector each change both the sweep and the liveness signature; dash motion under display:none and inside all six unpainted containers leaves both identical; the same five guard rows as the happy-dom suite (against Blink's computed values — stroke: transparent computes to rgba(0, 0, 0, 0)) and the <g> / <text> case leave both identical; a fade inside an HTML <defs> element changes both signatures.
  • motion-sample.browser.test.ts (happy-dom, 8 passing) unchanged.
  • Real-Chromium probes confirmed: straight <path> reports a 290×0 box; display:none descendants compute display: inline; descendants of all six containers (<defs>, <clipPath>, <mask>, <pattern>, <marker>, <symbol>) report 0×0 while checkVisibility returns true for them, so the container rule is what excludes them by intent rather than the box gate; computed stroke-dashoffset is rounded (144.99999999px → 145px), so the signature cannot vary on its own at a fixed seek.
  • Review passes on the diff surfaced and fixed: stroke: transparent / stroke-opacity: 0 counting as painted; stroke-dasharray: 0 (solid) counting as a pattern; tag-name matching hitting HTML unknown elements (replaced by instanceof SVGGeometryElement).
  • Review passes on the container rule surfaced and fixed: <mask> / <pattern> / <marker> missing from the never-painted set although they meet its definition (and the widened bbox gate would otherwise admit a straight connector inside them); the tag-name match lacking a namespace guard.
  • Mutation check on the guards: hashing the offset only, dropping the transparent-colour guard, the stroke-width > 0 guard, or the all-zero-is-solid guard, widening the element filter to SVGElement, and removing a name from UNPAINTED_SVG_CONTAINERS each fail exactly their own row(s) in both suites.
  • oxlint, oxfmt --check, and tsc --noEmit clean for packages/cli. The happy-dom suites cannot be collected directly in the local sandbox under bun (No such built-in module: node: from the @vitest-environment happy-dom loader); they were run with --environment node plus a setup file that populates globals from a happy-dom Window, and pass. CI runs them as-is.

Deliberately out of scope: dashing on <text> / <use> (no channel today); other "moves nothing else" SVG reveals (fill-opacity, stroke-opacity, gradient stop-opacity); animation of the content of a <mask> / <pattern> / <clipPath> / <marker> referenced by an otherwise static element (it changes pixels through the referencing element, whose own channels do not move — and Blink gives that content 0×0 boxes, so it was never signed); and pathLength attribute animation (rescales the dash pattern with no computed-style change) — this closes the stroke/dash gap only.

miguel-heygen and others added 2 commits September 13, 2026 03:51
The frozen-sweep guard (sweep_static) and keepsMoving liveness (motion_frozen)
each owned their own definition of "what counts as motion": the sweep
fingerprint in layout-audit.browser.js read nine channels while
motion-sample.browser.js compared box + opacity only. They meet in one decision
in checkPipeline.ts, so a fixed-width attr() countdown was accepted by the
sweep guard and still reported frozen by liveness.

Move the classifier into motion-signature.browser.js, injected before both
samplers, and make it the single owner:

- Per-element channels (box, opacity, font-variation axes, clip-path, direct
  text, form-control state, ::before/::after content, media pixels) as a
  reader list so further channels (e.g. SVG dash) plug in.
- Liveness consumes the same signature with 2px / 0.08 bucketing; the sweep
  guard consumes it exact. motion-sample throws at install if the shared
  script is missing.
- Counter state is a composition-level channel over box-generating elements
  and root ancestors. display:none subtrees generate no boxes and are
  excluded, and only declarations naming a counter that some painted
  generated content (::before/::after/::marker on a non-ignored, paintable
  host) consumes are folded in, so a decoy hidden counter cannot make a
  frozen composition read as live.
- clip-path is a channel, replacing the fingerprint's former reliance on the
  clipped-away visibility probe, so a wipe over a static box still counts.
- data-layout-ignore layers are excluded from the signature but not from the
  per-selector `visible` bit reported for explicit motion assertions.
- Root resolution for the "*" liveness scope now matches the sweep guard and
  the root element is part of its own signature.

Tests: motion-signature.browser.test.ts (happy-dom, per-element fake computed
style so ancestor handling comes from the classifier) and
motion-signature.browser.chromium.test.ts (real Chromium via puppeteer-core;
skips without a system browser) pin every branch: attr() substitution,
display:none decoys, named-counter gating, ::marker, zero-box hosts, ancestor
counters, textarea/indeterminate, root text, clip-path, ignore layers, and
sweep/liveness agreement on the countdown fixture.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Addressed in b8abfc7.

@miga-heygen
miga-heygen force-pushed the fix/sweep-static-svg-dash branch from f35dfe3 to b8abfc7 Compare September 14, 2026 18:26
@mintlify

mintlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 14, 2026, 6:29 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@miga-heygen
miga-heygen changed the base branch from main to magi/fix-sweep-static-text-fingerprint September 14, 2026 18:26
…ure asserted scopes

The shared motion classifier pruned only display:none subtrees, so text,
pseudo content, media pixels, and counter decoys inside skipped
content-visibility contents changed the frozen-sweep and liveness
signatures although nothing painted. Pruning is now platform-owned where
checkVisibility exists (display:none, skipped hidden/auto contents; a
display:contents host keeps its painting pseudo-elements), with the
computed-style fallback for happy-dom. A content-visibility:hidden host
signs its own box, opacity, clip-path, font axes, and widget state, but
not its text, pseudo content, control value, or media pixels; hosts where
size containment does not apply (non-atomic inline, display:contents,
table and internal table boxes, ruby) skip nothing. isVisibleElement
regains the checkVisibility floor layout-audit uses on its opacity-floor
path, so an asserted selector inside skipped contents reports hidden.

The layout-audit opt-out no longer applies to the element passed as the
signature root: a keepsMoving scope that is itself data-layout-ignore is
measured (an assertion naming an element outranks the opt-out) instead of
producing an empty signature that motionAudit reports as a missing
selector. Opt-outs inside the scope still apply.

motion-sample defers the missing-classifier error to the first call so
the driver's evaluate surfaces it instead of an opaque "is not a
function" page error; round() is shared.

The real-Chromium suite is opt-in on Windows (HYPERFRAMES_BROWSER_TESTS=1):
in the shared Windows package lane the runner's Chrome did not launch
within the package hookTimeout, and the suite already runs on Linux CI
where the system Chrome is found. The launch hook has its own 120s ceiling.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Rebased onto bfaf6eb as 582018d.

…s, caption paints

A display:contents child of a content-visibility:hidden host has no box
of its own, so the platform check could not tell it apart from an
ordinary display:contents owner and it stayed a counter consumer: a
change of the owner's counter-reset altered the sweep signature although
nothing paints. The parent's skipsContents verdict now prunes it; boxed
descendants keep being decided by the platform.

NOT_CONTAINABLE_DISPLAY had table-cell and table-caption inverted
versus Chromium 152, where a table-cell host skips its contents and a
table-caption host paints them.

Four Chromium pins: the display:contents-in-skipped-contents counter, a
table-cell host's own text and pseudo content (frozen), a table-caption
host's own text and pseudo content (motion), and a counter reachable
only through a skipped host's hidden ::marker / ::after (frozen; also
asserts the skipped child is not visible for motion-sample).

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Rebased onto 09418ae as 5f4196b.

A "draw the line in" SVG entrance animates stroke-dasharray /
stroke-dashoffset on a shape whose geometry never changes, so the
box+opacity channels read it as a frozen timeline and `hyperframes check`
false-positives sweep_static on a visibly animating composition.

- Append strokeDashChannel to ELEMENT_CHANNELS: hashes the computed dash
  pattern and offset for SVG geometry elements with a painted stroke;
  `none` / all-zero patterns, unpainted (none, transparent, zero-width,
  stroke-opacity 0) strokes and non-SVG elements contribute "".
- Widen isVisibleElement for stroked geometry with one degenerate bbox
  axis: Chromium's getBoundingClientRect for SVG shapes excludes the
  stroke, so a straight horizontal or vertical connector reports 0 height
  or 0 width and was dropped by the box gate — the most common shape for
  this animation. motion-sample's `visible` bit for such connectors is
  corrected as a consequence.
- Treat <defs>, <clipPath> and <symbol> as never-painted subtrees in the
  hidden-subtree walk (display is not inherited, so descendants are hidden
  only through the ancestor walk) rather than relying on one engine's
  empty-box behaviour for their descendants.

Tests: happy-dom pins for the degenerate-bbox connector (moves / frozen),
a display:none ancestor, <defs>/<clipPath> content and a stroke-opacity:0
decoy; real-Chromium pins for the connector draw-in and the ignored cases.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen
miga-heygen force-pushed the fix/sweep-static-svg-dash branch from 5f4196b to 95624bc Compare September 15, 2026 02:34
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Updated as 95624bc: SVG container set and guard.

Follow-up coverage for the SVG stroke-dash motion channel:

- stroke-dasharray changes at a fixed stroke-dashoffset now change both
  the exact sweep signature and the quantized liveness signature
  (happy-dom and Chromium)
- one table row per guard: stroke-opacity 0, a transparent stroke,
  stroke-width 0, and all-zero dash lists ("0", "0 0") each leave the
  signature unchanged under a moving offset
- a dashed stroke inherited by <g> / <text> (not SVGGeometryElement) is
  not signed
- a source-parity test asserts UNPAINTED_SVG_CONTAINERS and layout-audit's
  CONNECTOR_SKIP_CONTAINERS name the same containers
- the container comment no longer claims motion inside a referenced
  <mask> / <pattern> is attributed to the referencing element; its own
  channels are, the referenced content has none

Mutation-checked: hashing the offset only, dropping each stroke/dash
guard, widening the element filter to SVGElement, and removing a
container name each fail exactly their own rows in both suites.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Addressed in 434d1e0.

Base automatically changed from magi/fix-sweep-static-text-fingerprint to main October 2, 2026 13:25
@miguel-heygen

Copy link
Copy Markdown
Collaborator

Closing as stale after a few weeks without activity. Reopen after rebasing on main if still needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants