domotion-svg 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,8 @@ import { isTransparentBackground } from "../utils/transparent-background.js";
10
10
  import { rootSvgA11y } from "../render/format.js";
11
11
  import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
12
12
  import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
13
- import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
13
+ import { KEYFRAME_EPSILON, cullOverlapPct, padAfter, padBefore } from "../utils/keyframe-pad.js";
14
+ import { interpolateCssValue, resolveEasing } from "./easing.js";
14
15
  /**
15
16
  * Emit one magic-move frame (DM-898): the frame-i blob (held [start..holdEnd],
16
17
  * hard-cut out), the bridge composite (visible only across [holdEnd..transEnd]),
@@ -30,29 +31,32 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
30
31
  const beforeH = padBefore(hNum, KEYFRAME_EPSILON.cull, 3);
31
32
  const afterT = padAfter(tNum, KEYFRAME_EPSILON.cull, 3);
32
33
  // Frame i blob: visible only during its hold, hard-cut out at hold end.
34
+ // DM-1511: opacity does the cut; a SEPARATE wide-overlap `fd-` `visibility`
35
+ // track handles paint-culling so Firefox can't flash a transparent gap at the
36
+ // hand-off (it composites `visibility` off the opacity clock).
33
37
  groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
34
38
  keyframes.push(`
35
39
  @keyframes fv-${i} {
36
- 0% { opacity: 0; visibility: hidden; }
37
- ${beforeS}% { opacity: 0; visibility: hidden; }
38
- ${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
39
- ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
40
- ${afterH}% { opacity: 0; visibility: hidden; }
41
- 100% { opacity: 0; visibility: hidden; }
42
- }
43
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
40
+ 0% { opacity: 0; }
41
+ ${beforeS}% { opacity: 0; }
42
+ ${sNum.toFixed(3)}% { opacity: 1; }
43
+ ${hNum.toFixed(3)}% { opacity: 1; }
44
+ ${afterH}% { opacity: 0; }
45
+ 100% { opacity: 0; }
46
+ }${buildDisplayKeyframes(`fd-${i}`, startPct, holdEndPct, totalSec)}
47
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite, fd-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
44
48
  // Bridge composite: visible during the transition window only.
45
49
  groups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
46
50
  keyframes.push(`
47
51
  @keyframes mmv-${i} {
48
- 0% { opacity: 0; visibility: hidden; }
49
- ${beforeH}% { opacity: 0; visibility: hidden; }
50
- ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
51
- ${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
52
- ${afterT}% { opacity: 0; visibility: hidden; }
53
- 100% { opacity: 0; visibility: hidden; }
54
- }
55
- .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
52
+ 0% { opacity: 0; }
53
+ ${beforeH}% { opacity: 0; }
54
+ ${hNum.toFixed(3)}% { opacity: 1; }
55
+ ${tNum.toFixed(3)}% { opacity: 1; }
56
+ ${afterT}% { opacity: 0; }
57
+ 100% { opacity: 0; }
58
+ }${buildDisplayKeyframes(`mmd-${i}`, holdEndPct, transEndPct, totalSec)}
59
+ .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s step-end infinite, mmd-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
56
60
  // Per-element slide / fade keyframes within the window (linear interp).
57
61
  // The composite is only visible [holdEnd..transEnd], so the held values
58
62
  // outside that window are never painted — they just pin the endpoints.
@@ -127,16 +131,19 @@ holdToEnd) {
127
131
  const endNum = parseFloat(transEndPct);
128
132
  const beforeStart = padBefore(startNum, KEYFRAME_EPSILON.cull, 3);
129
133
  const afterEnd = padAfter(endNum, KEYFRAME_EPSILON.cull, 3);
134
+ // DM-1511: opacity does the cut (tight overlap, Firefox-safe); `visibility`
135
+ // is a SEPARATE wide-overlap paint-cull track so Firefox can't flash a
136
+ // transparent gap at the hand-off. (Previously one combined keyframe.)
130
137
  keyframes.push(`
131
138
  @keyframes fv-${i} {
132
- 0% { opacity: 0; visibility: hidden; }
133
- ${beforeStart}% { opacity: 0; visibility: hidden; }
134
- ${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
135
- ${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
136
- ${afterEnd}% { opacity: 0; visibility: hidden; }
137
- 100% { opacity: 0; visibility: hidden; }
138
- }
139
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
139
+ 0% { opacity: 0; }
140
+ ${beforeStart}% { opacity: 0; }
141
+ ${startNum.toFixed(3)}% { opacity: 1; }
142
+ ${endNum.toFixed(3)}% { opacity: 1; }
143
+ ${afterEnd}% { opacity: 0; }
144
+ 100% { opacity: 0; }
145
+ }${buildDisplayKeyframes(`fd-${i}`, startPct, transEndPct, totalSec)}
146
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite, fd-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
140
147
  }
141
148
  else {
142
149
  const prevEnd = i > 0
@@ -151,7 +158,7 @@ holdToEnd) {
151
158
  keyframes.push(`
152
159
  @keyframes fv-${i} {
153
160
  ${offSeg}${startPct}, 100% { opacity: 1; }
154
- }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, "100")}
161
+ }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, "100", totalSec)}
155
162
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
156
163
  }
157
164
  else {
@@ -159,7 +166,7 @@ holdToEnd) {
159
166
  @keyframes fv-${i} {
160
167
  0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
161
168
  ${startPct}, ${holdEndPct} { opacity: 1; }
162
- }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
169
+ }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct, totalSec)}
163
170
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
164
171
  }
165
172
  }
@@ -735,16 +742,26 @@ function pctNum(ms, total) {
735
742
  * `visibleStartPct` / `visibleEndPct` accept either a numeric-style string
736
743
  * (`"12.34"`) or one with a trailing `%` (`"12.34%"`) — `pct()` returns the
737
744
  * latter and the unmerged-path keyframes feed either form.
745
+ *
746
+ * DM-1511: the visible window is padded OUTWARD by a wide wall-clock margin
747
+ * (`cullOverlapPct`) so adjacent frames' paint windows OVERLAP. The visual cut
748
+ * is driven by the sibling `opacity` animation (`fv-*`), which Firefox
749
+ * composites in lock-step; `visibility` is only the paint-cull gate, and a
750
+ * sub-millisecond visibility hand-off flashed a transparent gap in Firefox at
751
+ * cut points. Over-wide visibility is harmless — the frame is `opacity:0`
752
+ * outside its true window — but removes any instant where both neighbors are
753
+ * `visibility:hidden`. `totalSec` is the scene length used to size the margin.
738
754
  */
739
- function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
755
+ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct, totalSec) {
740
756
  // DM-641: kept the function name for callers but the toggle is now on
741
757
  // `visibility`, not `display`, for the same reason as `fv-${i}` above —
742
758
  // animating `display` away from an element starting `display: none` never
743
759
  // ticks in Chromium.
744
- const start = parseFloat(String(visibleStartPct));
745
- const end = parseFloat(String(visibleEndPct));
746
- const startMinus = padBefore(start, KEYFRAME_EPSILON.display, 3);
747
- const endPlus = padAfter(end, KEYFRAME_EPSILON.display, 3);
760
+ const margin = cullOverlapPct(totalSec * 1000);
761
+ const start = Math.max(0, parseFloat(String(visibleStartPct)) - margin);
762
+ const end = Math.min(100, parseFloat(String(visibleEndPct)) + margin);
763
+ const startMinus = padBefore(start, KEYFRAME_EPSILON.cull, 3);
764
+ const endPlus = padAfter(end, KEYFRAME_EPSILON.cull, 3);
748
765
  return `
749
766
  @keyframes ${name} {
750
767
  0% { visibility: hidden; }
@@ -816,7 +833,7 @@ holdToEnd = false) {
816
833
  @keyframes fv-${i} {
817
834
  ${fvEnter}
818
835
  100% { opacity: 1; }
819
- }${buildDisplayKeyframes(`fd-${i}`, visStart, "100")}
836
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, "100", totalSec)}
820
837
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
821
838
  .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`;
822
839
  }
@@ -832,7 +849,7 @@ holdToEnd = false) {
832
849
  ${fvEnter}
833
850
  ${transEndPct} { opacity: 1; }
834
851
  ${padAfter(parseFloat(transEndPct), KEYFRAME_EPSILON.slide, 2)}%, 100% { opacity: 0; }
835
- }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
852
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd, totalSec)}
836
853
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
837
854
  .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`;
838
855
  }
@@ -914,6 +931,34 @@ function offsetForDirection(dir, w, h) {
914
931
  * visible (+ `delay`), animates to `to` over `duration`, then holds at `to`
915
932
  * until the loop restarts.
916
933
  */
934
+ /**
935
+ * DM-1512/1513: compose one keyframe stop's declaration from several property
936
+ * "parts" (a value per fused track). Transform-family tracks
937
+ * (`translateX`/`translateY`/`scale`/raw `transform`) collapse into a SINGLE
938
+ * `transform:` — two `transform:` declarations would clobber each other — while
939
+ * other properties (`opacity`, `clip-path`, …) emit alongside.
940
+ */
941
+ function composeAnimStop(parts) {
942
+ const transforms = [];
943
+ const others = [];
944
+ for (const p of parts) {
945
+ if (p.property === "translateX")
946
+ transforms.push(`translateX(${p.val})`);
947
+ else if (p.property === "translateY")
948
+ transforms.push(`translateY(${p.val})`);
949
+ else if (p.property === "scale")
950
+ transforms.push(`scale(${p.val})`);
951
+ else if (p.property === "transform")
952
+ transforms.push(p.val);
953
+ else if (p.property === "clipPath")
954
+ others.push(`clip-path: ${p.val};`);
955
+ else
956
+ others.push(`${p.property}: ${p.val};`);
957
+ }
958
+ if (transforms.length > 0)
959
+ others.push(`transform: ${transforms.join(" ")};`);
960
+ return others.join(" ");
961
+ }
917
962
  function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
918
963
  const totalMs = totalSec * 1000;
919
964
  const out = [];
@@ -930,17 +975,9 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
930
975
  const endMs = startMs + a.duration;
931
976
  const startPct = (startMs / totalMs) * 100;
932
977
  const endPct = (endMs / totalMs) * 100;
933
- const propValue = (val) => {
934
- if (a.property === "translateX")
935
- return `transform: translateX(${val});`;
936
- if (a.property === "translateY")
937
- return `transform: translateY(${val});`;
938
- if (a.property === "scale")
939
- return `transform: scale(${val});`;
940
- if (a.property === "clipPath")
941
- return `clip-path: ${val};`;
942
- return `${a.property}: ${val};`;
943
- };
978
+ const tracks = [{ property: a.property, from: a.from, to: a.to }, ...(a.fuse ?? [])];
979
+ const declFrom = composeAnimStop(tracks.map((t) => ({ property: t.property, val: t.from })));
980
+ const declTo = composeAnimStop(tracks.map((t) => ({ property: t.property, val: t.to })));
944
981
  // DM-1297: SVG transforms are origin-(0,0); a `transformOrigin` makes a
945
982
  // scale/rotate/translate resolve about the element's OWN box (e.g. a
946
983
  // center-origin scale-pop) instead of the SVG origin. `transform-box:
@@ -949,6 +986,58 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
949
986
  ? ` transform-box: fill-box; transform-origin: ${a.transformOrigin};`
950
987
  : "";
951
988
  const animName = `f${i}-${a.animId}-${ai}`;
989
+ // DM-1517: when a fused track carries its OWN duration/delay/easing, the
990
+ // tracks no longer share one `animation-timing-function`, so we can't emit
991
+ // a from/to pair. Instead SAMPLE each track's eased value over its own
992
+ // window at many stops and emit them with `linear` timing (easing baked
993
+ // in) — still ONE animation / one timeline. Only for one-shot reveals
994
+ // (`repeat` loops keep the shared-timing cycle form).
995
+ const sampledTracks = a.fuse ?? [];
996
+ const needsSampling = a.repeat == null
997
+ && sampledTracks.some((t) => t.duration != null || t.delay != null || t.easing != null);
998
+ if (needsSampling) {
999
+ const win = tracks.map((t) => {
1000
+ const tStart = frameStartMs + (t.delay ?? delay);
1001
+ const tEnd = tStart + (t.duration ?? a.duration);
1002
+ return {
1003
+ property: t.property, from: t.from, to: t.to,
1004
+ startPct: (tStart / totalMs) * 100,
1005
+ endPct: (tEnd / totalMs) * 100,
1006
+ ease: resolveEasing(t.easing ?? a.easing),
1007
+ };
1008
+ });
1009
+ const minStart = Math.min(...win.map((w) => w.startPct));
1010
+ const maxEnd = Math.max(...win.map((w) => w.endPct));
1011
+ // Stops: 0/100 + every track boundary + a fine grid across the active
1012
+ // span so each track's eased curve is well approximated.
1013
+ const stopSet = new Set([0, 100]);
1014
+ for (const w of win) {
1015
+ stopSet.add(w.startPct);
1016
+ stopSet.add(w.endPct);
1017
+ }
1018
+ const STEP = 2;
1019
+ for (let p = minStart; p < maxEnd; p += STEP)
1020
+ stopSet.add(p);
1021
+ const stops = [...stopSet].filter((p) => p >= 0 && p <= 100).sort((x, y) => x - y);
1022
+ const seen = new Set();
1023
+ const body = stops.map((p) => {
1024
+ const pctStr = p.toFixed(3);
1025
+ if (seen.has(pctStr))
1026
+ return "";
1027
+ seen.add(pctStr);
1028
+ const parts = win.map((w) => {
1029
+ const span = w.endPct - w.startPct;
1030
+ const localT = span > 0 ? Math.min(1, Math.max(0, (p - w.startPct) / span)) : (p >= w.endPct ? 1 : 0);
1031
+ return { property: w.property, val: interpolateCssValue(w.from, w.to, w.ease(localT)) };
1032
+ });
1033
+ return ` ${pctStr}% { ${composeAnimStop(parts)} }`;
1034
+ }).filter((s) => s !== "").join("\n");
1035
+ out.push(` @keyframes ${animName} {
1036
+ ${body}
1037
+ }
1038
+ .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s linear infinite;${originDecl} }`);
1039
+ continue;
1040
+ }
952
1041
  if (a.repeat != null) {
953
1042
  // DM-869: repeating animation (blink / pulse / breathe). The keyframe is
954
1043
  // a single from→to cycle on the animation's own `duration` clock, looped
@@ -971,8 +1060,8 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
971
1060
  const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
972
1061
  const direction = a.alternate === true ? " alternate" : "";
973
1062
  out.push(` @keyframes ${animName} {
974
- 0% { ${propValue(a.from)} }
975
- 100% { ${propValue(a.to)} }
1063
+ 0% { ${declFrom} }
1064
+ 100% { ${declTo} }
976
1065
  }
977
1066
  .anim-${a.animId} { animation: ${animName} ${a.duration}ms ${easing} ${startMs.toFixed(0)}ms ${iterations}${direction} both;${originDecl} }`);
978
1067
  }
@@ -981,10 +1070,10 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
981
1070
  // [startPct, endPct], hold `to` afterwards, mapped onto the global scene
982
1071
  // clock so it replays in sync each scene loop.
983
1072
  out.push(` @keyframes ${animName} {
984
- 0% { ${propValue(a.from)} }
985
- ${startPct.toFixed(3)}% { ${propValue(a.from)} }
986
- ${endPct.toFixed(3)}% { ${propValue(a.to)} }
987
- 100% { ${propValue(a.to)} }
1073
+ 0% { ${declFrom} }
1074
+ ${startPct.toFixed(3)}% { ${declFrom} }
1075
+ ${endPct.toFixed(3)}% { ${declTo} }
1076
+ 100% { ${declTo} }
988
1077
  }
989
1078
  .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s ${easing} infinite;${originDecl} }`);
990
1079
  }
@@ -267,7 +267,7 @@ export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, c
267
267
  kf.push(`@keyframes ${posName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{transform:translate(${num(p.x)}px,${num(p.y)}px)}`).join("")}}`);
268
268
  const posAnim = `${posName} ${totalSec}s linear infinite`;
269
269
  // Pulse fragments — one per click; each pushes its own keyframes into `kf`.
270
- const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf)).join("\n");
270
+ const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf, totalDurationMs)).join("\n");
271
271
  let pointerGroup;
272
272
  if (cursorTimeline != null && cursorTimeline.length > 0) {
273
273
  // DM-1106: one glyph per distinct keyword, each toggled by a DISCRETE opacity
@@ -303,28 +303,49 @@ ${pulseMarkup}
303
303
  }
304
304
  /** Build the SVG fragment for a single click pulse, pushing its keyframes into
305
305
  * `kf`. The expanding ring is `transform:scale` with `non-scaling-stroke` (so
306
- * it matches the old SMIL `r` growth but on the CSS timeline), played once at
307
- * its click time and frozen afterward (`forwards`), like the prior single-fire
308
- * SMIL pulse. */
309
- function buildPulseFragment(c, idx, uid, kf) {
310
- const beginSec = (c.t / 1000).toFixed(3);
311
- const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
306
+ * it matches the old SMIL `r` growth but on the CSS timeline).
307
+ *
308
+ * DM-1510: the pulse animation spans the WHOLE loop (`totalDurationMs`) and runs
309
+ * `infinite`, exactly like the cursor position track — NOT a `durSec`-long
310
+ * one-shot with a start delay. The pulse curve occupies its click window
311
+ * `[c.t, c.t + pulseDurationMs]` as a fraction of the loop and holds invisible
312
+ * (opacity 0, scale reset) the rest of the loop. A one-shot `forwards`
313
+ * animation only played on the first loop iteration, so the click rings
314
+ * vanished after the SVG looped even though the cursor kept moving. */
315
+ function buildPulseFragment(c, idx, uid, kf, totalDurationMs) {
316
+ const totalSec = totalDurationMs / 1000;
317
+ const durMs = c.style.pulseDurationMs;
312
318
  const r0 = 4;
313
319
  const r1 = c.style.pulseRadius;
314
320
  const innerR = r1 * 0.55;
315
321
  const outerName = `co-pulse-${uid}-${idx}o`;
316
322
  const innerName = `co-pulse-${uid}-${idx}i`;
317
- kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.9}100%{transform:scale(${num(r1 / r0)});opacity:0}}`);
318
- kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.95}100%{transform:scale(${num((r1 - 1) / r0)});opacity:0}}`);
319
- const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${durSec}s linear ${beginSec}s 1 forwards`;
323
+ // Fractions of the full loop where this pulse's window lands. The window's
324
+ // start/peak/end map to the old one-shot's 0% / 15% / 100% keyframes.
325
+ const fStart = c.t / totalDurationMs;
326
+ const fPeak = Math.min(1, (c.t + 0.15 * durMs) / totalDurationMs);
327
+ const fEnd = Math.min(1, (c.t + durMs) / totalDurationMs);
328
+ // Hold scale(1)+opacity 0 before the click (only needed when the click is not
329
+ // at t=0) and after the window (reset for the next loop, unless the window
330
+ // already reaches the loop end).
331
+ const lead = fStart > 0 ? `${pct(fStart)}{transform:scale(1);opacity:0}` : "";
332
+ const tail = fEnd < 1 ? `100%{transform:scale(1);opacity:0}` : "";
333
+ const peak = (op) => (fPeak < fEnd ? `${pct(fPeak)}{opacity:${op}}` : "");
334
+ kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.9)}${pct(fEnd)}{transform:scale(${num(r1 / r0)});opacity:0}${tail}}`);
335
+ kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.95)}${pct(fEnd)}{transform:scale(${num((r1 - 1) / r0)});opacity:0}${tail}}`);
336
+ const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${totalSec}s linear infinite`;
320
337
  // Right-half-disc fill for secondary clicks.
321
338
  let secondaryHalf = "";
322
339
  if (c.button === "secondary") {
323
340
  const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
324
341
  const halfName = `co-pulse-${uid}-${idx}h`;
325
- kf.push(`@keyframes ${halfName}{0%{opacity:0}20%{opacity:1}100%{opacity:0}}`);
342
+ const fHalfPeak = Math.min(1, (c.t + 0.20 * durMs) / totalDurationMs);
343
+ const leadH = fStart > 0 ? `${pct(fStart)}{opacity:0}` : "";
344
+ const tailH = fEnd < 1 ? `100%{opacity:0}` : "";
345
+ const peakH = fHalfPeak < fEnd ? `${pct(fHalfPeak)}{opacity:1}` : "";
346
+ kf.push(`@keyframes ${halfName}{0%{opacity:0}${leadH}${peakH}${pct(fEnd)}{opacity:0}${tailH}}`);
326
347
  secondaryHalf = `
327
- <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${durSec}s linear ${beginSec}s 1 forwards" />`;
348
+ <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${totalSec}s linear infinite" />`;
328
349
  }
329
350
  return ` <g class="cursor-click cursor-click-${idx}">
330
351
  <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0" style="${ringStyle(outerName)}" />
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Easing evaluation + CSS-value interpolation for BAKED keyframe sampling
3
+ * (DM-1517). Used by the animator when a fused animation's tracks have different
4
+ * windows/easings and so can't share one `animation-timing-function`: instead of
5
+ * a from→to pair, the animator samples each track's eased value at many stops
6
+ * and emits them with `linear` timing (the easing is baked into the values), so
7
+ * several property tracks with independent timing still animate as ONE CSS
8
+ * animation — one timeline, immune to Firefox's off-main-thread desync
9
+ * (docs/84). Pure functions, no I/O.
10
+ */
11
+ /** A normalized easing: progress `t` in [0,1] → eased output in [0,1]. */
12
+ export type EasingFn = (t: number) => number;
13
+ /** Solve a CSS `cubic-bezier(x1,y1,x2,y2)` for y at x=t (endpoints (0,0),(1,1)). */
14
+ export declare function cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn;
15
+ /**
16
+ * Parse a CSS easing string into an `EasingFn`. Handles the named keywords and
17
+ * `cubic-bezier(a,b,c,d)`. `step`/`steps()` and anything unrecognized fall back
18
+ * to `linear` — the sampled path is for smooth curves; discrete steps don't need
19
+ * baking (they'd be authored as explicit stops).
20
+ */
21
+ export declare function resolveEasing(css: string | undefined): EasingFn;
22
+ /**
23
+ * Interpolate between two CSS value strings at `t` in [0,1] by matching their
24
+ * numeric tokens pairwise and lerping each, keeping the surrounding literal
25
+ * skeleton. Handles the value shapes the intra-frame animator produces —
26
+ * `"0.3"`→`"1"` (scale), `"-0.6em"`→`"0em"` (translate), `"240px"`→`"0px"`,
27
+ * `"inset(-10% 100% -10% 0)"`→`"inset(-10% 0% -10% 0)"` (clip-path). If the two
28
+ * strings don't share the same non-numeric skeleton (e.g. different units), it
29
+ * can't be smoothly interpolated, so it steps at the midpoint.
30
+ */
31
+ export declare function interpolateCssValue(from: string, to: string, t: number): string;
Binary file
@@ -259,5 +259,22 @@ export declare const intraFrameAnimationSchema: z.ZodObject<{
259
259
  delay: z.ZodOptional<z.ZodNumber>;
260
260
  repeat: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"infinite">]>>;
261
261
  alternate: z.ZodOptional<z.ZodBoolean>;
262
+ fuse: z.ZodOptional<z.ZodArray<z.ZodObject<{
263
+ property: z.ZodEnum<{
264
+ width: "width";
265
+ clipPath: "clipPath";
266
+ height: "height";
267
+ transform: "transform";
268
+ opacity: "opacity";
269
+ scale: "scale";
270
+ translateX: "translateX";
271
+ translateY: "translateY";
272
+ }>;
273
+ from: z.ZodString;
274
+ to: z.ZodString;
275
+ duration: z.ZodOptional<z.ZodNumber>;
276
+ delay: z.ZodOptional<z.ZodNumber>;
277
+ easing: z.ZodOptional<z.ZodString>;
278
+ }, z.core.$strip>>>;
262
279
  }, z.core.$strip>;
263
280
  export type IntraFrameAnimation = z.infer<typeof intraFrameAnimationSchema>;
@@ -222,4 +222,35 @@ export const intraFrameAnimationSchema = z.object({
222
222
  repeat: z.union([z.number(), z.literal("infinite")]).optional(),
223
223
  /** DM-869: when true, the loop ping-pongs `from`→`to`→`from` (CSS `animation-direction: alternate`). */
224
224
  alternate: z.boolean().optional(),
225
+ /**
226
+ * DM-1512/1513: additional property tracks fused into THIS animation so they
227
+ * animate as ONE CSS animation on one element — a single timeline that can't
228
+ * desync. Firefox composites `opacity`/`transform` off the main thread and,
229
+ * under load, can demote one of two SEPARATE animations to the main thread
230
+ * while the other stays on the compositor, drifting them apart (e.g. a fade
231
+ * running ahead of its slide/scale). A single animation is always sampled at
232
+ * one instant regardless of thread, so fusing removes that failure mode.
233
+ *
234
+ * By default each fused track rides the primary entry's window (`delay` +
235
+ * `duration`) and `easing`, emitted as from/to stops. A track MAY override
236
+ * `duration` / `delay` / `easing` for independent timing (e.g. a fast fade over
237
+ * a slower slide, or a different curve) — when any track does, the whole
238
+ * animation is emitted by SAMPLING each track's eased value over its own window
239
+ * at many stops with `linear` timing (DM-1517), so it stays one animation / one
240
+ * timeline. The primary `property` is track 0; these are the rest. Multiple
241
+ * transform-family tracks (`translateX`/`translateY`/`scale`/`transform`) are
242
+ * composed into a single `transform:` declaration; other properties (`opacity`,
243
+ * `clip-path`, …) emit alongside. See docs/84-viewer-browser-support.md.
244
+ */
245
+ fuse: z.array(z.object({
246
+ property: z.enum(["width", "height", "opacity", "transform", "translateX", "translateY", "scale", "clipPath"]),
247
+ from: z.string(),
248
+ to: z.string(),
249
+ /** Override the primary's duration for this track (ms). Triggers sampling. */
250
+ duration: z.number().optional(),
251
+ /** Override the primary's delay for this track (ms). Triggers sampling. */
252
+ delay: z.number().optional(),
253
+ /** Override the primary's easing for this track. Triggers sampling. */
254
+ easing: z.string().optional(),
255
+ })).optional(),
225
256
  });
@@ -678,6 +678,23 @@ export declare const animateConfigSchema: z.ZodObject<{
678
678
  translateY: "translateY";
679
679
  }>;
680
680
  transformOrigin: z.ZodOptional<z.ZodString>;
681
+ fuse: z.ZodOptional<z.ZodArray<z.ZodObject<{
682
+ property: z.ZodEnum<{
683
+ width: "width";
684
+ clipPath: "clipPath";
685
+ height: "height";
686
+ transform: "transform";
687
+ opacity: "opacity";
688
+ scale: "scale";
689
+ translateX: "translateX";
690
+ translateY: "translateY";
691
+ }>;
692
+ from: z.ZodString;
693
+ to: z.ZodString;
694
+ duration: z.ZodOptional<z.ZodNumber>;
695
+ delay: z.ZodOptional<z.ZodNumber>;
696
+ easing: z.ZodOptional<z.ZodString>;
697
+ }, z.core.$strip>>>;
681
698
  selector: z.ZodString;
682
699
  repeat: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"infinite">]>>;
683
700
  }, z.core.$strip>>>;
@@ -734,6 +734,7 @@ async function buildCapturedFrame(fc, i, ctx) {
734
734
  repeat: a.repeat,
735
735
  alternate: a.alternate,
736
736
  transformOrigin: a.transformOrigin,
737
+ fuse: a.fuse,
737
738
  });
738
739
  }
739
740
  }
@@ -10,9 +10,14 @@
10
10
  * Same two animation constraints as `background-loop` (doc 71): only one
11
11
  * intra-frame animation applies per captured element, and SVG transforms are
12
12
  * origin-(0,0). So each animated unit is a `.kt-w-N` transform-wrapper (rise /
13
- * slide via origin-safe translateX/translateY) around a `.kt-wi-N` opacity-inner
14
- * (fade). The reveal is one-shot (no repeat): units hold `from` until their
15
- * staggered turn, animate in, then hold `to` so the headline stays assembled.
13
+ * slide via origin-safe translateX/translateY) around a `.kt-wi-N` inner span.
14
+ * DM-1512/1513: the move and the fade are FUSED into that one wrapper animation
15
+ * (via the animation entry's `fuse` list) rather than a separate opacity
16
+ * animation on the inner span — one CSS timeline can't desync under Firefox's
17
+ * off-main-thread compositing (see docs/84). The `.kt-wi-N` inner span is now
18
+ * inert (it just carries the glyph). The reveal is one-shot (no repeat): units
19
+ * hold `from` until their staggered turn, animate in, then hold `to` so the
20
+ * headline stays assembled.
16
21
  */
17
22
  import { z } from "zod";
18
23
  import type { AnimateConfig } from "../../cli/animate.js";
@@ -10,9 +10,14 @@
10
10
  * Same two animation constraints as `background-loop` (doc 71): only one
11
11
  * intra-frame animation applies per captured element, and SVG transforms are
12
12
  * origin-(0,0). So each animated unit is a `.kt-w-N` transform-wrapper (rise /
13
- * slide via origin-safe translateX/translateY) around a `.kt-wi-N` opacity-inner
14
- * (fade). The reveal is one-shot (no repeat): units hold `from` until their
15
- * staggered turn, animate in, then hold `to` so the headline stays assembled.
13
+ * slide via origin-safe translateX/translateY) around a `.kt-wi-N` inner span.
14
+ * DM-1512/1513: the move and the fade are FUSED into that one wrapper animation
15
+ * (via the animation entry's `fuse` list) rather than a separate opacity
16
+ * animation on the inner span — one CSS timeline can't desync under Firefox's
17
+ * off-main-thread compositing (see docs/84). The `.kt-wi-N` inner span is now
18
+ * inert (it just carries the glyph). The reveal is one-shot (no repeat): units
19
+ * hold `from` until their staggered turn, animate in, then hold `to` so the
20
+ * headline stays assembled.
16
21
  */
17
22
  import { runSingleFrameGenerator } from "../run-single-frame.js";
18
23
  import { z } from "zod";
@@ -256,39 +261,41 @@ export function buildKineticAnimations(p, plan) {
256
261
  // `boomerang`: each unit assembles then disassembles forever, phase-offset by
257
262
  // its stagger. `loop`: one-shot; the infinitely-looping scene replays it.
258
263
  const loopFields = p.loop === "boomerang" ? { repeat: "infinite", alternate: true } : {};
264
+ // DM-1512/1513: each unit's move + fade is emitted as ONE animation on the
265
+ // wrapper (`.kt-w-*`), with the fade FUSED into the move via `fuse` rather than
266
+ // a separate opacity animation on the inner span. One CSS animation is one
267
+ // timeline, so the fade and the move stay in perfect sync — immune to
268
+ // Firefox's off-main-thread compositing, which under load demotes one of two
269
+ // SEPARATE animations to the main thread and drifts them apart (a fade running
270
+ // ahead of its slide/scale). The fused fade rides the move's window + easing.
271
+ // See docs/84-viewer-browser-support.md.
272
+ const fade = [{ property: "opacity", from: "0", to: "1" }];
259
273
  for (const line of plan.lines) {
260
274
  for (const units of line) {
261
275
  for (const u of units) {
262
276
  const delay = u.index * p.staggerMs;
263
- // Fade in (all variants), on the inner span.
264
- anims.push({
265
- selector: `.kt-wi-${u.index}`,
266
- property: "opacity",
267
- from: "0",
268
- to: "1",
269
- duration: p.revealMs,
270
- delay,
271
- easing: "ease-out",
272
- ...loopFields,
273
- });
274
- // Move/reveal in, on the wrapper. `fade` has no wrapper animation.
277
+ const sel = `.kt-w-${u.index}`;
275
278
  if (p.variant === "rise") {
276
- anims.push({ selector: `.kt-w-${u.index}`, property: "translateY", from: "0.55em", to: "0em", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", ...loopFields });
279
+ anims.push({ selector: sel, property: "translateY", from: "0.55em", to: "0em", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", fuse: fade, ...loopFields });
277
280
  }
278
281
  else if (p.variant === "slide") {
279
- anims.push({ selector: `.kt-w-${u.index}`, property: "translateX", from: "-0.6em", to: "0em", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", ...loopFields });
282
+ anims.push({ selector: sel, property: "translateX", from: "-0.6em", to: "0em", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", fuse: fade, ...loopFields });
280
283
  }
281
284
  else if (p.variant === "clip") {
282
285
  // Left-to-right wipe via the `clipPath` intra-frame property (doc 08):
283
286
  // `inset(0 100% 0 0)` clips everything but the left edge; animating the
284
287
  // right inset to 0 reveals the unit left→right.
285
- anims.push({ selector: `.kt-w-${u.index}`, property: "clipPath", from: "inset(-10% 100% -10% 0)", to: "inset(-10% 0% -10% 0)", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", ...loopFields });
288
+ anims.push({ selector: sel, property: "clipPath", from: "inset(-10% 100% -10% 0)", to: "inset(-10% 0% -10% 0)", duration: p.revealMs, delay, easing: "cubic-bezier(0.22,1,0.36,1)", fuse: fade, ...loopFields });
286
289
  }
287
290
  else if (p.variant === "pop") {
288
291
  // Scale-pop: grow from small to full about the unit's OWN CENTER
289
292
  // (`transformOrigin`, DM-1297), with a back-eased overshoot. Without the
290
293
  // center origin an SVG scale would shrink toward the canvas corner.
291
- anims.push({ selector: `.kt-w-${u.index}`, property: "scale", from: "0.3", to: "1", duration: p.revealMs, delay, easing: "cubic-bezier(0.34,1.56,0.64,1)", transformOrigin: "center", ...loopFields });
294
+ anims.push({ selector: sel, property: "scale", from: "0.3", to: "1", duration: p.revealMs, delay, easing: "cubic-bezier(0.34,1.56,0.64,1)", transformOrigin: "center", fuse: fade, ...loopFields });
295
+ }
296
+ else {
297
+ // `fade` variant: no move — just the fade, on the wrapper.
298
+ anims.push({ selector: sel, property: "opacity", from: "0", to: "1", duration: p.revealMs, delay, easing: "ease-out", ...loopFields });
292
299
  }
293
300
  }
294
301
  }
@@ -37,9 +37,10 @@ export declare const lowerThirdParamsSchema: z.ZodObject<{
37
37
  export type LowerThirdParams = z.infer<typeof lowerThirdParamsSchema>;
38
38
  /**
39
39
  * Build the standalone HTML for the banner. Pure function (no I/O) so it's unit-
40
- * testable without a browser. The `.lt` wrapper fades (opacity) and the inner
41
- * `.lt-panel` slides (translateY) — two distinct selectors so the intra-frame
42
- * animations target different elements and never collide on one animId.
40
+ * testable without a browser. The `.lt-panel` slides (translateY) with the fade
41
+ * (opacity) FUSED into the same animation (DM-1512/1513) — one CSS timeline so
42
+ * the two can't desync under Firefox's off-main-thread compositing. `.lt` is a
43
+ * bare flex wrapper around the single `.lt-panel`.
43
44
  */
44
45
  export declare function buildLowerThirdHtml(p: LowerThirdParams): string;
45
46
  export declare const lowerThirdTemplate: Template<LowerThirdParams>;
@@ -39,9 +39,10 @@ function alignmentFor(position) {
39
39
  }
40
40
  /**
41
41
  * Build the standalone HTML for the banner. Pure function (no I/O) so it's unit-
42
- * testable without a browser. The `.lt` wrapper fades (opacity) and the inner
43
- * `.lt-panel` slides (translateY) — two distinct selectors so the intra-frame
44
- * animations target different elements and never collide on one animId.
42
+ * testable without a browser. The `.lt-panel` slides (translateY) with the fade
43
+ * (opacity) FUSED into the same animation (DM-1512/1513) — one CSS timeline so
44
+ * the two can't desync under Firefox's off-main-thread compositing. `.lt` is a
45
+ * bare flex wrapper around the single `.lt-panel`.
45
46
  */
46
47
  export function buildLowerThirdHtml(p) {
47
48
  const { justify, align } = alignmentFor(p.position);
@@ -109,9 +110,13 @@ export const lowerThirdTemplate = {
109
110
  width: params.width,
110
111
  height: params.height,
111
112
  durationMs: params.holdMs,
113
+ // DM-1512/1513: slide + fade FUSED into one animation on `.lt-panel` (the
114
+ // sole child of `.lt`, so fading it is equivalent to fading `.lt`). One
115
+ // CSS animation = one timeline, so the fade and slide can't desync under
116
+ // Firefox's off-main-thread compositing under load. The fade rides the
117
+ // slide's 600ms/cubic window (was a separate 450ms/ease-out track).
112
118
  animations: [
113
- { selector: ".lt", property: "opacity", from: "0", to: "1", duration: 450, easing: "ease-out" },
114
- { selector: ".lt-panel", property: "translateY", from: rise, to: "0px", duration: 600, easing: "cubic-bezier(0.22,1,0.36,1)" },
119
+ { selector: ".lt-panel", property: "translateY", from: rise, to: "0px", duration: 600, easing: "cubic-bezier(0.22,1,0.36,1)", fuse: [{ property: "opacity", from: "0", to: "1" }] },
115
120
  ],
116
121
  });
117
122
  },
@@ -22,3 +22,27 @@ export declare const KEYFRAME_EPSILON: {
22
22
  export declare function padBefore(pct: number, epsilon: number, precision: number): string;
23
23
  /** Percentage nudged UP by `epsilon` (capped at 100), printed at `precision`. */
24
24
  export declare function padAfter(pct: number, epsilon: number, precision: number): string;
25
+ /**
26
+ * DM-1511: Firefox composites a `visibility` animation off the main-thread /
27
+ * opacity clock. At a step-end frame hand-off it can drop the OUTGOING frame's
28
+ * paint a few compositor frames BEFORE the incoming frame starts painting,
29
+ * flashing the transparent page-through gap for ~30-70ms — the "hard flash to
30
+ * white/transparent at cut points" bug. Chromium and Safari keep the two in
31
+ * lock-step, so a sub-millisecond overlap is enough there; Firefox is not.
32
+ *
33
+ * The fix is to drive the visual cut with `opacity` alone (Firefox composites
34
+ * that correctly, and the tight `cull` overlap already guarantees a seamless
35
+ * hand-off) and use `visibility` ONLY as the paint-cull gate (DM-599), with its
36
+ * visible window padded WIDE — a fixed wall-clock margin on each side — so
37
+ * adjacent frames' paint windows overlap well past any compositor slop. The
38
+ * frame is `opacity:0` outside its true window, so the wide visibility window is
39
+ * never itself visible; it only means a frame is *paintable* a little early and
40
+ * a little late, which is harmless.
41
+ *
42
+ * Expressed as wall-clock (not a fixed %) so the overlap always beats the
43
+ * compositor slop, which is a handful of vsync frames regardless of how long the
44
+ * scene is. 150 ms is ~2x the largest gap observed (~70 ms).
45
+ */
46
+ export declare const VISIBILITY_CULL_OVERLAP_MS = 150;
47
+ /** `VISIBILITY_CULL_OVERLAP_MS` as a percentage of a scene `totalMs` long. */
48
+ export declare function cullOverlapPct(totalMs: number): number;
@@ -26,3 +26,29 @@ export function padBefore(pct, epsilon, precision) {
26
26
  export function padAfter(pct, epsilon, precision) {
27
27
  return Math.min(100, pct + epsilon).toFixed(precision);
28
28
  }
29
+ /**
30
+ * DM-1511: Firefox composites a `visibility` animation off the main-thread /
31
+ * opacity clock. At a step-end frame hand-off it can drop the OUTGOING frame's
32
+ * paint a few compositor frames BEFORE the incoming frame starts painting,
33
+ * flashing the transparent page-through gap for ~30-70ms — the "hard flash to
34
+ * white/transparent at cut points" bug. Chromium and Safari keep the two in
35
+ * lock-step, so a sub-millisecond overlap is enough there; Firefox is not.
36
+ *
37
+ * The fix is to drive the visual cut with `opacity` alone (Firefox composites
38
+ * that correctly, and the tight `cull` overlap already guarantees a seamless
39
+ * hand-off) and use `visibility` ONLY as the paint-cull gate (DM-599), with its
40
+ * visible window padded WIDE — a fixed wall-clock margin on each side — so
41
+ * adjacent frames' paint windows overlap well past any compositor slop. The
42
+ * frame is `opacity:0` outside its true window, so the wide visibility window is
43
+ * never itself visible; it only means a frame is *paintable* a little early and
44
+ * a little late, which is harmless.
45
+ *
46
+ * Expressed as wall-clock (not a fixed %) so the overlap always beats the
47
+ * compositor slop, which is a handful of vsync frames regardless of how long the
48
+ * scene is. 150 ms is ~2x the largest gap observed (~70 ms).
49
+ */
50
+ export const VISIBILITY_CULL_OVERLAP_MS = 150;
51
+ /** `VISIBILITY_CULL_OVERLAP_MS` as a percentage of a scene `totalMs` long. */
52
+ export function cullOverlapPct(totalMs) {
53
+ return totalMs > 0 ? (VISIBILITY_CULL_OVERLAP_MS / totalMs) * 100 : 0;
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domotion-svg",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
5
5
  "license": "MIT",
6
6
  "author": "Brian Westphal",
@@ -1355,6 +1355,47 @@
1355
1355
  "alternate": {
1356
1356
  "type": "boolean"
1357
1357
  },
1358
+ "fuse": {
1359
+ "type": "array",
1360
+ "items": {
1361
+ "type": "object",
1362
+ "properties": {
1363
+ "property": {
1364
+ "type": "string",
1365
+ "enum": [
1366
+ "width",
1367
+ "height",
1368
+ "opacity",
1369
+ "transform",
1370
+ "translateX",
1371
+ "translateY",
1372
+ "scale",
1373
+ "clipPath"
1374
+ ]
1375
+ },
1376
+ "from": {
1377
+ "type": "string"
1378
+ },
1379
+ "to": {
1380
+ "type": "string"
1381
+ },
1382
+ "duration": {
1383
+ "type": "number"
1384
+ },
1385
+ "delay": {
1386
+ "type": "number"
1387
+ },
1388
+ "easing": {
1389
+ "type": "string"
1390
+ }
1391
+ },
1392
+ "required": [
1393
+ "property",
1394
+ "from",
1395
+ "to"
1396
+ ]
1397
+ }
1398
+ },
1358
1399
  "selector": {
1359
1400
  "type": "string"
1360
1401
  }