Skip to content

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

Open
miga-heygen wants to merge 2 commits into
magi/fix-sweep-static-text-fingerprintfrom
fix/sweep-static-svg-dash
Open

miga-heygen wants to merge 2 commits into
magi/fix-sweep-static-text-fingerprintfrom
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.

@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
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Rebased onto bfaf6eb as 582018d.

@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.

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — 95624bcf Deployed Sep 15, 2026 by mintlify[bot]
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.

1 participant