Skip to content

Commit b8abfc7

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 fbec4c6 commit b8abfc7

4 files changed

Lines changed: 269 additions & 6 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` never count.

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

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

330+
// Blink reports a 290x0 box for this path (object bounding box, no stroke),
331+
// so this also pins that a stroked straight connector counts as visible.
332+
it("sees a stroke-dashoffset draw-in on a straight connector", async () => {
333+
await load(
334+
composition(
335+
"#wire { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; }",
336+
'<svg width="640" height="360"><path id="wire" d="M 10 10 L 300 10"/></svg>',
337+
),
338+
);
339+
const before = await sample();
340+
await mutate('document.getElementById("wire").style.strokeDashoffset = "145"');
341+
const after = await sample();
342+
343+
expect(after.sweep).not.toBe(before.sweep);
344+
expect(after.liveness).not.toBe(before.liveness);
345+
});
346+
347+
// Blink already reports an empty box inside <defs> / <clipPath>; this pins
348+
// that the platform and the classifier agree, not the container rule alone.
349+
it("ignores stroke-dash motion under display:none and inside <defs> / <clipPath>", async () => {
350+
await load(
351+
composition(
352+
"path { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; } #offstage { display: none; }",
353+
`<svg width="640" height="360">
354+
<g id="offstage"><path id="hidden" d="M 10 10 L 300 10"/></g>
355+
<defs><path id="template" d="M 10 20 L 300 20"/></defs>
356+
<clipPath id="reveal"><path id="clip" d="M 10 30 L 300 30"/></clipPath>
357+
<rect id="anchor" x="10" y="100" width="200" height="50" fill="#f00"/>
358+
</svg>`,
359+
),
360+
);
361+
const before = await sample();
362+
await mutate(
363+
'for (const id of ["hidden", "template", "clip"]) document.getElementById(id).style.strokeDashoffset = "0"',
364+
);
365+
const after = await sample();
366+
367+
expect(after).toEqual(before);
368+
});
369+
330370
it("sees textarea value and checkbox indeterminate changes", async () => {
331371
await load(
332372
composition(

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

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
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 ELEMENT_CHANNELS. `ctx` carries the element's
2424
// computed style, its ::before/::after styles, its inherited opacity, and the
2525
// quantize flag. A reader returns a string that is equal between two samples
@@ -33,6 +33,17 @@
3333
(function () {
3434
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
3535
const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]);
36+
// SVG containers whose direct content is never painted: <defs> and
37+
// <clipPath> only lend geometry to a referencing element, and <symbol>
38+
// renders only as <use> instances, whose shadow trees querySelectorAll
39+
// cannot reach. Blink happens to report an empty box for their descendants,
40+
// but a never-painted subtree should be excluded by rule, not by one
41+
// engine's bbox behaviour. SVG tag names are case-preserved (`clipPath`),
42+
// hence the lower-cased match.
43+
const UNPAINTED_SVG_CONTAINERS = new Set(["defs", "clippath", "symbol"]);
44+
// A computed stroke that paints nothing: `none`, or a fully transparent
45+
// colour (`transparent` computes to rgba(0, 0, 0, 0)).
46+
const TRANSPARENT_COLOR = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/;
3647
const FNV_OFFSET_BASIS = 2166136261;
3748
const FNV_PRIME = 16777619;
3849
const LIVENESS_POSITION_BUCKET_PX = 2;
@@ -71,7 +82,35 @@
7182
);
7283
}
7384

74-
// Same visibility floor as layout-audit.browser.js isVisibleElement's default.
85+
// `display` is not inherited: a child of a display:none parent still
86+
// computes display:block, and a shape inside <defs> computes as painted. A
87+
// subtree starts here when nothing under it can generate a box.
88+
function startsHiddenSubtree(element, style) {
89+
return style.display === "none" || UNPAINTED_SVG_CONTAINERS.has(element.tagName.toLowerCase());
90+
}
91+
92+
function paintsStroke(style) {
93+
const stroke = cssValue(style.stroke);
94+
return (
95+
stroke !== "" &&
96+
!TRANSPARENT_COLOR.test(stroke) &&
97+
Number.parseFloat(style.strokeWidth) > 0 &&
98+
Number.parseFloat(style.strokeOpacity) > 0
99+
);
100+
}
101+
102+
// An SVG geometry element (path/circle/ellipse/rect/line/polyline/polygon)
103+
// with a painted stroke. Chromium's getBoundingClientRect for SVG shapes is
104+
// the object bounding box WITHOUT the stroke, so a straight horizontal or
105+
// vertical connector reports 0 height or 0 width regardless of stroke-width
106+
// even though it is plainly on screen.
107+
function isStrokedShape(element, style) {
108+
return element instanceof SVGGeometryElement && paintsStroke(style);
109+
}
110+
111+
// layout-audit.browser.js isVisibleElement's default floor, widened by one
112+
// case: a stroked SVG shape whose geometry bbox is degenerate along one axis
113+
// (see isStrokedShape) is on screen even though that floor rejects it.
75114
// The author opt-out (data-layout-ignore / data-layout-check=ignore) is NOT
76115
// applied here: motion-sample reports this bit for explicitly asserted
77116
// selectors, and an assertion naming an element outranks a layout-audit
@@ -81,10 +120,13 @@
81120
// fallow-ignore-next-line complexity
82121
function isVisibleElement(element, style, opacity) {
83122
if (IGNORE_TAGS.has(element.tagName)) return false;
84-
if (isHiddenStyle(style || getComputedStyle(element))) return false;
123+
const computed = style || getComputedStyle(element);
124+
if (isHiddenStyle(computed)) return false;
85125
if ((opacity === undefined ? opacityChain(element) : opacity) < 0.2) return false;
86126
const rect = element.getBoundingClientRect();
87-
return rect.width > 0.5 && rect.height > 0.5;
127+
if (rect.width > 0.5 && rect.height > 0.5) return true;
128+
// A stroked shape paints along its one non-degenerate axis.
129+
return isStrokedShape(element, computed) && (rect.width > 0.5 || rect.height > 0.5);
88130
}
89131

90132
function foldField(hash, value) {
@@ -142,6 +184,25 @@
142184
return clip ? hashFields([clip]) : "";
143185
}
144186

187+
// `none` and an all-zero list (`0`, `0px 0px`) both render a solid stroke,
188+
// on which the offset has no visible effect.
189+
function dashPattern(style) {
190+
const dashes = cssValue(style.strokeDasharray);
191+
if (!dashes) return "";
192+
return dashes.split(/[\s,]+/).some((dash) => Number.parseFloat(dash) > 0) ? dashes : "";
193+
}
194+
195+
// A "draw the line in" SVG entrance animates stroke-dasharray /
196+
// stroke-dashoffset on a shape whose geometry never changes: no box, no
197+
// opacity, only how much of the stroke is currently dash-visible. Without a
198+
// dash pattern the offset has no visible effect, and without a painted
199+
// stroke neither does, so ordinary shapes stay "".
200+
function strokeDashChannel(element, ctx) {
201+
if (!isStrokedShape(element, ctx.style)) return "";
202+
const dashes = dashPattern(ctx.style);
203+
return dashes ? hashFields([dashes, ctx.style.strokeDashoffset || ""]) : "";
204+
}
205+
145206
// Direct text nodes only: descendants are signed separately, and a hidden
146207
// descendant's text mutation must not masquerade as visible motion.
147208
function textChannel(element) {
@@ -228,6 +289,7 @@
228289
opacityChannel,
229290
fontAxesChannel,
230291
clipPathChannel,
292+
strokeDashChannel,
231293
textChannel,
232294
controlChannel,
233295
generatedContentChannel,
@@ -326,7 +388,7 @@
326388
for (const element of [root, ...root.querySelectorAll("*")]) {
327389
if (IGNORE_TAGS.has(element.tagName)) continue;
328390
const style = getComputedStyle(element);
329-
if (style.display === "none" || hiddenSubtree.has(element.parentElement)) {
391+
if (startsHiddenSubtree(element, style) || hiddenSubtree.has(element.parentElement)) {
330392
hiddenSubtree.add(element);
331393
continue;
332394
}

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

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const BASE_STYLE: StyleOverride = {
3232
opacity: "1",
3333
fontVariationSettings: "normal",
3434
clipPath: "none",
35+
stroke: "none",
36+
strokeWidth: "1px",
37+
strokeOpacity: "1",
38+
strokeDasharray: "none",
39+
strokeDashoffset: "0px",
3540
counterReset: "none",
3641
counterIncrement: "none",
3742
counterSet: "none",
@@ -117,6 +122,14 @@ const ROOT = { left: 0, top: 0, width: 640, height: 360 };
117122
const COUNTDOWN = { left: 280, top: 140, width: 80, height: 48 };
118123
const ZERO_BOX = { left: 0, top: 0, width: 0, height: 0 };
119124
const COUNTER_CONSUMER = { after: { content: "counter(countdown)" } };
125+
// The real Chromium bbox of a horizontal stroked path: the object bounding
126+
// box excludes the stroke, so height is 0 regardless of stroke-width.
127+
const FLAT_CONNECTOR = { left: 10, top: 10, width: 290, height: 0 };
128+
const DASHED_STROKE: StyleOverride = {
129+
stroke: "rgb(0, 0, 0)",
130+
strokeWidth: "4px",
131+
strokeDasharray: "290px",
132+
};
120133

121134
afterEach(() => {
122135
vi.restoreAllMocks();
@@ -261,6 +274,153 @@ describe("motion-signature.browser media and geometry channels", () => {
261274
expect(collect()).toBe(collect());
262275
});
263276

277+
// A "draw the line in" SVG entrance (stroke-dashoffset animating on a path
278+
// whose `d` never changes) shares the font-axis blind spot: no box change,
279+
// no opacity change. The connector rect is deliberately degenerate (height
280+
// 0) so the test also pins that a stroked straight line passes the
281+
// visibility gate at all — the common real shape for this animation.
282+
it("changes the sweep fingerprint when only stroke-dashoffset moves on a straight connector", () => {
283+
document.body.innerHTML = `
284+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
285+
<svg id="diagram"><path id="connector" d="M 10 10 L 300 10" /></svg>
286+
</div>
287+
`;
288+
let dashOffset = "290px";
289+
installFixture({
290+
rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR },
291+
styles: {
292+
connector: {
293+
...DASHED_STROKE,
294+
get strokeDashoffset() {
295+
return dashOffset;
296+
},
297+
} as StyleOverride,
298+
},
299+
});
300+
301+
const collect = installScript();
302+
const hidden = collect();
303+
dashOffset = "145px"; // half drawn in
304+
const half = collect();
305+
dashOffset = "0px"; // fully revealed
306+
const drawn = collect();
307+
308+
expect(half).not.toBe(hidden);
309+
expect(drawn).not.toBe(half);
310+
});
311+
312+
it("keeps the sweep fingerprint identical when nothing moves, stroke dash included", () => {
313+
document.body.innerHTML = `
314+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
315+
<svg id="diagram"><path id="connector" d="M 10 10 L 300 10" /></svg>
316+
</div>
317+
`;
318+
installFixture({
319+
rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR },
320+
styles: { connector: { ...DASHED_STROKE, strokeDashoffset: "0px" } },
321+
});
322+
323+
const collect = installScript();
324+
325+
expect(collect()).toBe(collect());
326+
});
327+
328+
// `display` is not inherited: the fake getComputedStyle reports the child as
329+
// display:block, so only the classifier's ancestor walk can hide it.
330+
it("ignores stroke-dash motion under a display:none ancestor", () => {
331+
document.body.innerHTML = `
332+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
333+
<svg id="diagram"><g id="offstage"><path id="connector" d="M 10 10 L 300 10" /></g></svg>
334+
</div>
335+
`;
336+
let dashOffset = "290px";
337+
installFixture({
338+
rects: { root: ROOT, diagram: ROOT, offstage: ROOT, connector: FLAT_CONNECTOR },
339+
styles: {
340+
offstage: { display: "none" },
341+
connector: {
342+
...DASHED_STROKE,
343+
get strokeDashoffset() {
344+
return dashOffset;
345+
},
346+
} as StyleOverride,
347+
},
348+
});
349+
350+
const collect = installScript();
351+
const before = collect();
352+
dashOffset = "0px";
353+
354+
expect(collect()).toBe(before);
355+
});
356+
357+
// The stroke is never painted, so the dash pattern on it is not motion — and
358+
// the connector must not report as visible on the strength of its stroke.
359+
it("ignores stroke-dash motion on a stroke-opacity:0 connector", () => {
360+
document.body.innerHTML = `
361+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
362+
<svg id="diagram"><path id="connector" d="M 10 10 L 300 10" /></svg>
363+
</div>
364+
`;
365+
let dashOffset = "290px";
366+
installFixture({
367+
rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR },
368+
styles: {
369+
connector: {
370+
...DASHED_STROKE,
371+
strokeOpacity: "0",
372+
get strokeDashoffset() {
373+
return dashOffset;
374+
},
375+
} as StyleOverride,
376+
},
377+
});
378+
379+
const collect = installScript();
380+
const before = collect();
381+
dashOffset = "0px";
382+
383+
expect(collect()).toBe(before);
384+
});
385+
386+
// Blink reports an empty box for descendants of these containers; the
387+
// fixture deliberately gives them a rendered path's box so this pins the
388+
// container rule itself rather than the box gate.
389+
it("ignores stroke-dash motion inside <defs> and <clipPath>", () => {
390+
document.body.innerHTML = `
391+
<div id="root" data-composition-id="main" data-width="640" data-height="360">
392+
<svg id="diagram">
393+
<defs><path id="template" d="M 10 10 L 300 10" /></defs>
394+
<clipPath id="reveal"><path id="clip" d="M 10 20 L 300 20" /></clipPath>
395+
</svg>
396+
</div>
397+
`;
398+
let dashOffset = "290px";
399+
const animated = {
400+
...DASHED_STROKE,
401+
get strokeDashoffset() {
402+
return dashOffset;
403+
},
404+
} as StyleOverride;
405+
installFixture({
406+
rects: {
407+
root: ROOT,
408+
diagram: ROOT,
409+
defs: ROOT,
410+
reveal: ROOT,
411+
template: FLAT_CONNECTOR,
412+
clip: { left: 10, top: 20, width: 290, height: 0 },
413+
},
414+
styles: { template: animated, clip: animated },
415+
});
416+
417+
const collect = installScript();
418+
const before = collect();
419+
dashOffset = "0px";
420+
421+
expect(collect()).toBe(before);
422+
});
423+
264424
it("changes the sweep fingerprint when only clip-path moves", () => {
265425
document.body.innerHTML = `
266426
<div id="root" data-composition-id="main" data-width="640" data-height="360">

0 commit comments

Comments
 (0)