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