@lalalic/markcut 1.0.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/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { compileDescriptiveRoot, parseImportsBlock, extractDependencySpecs } from "./compiler";
|
|
3
|
+
describe("parseImportsBlock", () => {
|
|
4
|
+
it("parses named re-exports: export { Name } from \"spec\"", () => {
|
|
5
|
+
const entries = parseImportsBlock(`export { PieChart } from "npm:recharts"`);
|
|
6
|
+
expect(entries).toHaveLength(1);
|
|
7
|
+
expect(entries[0]).toEqual({ name: "PieChart", from: "npm:recharts", exports: "PieChart" });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses multiple named re-exports from same source", () => {
|
|
11
|
+
const entries = parseImportsBlock(`export { BarChart, LineChart } from "npm:recharts"`);
|
|
12
|
+
expect(entries).toHaveLength(2);
|
|
13
|
+
expect(entries[0]).toEqual({ name: "BarChart", from: "npm:recharts", exports: "BarChart" });
|
|
14
|
+
expect(entries[1]).toEqual({ name: "LineChart", from: "npm:recharts", exports: "LineChart" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses aliased re-exports: export { Name as Alias } from \"spec\"", () => {
|
|
18
|
+
const entries = parseImportsBlock(`export { PieChart as MyPie } from "npm:recharts"`);
|
|
19
|
+
expect(entries).toHaveLength(1);
|
|
20
|
+
expect(entries[0]).toEqual({ name: "MyPie", from: "npm:recharts", exports: "PieChart" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses inline function definitions: export function Name(...) { ... }", () => {
|
|
24
|
+
const entries = parseImportsBlock(`export function Hello({ name }) {\n return <div>Hello {name}</div>;\n}`);
|
|
25
|
+
expect(entries).toHaveLength(1);
|
|
26
|
+
expect(entries[0].name).toBe("Hello");
|
|
27
|
+
expect(entries[0].from).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses default export function: export default function Name(...) { ... }", () => {
|
|
31
|
+
const entries = parseImportsBlock(`export default function Greeting({ text }) {\n return <h1>{text}</h1>;\n}`);
|
|
32
|
+
expect(entries).toHaveLength(1);
|
|
33
|
+
expect(entries[0].name).toBe("Greeting");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles mixed block with re-exports and inline functions", () => {
|
|
37
|
+
const entries = parseImportsBlock(`export { PieChart } from "npm:recharts"
|
|
38
|
+
export { StatCounter } from "npm:stat-counter"
|
|
39
|
+
export function Hello() {
|
|
40
|
+
return <div>Hello</div>;
|
|
41
|
+
}`);
|
|
42
|
+
expect(entries).toHaveLength(3);
|
|
43
|
+
expect(entries[0]).toEqual({ name: "PieChart", from: "npm:recharts", exports: "PieChart" });
|
|
44
|
+
expect(entries[1]).toEqual({ name: "StatCounter", from: "npm:stat-counter", exports: "StatCounter" });
|
|
45
|
+
expect(entries[2].name).toBe("Hello");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns empty array for empty input", () => {
|
|
49
|
+
expect(parseImportsBlock("")).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("parses import statements (internal) and export declarations (registered)", () => {
|
|
53
|
+
const entries = parseImportsBlock(`import { something } from "other"
|
|
54
|
+
// comment
|
|
55
|
+
export { PieChart } from "npm:recharts"
|
|
56
|
+
`);
|
|
57
|
+
expect(entries).toHaveLength(1);
|
|
58
|
+
expect(entries[0]).toEqual({ name: "PieChart", from: "npm:recharts", exports: "PieChart" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("tracks import scope for bare export { Name } re-exports", () => {
|
|
62
|
+
const entries = parseImportsBlock(`import { PieChart as MyPie } from "npm:recharts"
|
|
63
|
+
export { MyPie }`);
|
|
64
|
+
expect(entries).toHaveLength(1);
|
|
65
|
+
expect(entries[0]).toEqual({ name: "MyPie", from: "npm:recharts", exports: "PieChart" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("import statements are internal deps, not component registrations", () => {
|
|
69
|
+
const entries = parseImportsBlock(`import Recharts from "npm:recharts"`);
|
|
70
|
+
expect(entries).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("parses export default Name from \"spec\"", () => {
|
|
74
|
+
const entries = parseImportsBlock(`export default Recharts from "npm:recharts"`);
|
|
75
|
+
expect(entries).toHaveLength(1);
|
|
76
|
+
expect(entries[0]).toEqual({ name: "Recharts", from: "npm:recharts", exports: "default" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("parses real-world JS imports block (exports only)", () => {
|
|
80
|
+
const block = `import { PieChart } from "npm:recharts"
|
|
81
|
+
import { BarChart, LineChart } from "npm:recharts"
|
|
82
|
+
import { StatCounter as Counter } from "npm:stat-counter"
|
|
83
|
+
|
|
84
|
+
export { PieChart }
|
|
85
|
+
export { BarChart, LineChart }
|
|
86
|
+
export { Counter }
|
|
87
|
+
|
|
88
|
+
export function Hello({ name }) {
|
|
89
|
+
return <div>Hello {name}</div>
|
|
90
|
+
}`;
|
|
91
|
+
const entries = parseImportsBlock(block);
|
|
92
|
+
// 4 re-exports (PieChart, BarChart, LineChart, Counter resolved from imports) + 1 inline function
|
|
93
|
+
expect(entries).toHaveLength(5);
|
|
94
|
+
expect(entries[0]).toEqual({ name: "PieChart", from: "npm:recharts", exports: "PieChart" });
|
|
95
|
+
expect(entries[1]).toEqual({ name: "BarChart", from: "npm:recharts", exports: "BarChart" });
|
|
96
|
+
expect(entries[2]).toEqual({ name: "LineChart", from: "npm:recharts", exports: "LineChart" });
|
|
97
|
+
expect(entries[3]).toEqual({ name: "Counter", from: "npm:stat-counter", exports: "StatCounter" });
|
|
98
|
+
expect(entries[4].name).toBe("Hello");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resolves components from importsBlock", () => {
|
|
102
|
+
const compiled = compileDescriptiveRoot({
|
|
103
|
+
layout: "series",
|
|
104
|
+
importsBlock: `export { PieChart } from "npm:recharts"`,
|
|
105
|
+
children: [{ type: "component", jsx: "<PieChart />", duration: 1 }],
|
|
106
|
+
});
|
|
107
|
+
const c = compiled.children[0] as any;
|
|
108
|
+
expect(c.imports).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("compileDescriptiveRoot — component imports", () => {
|
|
113
|
+
// Components resolve imports through the global root.imports → ComposeContext
|
|
114
|
+
// path, not through per-node imports.
|
|
115
|
+
|
|
116
|
+
it("component nodes do not carry per-node imports", () => {
|
|
117
|
+
const compiled = compileDescriptiveRoot({
|
|
118
|
+
layout: "series",
|
|
119
|
+
importsBlock: `export { PieChart } from "npm:recharts"
|
|
120
|
+
export { BarChart } from "npm:recharts"`,
|
|
121
|
+
children: [
|
|
122
|
+
{ type: "component", jsx: "<PieChart />", duration: 1 },
|
|
123
|
+
{ type: "component", jsx: "<BarChart />", duration: 1 },
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
const c1 = compiled.children[0] as any;
|
|
127
|
+
const c2 = compiled.children[1] as any;
|
|
128
|
+
// Per-node imports field is undefined — components resolve at root level
|
|
129
|
+
expect(c1.imports).toBeUndefined();
|
|
130
|
+
expect(c2.imports).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("compileDescriptiveRoot", () => {
|
|
135
|
+
it("compiles simple series into legacy root+actions", () => {
|
|
136
|
+
const compiled = compileDescriptiveRoot({
|
|
137
|
+
width: 640,
|
|
138
|
+
height: 480,
|
|
139
|
+
fps: 30,
|
|
140
|
+
layout: "series",
|
|
141
|
+
children: [
|
|
142
|
+
{ id: "img1", type: "image", src: "a.jpg", duration: 2 },
|
|
143
|
+
{ id: "v1", type: "video", src: "a.mp4", startFrom: 1, endAt: 4 },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(compiled.type).toBe("root");
|
|
148
|
+
expect(compiled.isSeries).toBe(true);
|
|
149
|
+
expect(compiled.children).toHaveLength(2);
|
|
150
|
+
|
|
151
|
+
const first = compiled.children[0] as any;
|
|
152
|
+
expect(first.actions[0].start).toBe(0);
|
|
153
|
+
expect(first.actions[0].end).toBe(2);
|
|
154
|
+
|
|
155
|
+
const second = compiled.children[1] as any;
|
|
156
|
+
expect(second.actions[0].startFrom).toBe(1);
|
|
157
|
+
expect(second.actions[0].endAt).toBe(4);
|
|
158
|
+
expect(second.actions[0].end).toBe(3);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("allows explicit start in parallel root", () => {
|
|
162
|
+
const compiled = compileDescriptiveRoot({
|
|
163
|
+
layout: "parallel",
|
|
164
|
+
children: [
|
|
165
|
+
{ id: "a", type: "image", src: "a.jpg", duration: 2, start: 1 },
|
|
166
|
+
{ id: "b", type: "image", src: "b.jpg", duration: 1, start: 0 },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(compiled.isSeries).toBe(false);
|
|
171
|
+
expect(compiled.durationInSeconds).toBe(3);
|
|
172
|
+
const first = compiled.children[0] as any;
|
|
173
|
+
expect(first.actions[0].start).toBe(1);
|
|
174
|
+
expect(first.actions[0].end).toBe(3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("warns on duplicate ids", () => {
|
|
178
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
179
|
+
compileDescriptiveRoot({
|
|
180
|
+
children: [
|
|
181
|
+
{ id: "dup", type: "image", src: "a.jpg", duration: 2 },
|
|
182
|
+
{ id: "dup", type: "image", src: "b.jpg", duration: 2 },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
expect(spy).toHaveBeenCalledWith(expect.stringMatching(/duplicate id/));
|
|
186
|
+
spy.mockRestore();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("compiles nested containers with correct aggregated duration", () => {
|
|
190
|
+
const compiled = compileDescriptiveRoot({
|
|
191
|
+
layout: "series",
|
|
192
|
+
children: [
|
|
193
|
+
{
|
|
194
|
+
id: "block-1",
|
|
195
|
+
type: "parallel",
|
|
196
|
+
children: [
|
|
197
|
+
{ id: "p-1", type: "image", src: "a.jpg", duration: 3, start: 0 },
|
|
198
|
+
{ id: "p-2", type: "image", src: "b.jpg", duration: 2, start: 1 },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "block-2",
|
|
203
|
+
type: "transitionSeries",
|
|
204
|
+
transition: "fade",
|
|
205
|
+
transitionTime: 0.5,
|
|
206
|
+
children: [
|
|
207
|
+
{ id: "t-1", type: "component", jsx: "AnimatedHeadline", duration: 2 },
|
|
208
|
+
{ id: "t-2", type: "image", src: "c.jpg", duration: 2 },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(compiled.children).toHaveLength(2);
|
|
215
|
+
expect(compiled.durationInSeconds).toBeCloseTo(6.5);
|
|
216
|
+
|
|
217
|
+
const first = compiled.children[0] as any;
|
|
218
|
+
expect(first.type).toBe("folder");
|
|
219
|
+
expect(first.isSeries).toBe(false);
|
|
220
|
+
|
|
221
|
+
const second = compiled.children[1] as any;
|
|
222
|
+
expect(second.type).toBe("folder");
|
|
223
|
+
expect(second.isSeries).toBe(true);
|
|
224
|
+
expect(second.transition).toBe("fade");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("compiles scene as descriptive nested container", () => {
|
|
228
|
+
const compiled = compileDescriptiveRoot({
|
|
229
|
+
layout: "series",
|
|
230
|
+
children: [
|
|
231
|
+
{
|
|
232
|
+
id: "scene-1",
|
|
233
|
+
type: "scene",
|
|
234
|
+
children: [
|
|
235
|
+
{ id: "s1-img", type: "image", src: "hero.jpg", duration: 2 },
|
|
236
|
+
{ id: "s1-audio", type: "audio", src: "narration.mp3", duration: 1.5 },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const scene = compiled.children[0] as any;
|
|
243
|
+
expect(scene.type).toBe("scene");
|
|
244
|
+
expect(scene.children).toHaveLength(2);
|
|
245
|
+
expect(scene.durationInSeconds).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("compiles include with src and include fallback children", () => {
|
|
249
|
+
const withSrc = compileDescriptiveRoot({
|
|
250
|
+
layout: "series",
|
|
251
|
+
children: [{ id: "inc-src", type: "include", src: "./child.json", duration: 4 }],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const includeWithSrc = withSrc.children[0] as any;
|
|
255
|
+
expect(includeWithSrc.type).toBe("include");
|
|
256
|
+
expect(includeWithSrc.actions[0].start).toBe(0);
|
|
257
|
+
expect(includeWithSrc.actions[0].end).toBe(4);
|
|
258
|
+
|
|
259
|
+
const withChildren = compileDescriptiveRoot({
|
|
260
|
+
layout: "series",
|
|
261
|
+
children: [
|
|
262
|
+
{
|
|
263
|
+
id: "inc-inline",
|
|
264
|
+
type: "include",
|
|
265
|
+
children: [
|
|
266
|
+
{ id: "inc-i1", type: "image", src: "a.jpg", duration: 3 },
|
|
267
|
+
{ id: "inc-i2", type: "image", src: "b.jpg", duration: 1, start: 1 },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const includeInline = withChildren.children[0] as any;
|
|
274
|
+
expect(includeInline.type).toBe("include");
|
|
275
|
+
expect(includeInline.children).toHaveLength(2);
|
|
276
|
+
expect(includeInline.actions[0].end).toBe(3);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("covers remaining types: effect (via effects:[]) and map", () => {
|
|
280
|
+
const compiled = compileDescriptiveRoot({
|
|
281
|
+
layout: "parallel",
|
|
282
|
+
children: [
|
|
283
|
+
{
|
|
284
|
+
id: "fx-img",
|
|
285
|
+
type: "image",
|
|
286
|
+
src: "fx.jpg",
|
|
287
|
+
duration: 2,
|
|
288
|
+
effects: ["fadeIn"],
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "map-1",
|
|
292
|
+
type: "map",
|
|
293
|
+
duration: 4,
|
|
294
|
+
waypoints: [
|
|
295
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
296
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
297
|
+
],
|
|
298
|
+
routeMarker: "🚲",
|
|
299
|
+
travelMode: "BICYCLING",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// effects:[] on image compiles into Effect wrapper stream
|
|
305
|
+
const effect = compiled.children[0] as any;
|
|
306
|
+
expect(effect.type).toBe("effect");
|
|
307
|
+
expect(effect.actions[0].start).toBe(0);
|
|
308
|
+
expect(effect.actions[0].end).toBe(2);
|
|
309
|
+
expect(effect.children).toHaveLength(1);
|
|
310
|
+
|
|
311
|
+
const map = compiled.children.find((c: any) => c.id === "map-1") as any;
|
|
312
|
+
expect(map.type).toBe("map");
|
|
313
|
+
expect(map.actions[0].end).toBe(4);
|
|
314
|
+
expect(map.waypoints).toHaveLength(2);
|
|
315
|
+
expect(map.routeMarker).toBe("🚲");
|
|
316
|
+
expect(map.travelMode).toBe("BICYCLING");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("compiles leaf node with effects=[fadeIn] (string shorthand)", () => {
|
|
320
|
+
const compiled = compileDescriptiveRoot({
|
|
321
|
+
layout: "series",
|
|
322
|
+
children: [
|
|
323
|
+
{
|
|
324
|
+
id: "img-fx",
|
|
325
|
+
type: "image",
|
|
326
|
+
src: "hero.jpg",
|
|
327
|
+
duration: 3,
|
|
328
|
+
effects: ["fadeIn"],
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// The leaf should be wrapped in an effect stream
|
|
334
|
+
const fx = compiled.children[0] as any;
|
|
335
|
+
expect(fx.type).toBe("effect");
|
|
336
|
+
expect(fx.animation).toBe("fadeIn");
|
|
337
|
+
expect(fx.animationIterationCount).toBe(1);
|
|
338
|
+
expect(fx.children).toHaveLength(1);
|
|
339
|
+
expect(fx.children[0].type).toBe("image");
|
|
340
|
+
expect(fx.children[0].src).toBe("hero.jpg");
|
|
341
|
+
// Effect action spans leaf duration
|
|
342
|
+
expect(fx.actions[0].start).toBe(0);
|
|
343
|
+
expect(fx.actions[0].end).toBe(3);
|
|
344
|
+
// Inner leaf action is relative (start=0)
|
|
345
|
+
expect(fx.children[0].actions[0].start).toBe(0);
|
|
346
|
+
expect(fx.children[0].actions[0].end).toBe(3);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("compiles leaf node with effects as object specs", () => {
|
|
350
|
+
const compiled = compileDescriptiveRoot({
|
|
351
|
+
layout: "parallel",
|
|
352
|
+
children: [
|
|
353
|
+
{
|
|
354
|
+
id: "img-fx-obj",
|
|
355
|
+
type: "image",
|
|
356
|
+
src: "card.jpg",
|
|
357
|
+
duration: 2,
|
|
358
|
+
start: 1,
|
|
359
|
+
effects: [{
|
|
360
|
+
animation: "bounceIn",
|
|
361
|
+
animationTimingFunction: "ease-out",
|
|
362
|
+
animationIterationCount: 2,
|
|
363
|
+
}],
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const fx = compiled.children[0] as any;
|
|
369
|
+
expect(fx.type).toBe("effect");
|
|
370
|
+
expect(fx.animation).toBe("bounceIn");
|
|
371
|
+
expect(fx.animationTimingFunction).toBe("ease-out");
|
|
372
|
+
expect(fx.animationIterationCount).toBe(2);
|
|
373
|
+
// Outermost effect carries the absolute timing from parallel layout
|
|
374
|
+
expect(fx.actions[0].start).toBe(1);
|
|
375
|
+
expect(fx.actions[0].end).toBe(3);
|
|
376
|
+
// Inner leaf timing is relative
|
|
377
|
+
expect(fx.children[0].actions[0].start).toBe(0);
|
|
378
|
+
expect(fx.children[0].actions[0].end).toBe(2);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("compiles effects on container (parallel) nodes", () => {
|
|
382
|
+
const compiled = compileDescriptiveRoot({
|
|
383
|
+
layout: "series",
|
|
384
|
+
children: [
|
|
385
|
+
{
|
|
386
|
+
id: "container-fx",
|
|
387
|
+
type: "parallel",
|
|
388
|
+
effects: ["fadeIn"],
|
|
389
|
+
children: [
|
|
390
|
+
{ id: "c1", type: "image", src: "a.jpg", duration: 2 },
|
|
391
|
+
{ id: "c2", type: "image", src: "b.jpg", duration: 3 },
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const fx = compiled.children[0] as any;
|
|
398
|
+
expect(fx.type).toBe("effect");
|
|
399
|
+
expect(fx.animation).toBe("fadeIn");
|
|
400
|
+
expect(fx.children).toHaveLength(1);
|
|
401
|
+
const innerFolder = fx.children[0];
|
|
402
|
+
expect(innerFolder.type).toBe("folder");
|
|
403
|
+
expect(innerFolder.isSeries).toBe(false);
|
|
404
|
+
expect(innerFolder.children).toHaveLength(2);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("compiles multiple effects nested", () => {
|
|
408
|
+
const compiled = compileDescriptiveRoot({
|
|
409
|
+
layout: "series",
|
|
410
|
+
children: [
|
|
411
|
+
{
|
|
412
|
+
id: "multi-fx",
|
|
413
|
+
type: "image",
|
|
414
|
+
src: "multi.jpg",
|
|
415
|
+
duration: 3,
|
|
416
|
+
effects: ["fadeIn", "bounceIn"],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const outer = compiled.children[0] as any;
|
|
422
|
+
expect(outer.type).toBe("effect");
|
|
423
|
+
expect(outer.animation).toBe("fadeIn"); // outermost = first in array
|
|
424
|
+
expect(outer.children).toHaveLength(1);
|
|
425
|
+
const inner = outer.children[0];
|
|
426
|
+
expect(inner.type).toBe("effect");
|
|
427
|
+
expect(inner.animation).toBe("bounceIn");
|
|
428
|
+
expect(inner.children).toHaveLength(1);
|
|
429
|
+
expect(inner.children[0].type).toBe("image");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("compiles effects:[] on image into Effect wrapper stream", () => {
|
|
433
|
+
const compiled = compileDescriptiveRoot({
|
|
434
|
+
layout: "series",
|
|
435
|
+
children: [
|
|
436
|
+
{
|
|
437
|
+
id: "inner",
|
|
438
|
+
type: "image",
|
|
439
|
+
src: "inner.jpg",
|
|
440
|
+
duration: 2,
|
|
441
|
+
effects: ["fadeIn"],
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// effects:[fadeIn] on image compiles into Effect wrapper
|
|
447
|
+
const fx = compiled.children[0] as any;
|
|
448
|
+
expect(fx.type).toBe("effect");
|
|
449
|
+
expect(fx.animation).toBe("fadeIn");
|
|
450
|
+
expect(fx.children).toHaveLength(1);
|
|
451
|
+
expect(fx.children[0].type).toBe("image");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("compiles effect with named params including duration", () => {
|
|
455
|
+
const compiled = compileDescriptiveRoot({
|
|
456
|
+
layout: "series",
|
|
457
|
+
children: [
|
|
458
|
+
{
|
|
459
|
+
id: "named-fx",
|
|
460
|
+
type: "image",
|
|
461
|
+
src: "hero.jpg",
|
|
462
|
+
duration: 5,
|
|
463
|
+
effects: ["fadeIn(2, ease-out, 3)"],
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const fx = compiled.children[0] as any;
|
|
469
|
+
expect(fx.type).toBe("effect");
|
|
470
|
+
expect(fx.animation).toBe("fadeIn");
|
|
471
|
+
expect(fx.animationTimingFunction).toBe("ease-out");
|
|
472
|
+
expect(fx.animationIterationCount).toBe(3);
|
|
473
|
+
// Effect duration from param overrides to 2s
|
|
474
|
+
expect(fx.actions[0].end - fx.actions[0].start).toBe(2);
|
|
475
|
+
// Inner leaf still plays full 5s
|
|
476
|
+
expect(fx.children[0].actions[0].end - fx.children[0].actions[0].start).toBe(5);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("compiles effect with comma-separated positional params (duration, timingFunction, iterationCount)", () => {
|
|
480
|
+
const compiled = compileDescriptiveRoot({
|
|
481
|
+
layout: "series",
|
|
482
|
+
children: [
|
|
483
|
+
{
|
|
484
|
+
id: "csv-fx",
|
|
485
|
+
type: "image",
|
|
486
|
+
src: "card.jpg",
|
|
487
|
+
duration: 4,
|
|
488
|
+
effects: ["fadeIn(2, ease-in, 3)"],
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const fx = compiled.children[0] as any;
|
|
494
|
+
expect(fx.type).toBe("effect");
|
|
495
|
+
expect(fx.animation).toBe("fadeIn");
|
|
496
|
+
expect(fx.animationTimingFunction).toBe("ease-in");
|
|
497
|
+
expect(fx.animationIterationCount).toBe(3);
|
|
498
|
+
expect(fx.actions[0].end - fx.actions[0].start).toBe(2);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("compiles effect with positional params — duration only", () => {
|
|
502
|
+
const compiled = compileDescriptiveRoot({
|
|
503
|
+
layout: "series",
|
|
504
|
+
children: [
|
|
505
|
+
{
|
|
506
|
+
id: "dur-only",
|
|
507
|
+
type: "image",
|
|
508
|
+
src: "bg.jpg",
|
|
509
|
+
duration: 4,
|
|
510
|
+
effects: ["fadeIn(1.5)"],
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const fx = compiled.children[0] as any;
|
|
516
|
+
expect(fx.type).toBe("effect");
|
|
517
|
+
expect(fx.animation).toBe("fadeIn");
|
|
518
|
+
expect(fx.actions[0].end - fx.actions[0].start).toBe(1.5);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("compiles effect with partial positional params — duration + timingFunction", () => {
|
|
522
|
+
const compiled = compileDescriptiveRoot({
|
|
523
|
+
layout: "series",
|
|
524
|
+
children: [
|
|
525
|
+
{
|
|
526
|
+
id: "partial-fx",
|
|
527
|
+
type: "image",
|
|
528
|
+
src: "card.jpg",
|
|
529
|
+
duration: 4,
|
|
530
|
+
effects: ["fadeIn(2.5, ease-out)"],
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const fx = compiled.children[0] as any;
|
|
536
|
+
expect(fx.animation).toBe("fadeIn");
|
|
537
|
+
expect(fx.animationTimingFunction).toBe("ease-out");
|
|
538
|
+
expect(fx.animationIterationCount).toBe(1);
|
|
539
|
+
expect(fx.actions[0].end - fx.actions[0].start).toBe(2.5);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("supports deep nested layout containers across all modes", () => {
|
|
543
|
+
const compiled = compileDescriptiveRoot({
|
|
544
|
+
layout: "series",
|
|
545
|
+
children: [
|
|
546
|
+
{
|
|
547
|
+
id: "outer-series",
|
|
548
|
+
type: "series",
|
|
549
|
+
children: [
|
|
550
|
+
{
|
|
551
|
+
id: "inner-parallel",
|
|
552
|
+
type: "parallel",
|
|
553
|
+
children: [
|
|
554
|
+
{ id: "p1", type: "image", src: "1.jpg", duration: 2, start: 0 },
|
|
555
|
+
{ id: "p2", type: "image", src: "2.jpg", duration: 1.5, start: 0.5 },
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
id: "inner-transition",
|
|
560
|
+
type: "transitionSeries",
|
|
561
|
+
transition: "fade",
|
|
562
|
+
transitionTime: 0.25,
|
|
563
|
+
children: [
|
|
564
|
+
{ id: "t1", type: "component", jsx: "AnimatedHeadline", duration: 2 },
|
|
565
|
+
{ id: "t2", type: "image", src: "3.jpg", duration: 1 },
|
|
566
|
+
],
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// inner parallel = max(2, 0.5 + 1.5) = 2
|
|
574
|
+
// inner transition = 2 + 1 - 0.25 = 2.75
|
|
575
|
+
// outer series = 2 + 2.75 = 4.75
|
|
576
|
+
expect(compiled.durationInSeconds).toBeCloseTo(4.75);
|
|
577
|
+
|
|
578
|
+
const outer = compiled.children[0] as any;
|
|
579
|
+
expect(outer.type).toBe("folder");
|
|
580
|
+
expect(outer.isSeries).toBe(true);
|
|
581
|
+
expect(outer.children).toHaveLength(2);
|
|
582
|
+
|
|
583
|
+
const innerParallel = outer.children[0];
|
|
584
|
+
expect(innerParallel.type).toBe("folder");
|
|
585
|
+
expect(innerParallel.isSeries).toBe(false);
|
|
586
|
+
|
|
587
|
+
const innerTransition = outer.children[1];
|
|
588
|
+
expect(innerTransition.type).toBe("folder");
|
|
589
|
+
expect(innerTransition.isSeries).toBe(true);
|
|
590
|
+
expect(innerTransition.transition).toBe("fade");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("supports merged transition syntax: transition:'wipe(0.8)' (inline time)", () => {
|
|
594
|
+
const compiled = compileDescriptiveRoot({
|
|
595
|
+
layout: "parallel",
|
|
596
|
+
children: [
|
|
597
|
+
{
|
|
598
|
+
id: "ts",
|
|
599
|
+
type: "transitionSeries",
|
|
600
|
+
transition: "wipe(0.8)",
|
|
601
|
+
children: [
|
|
602
|
+
{ id: "a1", type: "image", src: "a.jpg", duration: 2 },
|
|
603
|
+
{ id: "a2", type: "image", src: "b.jpg", duration: 2 },
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// ts local duration = 2 + 2 - 0.8 = 3.2
|
|
610
|
+
expect(compiled.durationInSeconds).toBeCloseTo(3.2);
|
|
611
|
+
|
|
612
|
+
const ts = compiled.children.find((c: any) => c.id === "ts") as any;
|
|
613
|
+
expect(ts.transition).toBe("wipe");
|
|
614
|
+
expect(ts.transitionTime).toBe(0.8);
|
|
615
|
+
expect(ts.durationInSeconds).toBeCloseTo(3.2);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("supports merged transition syntax inline time + separate transitionTime (separate wins)", () => {
|
|
619
|
+
const compiled = compileDescriptiveRoot({
|
|
620
|
+
layout: "series",
|
|
621
|
+
children: [
|
|
622
|
+
{
|
|
623
|
+
id: "ts",
|
|
624
|
+
type: "transitionSeries",
|
|
625
|
+
transition: "fade(0.5)",
|
|
626
|
+
transitionTime: 0.9,
|
|
627
|
+
children: [
|
|
628
|
+
{ id: "a1", type: "image", src: "a.jpg", duration: 2 },
|
|
629
|
+
{ id: "a2", type: "image", src: "b.jpg", duration: 2 },
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// separate transitionTime (0.9) overrides inline (0.5)
|
|
636
|
+
// ts local duration = 2 + 2 - 0.9 = 3.1
|
|
637
|
+
expect(compiled.durationInSeconds).toBeCloseTo(3.1);
|
|
638
|
+
|
|
639
|
+
const ts = compiled.children.find((c: any) => c.id === "ts") as any;
|
|
640
|
+
expect(ts.transition).toBe("fade");
|
|
641
|
+
expect(ts.transitionTime).toBe(0.9);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("supports transitionSeries nested inside parallel", () => {
|
|
645
|
+
const compiled = compileDescriptiveRoot({
|
|
646
|
+
layout: "parallel",
|
|
647
|
+
children: [
|
|
648
|
+
{
|
|
649
|
+
id: "ts",
|
|
650
|
+
type: "transitionSeries",
|
|
651
|
+
transition: "wipe",
|
|
652
|
+
transitionTime: 0.5,
|
|
653
|
+
children: [
|
|
654
|
+
{ id: "a1", type: "image", src: "a.jpg", duration: 2 },
|
|
655
|
+
{ id: "a2", type: "image", src: "b.jpg", duration: 2 },
|
|
656
|
+
],
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
id: "plain",
|
|
660
|
+
type: "series",
|
|
661
|
+
children: [{ id: "s1", type: "image", src: "c.jpg", duration: 2 }],
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ts local duration = 2 + 2 - 0.5 = 3.5
|
|
667
|
+
// plain local duration = 2
|
|
668
|
+
// parallel root takes max => 3.5
|
|
669
|
+
expect(compiled.durationInSeconds).toBeCloseTo(3.5);
|
|
670
|
+
|
|
671
|
+
const ts = compiled.children.find((c: any) => c.id === "ts") as any;
|
|
672
|
+
expect(ts.type).toBe("folder");
|
|
673
|
+
expect(ts.transition).toBe("wipe");
|
|
674
|
+
expect(ts.durationInSeconds).toBeCloseTo(3.5);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("lets scene organize storyboard flow with series layout", () => {
|
|
678
|
+
const compiled = compileDescriptiveRoot({
|
|
679
|
+
layout: "series",
|
|
680
|
+
children: [
|
|
681
|
+
{
|
|
682
|
+
id: "scene-story",
|
|
683
|
+
type: "scene",
|
|
684
|
+
layout: "series",
|
|
685
|
+
children: [
|
|
686
|
+
{ id: "shot-1", type: "image", src: "shot1.jpg", duration: 2 },
|
|
687
|
+
{ id: "shot-2", type: "image", src: "shot2.jpg", duration: 1 },
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const scene = compiled.children[0] as any;
|
|
694
|
+
expect(scene.type).toBe("scene");
|
|
695
|
+
expect(scene.durationInSeconds).toBe(3);
|
|
696
|
+
|
|
697
|
+
// Scene layout is lowered into an inner legacy folder for runtime compatibility.
|
|
698
|
+
expect(scene.children).toHaveLength(1);
|
|
699
|
+
const sceneLayoutFolder = scene.children[0];
|
|
700
|
+
expect(sceneLayoutFolder.type).toBe("folder");
|
|
701
|
+
expect(sceneLayoutFolder.isSeries).toBe(true);
|
|
702
|
+
expect(sceneLayoutFolder.children).toHaveLength(2);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("distributes rhythm children across beats", () => {
|
|
706
|
+
const compiled = compileDescriptiveRoot({
|
|
707
|
+
layout: "series",
|
|
708
|
+
children: [
|
|
709
|
+
{
|
|
710
|
+
id: "beat-drop",
|
|
711
|
+
type: "rhythm",
|
|
712
|
+
src: "beat.mp3",
|
|
713
|
+
spots: [0.5, 1.5, 2.5, 3.5],
|
|
714
|
+
children: [
|
|
715
|
+
{ id: "b1", type: "image", src: "a.jpg" },
|
|
716
|
+
{ id: "b2", type: "image", src: "b.jpg" },
|
|
717
|
+
{ id: "b3", type: "image", src: "c.jpg" },
|
|
718
|
+
{ id: "b4", type: "image", src: "d.jpg" },
|
|
719
|
+
],
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const rhythm = compiled.children[0] as any;
|
|
725
|
+
expect(rhythm.type).toBe("rhythm");
|
|
726
|
+
expect(rhythm.children).toHaveLength(4);
|
|
727
|
+
|
|
728
|
+
// child[0] starts at beat 0.5, ends at beat 1.5 => duration 1
|
|
729
|
+
expect(rhythm.children[0].actions[0].start).toBe(0.5);
|
|
730
|
+
expect(rhythm.children[0].actions[0].end).toBe(1.5);
|
|
731
|
+
|
|
732
|
+
// child[1] starts at beat 1.5, ends at beat 2.5 => duration 1
|
|
733
|
+
expect(rhythm.children[1].actions[0].start).toBe(1.5);
|
|
734
|
+
expect(rhythm.children[1].actions[0].end).toBe(2.5);
|
|
735
|
+
|
|
736
|
+
// last child starts at last beat (3.5), extends by avg gap (1.0) => ends at 4.5
|
|
737
|
+
expect(rhythm.children[3].actions[0].end).toBe(4.5);
|
|
738
|
+
// rhythm duration = last spot + avg gap
|
|
739
|
+
expect(rhythm.durationInSeconds).toBe(4.5);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("lets scene organize storyboard flow with transitionSeries layout", () => {
|
|
743
|
+
const compiled = compileDescriptiveRoot({
|
|
744
|
+
layout: "series",
|
|
745
|
+
children: [
|
|
746
|
+
{
|
|
747
|
+
id: "scene-trans",
|
|
748
|
+
type: "scene",
|
|
749
|
+
layout: "transitionSeries",
|
|
750
|
+
transition: "fade",
|
|
751
|
+
transitionTime: 0.5,
|
|
752
|
+
children: [
|
|
753
|
+
{ id: "a", type: "image", src: "a.jpg", duration: 2 },
|
|
754
|
+
{ id: "b", type: "image", src: "b.jpg", duration: 2 },
|
|
755
|
+
],
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const scene = compiled.children[0] as any;
|
|
761
|
+
expect(scene.durationInSeconds).toBeCloseTo(3.5);
|
|
762
|
+
const sceneLayoutFolder = scene.children[0];
|
|
763
|
+
expect(sceneLayoutFolder.type).toBe("folder");
|
|
764
|
+
expect(sceneLayoutFolder.transition).toBe("fade");
|
|
765
|
+
expect(sceneLayoutFolder.durationInSeconds).toBeCloseTo(3.5);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("compiles audio node with volume and foreground", () => {
|
|
769
|
+
const compiled = compileDescriptiveRoot({
|
|
770
|
+
layout: "parallel",
|
|
771
|
+
children: [
|
|
772
|
+
{ id: "bgm", type: "audio", src: "bg.mp3", duration: 5, volume: 0.3, foreground: false },
|
|
773
|
+
{ id: "sfx", type: "audio", src: "sfx.wav", duration: 1, volume: 1, start: 2 },
|
|
774
|
+
],
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const bgm = compiled.children.find((c: any) => c.id === "bgm") as any;
|
|
778
|
+
expect(bgm.type).toBe("audio");
|
|
779
|
+
expect(bgm.volume).toBe(0.3);
|
|
780
|
+
expect(bgm.foreground).toBe(false);
|
|
781
|
+
expect(bgm.actions[0].end).toBe(5);
|
|
782
|
+
|
|
783
|
+
const sfx = compiled.children.find((c: any) => c.id === "sfx") as any;
|
|
784
|
+
expect(sfx.actions[0].start).toBe(2);
|
|
785
|
+
expect(sfx.actions[0].end).toBe(3);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("compiles component node with props", () => {
|
|
789
|
+
const compiled = compileDescriptiveRoot({
|
|
790
|
+
layout: "series",
|
|
791
|
+
children: [
|
|
792
|
+
{
|
|
793
|
+
id: "headline",
|
|
794
|
+
type: "component",
|
|
795
|
+
jsx: "<AnimatedHeadline text='Hello' gradient />",
|
|
796
|
+
duration: 3,
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const c = compiled.children[0] as any;
|
|
802
|
+
expect(c.type).toBe("component");
|
|
803
|
+
expect(c.jsx).toContain("AnimatedHeadline");
|
|
804
|
+
expect(c.actions[0].end).toBe(3);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("compiles rhythm without children as audio leaf", () => {
|
|
808
|
+
const compiled = compileDescriptiveRoot({
|
|
809
|
+
layout: "series",
|
|
810
|
+
children: [
|
|
811
|
+
{ id: "beat", type: "rhythm", src: "beat.mp3", spots: [0.5, 2.5] },
|
|
812
|
+
],
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const r = compiled.children[0] as any;
|
|
816
|
+
expect(r.type).toBe("rhythm");
|
|
817
|
+
expect(r.src).toBe("beat.mp3");
|
|
818
|
+
expect(r.children).toEqual([]);
|
|
819
|
+
// rhythm duration = last spot + avg gap = 2.5 + 2.0 = 4.5
|
|
820
|
+
expect(r.actions[0].end).toBeCloseTo(4.5);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("compiles include with src as leaf", () => {
|
|
824
|
+
const compiled = compileDescriptiveRoot({
|
|
825
|
+
layout: "series",
|
|
826
|
+
children: [
|
|
827
|
+
{ id: "inc", type: "include", src: "./child.json", duration: 3 },
|
|
828
|
+
],
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const inc = compiled.children[0] as any;
|
|
832
|
+
expect(inc.type).toBe("include");
|
|
833
|
+
expect(inc.src).toBe("./child.json");
|
|
834
|
+
expect(inc.actions[0].end).toBe(3);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("compiles effects:[] with named params (duration, timingFunction, iterationCount)", () => {
|
|
838
|
+
const compiled = compileDescriptiveRoot({
|
|
839
|
+
layout: "series",
|
|
840
|
+
children: [
|
|
841
|
+
{
|
|
842
|
+
id: "inner",
|
|
843
|
+
type: "image",
|
|
844
|
+
src: "inner.jpg",
|
|
845
|
+
duration: 2,
|
|
846
|
+
effects: [{ animation: "fadeIn", duration: 3 }],
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const fx = compiled.children[0] as any;
|
|
852
|
+
expect(fx.type).toBe("effect");
|
|
853
|
+
expect(fx.animation).toBe("fadeIn");
|
|
854
|
+
expect(fx.children).toHaveLength(1);
|
|
855
|
+
expect(fx.children[0].type).toBe("image");
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("uses defaults when duration is missing", () => {
|
|
859
|
+
const compiled = compileDescriptiveRoot({
|
|
860
|
+
layout: "series",
|
|
861
|
+
children: [
|
|
862
|
+
{ id: "no-dr", type: "image", src: "x.jpg" },
|
|
863
|
+
{ id: "no-dr-vid", type: "video", src: "x.mp4" },
|
|
864
|
+
],
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const img = compiled.children[0] as any;
|
|
868
|
+
expect(img.actions[0].end).toBe(3); // image default is 3s
|
|
869
|
+
|
|
870
|
+
const vid = compiled.children[1] as any;
|
|
871
|
+
expect(vid.actions[0].end).toBe(3); // video default is 3s
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("uses default duration when duration is missing", () => {
|
|
875
|
+
const compiled = compileDescriptiveRoot({
|
|
876
|
+
layout: "series",
|
|
877
|
+
children: [
|
|
878
|
+
{ id: "no-dr", type: "image", src: "x.jpg" },
|
|
879
|
+
],
|
|
880
|
+
});
|
|
881
|
+
const img = compiled.children[0] as any;
|
|
882
|
+
expect(img.actions[0].end).toBe(3); // image default is 3s
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("preserves root-level instruction and stylesheet", () => {
|
|
886
|
+
const compiled = compileDescriptiveRoot({
|
|
887
|
+
instruction: "A stylish promo",
|
|
888
|
+
stylesheet: "body { font-family: sans-serif; }",
|
|
889
|
+
children: [
|
|
890
|
+
{ id: "a", type: "image", src: "a.jpg", duration: 2 },
|
|
891
|
+
],
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
expect((compiled as any).instruction).toBe("A stylish promo");
|
|
895
|
+
expect(compiled.stylesheet).toBe("body { font-family: sans-serif; }");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("compiles map with waypoints", () => {
|
|
899
|
+
const compiled = compileDescriptiveRoot({
|
|
900
|
+
layout: "series",
|
|
901
|
+
children: [
|
|
902
|
+
{
|
|
903
|
+
id: "route",
|
|
904
|
+
type: "map",
|
|
905
|
+
duration: 5,
|
|
906
|
+
waypoints: [
|
|
907
|
+
{ lat: 48.8566, lng: 2.3522, label: "Paris" },
|
|
908
|
+
{ lat: 51.5074, lng: -0.1278, label: "London" },
|
|
909
|
+
],
|
|
910
|
+
travelMode: "DRIVING",
|
|
911
|
+
},
|
|
912
|
+
],
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
const m = compiled.children[0] as any;
|
|
916
|
+
expect(m.type).toBe("map");
|
|
917
|
+
expect(m.waypoints).toHaveLength(2);
|
|
918
|
+
expect(m.travelMode).toBe("DRIVING");
|
|
919
|
+
expect(m.actions[0].end).toBe(5);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("filters invisible children", () => {
|
|
923
|
+
const compiled = compileDescriptiveRoot({
|
|
924
|
+
layout: "series",
|
|
925
|
+
children: [
|
|
926
|
+
{ id: "visible", type: "image", src: "a.jpg", duration: 2 },
|
|
927
|
+
{ id: "hidden", type: "image", src: "b.jpg", duration: 2, visible: false },
|
|
928
|
+
],
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
expect(compiled.children).toHaveLength(1);
|
|
932
|
+
const firstVisible = compiled.children[0] as any;
|
|
933
|
+
expect(firstVisible.id).toBe("visible");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("compiles empty children array", () => {
|
|
937
|
+
const compiled = compileDescriptiveRoot({
|
|
938
|
+
layout: "series",
|
|
939
|
+
children: [],
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
expect(compiled.children).toHaveLength(0);
|
|
943
|
+
expect(compiled.durationInSeconds).toBe(0);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("generates unique ids for nodes without id", () => {
|
|
947
|
+
const compiled = compileDescriptiveRoot({
|
|
948
|
+
children: [
|
|
949
|
+
{ type: "image", src: "a.jpg", duration: 1 },
|
|
950
|
+
{ type: "image", src: "b.jpg", duration: 1 },
|
|
951
|
+
],
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
const [a, b] = compiled.children as any[];
|
|
955
|
+
expect(a.id).toBeTruthy();
|
|
956
|
+
expect(b.id).toBeTruthy();
|
|
957
|
+
expect(a.id).not.toBe(b.id);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("extractDependencySpecs", () => {
|
|
962
|
+
it("extracts from export { Name } from \"spec\"", () => {
|
|
963
|
+
expect(extractDependencySpecs(`export { PieChart } from "npm:recharts"`)).toEqual(["npm:recharts"]);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("extracts from import statements", () => {
|
|
967
|
+
expect(extractDependencySpecs(`import { useState } from "react"`)).toEqual(["react"]);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("extracts multiple specs from mixed block", () => {
|
|
971
|
+
const block = `import { useState } from "react"
|
|
972
|
+
import ReactMarkdown from 'npm:react-markdown'
|
|
973
|
+
export { PieChart } from "npm:recharts"`;
|
|
974
|
+
expect(extractDependencySpecs(block)).toEqual(["react", "npm:react-markdown", "npm:recharts"]);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("extracts from export default Name from \"spec\"", () => {
|
|
978
|
+
expect(extractDependencySpecs(`export default Recharts from "npm:recharts"`)).toEqual(["npm:recharts"]);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("returns empty array for block with no from clauses", () => {
|
|
982
|
+
expect(extractDependencySpecs(`export function Hello() { return null; }`)).toEqual([]);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("extracts bare package names without npm: prefix", () => {
|
|
986
|
+
expect(extractDependencySpecs(`import { something } from "lodash"`)).toEqual(["lodash"]);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("handles semicolons after from spec", () => {
|
|
990
|
+
expect(extractDependencySpecs(`import { x } from "react";`)).toEqual(["react"]);
|
|
991
|
+
expect(extractDependencySpecs(`export { X } from "npm:recharts";`)).toEqual(["npm:recharts"]);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("handles single and double quotes", () => {
|
|
995
|
+
expect(extractDependencySpecs(`import { x } from 'react'`)).toEqual(["react"]);
|
|
996
|
+
expect(extractDependencySpecs(`import { x } from \`react\``)).toEqual(["react"]);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("handles spec with hash subpath", () => {
|
|
1000
|
+
expect(extractDependencySpecs(`export { Comp } from "npm:recharts#es/BarChart"`)).toEqual(["npm:recharts#es/BarChart"]);
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
describe("parseImportsBlock edge cases", () => {
|
|
1005
|
+
it("parses export { default } from \"spec\"", () => {
|
|
1006
|
+
const entries = parseImportsBlock(`export { default } from "npm:recharts"`);
|
|
1007
|
+
expect(entries).toHaveLength(1);
|
|
1008
|
+
expect(entries[0]).toEqual({ name: "default", from: "npm:recharts", exports: "default" });
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("parses export { default as Name } from \"spec\"", () => {
|
|
1012
|
+
const entries = parseImportsBlock(`export { default as PieChart } from "npm:recharts"`);
|
|
1013
|
+
expect(entries).toHaveLength(1);
|
|
1014
|
+
expect(entries[0]).toEqual({ name: "PieChart", from: "npm:recharts", exports: "default" });
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("import-only block produces no entries (internal deps only)", () => {
|
|
1018
|
+
const entries = parseImportsBlock(`import { useState } from "react"
|
|
1019
|
+
import ReactMarkdown from 'npm:react-markdown'`);
|
|
1020
|
+
expect(entries).toHaveLength(0);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("bare export without matching import produces no entry", () => {
|
|
1024
|
+
const entries = parseImportsBlock(`export { Unknown }`);
|
|
1025
|
+
expect(entries).toHaveLength(0);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("comment lines are skipped", () => {
|
|
1029
|
+
const entries = parseImportsBlock(`// comment
|
|
1030
|
+
export { PieChart } from "npm:recharts"
|
|
1031
|
+
/* block comment */
|
|
1032
|
+
export function Hello() { return null; }`);
|
|
1033
|
+
expect(entries).toHaveLength(2);
|
|
1034
|
+
expect(entries[0].name).toBe("PieChart");
|
|
1035
|
+
expect(entries[1].name).toBe("Hello");
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it("handles multiline inline function with nested braces", () => {
|
|
1039
|
+
const entries = parseImportsBlock(`export function Card({ title, children }) {
|
|
1040
|
+
return <div style={{ padding: 10, margin: 5 }}>
|
|
1041
|
+
<h2>{title}</h2>
|
|
1042
|
+
{children}
|
|
1043
|
+
</div>;
|
|
1044
|
+
}`);
|
|
1045
|
+
expect(entries).toHaveLength(1);
|
|
1046
|
+
expect(entries[0].name).toBe("Card");
|
|
1047
|
+
expect(entries[0].from).toBeUndefined();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("handles default import with as alias followed by bare re-export", () => {
|
|
1051
|
+
const entries = parseImportsBlock(`import ReactMarkdown from 'npm:react-markdown'
|
|
1052
|
+
export { ReactMarkdown }`);
|
|
1053
|
+
expect(entries).toHaveLength(1);
|
|
1054
|
+
expect(entries[0]).toEqual({ name: "ReactMarkdown", from: "npm:react-markdown", exports: "default" });
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
describe("compileDescriptiveRoot with importsBlock", () => {
|
|
1059
|
+
it("compiles component with re-exported import", () => {
|
|
1060
|
+
const compiled = compileDescriptiveRoot({
|
|
1061
|
+
layout: "series",
|
|
1062
|
+
importsBlock: `export { PieChart } from "npm:recharts"`,
|
|
1063
|
+
children: [
|
|
1064
|
+
{ type: "component", jsx: "<PieChart />", duration: 2 },
|
|
1065
|
+
],
|
|
1066
|
+
});
|
|
1067
|
+
expect(compiled.children).toHaveLength(1);
|
|
1068
|
+
const c = compiled.children[0] as any;
|
|
1069
|
+
expect(c.type).toBe("component");
|
|
1070
|
+
expect(c.jsx).toBe("<PieChart />");
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("compiles component with inline function definition from importsBlock", () => {
|
|
1074
|
+
const compiled = compileDescriptiveRoot({
|
|
1075
|
+
layout: "series",
|
|
1076
|
+
importsBlock: `export function Greeting({ name }) {
|
|
1077
|
+
return <div>Hello {name}</div>;
|
|
1078
|
+
}`,
|
|
1079
|
+
children: [
|
|
1080
|
+
{ type: "component", jsx: "<Greeting name='World' />", duration: 3 },
|
|
1081
|
+
],
|
|
1082
|
+
});
|
|
1083
|
+
expect(compiled.children).toHaveLength(1);
|
|
1084
|
+
const c = compiled.children[0] as any;
|
|
1085
|
+
expect(c.type).toBe("component");
|
|
1086
|
+
expect(c.jsx).toBe("<Greeting name='World' />");
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("compiles component with mixed import + inline from importsBlock", () => {
|
|
1090
|
+
const compiled = compileDescriptiveRoot({
|
|
1091
|
+
layout: "series",
|
|
1092
|
+
importsBlock: `import { useState } from "react"
|
|
1093
|
+
export { PieChart } from "npm:recharts"
|
|
1094
|
+
export function Counter() {
|
|
1095
|
+
const [count, setCount] = useState(0)
|
|
1096
|
+
return <div>{count}</div>
|
|
1097
|
+
}`,
|
|
1098
|
+
children: [
|
|
1099
|
+
{ type: "component", jsx: "<PieChart />", duration: 2 },
|
|
1100
|
+
{ type: "component", jsx: "<Counter />", duration: 3 },
|
|
1101
|
+
],
|
|
1102
|
+
});
|
|
1103
|
+
expect(compiled.children).toHaveLength(2);
|
|
1104
|
+
expect(compiled.children[0]).toMatchObject({ type: "component", jsx: "<PieChart />" });
|
|
1105
|
+
expect(compiled.children[1]).toMatchObject({ type: "component", jsx: "<Counter />" });
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("importsBlock overrides frontmatter imports", () => {
|
|
1109
|
+
const compiled = compileDescriptiveRoot({
|
|
1110
|
+
layout: "series",
|
|
1111
|
+
imports: [{ name: "OldComp", from: "npm:old" }],
|
|
1112
|
+
importsBlock: `export { PieChart } from "npm:recharts"`,
|
|
1113
|
+
children: [
|
|
1114
|
+
{ type: "component", jsx: "<PieChart />", duration: 1 },
|
|
1115
|
+
],
|
|
1116
|
+
});
|
|
1117
|
+
expect(compiled.children).toHaveLength(1);
|
|
1118
|
+
const c = compiled.children[0] as any;
|
|
1119
|
+
expect(c.type).toBe("component");
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("import statements in importsBlock do not interfere with component nodes", () => {
|
|
1123
|
+
// import statements should not cause errors even if the imported name
|
|
1124
|
+
// doesn't match any component JSX — they're internal deps
|
|
1125
|
+
const compiled = compileDescriptiveRoot({
|
|
1126
|
+
layout: "series",
|
|
1127
|
+
importsBlock: `import { something } from "react"
|
|
1128
|
+
export { PieChart } from "npm:recharts"`,
|
|
1129
|
+
children: [
|
|
1130
|
+
{ type: "component", jsx: "<PieChart />", duration: 2 },
|
|
1131
|
+
],
|
|
1132
|
+
});
|
|
1133
|
+
expect(compiled.children).toHaveLength(1);
|
|
1134
|
+
});
|
|
1135
|
+
});
|