domotion-svg 0.4.2 → 0.6.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 (211) hide show
  1. package/README.md +34 -4
  2. package/dist/animation/animator.d.ts +71 -3
  3. package/dist/animation/animator.js +247 -76
  4. package/dist/animation/index.d.ts +1 -0
  5. package/dist/animation/index.js +3 -2
  6. package/dist/animation/magic-move.d.ts +61 -0
  7. package/dist/animation/magic-move.js +243 -0
  8. package/dist/capture/index.js +117 -3
  9. package/dist/capture/script/index.js +14 -1
  10. package/dist/capture/script/pseudo-rules.d.ts +1 -1
  11. package/dist/capture/script/pseudo-rules.js +10 -3
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -1
  13. package/dist/capture/script/walker/borders-backgrounds.js +22 -9
  14. package/dist/capture/script/walker/masks-clips.js +35 -12
  15. package/dist/capture/script/walker/text-segments.d.ts +2 -2
  16. package/dist/capture/script/walker/text-segments.js +14 -3
  17. package/dist/capture/script.generated.js +1 -1
  18. package/dist/capture/types.d.ts +24 -0
  19. package/dist/cli/animate.d.ts +378 -0
  20. package/dist/cli/animate.js +740 -201
  21. package/dist/cli/capture.js +95 -5
  22. package/dist/cli/common.d.ts +35 -2
  23. package/dist/cli/common.js +92 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +76 -38
  26. package/dist/cli/review.d.ts +18 -0
  27. package/dist/cli/review.js +213 -0
  28. package/dist/cli/svg-to-video-core.d.ts +133 -0
  29. package/dist/cli/svg-to-video-core.js +533 -0
  30. package/dist/cli/svg-to-video.d.ts +14 -0
  31. package/dist/cli/svg-to-video.js +161 -0
  32. package/dist/render/borders.d.ts +50 -0
  33. package/dist/render/borders.js +55 -0
  34. package/dist/render/element-tree-to-svg.d.ts +60 -2
  35. package/dist/render/element-tree-to-svg.js +273 -71
  36. package/dist/render/embedded-font-builder.js +38 -0
  37. package/dist/render/form-controls.js +2 -2
  38. package/dist/render/glyph-helper.d.ts +73 -0
  39. package/dist/render/{coretext.js → glyph-helper.js} +68 -21
  40. package/dist/render/helper-acquire.d.ts +67 -0
  41. package/dist/render/helper-acquire.js +189 -0
  42. package/dist/render/index.d.ts +2 -1
  43. package/dist/render/index.js +6 -1
  44. package/dist/render/text-to-path.d.ts +145 -0
  45. package/dist/render/text-to-path.js +863 -92
  46. package/dist/render/text.js +153 -13
  47. package/dist/review/client.bundle.generated.d.ts +1 -0
  48. package/dist/review/client.bundle.generated.js +3 -0
  49. package/dist/review/client.d.ts +10 -0
  50. package/dist/review/client.js +197 -0
  51. package/dist/review/compare-pngs.d.ts +151 -0
  52. package/dist/review/compare-pngs.js +475 -0
  53. package/dist/review/region-overlay.d.ts +50 -0
  54. package/dist/review/region-overlay.js +361 -0
  55. package/dist/review/server.d.ts +45 -0
  56. package/dist/review/server.js +140 -0
  57. package/dist/scroll/composer.d.ts +7 -1
  58. package/dist/scroll/composer.js +14 -10
  59. package/package.json +14 -10
  60. package/dist/animation/animator.test.d.ts +0 -5
  61. package/dist/animation/animator.test.js +0 -346
  62. package/dist/animation/cursor-overlay.test.d.ts +0 -1
  63. package/dist/animation/cursor-overlay.test.js +0 -88
  64. package/dist/cross-origin-font-face.test.d.ts +0 -1
  65. package/dist/cross-origin-font-face.test.js +0 -107
  66. package/dist/dark-mode-capture.test.d.ts +0 -1
  67. package/dist/dark-mode-capture.test.js +0 -158
  68. package/dist/dark-mode-form-controls.test.d.ts +0 -1
  69. package/dist/dark-mode-form-controls.test.js +0 -218
  70. package/dist/embed-remote-images.test.d.ts +0 -1
  71. package/dist/embed-remote-images.test.js +0 -424
  72. package/dist/kerfjs-imports.test.d.ts +0 -1
  73. package/dist/kerfjs-imports.test.js +0 -36
  74. package/dist/mask.test.d.ts +0 -1
  75. package/dist/mask.test.js +0 -211
  76. package/dist/post-processing/optimize.test.d.ts +0 -1
  77. package/dist/post-processing/optimize.test.js +0 -40
  78. package/dist/preserve-aspect-ratio.test.d.ts +0 -1
  79. package/dist/preserve-aspect-ratio.test.js +0 -38
  80. package/dist/render/borders.test.d.ts +0 -1
  81. package/dist/render/borders.test.js +0 -148
  82. package/dist/render/conic-raster.test.d.ts +0 -1
  83. package/dist/render/conic-raster.test.js +0 -187
  84. package/dist/render/coretext.d.ts +0 -60
  85. package/dist/render/coretext.test.d.ts +0 -1
  86. package/dist/render/coretext.test.js +0 -94
  87. package/dist/render/gradients.test.d.ts +0 -1
  88. package/dist/render/gradients.test.js +0 -184
  89. package/dist/render/text-to-path.test.d.ts +0 -1
  90. package/dist/render/text-to-path.test.js +0 -951
  91. package/dist/render/text.test.d.ts +0 -1
  92. package/dist/render/text.test.js +0 -234
  93. package/dist/scroll/composer.test.d.ts +0 -1
  94. package/dist/scroll/composer.test.js +0 -452
  95. package/dist/scroll/executor.test.d.ts +0 -1
  96. package/dist/scroll/executor.test.js +0 -236
  97. package/dist/scroll/hoist-fixed.test.d.ts +0 -1
  98. package/dist/scroll/hoist-fixed.test.js +0 -103
  99. package/dist/scroll/hoist-sticky.test.d.ts +0 -1
  100. package/dist/scroll/hoist-sticky.test.js +0 -154
  101. package/dist/scroll/pattern.test.d.ts +0 -1
  102. package/dist/scroll/pattern.test.js +0 -438
  103. package/dist/stacking-context.test.d.ts +0 -1
  104. package/dist/stacking-context.test.js +0 -927
  105. package/dist/tree-ops/frame-merge.test.d.ts +0 -6
  106. package/dist/tree-ops/frame-merge.test.js +0 -189
  107. package/dist/tree-ops/resize-embedded-images.test.d.ts +0 -9
  108. package/dist/tree-ops/resize-embedded-images.test.js +0 -255
  109. package/dist/tree-ops/tree-diff.test.d.ts +0 -1
  110. package/dist/tree-ops/tree-diff.test.js +0 -267
  111. package/dist/tree-ops/viewbox-culling.test.d.ts +0 -1
  112. package/dist/tree-ops/viewbox-culling.test.js +0 -240
  113. package/dist/utils/region-feedback.test.d.ts +0 -1
  114. package/dist/utils/region-feedback.test.js +0 -221
  115. package/dist/webfont-unicode-range.test.d.ts +0 -1
  116. package/dist/webfont-unicode-range.test.js +0 -174
  117. package/src/animation/animator.test.ts +0 -368
  118. package/src/animation/animator.ts +0 -832
  119. package/src/animation/cursor-overlay.test.ts +0 -95
  120. package/src/animation/cursor-overlay.ts +0 -295
  121. package/src/animation/index.ts +0 -29
  122. package/src/capture/embed.ts +0 -305
  123. package/src/capture/emoji.ts +0 -226
  124. package/src/capture/index.ts +0 -1309
  125. package/src/capture/script/color-norm.ts +0 -80
  126. package/src/capture/script/emoji-detect.ts +0 -97
  127. package/src/capture/script/font-metrics.ts +0 -130
  128. package/src/capture/script/index.ts +0 -1506
  129. package/src/capture/script/placeholder-shown.ts +0 -54
  130. package/src/capture/script/pseudo-rules.ts +0 -223
  131. package/src/capture/script/utils.ts +0 -18
  132. package/src/capture/script/walker/borders-backgrounds.ts +0 -277
  133. package/src/capture/script/walker/counter-style-resolver.ts +0 -184
  134. package/src/capture/script/walker/form-controls.ts +0 -242
  135. package/src/capture/script/walker/input-value.ts +0 -221
  136. package/src/capture/script/walker/lists-counters.ts +0 -110
  137. package/src/capture/script/walker/masks-clips.ts +0 -181
  138. package/src/capture/script/walker/pseudo-content.ts +0 -784
  139. package/src/capture/script/walker/pseudo-inject.ts +0 -203
  140. package/src/capture/script/walker/replaced-elements.ts +0 -102
  141. package/src/capture/script/walker/text-segments.ts +0 -404
  142. package/src/capture/script/walker/transforms.ts +0 -160
  143. package/src/capture/script/warnings.ts +0 -39
  144. package/src/capture/script.generated.ts +0 -7
  145. package/src/capture/types.ts +0 -891
  146. package/src/capture/warnings.ts +0 -39
  147. package/src/cli/animate.ts +0 -521
  148. package/src/cli/capture.ts +0 -200
  149. package/src/cli/common.ts +0 -126
  150. package/src/cli/index.ts +0 -170
  151. package/src/cross-origin-font-face.test.ts +0 -119
  152. package/src/dark-mode-capture.test.ts +0 -178
  153. package/src/dark-mode-form-controls.test.ts +0 -229
  154. package/src/embed-remote-images.test.ts +0 -460
  155. package/src/globals.d.ts +0 -2
  156. package/src/index.ts +0 -82
  157. package/src/kerf-jsx-augmentation.d.ts +0 -36
  158. package/src/kerfjs-imports.test.tsx +0 -45
  159. package/src/mask.test.ts +0 -279
  160. package/src/post-processing/gzip.ts +0 -14
  161. package/src/post-processing/index.ts +0 -5
  162. package/src/post-processing/optimize.test.ts +0 -48
  163. package/src/post-processing/optimize.ts +0 -34
  164. package/src/preserve-aspect-ratio.test.ts +0 -49
  165. package/src/render/borders.test.ts +0 -160
  166. package/src/render/borders.ts +0 -783
  167. package/src/render/box-shadow.ts +0 -65
  168. package/src/render/colors.ts +0 -163
  169. package/src/render/conic-raster.test.ts +0 -213
  170. package/src/render/conic-raster.ts +0 -306
  171. package/src/render/coretext.test.ts +0 -131
  172. package/src/render/coretext.ts +0 -256
  173. package/src/render/css-tokens.ts +0 -44
  174. package/src/render/element-tree-to-svg.ts +0 -5660
  175. package/src/render/embedded-font-builder.ts +0 -221
  176. package/src/render/form-controls.ts +0 -1195
  177. package/src/render/format.ts +0 -24
  178. package/src/render/gradients.test.ts +0 -221
  179. package/src/render/gradients.ts +0 -1047
  180. package/src/render/index.ts +0 -22
  181. package/src/render/opentype.js.d.ts +0 -7
  182. package/src/render/text-to-path.test.ts +0 -1050
  183. package/src/render/text-to-path.ts +0 -2902
  184. package/src/render/text.test.ts +0 -262
  185. package/src/render/text.ts +0 -1017
  186. package/src/render/transforms.ts +0 -41
  187. package/src/scroll/composer.test.ts +0 -505
  188. package/src/scroll/composer.ts +0 -375
  189. package/src/scroll/executor.test.ts +0 -270
  190. package/src/scroll/executor.ts +0 -592
  191. package/src/scroll/hoist-fixed.test.ts +0 -117
  192. package/src/scroll/hoist-fixed.ts +0 -95
  193. package/src/scroll/hoist-sticky.test.ts +0 -173
  194. package/src/scroll/hoist-sticky.ts +0 -193
  195. package/src/scroll/index.ts +0 -36
  196. package/src/scroll/pattern.test.ts +0 -531
  197. package/src/scroll/pattern.ts +0 -610
  198. package/src/stacking-context.test.ts +0 -968
  199. package/src/tree-ops/frame-merge.test.ts +0 -208
  200. package/src/tree-ops/frame-merge.ts +0 -470
  201. package/src/tree-ops/index.ts +0 -11
  202. package/src/tree-ops/resize-embedded-images.test.ts +0 -292
  203. package/src/tree-ops/resize-embedded-images.ts +0 -177
  204. package/src/tree-ops/tree-diff.test.ts +0 -295
  205. package/src/tree-ops/tree-diff.ts +0 -236
  206. package/src/tree-ops/viewbox-culling.test.ts +0 -292
  207. package/src/tree-ops/viewbox-culling.ts +0 -327
  208. package/src/utils/escapeHtml.ts +0 -16
  209. package/src/utils/region-feedback.test.ts +0 -261
  210. package/src/utils/region-feedback.ts +0 -216
  211. package/src/webfont-unicode-range.test.ts +0 -207
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # Domotion
1
+ <p align="center">
2
+ <img src="examples/output/domotion-word-demo.svg" alt="Domotion — an animated wordmark cycling through twenty neon-retro typographic variants of the word domotion" width="600">
3
+ </p>
2
4
 
3
5
  DOM-to-animated-SVG renderer. Captures HTML/CSS rendered in headless Chromium and converts the captured tree into a self-contained SVG with optional CSS animations — pixel-faithful to what Chromium painted, scales crisply at any size, and embeds without external assets.
4
6
 
@@ -39,6 +41,9 @@ yourself to keep the first job's runtime down.
39
41
  The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
40
42
 
41
43
  ```bash
44
+ # Zero-install: run the published CLI straight from npm.
45
+ npx domotion-svg capture https://example.com -o example.svg
46
+
42
47
  # Capture a URL as SVG.
43
48
  domotion capture https://example.com -o example.svg
44
49
 
@@ -59,21 +64,46 @@ For a multi-frame animated SVG, write a small JSON config and run `domotion anim
59
64
  domotion animate ./demo.json
60
65
  ```
61
66
 
62
- The config describes each frame (input URL or HTML file, duration, transition, optional pre-capture actions like `click` / `fill` / `scroll` / `hover`). See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
67
+ The config describes each frame (input, duration, transition) plus a declarative surface for interaction demos: continuous-session frames that carry client-side state across steps (omit `input` / set `"continue": true`), DOM-mutation and interaction actions, richer readiness waits (`waitForText` / `waitForGone` / `waitForCount`), typing / tap / svg / blink overlays that can anchor to an element's box, an on-screen `cursor` (explicit or `"auto"`), `vars` + `${}` interpolation, and a small `evaluate` escape hatch. See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
68
+
69
+ ### Export to video
70
+
71
+ The package also ships a standalone `svg-to-video` CLI that renders an animated SVG (a `domotion animate` output, or any CSS-/SMIL-animated SVG) to a video file. It steps the animation timeline frame by frame in Chromium for frame-accurate timing, then pipes the frames to **ffmpeg** (a required external dependency — install via `brew` / `apt` / `winget`).
72
+
73
+ ```bash
74
+ # h264/mp4 at 30fps, contained to 1280px wide.
75
+ svg-to-video demo.svg -o demo.mp4 --width 1280
76
+
77
+ # 60fps VP9/webm with looping background music.
78
+ svg-to-video demo.svg -o demo.webm --format vp9 --fps 60 --music bed.mp3
79
+ ```
80
+
81
+ Supports target size (`--width`/`--height`, aspect-preserving), `--fps`, `--format` / `--container`, supersampling (`--scale`), background music / foreground audio / captions, and a disk-space pre-flight. See `svg-to-video --help`.
82
+
83
+ ### Reviewing a regression
84
+
85
+ If a capture comes out looking different from how Chromium painted the source page, the package ships an `svg-review` CLI to help you file a focused bug report. Capture once with `--debug` to get a reproduction bundle (HAR + the Chromium screenshot of the source + the SVG we produced), then open the bundle in the local review UI:
86
+
87
+ ```sh
88
+ domotion capture https://example.com --debug -o example.svg
89
+ svg-review --expected example.debug/expected.png --actual example.debug/actual.svg
90
+ ```
91
+
92
+ The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
63
93
 
64
94
  ### Scripting API
65
95
 
66
96
  When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
67
97
 
68
98
  ```ts
69
- import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
99
+ import { captureElementTree, elementTreeToSvg, launchChromium } from "domotion-svg";
70
100
 
71
101
  const browser = await launchChromium();
72
102
  const page = await browser.newPage();
73
103
  await page.setContent(`<div style="padding:20px;color:white;background:#0d1117">Hello</div>`);
74
104
 
75
105
  const tree = await captureElementTree(page, "body", { x: 0, y: 0, width: 800, height: 200 });
76
- const svg = wrapSvg(elementTreeToSvg(tree, 800, 200), 800, 200);
106
+ const svg = elementTreeToSvg(tree, 800, 200);
77
107
 
78
108
  console.log(svg);
79
109
  await browser.close();
@@ -5,6 +5,7 @@
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
7
  import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
8
+ import type { MagicMove } from "./magic-move.js";
8
9
  export interface AnimationFrame {
9
10
  /** SVG content for this frame (from dom-to-svg) */
10
11
  svgContent: string;
@@ -28,11 +29,26 @@ export interface AnimationFrame {
28
29
  * `crossfade` (default) overlaps fade-out and fade-in. `push-left` slides
29
30
  * the outgoing frame off and the incoming frame in from the right.
30
31
  * `scroll` keeps both visible during the transition. `cut` is instant —
31
- * no fade, no slide. For `cut`, `duration` is ignored.
32
+ * no fade, no slide. For `cut`, `duration` is ignored. `magic-move` blends
33
+ * shared elements between the two frames — matched elements slide from
34
+ * their old position to their new one while added/removed elements
35
+ * cross-fade (DM-898; see `docs/53-magic-move-transition.md`). It requires
36
+ * the per-frame `magicMove` bridge layer (built caller-side from the
37
+ * element trees); when that's absent it degrades to `crossfade`.
32
38
  */
33
- type: "crossfade" | "push-left" | "scroll" | "cut";
39
+ type: "crossfade" | "push-left" | "scroll" | "cut" | "magic-move";
34
40
  duration: number;
35
41
  };
42
+ /**
43
+ * Magic-move bridge layer for this frame's transition to the next, built by
44
+ * the caller via `buildMagicMove(prevTree, nextTree, …)`. Present only when
45
+ * `transition.type === "magic-move"` and both frames' element trees were
46
+ * available; the animator shows it during the transition window (moved
47
+ * elements slide, added fade in, removed fade out) between the hard-cut prev
48
+ * and next frame blobs. When `transition.type` is `magic-move` but this is
49
+ * null, the animator falls back to `crossfade`.
50
+ */
51
+ magicMove?: MagicMove | null;
36
52
  /** Overlays: typing, tap ripple */
37
53
  overlays?: AnimationOverlay[];
38
54
  /**
@@ -69,6 +85,17 @@ export interface TypingOverlay {
69
85
  * sits on a clean background.
70
86
  */
71
87
  bgHeight?: number;
88
+ /**
89
+ * DM-870: render a blinking insertion caret. The bar sweeps the type
90
+ * position while typing, then parks at the end of the text and blinks
91
+ * (opacity 1↔0) until the frame ends. `true` uses defaults (the typing
92
+ * `color`, 2px wide, ~530ms cadence); an object overrides them.
93
+ */
94
+ caret?: boolean | {
95
+ color?: string;
96
+ width?: number;
97
+ blinkMs?: number;
98
+ };
72
99
  }
73
100
  export interface TapOverlay {
74
101
  kind: "tap";
@@ -122,7 +149,29 @@ export interface SvgOverlay {
122
149
  delay?: number;
123
150
  };
124
151
  }
125
- export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay;
152
+ /**
153
+ * DM-871: a standalone blinking bar/box, for carets/dots not tied to a typing
154
+ * overlay — a recording dot, an attention pulse on a focused field, a cursor.
155
+ * Renders a rect that toggles opacity on a `periodMs` cycle for the frame's
156
+ * hold (sugar over a rect + a repeating opacity animation).
157
+ */
158
+ export interface BlinkOverlay {
159
+ kind: "blink";
160
+ /** Top-left corner in the captured frame's coordinate space. */
161
+ x: number;
162
+ y: number;
163
+ width: number;
164
+ height: number;
165
+ /** Full on/off cycle in ms (default 1000). */
166
+ periodMs?: number;
167
+ /** Fill color (default a light gray). */
168
+ color?: string;
169
+ /** Corner radius — set to half the width/height for a dot. */
170
+ radius?: number;
171
+ /** Ms after the frame becomes visible before blinking starts. Default 0. */
172
+ delay?: number;
173
+ }
174
+ export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay | BlinkOverlay;
126
175
  /**
127
176
  * Animate a CSS property on captured elements that match a selector, while
128
177
  * the frame is held on screen. The selector is resolved against the source
@@ -154,6 +203,17 @@ export interface IntraFrameAnimation {
154
203
  easing?: string;
155
204
  /** Ms after the frame becomes visible before animation starts. Default 0. */
156
205
  delay?: number;
206
+ /**
207
+ * DM-869: repeat count. A positive integer or `"infinite"`. When set, the
208
+ * animation loops on its own `duration` clock (CSS `animation-iteration-count`)
209
+ * rather than playing once — turning a property animation into a blink / pulse
210
+ * / breathe. The loop is only visible while the frame is on screen (the frame
211
+ * group's visibility gating). `"infinite"` is the robust choice for a looping
212
+ * scene; a finite count aligns to the frame's first appearance.
213
+ */
214
+ repeat?: number | "infinite";
215
+ /** DM-869: when true, the loop ping-pongs `from`→`to`→`from` (CSS `animation-direction: alternate`). */
216
+ alternate?: boolean;
157
217
  }
158
218
  export interface AnimationConfig {
159
219
  width: number;
@@ -184,5 +244,13 @@ export interface AnimationConfig {
184
244
  * uses `selector`; otherwise pass undefined / null.
185
245
  */
186
246
  resolveSelector?: SelectorResolver;
247
+ /**
248
+ * Canvas background color painted behind every frame (a full-viewport
249
+ * `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
250
+ * (DM-554): pass the captured page's root background so animated output
251
+ * matches `capture` output. Omitted / `"transparent"` / `"rgba(0, 0, 0, 0)"`
252
+ * → no rect, i.e. a transparent SVG that composites over a host background.
253
+ */
254
+ background?: string;
187
255
  }
188
256
  export declare function generateAnimatedSvg(config: AnimationConfig): string;
@@ -4,7 +4,6 @@
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 { mergeFrames } from "../tree-ops/frame-merge.js";
8
7
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
9
8
  export function generateAnimatedSvg(config) {
10
9
  const { width, height, frames } = config;
@@ -25,19 +24,20 @@ export function generateAnimatedSvg(config) {
25
24
  t += f.duration + td;
26
25
  }
27
26
  }
28
- // Fast path: if every transition is crossfade (or default) or `cut`, merge
29
- // all frames into a single de-duplicated tree with per-element visibility
30
- // timelines. `cut` is just `crossfade` with duration 0 — same merge logic
31
- // applies; it ends up as step-end keyframes flipping at exact frame
32
- // boundaries.
33
- const allMergeable = frames.every((f) => {
34
- const type = f.transition?.type;
35
- return type == null || type === "crossfade" || type === "cut";
36
- });
37
- const anyOverlays = frames.some((f) => f.overlays != null && f.overlays.length > 0);
38
- if (allMergeable && frames.length > 1 && !anyOverlays) {
39
- return composeMergedSvg(config, frameTiming, totalSec);
40
- }
27
+ // Every sequence composites: each frame is emitted as a complete, internally
28
+ // z-ordered `<g class="f f-N">` sub-SVG and switched/faded by opacity.
29
+ //
30
+ // There used to be an element-merge fast path (`mergeFrames`) for cut-only
31
+ // sequences that flattened all frames into one de-duplicated tree to save
32
+ // bytes. DM-854 took crossfade off it (it dropped per-frame z-order and
33
+ // step-end-switched instead of fading); DM-865 then showed it also mis-renders
34
+ // *near-identical* frames the same DOM evolved across frames, as produced by
35
+ // continuous-session capture — because differing text in a shared element slot
36
+ // can't be gated per frame (a bare text node carries no class, and a `<tspan>`
37
+ // with `visibility:hidden` still advances layout, shifting the surviving
38
+ // glyph). Compositing has neither problem. The dedup size win can be recovered
39
+ // later as `<defs>`/symbol-level glyph sharing across intact frame groups,
40
+ // which preserves each frame's layout (tracked with DM-854/DM-865).
41
41
  const frameGroups = [];
42
42
  const keyframes = [];
43
43
  let timeOffset = 0;
@@ -51,6 +51,11 @@ export function generateAnimatedSvg(config) {
51
51
  const prevFrame = i > 0 ? frames[i - 1] : null;
52
52
  const entersViaPush = prevFrame?.transition?.type === "push-left";
53
53
  const entersViaScroll = prevFrame?.transition?.type === "scroll";
54
+ // DM-898: a frame entered from a magic-move transition appears at its own
55
+ // start (= the predecessor's transition end), NOT overlap-faded — the
56
+ // magic-move bridge layer already covered the window, so a crossfade
57
+ // overlap here would double-show the next frame on top of the bridge.
58
+ const entersViaMagicMove = prevFrame?.transition?.type === "magic-move" && prevFrame?.magicMove != null;
54
59
  // Both push-left and scroll overlap their transition with the next
55
60
  // frame's entry — the next frame is already sliding in while the current
56
61
  // one slides out, so its show window starts at `timeOffset - prevTransDur`
@@ -74,15 +79,15 @@ export function generateAnimatedSvg(config) {
74
79
  keyframes.push(`
75
80
  @keyframes fp-${i} {
76
81
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
77
- ${startPct}% { transform: translateX(0); }
78
- ${holdEndPct}% { transform: translateX(0); }
79
- ${transEndPct}% { transform: translateX(-${width}px); }
82
+ ${startPct} { transform: translateX(0); }
83
+ ${holdEndPct} { transform: translateX(0); }
84
+ ${transEndPct} { transform: translateX(-${width}px); }
80
85
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
81
86
  }
82
87
  @keyframes fv-${i} {
83
88
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
84
- ${enterStartPct}% { opacity: 1; }
85
- ${transEndPct}% { opacity: 1; }
89
+ ${enterStartPct} { opacity: 1; }
90
+ ${transEndPct} { opacity: 1; }
86
91
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
87
92
  }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
88
93
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
@@ -103,20 +108,131 @@ export function generateAnimatedSvg(config) {
103
108
  keyframes.push(`
104
109
  @keyframes fp-${i} {
105
110
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
106
- ${startPct}% { transform: translateY(0); }
107
- ${holdEndPct}% { transform: translateY(0); }
108
- ${transEndPct}% { transform: translateY(-${height}px); }
111
+ ${startPct} { transform: translateY(0); }
112
+ ${holdEndPct} { transform: translateY(0); }
113
+ ${transEndPct} { transform: translateY(-${height}px); }
109
114
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
110
115
  }
111
116
  @keyframes fv-${i} {
112
117
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
113
- ${enterStartPct}% { opacity: 1; }
114
- ${transEndPct}% { opacity: 1; }
118
+ ${enterStartPct} { opacity: 1; }
119
+ ${transEndPct} { opacity: 1; }
115
120
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
116
121
  }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
117
122
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
118
123
  .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
119
124
  }
125
+ else if (transType === "magic-move" && frame.magicMove != null) {
126
+ // DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
127
+ // a bridge composite covers the transition window [holdEnd..transEnd],
128
+ // inside which matched elements slide prev→next, added elements fade in,
129
+ // and removed elements fade out. The next frame cuts in at transEnd
130
+ // (= its own start). The bridge's start state matches the prev frame's
131
+ // final paint and its end state the next frame's initial paint, so both
132
+ // hard cuts are seamless. (When `frame.magicMove` is null the type falls
133
+ // 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
+ }
235
+ }
120
236
  else {
121
237
  // Crossfade or cut: opacity in/out.
122
238
  //
@@ -157,7 +273,7 @@ export function generateAnimatedSvg(config) {
157
273
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
158
274
  }
159
275
  else {
160
- const fadeInStartPct = i > 0
276
+ const fadeInStartPct = (i > 0 && !entersViaMagicMove)
161
277
  ? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
162
278
  : startPct;
163
279
  const prevEnd = i > 0
@@ -191,6 +307,11 @@ export function generateAnimatedSvg(config) {
191
307
  frameGroups.push(svgMarkup);
192
308
  keyframes.push(css);
193
309
  }
310
+ else if (overlay.kind === "blink") {
311
+ const { svgMarkup, css } = renderBlinkOverlay(overlay, i, timeOffset, timeOffset + frame.duration, totalDuration, totalSec);
312
+ frameGroups.push(svgMarkup);
313
+ keyframes.push(css);
314
+ }
194
315
  }
195
316
  }
196
317
  timeOffset += frame.duration + transDur;
@@ -216,6 +337,13 @@ export function generateAnimatedSvg(config) {
216
337
  const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
217
338
  overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
218
339
  }
340
+ // Canvas background rect — only when a non-transparent background is given.
341
+ // Default (none / transparent) emits nothing so the SVG composites over the
342
+ // host page, matching the single-frame `transparentRootBgRect` path (DM-554).
343
+ const bg = config.background;
344
+ const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
345
+ ? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
346
+ : "";
219
347
  const out = `<?xml version="1.0" encoding="UTF-8"?>
220
348
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
221
349
  <defs>
@@ -227,8 +355,7 @@ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss +
227
355
  ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
228
356
  </style>
229
357
  <g clip-path="url(#viewport-clip)">
230
- <rect width="${width}" height="${height}" fill="#0d1117" />
231
- ${frameGroups.join("\n")}${overlayMarkup}
358
+ ${canvasBgRect}${frameGroups.join("\n")}${overlayMarkup}
232
359
  </g>
233
360
  </svg>`;
234
361
  return out;
@@ -337,6 +464,8 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
337
464
  // width-growing clip during the slice of the type timeline when that line's
338
465
  // characters are typed (line N starts after line N-1 finishes), so the caret
339
466
  // advances down the field exactly as it would in the browser.
467
+ // DM-870: per-line type timing, collected for the optional caret below.
468
+ const lineTimings = [];
340
469
  let cumChars = 0;
341
470
  lines.forEach((line, li) => {
342
471
  const lineY = overlay.y + li * lineHeight;
@@ -345,8 +474,11 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
345
474
  // is where the caret would sit just after the typed character anyway.
346
475
  const lineWidth = line.length * charWidth + charWidth;
347
476
  const clipId = `${id}-clip${li}`;
348
- const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
349
- const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
477
+ const lineStartMs = typeStartMs + (cumChars / visibleChars) * effTypeDur;
478
+ const lineEndMs = typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur;
479
+ const lineStartPct = pct(lineStartMs, totalDuration);
480
+ const lineEndPct = pct(lineEndMs, totalDuration);
481
+ lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
350
482
  cumChars += line.length;
351
483
  parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
352
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>`);
@@ -359,6 +491,48 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
359
491
  cssRules.push(`
360
492
  @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
361
493
  .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
494
+ // DM-870: blinking insertion caret. Sweeps the type position while typing
495
+ // (one linear translate segment per wrapped line, jumping to the next line's
496
+ // start), then parks at the end of the last line and blinks (step-end opacity
497
+ // toggle) until the overlay disappears. Two animations on one rect: a linear
498
+ // position track + a step-end opacity blink.
499
+ if (overlay.caret != null && overlay.caret !== false && lineTimings.length > 0) {
500
+ const caretOpts = typeof overlay.caret === "object" ? overlay.caret : {};
501
+ const caretColor = caretOpts.color ?? color;
502
+ const caretW = caretOpts.width ?? 2;
503
+ const blinkMs = caretOpts.blinkMs ?? 530;
504
+ const last = lineTimings[lineTimings.length - 1];
505
+ const endX = last.len * charWidth;
506
+ const endY = last.li * lineHeight;
507
+ // Position track: hold at line 0 start until typing begins, then sweep each
508
+ // line, then hold at the text end through the blink + disappear.
509
+ const posStops = [`0%, ${typeStartPct} { transform: translate(0px, 0px); }`];
510
+ for (const lt of lineTimings) {
511
+ posStops.push(`${pct(lt.startMs, totalDuration)} { transform: translate(0px, ${lt.li * lineHeight}px); }`);
512
+ posStops.push(`${pct(lt.endMs, totalDuration)} { transform: translate(${lt.len * charWidth}px, ${lt.li * lineHeight}px); }`);
513
+ }
514
+ posStops.push(`${holdEndPct}, 100% { transform: translate(${endX}px, ${endY}px); }`);
515
+ // Blink: invisible until typing starts, solid through typing, then toggle
516
+ // on/off every half-period until the overlay disappears.
517
+ const blinkStops = [
518
+ `0%, ${typeStartPct} { opacity: 0; }`,
519
+ `${pct(typeStartMs + 30, totalDuration)} { opacity: 1; }`,
520
+ `${pct(textEndMs, totalDuration)} { opacity: 1; }`,
521
+ ];
522
+ let t = textEndMs + blinkMs / 2;
523
+ let on = false;
524
+ while (t < holdEndMs) {
525
+ blinkStops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
526
+ t += blinkMs / 2;
527
+ on = !on;
528
+ }
529
+ blinkStops.push(`${disappearPct}, 100% { opacity: 0; }`);
530
+ parts.push(` <rect class="${id}-caret" x="${overlay.x}" y="${overlay.y - fontSize + 2}" width="${caretW}" height="${fontSize}" fill="${caretColor}" />`);
531
+ cssRules.push(`
532
+ @keyframes ${id}-caret-pos { ${posStops.join(" ")} }
533
+ @keyframes ${id}-caret-blink { ${blinkStops.join(" ")} }
534
+ .${id}-caret { animation: ${id}-caret-pos ${totalSec.toFixed(2)}s linear infinite, ${id}-caret-blink ${totalSec.toFixed(2)}s step-end infinite; }`);
535
+ }
362
536
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
363
537
  }
364
538
  function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
@@ -380,6 +554,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
380
554
  .${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
381
555
  return { svgMarkup, css };
382
556
  }
557
+ function renderBlinkOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
558
+ const id = `blink${frameIdx}`;
559
+ const period = overlay.periodMs ?? 1000;
560
+ const color = overlay.color ?? "#e6edf3";
561
+ const startMs = frameStart + (overlay.delay ?? 0);
562
+ const radiusAttr = overlay.radius != null ? ` rx="${overlay.radius}" ry="${overlay.radius}"` : "";
563
+ // Toggle opacity on/off every half-period across the frame's hold, then off.
564
+ // step-end keeps each state until the next stop (a hard blink, not a fade).
565
+ const stops = [`0%, ${pct(startMs, totalDuration)} { opacity: 0; }`];
566
+ let t = startMs;
567
+ let on = true;
568
+ while (t < frameEnd) {
569
+ stops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
570
+ t += period / 2;
571
+ on = !on;
572
+ }
573
+ stops.push(`${pct(frameEnd, totalDuration)}, 100% { opacity: 0; }`);
574
+ const svgMarkup = ` <rect class="${id}" x="${overlay.x}" y="${overlay.y}" width="${overlay.width}" height="${overlay.height}"${radiusAttr} fill="${color}" />`;
575
+ const css = `
576
+ @keyframes ${id} { ${stops.join(" ")} }
577
+ .${id} { animation: ${id} ${totalSec.toFixed(2)}s step-end infinite; }`;
578
+ return { svgMarkup, css };
579
+ }
383
580
  function pct(ms, total) {
384
581
  return `${((ms / total) * 100).toFixed(2)}%`;
385
582
  }
@@ -483,50 +680,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
483
680
  return `translate(-${w}px, 0)`;
484
681
  return `translate(${w}px, 0)`; // right
485
682
  }
486
- /**
487
- * Compose the animated SVG using the frame-merge pipeline. Every element in
488
- * every frame is reduced to one render with a visibility timeline. Stable
489
- * elements (prompt, background, typed characters that stay on screen) emit
490
- * once with opacity: 1 throughout; changing elements get step-end keyframes
491
- * that flip their opacity at the appropriate frame boundaries.
492
- */
493
- function composeMergedSvg(config, frameTiming, totalSec) {
494
- const { width, height, frames } = config;
495
- const framesSvg = frames.map((f) => f.svgContent);
496
- const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
497
- const sharedDefsMarkup = config.sharedDefs ?? "";
498
- const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
499
- // DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
500
- const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
501
- // Cursor overlay (DM-277). Same emission as the unmerged path — the
502
- // overlay sits above the merged frame group, clipped to the viewport.
503
- const totalDuration = totalSec * 1000;
504
- let overlayMarkup = "";
505
- if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
506
- const frameStarts = [];
507
- let acc = 0;
508
- for (const f of frames) {
509
- frameStarts.push(acc);
510
- acc += f.duration + transitionDuration(f);
511
- }
512
- const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
513
- overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
514
- }
515
- return `<?xml version="1.0" encoding="UTF-8"?>
516
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
517
- <defs>
518
- <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
519
- </defs>
520
- <style>
521
- ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
522
- ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
523
- </style>
524
- <g clip-path="url(#viewport-clip)">
525
- <rect width="${width}" height="${height}" fill="#0d1117" />
526
- ${merged}${overlayMarkup}
527
- </g>
528
- </svg>`;
529
- }
530
683
  /**
531
684
  * Compile each frame's intra-frame animations into CSS. Each animation gets
532
685
  * a uniquely-named keyframe block whose timing is mapped onto the global
@@ -560,15 +713,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
560
713
  return `${a.property}: ${val};`;
561
714
  };
562
715
  const animName = `f${i}-${a.animId}-${ai}`;
563
- // Hold `from` until startPct, animate from→to during [startPct, endPct],
564
- // hold `to` afterwards. Pre-frame holds use 0% as the from anchor.
565
- out.push(` @keyframes ${animName} {
716
+ if (a.repeat != null) {
717
+ // DM-869: repeating animation (blink / pulse / breathe). The keyframe is
718
+ // a single from→to cycle on the animation's own `duration` clock, looped
719
+ // via animation-iteration-count + (optional) direction:alternate. The
720
+ // loop is only visible while the frame is on screen (the frame group's
721
+ // visibility gating); `animation-delay` aligns the first cycle to the
722
+ // frame's appearance. `fill-mode: both` holds `from` before the delay.
723
+ const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
724
+ const direction = a.alternate === true ? " alternate" : "";
725
+ out.push(` @keyframes ${animName} {
726
+ 0% { ${propValue(a.from)} }
727
+ 100% { ${propValue(a.to)} }
728
+ }
729
+ .anim-${a.animId} { animation: ${animName} ${a.duration}ms ${iterations}${direction}; animation-timing-function: ${easing}; animation-delay: ${startMs.toFixed(0)}ms; animation-fill-mode: both; }`);
730
+ }
731
+ else {
732
+ // One-shot: hold `from` until startPct, animate from→to during
733
+ // [startPct, endPct], hold `to` afterwards, mapped onto the global scene
734
+ // clock so it replays in sync each scene loop.
735
+ out.push(` @keyframes ${animName} {
566
736
  0% { ${propValue(a.from)} }
567
737
  ${startPct.toFixed(3)}% { ${propValue(a.from)} }
568
738
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
569
739
  100% { ${propValue(a.to)} }
570
740
  }
571
741
  .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
742
+ }
572
743
  }
573
744
  }
574
745
  return out.length === 0 ? "" : "\n" + out.join("\n");
@@ -1,2 +1,3 @@
1
+ export { buildMagicMove, type MagicMove, type MagicMoveSlide, } from "./magic-move.js";
1
2
  export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
2
3
  export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
@@ -1,7 +1,8 @@
1
1
  // Public surface of the animation pipeline. The composer
2
2
  // (`generateAnimatedSvg`) consumes per-frame element trees + transition /
3
3
  // overlay config and emits one self-contained SVG with `@keyframes` cross-
4
- // fade / push-left / scroll / cut transitions and optional typing / tap /
5
- // SVG / cursor overlays.
4
+ // fade / push-left / scroll / cut / magic-move transitions and optional
5
+ // typing / tap / SVG / cursor overlays.
6
+ export { buildMagicMove, } from "./magic-move.js";
6
7
  export { generateAnimatedSvg, } from "./animator.js";
7
8
  export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";