@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.
Files changed (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Integration tests for the Markdown Descriptive pipeline.
3
+ *
4
+ * Tests the full pipeline: markdown → parse → compile → stream tree.
5
+ * Each fixture is a `.md` file in `tests/fixtures/md/` that exercises
6
+ * specific features of the descriptive markdown format.
7
+ *
8
+ * These tests verify:
9
+ * - Correct parsing of markdown into DescriptiveRoot
10
+ * - Correct compilation into stream tree (Root)
11
+ * - Structural correctness (types, durations, nesting)
12
+ * - Frontmatter and imports resolution
13
+ * - Code fence parsing (```js imports and ```jsx Name)
14
+ * - Edge cases (empty input, special characters, etc.)
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { parseMarkdownDescriptive } from "../src/descriptive/markdown";
20
+ import { compileDescriptiveRoot } from "../src/descriptive/compiler";
21
+
22
+ const MD_FIXTURES_DIR = resolve(__dirname, "fixtures/md");
23
+
24
+ interface MdFixture {
25
+ name: string;
26
+ source: string;
27
+ path: string;
28
+ }
29
+
30
+ function loadAllFixtures(): MdFixture[] {
31
+ if (!existsSync(MD_FIXTURES_DIR)) return [];
32
+ return readdirSync(MD_FIXTURES_DIR)
33
+ .filter((f) => f.endsWith(".md"))
34
+ .map((f) => ({
35
+ name: f.replace(/\.md$/, ""),
36
+ source: readFileSync(resolve(MD_FIXTURES_DIR, f), "utf-8"),
37
+ path: resolve(MD_FIXTURES_DIR, f),
38
+ }));
39
+ }
40
+
41
+ function loadFixture(name: string): MdFixture {
42
+ const path = resolve(MD_FIXTURES_DIR, `${name}.md`);
43
+ return {
44
+ name,
45
+ source: readFileSync(path, "utf-8"),
46
+ path,
47
+ };
48
+ }
49
+
50
+ // ── Parse & Compile each fixture ──────────────────────────────────────────
51
+
52
+ describe("markdown fixtures — parse & compile", () => {
53
+ const fixtures = loadAllFixtures();
54
+
55
+ it("has fixtures to test", () => {
56
+ expect(fixtures.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ for (const fx of fixtures) {
60
+ it(`parses and compiles: ${fx.name}`, () => {
61
+ // Parse markdown → DescriptiveRoot
62
+ const parsed = parseMarkdownDescriptive(fx.source);
63
+ expect(parsed).toBeDefined();
64
+ expect(parsed.children).toBeDefined();
65
+
66
+ // Compile DescriptiveRoot → stream tree (Root)
67
+ const compiled = compileDescriptiveRoot(parsed);
68
+ expect(compiled).toBeDefined();
69
+ expect(compiled.type).toBe("root");
70
+ expect(compiled.id).toBe("root");
71
+ expect(typeof compiled.width).toBe("number");
72
+ expect(typeof compiled.height).toBe("number");
73
+ expect(typeof compiled.fps).toBe("number");
74
+
75
+ // Must have at least one child
76
+ expect(compiled.children.length).toBeGreaterThanOrEqual(0);
77
+
78
+ // All children must have valid types
79
+ for (const child of compiled.children) {
80
+ expect(child.type).toBeDefined();
81
+ expect(["folder", "scene", "video", "audio", "image", "component", "effect", "rhythm", "map", "include", "root"]).toContain(child.type);
82
+ }
83
+ });
84
+ }
85
+ });
86
+
87
+ // ── Basic ─────────────────────────────────────────────────────────────────
88
+
89
+ describe("basic fixture", () => {
90
+ const fx = loadFixture("basic");
91
+
92
+ it("parses root metadata correctly", () => {
93
+ const parsed = parseMarkdownDescriptive(fx.source);
94
+ expect(parsed.width).toBe(640);
95
+ expect(parsed.height).toBe(480);
96
+ expect(parsed.fps).toBe(30);
97
+ expect(parsed.layout).toBe("series");
98
+ });
99
+
100
+ it("compiles root with correct dimensions", () => {
101
+ const parsed = parseMarkdownDescriptive(fx.source);
102
+ const compiled = compileDescriptiveRoot(parsed);
103
+ expect(compiled.width).toBe(640);
104
+ expect(compiled.height).toBe(480);
105
+ expect(compiled.fps).toBe(30);
106
+ expect(compiled.isSeries).toBe(true);
107
+ });
108
+
109
+ it("parses scene with children", () => {
110
+ const parsed = parseMarkdownDescriptive(fx.source);
111
+ expect(parsed.children).toHaveLength(1);
112
+ const scene = parsed.children[0] as any;
113
+ expect(scene.type).toBe("scene");
114
+ expect(scene.name).toBe("Intro");
115
+ expect(scene.layout).toBe("parallel");
116
+ expect(scene.children).toHaveLength(2);
117
+ expect(scene.children[0].type).toBe("image");
118
+ expect(scene.children[1].type).toBe("video");
119
+ });
120
+ });
121
+
122
+ // ── All Nodes ─────────────────────────────────────────────────────────────
123
+
124
+ describe("all-nodes fixture", () => {
125
+ const fx = loadFixture("all-nodes");
126
+
127
+ it("parses all node types", () => {
128
+ const parsed = parseMarkdownDescriptive(fx.source);
129
+ // Intro scene with root instruction
130
+ expect(parsed.instruction).toBe("Full node type demo");
131
+ expect(parsed.metadata).toBe("all-nodes");
132
+ expect(parsed.children).toHaveLength(6);
133
+
134
+ const sceneTypes = parsed.children.map((s: any) => s.name);
135
+ expect(sceneTypes).toContain("Media");
136
+ expect(sceneTypes).toContain("Components");
137
+ expect(sceneTypes).toContain("Effects");
138
+ expect(sceneTypes).toContain("Rhythm");
139
+ expect(sceneTypes).toContain("Map");
140
+ expect(sceneTypes).toContain("Include");
141
+ });
142
+
143
+ it("parses image with fit", () => {
144
+ const parsed = parseMarkdownDescriptive(fx.source);
145
+ const mediaScene = parsed.children[0] as any;
146
+ const img = mediaScene.children[0];
147
+ expect(img.type).toBe("image");
148
+ expect(img.src).toBe("photo.jpg");
149
+ expect(img.duration).toBe(3);
150
+ expect(img.fit).toBe("cover");
151
+ });
152
+
153
+ it("parses video with trim and playbackRate", () => {
154
+ const parsed = parseMarkdownDescriptive(fx.source);
155
+ const mediaScene = parsed.children[0] as any;
156
+ const vid = mediaScene.children[1];
157
+ expect(vid.type).toBe("video");
158
+ expect(vid.src).toBe("movie.mp4");
159
+ expect(vid.startFrom).toBe(0.5);
160
+ expect(vid.endAt).toBe(3.5);
161
+ expect(vid.volume).toBe(0.9);
162
+ expect(vid.playbackRate).toBe(1.2);
163
+ });
164
+
165
+ it("parses audio with foreground", () => {
166
+ const parsed = parseMarkdownDescriptive(fx.source);
167
+ const mediaScene = parsed.children[0] as any;
168
+ const aud = mediaScene.children[2];
169
+ expect(aud.type).toBe("audio");
170
+ expect(aud.foreground).toBe(true);
171
+ expect(aud.volume).toBe(0.5);
172
+ });
173
+
174
+ it("parses component with jsx", () => {
175
+ const parsed = parseMarkdownDescriptive(fx.source);
176
+ const compScene = parsed.children[1] as any;
177
+ const c1 = compScene.children[0];
178
+ expect(c1.type).toBe("component");
179
+ expect(c1.jsx).toContain("AnimatedHeadline");
180
+ expect(c1.duration).toBe(3);
181
+ });
182
+
183
+ it("parses effect with animation", () => {
184
+ const parsed = parseMarkdownDescriptive(fx.source);
185
+ const fxScene = parsed.children[2] as any;
186
+ const e1 = fxScene.children[0];
187
+ expect(e1.type).toBe("image");
188
+ expect(e1.effects).toEqual(["fadeIn(3)"]);
189
+
190
+ const e2 = fxScene.children[1];
191
+ expect(e2.type).toBe("image");
192
+ expect(e2.effects).toEqual(["bounceIn(,ease-out,2)"]);
193
+ });
194
+
195
+ it("parses rhythm with spots", () => {
196
+ const parsed = parseMarkdownDescriptive(fx.source);
197
+ const rhythmScene = parsed.children.find((s: any) => s.name === "Rhythm");
198
+ const r = rhythmScene!.children[0];
199
+ expect(r.type).toBe("rhythm");
200
+ expect(r.src).toBe("beat.mp3");
201
+ expect(r.spots).toEqual([0.5, 1.2, 1.9, 2.8]);
202
+ expect(r.volume).toBe(0.8);
203
+ expect(r.children).toHaveLength(4);
204
+ });
205
+
206
+ it("parses map with waypoints", () => {
207
+ const parsed = parseMarkdownDescriptive(fx.source);
208
+ const mapScene = parsed.children[4] as any;
209
+ const m = mapScene.children[0];
210
+ expect(m.type).toBe("map");
211
+ expect(m.duration).toBe(4);
212
+ expect(m.travelMode).toBe("DRIVING");
213
+ expect(m.waypoints).toHaveLength(3);
214
+ expect(m.waypoints[0].label).toBe("SF");
215
+ expect(m.waypoints[1].label).toBe("LA");
216
+ expect(m.waypoints[2].label).toBe("LV");
217
+ });
218
+
219
+ it("parses include with src", () => {
220
+ const parsed = parseMarkdownDescriptive(fx.source);
221
+ const includeScene = parsed.children[5] as any;
222
+ const inc = includeScene.children[0];
223
+ expect(inc.type).toBe("include");
224
+ expect(inc.src).toBe("./child.json");
225
+ expect(inc.duration).toBe(3);
226
+ });
227
+ });
228
+
229
+ // ── Scenes ────────────────────────────────────────────────────────────────
230
+
231
+ describe("scenes fixture", () => {
232
+ const fx = loadFixture("scenes");
233
+
234
+ it("parses multiple scenes with different layouts", () => {
235
+ const parsed = parseMarkdownDescriptive(fx.source);
236
+ expect(parsed.children).toHaveLength(3);
237
+
238
+ const [hook, journey, wrap] = parsed.children as any[];
239
+ expect(hook.name).toBe("Hook");
240
+ expect(hook.layout).toBe("parallel");
241
+ expect(hook.instruction).toBe("Grab attention fast");
242
+
243
+ expect(journey.name).toBe("Journey");
244
+ expect(journey.layout).toBe("transitionSeries");
245
+ expect(journey.transition).toBe("fade");
246
+ expect(journey.transitionTime).toBe(0.5);
247
+ // Script is now an audio child instead of a property on the scene
248
+ expect(journey.children[0].type).toBe("audio");
249
+ expect(journey.children[0].script).toBe("Follow along on this adventure");
250
+ expect(journey.children).toHaveLength(4); // 1 audio + 3 images
251
+
252
+ expect(wrap.name).toBe("WrapUp");
253
+ expect(wrap.layout).toBe("parallel");
254
+ // Script is now an audio child instead of a property on the scene
255
+ expect(wrap.children[0].type).toBe("audio");
256
+ expect(wrap.children[0].script).toBe("Thanks for watching");
257
+ expect(wrap.children).toHaveLength(2); // 1 audio + 1 image
258
+ });
259
+
260
+ it("compiles transitionSeries with overlap", () => {
261
+ const parsed = parseMarkdownDescriptive(fx.source);
262
+ const compiled = compileDescriptiveRoot(parsed);
263
+ // Journey scene: 3 images of 2,2,3 with transition 0.5 → total = 2+2+3 - 2*0.5 = 6
264
+ const journey = compiled.children[1] as any;
265
+ expect(journey.type).toBe("scene" || "folder");
266
+ });
267
+ });
268
+
269
+ // ── Frontmatter ───────────────────────────────────────────────────────────
270
+
271
+ describe("frontmatter fixture", () => {
272
+ const fx = loadFixture("frontmatter");
273
+
274
+ it("parses root attrs from config line (not frontmatter)", () => {
275
+ const parsed = parseMarkdownDescriptive(fx.source);
276
+ expect(parsed.width).toBe(640);
277
+ expect(parsed.height).toBe(480);
278
+ expect(parsed.fps).toBe(30);
279
+ });
280
+
281
+ it("parses imports from ```js imports block", () => {
282
+ const parsed = parseMarkdownDescriptive(fx.source);
283
+ // importsBlock is set, not frontmatter imports
284
+ expect(parsed.imports).toBeUndefined();
285
+ expect(parsed.importsBlock).toBeDefined();
286
+ expect(parsed.importsBlock).toContain("StatCounter");
287
+ expect(parsed.importsBlock).toContain("github:foo/bar/src/Logo.tsx");
288
+ expect(parsed.importsBlock).toContain("InlineBadge");
289
+ });
290
+
291
+ it("parses tts config from root config line", () => {
292
+ const parsed = parseMarkdownDescriptive(fx.source);
293
+ expect(parsed.tts).toBeDefined();
294
+ expect(parsed.tts).toContain("edge-tts");
295
+ expect(parsed.tts).toContain("{input}");
296
+ expect(parsed.tts).toContain("{output}");
297
+ });
298
+
299
+ it("parses stt config from root config line", () => {
300
+ const parsed = parseMarkdownDescriptive(fx.source);
301
+ expect(parsed.stt).toBeDefined();
302
+ expect(parsed.stt).toContain("whisper");
303
+ expect(parsed.stt).toContain("{input}");
304
+ expect(parsed.stt).toContain("{output}");
305
+ expect(parsed.stt).toContain("--language zh");
306
+ });
307
+
308
+ it("compiles with resolved imports", () => {
309
+ const parsed = parseMarkdownDescriptive(fx.source);
310
+ const compiled = compileDescriptiveRoot(parsed);
311
+ const scene = compiled.children[0] as any;
312
+ const statComp = scene.children[0];
313
+ // imports removed from component schema — now at root level
314
+ // imports removed from component schema — now at root level
315
+ });
316
+ });
317
+
318
+ describe("subtitle from root config line", () => {
319
+ const src = `# video
320
+ width:640 height:480 fps:30 layout:series subtitle:captions.vtt
321
+ ## Scene
322
+ - image src:bg.jpg duration:3
323
+ `;
324
+
325
+ it("parses subtitle from root config line", () => {
326
+ const parsed = parseMarkdownDescriptive(src);
327
+ expect(parsed.subtitle).toBeDefined();
328
+ expect(parsed.subtitle!.src).toBe("captions.vtt");
329
+ });
330
+
331
+ it("compiles subtitle into root stream tree", () => {
332
+ const parsed = parseMarkdownDescriptive(src);
333
+ const compiled = compileDescriptiveRoot(parsed);
334
+ expect(compiled.subtitle).toBeDefined();
335
+ expect(compiled.subtitle!.src).toBe("captions.vtt");
336
+ });
337
+ });
338
+
339
+ // ── Imports Block ─────────────────────────────────────────────────────────
340
+
341
+ describe("imports-block fixture", () => {
342
+ const fx = loadFixture("imports-block");
343
+
344
+ it("parses ```js imports code fence", () => {
345
+ const parsed = parseMarkdownDescriptive(fx.source);
346
+ expect(parsed.importsBlock).toBeDefined();
347
+ expect(parsed.importsBlock).toContain("import { PieChart } from");
348
+ expect(parsed.importsBlock).toContain("export function Hello");
349
+ });
350
+
351
+ it("compiles with importsBlock resolution", () => {
352
+ const parsed = parseMarkdownDescriptive(fx.source);
353
+ const compiled = compileDescriptiveRoot(parsed);
354
+ const scene = compiled.children[0] as any;
355
+ const pieChart = scene.children[0];
356
+ // imports removed from component schema
357
+ const hello = scene.children[1];
358
+ // imports removed from component schema
359
+ });
360
+ });
361
+
362
+ // ── JSX Code Fence ────────────────────────────────────────────────────────
363
+
364
+ describe("jsx-code-fence fixture", () => {
365
+ const fx = loadFixture("jsx-code-fence");
366
+
367
+ it("parses inline component definitions from ```js imports block", () => {
368
+ const parsed = parseMarkdownDescriptive(fx.source);
369
+ expect(parsed.imports).toBeUndefined();
370
+ expect(parsed.importsBlock).toBeDefined();
371
+ expect(parsed.importsBlock).toContain("function Greeting");
372
+ expect(parsed.importsBlock).toContain("function Counter");
373
+ });
374
+
375
+ it("compiles with inline component definitions from imports block", () => {
376
+ const parsed = parseMarkdownDescriptive(fx.source);
377
+ const compiled = compileDescriptiveRoot(parsed);
378
+ const scene = compiled.children[0] as any;
379
+ const greeting = scene.children[0];
380
+ const counter = scene.children[1];
381
+ // imports removed from component schema
382
+ expect(greeting.jsx).toContain("Greeting");
383
+ // imports removed from component schema
384
+ expect(counter.jsx).toContain("Counter");
385
+ });
386
+ });
387
+
388
+ // ── Nested Scenes ─────────────────────────────────────────────────────────
389
+
390
+ describe("nested-scenes fixture", () => {
391
+ const fx = loadFixture("nested-scenes");
392
+
393
+ it("parses nested scenes by heading depth", () => {
394
+ const parsed = parseMarkdownDescriptive(fx.source);
395
+ expect(parsed.children).toHaveLength(3);
396
+
397
+ const ch1 = parsed.children[0] as any;
398
+ expect(ch1.name).toBe("Chapter1");
399
+ expect(ch1.title).toBe("The Beginning");
400
+ expect(ch1.layout).toBe("series");
401
+
402
+ // Chapter1 has 2 nested scenes (Shot1, Shot2) + their leaf children
403
+ const shot1 = ch1.children[0] as any;
404
+ expect(shot1.type).toBe("scene");
405
+ expect(shot1.name).toBe("Shot1");
406
+ expect(shot1.children).toHaveLength(2);
407
+
408
+ const ch2 = parsed.children[1] as any;
409
+ expect(ch2.name).toBe("Chapter2");
410
+ expect(ch2.layout).toBe("series");
411
+
412
+ const ch3 = parsed.children[2] as any;
413
+ expect(ch3.name).toBe("Chapter3");
414
+ expect(ch3.layout).toBe("parallel");
415
+ });
416
+ });
417
+
418
+ // ── Effects ───────────────────────────────────────────────────────────────
419
+
420
+ describe("effects fixture", () => {
421
+ const fx = loadFixture("effects");
422
+
423
+ it("parses effects as arrays on leaf nodes", () => {
424
+ const parsed = parseMarkdownDescriptive(fx.source);
425
+ expect(parsed.children).toHaveLength(4);
426
+
427
+ const fades = parsed.children[0] as any;
428
+ expect(fades.children[0].effects).toEqual(["fadeIn(3)"]);
429
+ expect(fades.children[1].effects).toEqual(["fadeOut(3)"]);
430
+ expect(fades.children[0].type).toBe("image");
431
+ expect(fades.children[1].type).toBe("image");
432
+
433
+ const slides = parsed.children[1] as any;
434
+ expect(slides.children[0].effects).toEqual(["slideInLeft(3)"]);
435
+ expect(slides.children[1].effects).toEqual(["slideInRight(3)"]);
436
+
437
+ const attention = parsed.children[2] as any;
438
+ expect(attention.children[0].effects).toEqual(["bounceIn(,ease-out,2)"]);
439
+ expect(attention.children[1].effects).toEqual(["pulse(,ease-in-out)"]);
440
+ });
441
+
442
+ it("compiles effects into Effect wrapper streams", () => {
443
+ const parsed = parseMarkdownDescriptive(fx.source);
444
+ const compiled = compileDescriptiveRoot(parsed);
445
+
446
+ // Fades: two images with fadeIn/fadeOut — each is an Effect wrapper
447
+ const fadesScene = compiled.children[0] as any;
448
+ expect(fadesScene.children[0].type).toBe("effect");
449
+ expect(fadesScene.children[0].animation).toBe("fadeIn");
450
+ expect(fadesScene.children[1].type).toBe("effect");
451
+ expect(fadesScene.children[1].animation).toBe("fadeOut");
452
+
453
+ // Slides: slideInLeft / slideInRight
454
+ const slidesScene = compiled.children[1] as any;
455
+ expect(slidesScene.children[0].animation).toBe("slideInLeft");
456
+ expect(slidesScene.children[1].animation).toBe("slideInRight");
457
+
458
+ // Attention: bounceIn + ease-out + 2 iterations
459
+ const attnScene = compiled.children[2] as any;
460
+ expect(attnScene.children[0].animation).toBe("bounceIn");
461
+ expect(attnScene.children[0].animationTimingFunction).toBe("ease-out");
462
+ expect(attnScene.children[0].animationIterationCount).toBe(2);
463
+ expect(attnScene.children[1].animation).toBe("pulse");
464
+ expect(attnScene.children[1].animationTimingFunction).toBe("ease-in-out");
465
+ });
466
+
467
+ it("compiles custom keyframes via JSON object effects syntax", () => {
468
+ const parsed = parseMarkdownDescriptive(fx.source);
469
+ const compiled = compileDescriptiveRoot(parsed);
470
+ const customScene = compiled.children[3] as any;
471
+ const e = customScene.children[0]; // Effect wrapper
472
+ expect(e.type).toBe("effect");
473
+ expect(e.animation).toBe("custom");
474
+ expect(e.customKeyframes).toBeDefined();
475
+ expect(e.customKeyframes["0"]).toBeDefined();
476
+ expect(e.customKeyframes["50"]).toBeDefined();
477
+ expect(e.customKeyframes["100"]).toBeDefined();
478
+ });
479
+ });
480
+
481
+ // ── Map ───────────────────────────────────────────────────────────────────
482
+
483
+ describe("map fixture", () => {
484
+ const fx = loadFixture("map");
485
+
486
+ it("parses multiple map configurations", () => {
487
+ const parsed = parseMarkdownDescriptive(fx.source);
488
+ expect(parsed.children).toHaveLength(3);
489
+
490
+ const roadTrip = parsed.children[0] as any;
491
+ const m1 = roadTrip.children[0];
492
+ expect(m1.type).toBe("map");
493
+ expect(m1.waypoints).toHaveLength(3);
494
+ expect(m1.travelMode).toBe("DRIVING");
495
+ expect(m1.routeColor).toBe("#FF5733");
496
+ expect(m1.routeWeight).toBe(6);
497
+ expect(m1.zoom).toBe(8);
498
+ expect(m1.mapType).toBe("roadmap");
499
+ expect(m1.routeMarker).toBe("🚗");
500
+
501
+ const walking = parsed.children[1] as any;
502
+ const m2 = walking.children[0];
503
+ expect(m2.travelMode).toBe("WALKING");
504
+ expect(m2.mapType).toBe("hybrid");
505
+ expect(m2.zoom).toBe(15);
506
+ expect(m2.routeMarker).toBe("🚶");
507
+
508
+ const transit = parsed.children[2] as any;
509
+ const m3 = transit.children[0];
510
+ expect(m3.travelMode).toBe("TRANSIT");
511
+ expect(m3.mapType).toBe("satellite");
512
+ expect(m3.zoom).toBe(12);
513
+ expect(m3.routeMarker).toBe("🗼");
514
+ });
515
+ });
516
+
517
+ // ── Rhythm ────────────────────────────────────────────────────────────────
518
+
519
+ describe("rhythm fixture", () => {
520
+ const fx = loadFixture("rhythm");
521
+
522
+ it("parses rhythm with beat-synced children", () => {
523
+ const parsed = parseMarkdownDescriptive(fx.source);
524
+ expect(parsed.children).toHaveLength(2);
525
+
526
+ const drop = parsed.children[0] as any;
527
+ const r1 = drop.children[0];
528
+ expect(r1.type).toBe("rhythm");
529
+ expect(r1.spots).toEqual([0.0, 0.5, 1.0, 1.5]);
530
+ expect(r1.volume).toBe(0.9);
531
+ expect(r1.children).toHaveLength(4);
532
+
533
+ const slow = parsed.children[1] as any;
534
+ const r2 = slow.children[0];
535
+ expect(r2.spots).toEqual([0.0, 1.2, 2.5, 3.8, 5.0]);
536
+ expect(r2.volume).toBe(0.6);
537
+ expect(r2.children).toHaveLength(5);
538
+ });
539
+
540
+ it("compiles rhythm with derived durations", () => {
541
+ const parsed = parseMarkdownDescriptive(fx.source);
542
+ const compiled = compileDescriptiveRoot(parsed);
543
+ // The rhythm node should compile to a rhythm stream
544
+ const rootChildren = compiled.children;
545
+ expect(rootChildren.length).toBeGreaterThan(0);
546
+ });
547
+ });
548
+
549
+ // ── Tween ─────────────────────────────────────────────────────────────────
550
+
551
+ describe("tween fixture", () => {
552
+ const fx = loadFixture("tween");
553
+
554
+ it("parses components with tween() expressions", () => {
555
+ const parsed = parseMarkdownDescriptive(fx.source);
556
+ expect(parsed.children).toHaveLength(3);
557
+
558
+ const bar = parsed.children[0] as any;
559
+ expect(bar.children[0].type).toBe("component");
560
+ expect(bar.children[0].jsx).toContain("tween(");
561
+
562
+ const nivo = parsed.children[1] as any;
563
+ expect(nivo.children[0].jsx).toContain("tween(0,80)");
564
+
565
+ const color = parsed.children[2] as any;
566
+ expect(color.children[0].jsx).toContain("tween('#000','#FFF')");
567
+ });
568
+ });
569
+
570
+ // ── Edge Cases ────────────────────────────────────────────────────────────
571
+
572
+ describe("edge-cases fixture", () => {
573
+ const fx = loadFixture("edge-cases");
574
+
575
+ it("parses empty include", () => {
576
+ const parsed = parseMarkdownDescriptive(fx.source);
577
+ const emptyScene = parsed.children.find((s: any) => s.name === "Empty");
578
+ expect(emptyScene!.children[0].type).toBe("include");
579
+ // include without src is valid
580
+ });
581
+
582
+ it("handles special characters in src", () => {
583
+ const parsed = parseMarkdownDescriptive(fx.source);
584
+ const specialScene = parsed.children.find((s: any) => s.name === "SpecialChars");
585
+ expect(specialScene!.children[0].src).toBe("my photo.jpg");
586
+ expect(specialScene!.children[1].src).toBe("bgm (copy).mp3");
587
+ });
588
+
589
+ it("parses quoted scripts as audio siblings", () => {
590
+ const parsed = parseMarkdownDescriptive(fx.source);
591
+ const quotedScene = parsed.children.find((s: any) => s.name === "QuotedScripts");
592
+ // Script on image: converts to audio sibling
593
+ expect(quotedScene!.children[0].type).toBe("image");
594
+ expect(quotedScene!.children[1].type).toBe("audio");
595
+ expect(quotedScene!.children[1].script).toBe("Special chars: #hash, $dollar, %percent, &ampersand");
596
+ // Script on video: converts to audio sibling
597
+ expect(quotedScene!.children[2].type).toBe("video");
598
+ expect(quotedScene!.children[3].type).toBe("audio");
599
+ expect(quotedScene!.children[3].script).toBe("Path with spaces: /Users/me/my video.mp4");
600
+ });
601
+
602
+ it("handles long durations", () => {
603
+ const parsed = parseMarkdownDescriptive(fx.source);
604
+ const longScene = parsed.children.find((s: any) => s.name === "LongDuration");
605
+ expect(longScene!.children[0].duration).toBe(60);
606
+ });
607
+
608
+ it("handles visible:false", () => {
609
+ const parsed = parseMarkdownDescriptive(fx.source);
610
+ const visibleScene = parsed.children.find((s: any) => s.name === "VisibleFalse");
611
+ expect(visibleScene!.children[0].visible).toBe(false);
612
+ // Second image has no visible key; defaults to true at compile time
613
+ expect(visibleScene!.children[1].visible).toBeUndefined();
614
+ });
615
+
616
+ it("parses isBackground on audio", () => {
617
+ const parsed = parseMarkdownDescriptive(fx.source);
618
+ const bgScene = parsed.children.find((s: any) => s.name === "BackgroundAudio");
619
+ expect(bgScene!.children[0].isBackground).toBe(true);
620
+ });
621
+
622
+ it("parses start offsets in parallel", () => {
623
+ const parsed = parseMarkdownDescriptive(fx.source);
624
+ const parallelScene = parsed.children.find((s: any) => s.name === "StartOffsets");
625
+ expect(parallelScene!.children[0].start).toBe(1);
626
+ expect(parallelScene!.children[0].duration).toBe(3);
627
+ expect(parallelScene!.children[1].start).toBe(0);
628
+ expect(parallelScene!.children[1].duration).toBe(2);
629
+ expect(parallelScene!.children[2].start).toBe(2);
630
+ expect(parallelScene!.children[2].duration).toBe(4);
631
+
632
+ // Compile should succeed (start is only valid in parallel)
633
+ const compiled = compileDescriptiveRoot(
634
+ parseMarkdownDescriptive(fx.source),
635
+ );
636
+ expect(compiled.type).toBe("root");
637
+ });
638
+ });
639
+
640
+ // ── Full Feature ──────────────────────────────────────────────────────────
641
+
642
+ describe("full-feature fixture", () => {
643
+ const fx = loadFixture("full-feature");
644
+
645
+ it("parses complete video with all features", () => {
646
+ const parsed = parseMarkdownDescriptive(fx.source);
647
+ // Frontmatter
648
+ expect(parsed.width).toBe(1080);
649
+ expect(parsed.height).toBe(1920);
650
+ expect(parsed.fps).toBe(30);
651
+ expect(parsed.instruction).toBe("Full feature demo video");
652
+ expect(parsed.metadata).toBe("v2.0-feature-test");
653
+
654
+ // Imports are in ```js imports block (not frontmatter)
655
+ expect(parsed.imports).toBeUndefined();
656
+ expect(parsed.importsBlock).toBeDefined();
657
+ expect(parsed.importsBlock).toContain("AnimatedHeadline");
658
+ expect(parsed.importsBlock).toContain("StatCounter");
659
+
660
+ // TTS
661
+ expect(parsed.tts).toBeDefined();
662
+ expect(parsed.tts).toContain("edge-tts");
663
+ expect(parsed.tts).toContain("{input}");
664
+
665
+ // 9 scenes (including nested ones)
666
+ const topLevel = parsed.children.filter((s: any) => s.type === "scene");
667
+ expect(topLevel.length).toBeGreaterThanOrEqual(5);
668
+ const names = topLevel.map((s: any) => s.name);
669
+ expect(names).toContain("OpeningHook");
670
+ expect(names).toContain("StatShowcase");
671
+ expect(names).toContain("MediaMontage");
672
+ expect(names).toContain("AnimatedEffects");
673
+ expect(names).toContain("InteractiveMap");
674
+ expect(names).toContain("BeatSync");
675
+ expect(names).toContain("Closing");
676
+ });
677
+
678
+ it("compiles full feature without errors", () => {
679
+ const parsed = parseMarkdownDescriptive(fx.source);
680
+ const compiled = compileDescriptiveRoot(parsed);
681
+ expect(compiled.type).toBe("root");
682
+ expect(compiled.children.length).toBeGreaterThan(0);
683
+
684
+ // Verify all children compile to valid stream types
685
+ for (const child of compiled.children) {
686
+ expect(child.type).toBeDefined();
687
+ }
688
+ });
689
+
690
+ it("resolves inline component definitions from imports block in full feature", () => {
691
+ const parsed = parseMarkdownDescriptive(fx.source);
692
+ const compiled = compileDescriptiveRoot(parsed);
693
+ // The last scene has a Logo component defined inline in the ```js imports block
694
+ const closingScene = compiled.children[compiled.children.length - 1] as any;
695
+ // Find the component in the closing scene
696
+ const comps = findComponents(closingScene);
697
+ const logo = comps.find((c: any) => c.jsx?.includes("Logo"));
698
+ if (logo) {
699
+ // imports removed from component schema
700
+ }
701
+ });
702
+ });
703
+
704
+ // ── component-imports fixture ──────────────────────────────────────────────
705
+ // Tests imports block with npm package, git repo, and inline function
706
+
707
+ describe("component-imports fixture", () => {
708
+ const fx = loadFixture("component-imports");
709
+
710
+ it("parses import specifiers correctly", () => {
711
+ const parsed = parseMarkdownDescriptive(fx.source);
712
+ expect(parsed.importsBlock).toBeDefined();
713
+ expect(parsed.importsBlock).toContain("npm:react-minimal-pie-chart");
714
+ expect(parsed.importsBlock).toContain("git:user/repo/path/to/Hello.tsx");
715
+ expect(parsed.importsBlock).toContain("export function Hello");
716
+ });
717
+
718
+ it("compiles with resolved imports registry", () => {
719
+ const parsed = parseMarkdownDescriptive(fx.source);
720
+ const compiled = compileDescriptiveRoot(parsed);
721
+ // Root has 1 scene child (series layout)
722
+ expect(compiled.children).toHaveLength(1);
723
+ const scene = compiled.children[0];
724
+ expect(scene.type).toBe("scene");
725
+ const comps = scene.children.filter((c: any) => c.type === "component");
726
+ expect(comps).toHaveLength(2); // Hello + PieChart
727
+ });
728
+ });
729
+
730
+ // ── Helper ────────────────────────────────────────────────────────────────
731
+
732
+ function findComponents(node: any): any[] {
733
+ const results: any[] = [];
734
+ if (!node) return results;
735
+ if (node.type === "component") results.push(node);
736
+ if (node.children) {
737
+ for (const child of node.children) {
738
+ results.push(...findComponents(child));
739
+ }
740
+ }
741
+ return results;
742
+ }