@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,866 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseMarkdownDescriptive } from "./markdown";
3
+ import { compileDescriptiveRoot } from "./compiler";
4
+
5
+ describe("parseMarkdownDescriptive", () => {
6
+ it("supports nested scenes by heading depth", () => {
7
+ const doc = `# video\nlayout:series\n## Parent\n- image src:p.jpg duration:2\n### Child\n- image src:c.jpg duration:1`;
8
+ const parsed = parseMarkdownDescriptive(doc);
9
+
10
+ const parent = parsed.children[0]! as any;
11
+ expect(parent.type).toBe("scene");
12
+ expect(parent.children).toHaveLength(2);
13
+
14
+ const child = parent.children[1]!;
15
+ expect(child.type).toBe("scene");
16
+ expect(child.children[0]!.type).toBe("image");
17
+ });
18
+
19
+ it("reads scene metadata from lines below heading", () => {
20
+ const doc = `# video\nlayout:series\n## Hook\nlayout:parallel instruction:"Fast opener"\n- image src:cover.jpg duration:2`;
21
+ const parsed = parseMarkdownDescriptive(doc);
22
+
23
+ const scene = parsed.children[0]! as any;
24
+ expect(scene.type).toBe("scene");
25
+ expect(scene.name).toBe("Hook");
26
+ expect(scene.layout).toBe("parallel");
27
+ expect(scene.instruction).toBe("Fast opener");
28
+ expect(scene.children[0]!!.type).toBe("image");
29
+ });
30
+
31
+
32
+
33
+ it("accepts full-word layout values", () => {
34
+ const doc = `# video\nlayout:series\n## Intro\nlayout:parallel\n- parallel\n - image src:p.jpg duration:2\n## Journey\nlayout:transitionSeries transition:fade transitionTime:0.4\n- image src:a.jpg duration:2\n- image src:b.jpg duration:2`;
35
+ const parsed = parseMarkdownDescriptive(doc);
36
+
37
+ expect(parsed.layout).toBe("series");
38
+
39
+ const intro = parsed.children[0]! as any;
40
+ expect(intro.layout).toBe("parallel");
41
+ expect(intro.children[0]!.type).toBe("parallel");
42
+
43
+ const journey = parsed.children[1]! as any;
44
+ expect(journey.layout).toBe("transitionSeries");
45
+ expect(journey.transition).toBe("fade");
46
+ expect(journey.transitionTime).toBe(0.4);
47
+ });
48
+
49
+ it("accepts merged transition syntax: transition:fade(0.5)", () => {
50
+ const doc = `# video\nlayout:series\n## Scene\nlayout:transitionSeries transition:fade(0.5)\n- image src:a.jpg duration:2\n- image src:b.jpg duration:2`;
51
+ const parsed = parseMarkdownDescriptive(doc);
52
+
53
+ const scene = parsed.children[0]! as any;
54
+ expect(scene.layout).toBe("transitionSeries");
55
+ expect(scene.transition).toBe("fade");
56
+ expect(scene.transitionTime).toBe(0.5);
57
+ });
58
+
59
+ it("accepts merged transition syntax: transition:fade(0.3) with explicit transitionTime override", () => {
60
+ const doc = `# video\nlayout:series\n## Scene\nlayout:transitionSeries transition:fade(0.3) transitionTime:0.8\n- image src:a.jpg duration:2\n- image src:b.jpg duration:2`;
61
+ const parsed = parseMarkdownDescriptive(doc);
62
+
63
+ const scene = parsed.children[0]! as any;
64
+ expect(scene.layout).toBe("transitionSeries");
65
+ expect(scene.transition).toBe("fade");
66
+ // Separate transitionTime overrides the inline time
67
+ expect(scene.transitionTime).toBe(0.8);
68
+ });
69
+
70
+ it("accepts full-word type tokens", () => {
71
+ const doc = `# video\nlayout:series\n## Intro\nlayout:parallel\n- image src:cover.jpg duration:2\n- video src:clip.mp4 startFrom:1 endAt:3\n- audio src:bgm.mp3 duration:3 volume:0.5\n- component duration:2 jsx:"<AnimatedHeadline />"\n- image src:card.jpg duration:1 effects:[fadeIn]\n- map duration:2 waypoints:[37.77,-122.41,"SF";34.05,-118.24,"LA"]`;
72
+ const parsed = parseMarkdownDescriptive(doc);
73
+
74
+ const scene = parsed.children[0]! as any;
75
+ expect(scene.children[0]!!.type).toBe("image");
76
+ expect(scene.children[1]!!.type).toBe("video");
77
+ expect(scene.children[2]!!.type).toBe("audio");
78
+ expect(scene.children[3]!!.type).toBe("component");
79
+ expect(scene.children[4]!!.type).toBe("image");
80
+ expect(scene.children[4]!!.effects).toEqual(["fadeIn"]);
81
+ expect(scene.children[5]!!.type).toBe("map");
82
+ });
83
+
84
+ it("accepts full-word keys and normalizes enum aliases in keyed values", () => {
85
+ const doc = `# video\nlayout:series width:1080 height:1920 fps:30\n## Intro\nlayout:parallel instruction:"desc"\n- image src:cover.jpg duration:2`;
86
+ const parsed = parseMarkdownDescriptive(doc);
87
+
88
+ expect(parsed.layout).toBe("series");
89
+ expect(parsed.width).toBe(1080);
90
+
91
+ const intro = parsed.children[0]! as any;
92
+ expect(intro.layout).toBe("parallel");
93
+ expect(intro.instruction).toBe("desc");
94
+
95
+ const leaf = intro.children[0]!;
96
+ expect(leaf.type).toBe("image");
97
+ expect(leaf.src).toBe("cover.jpg");
98
+ expect(leaf.duration).toBe(2);
99
+ });
100
+
101
+ it("parses root metadata: width, height, fps, layout", () => {
102
+ const doc = `# video
103
+ width:1920 height:1080 fps:30 layout:series
104
+ ## Scene
105
+ - image src:cover.jpg duration:2`;
106
+ const parsed = parseMarkdownDescriptive(doc);
107
+
108
+ expect(parsed.width).toBe(1920);
109
+ expect(parsed.height).toBe(1080);
110
+ expect(parsed.fps).toBe(30);
111
+ expect(parsed.layout).toBe("series");
112
+ });
113
+
114
+ it("parses root instruction and metadata fields", () => {
115
+ const doc = `# video
116
+ instruction:"A cool travel video" metadata:"summer 2026"
117
+ ## Scene
118
+ - image src:a.jpg duration:2`;
119
+ const parsed = parseMarkdownDescriptive(doc);
120
+
121
+ expect(parsed.instruction).toBe("A cool travel video");
122
+ expect(parsed.metadata).toBe("summer 2026");
123
+ });
124
+
125
+ it("parses key:value syntax correctly", () => {
126
+ const doc = `# video
127
+ width:1920 height:1080 fps:30 layout:series
128
+ ## Scene
129
+ - image src:a.jpg duration:2`;
130
+ const parsed = parseMarkdownDescriptive(doc);
131
+
132
+ expect(parsed.width).toBe(1920);
133
+ expect(parsed.height).toBe(1080);
134
+ expect(parsed.fps).toBe(30);
135
+ expect(parsed.layout).toBe("series");
136
+
137
+ const scene = parsed.children[0]! as any;
138
+ expect(scene.children[0]!!.type).toBe("image");
139
+ expect(scene.children[0]!!.src).toBe("a.jpg");
140
+ expect(scene.children[0]!!.duration).toBe(2);
141
+ });
142
+
143
+ it("throws on unrecognized token", () => {
144
+ const doc = `# video
145
+ ## Scene
146
+ - image src:a.jpg duration:2 zoom`;
147
+ expect(() => parseMarkdownDescriptive(doc)).toThrow();
148
+ });
149
+
150
+ it("parses component nodes with props", () => {
151
+ const doc = `# video
152
+ layout:series
153
+ ## Demo
154
+ - component duration:3 jsx:"<AnimatedHeadline text='Hello' gradient />"`;
155
+ const parsed = parseMarkdownDescriptive(doc, );
156
+
157
+ const scene = parsed.children[0]! as any;
158
+ const c = scene.children[0]!!;
159
+ expect(c.type).toBe("component");
160
+ expect(c.duration).toBe(3);
161
+ });
162
+
163
+ it("parses effects:[...] on leaf nodes", () => {
164
+ const doc = `# video
165
+ ## Scene
166
+ - image src:card.jpg duration:1 effects:[fadeIn]`;
167
+ const parsed = parseMarkdownDescriptive(doc);
168
+
169
+ const scene = parsed.children[0]! as any;
170
+ const img = scene.children[0]!!;
171
+ expect(img.type).toBe("image");
172
+ expect(img.effects).toEqual(["fadeIn"]);
173
+ });
174
+
175
+ it("parses effects:[fadeIn] on leaf nodes", () => {
176
+ const doc = `# video
177
+ ## Scene
178
+ - image src:card.jpg duration:2 effects:[fadeIn]`;
179
+ const parsed = parseMarkdownDescriptive(doc);
180
+
181
+ const scene = parsed.children[0]! as any;
182
+ const img = scene.children[0]!! as any;
183
+ expect(img.type).toBe("image");
184
+ expect(img.effects).toEqual(["fadeIn"]);
185
+ });
186
+
187
+ it("parses multiple effects and parameterized syntax", () => {
188
+ const doc = `# video
189
+ ## Scene
190
+ - image src:card.jpg duration:3 effects:[fadeIn, bounceIn(1, ease-out, 2)]`;
191
+ const parsed = parseMarkdownDescriptive(doc);
192
+
193
+ const scene = parsed.children[0]! as any;
194
+ const img = scene.children[0]!! as any;
195
+ expect(img.effects).toHaveLength(2);
196
+ expect(img.effects![0]).toBe("fadeIn");
197
+ expect(img.effects![1]).toMatch(/^bounceIn\(/);
198
+ });
199
+
200
+ it("parses effects on component nodes", () => {
201
+ const doc = `# video
202
+ ## Demo
203
+ - component duration:3 jsx:"<Headline />" effects:[fadeIn]`;
204
+ const parsed = parseMarkdownDescriptive(doc);
205
+
206
+ const scene = parsed.children[0]! as any;
207
+ const c = scene.children[0]!! as any;
208
+ expect(c.type).toBe("component");
209
+ expect(c.effects).toEqual(["fadeIn"]);
210
+ });
211
+
212
+ it("parses effects with comma-separated positional params — all three", () => {
213
+ const doc = `# video
214
+ ## Scene
215
+ - image src:card.jpg duration:3 effects:[fadeIn(1.5, ease-out, 2)]`;
216
+ const parsed = parseMarkdownDescriptive(doc);
217
+
218
+ const scene = parsed.children[0]! as any;
219
+ const img = scene.children[0]!! as any;
220
+ expect(img.effects).toHaveLength(1);
221
+ expect(img.effects![0]).toBe("fadeIn(1.5, ease-out, 2)");
222
+ });
223
+
224
+ it("parses effects with comma-separated positional params — duration only", () => {
225
+ const doc = `# video
226
+ ## Scene
227
+ - image src:card.jpg duration:4 effects:[fadeIn(2)]`;
228
+ const parsed = parseMarkdownDescriptive(doc);
229
+
230
+ const scene = parsed.children[0]! as any;
231
+ const img = scene.children[0]!! as any;
232
+ expect(img.effects![0]).toBe("fadeIn(2)");
233
+ });
234
+
235
+ it("parses map node with waypoints", () => {
236
+ const doc = `# video
237
+ layout:series
238
+ ## Route
239
+ - map duration:4 waypoints:[37.77,-122.41,"SF";34.05,-118.24,"LA"]`;
240
+ const parsed = parseMarkdownDescriptive(doc, );
241
+
242
+ const scene = parsed.children[0]! as any;
243
+ const m = scene.children[0]!!;
244
+ expect(m.type).toBe("map");
245
+ expect(m.duration).toBe(4);
246
+ expect(m.waypoints).toHaveLength(2);
247
+ expect(m.waypoints[0].lat).toBe(37.77);
248
+ expect(m.waypoints[0].label).toBe("SF");
249
+ expect(m.waypoints[1].label).toBe("LA");
250
+ });
251
+
252
+ it("parses include node", () => {
253
+ const doc = `# video
254
+ layout:series
255
+ ## Section
256
+ - include src:child.json duration:4`;
257
+ const parsed = parseMarkdownDescriptive(doc, );
258
+
259
+ const scene = parsed.children[0]! as any;
260
+ const inc = scene.children[0]!!;
261
+ expect(inc.type).toBe("include");
262
+ expect(inc.src).toBe("child.json");
263
+ expect(inc.duration).toBe(4);
264
+ });
265
+
266
+ it("parses audio node", () => {
267
+ const doc = `# video
268
+ layout:series
269
+ ## Scene
270
+ - audio src:bgm.mp3 duration:4 volume:0.5`;
271
+ const parsed = parseMarkdownDescriptive(doc, );
272
+
273
+ const scene = parsed.children[0]! as any;
274
+ const a = scene.children[0]!!;
275
+ expect(a.type).toBe("audio");
276
+ expect(a.src).toBe("bgm.mp3");
277
+ expect(a.duration).toBe(4);
278
+ expect(a.volume).toBe(0.5);
279
+ });
280
+
281
+ it("parses rhythm node", () => {
282
+ const doc = `# video
283
+ layout:series
284
+ ## Beat
285
+ - rhythm src:track.mp3 duration:5`;
286
+ const parsed = parseMarkdownDescriptive(doc, );
287
+
288
+ const scene = parsed.children[0]! as any;
289
+ const r = scene.children[0]!!;
290
+ expect(r.type).toBe("rhythm");
291
+ expect(r.src).toBe("track.mp3");
292
+ expect(r.duration).toBe(5);
293
+ });
294
+
295
+ it("parses quoted strings with special characters", () => {
296
+ const doc = `# video
297
+ ## Scene
298
+ layout:parallel
299
+ - image src:a.jpg duration:2`;
300
+ const parsed = parseMarkdownDescriptive(doc);
301
+
302
+ const scene = parsed.children[0]! as any;
303
+ expect(scene.children[0].type).toBe("image");
304
+ expect(scene.children[0].src).toBe("a.jpg");
305
+ });
306
+
307
+ it("handles empty input gracefully", () => {
308
+ const parsed = parseMarkdownDescriptive("", );
309
+ expect(parsed.children).toEqual([]);
310
+ });
311
+ it("parses image with prompt instead of src", () => {
312
+ const doc = `# video
313
+ width:640 height:480 layout:series
314
+ ## Scene
315
+ layout:parallel
316
+ - image prompt:"a beautiful sunset over mountains" duration:3`;
317
+ const parsed = parseMarkdownDescriptive(doc);
318
+ const img = parsed.children[0]!.children[0];
319
+ expect(img.type).toBe("image");
320
+ expect(img.prompt).toBe("a beautiful sunset over mountains");
321
+ expect(img.src).toBeUndefined();
322
+ expect(img.duration).toBe(3);
323
+ });
324
+
325
+ it("parses video with prompt instead of src", () => {
326
+ const doc = `# video
327
+ width:640 height:480 layout:series
328
+ ## Scene
329
+ layout:parallel
330
+ - video prompt:"waves crashing on beach" duration:5`;
331
+ const parsed = parseMarkdownDescriptive(doc);
332
+ const vid = parsed.children[0]!.children[0];
333
+ expect(vid.type).toBe("video");
334
+ expect(vid.prompt).toBe("waves crashing on beach");
335
+ expect(vid.src).toBeUndefined();
336
+ expect(vid.duration).toBe(5);
337
+ });
338
+ it("round-trips markdown → parse → compile", () => {
339
+ const doc = `# video
340
+ width:1080 height:1920 fps:30 layout:series
341
+ ## Hook
342
+ layout:parallel instruction:"Visual opening"
343
+ - image src:cover.jpg duration:3
344
+ - video src:clip.mp4 startFrom:1 endAt:4
345
+ ## End
346
+ layout:parallel
347
+ - image src:final.jpg duration:2`;
348
+
349
+ const parsed = parseMarkdownDescriptive(doc);
350
+ const compiled = compileDescriptiveRoot(parsed);
351
+
352
+ expect(compiled.type).toBe("root");
353
+ expect(compiled.width).toBe(1080);
354
+ expect(compiled.height).toBe(1920);
355
+ expect(compiled.isSeries).toBe(true);
356
+ expect(compiled.children).toHaveLength(2);
357
+
358
+ const hook = compiled.children[0]! as any;
359
+ expect(hook.type).toBe("scene");
360
+ expect(hook.children).toHaveLength(2);
361
+
362
+ const end = compiled.children[1]! as any;
363
+ expect(end.type).toBe("scene");
364
+ expect(end.durationInSeconds).toBe(2);
365
+ });
366
+
367
+ describe("frontmatter", () => {
368
+ it("frontmatter does not affect root config", () => {
369
+ const doc = `---
370
+ width: 1080
371
+ height: 1920
372
+ fps: 30
373
+ ---
374
+ # video
375
+ width:640 height:480 fps:24 layout:series
376
+ ## Scene
377
+ - image src:a.jpg duration:1`;
378
+ const parsed = parseMarkdownDescriptive(doc);
379
+ // Frontmatter is metadata only — root config comes from key-value line
380
+ expect(parsed.width).toBe(640);
381
+ expect(parsed.height).toBe(480);
382
+ expect(parsed.fps).toBe(24);
383
+ });
384
+
385
+ it("parses imports from ~~~js imports block with multiple imports", () => {
386
+ const doc = `---
387
+ width: 640
388
+ height: 480
389
+ ---
390
+ # video
391
+ ## Demo
392
+ - component duration:1 jsx:"<ComA />"
393
+
394
+ ~~~js imports
395
+ import { ComA } from "npm:stat-counter"
396
+ import { ComB } from "github:foo/bar/src/Logo.tsx"
397
+ import { ComC } from "https://cdn.example.com/banner.js"
398
+ ~~~`;
399
+ const parsed = parseMarkdownDescriptive(doc);
400
+ // imports removed from component schema — now at root level
401
+ // imports removed from component schema — now at root level
402
+
403
+ // Compiler resolves from: specs onto component nodes
404
+ const compiled = compileDescriptiveRoot(parsed);
405
+ const scene = compiled.children[0]! as any;
406
+ const c = scene.children[0]!!;
407
+ // imports removed from component schema — now at root level
408
+ });
409
+
410
+ it("parses JSON array imports is no longer supported — use ~~~js imports instead", () => {
411
+ // JSON array imports in frontmatter are no longer supported.
412
+ // Use ~~~js imports code blocks instead.
413
+ const doc = `# video
414
+ - component duration:1 jsx:"<ComA />"
415
+
416
+ ~~~js imports
417
+ import { ComA } from "npm:pkg"
418
+ import { ComB } from "npm:other"
419
+ ~~~`;
420
+ const parsed = parseMarkdownDescriptive(doc);
421
+ // imports removed from component schema — now at root level
422
+ // imports removed from component schema — now at root level
423
+ // imports removed from component schema — now at root level
424
+ });
425
+
426
+ it("parses inline component definitions from ~~~js imports block", () => {
427
+ const doc = `# video
428
+ ## Demo
429
+ - component duration:1 jsx:"<Badge />"
430
+
431
+ ~~~js imports
432
+ export function Badge({ text }) {
433
+ return <span>{text}</span>
434
+ }
435
+
436
+ import { Card } from "npm:card"
437
+ ~~~`;
438
+ const parsed = parseMarkdownDescriptive(doc);
439
+ // imports removed from component schema — now at root level
440
+ // imports removed from component schema — now at root level
441
+ // imports removed from component schema — now at root level
442
+ });
443
+
444
+ it("does not treat unrelated ``` code blocks as components", () => {
445
+ const doc = `# video
446
+ ## Scene
447
+ - image src:a.jpg duration:1
448
+
449
+ \`\`\`bash
450
+ npm install foo
451
+ \`\`\`
452
+
453
+ \`\`\`jsx
454
+ export default function Unnamed() { return null; }
455
+ \`\`\`
456
+ `;
457
+ const parsed = parseMarkdownDescriptive(doc);
458
+ // bash block ignored; jsx block without name also ignored
459
+ // imports removed from component schema — now at root level
460
+ });
461
+
462
+ it("supports inline jsx: key on a component node (usage JSX)", () => {
463
+ const doc = `# video
464
+ ## Scene
465
+ - component duration:1 jsx:"<Foo />"`;
466
+ const parsed = parseMarkdownDescriptive(doc);
467
+ const scene = parsed.children[0]! as any;
468
+ const c = scene.children[0]!!;
469
+ expect(c.jsx).toBe("<Foo />");
470
+ expect(c.jsx).toContain("Foo");
471
+ });
472
+
473
+ it("component node can have jsx: only (no componentName)", () => {
474
+ const doc = `# video
475
+ ## Scene
476
+ - component duration:1 jsx:"<Greeting name='World' />"
477
+
478
+ ~~~js imports
479
+ import { Greeting } from "npm:greeting"
480
+ ~~~`;
481
+ const parsed = parseMarkdownDescriptive(doc);
482
+ const scene = parsed.children[0] as any;
483
+ const c = scene.children[0];
484
+ expect(c.jsx).toBe("<Greeting name='World' />");
485
+ expect(c.jsx).toContain("Greeting");
486
+
487
+ const compiled = compileDescriptiveRoot(parsed);
488
+ const compiledScene = compiled.children[0]! as any;
489
+ const cc = compiledScene.children[0]!;
490
+ expect(cc.jsx).toBe("<Greeting name='World' />");
491
+ // imports removed from component schema — now at root level
492
+ // imports removed from component schema — now at root level
493
+ });
494
+
495
+ it("imports from ~~~js imports block resolve onto component nodes", () => {
496
+ const doc = `# video
497
+ ## Scene
498
+ - component duration:1 jsx:"<Logo />"
499
+
500
+ ~~~js imports
501
+ import { Logo } from "npm:logo-pkg"
502
+ ~~~`;
503
+ const parsed = parseMarkdownDescriptive(doc);
504
+ // imports removed from component schema — now at root level
505
+
506
+ const compiled = compileDescriptiveRoot(parsed);
507
+ const scene = compiled.children[0]! as any;
508
+ // imports removed from component schema — now at root level
509
+ });
510
+
511
+ // ── Import source types ──────────────────────────────────────────────
512
+
513
+ it("~~~js imports with from:npm resolves to esm.sh", () => {
514
+ const doc = `# video
515
+ ## Scene
516
+ - component duration:1 jsx:"<CompA />"
517
+
518
+ ~~~js imports
519
+ import { CompA } from "npm:some-pkg"
520
+ ~~~`;
521
+ const parsed = parseMarkdownDescriptive(doc);
522
+ // imports removed from component schema — now at root level
523
+ const compiled = compileDescriptiveRoot(parsed);
524
+ // imports removed from component schema — now at root level
525
+ });
526
+
527
+ it("~~~js imports with from:npm@version", () => {
528
+ const doc = `# video
529
+ ## Scene
530
+ - component duration:1 jsx:"<Chart />"
531
+ - component duration:1 jsx:"<Chart />"
532
+
533
+ ~~~js imports
534
+ import { Chart } from "npm:chart-js@4.5.0"
535
+ ~~~`;
536
+ const parsed = parseMarkdownDescriptive(doc);
537
+ // imports removed from component schema — now at root level
538
+ const compiled = compileDescriptiveRoot(parsed);
539
+ const [byName, byJsx] = (compiled.children[0]! as any).children;
540
+ // imports removed from component schema — now at root level
541
+ // imports removed from component schema — now at root level
542
+ });
543
+
544
+ it("~~~js imports with from:git:user/repo@branch/path", () => {
545
+ const doc = `# video
546
+ ## Scene
547
+ - component duration:1 jsx:"<Badge />"
548
+
549
+ ~~~js imports
550
+ import { Badge } from "git:myorg/badge-component@master/src/Badge.tsx"
551
+ ~~~`;
552
+ const parsed = parseMarkdownDescriptive(doc);
553
+ const compiled = compileDescriptiveRoot(parsed);
554
+ // imports removed from component schema — now at root level
555
+ });
556
+
557
+ it("~~~js imports with from:github:user/repo", () => {
558
+ const doc = `# video
559
+ ## Scene
560
+ - component duration:1 jsx:"<Logo />"
561
+
562
+ ~~~js imports
563
+ import { Logo } from "github:team/logo-assets"
564
+ ~~~`;
565
+ const parsed = parseMarkdownDescriptive(doc);
566
+ const compiled = compileDescriptiveRoot(parsed);
567
+ // imports removed from component schema — now at root level
568
+ });
569
+
570
+ it("~~~js imports with from:https URL", () => {
571
+ const doc = `# video
572
+ ## Scene
573
+ - component duration:1 jsx:"<Widget />"
574
+
575
+ ~~~js imports
576
+ import { Widget } from "https://cdn.example.com/widget.mjs"
577
+ ~~~`;
578
+ const parsed = parseMarkdownDescriptive(doc);
579
+ const compiled = compileDescriptiveRoot(parsed);
580
+ // imports removed from component schema — now at root level
581
+ });
582
+
583
+ it("~~~js imports with from:http URL", () => {
584
+ const doc = `# video
585
+ ## Scene
586
+ - component duration:1 jsx:"<DevUI />"
587
+
588
+ ~~~js imports
589
+ import { DevUI } from "http://localhost:5173/src/components/DevPanel.tsx"
590
+ ~~~`;
591
+ const parsed = parseMarkdownDescriptive(doc);
592
+ const compiled = compileDescriptiveRoot(parsed);
593
+ // imports removed from component schema — now at root level
594
+ });
595
+
596
+ it("~~~js imports with from:local relative path", () => {
597
+ const doc = `# video
598
+ ## Scene
599
+ - component duration:1 jsx:"<LocalComp />"
600
+
601
+ ~~~js imports
602
+ import { LocalComp } from "./components/MyWidget.tsx"
603
+ ~~~`;
604
+ const parsed = parseMarkdownDescriptive(doc);
605
+ const compiled = compileDescriptiveRoot(parsed);
606
+ // imports removed from component schema — now at root level
607
+ });
608
+
609
+ it("~~~js imports with from:absolute path", () => {
610
+ const doc = `# video
611
+ ## Scene
612
+ - component duration:1 jsx:"<Helper />"
613
+
614
+ ~~~js imports
615
+ import { Helper } from "/Users/me/lib/helper.tsx"
616
+ ~~~`;
617
+ const parsed = parseMarkdownDescriptive(doc);
618
+ const compiled = compileDescriptiveRoot(parsed);
619
+ // imports removed from component schema — now at root level
620
+ });
621
+
622
+ // ── Import entry variants ────────────────────────────────────────────
623
+
624
+ it("~~~js imports with from: + exports:", () => {
625
+ // exports is not directly represented in import statements;
626
+ // the default export is used. This is a compiler concern.
627
+ const doc = `# video
628
+ ## Scene
629
+ - component duration:1 jsx:"<Counter />"
630
+
631
+ ~~~js imports
632
+ import { Counter } from "npm:stat-counter"
633
+ ~~~`;
634
+ const parsed = parseMarkdownDescriptive(doc);
635
+ // imports removed from component schema — now at root level
636
+ });
637
+
638
+ it("~~~js imports with inline function definition", () => {
639
+ const doc = `# video
640
+ ## Scene
641
+ - component duration:1 jsx:"<Greeting />"
642
+
643
+ ~~~js imports
644
+ export function Greeting({ name }) {
645
+ return <h1>{name}</h1>
646
+ }
647
+ ~~~`;
648
+ const parsed = parseMarkdownDescriptive(doc);
649
+ // imports removed from component schema — now at root level
650
+ // imports removed from component schema — now at root level
651
+ });
652
+
653
+ // ── Component node usage modes ────────────────────────────────────────
654
+
655
+ it("component node resolved from ~~~js imports", () => {
656
+ const doc = `# video
657
+ ## Scene
658
+ - component duration:2 jsx:"<StatBox value={10} label='Score' />"
659
+
660
+ ~~~js imports
661
+ import { StatBox } from "npm:stat-box"
662
+ ~~~`;
663
+ const parsed = parseMarkdownDescriptive(doc);
664
+ const compiled = compileDescriptiveRoot(parsed);
665
+ const c = (compiled.children[0]! as any).children[0]!;
666
+ // imports removed from component schema — now at root level
667
+ });
668
+
669
+ it("component node with jsx only (no componentName)", () => {
670
+ const doc = `# video
671
+ ## Scene
672
+ - component duration:2 jsx:"<Greeting name='World' />"`;
673
+ const parsed = parseMarkdownDescriptive(doc);
674
+ const c = (parsed.children[0]! as any).children[0]!;
675
+ expect(c.jsx).toBe("<Greeting name='World' />");
676
+ });
677
+
678
+ it("component node with jsx referencing imported components", () => {
679
+ const doc = `# video
680
+ ## Scene
681
+ - component duration:2 jsx:"<Header title='Page' /><Footer />"
682
+
683
+ ~~~js imports
684
+ import { Header } from "npm:header-lib"
685
+ import { Footer } from "git:org/footer@main"
686
+ ~~~`;
687
+ const parsed = parseMarkdownDescriptive(doc);
688
+ const compiled = compileDescriptiveRoot(parsed);
689
+ const c = (compiled.children[0]! as any).children[0]!;
690
+ expect(c.jsx).toBe("<Header title='Page' /><Footer />");
691
+ // imports removed from component schema — now at root level
692
+ // imports removed from component schema — now at root level
693
+ });
694
+
695
+ it("component node with both componentName and jsx", () => {
696
+ const doc = `# video
697
+ ## Scene
698
+ - component duration:2 jsx:"<Widget mode='dark' />"`;
699
+ const parsed = parseMarkdownDescriptive(doc);
700
+ const c = (parsed.children[0]! as any).children[0]!;
701
+ expect(c.jsx).toBe("<Widget mode='dark' />");
702
+ // props merged into jsx expression
703
+ });
704
+
705
+ // ── Multiple imports + mixed component nodes ──────────────────────────
706
+
707
+ it("multiple imports with mixed sources in one ~~~js imports block", () => {
708
+ const doc = `# video
709
+ ## Scene
710
+ - component duration:1 jsx:"<Counter />"
711
+ - component duration:1 jsx:"<Logo />"
712
+ - component duration:1 jsx:"<Badge />"
713
+ - component duration:1 jsx:"<Chart />"
714
+
715
+ ~~~js imports
716
+ import { Counter } from "npm:counter-lib"
717
+ import { Logo } from "github:org/design-system/src/Logo.tsx"
718
+ import { Chart } from "https://cdn.example.com/chart.js"
719
+
720
+ export function Badge({ label }) {
721
+ return <span>{label}</span>
722
+ }
723
+ ~~~`;
724
+ const parsed = parseMarkdownDescriptive(doc);
725
+ // imports removed from component schema — now at root level
726
+ // imports removed from component schema — now at root level
727
+ // imports removed from component schema — now at root level
728
+ // imports removed from component schema — now at root level
729
+
730
+ const compiled = compileDescriptiveRoot(parsed);
731
+ const children = (compiled.children[0]! as any).children;
732
+ // imports removed from component schema — now at root level
733
+ // imports removed from component schema — now at root level
734
+ // imports removed from component schema — now at root level
735
+ // imports removed from component schema — now at root level
736
+ });
737
+
738
+ it("jsx usage node with no frontmatter imports (host-registered fallback)", () => {
739
+ const doc = `# video
740
+ ## Scene
741
+ - component duration:2 jsx:"<AnimatedHeadline text='Hello' />"`;
742
+ const parsed = parseMarkdownDescriptive(doc);
743
+ // No imports → jsx will reference host-registered components at runtime
744
+ const compiled = compileDescriptiveRoot(parsed);
745
+ const c = (compiled.children[0]! as any).children[0]!;
746
+ expect(c.jsx).toBe("<AnimatedHeadline text='Hello' />");
747
+ // imports removed from component schema — now at root level
748
+ });
749
+
750
+ it("component node with pipe-delimited jsx body on bullet line", () => {
751
+ // jsx can contain spaces, commas, quotes — the pipe | syntax on the bullet
752
+ const doc = `# video
753
+ ## Scene
754
+ - component duration:2 jsx:"<Counter value={42} suffix='%' />"`;
755
+ const parsed = parseMarkdownDescriptive(doc);
756
+ const c = (parsed.children[0]! as any).children[0]!;
757
+ expect(c.jsx).toBe("<Counter value={42} suffix='%' />");
758
+ });
759
+
760
+ it("empty frontmatter (just --- line pairs) is ignored", () => {
761
+ const doc = `---
762
+ ---
763
+ # video
764
+ ## Scene
765
+ - image src:a.jpg duration:1`;
766
+ const parsed = parseMarkdownDescriptive(doc);
767
+ // imports removed from component schema — now at root level
768
+ });
769
+ });
770
+
771
+ // ── Script / audio alias ─────────────────────────────────────────────
772
+
773
+ describe("script — audio alias", () => {
774
+ it("inline audio script:key on audio node", () => {
775
+ const doc = `# video
776
+ ## S
777
+ layout:parallel
778
+ - audio src:bg.mp3 duration:3 volume:0.5 script:"inline narration"`;
779
+ const parsed = parseMarkdownDescriptive(doc);
780
+ const node = (parsed.children[0] as any).children[0];
781
+ expect(node.type).toBe("audio");
782
+ expect(node.src).toBe("bg.mp3");
783
+ expect(node.script).toBe("inline narration");
784
+ expect(node.volume).toBe(0.5);
785
+ expect(node.duration).toBe(3);
786
+ });
787
+
788
+ it("standalone - script bullet creates audio node with script", () => {
789
+ const doc = `# video
790
+ ## S
791
+ layout:parallel
792
+ - script "standalone narration"`;
793
+ const parsed = parseMarkdownDescriptive(doc);
794
+ const node = (parsed.children[0] as any).children[0];
795
+ expect(node.type).toBe("audio");
796
+ expect(node.script).toBe("standalone narration");
797
+ expect(node.volume).toBe(1); // default
798
+ });
799
+
800
+ it("standalone - script bullet supports audio keys", () => {
801
+ const doc = `# video
802
+ ## S
803
+ layout:parallel
804
+ - script "narration" volume:0.3 start:2 duration:5 foreground:true isBackground:true`;
805
+ const parsed = parseMarkdownDescriptive(doc);
806
+ const node = (parsed.children[0] as any).children[0];
807
+ expect(node.type).toBe("audio");
808
+ expect(node.script).toBe("narration");
809
+ expect(node.volume).toBe(0.3);
810
+ expect(node.start).toBe(2);
811
+ expect(node.duration).toBe(5);
812
+ expect(node.foreground).toBe(true);
813
+ expect(node.isBackground).toBe(true);
814
+ });
815
+
816
+ it("code fence ~~~script sets script on audio node", () => {
817
+ const doc = `# video
818
+ ## S
819
+ layout:parallel
820
+ - audio src:bg.mp3 duration:3
821
+ ~~~script
822
+ code fence content
823
+ ~~~`;
824
+ const parsed = parseMarkdownDescriptive(doc);
825
+ const node = (parsed.children[0] as any).children[0];
826
+ expect(node.type).toBe("audio");
827
+ expect(node.src).toBe("bg.mp3");
828
+ expect(node.script).toBe("code fence content");
829
+ });
830
+
831
+ it("throws when standalone - script has no text", () => {
832
+ const doc = `# video
833
+ ## S
834
+ layout:parallel
835
+ - script`;
836
+ expect(() => parseMarkdownDescriptive(doc)).toThrow("script requires text content");
837
+ });
838
+
839
+ it("compiles script audio node through full pipeline", () => {
840
+ const doc = `# video
841
+ ## S
842
+ layout:parallel
843
+ - audio src:bg.mp3 duration:3 script:"tts text"`;
844
+ const parsed = parseMarkdownDescriptive(doc);
845
+ const compiled = compileDescriptiveRoot(parsed);
846
+ const child = (compiled.children[0] as any).children[0];
847
+ expect(child.type).toBe("audio");
848
+ expect(child.src).toBe("bg.mp3");
849
+ // script field is not in the compiled schema — it's consumed by resolveScripts
850
+ expect(child.script).toBeUndefined();
851
+ });
852
+
853
+ it("compiles standalone - script through full pipeline", () => {
854
+ const doc = `# video
855
+ ## S
856
+ layout:parallel
857
+ - script "tts text"`;
858
+ const parsed = parseMarkdownDescriptive(doc);
859
+ const compiled = compileDescriptiveRoot(parsed);
860
+ const child = (compiled.children[0] as any).children[0];
861
+ expect(child.type).toBe("audio");
862
+ // No src yet — resolveScripts will set it later from script field
863
+ expect(child.src).toBeUndefined();
864
+ });
865
+ });
866
+ });