Skip to content

Commit 5f4196b

Browse files
fix(cli): sign SVG stroke-dash motion in the shared motion classifier
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>
1 parent 09418ae commit 5f4196b

4 files changed

Lines changed: 315 additions & 38 deletions

File tree

‎docs/packages/cli.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,8 @@ HTML. Without a sidecar, nothing changes.
717717

718718
`keepsMoving` uses the same motion classifier as the frozen-sweep guard: box
719719
geometry, opacity, text and generated content, form-control state, painted CSS
720-
counters, clip-path, variable-font axes, and the pixels of visible
720+
counters, clip-path, variable-font axes, SVG stroke dashing
721+
(`stroke-dasharray` / `stroke-dashoffset`), and the pixels of visible
721722
canvas/video/img elements all count as motion — so a playing same-origin (or
722723
CORS-readable) background video keeps a scope live on its own. Elements under
723724
`data-layout-ignore` inside the scope never count; the opt-out does not apply to

‎packages/cli/src/commands/motion-signature.browser.chromium.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,46 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => {
355355
expect(after.liveness).not.toBe(before.liveness);
356356
});
357357

358+
// Blink reports a 290x0 box for this path (object bounding box, no stroke),
359+
// so this also pins that a stroked straight connector counts as visible.
360+
it("sees a stroke-dashoffset draw-in on a straight connector", async () => {
361+
await load(
362+
composition(
363+
"#wire { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; }",
364+
'<svg width="640" height="360"><path id="wire" d="M 10 10 L 300 10"/></svg>',
365+
),
366+
);
367+
const before = await sample();
368+
await mutate('document.getElementById("wire").style.strokeDashoffset = "145"');
369+
const after = await sample();
370+
371+
expect(after.sweep).not.toBe(before.sweep);
372+
expect(after.liveness).not.toBe(before.liveness);
373+
});
374+
375+
// Blink already reports an empty box inside <defs> / <clipPath>; this pins
376+
// that the platform and the classifier agree, not the container rule alone.
377+
it("ignores stroke-dash motion under display:none and inside <defs> / <clipPath>", async () => {
378+
await load(
379+
composition(
380+
"path { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; } #offstage { display: none; }",
381+
`<svg width="640" height="360">
382+
<g id="offstage"><path id="hidden" d="M 10 10 L 300 10"/></g>
383+
<defs><path id="template" d="M 10 20 L 300 20"/></defs>
384+
<clipPath id="reveal"><path id="clip" d="M 10 30 L 300 30"/></clipPath>
385+
<rect id="anchor" x="10" y="100" width="200" height="50" fill="#f00"/>
386+
</svg>`,
387+
),
388+
);
389+
const before = await sample();
390+
await mutate(
391+
'for (const id of ["hidden", "template", "clip"]) document.getElementById(id).style.strokeDashoffset = "0"',
392+
);
393+
const after = await sample();
394+
395+
expect(after).toEqual(before);
396+
});
397+
358398
it("sees textarea value and checkbox indeterminate changes", async () => {
359399
await load(
360400
composition(

‎packages/cli/src/commands/motion-signature.browser.js‎

Lines changed: 113 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@
1919
// bucketing, while the sweep guard wants exact (0.01) rounding because it asks
2020
// whether the seek moved anything at all.
2121
//
22-
// Adding a channel (e.g. SVG stroke-dasharray/dashoffset): append one reader
22+
// Adding a channel (e.g. SVG fill-opacity): append one reader
2323
// `(element, ctx) => string` to BOX_CHANNELS (reads the element's own box,
24-
// including a control's widget type and checked state) or CONTENT_CHANNELS (reads what the
25-
// element's contents paint — text, pseudo content, control values, media
26-
// pixels — which `content-visibility: hidden` skips). `ctx`
27-
// carries the element's computed style, its ::before/::after styles, its
28-
// inherited opacity, and the quantize flag. A reader returns a string that is
29-
// equal between two samples iff that channel did not visibly change; return ""
30-
// for elements the channel does not apply to so ordinary compositions gain no
31-
// payload.
24+
// including a control's widget type and checked state, or an SVG shape's
25+
// stroke) or CONTENT_CHANNELS (reads what the element's contents paint — text,
26+
// pseudo content, control values, media pixels — which
27+
// `content-visibility: hidden` skips). `ctx` carries the element's computed
28+
// style, its ::before/::after styles, its inherited opacity, and the quantize
29+
// flag. A reader returns a string that is equal between two samples iff that
30+
// channel did not visibly change; return "" for elements the channel does not
31+
// apply to so ordinary compositions gain no payload.
3232
//
3333
// Signatures are a single opaque string per sample (not a structured array):
3434
// Node only ever needs equality, never per-element diffing. Textual channels
@@ -37,6 +37,17 @@
3737
(function () {
3838
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
3939
const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]);
40+
// SVG containers whose direct content is never painted: <defs> and
41+
// <clipPath> only lend geometry to a referencing element, and <symbol>
42+
// renders only as <use> instances, whose shadow trees querySelectorAll
43+
// cannot reach. Blink happens to report an empty box for their descendants,
44+
// but a never-painted subtree should be excluded by rule, not by one
45+
// engine's bbox behaviour. SVG tag names are case-preserved (`clipPath`),
46+
// hence the lower-cased match.
47+
const UNPAINTED_SVG_CONTAINERS = new Set(["defs", "clippath", "symbol"]);
48+
// A computed stroke that paints nothing: `none`, or a fully transparent
49+
// colour (`transparent` computes to rgba(0, 0, 0, 0)).
50+
const TRANSPARENT_COLOR = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/;
4051
const FNV_OFFSET_BASIS = 2166136261;
4152
const FNV_PRIME = 16777619;
4253
const LIVENESS_POSITION_BUCKET_PX = 2;
@@ -109,17 +120,65 @@
109120
);
110121
}
111122

123+
// Whether `element` starts an unrendered subtree: nothing under it paints
124+
// and nothing under it can feed a painted counter(). `display` is not
125+
// inherited — a child of a display:none parent still computes display:block,
126+
// and a shape inside <defs> computes as painted — so compositionSignature
127+
// propagates this to descendants itself. The platform decides where it can
128+
// (checkVisibility: display:none and skipped contents); the fallback is
129+
// display:none. display:contents has no box of its own but its
130+
// pseudo-elements and children render, so it stays an owner — except as
131+
// the child of a host that skips its contents, where its pseudo-elements
132+
// paint nothing either. The platform check cannot tell that from an
133+
// ordinary display:contents host (both have no box), so the parent's
134+
// skipsContents verdict (`parentSkipsContents`) decides; a display:contents
135+
// child of an off-screen `auto` host is not caught (see
136+
// compositionSignature). Without the platform check a skipped host is
137+
// itself unrendered, so the caller never reaches this with a true
138+
// `parentSkipsContents`. An unpainted SVG container
139+
// (UNPAINTED_SVG_CONTAINERS) is excluded by rule: its shapes have layout
140+
// boxes, so checkVisibility cannot know they never reach the screen.
141+
function startsUnrenderedSubtree(element, style, platformDecides, parentSkipsContents) {
142+
if (UNPAINTED_SVG_CONTAINERS.has(element.tagName.toLowerCase())) return true;
143+
if (style.display === "contents") return parentSkipsContents;
144+
return platformDecides
145+
? !element.checkVisibility(RENDERED_BOX_OPTIONS)
146+
: style.display === "none";
147+
}
148+
149+
function paintsStroke(style) {
150+
const stroke = cssValue(style.stroke);
151+
return (
152+
stroke !== "" &&
153+
!TRANSPARENT_COLOR.test(stroke) &&
154+
Number.parseFloat(style.strokeWidth) > 0 &&
155+
Number.parseFloat(style.strokeOpacity) > 0
156+
);
157+
}
158+
159+
// An SVG geometry element (path/circle/ellipse/rect/line/polyline/polygon)
160+
// with a painted stroke. Chromium's getBoundingClientRect for SVG shapes is
161+
// the object bounding box WITHOUT the stroke, so a straight horizontal or
162+
// vertical connector reports 0 height or 0 width regardless of stroke-width
163+
// even though it is plainly on screen.
164+
function isStrokedShape(element, style) {
165+
return element instanceof SVGGeometryElement && paintsStroke(style);
166+
}
167+
112168
// Visibility floor: checkVisibility (as layout-audit.browser.js
113169
// isVisibleElement's opacity-floor path; its default path skips it and so
114170
// cannot see skipped contents), then display/visibility, then inherited
115-
// opacity, then a non-empty box. Kept local rather than shared because
116-
// layout-audit is also installed and tested on its own; this module owns the
117-
// decision for both motion samplers. The author opt-out (data-layout-ignore /
118-
// data-layout-check=ignore) is NOT applied here: motion-sample reports this
119-
// bit for explicitly asserted selectors, and an assertion naming an element
120-
// outranks a layout-audit opt-out. compositionSignature applies the opt-out
121-
// itself (see there). clip-path is not probed either; it is a channel, so a
122-
// clip-path wipe over a static box counts as motion directly.
171+
// opacity, then a non-empty box — widened by one case: a stroked SVG shape
172+
// whose geometry bbox is degenerate along one axis (see isStrokedShape) is
173+
// on screen even though that floor rejects it. Kept local rather than shared
174+
// because layout-audit is also installed and tested on its own; this module
175+
// owns the decision for both motion samplers. The author opt-out
176+
// (data-layout-ignore / data-layout-check=ignore) is NOT applied here:
177+
// motion-sample reports this bit for explicitly asserted selectors, and an
178+
// assertion naming an element outranks a layout-audit opt-out.
179+
// compositionSignature applies the opt-out itself (see there). clip-path is
180+
// not probed either; it is a channel, so a clip-path wipe over a static box
181+
// counts as motion directly.
123182
// fallow-ignore-next-line complexity
124183
function isVisibleElement(element, style, opacity) {
125184
if (IGNORE_TAGS.has(element.tagName)) return false;
@@ -129,10 +188,13 @@
129188
) {
130189
return false;
131190
}
132-
if (isHiddenStyle(style || getComputedStyle(element))) return false;
191+
const computed = style || getComputedStyle(element);
192+
if (isHiddenStyle(computed)) return false;
133193
if ((opacity === undefined ? opacityChain(element) : opacity) < 0.2) return false;
134194
const rect = element.getBoundingClientRect();
135-
return rect.width > 0.5 && rect.height > 0.5;
195+
if (rect.width > 0.5 && rect.height > 0.5) return true;
196+
// A stroked shape paints along its one non-degenerate axis.
197+
return isStrokedShape(element, computed) && (rect.width > 0.5 || rect.height > 0.5);
136198
}
137199

138200
function foldField(hash, value) {
@@ -190,6 +252,25 @@
190252
return clip ? hashFields([clip]) : "";
191253
}
192254

255+
// `none` and an all-zero list (`0`, `0px 0px`) both render a solid stroke,
256+
// on which the offset has no visible effect.
257+
function dashPattern(style) {
258+
const dashes = cssValue(style.strokeDasharray);
259+
if (!dashes) return "";
260+
return dashes.split(/[\s,]+/).some((dash) => Number.parseFloat(dash) > 0) ? dashes : "";
261+
}
262+
263+
// A "draw the line in" SVG entrance animates stroke-dasharray /
264+
// stroke-dashoffset on a shape whose geometry never changes: no box, no
265+
// opacity, only how much of the stroke is currently dash-visible. Without a
266+
// dash pattern the offset has no visible effect, and without a painted
267+
// stroke neither does, so ordinary shapes stay "".
268+
function strokeDashChannel(element, ctx) {
269+
if (!isStrokedShape(element, ctx.style)) return "";
270+
const dashes = dashPattern(ctx.style);
271+
return dashes ? hashFields([dashes, ctx.style.strokeDashoffset || ""]) : "";
272+
}
273+
193274
// Direct text nodes only: descendants are signed separately, and a hidden
194275
// descendant's text mutation must not masquerade as visible motion.
195276
function textChannel(element) {
@@ -271,14 +352,15 @@
271352
}
272353

273354
// The element's own box still paints when its contents are skipped
274-
// (content-visibility: hidden) — including a checkbox's check glyph; its
275-
// text, pseudo boxes, control value, and replaced content (a canvas/video/img's
276-
// pixels) do not.
355+
// (content-visibility: hidden) — including a checkbox's check glyph and an
356+
// SVG shape's stroke; its text, pseudo boxes, control value, and replaced
357+
// content (a canvas/video/img's pixels) do not.
277358
const BOX_CHANNELS = [
278359
boxChannel,
279360
opacityChannel,
280361
fontAxesChannel,
281362
clipPathChannel,
363+
strokeDashChannel,
282364
controlWidgetChannel,
283365
];
284366
const CONTENT_CHANNELS = [
@@ -390,28 +472,22 @@
390472
const quantize = !!(options && options.quantize);
391473
const parts = [];
392474
const boxOwners = [];
393-
// Unrendered elements (display:none subtrees, skipped contents) paint
394-
// nothing and cannot feed a painted counter(). The platform decides where it
395-
// can; the fallback is display:none and content-visibility:hidden hosts,
396-
// propagated to descendants. display:contents has no box of its own but its
397-
// pseudo-elements and children render, so it stays an owner — except as
398-
// the child of a host that skips its contents, where its pseudo-elements
399-
// paint nothing either. The platform check cannot tell that from an
400-
// ordinary display:contents host (both have no box), so the parent's
401-
// skipsContents verdict decides; a display:contents child of an off-screen
402-
// `auto` host is not caught (see below).
475+
// Unrendered subtrees (see startsUnrenderedSubtree; without the platform
476+
// check, content-visibility:hidden hosts too) paint nothing and cannot feed
477+
// a painted counter(); membership is propagated to descendants here, and
478+
// hosts that skip their contents are remembered so a display:contents
479+
// child of one is pruned as well.
403480
const unrenderedBelow = new Set();
404481
const skippedHosts = new Set();
405482
for (const element of [root, ...root.querySelectorAll("*")]) {
406483
if (IGNORE_TAGS.has(element.tagName)) continue;
407484
const style = getComputedStyle(element);
408485
const parent = element.parentElement;
409486
const platformDecides = typeof element.checkVisibility === "function";
410-
const noBox = platformDecides
411-
? !element.checkVisibility(RENDERED_BOX_OPTIONS)
412-
: style.display === "none";
413-
const boxlessOwner = style.display === "contents" && !skippedHosts.has(parent);
414-
if (unrenderedBelow.has(parent) || (noBox && !boxlessOwner)) {
487+
if (
488+
unrenderedBelow.has(parent) ||
489+
startsUnrenderedSubtree(element, style, platformDecides, skippedHosts.has(parent))
490+
) {
415491
unrenderedBelow.add(element);
416492
continue;
417493
}

0 commit comments

Comments
 (0)