domotion-svg 0.1.1

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 (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * SVG Animation Composer
3
+ *
4
+ * Takes captured SVG frame content and composes them into a single
5
+ * animated SVG with CSS keyframe transitions.
6
+ */
7
+ import { mergeFrames } from "./frame-merge.js";
8
+ import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
9
+ export function generateAnimatedSvg(config) {
10
+ const { width, height, frames } = config;
11
+ const totalDuration = frames.reduce((sum, f) => sum + f.duration + transitionDuration(f), 0);
12
+ const totalSec = totalDuration / 1000;
13
+ // Pre-compute per-frame timing windows (used by both the merge pipeline for
14
+ // timeline keyframes and the atomic push/scroll fallbacks below).
15
+ const frameTiming = {
16
+ startPct: [], holdEndPct: [], transEndPct: [],
17
+ };
18
+ {
19
+ let t = 0;
20
+ for (const f of frames) {
21
+ const td = transitionDuration(f);
22
+ frameTiming.startPct.push((t / totalDuration) * 100);
23
+ frameTiming.holdEndPct.push(((t + f.duration) / totalDuration) * 100);
24
+ frameTiming.transEndPct.push(((t + f.duration + td) / totalDuration) * 100);
25
+ t += f.duration + td;
26
+ }
27
+ }
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
+ }
41
+ const frameGroups = [];
42
+ const keyframes = [];
43
+ let timeOffset = 0;
44
+ for (let i = 0; i < frames.length; i++) {
45
+ const frame = frames[i];
46
+ const transDur = transitionDuration(frame);
47
+ const transType = frame.transition?.type ?? "crossfade";
48
+ const startPct = pct(timeOffset, totalDuration);
49
+ const holdEndPct = pct(timeOffset + frame.duration, totalDuration);
50
+ const transEndPct = pct(timeOffset + frame.duration + transDur, totalDuration);
51
+ const prevFrame = i > 0 ? frames[i - 1] : null;
52
+ const entersViaPush = prevFrame?.transition?.type === "push-left";
53
+ const prevTransDur = prevFrame != null ? transitionDuration(prevFrame) : 300;
54
+ const enterStartPct = entersViaPush
55
+ ? pct(timeOffset - prevTransDur, totalDuration)
56
+ : startPct;
57
+ if (transType === "push-left") {
58
+ // Push: slide in from right, slide out to left
59
+ 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>`);
60
+ keyframes.push(`
61
+ @keyframes fp-${i} {
62
+ 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
63
+ ${startPct}% { transform: translateX(0); }
64
+ ${holdEndPct}% { transform: translateX(0); }
65
+ ${transEndPct}% { transform: translateX(-${width}px); }
66
+ ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
67
+ }
68
+ @keyframes fv-${i} {
69
+ 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
70
+ ${enterStartPct}% { opacity: 1; }
71
+ ${transEndPct}% { opacity: 1; }
72
+ ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
73
+ }
74
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }
75
+ .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
76
+ }
77
+ else if (transType === "scroll") {
78
+ // Scroll: keep visible, no fade during scroll, fade only at end
79
+ frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
80
+ const fadeEndPct = pct(timeOffset + frame.duration + transDur + 200, totalDuration);
81
+ const prevEnd = i > 0 ? `${Math.max(0, parseFloat(startPct) - 0.1).toFixed(2)}%,` : "";
82
+ keyframes.push(`
83
+ @keyframes fv-${i} {
84
+ 0%, ${prevEnd} ${fadeEndPct}, 100% { opacity: 0; }
85
+ ${startPct}, ${transEndPct} { opacity: 1; }
86
+ }
87
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
88
+ }
89
+ else {
90
+ // Crossfade or cut: opacity in/out.
91
+ //
92
+ // For `cut` (transDur === 0): use disjoint keyframes with step-end timing
93
+ // so opacity flips instantly at frame boundaries with no interpolation
94
+ // smear — frame N is opaque from startPct to transEndPct EXCLUSIVE, and
95
+ // 0 outside. Without step-end, linear interpolation between distant
96
+ // keyframes makes adjacent frames bleed across the entire cycle.
97
+ //
98
+ // For crossfade: the fade-in OVERLAPS the previous frame's fade-out so
99
+ // shared pixels stay visible during the transition. Linear interpolation
100
+ // is what we want here.
101
+ frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
102
+ const isCut = transType === "cut" || transDur === 0;
103
+ if (isCut) {
104
+ const startNum = parseFloat(startPct);
105
+ const endNum = parseFloat(transEndPct);
106
+ const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
107
+ const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
108
+ keyframes.push(`
109
+ @keyframes fv-${i} {
110
+ 0% { opacity: 0; }
111
+ ${beforeStart}% { opacity: 0; }
112
+ ${startNum.toFixed(3)}% { opacity: 1; }
113
+ ${endNum.toFixed(3)}% { opacity: 1; }
114
+ ${afterEnd}% { opacity: 0; }
115
+ 100% { opacity: 0; }
116
+ }
117
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
118
+ }
119
+ else {
120
+ const fadeInStartPct = i > 0
121
+ ? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
122
+ : startPct;
123
+ const prevEnd = i > 0
124
+ ? `${Math.max(0, parseFloat(fadeInStartPct) - 0.01).toFixed(2)}%,`
125
+ : "";
126
+ keyframes.push(`
127
+ @keyframes fv-${i} {
128
+ 0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
129
+ ${startPct}, ${holdEndPct} { opacity: 1; }
130
+ }
131
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
132
+ }
133
+ }
134
+ // Overlays
135
+ if (frame.overlays != null) {
136
+ for (const overlay of frame.overlays) {
137
+ if (overlay.kind === "typing") {
138
+ const { svgMarkup, css } = renderTypingOverlay(overlay, i, timeOffset, timeOffset + frame.duration, totalDuration, totalSec);
139
+ frameGroups.push(svgMarkup);
140
+ keyframes.push(css);
141
+ }
142
+ else if (overlay.kind === "tap") {
143
+ const { svgMarkup, css } = renderTapOverlay(overlay, i, timeOffset, totalDuration, totalSec);
144
+ frameGroups.push(svgMarkup);
145
+ keyframes.push(css);
146
+ }
147
+ else if (overlay.kind === "svg") {
148
+ const { svgMarkup, css } = renderSvgOverlay(overlay, i, timeOffset, frame.duration, totalDuration, totalSec);
149
+ frameGroups.push(svgMarkup);
150
+ keyframes.push(css);
151
+ }
152
+ }
153
+ }
154
+ timeOffset += frame.duration + transDur;
155
+ }
156
+ // Compose final SVG with XML declaration for proper UTF-8
157
+ const sharedDefsMarkup = config.sharedDefs ?? "";
158
+ const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
159
+ // Cursor overlay (DM-277). The frame start times let the resolver pick
160
+ // which frame's selector matches apply at each event's timestamp.
161
+ let overlayMarkup = "";
162
+ if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
163
+ const frameStarts = [];
164
+ let acc = 0;
165
+ for (const f of frames) {
166
+ frameStarts.push(acc);
167
+ acc += f.duration + transitionDuration(f);
168
+ }
169
+ const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
170
+ overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
171
+ }
172
+ const out = `<?xml version="1.0" encoding="UTF-8"?>
173
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
174
+ <defs>
175
+ <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
176
+ </defs>
177
+ <style>
178
+ .f { opacity: 0; }
179
+ ${keyframes.join("\n")}${animationCss}
180
+ </style>
181
+ <g clip-path="url(#viewport-clip)">
182
+ <rect width="${width}" height="${height}" fill="#0d1117" />
183
+ ${frameGroups.join("\n")}${overlayMarkup}
184
+ </g>
185
+ </svg>`;
186
+ return out;
187
+ }
188
+ /**
189
+ * Effective transition duration for a frame. `cut` is always 0 — the type
190
+ * means "instant" so any duration on the input is meaningless. Default
191
+ * (no transition specified) is 300ms (legacy crossfade duration).
192
+ */
193
+ function transitionDuration(f) {
194
+ if (f.transition == null)
195
+ return 300;
196
+ if (f.transition.type === "cut")
197
+ return 0;
198
+ return f.transition.duration;
199
+ }
200
+ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
201
+ const delay = overlay.delay ?? 300;
202
+ const speed = overlay.speed ?? 60;
203
+ const fontSize = overlay.fontSize ?? 14;
204
+ const charWidth = fontSize * 0.6;
205
+ const color = overlay.color ?? "#e6edf3";
206
+ const typeStartMs = frameStart + delay;
207
+ const parts = [];
208
+ const cssRules = [];
209
+ const id = `t${frameIdx}`;
210
+ // Background mask
211
+ if (overlay.bgColor != null) {
212
+ const bgW = overlay.bgWidth ?? overlay.text.length * charWidth + 8;
213
+ const bgH = overlay.bgHeight ?? fontSize + 6;
214
+ const bgStartPct = pct(typeStartMs, totalDuration);
215
+ const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
216
+ parts.push(` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`);
217
+ cssRules.push(`
218
+ @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${bgEndPct}, 100% { opacity: 0; } }
219
+ .${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
220
+ }
221
+ // Render full text with an animated clip that reveals characters one-by-one.
222
+ // The overlay must disappear by the time the frame ends — otherwise it'll
223
+ // leak across the cut boundary and overlap the next frame's content.
224
+ const textEndMs = typeStartMs + overlay.text.length * speed;
225
+ const holdEndMs = Math.min(frameStart + 3000, frameEnd);
226
+ const fullTextWidth = overlay.text.length * charWidth + 4;
227
+ const textHeight = fontSize + 4;
228
+ const clipId = `${id}-clip`;
229
+ // Clip rect animation: width grows from 0 to full text width
230
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-reveal" x="${overlay.x}" y="${overlay.y - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
231
+ parts.push(` <text class="${id}-text" x="${overlay.x}" y="${overlay.y}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(overlay.text)}</text>`);
232
+ const typeStartPct = pct(typeStartMs, totalDuration);
233
+ const typeEndPct = pct(textEndMs, totalDuration);
234
+ const holdEndPct = pct(holdEndMs, totalDuration);
235
+ cssRules.push(`
236
+ @keyframes ${id}-reveal { 0%, ${typeStartPct} { width: 0; } ${typeEndPct} { width: ${fullTextWidth}px; } ${holdEndPct} { width: ${fullTextWidth}px; } ${pct(holdEndMs + 100, totalDuration)}, 100% { width: 0; } }
237
+ .${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
238
+ @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
239
+ .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
240
+ return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
241
+ }
242
+ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
243
+ const delay = overlay.delay ?? 50;
244
+ const tapMs = frameStart + delay;
245
+ const rippleDur = 500;
246
+ const id = `tap${frameIdx}`;
247
+ const tapStartPct = pct(tapMs, totalDuration);
248
+ const tapPeakPct = pct(tapMs + rippleDur * 0.3, totalDuration);
249
+ const tapEndPct = pct(tapMs + rippleDur, totalDuration);
250
+ const svgMarkup = [
251
+ ` <circle class="${id}" cx="${overlay.x}" cy="${overlay.y}" r="0" fill="rgba(255,255,255,0.35)" />`,
252
+ ` <circle class="${id}-dot" cx="${overlay.x}" cy="${overlay.y}" r="7" fill="rgba(255,255,255,0.5)" />`,
253
+ ].join("\n");
254
+ const css = `
255
+ @keyframes ${id} { 0%, ${tapStartPct} { r: 0; opacity: 0.4; } ${tapPeakPct} { r: 28; opacity: 0.2; } ${tapEndPct}, 100% { r: 35; opacity: 0; } }
256
+ .${id} { animation: ${id} ${totalSec.toFixed(2)}s infinite; }
257
+ @keyframes ${id}-dot { 0%, ${tapStartPct} { opacity: 0; } ${pct(tapMs + 20, totalDuration)} { opacity: 0.6; } ${tapPeakPct} { opacity: 0.2; } ${tapEndPct}, 100% { opacity: 0; } }
258
+ .${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
259
+ return { svgMarkup, css };
260
+ }
261
+ function pct(ms, total) {
262
+ return `${((ms / total) * 100).toFixed(2)}%`;
263
+ }
264
+ /**
265
+ * Render a frame-local SVG overlay. The embedded SVG markup is wrapped in a
266
+ * `<g transform="translate(x y)" clip-path="..."/>` and an inner
267
+ * `<g class="ov-<id>">` so `enter`/`exit` / explicit animations can target
268
+ * the overlay without colliding with classes inside the embedded SVG.
269
+ */
270
+ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDuration, totalSec) {
271
+ const id = `ov-${frameIdx}-${overlay.animId}`;
272
+ const clipId = `${id}-clip`;
273
+ const visibilityId = `${id}-vis`;
274
+ const overlayEnd = frameStart + frameHoldMs;
275
+ const visStart = pct(frameStart, totalDuration);
276
+ const visEnd = pct(overlayEnd, totalDuration);
277
+ const cssRules = [];
278
+ // Visibility timeline: hidden until frame start, visible during the hold.
279
+ cssRules.push(`
280
+ @keyframes ${visibilityId} { 0%, ${pct(Math.max(0, frameStart - 1), totalDuration)} { opacity: 0; } ${visStart} { opacity: 1; } ${visEnd} { opacity: 1; } ${pct(overlayEnd + 1, totalDuration)}, 100% { opacity: 0; } }
281
+ .${id} { animation: ${visibilityId} ${totalSec.toFixed(2)}s infinite; }`);
282
+ // Slide-in entrance (DM-211): translate from off-screen to (0, 0) over
283
+ // duration ms, starting at frame start + delay.
284
+ if (overlay.enter != null) {
285
+ const e = overlay.enter;
286
+ const easing = e.easing ?? "ease-out";
287
+ const enterDelay = e.delay ?? 0;
288
+ const fromStr = offsetForDirection(e.from, overlay.width, overlay.height, true);
289
+ const enterStart = frameStart + enterDelay;
290
+ const enterEnd = enterStart + e.duration;
291
+ const enterId = `${id}-enter`;
292
+ cssRules.push(`
293
+ @keyframes ${enterId} { 0% { transform: ${fromStr}; } ${pct(enterStart, totalDuration)} { transform: ${fromStr}; } ${pct(enterEnd, totalDuration)} { transform: translate(0, 0); } 100% { transform: translate(0, 0); } }
294
+ .${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
295
+ }
296
+ // Slide-out exit. Mirror of enter — translate from (0,0) to off-screen.
297
+ if (overlay.exit != null) {
298
+ const e = overlay.exit;
299
+ const easing = e.easing ?? "ease-in";
300
+ const exitDelay = e.delay ?? 0;
301
+ const toStr = offsetForDirection(e.from, overlay.width, overlay.height, false);
302
+ const exitStart = overlayEnd - e.duration - exitDelay;
303
+ const exitId = `${id}-exit`;
304
+ cssRules.push(`
305
+ @keyframes ${exitId} { 0%, ${pct(exitStart, totalDuration)} { transform: translate(0, 0); } ${pct(exitStart + e.duration, totalDuration)} { transform: ${toStr}; } 100% { transform: ${toStr}; } }
306
+ .${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
307
+ }
308
+ // Markup: outer wrapper translates to (x, y) and clips, inner wrapper
309
+ // carries the visibility class, then the enter/exit transform wrapper, then
310
+ // the inlined SVG content.
311
+ const enterClass = overlay.enter != null ? ` ${id}-enter` : "";
312
+ const exitClass = overlay.exit != null ? ` ${id}-exit` : "";
313
+ const svgMarkup = ` <g transform="translate(${overlay.x} ${overlay.y})" clip-path="url(#${clipId})">
314
+ <defs><clipPath id="${clipId}"><rect width="${overlay.width}" height="${overlay.height}"/></clipPath></defs>
315
+ <g class="${id}${enterClass}${exitClass}">${overlay.innerSvg}</g>
316
+ </g>`;
317
+ return { svgMarkup, css: cssRules.join("") };
318
+ }
319
+ /**
320
+ * Offset string for a slide direction. When `outFrom` is true the offset is
321
+ * the off-screen starting position (i.e. the overlay sits there before
322
+ * animating to `(0,0)`). When false, it's the off-screen end position
323
+ * (overlay animates from `(0,0)` to here on exit).
324
+ */
325
+ function offsetForDirection(dir, w, h, _outFrom) {
326
+ if (dir === "top")
327
+ return `translate(0, -${h}px)`;
328
+ if (dir === "bottom")
329
+ return `translate(0, ${h}px)`;
330
+ if (dir === "left")
331
+ return `translate(-${w}px, 0)`;
332
+ return `translate(${w}px, 0)`; // right
333
+ }
334
+ /**
335
+ * Compose the animated SVG using the frame-merge pipeline. Every element in
336
+ * every frame is reduced to one render with a visibility timeline. Stable
337
+ * elements (prompt, background, typed characters that stay on screen) emit
338
+ * once with opacity: 1 throughout; changing elements get step-end keyframes
339
+ * that flip their opacity at the appropriate frame boundaries.
340
+ */
341
+ function composeMergedSvg(config, frameTiming, totalSec) {
342
+ const { width, height, frames } = config;
343
+ const framesSvg = frames.map((f) => f.svgContent);
344
+ const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
345
+ const sharedDefsMarkup = config.sharedDefs ?? "";
346
+ const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
347
+ // Cursor overlay (DM-277). Same emission as the unmerged path — the
348
+ // overlay sits above the merged frame group, clipped to the viewport.
349
+ const totalDuration = totalSec * 1000;
350
+ let overlayMarkup = "";
351
+ if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
352
+ const frameStarts = [];
353
+ let acc = 0;
354
+ for (const f of frames) {
355
+ frameStarts.push(acc);
356
+ acc += f.duration + transitionDuration(f);
357
+ }
358
+ const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
359
+ overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
360
+ }
361
+ return `<?xml version="1.0" encoding="UTF-8"?>
362
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
363
+ <defs>
364
+ <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
365
+ </defs>
366
+ <style>
367
+ :root { --scene-dur: ${totalSec.toFixed(2)}s; }
368
+ ${css}${animationCss}
369
+ </style>
370
+ <g clip-path="url(#viewport-clip)">
371
+ <rect width="${width}" height="${height}" fill="#0d1117" />
372
+ ${merged}${overlayMarkup}
373
+ </g>
374
+ </svg>`;
375
+ }
376
+ /**
377
+ * Compile each frame's intra-frame animations into CSS. Each animation gets
378
+ * a uniquely-named keyframe block whose timing is mapped onto the global
379
+ * scene clock so the property holds at `from` until the frame becomes
380
+ * visible (+ `delay`), animates to `to` over `duration`, then holds at `to`
381
+ * until the loop restarts.
382
+ */
383
+ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
384
+ const totalMs = totalSec * 1000;
385
+ const out = [];
386
+ for (let i = 0; i < frames.length; i++) {
387
+ const animations = frames[i].animations;
388
+ if (animations == null || animations.length === 0)
389
+ continue;
390
+ const frameStartMs = (frameTiming.startPct[i] / 100) * totalMs;
391
+ for (let ai = 0; ai < animations.length; ai++) {
392
+ const a = animations[ai];
393
+ const delay = a.delay ?? 0;
394
+ const easing = a.easing ?? "linear";
395
+ const startMs = frameStartMs + delay;
396
+ const endMs = startMs + a.duration;
397
+ const startPct = (startMs / totalMs) * 100;
398
+ const endPct = (endMs / totalMs) * 100;
399
+ const propValue = (val) => {
400
+ if (a.property === "translateX")
401
+ return `transform: translateX(${val});`;
402
+ if (a.property === "translateY")
403
+ return `transform: translateY(${val});`;
404
+ if (a.property === "clipPath")
405
+ return `clip-path: ${val};`;
406
+ return `${a.property}: ${val};`;
407
+ };
408
+ const animName = `f${i}-${a.animId}-${ai}`;
409
+ // Hold `from` until startPct, animate from→to during [startPct, endPct],
410
+ // hold `to` afterwards. Pre-frame holds use 0% as the from anchor.
411
+ out.push(` @keyframes ${animName} {
412
+ 0% { ${propValue(a.from)} }
413
+ ${startPct.toFixed(3)}% { ${propValue(a.from)} }
414
+ ${endPct.toFixed(3)}% { ${propValue(a.to)} }
415
+ 100% { ${propValue(a.to)} }
416
+ }
417
+ .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
418
+ }
419
+ }
420
+ return out.length === 0 ? "" : "\n" + out.join("\n");
421
+ }
422
+ function escapeXml(s) {
423
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
424
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Unit tests for the SVG animator — specifically the keyframe timing and
3
+ * shared-defs hoist. Regression tests for SK-662 (flicker + size).
4
+ */
5
+ export {};
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Unit tests for the SVG animator — specifically the keyframe timing and
3
+ * shared-defs hoist. Regression tests for SK-662 (flicker + size).
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { generateAnimatedSvg } from "./animator.js";
7
+ describe("animator", () => {
8
+ it("includes sharedDefs in the top-level <defs>", () => {
9
+ const svg = generateAnimatedSvg({
10
+ width: 100,
11
+ height: 100,
12
+ sharedDefs: `<path id="g0" d="M0 0L1 1Z"/>`,
13
+ frames: [
14
+ { svgContent: `<use href="#g0"/>`, duration: 200 },
15
+ ],
16
+ });
17
+ // The sharedDefs markup should appear inside the top-level <defs> block,
18
+ // immediately after the viewport clip, NOT inside any frame's <g class="f">.
19
+ const topDefs = svg.match(/<defs>[\s\S]*?<\/defs>/);
20
+ expect(topDefs).not.toBeNull();
21
+ expect(topDefs[0]).toContain(`id="g0"`);
22
+ });
23
+ it("crossfade-only scenes route through the merge pipeline (no per-frame fv- groups)", () => {
24
+ // With the merge pipeline, an all-crossfade scene is reduced to a single
25
+ // element tree with per-element visibility timelines — NOT per-frame
26
+ // opacity groups. fv-N keyframes (the old model) should be absent;
27
+ // timeline classes (tN) should appear when frames differ.
28
+ const svg = generateAnimatedSvg({
29
+ width: 100,
30
+ height: 100,
31
+ frames: [
32
+ { svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "crossfade", duration: 200 } },
33
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
34
+ ],
35
+ });
36
+ expect(svg).not.toMatch(/@keyframes fv-/);
37
+ expect(svg).toMatch(/@keyframes t\d+/);
38
+ expect(svg).toContain("--scene-dur");
39
+ });
40
+ it("crossfade: identical content across frames is rendered once", () => {
41
+ // Regression test for SK-662 — stable elements should not be re-drawn per
42
+ // frame. Two frames with the same <rect> should produce exactly one <rect>.
43
+ const svg = generateAnimatedSvg({
44
+ width: 100,
45
+ height: 100,
46
+ frames: [
47
+ { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "crossfade", duration: 100 } },
48
+ { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500 },
49
+ ],
50
+ });
51
+ expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(1);
52
+ });
53
+ it("non-crossfade transitions are unaffected", () => {
54
+ const svg = generateAnimatedSvg({
55
+ width: 100,
56
+ height: 100,
57
+ frames: [
58
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 200 } },
59
+ { svgContent: `<rect/>`, duration: 1000 },
60
+ ],
61
+ });
62
+ // push-left uses translateX keyframes — presence is enough.
63
+ expect(svg).toContain("translateX");
64
+ });
65
+ it("cut transition: ignores duration on the input", () => {
66
+ // `cut` is supposed to be instant regardless of what `duration` was passed
67
+ // (so a config that mistakenly leaves `duration: 9999` on a cut doesn't
68
+ // bloat the scene). Compare a 9999-duration cut against a 0-duration cut:
69
+ // they should produce the same scene length.
70
+ const a = generateAnimatedSvg({
71
+ width: 100, height: 100,
72
+ frames: [
73
+ { svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "cut", duration: 9999 } },
74
+ { svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
75
+ ],
76
+ });
77
+ const b = generateAnimatedSvg({
78
+ width: 100, height: 100,
79
+ frames: [
80
+ { svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
81
+ { svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
82
+ ],
83
+ });
84
+ const aDur = a.match(/--scene-dur:\s*([0-9.]+)s/)?.[1];
85
+ const bDur = b.match(/--scene-dur:\s*([0-9.]+)s/)?.[1];
86
+ expect(aDur).toBe(bDur);
87
+ });
88
+ it("cut transition: routes through the merge fast path (no fv-N groups)", () => {
89
+ const svg = generateAnimatedSvg({
90
+ width: 100, height: 100,
91
+ frames: [
92
+ { svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
93
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
94
+ { svgContent: `<rect fill="green" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
95
+ ],
96
+ });
97
+ expect(svg).not.toMatch(/@keyframes fv-/);
98
+ expect(svg).toMatch(/@keyframes t\d+/);
99
+ // 3 frames × 1000ms hold + 3 × 0ms cut transitions = 3000ms total.
100
+ expect(svg).toMatch(/--scene-dur:\s*3\.00s/);
101
+ });
102
+ it("intra-frame animation: emits @keyframes scoped to frame's visibility window", () => {
103
+ // DM-209: an animation declared on a frame should compile into a
104
+ // @keyframes block whose timing maps onto the global scene clock,
105
+ // gated by the frame's start position.
106
+ const svg = generateAnimatedSvg({
107
+ width: 100, height: 100,
108
+ frames: [
109
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
110
+ {
111
+ svgContent: `<rect/>`,
112
+ duration: 2000,
113
+ transition: { type: "cut", duration: 0 },
114
+ animations: [{
115
+ animId: "bar",
116
+ property: "width",
117
+ from: "0%",
118
+ to: "100%",
119
+ duration: 2000,
120
+ easing: "ease-out",
121
+ }],
122
+ },
123
+ ],
124
+ });
125
+ // Total scene = 3000ms. Frame 1 starts at 33.333%. Animation is during
126
+ // [frame-start + 0ms, frame-start + 2000ms] = [33.333%, 100%].
127
+ expect(svg).toMatch(/@keyframes f1-bar-0/);
128
+ expect(svg).toMatch(/\.anim-bar\s*{[^}]*animation:\s*f1-bar-0/);
129
+ // Both `from` and `to` values must appear in the keyframe block.
130
+ expect(svg).toContain("width: 0%");
131
+ expect(svg).toContain("width: 100%");
132
+ // Timing function passes through.
133
+ expect(svg).toContain("ease-out");
134
+ });
135
+ it("intra-frame animation: translateY desugars to transform: translateY()", () => {
136
+ const svg = generateAnimatedSvg({
137
+ width: 100, height: 100,
138
+ frames: [
139
+ {
140
+ svgContent: `<rect/>`,
141
+ duration: 1000,
142
+ animations: [{
143
+ animId: "slide",
144
+ property: "translateY",
145
+ from: "240px",
146
+ to: "0px",
147
+ duration: 400,
148
+ }],
149
+ },
150
+ ],
151
+ });
152
+ expect(svg).toContain("transform: translateY(240px)");
153
+ expect(svg).toContain("transform: translateY(0px)");
154
+ });
155
+ it("cut transition: timeline boundary is exactly at the frame edge", () => {
156
+ // For two frames each held 1000ms with cut transitions and no overlap,
157
+ // the visibility flip should land at exactly 50% of the scene.
158
+ const svg = generateAnimatedSvg({
159
+ width: 100, height: 100,
160
+ frames: [
161
+ { svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
162
+ { svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
163
+ ],
164
+ });
165
+ expect(svg).toMatch(/--scene-dur:\s*2\.00s/);
166
+ // 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
167
+ expect(svg).toMatch(/50\.000%/);
168
+ });
169
+ });
@@ -0,0 +1 @@
1
+ export {};