egaki 0.5.0 → 0.7.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 (280) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/cli/cli.js +1329 -0
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/{credentials.d.ts → cli/credentials.d.ts} +11 -2
  22. package/dist/cli/credentials.d.ts.map +1 -0
  23. package/dist/{credentials.js → cli/credentials.js} +88 -5
  24. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  25. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  26. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  27. package/dist/cli/generate.d.ts +140 -0
  28. package/dist/cli/generate.d.ts.map +1 -0
  29. package/dist/cli/generate.js +604 -0
  30. package/dist/cli/generate.test.d.ts +2 -0
  31. package/dist/cli/generate.test.d.ts.map +1 -0
  32. package/dist/cli/generate.test.js +131 -0
  33. package/dist/cli/login.d.ts.map +1 -0
  34. package/dist/{login.js → cli/login.js} +45 -4
  35. package/dist/cli/main.d.ts +3 -0
  36. package/dist/cli/main.d.ts.map +1 -0
  37. package/dist/cli/main.js +7 -0
  38. package/dist/cli/model-catalog.d.ts +141 -0
  39. package/dist/cli/model-catalog.d.ts.map +1 -0
  40. package/dist/cli/model-catalog.js +2135 -0
  41. package/dist/{models.d.ts → cli/models.d.ts} +16 -9
  42. package/dist/cli/models.d.ts.map +1 -0
  43. package/dist/cli/models.js +313 -0
  44. package/dist/cli/open-browser.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.d.ts +3 -0
  46. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  47. package/dist/cli/openai-speech-provider.js +28 -0
  48. package/dist/cli/separation-generate.d.ts +34 -0
  49. package/dist/cli/separation-generate.d.ts.map +1 -0
  50. package/dist/cli/separation-generate.js +117 -0
  51. package/dist/cli/speech-catalog.d.ts +35 -0
  52. package/dist/cli/speech-catalog.d.ts.map +1 -0
  53. package/dist/cli/speech-catalog.js +195 -0
  54. package/dist/cli/speech-generate.d.ts +86 -0
  55. package/dist/cli/speech-generate.d.ts.map +1 -0
  56. package/dist/cli/speech-generate.js +156 -0
  57. package/dist/cli/speech-models.d.ts +8 -0
  58. package/dist/cli/speech-models.d.ts.map +1 -0
  59. package/dist/cli/speech-models.js +109 -0
  60. package/dist/cli/speech-provider.test.d.ts +2 -0
  61. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  62. package/dist/cli/speech-provider.test.js +197 -0
  63. package/dist/cli/subscription.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.d.ts +35 -0
  65. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  66. package/dist/cli/transcription-catalog.js +192 -0
  67. package/dist/cli/transcription-generate.d.ts +62 -0
  68. package/dist/cli/transcription-generate.d.ts.map +1 -0
  69. package/dist/cli/transcription-generate.js +144 -0
  70. package/dist/cli/transcription-models.d.ts +9 -0
  71. package/dist/cli/transcription-models.d.ts.map +1 -0
  72. package/dist/cli/transcription-models.js +100 -0
  73. package/dist/cli/transcription-providers.d.ts +22 -0
  74. package/dist/cli/transcription-providers.d.ts.map +1 -0
  75. package/dist/cli/transcription-providers.js +290 -0
  76. package/dist/cli/video-model-catalog.d.ts +2 -0
  77. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  78. package/dist/cli/video-model-catalog.js +5 -0
  79. package/dist/cli/xai-auth.d.ts +29 -0
  80. package/dist/cli/xai-auth.d.ts.map +1 -0
  81. package/dist/cli/xai-auth.js +325 -0
  82. package/dist/vite/angled-screen.d.ts +57 -0
  83. package/dist/vite/angled-screen.d.ts.map +1 -0
  84. package/dist/vite/angled-screen.js +115 -0
  85. package/dist/vite/app.d.ts +51 -0
  86. package/dist/vite/app.d.ts.map +1 -0
  87. package/dist/vite/app.js +150 -0
  88. package/dist/vite/bands-shader.d.ts +47 -0
  89. package/dist/vite/bands-shader.d.ts.map +1 -0
  90. package/dist/vite/bands-shader.js +267 -0
  91. package/dist/vite/code-block.d.ts +107 -0
  92. package/dist/vite/code-block.d.ts.map +1 -0
  93. package/dist/vite/code-block.js +641 -0
  94. package/dist/vite/components.d.ts +36 -0
  95. package/dist/vite/components.d.ts.map +1 -0
  96. package/dist/vite/components.js +113 -0
  97. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  98. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  99. package/dist/vite/dispersion-rings-shader.js +282 -0
  100. package/dist/vite/easing-curves.d.ts +129 -0
  101. package/dist/vite/easing-curves.d.ts.map +1 -0
  102. package/dist/vite/easing-curves.js +584 -0
  103. package/dist/vite/easing-curves.test.d.ts +2 -0
  104. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  105. package/dist/vite/easing-curves.test.js +200 -0
  106. package/dist/vite/generated-media-client.d.ts +35 -0
  107. package/dist/vite/generated-media-client.d.ts.map +1 -0
  108. package/dist/vite/generated-media-client.js +76 -0
  109. package/dist/vite/keyframes.d.ts +129 -0
  110. package/dist/vite/keyframes.d.ts.map +1 -0
  111. package/dist/vite/keyframes.js +194 -0
  112. package/dist/vite/layout-editor.d.ts +45 -0
  113. package/dist/vite/layout-editor.d.ts.map +1 -0
  114. package/dist/vite/layout-editor.js +587 -0
  115. package/dist/vite/layout-transition.d.ts +136 -0
  116. package/dist/vite/layout-transition.d.ts.map +1 -0
  117. package/dist/vite/layout-transition.js +581 -0
  118. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  119. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  120. package/dist/vite/liquid-gradient-shader.js +413 -0
  121. package/dist/vite/mdx-client.d.ts +15 -0
  122. package/dist/vite/mdx-client.d.ts.map +1 -0
  123. package/dist/vite/mdx-client.js +265 -0
  124. package/dist/vite/mdx-parse.d.ts +102 -0
  125. package/dist/vite/mdx-parse.d.ts.map +1 -0
  126. package/dist/vite/mdx-parse.js +251 -0
  127. package/dist/vite/mdx-provided-components.d.ts +18 -0
  128. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  129. package/dist/vite/mdx-provided-components.js +14 -0
  130. package/dist/vite/mdx-video.d.ts +327 -0
  131. package/dist/vite/mdx-video.d.ts.map +1 -0
  132. package/dist/vite/mdx-video.js +596 -0
  133. package/dist/vite/mdx-video.test.d.ts +9 -0
  134. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  135. package/dist/vite/mdx-video.test.js +1945 -0
  136. package/dist/vite/media-components.d.ts +45 -0
  137. package/dist/vite/media-components.d.ts.map +1 -0
  138. package/dist/vite/media-components.js +355 -0
  139. package/dist/vite/media-duration-store.d.ts +66 -0
  140. package/dist/vite/media-duration-store.d.ts.map +1 -0
  141. package/dist/vite/media-duration-store.js +204 -0
  142. package/dist/vite/player-page.d.ts +35 -0
  143. package/dist/vite/player-page.d.ts.map +1 -0
  144. package/dist/vite/player-page.js +720 -0
  145. package/dist/vite/render-client.d.ts +18 -0
  146. package/dist/vite/render-client.d.ts.map +1 -0
  147. package/dist/vite/render-client.js +67 -0
  148. package/dist/vite/sdk.d.ts +221 -0
  149. package/dist/vite/sdk.d.ts.map +1 -0
  150. package/dist/vite/sdk.js +355 -0
  151. package/dist/vite/server-components.d.ts +55 -0
  152. package/dist/vite/server-components.d.ts.map +1 -0
  153. package/dist/vite/server-components.js +156 -0
  154. package/dist/vite/server-mdx.d.ts +74 -0
  155. package/dist/vite/server-mdx.d.ts.map +1 -0
  156. package/dist/vite/server-mdx.js +259 -0
  157. package/dist/vite/shader-renderer.d.ts +48 -0
  158. package/dist/vite/shader-renderer.d.ts.map +1 -0
  159. package/dist/vite/shader-renderer.js +411 -0
  160. package/dist/vite/store-hooks.d.ts +12 -0
  161. package/dist/vite/store-hooks.d.ts.map +1 -0
  162. package/dist/vite/store-hooks.js +93 -0
  163. package/dist/vite/store.d.ts +75 -0
  164. package/dist/vite/store.d.ts.map +1 -0
  165. package/dist/vite/store.js +52 -0
  166. package/dist/vite/store.test.d.ts +6 -0
  167. package/dist/vite/store.test.d.ts.map +1 -0
  168. package/dist/vite/store.test.js +172 -0
  169. package/dist/vite/tweakpane-hook.d.ts +94 -0
  170. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  171. package/dist/vite/tweakpane-hook.js +450 -0
  172. package/dist/vite/vite-plugin.d.ts +28 -0
  173. package/dist/vite/vite-plugin.d.ts.map +1 -0
  174. package/dist/vite/vite-plugin.js +538 -0
  175. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  176. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  177. package/dist/vite/wave-gradient-shader.js +207 -0
  178. package/package.json +103 -25
  179. package/skills/egaki/SKILL.md +162 -1
  180. package/src/cli/cache-utils.ts +169 -0
  181. package/src/cli/cached-generate.test.ts +531 -0
  182. package/src/cli/cached-generate.ts +348 -0
  183. package/src/cli/cartesia-provider.ts +249 -0
  184. package/src/cli/cli.ts +1818 -0
  185. package/src/cli/clone-generate.ts +202 -0
  186. package/src/{credentials.ts → cli/credentials.ts} +90 -7
  187. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  188. package/src/cli/generate.test.ts +166 -0
  189. package/src/cli/generate.ts +912 -0
  190. package/src/{login.ts → cli/login.ts} +48 -3
  191. package/src/cli/main.ts +8 -0
  192. package/src/cli/model-catalog.ts +2336 -0
  193. package/src/cli/models.ts +408 -0
  194. package/src/cli/openai-speech-provider.ts +34 -0
  195. package/src/cli/separation-generate.ts +187 -0
  196. package/src/cli/speech-catalog.ts +253 -0
  197. package/src/cli/speech-generate.ts +275 -0
  198. package/src/cli/speech-models.ts +146 -0
  199. package/src/cli/speech-provider.test.ts +226 -0
  200. package/src/cli/transcription-catalog.ts +250 -0
  201. package/src/cli/transcription-generate.ts +214 -0
  202. package/src/cli/transcription-models.ts +136 -0
  203. package/src/cli/transcription-providers.ts +427 -0
  204. package/src/cli/video-model-catalog.ts +14 -0
  205. package/src/cli/xai-auth.ts +396 -0
  206. package/src/vite/angled-screen.tsx +214 -0
  207. package/src/vite/app.tsx +197 -0
  208. package/src/vite/bands-shader.tsx +319 -0
  209. package/src/vite/code-block.tsx +939 -0
  210. package/src/vite/components.tsx +278 -0
  211. package/src/vite/dispersion-rings-shader.tsx +331 -0
  212. package/src/vite/easing-curves.test.ts +232 -0
  213. package/src/vite/easing-curves.ts +743 -0
  214. package/src/vite/generated-media-client.tsx +121 -0
  215. package/src/vite/keyframes.tsx +347 -0
  216. package/src/vite/layout-editor.tsx +709 -0
  217. package/src/vite/layout-transition.tsx +706 -0
  218. package/src/vite/liquid-gradient-shader.tsx +474 -0
  219. package/src/vite/mdx-client.tsx +420 -0
  220. package/src/vite/mdx-parse.ts +343 -0
  221. package/src/vite/mdx-provided-components.ts +19 -0
  222. package/src/vite/mdx-video.test.tsx +2219 -0
  223. package/src/vite/mdx-video.tsx +976 -0
  224. package/src/vite/media-components.tsx +473 -0
  225. package/src/vite/media-duration-store.ts +225 -0
  226. package/src/vite/motion-timing.ts +194 -0
  227. package/src/vite/player-page.tsx +1107 -0
  228. package/src/vite/render-client.ts +84 -0
  229. package/src/vite/sdk.ts +598 -0
  230. package/src/vite/server-components.tsx +241 -0
  231. package/src/vite/server-mdx.ts +285 -0
  232. package/src/vite/shader-renderer.tsx +535 -0
  233. package/src/vite/store-hooks.ts +117 -0
  234. package/src/vite/store.test.ts +202 -0
  235. package/src/vite/store.ts +115 -0
  236. package/src/vite/styles.css +21 -0
  237. package/src/vite/tweakpane-hook.tsx +593 -0
  238. package/src/vite/virtual-modules.d.ts +30 -0
  239. package/src/vite/vite-plugin.ts +584 -0
  240. package/src/vite/wave-gradient-shader.tsx +253 -0
  241. package/README.md +0 -266
  242. package/dist/antigravity-auth.d.ts +0 -15
  243. package/dist/antigravity-auth.d.ts.map +0 -1
  244. package/dist/antigravity-auth.js +0 -544
  245. package/dist/antigravity-image.d.ts +0 -11
  246. package/dist/antigravity-image.d.ts.map +0 -1
  247. package/dist/antigravity-image.js +0 -285
  248. package/dist/chatgpt-auth.d.ts.map +0 -1
  249. package/dist/cli.d.ts +0 -3
  250. package/dist/cli.d.ts.map +0 -1
  251. package/dist/cli.js +0 -968
  252. package/dist/credentials.d.ts.map +0 -1
  253. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  254. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  255. package/dist/gateway-model-candidates.generated.js +0 -64
  256. package/dist/login.d.ts.map +0 -1
  257. package/dist/model-catalog.d.ts +0 -42
  258. package/dist/model-catalog.d.ts.map +0 -1
  259. package/dist/model-catalog.js +0 -1001
  260. package/dist/models.d.ts.map +0 -1
  261. package/dist/models.js +0 -246
  262. package/dist/open-browser.d.ts.map +0 -1
  263. package/dist/subscription.d.ts.map +0 -1
  264. package/dist/video-model-catalog.d.ts +0 -52
  265. package/dist/video-model-catalog.d.ts.map +0 -1
  266. package/dist/video-model-catalog.js +0 -306
  267. package/src/cli.ts +0 -1378
  268. package/src/model-catalog.ts +0 -1077
  269. package/src/models.ts +0 -309
  270. package/src/video-model-catalog.ts +0 -363
  271. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  272. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  273. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  274. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  275. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  276. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  277. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  278. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  279. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  280. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
@@ -0,0 +1,1945 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Tests for mdx-video: section splitting, frontmatter parsing,
4
+ * background extraction, and JSX output via createMdxComposition.
5
+ *
6
+ * Uses inline snapshots so the rendered Remotion JSX structure
7
+ * is visible directly in the test file for easy debugging.
8
+ */
9
+ import React from 'react';
10
+ import { describe, expect, test } from 'vitest';
11
+ import { renderToStaticMarkup } from 'react-dom/server';
12
+ import { SafeMdxRenderer } from 'safe-mdx';
13
+ import { mdxParse, extractImports, resolveModulePath } from 'safe-mdx/parse';
14
+ import { MdastToJsx } from 'safe-mdx';
15
+ import { splitIntoSections, calculateTotalDuration, resolveAutoDurations, parseFrontmatter, aspectRatioFromDimensions, buildMdxScope } from "./mdx-parse.js";
16
+ import { findChangedSectionIndex } from "./vite-plugin.js";
17
+ import { computeEffectiveDuration } from "./media-duration-store.js";
18
+ import { findServerNodes, blankServerContents, collectServerImportSources, wrapGenerateNodes, collectServerFileImportNames } from "./server-mdx.js";
19
+ import { stableJsonKey, hashKey, promptPrefix } from "./server-components.js";
20
+ import { MDX_BUILTIN_COMPONENTS, springFromDuration, findSpringConfig } from "./mdx-video.js";
21
+ describe('MDX_BUILTIN_COMPONENTS', () => {
22
+ test('registry keys match client and server slot maps', () => {
23
+ expect(Object.keys(MDX_BUILTIN_COMPONENTS).sort()).toMatchInlineSnapshot(`
24
+ [
25
+ "AngledScreen",
26
+ "Audio",
27
+ "Background",
28
+ "BandsShader",
29
+ "Blur",
30
+ "BlurReveal",
31
+ "CodeBlock",
32
+ "DispersionRingsShader",
33
+ "Fill",
34
+ "GeneratedImage",
35
+ "GeneratedSpeech",
36
+ "GeneratedVideo",
37
+ "Img",
38
+ "LayoutTransition",
39
+ "LiquidGradientShader",
40
+ "MaskedSlideReveal",
41
+ "Opacity",
42
+ "Scale",
43
+ "ShimmerSweep",
44
+ "StaggeredFadeUp",
45
+ "TranslateX",
46
+ "TranslateY",
47
+ "Video",
48
+ "WaveGradientShader",
49
+ ]
50
+ `);
51
+ });
52
+ });
53
+ // Helper: parse MDX and split into sections in one call
54
+ function split(mdx) {
55
+ const ast = mdxParse(mdx);
56
+ return splitIntoSections(ast);
57
+ }
58
+ // Helper: summarize sections for readable snapshots
59
+ function summarize(mdx) {
60
+ const { sections, frontmatter } = split(mdx);
61
+ return {
62
+ frontmatter,
63
+ sections: sections.map((s) => ({
64
+ heading: s.heading,
65
+ durationInFrames: s.durationInFrames,
66
+ nodes: s.nodes.length,
67
+ })),
68
+ };
69
+ }
70
+ describe('splitIntoSections', () => {
71
+ test('basic sections with headings', () => {
72
+ const result = summarize(`
73
+ # Intro
74
+
75
+ Hello world
76
+
77
+ # Middle
78
+
79
+ Some content
80
+
81
+ # End
82
+
83
+ Goodbye
84
+ `);
85
+ expect(result).toMatchInlineSnapshot(`
86
+ {
87
+ "frontmatter": {
88
+ "bpm": 120,
89
+ "fps": 30,
90
+ "height": 1080,
91
+ "scale": 1,
92
+ "width": 1920,
93
+ },
94
+ "sections": [
95
+ {
96
+ "durationInFrames": null,
97
+ "heading": "Intro",
98
+ "nodes": 1,
99
+ },
100
+ {
101
+ "durationInFrames": null,
102
+ "heading": "Middle",
103
+ "nodes": 1,
104
+ },
105
+ {
106
+ "durationInFrames": null,
107
+ "heading": "End",
108
+ "nodes": 1,
109
+ },
110
+ ],
111
+ }
112
+ `);
113
+ });
114
+ test('frontmatter parsing with yaml', () => {
115
+ const result = summarize(`---
116
+ fps: 60
117
+ bpm: 140
118
+ ---
119
+
120
+ # Scene
121
+ `);
122
+ expect(result.frontmatter).toMatchInlineSnapshot(`
123
+ {
124
+ "bpm": 140,
125
+ "fps": 60,
126
+ "height": 1080,
127
+ "scale": 1,
128
+ "width": 1920,
129
+ }
130
+ `);
131
+ });
132
+ test('duration in heading (seconds)', () => {
133
+ const result = summarize(`
134
+ # Opening duration=3.3s
135
+
136
+ Content
137
+ `);
138
+ expect(result.sections[0].heading).toBe('Opening');
139
+ expect(result.sections[0].durationInFrames).toBe(99); // 3.3 * 30fps
140
+ });
141
+ test('duration in heading (frames)', () => {
142
+ const result = summarize(`
143
+ # Scene duration=200
144
+
145
+ Content
146
+ `);
147
+ expect(result.sections[0].durationInFrames).toBe(200);
148
+ });
149
+ test('duration in heading (beats)', () => {
150
+ const result = summarize(`---
151
+ fps: 30
152
+ bpm: 120
153
+ ---
154
+
155
+ # Scene duration=4beats
156
+
157
+ Content
158
+ `);
159
+ // 120bpm = 2 beats/sec, 1 beat = 15 frames, 4 beats = 60 frames
160
+ expect(result.sections[0].durationInFrames).toBe(60);
161
+ });
162
+ test('duration with frames/f unit aliases', () => {
163
+ const result = summarize(`
164
+ # A duration=90frames
165
+
166
+ x
167
+
168
+ # B duration=90frame
169
+
170
+ y
171
+
172
+ # C duration=90f
173
+
174
+ z
175
+
176
+ # D duration=90fps
177
+
178
+ w
179
+ `);
180
+ expect(result.sections[0].durationInFrames).toBe(90);
181
+ expect(result.sections[1].durationInFrames).toBe(90);
182
+ expect(result.sections[2].durationInFrames).toBe(90);
183
+ expect(result.sections[3].durationInFrames).toBe(90);
184
+ });
185
+ test('background before first heading goes to preamble, not a section', () => {
186
+ const result = split(`
187
+ <Background>
188
+ <BandsShader colors={['#6366f1']} />
189
+ </Background>
190
+
191
+ # Scene
192
+
193
+ Content
194
+ `);
195
+ // Content before the first heading goes to preamble, not an implicit section
196
+ expect(result.sections).toHaveLength(1);
197
+ expect(result.sections[0].heading).toBe('Scene');
198
+ expect(result.preamble.length).toBeGreaterThan(0);
199
+ });
200
+ test('background after heading is a regular content node', () => {
201
+ const result = summarize(`
202
+ # Scene
203
+
204
+ <Background>
205
+ <BandsShader colors={['#6366f1']} />
206
+ </Background>
207
+
208
+ Content
209
+ `);
210
+ // Background is included in section.nodes alongside content
211
+ expect(result.sections).toHaveLength(1);
212
+ expect(result.sections[0].nodes).toBe(2); // Background + Content paragraph
213
+ });
214
+ test('each section keeps its own background nodes', () => {
215
+ const result = summarize(`
216
+ # Scene 1
217
+
218
+ <Background>
219
+ <Bg1 />
220
+ </Background>
221
+
222
+ Content 1
223
+
224
+ # Scene 2
225
+
226
+ <Background>
227
+ <Bg2 />
228
+ </Background>
229
+
230
+ Content 2
231
+ `);
232
+ expect(result.sections[0].nodes).toBe(2); // Background + Content
233
+ expect(result.sections[1].nodes).toBe(2); // Background + Content
234
+ });
235
+ test('import statements are skipped (not treated as content)', () => {
236
+ const result = summarize(`
237
+ import { Foo } from './foo'
238
+
239
+ # Scene
240
+
241
+ Content
242
+ `);
243
+ // Import should not create an implicit section before the heading
244
+ expect(result.sections).toHaveLength(1);
245
+ expect(result.sections[0].heading).toBe('Scene');
246
+ });
247
+ test('content before first heading goes to preamble', () => {
248
+ const result = split(`
249
+ Some orphan content
250
+
251
+ # Scene
252
+
253
+ More content
254
+ `);
255
+ expect(result.sections).toHaveLength(1);
256
+ expect(result.sections[0].heading).toBe('Scene');
257
+ expect(result.preamble.length).toBeGreaterThan(0);
258
+ });
259
+ });
260
+ describe('parseFrontmatter and MDX scope (FPS, BEAT)', () => {
261
+ test('default values: 30fps, 120bpm', () => {
262
+ const ast = mdxParse('# Hello');
263
+ const fm = parseFrontmatter(ast);
264
+ expect(fm).toEqual({ fps: 30, bpm: 120, width: 1920, height: 1080, scale: 1 });
265
+ });
266
+ test('custom frontmatter', () => {
267
+ const ast = mdxParse(`---\nfps: 60\nbpm: 140\n---\n\n# Hello`);
268
+ const fm = parseFrontmatter(ast);
269
+ expect(fm).toEqual({ fps: 60, bpm: 140, width: 1920, height: 1080, scale: 1 });
270
+ });
271
+ test('custom width and height', () => {
272
+ const ast = mdxParse(`---\nwidth: 1080\nheight: 1920\n---\n\n# Vertical`);
273
+ const fm = parseFrontmatter(ast);
274
+ expect(fm).toEqual({ fps: 30, bpm: 120, width: 1080, height: 1920, scale: 1 });
275
+ });
276
+ test('custom scale', () => {
277
+ const ast = mdxParse(`---\nscale: 2\n---\n\n# Hello`);
278
+ expect(parseFrontmatter(ast)).toEqual({ fps: 30, bpm: 120, width: 1920, height: 1080, scale: 2 });
279
+ });
280
+ test('invalid scale falls back to default', () => {
281
+ expect(parseFrontmatter(mdxParse(`---\nscale: 0\n---\n\n# Hello`)).scale).toBe(1);
282
+ expect(parseFrontmatter(mdxParse(`---\nscale: -1\n---\n\n# Hello`)).scale).toBe(1);
283
+ expect(parseFrontmatter(mdxParse(`---\nscale: "foo"\n---\n\n# Hello`)).scale).toBe(1);
284
+ });
285
+ test('aspectRatioFromDimensions: exact standard ratios', () => {
286
+ expect(aspectRatioFromDimensions(1920, 1080)).toBe('16:9');
287
+ expect(aspectRatioFromDimensions(1080, 1920)).toBe('9:16');
288
+ expect(aspectRatioFromDimensions(1080, 1080)).toBe('1:1');
289
+ expect(aspectRatioFromDimensions(1440, 1080)).toBe('4:3');
290
+ expect(aspectRatioFromDimensions(1080, 1440)).toBe('3:4');
291
+ expect(aspectRatioFromDimensions(1080, 1350)).toBe('4:5');
292
+ expect(aspectRatioFromDimensions(1350, 1080)).toBe('5:4');
293
+ expect(aspectRatioFromDimensions(1080, 1620)).toBe('2:3');
294
+ expect(aspectRatioFromDimensions(1620, 1080)).toBe('3:2');
295
+ });
296
+ test('aspectRatioFromDimensions: non-standard picks closest', () => {
297
+ // 1920×1200 = 8:5 (1.6) — closest standard is 16:9 (1.778) vs 3:2 (1.5)
298
+ // 3:2 is closer (diff 0.1 vs 0.178)
299
+ expect(aspectRatioFromDimensions(1920, 1200)).toBe('3:2');
300
+ // 2560×1440 = 16:9 exact
301
+ expect(aspectRatioFromDimensions(2560, 1440)).toBe('16:9');
302
+ // 3840×2160 = 16:9 exact
303
+ expect(aspectRatioFromDimensions(3840, 2160)).toBe('16:9');
304
+ });
305
+ test('aspectRatioFromDimensions: constrained to allowed ratios', () => {
306
+ // Veo only supports 16:9 and 9:16
307
+ expect(aspectRatioFromDimensions(1920, 1080, ['16:9', '9:16'])).toBe('16:9');
308
+ expect(aspectRatioFromDimensions(1080, 1920, ['16:9', '9:16'])).toBe('9:16');
309
+ // Square composition (1.0) is closer to 9:16 (0.5625) than 16:9 (1.778)
310
+ expect(aspectRatioFromDimensions(1080, 1080, ['16:9', '9:16'])).toBe('9:16');
311
+ // When allowed list includes 1:1, square matches exactly
312
+ expect(aspectRatioFromDimensions(1080, 1080, ['16:9', '9:16', '1:1'])).toBe('1:1');
313
+ // Non-standard dimensions pick closest from allowed
314
+ expect(aspectRatioFromDimensions(1920, 1200, ['16:9', '9:16', '1:1'])).toBe('16:9');
315
+ });
316
+ test('FPS and BEAT available in safe-mdx scope for expressions', () => {
317
+ const fps = 30;
318
+ const bpm = 120;
319
+ const mdxScope = { FPS: fps, BEAT: fps / (bpm / 60) };
320
+ // BEAT at 120bpm, 30fps = 30 / (120/60) = 15 frames per beat
321
+ expect(mdxScope.BEAT).toBe(15);
322
+ // Verify scope works in safe-mdx expressions
323
+ function Box({ delay }) {
324
+ return _jsx("div", { "data-delay": delay });
325
+ }
326
+ const code = `<Box delay={0.5 * FPS} />`;
327
+ const ast = mdxParse(code);
328
+ const visitor = new MdastToJsx({
329
+ markdown: code,
330
+ mdast: ast,
331
+ components: { Box },
332
+ scope: mdxScope,
333
+ baseUrl: './',
334
+ evaluateOptions: { functions: true },
335
+ });
336
+ const result = visitor.run();
337
+ const html = renderToStaticMarkup(result);
338
+ // 0.5 * 30 = 15
339
+ expect(html).toMatchInlineSnapshot(`"<div data-delay="15"></div>"`);
340
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
341
+ });
342
+ test('Math.round and other JS globals available in scope via buildMdxScope', () => {
343
+ const fps = 30;
344
+ const bpm = 120;
345
+ const mdxScope = buildMdxScope(fps, bpm);
346
+ function Box({ trimBefore, trimAfter }) {
347
+ return _jsx("div", { "data-trim-before": trimBefore, "data-trim-after": trimAfter });
348
+ }
349
+ const code = `<Box trimBefore={Math.round(1.47 * FPS)} trimAfter={Math.round(4.87 * FPS)} />`;
350
+ const ast = mdxParse(code);
351
+ const visitor = new MdastToJsx({
352
+ markdown: code,
353
+ mdast: ast,
354
+ components: { Box },
355
+ scope: mdxScope,
356
+ baseUrl: './',
357
+ evaluateOptions: { functions: true },
358
+ });
359
+ const result = visitor.run();
360
+ const html = renderToStaticMarkup(result);
361
+ // Math.round(1.47 * 30) = 44, Math.round(4.87 * 30) = 146
362
+ expect(html).toMatchInlineSnapshot(`"<div data-trim-before="44" data-trim-after="146"></div>"`);
363
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
364
+ });
365
+ test('BEAT expression in safe-mdx scope', () => {
366
+ const fps = 30;
367
+ const bpm = 120;
368
+ const mdxScope = { FPS: fps, BEAT: fps / (bpm / 60) };
369
+ function Box({ duration }) {
370
+ return _jsx("div", { "data-duration": duration });
371
+ }
372
+ const code = `<Box duration={2 * BEAT} />`;
373
+ const ast = mdxParse(code);
374
+ const visitor = new MdastToJsx({
375
+ markdown: code,
376
+ mdast: ast,
377
+ components: { Box },
378
+ scope: mdxScope,
379
+ baseUrl: './',
380
+ evaluateOptions: { functions: true },
381
+ });
382
+ const result = visitor.run();
383
+ const html = renderToStaticMarkup(result);
384
+ // 2 * 15 = 30
385
+ expect(html).toMatchInlineSnapshot(`"<div data-duration="30"></div>"`);
386
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
387
+ });
388
+ });
389
+ describe('calculateTotalDuration', () => {
390
+ test('sums all section durations', () => {
391
+ const { sections, frontmatter } = split(`
392
+ # A duration=100
393
+
394
+ x
395
+
396
+ # B duration=200
397
+
398
+ y
399
+
400
+ # C duration=50
401
+
402
+ z
403
+ `);
404
+ const resolved = resolveAutoDurations(sections, frontmatter.fps, frontmatter.bpm);
405
+ expect(calculateTotalDuration(resolved)).toBe(350);
406
+ });
407
+ });
408
+ describe('resolveAutoDurations', () => {
409
+ test('resolves null duration from section durations map', () => {
410
+ const { sections, frontmatter } = split(`
411
+ # Scene
412
+
413
+ Content
414
+ `);
415
+ expect(sections[0].durationInFrames).toBe(null);
416
+ // sectionDurations keyed by section index as string
417
+ const resolved = resolveAutoDurations(sections, frontmatter.fps, frontmatter.bpm, { '0': 10 });
418
+ // 10 seconds * 30 fps = 300 frames
419
+ expect(resolved[0].durationInFrames).toBe(300);
420
+ });
421
+ test('falls back to default when no section duration available', () => {
422
+ const { sections, frontmatter } = split(`
423
+ # Scene
424
+
425
+ Just text
426
+ `);
427
+ const resolved = resolveAutoDurations(sections, frontmatter.fps, frontmatter.bpm);
428
+ // Default: 10 beats at 120bpm/30fps = 150 frames
429
+ expect(resolved[0].durationInFrames).toBe(150);
430
+ });
431
+ test('preserves explicit duration unchanged', () => {
432
+ const { sections, frontmatter } = split(`
433
+ # Scene duration=3s
434
+
435
+ Content
436
+ `);
437
+ expect(sections[0].durationInFrames).toBe(90); // 3s * 30fps
438
+ // Even if the store has a duration for this section, explicit wins
439
+ const resolved = resolveAutoDurations(sections, frontmatter.fps, frontmatter.bpm, { '0': 100 });
440
+ expect(resolved[0].durationInFrames).toBe(90);
441
+ });
442
+ test('mixed sections: explicit and auto', () => {
443
+ const { sections, frontmatter } = split(`
444
+ # Explicit duration=2s
445
+
446
+ Text
447
+
448
+ # Auto
449
+
450
+ More text
451
+ `);
452
+ const resolved = resolveAutoDurations(sections, frontmatter.fps, frontmatter.bpm, { '1': 45.2 });
453
+ expect(resolved[0].durationInFrames).toBe(60); // 2s * 30fps (explicit)
454
+ expect(resolved[1].durationInFrames).toBe(1356); // round(45.2 * 30) (auto)
455
+ });
456
+ });
457
+ describe('Background rendering', () => {
458
+ test('background node preserved in section nodes with children', () => {
459
+ const mdx = `
460
+ # Scene
461
+
462
+ <Background>
463
+ <div style={{ background: 'red' }}>gradient</div>
464
+ </Background>
465
+
466
+ Content
467
+ `;
468
+ const ast = mdxParse(mdx);
469
+ const result = splitIntoSections(ast);
470
+ // Background is a regular node in section.nodes
471
+ const bgNode = result.sections[0].nodes.find((n) => n.name === 'Background');
472
+ expect(bgNode).toBeDefined();
473
+ expect(bgNode.children.length).toBeGreaterThan(0);
474
+ });
475
+ });
476
+ // ---------------------------------------------------------------------------
477
+ // Module resolution via safe-mdx MdastToJsx
478
+ // ---------------------------------------------------------------------------
479
+ describe('safe-mdx module resolution', () => {
480
+ // Base components map (element overrides safe-mdx needs)
481
+ const baseComponents = {
482
+ p: ({ children }) => _jsx("p", { children: children }),
483
+ h1: ({ children }) => _jsx("h1", { children: children }),
484
+ };
485
+ test('named import: component renders from modules', () => {
486
+ function MyBadge({ label }) {
487
+ return _jsx("span", { className: "badge", children: label });
488
+ }
489
+ const code = `
490
+ import { MyBadge } from './components'
491
+
492
+ <MyBadge label="Hello" />
493
+ `;
494
+ const mdast = mdxParse(code);
495
+ const visitor = new MdastToJsx({
496
+ markdown: code,
497
+ mdast,
498
+ components: baseComponents,
499
+ modules: {
500
+ './components.tsx': { MyBadge },
501
+ },
502
+ baseUrl: './',
503
+ });
504
+ const result = visitor.run();
505
+ const html = renderToStaticMarkup(result);
506
+ expect(html).toMatchInlineSnapshot(`"<span class="badge">Hello</span>"`);
507
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
508
+ });
509
+ test('named import: data values available in expressions', () => {
510
+ const ITEMS = ['apple', 'banana', 'cherry'];
511
+ const code = `
512
+ import { ITEMS } from './data'
513
+
514
+ There are {ITEMS.length} items.
515
+ `;
516
+ const mdast = mdxParse(code);
517
+ const visitor = new MdastToJsx({
518
+ markdown: code,
519
+ mdast,
520
+ components: baseComponents,
521
+ modules: {
522
+ './data.tsx': { ITEMS },
523
+ },
524
+ baseUrl: './',
525
+ evaluateOptions: { functions: true },
526
+ });
527
+ const result = visitor.run();
528
+ const html = renderToStaticMarkup(result);
529
+ expect(html).toMatchInlineSnapshot(`"<p>There are 3 items.</p>"`);
530
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
531
+ });
532
+ test('named import: data passed as prop to component', () => {
533
+ function ItemList({ items }) {
534
+ return _jsx("ul", { children: items.map((i) => _jsx("li", { children: i }, i)) });
535
+ }
536
+ const MY_DATA = ['one', 'two'];
537
+ const code = `
538
+ import { ItemList, MY_DATA } from './components'
539
+
540
+ <ItemList items={MY_DATA} />
541
+ `;
542
+ const mdast = mdxParse(code);
543
+ const visitor = new MdastToJsx({
544
+ markdown: code,
545
+ mdast,
546
+ components: baseComponents,
547
+ modules: {
548
+ './components.tsx': { ItemList, MY_DATA },
549
+ },
550
+ baseUrl: './',
551
+ });
552
+ const result = visitor.run();
553
+ const html = renderToStaticMarkup(result);
554
+ expect(html).toMatchInlineSnapshot(`"<ul><li>one</li><li>two</li></ul>"`);
555
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
556
+ });
557
+ test('unresolved import: produces error, does not crash', () => {
558
+ const code = `
559
+ import { Missing } from './nonexistent'
560
+
561
+ <Missing />
562
+ `;
563
+ const mdast = mdxParse(code);
564
+ const visitor = new MdastToJsx({
565
+ markdown: code,
566
+ mdast,
567
+ components: baseComponents,
568
+ modules: {},
569
+ baseUrl: './',
570
+ });
571
+ const result = visitor.run();
572
+ renderToStaticMarkup(result);
573
+ expect(visitor.errors.length).toBeGreaterThan(0);
574
+ expect(visitor.errors[0].message).toContain('Unresolved import');
575
+ });
576
+ test('multiple imports from different files', () => {
577
+ function Card({ title }) {
578
+ return _jsx("div", { className: "card", children: title });
579
+ }
580
+ const CONFIG = { theme: 'dark' };
581
+ const code = `
582
+ import { Card } from './ui'
583
+ import { CONFIG } from './config'
584
+
585
+ <Card title={CONFIG.theme} />
586
+ `;
587
+ const mdast = mdxParse(code);
588
+ const visitor = new MdastToJsx({
589
+ markdown: code,
590
+ mdast,
591
+ components: baseComponents,
592
+ modules: {
593
+ './ui.tsx': { Card },
594
+ './config.tsx': { CONFIG },
595
+ },
596
+ baseUrl: './',
597
+ });
598
+ const result = visitor.run();
599
+ const html = renderToStaticMarkup(result);
600
+ expect(html).toMatchInlineSnapshot(`"<div class="card">dark</div>"`);
601
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`);
602
+ });
603
+ });
604
+ // ---------------------------------------------------------------------------
605
+ // keyframes() — animation interpolation
606
+ // ---------------------------------------------------------------------------
607
+ import { keyframes, fromLottieProperty, extractLottieDimensionEasing } from "./mdx-video.js";
608
+ describe('keyframes', () => {
609
+ test('single keyframe returns its value', () => {
610
+ expect(keyframes(0, [{ time: 0, value: 42 }])).toBe(42);
611
+ expect(keyframes(100, [{ time: 0, value: 42 }])).toBe(42);
612
+ });
613
+ test('linear interpolation between two keyframes', () => {
614
+ const kfs = [
615
+ { time: 0, value: 0 },
616
+ { time: 100, value: 100 },
617
+ ];
618
+ expect(keyframes(0, [...kfs])).toBe(0);
619
+ expect(keyframes(50, [...kfs])).toBe(50);
620
+ expect(keyframes(100, [...kfs])).toBe(100);
621
+ });
622
+ test('clamps outside keyframe range', () => {
623
+ const kfs = [
624
+ { time: 10, value: 0 },
625
+ { time: 20, value: 100 },
626
+ ];
627
+ expect(keyframes(0, kfs)).toBe(0);
628
+ expect(keyframes(30, kfs)).toBe(100);
629
+ });
630
+ test('bezier easing changes interpolation curve', () => {
631
+ const linear = [
632
+ { time: 0, value: 0 },
633
+ { time: 100, value: 100 },
634
+ ];
635
+ const eased = [
636
+ { time: 0, value: 0, easing: [0.8, 0, 1, 1] },
637
+ { time: 100, value: 100 },
638
+ ];
639
+ const linearMid = keyframes(50, linear);
640
+ const easedMid = keyframes(50, eased);
641
+ // Strong ease-in at midpoint should be well below linear
642
+ expect(linearMid).toBe(50);
643
+ expect(easedMid).toBeLessThan(40);
644
+ // But endpoints should be the same
645
+ expect(keyframes(0, eased)).toBe(0);
646
+ expect(keyframes(100, eased)).toBe(100);
647
+ });
648
+ test('overshoot with y > 1', () => {
649
+ const kfs = [
650
+ { time: 0, value: 0, easing: [0.34, 1.56, 0.64, 1] },
651
+ { time: 100, value: 100 },
652
+ ];
653
+ // With overshoot easing, some intermediate frame should exceed 100
654
+ const values = Array.from({ length: 101 }, (_, i) => keyframes(i, kfs));
655
+ const max = Math.max(...values);
656
+ expect(max).toBeGreaterThan(100);
657
+ // Endpoints should still be correct
658
+ expect(values[0]).toBe(0);
659
+ expect(values[100]).toBe(100);
660
+ });
661
+ test('hold keyframe (step function)', () => {
662
+ const kfs = [
663
+ { time: 0, value: 0, hold: true },
664
+ { time: 30, value: 100 },
665
+ { time: 60, value: 200 },
666
+ ];
667
+ expect(keyframes(0, kfs)).toBe(0);
668
+ expect(keyframes(15, kfs)).toBe(0);
669
+ expect(keyframes(29, kfs)).toBe(0);
670
+ expect(keyframes(30, kfs)).toBe(100);
671
+ expect(keyframes(45, kfs)).toBe(150);
672
+ });
673
+ test('multi-keyframe sequence', () => {
674
+ const kfs = [
675
+ { time: 0, value: 0 },
676
+ { time: 50, value: 100 },
677
+ { time: 100, value: 0 },
678
+ ];
679
+ expect(keyframes(0, kfs)).toBe(0);
680
+ expect(keyframes(25, kfs)).toBe(50);
681
+ expect(keyframes(50, kfs)).toBe(100);
682
+ expect(keyframes(75, kfs)).toBe(50);
683
+ expect(keyframes(100, kfs)).toBe(0);
684
+ });
685
+ test('per-segment easing in multi-keyframe', () => {
686
+ const linear = [
687
+ { time: 0, value: 0 },
688
+ { time: 50, value: 100 },
689
+ { time: 100, value: 0 },
690
+ ];
691
+ const eased = [
692
+ { time: 0, value: 0, easing: [0.8, 0, 1, 1] },
693
+ { time: 50, value: 100, easing: [0.8, 0, 1, 1] },
694
+ { time: 100, value: 0 },
695
+ ];
696
+ // Midpoints of each segment: linear gives 50, strong ease-in gives much less
697
+ expect(keyframes(25, linear)).toBe(50);
698
+ expect(keyframes(25, eased)).toBeLessThan(40);
699
+ });
700
+ test('vector keyframes return arrays', () => {
701
+ const kfs = [
702
+ { time: 0, value: [0, 0] },
703
+ { time: 100, value: [200, 400] },
704
+ ];
705
+ const result = keyframes(50, kfs);
706
+ expect(result).toEqual([100, 200]);
707
+ });
708
+ test('vector keyframes clamp at boundaries', () => {
709
+ const kfs = [
710
+ { time: 10, value: [0, 100] },
711
+ { time: 20, value: [50, 200] },
712
+ ];
713
+ expect(keyframes(0, kfs)).toEqual([0, 100]);
714
+ expect(keyframes(30, kfs)).toEqual([50, 200]);
715
+ });
716
+ test('vector hold keyframe', () => {
717
+ const kfs = [
718
+ { time: 0, value: [10, 20], hold: true },
719
+ { time: 30, value: [100, 200] },
720
+ ];
721
+ expect(keyframes(0, kfs)).toEqual([10, 20]);
722
+ expect(keyframes(15, kfs)).toEqual([10, 20]);
723
+ expect(keyframes(30, kfs)).toEqual([100, 200]);
724
+ });
725
+ test('dimensionEasing overrides per-dimension', () => {
726
+ const kfs = [
727
+ { time: 0, value: [0, 0], easing: [0, 0, 1, 1] },
728
+ { time: 100, value: [100, 100] },
729
+ ];
730
+ // Without dimension easing, both dimensions are linear
731
+ const linear = keyframes(50, kfs);
732
+ expect(linear[0]).toBeCloseTo(50, 0);
733
+ expect(linear[1]).toBeCloseTo(50, 0);
734
+ // With dimension easing overriding dim 1 with strong ease-in
735
+ const withDimEasing = keyframes(50, kfs, {
736
+ dimensionEasing: [undefined, [0.8, 0, 1, 1]],
737
+ });
738
+ // Dim 0 stays linear (uses keyframe easing which is linear)
739
+ expect(withDimEasing[0]).toBeCloseTo(50, 0);
740
+ // Dim 1 uses strong ease-in, should be well below 50
741
+ expect(withDimEasing[1]).toBeLessThan(40);
742
+ });
743
+ test('empty keyframes throws', () => {
744
+ expect(() => keyframes(0, [])).toThrow('at least one keyframe');
745
+ });
746
+ });
747
+ describe('fromLottieProperty', () => {
748
+ test('static scalar property', () => {
749
+ const result = fromLottieProperty({ a: 0, k: 50 });
750
+ expect(result).toMatchInlineSnapshot(`
751
+ [
752
+ {
753
+ "time": 0,
754
+ "value": 50,
755
+ },
756
+ ]
757
+ `);
758
+ });
759
+ test('static vector property', () => {
760
+ const result = fromLottieProperty({ a: 0, k: [100, 200] });
761
+ expect(result).toMatchInlineSnapshot(`
762
+ [
763
+ {
764
+ "time": 0,
765
+ "value": [
766
+ 100,
767
+ 200,
768
+ ],
769
+ },
770
+ ]
771
+ `);
772
+ });
773
+ test('animated scalar property', () => {
774
+ const result = fromLottieProperty({
775
+ a: 1,
776
+ k: [
777
+ { t: 0, s: [0], o: { x: [0.333], y: [0] }, i: { x: [0.667], y: [1] } },
778
+ { t: 30, s: [100] },
779
+ ],
780
+ });
781
+ expect(result).toMatchInlineSnapshot(`
782
+ [
783
+ {
784
+ "easing": [
785
+ 0.333,
786
+ 0,
787
+ 0.667,
788
+ 1,
789
+ ],
790
+ "time": 0,
791
+ "value": 0,
792
+ },
793
+ {
794
+ "time": 30,
795
+ "value": 100,
796
+ },
797
+ ]
798
+ `);
799
+ });
800
+ test('animated scalar with hold', () => {
801
+ const result = fromLottieProperty({
802
+ a: 1,
803
+ k: [
804
+ { t: 0, s: [50], h: 1 },
805
+ { t: 30, s: [100] },
806
+ ],
807
+ });
808
+ expect(result).toMatchInlineSnapshot(`
809
+ [
810
+ {
811
+ "hold": true,
812
+ "time": 0,
813
+ "value": 50,
814
+ },
815
+ {
816
+ "time": 30,
817
+ "value": 100,
818
+ },
819
+ ]
820
+ `);
821
+ });
822
+ test('animated vector property', () => {
823
+ const result = fromLottieProperty({
824
+ a: 1,
825
+ k: [
826
+ { t: 0, s: [100, 200], o: { x: [0.5, 0.3], y: [0, 0.2] }, i: { x: [0.5, 0.7], y: [1, 0.8] } },
827
+ { t: 60, s: [500, 400] },
828
+ ],
829
+ });
830
+ expect(result).toMatchInlineSnapshot(`
831
+ [
832
+ {
833
+ "easing": [
834
+ 0.5,
835
+ 0,
836
+ 0.5,
837
+ 1,
838
+ ],
839
+ "time": 0,
840
+ "value": [
841
+ 100,
842
+ 200,
843
+ ],
844
+ },
845
+ {
846
+ "time": 60,
847
+ "value": [
848
+ 500,
849
+ 400,
850
+ ],
851
+ },
852
+ ]
853
+ `);
854
+ });
855
+ test('roundtrip: fromLottieProperty -> keyframes produces correct values', () => {
856
+ const kfs = fromLottieProperty({
857
+ a: 1,
858
+ k: [
859
+ { t: 0, s: [0], o: { x: [0], y: [0] }, i: { x: [1], y: [1] } },
860
+ { t: 100, s: [200] },
861
+ ],
862
+ });
863
+ // Linear easing (o={0,0} i={1,1}), so midpoint should be 100
864
+ expect(keyframes(50, kfs)).toBe(100);
865
+ expect(keyframes(0, kfs)).toBe(0);
866
+ expect(keyframes(100, kfs)).toBe(200);
867
+ });
868
+ });
869
+ describe('extractLottieDimensionEasing', () => {
870
+ test('returns undefined for scalar properties', () => {
871
+ const result = extractLottieDimensionEasing({ a: 1, k: [{ t: 0, s: [0], o: { x: [0.5], y: [0] }, i: { x: [0.5], y: [1] } }, { t: 30, s: [100] }] }, 0);
872
+ expect(result).toBeUndefined();
873
+ });
874
+ test('returns per-dimension curves for vector properties', () => {
875
+ const result = extractLottieDimensionEasing({
876
+ a: 1,
877
+ k: [
878
+ { t: 0, s: [0, 0], o: { x: [0.3, 0.5], y: [0, 0.2] }, i: { x: [0.7, 0.8], y: [1, 0.9] } },
879
+ { t: 30, s: [100, 200] },
880
+ ],
881
+ }, 0);
882
+ expect(result).toMatchInlineSnapshot(`
883
+ [
884
+ [
885
+ 0.3,
886
+ 0,
887
+ 0.7,
888
+ 1,
889
+ ],
890
+ [
891
+ 0.5,
892
+ 0.2,
893
+ 0.8,
894
+ 0.9,
895
+ ],
896
+ ]
897
+ `);
898
+ });
899
+ test('returns undefined for static properties', () => {
900
+ expect(extractLottieDimensionEasing({ a: 0, k: [100, 200] }, 0)).toBeUndefined();
901
+ });
902
+ });
903
+ // ---------------------------------------------------------------------------
904
+ // MDX file imports — React composition approach
905
+ // ---------------------------------------------------------------------------
906
+ describe('MDX file imports', () => {
907
+ const baseComponents = {
908
+ p: ({ children }) => _jsx("p", { children: children }),
909
+ h1: ({ children }) => _jsx("h1", { children: children }),
910
+ strong: ({ children }) => _jsx("strong", { children: children }),
911
+ };
912
+ /** Simulates the rendering pipeline from mdx-client.tsx: detect .mdx imports in the
913
+ * main MDX, render each one into a component, then merge into the modules map. */
914
+ function renderWithMdxImports(mainCode, modules) {
915
+ const ast = mdxParse(mainCode);
916
+ const moduleKeys = Object.keys(modules);
917
+ const merged = { ...modules };
918
+ const imports = extractImports(ast);
919
+ for (const imp of imports) {
920
+ if (!/\.mdx?$/.test(imp.source))
921
+ continue;
922
+ const key = resolveModulePath(imp.source, './', moduleKeys);
923
+ if (!key || !merged[key])
924
+ continue;
925
+ const rawContent = merged[key].default;
926
+ if (typeof rawContent !== 'string')
927
+ continue;
928
+ const importedAst = mdxParse(rawContent);
929
+ const renderedJsx = (_jsx(SafeMdxRenderer, { markdown: rawContent, mdast: importedAst, components: baseComponents, modules: merged, baseUrl: "./" }));
930
+ merged[key] = { default: () => renderedJsx };
931
+ }
932
+ const visitor = new MdastToJsx({
933
+ markdown: mainCode,
934
+ mdast: ast,
935
+ components: baseComponents,
936
+ modules: merged,
937
+ baseUrl: './',
938
+ });
939
+ return { jsx: visitor.run(), errors: visitor.errors };
940
+ }
941
+ test('imported .mdx renders as React component', () => {
942
+ const mainCode = `
943
+ import Intro from './intro.mdx'
944
+
945
+ <Intro />
946
+ `;
947
+ const modules = {
948
+ './intro.mdx': { default: 'Hello from **imported** MDX' },
949
+ };
950
+ const { jsx, errors } = renderWithMdxImports(mainCode, modules);
951
+ const html = renderToStaticMarkup(jsx);
952
+ expect(html).toMatchInlineSnapshot(`"<p>Hello from <strong>imported</strong> MDX</p>"`);
953
+ expect(errors).toMatchInlineSnapshot(`[]`);
954
+ });
955
+ test('imported .mdx can use .tsx components from the same modules map', () => {
956
+ function Badge({ label }) {
957
+ return _jsx("span", { className: "badge", children: label });
958
+ }
959
+ const mainCode = `
960
+ import Snippet from './snippet.mdx'
961
+
962
+ <Snippet />
963
+ `;
964
+ const snippetContent = `
965
+ import { Badge } from './ui'
966
+
967
+ <Badge label="new" />
968
+ `;
969
+ const modules = {
970
+ './snippet.mdx': { default: snippetContent },
971
+ './ui.tsx': { Badge },
972
+ };
973
+ const { jsx, errors } = renderWithMdxImports(mainCode, modules);
974
+ const html = renderToStaticMarkup(jsx);
975
+ expect(html).toMatchInlineSnapshot(`"<span class="badge">new</span>"`);
976
+ expect(errors).toMatchInlineSnapshot(`[]`);
977
+ });
978
+ test('unresolved .mdx import produces error gracefully', () => {
979
+ const mainCode = `
980
+ import Missing from './missing.mdx'
981
+
982
+ <Missing />
983
+ `;
984
+ const { jsx, errors } = renderWithMdxImports(mainCode, {});
985
+ renderToStaticMarkup(jsx);
986
+ expect(errors.length).toBeGreaterThan(0);
987
+ expect(errors[0].message).toContain('Unresolved import');
988
+ });
989
+ test('imported .mdx with multiple elements', () => {
990
+ const mainCode = `
991
+ import Content from './content.mdx'
992
+
993
+ <Content />
994
+ `;
995
+ const modules = {
996
+ './content.mdx': {
997
+ default: `First paragraph
998
+
999
+ Second paragraph with **bold**`,
1000
+ },
1001
+ };
1002
+ const { jsx, errors } = renderWithMdxImports(mainCode, modules);
1003
+ const html = renderToStaticMarkup(jsx);
1004
+ expect(html).toMatchInlineSnapshot(`"<p>First paragraph</p><p>Second paragraph with <strong>bold</strong></p>"`);
1005
+ expect(errors).toMatchInlineSnapshot(`[]`);
1006
+ });
1007
+ test('main MDX can mix .mdx imports with .tsx imports', () => {
1008
+ function Card({ title, children }) {
1009
+ return _jsxs("div", { className: "card", children: [_jsx("h2", { children: title }), children] });
1010
+ }
1011
+ const mainCode = `
1012
+ import { Card } from './ui'
1013
+ import Snippet from './snippet.mdx'
1014
+
1015
+ <Card title="Welcome">
1016
+ <Snippet />
1017
+ </Card>
1018
+ `;
1019
+ const modules = {
1020
+ './ui.tsx': { Card },
1021
+ './snippet.mdx': { default: 'Snippet **content**' },
1022
+ };
1023
+ const { jsx, errors } = renderWithMdxImports(mainCode, modules);
1024
+ const html = renderToStaticMarkup(jsx);
1025
+ expect(html).toMatchInlineSnapshot(`"<div class="card"><h2>Welcome</h2><p>Snippet <strong>content</strong></p></div>"`);
1026
+ expect(errors).toMatchInlineSnapshot(`[]`);
1027
+ });
1028
+ });
1029
+ describe('LayoutTransition', () => {
1030
+ test('renders wrapper div with data-layout-id and children', async () => {
1031
+ const { LayoutTransition, LayoutTransitionProvider, LayoutGhost } = await import("./mdx-video.js");
1032
+ const html = renderToStaticMarkup(_jsxs(LayoutTransitionProvider, { children: [_jsx(LayoutGhost, { children: _jsx(LayoutTransition, { id: "title", children: _jsx("span", { children: "Hello" }) }) }), _jsx(LayoutTransition, { id: "title", duration: 25, bounce: 0.2, children: _jsx("span", { children: "Hello" }) })] }));
1033
+ expect(html).toMatchInlineSnapshot(`"<div data-layout-id="title" style="transform-origin:0 0;width:fit-content"><span>Hello</span></div><div data-layout-id="title" style="transform-origin:0 0;width:fit-content"><span>Hello</span></div>"`);
1034
+ });
1035
+ test('renders children without provider (used outside sections)', async () => {
1036
+ const { LayoutTransition } = await import("./mdx-video.js");
1037
+ const html = renderToStaticMarkup(_jsx(LayoutTransition, { id: "solo", children: _jsx("span", { children: "Alone" }) }));
1038
+ expect(html).toMatchInlineSnapshot(`"<div data-layout-id="solo" style="transform-origin:0 0;width:fit-content"><span>Alone</span></div>"`);
1039
+ });
1040
+ test('intra-scene: renders multiple instances with showFrom/showUpTo', async () => {
1041
+ const { LayoutTransition, LayoutTransitionProvider } = await import("./mdx-video.js");
1042
+ // Outside Remotion context, useSafeCurrentFrame returns 0, so only the
1043
+ // first instance (showFrom=0) should be visible. Others get visibility:hidden.
1044
+ const html = renderToStaticMarkup(_jsxs(LayoutTransitionProvider, { children: [_jsx(LayoutTransition, { id: "dot", showFrom: 0, showUpTo: 30, children: _jsx("span", { children: "Dot 1" }) }), _jsx(LayoutTransition, { id: "dot", showFrom: 30, showUpTo: 60, children: _jsx("span", { children: "Dot 2" }) }), _jsx(LayoutTransition, { id: "dot", showFrom: 60, showUpTo: 90, children: _jsx("span", { children: "Dot 3" }) })] }));
1045
+ // First is active (frame 0 is in [0, 30)), second and third are hidden
1046
+ expect(html).toContain('data-layout-id="dot"');
1047
+ expect(html).toContain('<span>Dot 1</span>');
1048
+ expect(html).toContain('visibility:hidden');
1049
+ expect(html).toContain('<span>Dot 2</span>');
1050
+ expect(html).toContain('<span>Dot 3</span>');
1051
+ });
1052
+ test('intra-scene: without time range props, no visibility style applied', async () => {
1053
+ const { LayoutTransition, LayoutTransitionProvider } = await import("./mdx-video.js");
1054
+ const html = renderToStaticMarkup(_jsx(LayoutTransitionProvider, { children: _jsx(LayoutTransition, { id: "title", children: _jsx("span", { children: "Always visible" }) }) }));
1055
+ expect(html).not.toContain('visibility');
1056
+ expect(html).toContain('<span>Always visible</span>');
1057
+ });
1058
+ test('accepts mode prop without changing rendered output', async () => {
1059
+ const { LayoutTransition } = await import("./mdx-video.js");
1060
+ const html = renderToStaticMarkup(_jsx(LayoutTransition, { id: "card", mode: "position", children: _jsx("div", { children: "Card" }) }));
1061
+ // mode is an internal prop consumed by LayoutAnimationLayer, not rendered
1062
+ expect(html).toContain('data-layout-id="card"');
1063
+ expect(html).toContain('<div>Card</div>');
1064
+ expect(html).not.toContain('mode');
1065
+ });
1066
+ test('mode="size" with ghost and visible', async () => {
1067
+ const { LayoutTransition, LayoutTransitionProvider, LayoutGhost } = await import("./mdx-video.js");
1068
+ const html = renderToStaticMarkup(_jsxs(LayoutTransitionProvider, { children: [_jsx(LayoutGhost, { children: _jsx(LayoutTransition, { id: "box", mode: "size", children: _jsx("div", { style: { width: 100, height: 100 }, children: "Small" }) }) }), _jsx(LayoutTransition, { id: "box", mode: "size", children: _jsx("div", { style: { width: 200, height: 200 }, children: "Big" }) })] }));
1069
+ expect(html).toContain('data-layout-id="box"');
1070
+ expect(html).toContain('Small');
1071
+ expect(html).toContain('Big');
1072
+ });
1073
+ });
1074
+ describe('findServerNodes', () => {
1075
+ test('flow-level Server elements with position keys', () => {
1076
+ const mdx = `# Section duration=2s
1077
+
1078
+ <Server>
1079
+ <AsyncStats delay={500} />
1080
+ </Server>
1081
+
1082
+ Some text
1083
+
1084
+ <Server>
1085
+ <TextToSpeech text="hello" />
1086
+ </Server>
1087
+ `;
1088
+ const nodes = findServerNodes(mdxParse(mdx));
1089
+ expect(nodes.map((n) => ({ key: n.key, type: n.node.type, children: n.node.children.length }))).toMatchInlineSnapshot(`
1090
+ [
1091
+ {
1092
+ "children": 1,
1093
+ "key": "3",
1094
+ "type": "mdxJsxFlowElement",
1095
+ },
1096
+ {
1097
+ "children": 1,
1098
+ "key": "9",
1099
+ "type": "mdxJsxFlowElement",
1100
+ },
1101
+ ]
1102
+ `);
1103
+ });
1104
+ test('inline Server inside a paragraph', () => {
1105
+ const mdx = `# Section
1106
+
1107
+ before <Server><Stat /></Server> after
1108
+ `;
1109
+ const nodes = findServerNodes(mdxParse(mdx));
1110
+ expect(nodes.map((n) => ({ key: n.key, type: n.node.type }))).toMatchInlineSnapshot(`
1111
+ [
1112
+ {
1113
+ "key": "3",
1114
+ "type": "mdxJsxTextElement",
1115
+ },
1116
+ ]
1117
+ `);
1118
+ });
1119
+ test('nested Server is skipped (outer slot covers it)', () => {
1120
+ const mdx = `<Server>
1121
+ <Server>
1122
+ <Inner />
1123
+ </Server>
1124
+ </Server>
1125
+ `;
1126
+ const nodes = findServerNodes(mdxParse(mdx));
1127
+ expect(nodes.map((n) => n.key)).toMatchInlineSnapshot(`
1128
+ [
1129
+ "1",
1130
+ ]
1131
+ `);
1132
+ });
1133
+ test('Server inside other wrapper elements is found', () => {
1134
+ const mdx = `<Opacity from={0} to={1} duration={15} label="test">
1135
+ <Server>
1136
+ <AsyncStats />
1137
+ </Server>
1138
+ </Opacity>
1139
+ `;
1140
+ const nodes = findServerNodes(mdxParse(mdx));
1141
+ expect(nodes.map((n) => n.key)).toMatchInlineSnapshot(`
1142
+ [
1143
+ "2",
1144
+ ]
1145
+ `);
1146
+ });
1147
+ });
1148
+ describe('blankServerContents', () => {
1149
+ function blank(mdx) {
1150
+ const nodes = findServerNodes(mdxParse(mdx));
1151
+ return blankServerContents(mdx, nodes);
1152
+ }
1153
+ test('multi-line flow block preserves line count', () => {
1154
+ const mdx = `# Section duration=2s
1155
+
1156
+ <Server>
1157
+ <AsyncStats
1158
+ delay={500}
1159
+ />
1160
+ </Server>
1161
+
1162
+ after content
1163
+ `;
1164
+ const result = blank(mdx);
1165
+ expect(result.split('\n').length).toBe(mdx.split('\n').length);
1166
+ expect(result).toMatchInlineSnapshot(`
1167
+ "# Section duration=2s
1168
+
1169
+ <Server />
1170
+
1171
+
1172
+
1173
+
1174
+
1175
+ after content
1176
+ "
1177
+ `);
1178
+ });
1179
+ test('multiple Server blocks, back-to-front splicing', () => {
1180
+ const mdx = `<Server>
1181
+ <One />
1182
+ </Server>
1183
+
1184
+ middle
1185
+
1186
+ <Server>
1187
+ <Two />
1188
+ </Server>
1189
+ `;
1190
+ const result = blank(mdx);
1191
+ expect(result.split('\n').length).toBe(mdx.split('\n').length);
1192
+ expect(result).toMatchInlineSnapshot(`
1193
+ "<Server />
1194
+
1195
+
1196
+
1197
+ middle
1198
+
1199
+ <Server />
1200
+
1201
+
1202
+ "
1203
+ `);
1204
+ });
1205
+ test('inline Server keeps surrounding text on the same line', () => {
1206
+ const mdx = `before <Server><Stat /></Server> after
1207
+ `;
1208
+ const result = blank(mdx);
1209
+ expect(result.split('\n').length).toBe(mdx.split('\n').length);
1210
+ expect(result).toMatchInlineSnapshot(`
1211
+ "before <Server /> after
1212
+ "
1213
+ `);
1214
+ });
1215
+ test('nested Server: only outer block replaced', () => {
1216
+ const mdx = `<Server>
1217
+ <Server>
1218
+ <Inner />
1219
+ </Server>
1220
+ </Server>
1221
+ `;
1222
+ const result = blank(mdx);
1223
+ expect(result.split('\n').length).toBe(mdx.split('\n').length);
1224
+ expect(result).toMatchInlineSnapshot(`
1225
+ "<Server />
1226
+
1227
+
1228
+
1229
+
1230
+ "
1231
+ `);
1232
+ });
1233
+ test('roundtrip: blanked Server node keeps the original position key', () => {
1234
+ const mdx = `# A duration=1s
1235
+
1236
+ text before
1237
+
1238
+ <Server>
1239
+ <AsyncStats />
1240
+ </Server>
1241
+
1242
+ # B duration=1s
1243
+
1244
+ <Opacity from={0} to={1} duration={15} label="test">
1245
+ <Server><Inline /></Server>
1246
+ </Opacity>
1247
+ `;
1248
+ const originalNodes = findServerNodes(mdxParse(mdx));
1249
+ const blanked = blankServerContents(mdx, originalNodes);
1250
+ const reparsedNodes = findServerNodes(mdxParse(blanked));
1251
+ expect(reparsedNodes.map((n) => n.key)).toEqual(originalNodes.map((n) => n.key));
1252
+ expect(originalNodes.map((n) => n.key)).toMatchInlineSnapshot(`
1253
+ [
1254
+ "5",
1255
+ "12",
1256
+ ]
1257
+ `);
1258
+ });
1259
+ });
1260
+ describe('collectServerImportSources', () => {
1261
+ function classify(mdx) {
1262
+ return collectServerImportSources(mdxParse(mdx));
1263
+ }
1264
+ test('import used inside Server is collected', () => {
1265
+ const mdx = `import { AsyncStats } from './async-stats'
1266
+ import { FeatureGrid } from './components'
1267
+
1268
+ # A duration=1s
1269
+
1270
+ <FeatureGrid />
1271
+
1272
+ <Server>
1273
+ <AsyncStats />
1274
+ </Server>
1275
+ `;
1276
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1277
+ [
1278
+ "./async-stats",
1279
+ ]
1280
+ `);
1281
+ });
1282
+ test('component used inside AND outside Server is still collected', () => {
1283
+ const mdx = `import { Stats } from './stats'
1284
+
1285
+ <Server>
1286
+ <Stats />
1287
+ </Server>
1288
+
1289
+ <Stats />
1290
+ `;
1291
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1292
+ [
1293
+ "./stats",
1294
+ ]
1295
+ `);
1296
+ });
1297
+ test('attribute expression identifiers inside Server pull in data imports', () => {
1298
+ const mdx = `import { Chart } from './chart'
1299
+ import { STATS } from './stats-data'
1300
+
1301
+ <Server>
1302
+ <Chart data={STATS} />
1303
+ </Server>
1304
+ `;
1305
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1306
+ [
1307
+ "./chart",
1308
+ "./stats-data",
1309
+ ]
1310
+ `);
1311
+ });
1312
+ test('flow expression identifiers inside Server are collected', () => {
1313
+ const mdx = `import { format } from './utils'
1314
+
1315
+ <Server>
1316
+ {format('hello')}
1317
+ </Server>
1318
+ `;
1319
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1320
+ [
1321
+ "./utils",
1322
+ ]
1323
+ `);
1324
+ });
1325
+ test('namespace member JSX uses the root identifier', () => {
1326
+ const mdx = `import * as widgets from './widgets'
1327
+
1328
+ <Server>
1329
+ <widgets.Stats />
1330
+ </Server>
1331
+ `;
1332
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1333
+ [
1334
+ "./widgets",
1335
+ ]
1336
+ `);
1337
+ });
1338
+ test('bare specifiers are collected (resolved by vite at import time)', () => {
1339
+ const mdx = `import { TextToSpeech } from 'egaki/video'
1340
+
1341
+ <Server>
1342
+ <TextToSpeech text="hi" />
1343
+ </Server>
1344
+ `;
1345
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1346
+ [
1347
+ "egaki/video",
1348
+ ]
1349
+ `);
1350
+ });
1351
+ test('no Server blocks means no sources', () => {
1352
+ const mdx = `import { FeatureGrid } from './components'
1353
+
1354
+ <FeatureGrid />
1355
+ `;
1356
+ expect(classify(mdx)).toMatchInlineSnapshot(`[]`);
1357
+ });
1358
+ test('default import used inside Server', () => {
1359
+ const mdx = `import Tts from './tts'
1360
+
1361
+ <Server>
1362
+ <Tts voice="echo" />
1363
+ </Server>
1364
+ `;
1365
+ expect(classify(mdx)).toMatchInlineSnapshot(`
1366
+ [
1367
+ "./tts",
1368
+ ]
1369
+ `);
1370
+ });
1371
+ });
1372
+ // ---------------------------------------------------------------------------
1373
+ // wrapGenerateNodes
1374
+ // ---------------------------------------------------------------------------
1375
+ describe('wrapGenerateNodes', () => {
1376
+ test('wraps bare GeneratedImage in Server', () => {
1377
+ const mdx = `# Section duration=2s
1378
+
1379
+ <GeneratedImage prompt="a cat" seed={1} />
1380
+ `;
1381
+ const ast = mdxParse(mdx);
1382
+ wrapGenerateNodes(ast);
1383
+ const nodes = findServerNodes(ast);
1384
+ expect(nodes.map((n) => ({
1385
+ key: n.key,
1386
+ childName: n.node.children[0]?.name,
1387
+ }))).toMatchInlineSnapshot(`
1388
+ [
1389
+ {
1390
+ "childName": "GeneratedImage",
1391
+ "key": "3",
1392
+ },
1393
+ ]
1394
+ `);
1395
+ });
1396
+ test('wraps all three generated component types', () => {
1397
+ const mdx = `# Section duration=5s
1398
+
1399
+ <GeneratedImage prompt="a cat" seed={1} />
1400
+
1401
+ <GeneratedVideo prompt="a dog running" seed={2} />
1402
+
1403
+ <GeneratedSpeech text="hello world" seed={3} />
1404
+ `;
1405
+ const ast = mdxParse(mdx);
1406
+ wrapGenerateNodes(ast);
1407
+ const nodes = findServerNodes(ast);
1408
+ expect(nodes.map((n) => ({
1409
+ key: n.key,
1410
+ childName: n.node.children[0]?.name,
1411
+ }))).toMatchInlineSnapshot(`
1412
+ [
1413
+ {
1414
+ "childName": "GeneratedImage",
1415
+ "key": "3",
1416
+ },
1417
+ {
1418
+ "childName": "GeneratedVideo",
1419
+ "key": "5",
1420
+ },
1421
+ {
1422
+ "childName": "GeneratedSpeech",
1423
+ "key": "7",
1424
+ },
1425
+ ]
1426
+ `);
1427
+ });
1428
+ test('skips when already inside Server', () => {
1429
+ const mdx = `# Section duration=2s
1430
+
1431
+ <Server>
1432
+ <GeneratedImage prompt="a cat" seed={1} />
1433
+ </Server>
1434
+ `;
1435
+ const ast = mdxParse(mdx);
1436
+ wrapGenerateNodes(ast);
1437
+ const nodes = findServerNodes(ast);
1438
+ // Only one Server node (the original), not double-wrapped
1439
+ expect(nodes.length).toBe(1);
1440
+ expect(nodes[0].node.children[0]?.name).not.toBe('Server');
1441
+ });
1442
+ test('preserves line positions for slot keying', () => {
1443
+ const mdx = `# A duration=1s
1444
+
1445
+ <GeneratedImage prompt="test" seed={1} />
1446
+
1447
+ # B duration=1s
1448
+
1449
+ <GeneratedVideo prompt="test" seed={2} />
1450
+ `;
1451
+ const ast = mdxParse(mdx);
1452
+ wrapGenerateNodes(ast);
1453
+ const nodes = findServerNodes(ast);
1454
+ expect(nodes.map((n) => n.key)).toMatchInlineSnapshot(`
1455
+ [
1456
+ "3",
1457
+ "7",
1458
+ ]
1459
+ `);
1460
+ });
1461
+ test('wraps inside other wrapper elements', () => {
1462
+ const mdx = `<Opacity from={0} to={1} duration={15} label="test">
1463
+ <GeneratedImage prompt="nested" seed={1} />
1464
+ </Opacity>
1465
+ `;
1466
+ const ast = mdxParse(mdx);
1467
+ wrapGenerateNodes(ast);
1468
+ const nodes = findServerNodes(ast);
1469
+ expect(nodes.length).toBe(1);
1470
+ expect(nodes[0].node.children[0]?.name).toBe('GeneratedImage');
1471
+ });
1472
+ test('blankServerContents roundtrip preserves line count', () => {
1473
+ const mdx = `# Section duration=2s
1474
+
1475
+ <GeneratedImage prompt="a cat" seed={1} />
1476
+
1477
+ some text after
1478
+ `;
1479
+ const ast = mdxParse(mdx);
1480
+ wrapGenerateNodes(ast);
1481
+ const nodes = findServerNodes(ast);
1482
+ const blanked = blankServerContents(mdx, nodes);
1483
+ expect(blanked.split('\n').length).toBe(mdx.split('\n').length);
1484
+ });
1485
+ });
1486
+ // ---------------------------------------------------------------------------
1487
+ // collectServerFileImportNames + auto-wrap .server imports
1488
+ // ---------------------------------------------------------------------------
1489
+ describe('collectServerFileImportNames', () => {
1490
+ test('collects names from .server import source', () => {
1491
+ const mdx = `import { HeroScene } from './hero-scene.server'
1492
+
1493
+ # Scene
1494
+ <HeroScene />
1495
+ `;
1496
+ const ast = mdxParse(mdx);
1497
+ expect([...collectServerFileImportNames(ast)]).toMatchInlineSnapshot(`
1498
+ [
1499
+ "HeroScene",
1500
+ ]
1501
+ `);
1502
+ });
1503
+ test('collects names from .server.tsx import source', () => {
1504
+ const mdx = `import { HeroScene, Sidebar } from './hero-scene.server.tsx'
1505
+
1506
+ # Scene
1507
+ <HeroScene />
1508
+ `;
1509
+ const ast = mdxParse(mdx);
1510
+ expect([...collectServerFileImportNames(ast)].sort()).toMatchInlineSnapshot(`
1511
+ [
1512
+ "HeroScene",
1513
+ "Sidebar",
1514
+ ]
1515
+ `);
1516
+ });
1517
+ test('collects default imports from .server files', () => {
1518
+ const mdx = `import MyScene from './my-scene.server'
1519
+
1520
+ # Scene
1521
+ <MyScene />
1522
+ `;
1523
+ const ast = mdxParse(mdx);
1524
+ expect([...collectServerFileImportNames(ast)]).toMatchInlineSnapshot(`
1525
+ [
1526
+ "MyScene",
1527
+ ]
1528
+ `);
1529
+ });
1530
+ test('ignores non-server imports', () => {
1531
+ const mdx = `import { Widget } from './widget'
1532
+ import { ServerThing } from './thing.server'
1533
+
1534
+ # Scene
1535
+ <Widget />
1536
+ <ServerThing />
1537
+ `;
1538
+ const ast = mdxParse(mdx);
1539
+ expect([...collectServerFileImportNames(ast)]).toMatchInlineSnapshot(`
1540
+ [
1541
+ "ServerThing",
1542
+ ]
1543
+ `);
1544
+ });
1545
+ });
1546
+ describe('wrapGenerateNodes with .server imports', () => {
1547
+ test('auto-wraps components from .server import', () => {
1548
+ const mdx = `import { HeroScene } from './hero-scene.server'
1549
+
1550
+ # Scene duration=5s
1551
+
1552
+ <HeroScene />
1553
+ `;
1554
+ const ast = mdxParse(mdx);
1555
+ wrapGenerateNodes(ast);
1556
+ const nodes = findServerNodes(ast);
1557
+ expect(nodes.map((n) => ({
1558
+ key: n.key,
1559
+ childName: n.node.children[0]?.name,
1560
+ }))).toMatchInlineSnapshot(`
1561
+ [
1562
+ {
1563
+ "childName": "HeroScene",
1564
+ "key": "5",
1565
+ },
1566
+ ]
1567
+ `);
1568
+ });
1569
+ test('does not wrap non-server imports', () => {
1570
+ const mdx = `import { Widget } from './widget'
1571
+
1572
+ # Scene duration=5s
1573
+
1574
+ <Widget />
1575
+ `;
1576
+ const ast = mdxParse(mdx);
1577
+ wrapGenerateNodes(ast);
1578
+ const nodes = findServerNodes(ast);
1579
+ expect(nodes).toMatchInlineSnapshot(`[]`);
1580
+ });
1581
+ test('mixed: wraps server imports, leaves regular imports alone', () => {
1582
+ const mdx = `import { HeroScene } from './hero-scene.server'
1583
+ import { Widget } from './widget'
1584
+
1585
+ # Scene duration=5s
1586
+
1587
+ <HeroScene />
1588
+ <Widget />
1589
+ `;
1590
+ const ast = mdxParse(mdx);
1591
+ wrapGenerateNodes(ast);
1592
+ const nodes = findServerNodes(ast);
1593
+ expect(nodes.map((n) => ({
1594
+ key: n.key,
1595
+ childName: n.node.children[0]?.name,
1596
+ }))).toMatchInlineSnapshot(`
1597
+ [
1598
+ {
1599
+ "childName": "HeroScene",
1600
+ "key": "6",
1601
+ },
1602
+ ]
1603
+ `);
1604
+ });
1605
+ test('skips when .server component is already inside Server', () => {
1606
+ const mdx = `import { HeroScene } from './hero-scene.server'
1607
+
1608
+ # Scene duration=5s
1609
+
1610
+ <Server>
1611
+ <HeroScene />
1612
+ </Server>
1613
+ `;
1614
+ const ast = mdxParse(mdx);
1615
+ wrapGenerateNodes(ast);
1616
+ const nodes = findServerNodes(ast);
1617
+ expect(nodes.length).toBe(1);
1618
+ expect(nodes[0].node.children[0]?.name).not.toBe('Server');
1619
+ });
1620
+ test('blankServerContents preserves line count for .server wrapping', () => {
1621
+ const mdx = `import { HeroScene } from './hero-scene.server'
1622
+
1623
+ # Scene duration=5s
1624
+
1625
+ <HeroScene />
1626
+
1627
+ some text after
1628
+ `;
1629
+ const ast = mdxParse(mdx);
1630
+ wrapGenerateNodes(ast);
1631
+ const nodes = findServerNodes(ast);
1632
+ const blanked = blankServerContents(mdx, nodes);
1633
+ expect(blanked.split('\n').length).toBe(mdx.split('\n').length);
1634
+ });
1635
+ test('auto-wraps namespace imports from .server files', () => {
1636
+ const mdx = `import * as Scenes from './scenes.server'
1637
+
1638
+ # Scene
1639
+ <Scenes.Hero />
1640
+ `;
1641
+ const ast = mdxParse(mdx);
1642
+ wrapGenerateNodes(ast);
1643
+ const nodes = findServerNodes(ast);
1644
+ expect(nodes.length).toBe(1);
1645
+ expect(nodes[0].node.children[0]?.name).toBe('Scenes.Hero');
1646
+ });
1647
+ test('.server.ts extension is recognized', () => {
1648
+ const mdx = `import { DataScene } from './data.server.ts'
1649
+
1650
+ # Scene
1651
+ <DataScene />
1652
+ `;
1653
+ const ast = mdxParse(mdx);
1654
+ wrapGenerateNodes(ast);
1655
+ const nodes = findServerNodes(ast);
1656
+ expect(nodes.length).toBe(1);
1657
+ expect(nodes[0].node.children[0]?.name).toBe('DataScene');
1658
+ });
1659
+ });
1660
+ // ---------------------------------------------------------------------------
1661
+ // Caching utilities (server-components.tsx)
1662
+ // ---------------------------------------------------------------------------
1663
+ describe('stableJsonKey', () => {
1664
+ test('produces identical output regardless of key order', () => {
1665
+ const a = stableJsonKey({ prompt: 'cat', seed: 1, model: 'imagen-4' });
1666
+ const b = stableJsonKey({ model: 'imagen-4', prompt: 'cat', seed: 1 });
1667
+ expect(a).toBe(b);
1668
+ });
1669
+ test('different values produce different keys', () => {
1670
+ const a = stableJsonKey({ prompt: 'cat', seed: 1 });
1671
+ const b = stableJsonKey({ prompt: 'dog', seed: 1 });
1672
+ expect(a).not.toBe(b);
1673
+ });
1674
+ test('undefined values are included', () => {
1675
+ const a = stableJsonKey({ prompt: 'cat', seed: 1, model: undefined });
1676
+ const b = stableJsonKey({ prompt: 'cat', seed: 1 });
1677
+ // JSON.stringify drops undefined values, so these should be equal
1678
+ expect(a).toBe(b);
1679
+ });
1680
+ });
1681
+ describe('hashKey', () => {
1682
+ test('produces 8-char hex string', () => {
1683
+ const h = hashKey('test input');
1684
+ expect(h).toMatch(/^[0-9a-f]{8}$/);
1685
+ });
1686
+ test('is deterministic', () => {
1687
+ expect(hashKey('same')).toBe(hashKey('same'));
1688
+ });
1689
+ });
1690
+ describe('promptPrefix', () => {
1691
+ test('converts to kebab case', () => {
1692
+ expect(promptPrefix('A beautiful sunset over the ocean')).toMatchInlineSnapshot(`"a-beautiful-sunset-over-the-ocean"`);
1693
+ });
1694
+ test('truncates long prompts to 40 chars', () => {
1695
+ const long = 'a very long prompt that exceeds forty characters and should be truncated';
1696
+ const result = promptPrefix(long);
1697
+ expect(result.length).toBeLessThanOrEqual(40);
1698
+ });
1699
+ test('removes special characters', () => {
1700
+ expect(promptPrefix('Hello! @world #test (foo) [bar]')).toMatchInlineSnapshot(`"hello-world-test-foo-bar"`);
1701
+ });
1702
+ test('handles empty string', () => {
1703
+ expect(promptPrefix('')).toBe('generated');
1704
+ });
1705
+ test('handles non-ascii-only string', () => {
1706
+ expect(promptPrefix('🎨🖼️')).toBe('generated');
1707
+ });
1708
+ test('does not end with a dash', () => {
1709
+ const result = promptPrefix('a b c d e f g h i j k l m n o p q r s t u v');
1710
+ expect(result.endsWith('-')).toBe(false);
1711
+ });
1712
+ });
1713
+ describe('computeEffectiveDuration with startInFrames', () => {
1714
+ test('no offset returns media-only duration', () => {
1715
+ expect(computeEffectiveDuration({ rawSeconds: 3, fps: 30 })).toBe(3);
1716
+ });
1717
+ test('positive startInFrames adds delay to total duration', () => {
1718
+ // 3s media + 30 frames (1s) delay = 4s
1719
+ expect(computeEffectiveDuration({ rawSeconds: 3, fps: 30, startInFrames: 30 })).toBe(4);
1720
+ });
1721
+ test('negative startInFrames does not add to duration', () => {
1722
+ // negative anchors to section end, doesn't extend auto-duration
1723
+ expect(computeEffectiveDuration({ rawSeconds: 3, fps: 30, startInFrames: -60 })).toBe(3);
1724
+ });
1725
+ test('startInFrames works with trim props', () => {
1726
+ // trim: frames 0-90 = 3s, + 30 frames delay = 4s
1727
+ expect(computeEffectiveDuration({ fps: 30, trimBefore: 0, trimAfter: 90, startInFrames: 30 })).toBe(4);
1728
+ });
1729
+ test('startInFrames works with playbackRate', () => {
1730
+ // 3s media at 2x = 1.5s, + 30 delay frames (1s, not affected by rate) = 2.5s
1731
+ expect(computeEffectiveDuration({ rawSeconds: 3, fps: 30, playbackRate: 2, startInFrames: 30 })).toBe(2.5);
1732
+ });
1733
+ test('returns null when duration cannot be determined', () => {
1734
+ expect(computeEffectiveDuration({ fps: 30, startInFrames: 30 })).toBeNull();
1735
+ });
1736
+ });
1737
+ describe('findChangedSectionIndex', () => {
1738
+ const base = [
1739
+ '---',
1740
+ 'fps: 30',
1741
+ 'bpm: 120',
1742
+ '---',
1743
+ '',
1744
+ '# Intro duration=3s',
1745
+ '',
1746
+ 'Hello world',
1747
+ '',
1748
+ '# Middle duration=2s',
1749
+ '',
1750
+ 'Some content here',
1751
+ '',
1752
+ '# Outro duration=1s',
1753
+ '',
1754
+ 'Goodbye',
1755
+ ].join('\n');
1756
+ test('identical content returns null', () => {
1757
+ expect(findChangedSectionIndex(base, base)).toBe(null);
1758
+ });
1759
+ test('editing first section returns 0', () => {
1760
+ const edited = base.replace('Hello world', 'Hello EDITED');
1761
+ expect(findChangedSectionIndex(base, edited)).toBe(0);
1762
+ });
1763
+ test('editing second section returns 1', () => {
1764
+ const edited = base.replace('Some content here', 'New content');
1765
+ expect(findChangedSectionIndex(base, edited)).toBe(1);
1766
+ });
1767
+ test('editing last section returns 2', () => {
1768
+ const edited = base.replace('Goodbye', 'See ya');
1769
+ expect(findChangedSectionIndex(base, edited)).toBe(2);
1770
+ });
1771
+ test('adding a new section returns its index', () => {
1772
+ const added = base + '\n\n# Bonus duration=1s\n\nExtra stuff';
1773
+ expect(findChangedSectionIndex(base, added)).toBe(3);
1774
+ });
1775
+ test('deleting last section returns null (skip deletions)', () => {
1776
+ const deleted = base.replace('\n# Outro duration=1s\n\nGoodbye', '');
1777
+ expect(findChangedSectionIndex(base, deleted)).toBe(null);
1778
+ });
1779
+ test('deleting middle section returns null (skip deletions)', () => {
1780
+ const deleted = base.replace('\n# Middle duration=2s\n\nSome content here\n', '\n');
1781
+ expect(findChangedSectionIndex(base, deleted)).toBe(null);
1782
+ });
1783
+ test('editing heading text returns that section index', () => {
1784
+ const edited = base.replace('# Middle duration=2s', '# Middle Renamed duration=2s');
1785
+ expect(findChangedSectionIndex(base, edited)).toBe(1);
1786
+ });
1787
+ test('preamble-only change returns null', () => {
1788
+ const withPreamble = '<Audio src="/bg.mp3" />\n\n' + base.split('\n').slice(4).join('\n');
1789
+ const oldPreamble = '---\nfps: 30\nbpm: 120\n---\n\n' + withPreamble;
1790
+ const newPreamble = oldPreamble.replace('/bg.mp3', '/new-bg.mp3');
1791
+ expect(findChangedSectionIndex(oldPreamble, newPreamble)).toBe(null);
1792
+ });
1793
+ test('frontmatter-only change returns null', () => {
1794
+ const edited = base.replace('fps: 30', 'fps: 60');
1795
+ expect(findChangedSectionIndex(base, edited)).toBe(null);
1796
+ });
1797
+ test('editing heading duration returns that section index', () => {
1798
+ const edited = base.replace('duration=3s', 'duration=4s');
1799
+ expect(findChangedSectionIndex(base, edited)).toBe(0);
1800
+ });
1801
+ test('editing heading transition returns that section index', () => {
1802
+ const old = '# Intro duration=3s transition=10\n\nHello';
1803
+ const edited = '# Intro duration=3s transition=20\n\nHello';
1804
+ expect(findChangedSectionIndex(old, edited)).toBe(0);
1805
+ });
1806
+ test('broken MDX returns null (does not throw)', () => {
1807
+ const valid = '# Intro duration=3s\n\nHello world';
1808
+ const broken = '# Intro duration=3s\n\n<Component';
1809
+ expect(findChangedSectionIndex(valid, broken)).toBe(null);
1810
+ });
1811
+ test('both sources broken returns null', () => {
1812
+ expect(findChangedSectionIndex('<Foo', '<Bar')).toBe(null);
1813
+ });
1814
+ });
1815
+ // ---------------------------------------------------------------------------
1816
+ // Parse error recovery
1817
+ // ---------------------------------------------------------------------------
1818
+ describe('mdxParse error recovery', () => {
1819
+ test('mdxParse throws on unclosed JSX tag', () => {
1820
+ expect(() => mdxParse('<Component')).toThrow();
1821
+ });
1822
+ test('mdxParse throws on broken expression', () => {
1823
+ expect(() => mdxParse('# Hello\n\n{broken expression')).toThrow();
1824
+ });
1825
+ test('splitIntoSections works on valid mdast from valid MDX', () => {
1826
+ const ast = mdxParse('# Scene duration=3s\n\nHello');
1827
+ const result = splitIntoSections(ast);
1828
+ expect(result.sections.length).toBe(1);
1829
+ expect(result.sections[0].heading).toBe('Scene');
1830
+ });
1831
+ test('parseFrontmatter returns defaults on minimal mdast', () => {
1832
+ // Simulate what happens when parse fails and we use a fallback
1833
+ const fm = parseFrontmatter({ type: 'root', children: [] });
1834
+ expect(fm).toMatchInlineSnapshot(`
1835
+ {
1836
+ "bpm": 120,
1837
+ "fps": 30,
1838
+ "height": 1080,
1839
+ "scale": 1,
1840
+ "width": 1920,
1841
+ }
1842
+ `);
1843
+ });
1844
+ });
1845
+ // ---------------------------------------------------------------------------
1846
+ // Spring physics: springFromDuration and findSpringConfig
1847
+ // ---------------------------------------------------------------------------
1848
+ describe('springFromDuration', () => {
1849
+ const round = (n) => Math.round(n * 1000) / 1000;
1850
+ test('matches Motion visualDuration formula', () => {
1851
+ // Motion: root = 2π / (vd * 1.2), stiffness = root², damping = 2 * zeta * sqrt(stiffness)
1852
+ const check = (duration, bounce) => {
1853
+ const result = springFromDuration(duration, bounce);
1854
+ const omega = (2 * Math.PI) / (duration * 1.2);
1855
+ const stiffness = omega * omega;
1856
+ // Motion clamps dampingRatio to [0.05, 1], not bounce
1857
+ const dampingRatio = Math.max(0.05, Math.min(1, 1 - bounce));
1858
+ const damping = 2 * dampingRatio * Math.sqrt(stiffness);
1859
+ expect(round(result.stiffness)).toBe(round(stiffness));
1860
+ expect(round(result.damping)).toBe(round(damping));
1861
+ expect(result.mass).toBe(1);
1862
+ };
1863
+ check(0.3, 0.3);
1864
+ check(0.5, 0);
1865
+ check(0.5, 0.5);
1866
+ check(0.67, 0.85);
1867
+ check(1.0, 1.0);
1868
+ });
1869
+ test('bounce=0 produces critically damped spring (no overshoot)', () => {
1870
+ const config = springFromDuration(0.5, 0);
1871
+ // dampingRatio = max(0.05, 1 - 0) = 1 → critically damped
1872
+ expect(round(config.damping)).toBe(round(2 * 1 * Math.sqrt(config.stiffness)));
1873
+ });
1874
+ test('bounce=1 produces minimum damping (max overshoot)', () => {
1875
+ const config = springFromDuration(0.5, 1);
1876
+ // dampingRatio = max(0.05, 1 - 1) = 0.05 → nearly undamped
1877
+ expect(round(config.damping)).toBe(round(2 * 0.05 * Math.sqrt(config.stiffness)));
1878
+ });
1879
+ test('snapshot key configs', () => {
1880
+ expect(springFromDuration(0.3, 0.3)).toMatchInlineSnapshot(`
1881
+ {
1882
+ "damping": 24.434609527920614,
1883
+ "mass": 1,
1884
+ "stiffness": 304.61741978670864,
1885
+ }
1886
+ `);
1887
+ expect(springFromDuration(0.5, 0.5)).toMatchInlineSnapshot(`
1888
+ {
1889
+ "damping": 10.471975511965978,
1890
+ "mass": 1,
1891
+ "stiffness": 109.6622711232151,
1892
+ }
1893
+ `);
1894
+ expect(springFromDuration(0.67, 0.85)).toMatchInlineSnapshot(`
1895
+ {
1896
+ "damping": 2.344472129544622,
1897
+ "mass": 1,
1898
+ "stiffness": 61.07277295790548,
1899
+ }
1900
+ `);
1901
+ });
1902
+ });
1903
+ describe('findSpringConfig', () => {
1904
+ const round = (n) => Math.round(n * 1000) / 1000;
1905
+ test('produces valid spring config', () => {
1906
+ const config = findSpringConfig(0.8, 0.3);
1907
+ expect(config.stiffness).toBeGreaterThan(0);
1908
+ expect(config.damping).toBeGreaterThan(0);
1909
+ expect(config.mass).toBe(1);
1910
+ });
1911
+ test('higher bounce = lower damping ratio', () => {
1912
+ const low = findSpringConfig(0.8, 0.2);
1913
+ const high = findSpringConfig(0.8, 0.8);
1914
+ // Higher bounce → lower damping ratio → lower absolute damping
1915
+ expect(high.damping).toBeLessThan(low.damping);
1916
+ });
1917
+ test('bounce=0 produces critically damped spring', () => {
1918
+ const config = findSpringConfig(0.8, 0);
1919
+ // zeta = 1 → damping = 2 * 1 * sqrt(mass * stiffness)
1920
+ expect(round(config.damping)).toBe(round(2 * Math.sqrt(config.mass * config.stiffness)));
1921
+ });
1922
+ test('snapshot key configs', () => {
1923
+ expect(findSpringConfig(0.5, 0.3)).toMatchInlineSnapshot(`
1924
+ {
1925
+ "damping": 27.55101044670115,
1926
+ "mass": 1,
1927
+ "stiffness": 387.2745799154265,
1928
+ }
1929
+ `);
1930
+ expect(findSpringConfig(0.8, 0.5)).toMatchInlineSnapshot(`
1931
+ {
1932
+ "damping": 15.896122836620204,
1933
+ "mass": 1,
1934
+ "stiffness": 252.68672123691837,
1935
+ }
1936
+ `);
1937
+ expect(findSpringConfig(1.0, 0.85)).toMatchInlineSnapshot(`
1938
+ {
1939
+ "damping": 10.044027575315129,
1940
+ "mass": 1,
1941
+ "stiffness": 1120.9165548187852,
1942
+ }
1943
+ `);
1944
+ });
1945
+ });