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.
- package/CHANGELOG.md +140 -9
- package/dist/cli/cache-utils.d.ts +33 -0
- package/dist/cli/cache-utils.d.ts.map +1 -0
- package/dist/cli/cache-utils.js +157 -0
- package/dist/cli/cached-generate.d.ts +105 -0
- package/dist/cli/cached-generate.d.ts.map +1 -0
- package/dist/cli/cached-generate.js +208 -0
- package/dist/cli/cached-generate.test.d.ts +11 -0
- package/dist/cli/cached-generate.test.d.ts.map +1 -0
- package/dist/cli/cached-generate.test.js +431 -0
- package/dist/cli/cartesia-provider.d.ts +16 -0
- package/dist/cli/cartesia-provider.d.ts.map +1 -0
- package/dist/cli/cartesia-provider.js +184 -0
- package/dist/cli/chatgpt-auth.d.ts.map +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/{cli.js → cli/cli.js} +626 -600
- package/dist/cli/clone-generate.d.ts +26 -0
- package/dist/cli/clone-generate.d.ts.map +1 -0
- package/dist/cli/clone-generate.js +148 -0
- package/dist/cli/credentials.d.ts.map +1 -0
- package/dist/{credentials.js → cli/credentials.js} +20 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
- package/dist/cli/elevenlabs-speech-provider.js +199 -0
- package/dist/cli/generate.d.ts +140 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +604 -0
- package/dist/cli/generate.test.d.ts +2 -0
- package/dist/cli/generate.test.d.ts.map +1 -0
- package/dist/cli/generate.test.js +131 -0
- package/dist/cli/login.d.ts.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +7 -0
- package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
- package/dist/cli/model-catalog.d.ts.map +1 -0
- package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
- package/dist/{models.d.ts → cli/models.d.ts} +12 -5
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/{models.js → cli/models.js} +58 -57
- package/dist/cli/open-browser.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.d.ts +3 -0
- package/dist/cli/openai-speech-provider.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.js +28 -0
- package/dist/cli/separation-generate.d.ts +34 -0
- package/dist/cli/separation-generate.d.ts.map +1 -0
- package/dist/cli/separation-generate.js +117 -0
- package/dist/cli/speech-catalog.d.ts +35 -0
- package/dist/cli/speech-catalog.d.ts.map +1 -0
- package/dist/cli/speech-catalog.js +195 -0
- package/dist/cli/speech-generate.d.ts +86 -0
- package/dist/cli/speech-generate.d.ts.map +1 -0
- package/dist/cli/speech-generate.js +156 -0
- package/dist/cli/speech-models.d.ts +8 -0
- package/dist/cli/speech-models.d.ts.map +1 -0
- package/dist/cli/speech-models.js +109 -0
- package/dist/cli/speech-provider.test.d.ts +2 -0
- package/dist/cli/speech-provider.test.d.ts.map +1 -0
- package/dist/cli/speech-provider.test.js +197 -0
- package/dist/cli/subscription.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.d.ts +35 -0
- package/dist/cli/transcription-catalog.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.js +192 -0
- package/dist/cli/transcription-generate.d.ts +62 -0
- package/dist/cli/transcription-generate.d.ts.map +1 -0
- package/dist/cli/transcription-generate.js +144 -0
- package/dist/cli/transcription-models.d.ts +9 -0
- package/dist/cli/transcription-models.d.ts.map +1 -0
- package/dist/cli/transcription-models.js +100 -0
- package/dist/cli/transcription-providers.d.ts +22 -0
- package/dist/cli/transcription-providers.d.ts.map +1 -0
- package/dist/cli/transcription-providers.js +290 -0
- package/dist/cli/video-model-catalog.d.ts.map +1 -0
- package/dist/cli/xai-auth.d.ts.map +1 -0
- package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
- package/dist/vite/angled-screen.d.ts +57 -0
- package/dist/vite/angled-screen.d.ts.map +1 -0
- package/dist/vite/angled-screen.js +115 -0
- package/dist/vite/app.d.ts +51 -0
- package/dist/vite/app.d.ts.map +1 -0
- package/dist/vite/app.js +150 -0
- package/dist/vite/bands-shader.d.ts +47 -0
- package/dist/vite/bands-shader.d.ts.map +1 -0
- package/dist/vite/bands-shader.js +267 -0
- package/dist/vite/code-block.d.ts +107 -0
- package/dist/vite/code-block.d.ts.map +1 -0
- package/dist/vite/code-block.js +641 -0
- package/dist/vite/components.d.ts +36 -0
- package/dist/vite/components.d.ts.map +1 -0
- package/dist/vite/components.js +113 -0
- package/dist/vite/dispersion-rings-shader.d.ts +44 -0
- package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
- package/dist/vite/dispersion-rings-shader.js +282 -0
- package/dist/vite/easing-curves.d.ts +129 -0
- package/dist/vite/easing-curves.d.ts.map +1 -0
- package/dist/vite/easing-curves.js +584 -0
- package/dist/vite/easing-curves.test.d.ts +2 -0
- package/dist/vite/easing-curves.test.d.ts.map +1 -0
- package/dist/vite/easing-curves.test.js +200 -0
- package/dist/vite/generated-media-client.d.ts +35 -0
- package/dist/vite/generated-media-client.d.ts.map +1 -0
- package/dist/vite/generated-media-client.js +76 -0
- package/dist/vite/keyframes.d.ts +129 -0
- package/dist/vite/keyframes.d.ts.map +1 -0
- package/dist/vite/keyframes.js +194 -0
- package/dist/vite/layout-editor.d.ts +45 -0
- package/dist/vite/layout-editor.d.ts.map +1 -0
- package/dist/vite/layout-editor.js +587 -0
- package/dist/vite/layout-transition.d.ts +136 -0
- package/dist/vite/layout-transition.d.ts.map +1 -0
- package/dist/vite/layout-transition.js +581 -0
- package/dist/vite/liquid-gradient-shader.d.ts +62 -0
- package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
- package/dist/vite/liquid-gradient-shader.js +413 -0
- package/dist/vite/mdx-client.d.ts +15 -0
- package/dist/vite/mdx-client.d.ts.map +1 -0
- package/dist/vite/mdx-client.js +265 -0
- package/dist/vite/mdx-parse.d.ts +102 -0
- package/dist/vite/mdx-parse.d.ts.map +1 -0
- package/dist/vite/mdx-parse.js +251 -0
- package/dist/vite/mdx-provided-components.d.ts +18 -0
- package/dist/vite/mdx-provided-components.d.ts.map +1 -0
- package/dist/vite/mdx-provided-components.js +14 -0
- package/dist/vite/mdx-video.d.ts +327 -0
- package/dist/vite/mdx-video.d.ts.map +1 -0
- package/dist/vite/mdx-video.js +596 -0
- package/dist/vite/mdx-video.test.d.ts +9 -0
- package/dist/vite/mdx-video.test.d.ts.map +1 -0
- package/dist/vite/mdx-video.test.js +1945 -0
- package/dist/vite/media-components.d.ts +45 -0
- package/dist/vite/media-components.d.ts.map +1 -0
- package/dist/vite/media-components.js +355 -0
- package/dist/vite/media-duration-store.d.ts +66 -0
- package/dist/vite/media-duration-store.d.ts.map +1 -0
- package/dist/vite/media-duration-store.js +204 -0
- package/dist/vite/player-page.d.ts +35 -0
- package/dist/vite/player-page.d.ts.map +1 -0
- package/dist/vite/player-page.js +720 -0
- package/dist/vite/render-client.d.ts +18 -0
- package/dist/vite/render-client.d.ts.map +1 -0
- package/dist/vite/render-client.js +67 -0
- package/dist/vite/sdk.d.ts +221 -0
- package/dist/vite/sdk.d.ts.map +1 -0
- package/dist/vite/sdk.js +355 -0
- package/dist/vite/server-components.d.ts +55 -0
- package/dist/vite/server-components.d.ts.map +1 -0
- package/dist/vite/server-components.js +156 -0
- package/dist/vite/server-mdx.d.ts +74 -0
- package/dist/vite/server-mdx.d.ts.map +1 -0
- package/dist/vite/server-mdx.js +259 -0
- package/dist/vite/shader-renderer.d.ts +48 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -0
- package/dist/vite/shader-renderer.js +411 -0
- package/dist/vite/store-hooks.d.ts +12 -0
- package/dist/vite/store-hooks.d.ts.map +1 -0
- package/dist/vite/store-hooks.js +93 -0
- package/dist/vite/store.d.ts +75 -0
- package/dist/vite/store.d.ts.map +1 -0
- package/dist/vite/store.js +52 -0
- package/dist/vite/store.test.d.ts +6 -0
- package/dist/vite/store.test.d.ts.map +1 -0
- package/dist/vite/store.test.js +172 -0
- package/dist/vite/tweakpane-hook.d.ts +94 -0
- package/dist/vite/tweakpane-hook.d.ts.map +1 -0
- package/dist/vite/tweakpane-hook.js +450 -0
- package/dist/vite/vite-plugin.d.ts +28 -0
- package/dist/vite/vite-plugin.d.ts.map +1 -0
- package/dist/vite/vite-plugin.js +538 -0
- package/dist/vite/wave-gradient-shader.d.ts +41 -0
- package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
- package/dist/vite/wave-gradient-shader.js +207 -0
- package/package.json +101 -24
- package/skills/egaki/SKILL.md +162 -1
- package/src/cli/cache-utils.ts +169 -0
- package/src/cli/cached-generate.test.ts +531 -0
- package/src/cli/cached-generate.ts +348 -0
- package/src/cli/cartesia-provider.ts +249 -0
- package/src/{cli.ts → cli/cli.ts} +836 -884
- package/src/cli/clone-generate.ts +202 -0
- package/src/{credentials.ts → cli/credentials.ts} +20 -0
- package/src/cli/elevenlabs-speech-provider.ts +243 -0
- package/src/cli/generate.test.ts +166 -0
- package/src/cli/generate.ts +912 -0
- package/src/cli/main.ts +8 -0
- package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
- package/src/{models.ts → cli/models.ts} +65 -86
- package/src/cli/openai-speech-provider.ts +34 -0
- package/src/cli/separation-generate.ts +187 -0
- package/src/cli/speech-catalog.ts +253 -0
- package/src/cli/speech-generate.ts +275 -0
- package/src/cli/speech-models.ts +146 -0
- package/src/cli/speech-provider.test.ts +226 -0
- package/src/cli/transcription-catalog.ts +250 -0
- package/src/cli/transcription-generate.ts +214 -0
- package/src/cli/transcription-models.ts +136 -0
- package/src/cli/transcription-providers.ts +427 -0
- package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
- package/src/vite/angled-screen.tsx +214 -0
- package/src/vite/app.tsx +197 -0
- package/src/vite/bands-shader.tsx +319 -0
- package/src/vite/code-block.tsx +939 -0
- package/src/vite/components.tsx +278 -0
- package/src/vite/dispersion-rings-shader.tsx +331 -0
- package/src/vite/easing-curves.test.ts +232 -0
- package/src/vite/easing-curves.ts +743 -0
- package/src/vite/generated-media-client.tsx +121 -0
- package/src/vite/keyframes.tsx +347 -0
- package/src/vite/layout-editor.tsx +709 -0
- package/src/vite/layout-transition.tsx +706 -0
- package/src/vite/liquid-gradient-shader.tsx +474 -0
- package/src/vite/mdx-client.tsx +420 -0
- package/src/vite/mdx-parse.ts +343 -0
- package/src/vite/mdx-provided-components.ts +19 -0
- package/src/vite/mdx-video.test.tsx +2219 -0
- package/src/vite/mdx-video.tsx +976 -0
- package/src/vite/media-components.tsx +473 -0
- package/src/vite/media-duration-store.ts +225 -0
- package/src/vite/motion-timing.ts +194 -0
- package/src/vite/player-page.tsx +1107 -0
- package/src/vite/render-client.ts +84 -0
- package/src/vite/sdk.ts +598 -0
- package/src/vite/server-components.tsx +241 -0
- package/src/vite/server-mdx.ts +285 -0
- package/src/vite/shader-renderer.tsx +535 -0
- package/src/vite/store-hooks.ts +117 -0
- package/src/vite/store.test.ts +202 -0
- package/src/vite/store.ts +115 -0
- package/src/vite/styles.css +21 -0
- package/src/vite/tweakpane-hook.tsx +593 -0
- package/src/vite/virtual-modules.d.ts +30 -0
- package/src/vite/vite-plugin.ts +584 -0
- package/src/vite/wave-gradient-shader.tsx +253 -0
- package/README.md +0 -370
- package/dist/antigravity-auth.d.ts +0 -15
- package/dist/antigravity-auth.d.ts.map +0 -1
- package/dist/antigravity-auth.js +0 -544
- package/dist/antigravity-image.d.ts +0 -11
- package/dist/antigravity-image.d.ts.map +0 -1
- package/dist/antigravity-image.js +0 -285
- package/dist/chatgpt-auth.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/credentials.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.d.ts +0 -11
- package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.js +0 -64
- package/dist/login.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/models.d.ts.map +0 -1
- package/dist/open-browser.d.ts.map +0 -1
- package/dist/subscription.d.ts.map +0 -1
- package/dist/video-model-catalog.d.ts.map +0 -1
- package/dist/xai-auth.d.ts.map +0 -1
- /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
- /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
- /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
- /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
- /package/dist/{login.js → cli/login.js} +0 -0
- /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
- /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
- /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
- /package/dist/{subscription.js → cli/subscription.js} +0 -0
- /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
- /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
- /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
- /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
- /package/src/{login.ts → cli/login.ts} +0 -0
- /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
- /package/src/{subscription.ts → cli/subscription.ts} +0 -0
- /package/src/{video-model-catalog.ts → cli/video-model-catalog.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
|
+
});
|