domotion-svg 0.8.0 → 0.11.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.
Files changed (97) hide show
  1. package/assets/fonts/LICENSE-last-resort-font.txt +94 -0
  2. package/assets/fonts/LastResortHE-Regular.ttf +0 -0
  3. package/dist/animation/animator.d.ts +9 -1
  4. package/dist/animation/animator.js +201 -218
  5. package/dist/animation/cursor-glyphs.d.ts +52 -0
  6. package/dist/animation/cursor-glyphs.js +138 -0
  7. package/dist/animation/cursor-overlay.d.ts +47 -2
  8. package/dist/animation/cursor-overlay.js +152 -33
  9. package/dist/animation/frame-timeline.d.ts +27 -0
  10. package/dist/animation/frame-timeline.js +26 -0
  11. package/dist/animation/index.d.ts +2 -1
  12. package/dist/animation/index.js +2 -1
  13. package/dist/animation/magic-move.js +4 -4
  14. package/dist/capture/embed.js +9 -4
  15. package/dist/capture/emoji.js +7 -0
  16. package/dist/capture/index.js +61 -56
  17. package/dist/capture/initial-letter-probe.d.ts +37 -0
  18. package/dist/capture/initial-letter-probe.js +144 -0
  19. package/dist/capture/script/emoji-detect.d.ts +2 -2
  20. package/dist/capture/script/emoji-detect.js +114 -5
  21. package/dist/capture/script/index.js +33 -546
  22. package/dist/capture/script/utils.d.ts +22 -0
  23. package/dist/capture/script/utils.js +70 -0
  24. package/dist/capture/script/walker/counter-prewalk.d.ts +3 -0
  25. package/dist/capture/script/walker/counter-prewalk.js +155 -0
  26. package/dist/capture/script/walker/inline-svg.d.ts +1 -0
  27. package/dist/capture/script/walker/inline-svg.js +414 -0
  28. package/dist/capture/script/walker/input-value.d.ts +8 -0
  29. package/dist/capture/script/walker/input-value.js +153 -7
  30. package/dist/capture/script/walker/pseudo-content.d.ts +10 -7
  31. package/dist/capture/script/walker/pseudo-content.js +272 -247
  32. package/dist/capture/script/walker/pseudo-inject.js +22 -2
  33. package/dist/capture/script/walker/replaced-elements.js +3 -5
  34. package/dist/capture/script/walker/text-segments.d.ts +89 -26
  35. package/dist/capture/script/walker/text-segments.js +655 -217
  36. package/dist/capture/script.generated.js +1 -1
  37. package/dist/capture/types.d.ts +102 -0
  38. package/dist/cli/animate-config-json-schema.d.ts +32 -0
  39. package/dist/cli/animate-config-json-schema.js +65 -0
  40. package/dist/cli/animate.d.ts +57 -8
  41. package/dist/cli/animate.js +53 -14
  42. package/dist/cli/common.d.ts +19 -0
  43. package/dist/cli/common.js +49 -0
  44. package/dist/cli/review.js +10 -30
  45. package/dist/cli/scrubber.d.ts +17 -0
  46. package/dist/cli/scrubber.js +91 -0
  47. package/dist/cli/svg-to-video-core.d.ts +3 -0
  48. package/dist/cli/svg-to-video-core.js +18 -6
  49. package/dist/cli/svg-to-video.js +11 -20
  50. package/dist/render/element-tree-to-svg.js +427 -181
  51. package/dist/render/glyph-helper.d.ts +73 -3
  52. package/dist/render/glyph-helper.js +431 -19
  53. package/dist/render/render-profile.d.ts +11 -0
  54. package/dist/render/render-profile.js +39 -0
  55. package/dist/render/text-to-path.d.ts +78 -2
  56. package/dist/render/text-to-path.js +1424 -363
  57. package/dist/render/text.d.ts +14 -0
  58. package/dist/render/text.js +88 -4
  59. package/dist/render/unicode-font-routing.darwin.generated.d.ts +8 -0
  60. package/dist/render/unicode-font-routing.darwin.generated.js +484 -0
  61. package/dist/render/unicode-font-routing.linux.generated.d.ts +8 -0
  62. package/dist/render/unicode-font-routing.linux.generated.js +350 -0
  63. package/dist/render/unicode-font-routing.win32.generated.d.ts +7 -0
  64. package/dist/render/unicode-font-routing.win32.generated.js +374 -0
  65. package/dist/render/vertical-text.d.ts +50 -0
  66. package/dist/render/vertical-text.js +284 -0
  67. package/dist/review/compare-pngs.js +42 -24
  68. package/dist/review/server.js +3 -1
  69. package/dist/scroll/composer.js +278 -208
  70. package/dist/scroll/executor.d.ts +26 -1
  71. package/dist/scroll/executor.js +70 -24
  72. package/dist/scroll/hoist-fixed.js +2 -25
  73. package/dist/scroll/hoist-sticky.js +2 -24
  74. package/dist/scroll/pattern.d.ts +5 -1
  75. package/dist/scrubber/client.bundle.generated.d.ts +1 -0
  76. package/dist/scrubber/client.bundle.generated.js +3 -0
  77. package/dist/scrubber/client.d.ts +12 -0
  78. package/dist/scrubber/client.js +668 -0
  79. package/dist/scrubber/crop.d.ts +51 -0
  80. package/dist/scrubber/crop.js +144 -0
  81. package/dist/scrubber/server.d.ts +35 -0
  82. package/dist/scrubber/server.js +347 -0
  83. package/dist/scrubber/trim.d.ts +42 -0
  84. package/dist/scrubber/trim.js +351 -0
  85. package/dist/tree-ops/prune-tree.d.ts +14 -0
  86. package/dist/tree-ops/prune-tree.js +40 -0
  87. package/dist/tree-ops/tree-diff.d.ts +3 -0
  88. package/dist/tree-ops/tree-diff.js +7 -6
  89. package/dist/tree-ops/viewbox-culling.js +3 -2
  90. package/dist/utils/keyframe-pad.d.ts +24 -0
  91. package/dist/utils/keyframe-pad.js +28 -0
  92. package/dist/utils/wait-events.d.ts +50 -0
  93. package/dist/utils/wait-events.js +87 -0
  94. package/package.json +12 -4
  95. package/schemas/animate-config.schema.json +1253 -0
  96. package/dist/tree-ops/frame-merge.d.ts +0 -105
  97. package/dist/tree-ops/frame-merge.js +0 -395
@@ -0,0 +1,94 @@
1
+ This Font Software is licensed under the SIL Open Font License,
2
+ Version 1.1.
3
+
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font
14
+ creation efforts of academic and linguistic communities, and to
15
+ provide a free and open framework in which fonts may be shared and
16
+ improved in partnership with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply to
25
+ any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software
36
+ components as distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to,
39
+ deleting, or substituting -- in part or in whole -- any of the
40
+ components of the Original Version, by changing formats or by porting
41
+ the Font Software to a new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed,
49
+ modify, redistribute, and sell modified and unmodified copies of the
50
+ Font Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components, in
53
+ Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the
64
+ corresponding Copyright Holder. This restriction only applies to the
65
+ primary font name as presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created using
77
+ the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
93
+
94
+ SPDX-License-Identifier: OFL-1.1
@@ -4,7 +4,7 @@
4
4
  * Takes captured SVG frame content and composes them into a single
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
- import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
7
+ import { type CursorAtResolver, type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
8
8
  import type { MagicMove } from "./magic-move.js";
9
9
  export interface AnimationFrame {
10
10
  /** SVG content for this frame (from dom-to-svg) */
@@ -244,6 +244,14 @@ export interface AnimationConfig {
244
244
  * uses `selector`; otherwise pass undefined / null.
245
245
  */
246
246
  resolveSelector?: SelectorResolver;
247
+ /**
248
+ * DM-1106: auto cursor-TYPE hit-tester — given a viewport point and frame
249
+ * index, returns the cursor keyword under it (the caller builds this from the
250
+ * per-frame captured trees via `cursorAtPoint`). When provided, the overlay
251
+ * paints the matching glyph per element and switches at boundary crossings;
252
+ * when omitted, the overlay paints the single arrow.
253
+ */
254
+ resolveCursorAt?: CursorAtResolver;
247
255
  /**
248
256
  * Canvas background color painted behind every frame (a full-viewport
249
257
  * `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
@@ -5,9 +5,156 @@
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
7
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
8
+ import { escapeHtml } from "../utils/escapeHtml.js";
9
+ import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
10
+ import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
11
+ /**
12
+ * Emit one magic-move frame (DM-898): the frame-i blob (held [start..holdEnd],
13
+ * hard-cut out), the bridge composite (visible only across [holdEnd..transEnd]),
14
+ * the per-element slide / fade keyframes within that window, and the
15
+ * `prefers-reduced-motion` pinning (DM-901 / DM-903). Returns the SVG group
16
+ * fragments and the `@keyframes`/rule CSS for the caller to splice in.
17
+ * Extracted from `generateAnimatedSvg`'s per-frame loop (DM-1089) — byte-identical.
18
+ */
19
+ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, totalSec) {
20
+ const groups = [];
21
+ const keyframes = [];
22
+ const sNum = parseFloat(startPct);
23
+ const hNum = parseFloat(holdEndPct);
24
+ const tNum = parseFloat(transEndPct);
25
+ const beforeS = padBefore(sNum, KEYFRAME_EPSILON.cull, 3);
26
+ const afterH = padAfter(hNum, KEYFRAME_EPSILON.cull, 3);
27
+ const beforeH = padBefore(hNum, KEYFRAME_EPSILON.cull, 3);
28
+ const afterT = padAfter(tNum, KEYFRAME_EPSILON.cull, 3);
29
+ // Frame i blob: visible only during its hold, hard-cut out at hold end.
30
+ groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
31
+ keyframes.push(`
32
+ @keyframes fv-${i} {
33
+ 0% { opacity: 0; visibility: hidden; }
34
+ ${beforeS}% { opacity: 0; visibility: hidden; }
35
+ ${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
36
+ ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
37
+ ${afterH}% { opacity: 0; visibility: hidden; }
38
+ 100% { opacity: 0; visibility: hidden; }
39
+ }
40
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
41
+ // Bridge composite: visible during the transition window only.
42
+ groups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
43
+ keyframes.push(`
44
+ @keyframes mmv-${i} {
45
+ 0% { opacity: 0; visibility: hidden; }
46
+ ${beforeH}% { opacity: 0; visibility: hidden; }
47
+ ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
48
+ ${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
49
+ ${afterT}% { opacity: 0; visibility: hidden; }
50
+ 100% { opacity: 0; visibility: hidden; }
51
+ }
52
+ .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
53
+ // Per-element slide / fade keyframes within the window (linear interp).
54
+ // The composite is only visible [holdEnd..transEnd], so the held values
55
+ // outside that window are never painted — they just pin the endpoints.
56
+ //
57
+ // A dual-render cross-fade copy (DM-903) is BOTH a slide and a fade, so
58
+ // its element needs two animations. They MUST go in one `animation:`
59
+ // declaration (comma-joined) — two separate `.cls { animation: … }` rules
60
+ // would have the later one silently override the former, dropping the
61
+ // slide. Accumulate per-class animation entries and emit one rule each.
62
+ const animEntries = new Map();
63
+ const addAnim = (cls, name) => {
64
+ const list = animEntries.get(cls) ?? [];
65
+ list.push(`${name} ${totalSec.toFixed(2)}s infinite`);
66
+ animEntries.set(cls, list);
67
+ };
68
+ for (const s of mm.slides) {
69
+ keyframes.push(`
70
+ @keyframes mms-${s.cls} {
71
+ 0%, ${hNum.toFixed(3)}% { transform: ${s.from}; }
72
+ ${tNum.toFixed(3)}%, 100% { transform: ${s.to}; }
73
+ }`);
74
+ addAnim(s.cls, `mms-${s.cls}`);
75
+ }
76
+ for (const cls of mm.fadeIn) {
77
+ keyframes.push(`
78
+ @keyframes mmf-${cls} {
79
+ 0%, ${hNum.toFixed(3)}% { opacity: 0; }
80
+ ${tNum.toFixed(3)}%, 100% { opacity: 1; }
81
+ }`);
82
+ addAnim(cls, `mmf-${cls}`);
83
+ }
84
+ for (const cls of mm.fadeOut) {
85
+ keyframes.push(`
86
+ @keyframes mmf-${cls} {
87
+ 0%, ${hNum.toFixed(3)}% { opacity: 1; }
88
+ ${tNum.toFixed(3)}%, 100% { opacity: 0; }
89
+ }`);
90
+ addAnim(cls, `mmf-${cls}`);
91
+ }
92
+ for (const [cls, entries] of animEntries) {
93
+ keyframes.push(` .${cls} { animation: ${entries.join(", ")}; }`);
94
+ }
95
+ // DM-901: honor `prefers-reduced-motion: reduce` — pin everything to the
96
+ // NEXT state instead of animating, so the transition degrades to a
97
+ // cut-like reveal for motion-sensitive viewers.
98
+ const reduceRules = [];
99
+ if (mm.slides.length > 0)
100
+ reduceRules.push(`${mm.slides.map((s) => `.${s.cls}`).join(", ")} { animation: none; transform: none; }`);
101
+ if (mm.fadeIn.length > 0)
102
+ reduceRules.push(`${mm.fadeIn.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 1; }`);
103
+ if (mm.fadeOut.length > 0)
104
+ reduceRules.push(`${mm.fadeOut.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 0; }`);
105
+ if (reduceRules.length > 0) {
106
+ keyframes.push(`
107
+ @media (prefers-reduced-motion: reduce) {
108
+ ${reduceRules.join("\n ")}
109
+ }`);
110
+ }
111
+ return { groups, keyframes };
112
+ }
113
+ /**
114
+ * Emit one crossfade or cut frame (the default transition path): the frame
115
+ * blob plus its opacity keyframes. `cut` (or zero-duration) uses disjoint
116
+ * step-end keyframes so opacity flips instantly with no interpolation smear;
117
+ * crossfade overlaps the fade-in with the previous frame's fade-out, with the
118
+ * visible window driven by `fadeInStartPct` (precomputed by the caller, which
119
+ * knows the overlap state). Extracted from `generateAnimatedSvg` (DM-1089).
120
+ */
121
+ function emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec) {
122
+ const groups = [];
123
+ const keyframes = [];
124
+ groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
125
+ const isCut = transType === "cut" || transDur === 0;
126
+ if (isCut) {
127
+ const startNum = parseFloat(startPct);
128
+ const endNum = parseFloat(transEndPct);
129
+ const beforeStart = padBefore(startNum, KEYFRAME_EPSILON.cull, 3);
130
+ const afterEnd = padAfter(endNum, KEYFRAME_EPSILON.cull, 3);
131
+ keyframes.push(`
132
+ @keyframes fv-${i} {
133
+ 0% { opacity: 0; visibility: hidden; }
134
+ ${beforeStart}% { opacity: 0; visibility: hidden; }
135
+ ${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
136
+ ${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
137
+ ${afterEnd}% { opacity: 0; visibility: hidden; }
138
+ 100% { opacity: 0; visibility: hidden; }
139
+ }
140
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
141
+ }
142
+ else {
143
+ const prevEnd = i > 0
144
+ ? `${padBefore(parseFloat(fadeInStartPct), KEYFRAME_EPSILON.display, 2)}%,`
145
+ : "";
146
+ keyframes.push(`
147
+ @keyframes fv-${i} {
148
+ 0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
149
+ ${startPct}, ${holdEndPct} { opacity: 1; }
150
+ }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
151
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
152
+ }
153
+ return { groups, keyframes };
154
+ }
8
155
  export function generateAnimatedSvg(config) {
9
156
  const { width, height, frames } = config;
10
- const totalDuration = frames.reduce((sum, f) => sum + f.duration + transitionDuration(f), 0);
157
+ const totalDuration = frames.reduce((sum, f) => sum + frameAdvanceMs(f), 0);
11
158
  const totalSec = totalDuration / 1000;
12
159
  // Pre-compute per-frame timing windows (used by both the merge pipeline for
13
160
  // timeline keyframes and the atomic push/scroll fallbacks below).
@@ -17,7 +164,7 @@ export function generateAnimatedSvg(config) {
17
164
  {
18
165
  let t = 0;
19
166
  for (const f of frames) {
20
- const td = transitionDuration(f);
167
+ const td = transitionDurationMs(f);
21
168
  frameTiming.startPct.push((t / totalDuration) * 100);
22
169
  frameTiming.holdEndPct.push(((t + f.duration) / totalDuration) * 100);
23
170
  frameTiming.transEndPct.push(((t + f.duration + td) / totalDuration) * 100);
@@ -43,7 +190,7 @@ export function generateAnimatedSvg(config) {
43
190
  let timeOffset = 0;
44
191
  for (let i = 0; i < frames.length; i++) {
45
192
  const frame = frames[i];
46
- const transDur = transitionDuration(frame);
193
+ const transDur = transitionDurationMs(frame);
47
194
  const transType = frame.transition?.type ?? "crossfade";
48
195
  const startPct = pct(timeOffset, totalDuration);
49
196
  const holdEndPct = pct(timeOffset + frame.duration, totalDuration);
@@ -61,7 +208,7 @@ export function generateAnimatedSvg(config) {
61
208
  // one slides out, so its show window starts at `timeOffset - prevTransDur`
62
209
  // rather than at `startPct`.
63
210
  const entersViaOverlap = entersViaPush || entersViaScroll;
64
- const prevTransDur = prevFrame != null ? transitionDuration(prevFrame) : 300;
211
+ const prevTransDur = prevFrame != null ? transitionDurationMs(prevFrame) : DEFAULT_TRANSITION_MS;
65
212
  const enterStartPct = entersViaOverlap
66
213
  ? pct(timeOffset - prevTransDur, totalDuration)
67
214
  : startPct;
@@ -74,24 +221,7 @@ export function generateAnimatedSvg(config) {
74
221
  // Window is [enterStartPct .. transEndPct] (when the slide has fully
75
222
  // exited the viewBox); 0.01% pad on each side keeps the snap inside the
76
223
  // existing opacity:0 bookend.
77
- const visStart = enterStartPct;
78
- const visEnd = transEndPct;
79
- keyframes.push(`
80
- @keyframes fp-${i} {
81
- 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
82
- ${startPct} { transform: translateX(0); }
83
- ${holdEndPct} { transform: translateX(0); }
84
- ${transEndPct} { transform: translateX(-${width}px); }
85
- ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
86
- }
87
- @keyframes fv-${i} {
88
- 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
89
- ${enterStartPct} { opacity: 1; }
90
- ${transEndPct} { opacity: 1; }
91
- ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
92
- }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
93
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
94
- .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
224
+ keyframes.push(slideKeyframes(i, "X", width, entersViaPush, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec));
95
225
  }
96
226
  else if (transType === "scroll") {
97
227
  // DM-609: `scroll` now means real geometric scroll between two frames
@@ -100,27 +230,10 @@ export function generateAnimatedSvg(config) {
100
230
  // slides up from the bottom of the viewport, outgoing slides up off
101
231
  // the top. Uses height instead of width and translateY instead of
102
232
  // translateX, otherwise identical machinery (incl. the cull-friendly
103
- // `fd-${i}` display animation).
104
- const entersViaScroll = prevFrame?.transition?.type === "scroll";
233
+ // `fd-${i}` display animation). (`entersViaScroll` is already computed in
234
+ // the outer scope above — same value, no need to redeclare/shadow it.)
105
235
  frameGroups.push(` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${frame.svgContent}\n </g></g>`);
106
- const visStart = enterStartPct;
107
- const visEnd = transEndPct;
108
- keyframes.push(`
109
- @keyframes fp-${i} {
110
- 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
111
- ${startPct} { transform: translateY(0); }
112
- ${holdEndPct} { transform: translateY(0); }
113
- ${transEndPct} { transform: translateY(-${height}px); }
114
- ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
115
- }
116
- @keyframes fv-${i} {
117
- 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
118
- ${enterStartPct} { opacity: 1; }
119
- ${transEndPct} { opacity: 1; }
120
- ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
121
- }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
122
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
123
- .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
236
+ keyframes.push(slideKeyframes(i, "Y", height, entersViaScroll, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec));
124
237
  }
125
238
  else if (transType === "magic-move" && frame.magicMove != null) {
126
239
  // DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
@@ -131,163 +244,21 @@ export function generateAnimatedSvg(config) {
131
244
  // final paint and its end state the next frame's initial paint, so both
132
245
  // hard cuts are seamless. (When `frame.magicMove` is null the type falls
133
246
  // through to the crossfade branch below — the documented fallback.)
134
- const mm = frame.magicMove;
135
- const sNum = parseFloat(startPct);
136
- const hNum = parseFloat(holdEndPct);
137
- const tNum = parseFloat(transEndPct);
138
- const beforeS = Math.max(0, sNum - 0.001).toFixed(3);
139
- const afterH = Math.min(100, hNum + 0.001).toFixed(3);
140
- const beforeH = Math.max(0, hNum - 0.001).toFixed(3);
141
- const afterT = Math.min(100, tNum + 0.001).toFixed(3);
142
- // Frame i blob: visible only during its hold, hard-cut out at hold end.
143
- frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
144
- keyframes.push(`
145
- @keyframes fv-${i} {
146
- 0% { opacity: 0; visibility: hidden; }
147
- ${beforeS}% { opacity: 0; visibility: hidden; }
148
- ${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
149
- ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
150
- ${afterH}% { opacity: 0; visibility: hidden; }
151
- 100% { opacity: 0; visibility: hidden; }
152
- }
153
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
154
- // Bridge composite: visible during the transition window only.
155
- frameGroups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
156
- keyframes.push(`
157
- @keyframes mmv-${i} {
158
- 0% { opacity: 0; visibility: hidden; }
159
- ${beforeH}% { opacity: 0; visibility: hidden; }
160
- ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
161
- ${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
162
- ${afterT}% { opacity: 0; visibility: hidden; }
163
- 100% { opacity: 0; visibility: hidden; }
164
- }
165
- .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
166
- // Per-element slide / fade keyframes within the window (linear interp).
167
- // The composite is only visible [holdEnd..transEnd], so the held values
168
- // outside that window are never painted — they just pin the endpoints.
169
- //
170
- // A dual-render cross-fade copy (DM-903) is BOTH a slide and a fade, so
171
- // its element needs two animations. They MUST go in one `animation:`
172
- // declaration (comma-joined) — two separate `.cls { animation: … }` rules
173
- // would have the later one silently override the former, dropping the
174
- // slide. Accumulate per-class animation entries and emit one rule each.
175
- const animEntries = new Map();
176
- const addAnim = (cls, name) => {
177
- const list = animEntries.get(cls) ?? [];
178
- list.push(`${name} ${totalSec.toFixed(2)}s infinite`);
179
- animEntries.set(cls, list);
180
- };
181
- for (const s of mm.slides) {
182
- // Interpolate the element's transform `from → to` across the window.
183
- // The next-appearance copy maps its prev rect → `none` (final next
184
- // rect); a cross-fade prev copy maps `none` → its next rect, so both
185
- // copies trace the same path (DM-899 geometry; DM-903 paired copies).
186
- keyframes.push(`
187
- @keyframes mms-${s.cls} {
188
- 0%, ${hNum.toFixed(3)}% { transform: ${s.from}; }
189
- ${tNum.toFixed(3)}%, 100% { transform: ${s.to}; }
190
- }`);
191
- addAnim(s.cls, `mms-${s.cls}`);
192
- }
193
- for (const cls of mm.fadeIn) {
194
- keyframes.push(`
195
- @keyframes mmf-${cls} {
196
- 0%, ${hNum.toFixed(3)}% { opacity: 0; }
197
- ${tNum.toFixed(3)}%, 100% { opacity: 1; }
198
- }`);
199
- addAnim(cls, `mmf-${cls}`);
200
- }
201
- for (const cls of mm.fadeOut) {
202
- keyframes.push(`
203
- @keyframes mmf-${cls} {
204
- 0%, ${hNum.toFixed(3)}% { opacity: 1; }
205
- ${tNum.toFixed(3)}%, 100% { opacity: 0; }
206
- }`);
207
- addAnim(cls, `mmf-${cls}`);
208
- }
209
- for (const [cls, entries] of animEntries) {
210
- keyframes.push(` .${cls} { animation: ${entries.join(", ")}; }`);
211
- }
212
- // DM-901: honor `prefers-reduced-motion: reduce` — pin everything to the
213
- // NEXT state instead of animating, so the transition degrades to a
214
- // cut-like reveal for motion-sensitive viewers. Slides drop to their
215
- // final transform (`none` for the next copy; the prev cross-fade copy is
216
- // also hidden via its fade-out below). Added / next-appearance fades snap
217
- // to opacity 1; removed / prev-appearance fades snap to opacity 0. Static
218
- // CSS, so output stays deterministic; rasterizers default to
219
- // `no-preference` and play the full move. (DM-903: the fade rules now
220
- // also matter — without pinning fade-out to 0 the prev-appearance copy
221
- // would stay visible at full opacity.)
222
- const reduceRules = [];
223
- if (mm.slides.length > 0)
224
- reduceRules.push(`${mm.slides.map((s) => `.${s.cls}`).join(", ")} { animation: none; transform: none; }`);
225
- if (mm.fadeIn.length > 0)
226
- reduceRules.push(`${mm.fadeIn.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 1; }`);
227
- if (mm.fadeOut.length > 0)
228
- reduceRules.push(`${mm.fadeOut.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 0; }`);
229
- if (reduceRules.length > 0) {
230
- keyframes.push(`
231
- @media (prefers-reduced-motion: reduce) {
232
- ${reduceRules.join("\n ")}
233
- }`);
234
- }
247
+ const r = emitMagicMoveFrame(i, frame, frame.magicMove, startPct, holdEndPct, transEndPct, totalSec);
248
+ frameGroups.push(...r.groups);
249
+ keyframes.push(...r.keyframes);
235
250
  }
236
251
  else {
237
- // Crossfade or cut: opacity in/out.
238
- //
239
- // For `cut` (transDur === 0): use disjoint keyframes with step-end timing
240
- // so opacity flips instantly at frame boundaries with no interpolation
241
- // smear frame N is opaque from startPct to transEndPct EXCLUSIVE, and
242
- // 0 outside. Without step-end, linear interpolation between distant
243
- // keyframes makes adjacent frames bleed across the entire cycle.
244
- //
245
- // For crossfade: the fade-in OVERLAPS the previous frame's fade-out so
246
- // shared pixels stay visible during the transition. Linear interpolation
247
- // is what we want here.
248
- frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
249
- const isCut = transType === "cut" || transDur === 0;
250
- if (isCut) {
251
- const startNum = parseFloat(startPct);
252
- const endNum = parseFloat(transEndPct);
253
- const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
254
- const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
255
- // DM-599: cut already uses step-end on the opacity animation, so we
256
- // fold visibility into the same keyframes block — both snap together.
257
- // DM-641: this used to toggle `display`. The base `.f { display: none }`
258
- // rule kept the element out of the render tree at t=0, and Chromium
259
- // doesn't tick infinite animations on out-of-tree elements — so the
260
- // 0% keyframe never ran and the frame stayed permanently hidden.
261
- // Switching to `visibility` leaves the element in the render tree
262
- // (still skips painting, which was the DM-599 goal) so the animation
263
- // ticks normally.
264
- keyframes.push(`
265
- @keyframes fv-${i} {
266
- 0% { opacity: 0; visibility: hidden; }
267
- ${beforeStart}% { opacity: 0; visibility: hidden; }
268
- ${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
269
- ${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
270
- ${afterEnd}% { opacity: 0; visibility: hidden; }
271
- 100% { opacity: 0; visibility: hidden; }
272
- }
273
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
274
- }
275
- else {
276
- const fadeInStartPct = (i > 0 && !entersViaMagicMove)
277
- ? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
278
- : startPct;
279
- const prevEnd = i > 0
280
- ? `${Math.max(0, parseFloat(fadeInStartPct) - 0.01).toFixed(2)}%,`
281
- : "";
282
- // DM-599: visible window spans the full fade — fadeInStart through
283
- // transEnd (display stays `inline` while opacity interpolates).
284
- keyframes.push(`
285
- @keyframes fv-${i} {
286
- 0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
287
- ${startPct}, ${holdEndPct} { opacity: 1; }
288
- }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
289
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
290
- }
252
+ // Crossfade or cut: opacity in/out (see emitCrossfadeOrCutFrame). The
253
+ // crossfade fade-in OVERLAPS the previous frame's fade-out, so its visible
254
+ // window starts at fadeInStartPct which depends on the loop's overlap
255
+ // state (entersViaMagicMove / prevTransDur), so it's computed here.
256
+ const fadeInStartPct = (i > 0 && !entersViaMagicMove)
257
+ ? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
258
+ : startPct;
259
+ const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec);
260
+ frameGroups.push(...r.groups);
261
+ keyframes.push(...r.keyframes);
291
262
  }
292
263
  // Overlays
293
264
  if (frame.overlays != null) {
@@ -332,10 +303,10 @@ export function generateAnimatedSvg(config) {
332
303
  let acc = 0;
333
304
  for (const f of frames) {
334
305
  frameStarts.push(acc);
335
- acc += f.duration + transitionDuration(f);
306
+ acc += frameAdvanceMs(f);
336
307
  }
337
- const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
338
- overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
308
+ const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null, config.resolveCursorAt ?? null);
309
+ overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration, resolved.cursorTimeline);
339
310
  }
340
311
  // Canvas background rect — only when a non-transparent background is given.
341
312
  // Default (none / transparent) emits nothing so the SVG composites over the
@@ -360,18 +331,6 @@ ${canvasBgRect}${frameGroups.join("\n")}${overlayMarkup}
360
331
  </svg>`;
361
332
  return out;
362
333
  }
363
- /**
364
- * Effective transition duration for a frame. `cut` is always 0 — the type
365
- * means "instant" so any duration on the input is meaningless. Default
366
- * (no transition specified) is 300ms (legacy crossfade duration).
367
- */
368
- function transitionDuration(f) {
369
- if (f.transition == null)
370
- return 300;
371
- if (f.transition.type === "cut")
372
- return 0;
373
- return f.transition.duration;
374
- }
375
334
  /**
376
335
  * Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
377
336
  * browser textarea does: break on spaces, char-break a word longer than the
@@ -481,7 +440,7 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
481
440
  lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
482
441
  cumChars += line.length;
483
442
  parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
484
- parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`);
443
+ parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeHtml(line)}</text>`);
485
444
  cssRules.push(`
486
445
  @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
487
446
  .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
@@ -598,8 +557,8 @@ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
598
557
  // ticks in Chromium.
599
558
  const start = parseFloat(String(visibleStartPct));
600
559
  const end = parseFloat(String(visibleEndPct));
601
- const startMinus = Math.max(0, start - 0.01).toFixed(3);
602
- const endPlus = Math.min(100, end + 0.01).toFixed(3);
560
+ const startMinus = padBefore(start, KEYFRAME_EPSILON.display, 3);
561
+ const endPlus = padAfter(end, KEYFRAME_EPSILON.display, 3);
603
562
  return `
604
563
  @keyframes ${name} {
605
564
  0% { visibility: hidden; }
@@ -610,6 +569,33 @@ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
610
569
  100% { visibility: hidden; }
611
570
  }`;
612
571
  }
572
+ /**
573
+ * Slide-transition keyframes (push-left / scroll). The two transitions are the
574
+ * same machinery on different axes: `push-left` slides horizontally (axis `X`,
575
+ * `size` = width), `scroll` slides vertically (axis `Y`, `size` = height). The
576
+ * incoming frame starts off-screen (`+size`) only when the predecessor was the
577
+ * same slide type (`entersSliding`), holds at 0 across its show window, then
578
+ * exits to `-size`. 0.1% pads on each bookend keep the snap inside the
579
+ * opacity:0 frame. Emits the fp/fv/fd keyframes + the `.f-`/`.fp-` rules.
580
+ */
581
+ function slideKeyframes(i, axis, size, entersSliding, enterStartPct, startPct, holdEndPct, transEndPct, visStart, visEnd, totalSec) {
582
+ return `
583
+ @keyframes fp-${i} {
584
+ 0%, ${padBefore(parseFloat(enterStartPct), KEYFRAME_EPSILON.slide, 2)}% { transform: translate${axis}(${entersSliding ? size : 0}px); }
585
+ ${startPct} { transform: translate${axis}(0); }
586
+ ${holdEndPct} { transform: translate${axis}(0); }
587
+ ${transEndPct} { transform: translate${axis}(-${size}px); }
588
+ ${padAfter(parseFloat(transEndPct), KEYFRAME_EPSILON.slide, 2)}%, 100% { transform: translate${axis}(-${size}px); }
589
+ }
590
+ @keyframes fv-${i} {
591
+ 0%, ${padBefore(parseFloat(enterStartPct), KEYFRAME_EPSILON.slide, 2)}% { opacity: 0; }
592
+ ${enterStartPct} { opacity: 1; }
593
+ ${transEndPct} { opacity: 1; }
594
+ ${padAfter(parseFloat(transEndPct), KEYFRAME_EPSILON.slide, 2)}%, 100% { opacity: 0; }
595
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
596
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
597
+ .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`;
598
+ }
613
599
  /**
614
600
  * Render a frame-local SVG overlay. The embedded SVG markup is wrapped in a
615
601
  * `<g transform="translate(x y)" clip-path="..."/>` and an inner
@@ -744,6 +730,3 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
744
730
  }
745
731
  return out.length === 0 ? "" : "\n" + out.join("\n");
746
732
  }
747
- function escapeXml(s) {
748
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
749
- }