@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,674 @@
1
+ import type {
2
+ DescriptiveAudio,
3
+ DescriptiveComponent,
4
+ DescriptiveContainer,
5
+ DescriptiveImage,
6
+ DescriptiveInclude,
7
+ DescriptiveMap,
8
+ DescriptiveMapWaypoint,
9
+ DescriptiveNode,
10
+ DescriptiveRhythm,
11
+ DescriptiveRoot,
12
+ DescriptiveScene,
13
+ DescriptiveVideo,
14
+ } from "./compiler";
15
+ import { unified } from "unified";
16
+ import remarkParse from "remark-parse";
17
+ import remarkFrontmatter from "remark-frontmatter";
18
+ import * as yaml from "js-yaml";
19
+ import {
20
+ DslError,
21
+ LAYOUT_VALUES,
22
+ TRANSITION_VALUES,
23
+ isQuoted,
24
+ unquote,
25
+ splitTokens,
26
+ parseKeyValueTokens,
27
+ } from "./dsl";
28
+ import type { ParseContext } from "./dsl";
29
+
30
+ type ParentNode = DescriptiveRoot | DescriptiveScene | DescriptiveContainer | DescriptiveInclude;
31
+
32
+ /** Re-export so existing deep imports keep working. */
33
+ export { DslError };
34
+ export type { ParseContext };
35
+
36
+ const TYPE_TOKENS: Record<string, string> = {
37
+ image: "image",
38
+ video: "video",
39
+ audio: "audio",
40
+ component: "component",
41
+ rhythm: "rhythm",
42
+ include: "include",
43
+ map: "map",
44
+ script: "script",
45
+ series: "series",
46
+ parallel: "parallel",
47
+ transitionSeries: "transitionSeries",
48
+ };
49
+
50
+ function parseHeaderScene(line: string, lineNum?: number): DescriptiveScene {
51
+ const text = line.replace(/^#+\s*/, "").trim();
52
+ const tokens = splitTokens(text);
53
+ const nameToken = tokens.shift();
54
+ const attrs = parseKeyValueTokens(tokens, lineNum ? { line: lineNum, lineText: line } : undefined);
55
+
56
+ // Scene name from heading text (first token), title from " - " separator
57
+ let sceneName: string | undefined;
58
+ let sceneTitle: string | undefined;
59
+ if (nameToken) {
60
+ const raw = unquote(nameToken);
61
+ const sep = raw.indexOf(" - ");
62
+ if (sep !== -1) {
63
+ sceneName = raw.slice(0, sep).trim();
64
+ sceneTitle = raw.slice(sep + 3).trim();
65
+ } else {
66
+ sceneName = raw;
67
+ }
68
+ }
69
+ sceneName = sceneName || (attrs.name ? String(attrs.name) : undefined);
70
+ sceneTitle = sceneTitle || (attrs.title ? String(attrs.title) : undefined);
71
+
72
+ return {
73
+ type: "scene",
74
+ id: attrs.id as any,
75
+ name: sceneName,
76
+ title: sceneTitle,
77
+ instruction: attrs.instruction ? String(attrs.instruction) : undefined,
78
+ layout: attrs.layout as any,
79
+ transition: attrs.transition as any,
80
+ transitionTime: attrs.transitionTime as any,
81
+ on: attrs.on as any,
82
+ children: [],
83
+ };
84
+ }
85
+
86
+ function pushChild(parent: ParentNode, child: DescriptiveNode): void {
87
+ if (!parent.children) parent.children = [];
88
+ parent.children.push(child);
89
+ }
90
+
91
+ /**
92
+ * Copy any attrs that aren't standard leaf-node keys onto the node.
93
+ * These are variant overrides like `zh`, `portrait-src`, etc.
94
+ */
95
+ function preserveVariantAttrs(node: Record<string, unknown>, attrs: Record<string, unknown>): void {
96
+ // Standard keys consumed by leaf node types — anything else is a variant override
97
+ const STANDARD = new Set([
98
+ "type", "id", "src", "script", "jsx", "volume", "playbackRate",
99
+ "start", "duration", "startFrom", "endAt", "loop", "width", "height", "fit",
100
+ "foreground", "visible", "isBackground", "instruction", "style", "effects", "on",
101
+ "spots", "waypoints", "routeColor", "routeWeight", "routeMarker",
102
+ "travelMode", "zoom", "center", "mapType", "data", "prompt",
103
+ "name", "title", "transition", "transitionTime", "layout",
104
+ "componentName", "props",
105
+ ]);
106
+ for (const [k, v] of Object.entries(attrs)) {
107
+ if (!STANDARD.has(k)) {
108
+ (node as any)[k] = v;
109
+ }
110
+ }
111
+ }
112
+
113
+ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
114
+ const ctx: ParseContext | undefined = lineNum ? { line: lineNum, lineText: content } : undefined;
115
+ const tokens = splitTokens(content);
116
+ if (tokens.length === 0) throw new DslError("empty node line", ctx);
117
+
118
+ let typeToken = tokens[0]!;
119
+ let type = TYPE_TOKENS[typeToken];
120
+
121
+ if (type) tokens.shift();
122
+
123
+ let firstPositional: string | undefined;
124
+ if (type) {
125
+ const t0 = tokens[0];
126
+ if (
127
+ t0 &&
128
+ // Quoted tokens are always positional values (even if they contain ':')
129
+ (isQuoted(t0) || t0.indexOf(":") === -1) &&
130
+ !LAYOUT_VALUES.has(t0 as any) &&
131
+ !TRANSITION_VALUES.has(t0 as any)
132
+ ) {
133
+ firstPositional = t0;
134
+ tokens.shift();
135
+ }
136
+ }
137
+
138
+ if (!type) {
139
+ throw new DslError(`missing or unknown node type: ${typeToken}`, { ...ctx, token: typeToken });
140
+ }
141
+
142
+ // Container bullets
143
+ if (type === "series" || type === "parallel" || type === "transitionSeries") {
144
+ const attrs = parseKeyValueTokens(tokens, ctx);
145
+ const node: DescriptiveContainer = {
146
+ type: type as any,
147
+ id: attrs.id as any,
148
+ instruction: attrs.instruction as any,
149
+ transition: attrs.transition as any,
150
+ transitionTime: attrs.transitionTime as any,
151
+ effects: attrs.effects as any,
152
+ on: attrs.on as any,
153
+ children: [],
154
+ };
155
+ return node;
156
+ }
157
+
158
+ const attrs = parseKeyValueTokens(tokens, ctx);
159
+
160
+ switch (type) {
161
+ case "image": {
162
+ const src = firstPositional ?? (attrs.src as string | undefined);
163
+ const prompt = attrs.prompt as string | undefined;
164
+ // src or prompt may be set later via indented property collection
165
+ const node: DescriptiveImage = {
166
+ type: "image",
167
+ id: attrs.id as any,
168
+ src,
169
+ prompt,
170
+ fit: attrs.fit as any,
171
+ duration: attrs.duration as any,
172
+ start: attrs.start as any,
173
+ instruction: attrs.instruction as any,
174
+ visible: attrs.visible as any,
175
+ isBackground: attrs.isBackground as any,
176
+ style: attrs.style as any,
177
+ effects: attrs.effects as any,
178
+ on: attrs.on as any,
179
+ };
180
+ preserveVariantAttrs(node, attrs);
181
+ return node;
182
+ }
183
+ case "video": {
184
+ const src = firstPositional ?? (attrs.src as string | undefined);
185
+ const prompt = attrs.prompt as string | undefined;
186
+ // src or prompt may be set later via indented property collection
187
+ const node: DescriptiveVideo = {
188
+ type: "video",
189
+ id: attrs.id as any,
190
+ src,
191
+ prompt,
192
+ duration: attrs.duration as any,
193
+ start: attrs.start as any,
194
+ startFrom: attrs.startFrom as any,
195
+ endAt: attrs.endAt as any,
196
+ volume: attrs.volume as any,
197
+ playbackRate: attrs.playbackRate as any,
198
+ width: attrs.width as any,
199
+ height: attrs.height as any,
200
+ instruction: attrs.instruction as any,
201
+ visible: attrs.visible as any,
202
+ isBackground: attrs.isBackground as any,
203
+ style: attrs.style as any,
204
+ effects: attrs.effects as any,
205
+ on: attrs.on as any,
206
+ };
207
+ preserveVariantAttrs(node, attrs);
208
+ return node;
209
+ }
210
+ case "audio": {
211
+ const src = firstPositional ?? (attrs.src as string | undefined);
212
+ if (!src) throw new DslError("audio requires src", ctx);
213
+ const node: DescriptiveAudio = {
214
+ type: "audio",
215
+ id: attrs.id as any,
216
+ src,
217
+ duration: attrs.duration as any,
218
+ start: attrs.start as any,
219
+ startFrom: attrs.startFrom as any,
220
+ endAt: attrs.endAt as any,
221
+ volume: attrs.volume as any,
222
+ foreground: attrs.foreground as any,
223
+ loop: attrs.loop as any,
224
+ instruction: attrs.instruction as any,
225
+ script: attrs.script as any,
226
+ visible: attrs.visible as any,
227
+ isBackground: attrs.isBackground as any,
228
+ style: attrs.style as any,
229
+ effects: attrs.effects as any,
230
+ on: attrs.on as any,
231
+ };
232
+ preserveVariantAttrs(node, attrs);
233
+ return node;
234
+ }
235
+ case "component": {
236
+ const jsx = attrs.jsx as string | undefined;
237
+ // jsx may be set later via indented code fence property collection
238
+ const node: DescriptiveComponent = {
239
+ type: "component",
240
+ id: attrs.id as any,
241
+ jsx,
242
+ duration: attrs.duration as any,
243
+ start: attrs.start as any,
244
+ instruction: attrs.instruction as any,
245
+ visible: attrs.visible as any,
246
+ isBackground: attrs.isBackground as any,
247
+ style: attrs.style as any,
248
+ effects: attrs.effects as any,
249
+ on: attrs.on as any,
250
+ };
251
+ preserveVariantAttrs(node, attrs);
252
+ return node;
253
+ }
254
+ case "rhythm": {
255
+ const src = firstPositional ?? (attrs.src as string | undefined);
256
+ if (!src) throw new DslError("rhythm requires src", ctx);
257
+ const node: DescriptiveRhythm = {
258
+ type: "rhythm",
259
+ id: attrs.id as any,
260
+ src,
261
+ duration: attrs.duration as any,
262
+ start: attrs.start as any,
263
+ volume: attrs.volume as any,
264
+ spots: attrs.spots as any,
265
+ instruction: attrs.instruction as any,
266
+ visible: attrs.visible as any,
267
+ isBackground: attrs.isBackground as any,
268
+ style: attrs.style as any,
269
+ effects: attrs.effects as any,
270
+ on: attrs.on as any,
271
+ };
272
+ preserveVariantAttrs(node, attrs);
273
+ return node;
274
+ }
275
+ case "include": {
276
+ const src = firstPositional ?? (attrs.src as string | undefined);
277
+ const node: DescriptiveInclude = {
278
+ type: "include",
279
+ id: attrs.id as any,
280
+ src,
281
+ duration: attrs.duration as any,
282
+ start: attrs.start as any,
283
+ volume: attrs.volume as any,
284
+ instruction: attrs.instruction as any,
285
+ visible: attrs.visible as any,
286
+ isBackground: attrs.isBackground as any,
287
+ style: attrs.style as any,
288
+ effects: attrs.effects as any,
289
+ on: attrs.on as any,
290
+ };
291
+ return node;
292
+ }
293
+ case "script": {
294
+ const raw = firstPositional ?? (attrs.script ? String(attrs.script) : undefined);
295
+ if (!raw) throw new DslError("script requires text content", ctx);
296
+ // Unquote if needed (standalone `- script "..."` preserves quotes in the token)
297
+ const text = isQuoted(raw) ? unquote(raw) : raw;
298
+ // Script is an alias for audio — creates an audio node with TTS-needed marker.
299
+ const scriptNode: any = {
300
+ type: "audio",
301
+ id: attrs.id as any,
302
+ script: text,
303
+ volume: (attrs.volume as number | undefined) ?? 1,
304
+ start: attrs.start as any,
305
+ duration: attrs.duration as any,
306
+ foreground: attrs.foreground as any,
307
+ isBackground: attrs.isBackground as any,
308
+ style: attrs.style as any,
309
+ visible: attrs.visible as any,
310
+ effects: attrs.effects as any,
311
+ on: attrs.on as any,
312
+ };
313
+ preserveVariantAttrs(scriptNode, attrs);
314
+ return scriptNode;
315
+ }
316
+ case "map": {
317
+ const node: DescriptiveMap = {
318
+ type: "map",
319
+ id: attrs.id as any,
320
+ waypoints: (attrs.waypoints as DescriptiveMapWaypoint[] | undefined) ?? [],
321
+ duration: attrs.duration as any,
322
+ start: attrs.start as any,
323
+ routeMarker: attrs.routeMarker as any,
324
+ travelMode: attrs.travelMode as any,
325
+ routeColor: attrs.routeColor as any,
326
+ routeWeight: attrs.routeWeight as any,
327
+ zoom: attrs.zoom as any,
328
+ center: attrs.center as any,
329
+ mapType: attrs.mapType as any,
330
+ instruction: attrs.instruction as any,
331
+ visible: attrs.visible as any,
332
+ isBackground: attrs.isBackground as any,
333
+ style: attrs.style as any,
334
+ effects: attrs.effects as any,
335
+ on: attrs.on as any,
336
+ };
337
+ preserveVariantAttrs(node, attrs);
338
+ return node;
339
+ }
340
+ default:
341
+ throw new DslError(`unsupported node type: ${type}`, { ...ctx, token: type });
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Parse result with variant support.
347
+ *
348
+ * `# video` defines the base root (config + scenes).
349
+ * Each subsequent `# <name>` defines a variant with root config overrides only.
350
+ * Variant names become keys for `<variant>-<key>` overrides on leaf nodes.
351
+ */
352
+ export interface VariantParseResult {
353
+ /** Base root — the `# video` section. Contains all scenes. */
354
+ base: DescriptiveRoot;
355
+ /** Named variants — keyed by heading text, each with root config overrides. */
356
+ variants: Map<string, Partial<DescriptiveRoot>>;
357
+ }
358
+
359
+ /**
360
+ * Backward-compatible: parses markdown and returns only the base `# video` root.
361
+ * Equivalent to `parseMarkdownVariants(md).base`.
362
+ */
363
+ export function parseMarkdownDescriptive(markdown: string): DescriptiveRoot {
364
+ return internalParse(markdown).base;
365
+ }
366
+
367
+ /**
368
+ * Full variant-aware parse. Returns the base root + all variant configs.
369
+ */
370
+ export function parseMarkdownVariants(markdown: string): VariantParseResult {
371
+ return internalParse(markdown);
372
+ }
373
+
374
+ /** Shared internal implementation */
375
+ function internalParse(markdown: string): VariantParseResult {
376
+ const base: DescriptiveRoot = { children: [] };
377
+ const variants: Map<string, Partial<DescriptiveRoot>> = new Map();
378
+ let activeVariant: string | null = null;
379
+ const lines = markdown.split("\n");
380
+
381
+ // Use remark to parse the markdown into an MDAST tree for STRUCTURE only
382
+ const mdast = unified()
383
+ .use(remarkParse)
384
+ .use(remarkFrontmatter)
385
+ .parse(markdown);
386
+
387
+ let sceneStack: Array<{ level: number; scene: DescriptiveScene }> = [];
388
+ let inSceneMetadata = false;
389
+ let currentScene: DescriptiveScene | null = null;
390
+
391
+ // Get the target root for current state: base or variant
392
+ function targetRoot(): DescriptiveRoot {
393
+ if (activeVariant && variants.has(activeVariant)) {
394
+ const v = variants.get(activeVariant)!;
395
+ if (!v.children) v.children = [];
396
+ return v as DescriptiveRoot;
397
+ }
398
+ return base;
399
+ }
400
+
401
+ for (const node of (mdast as any).children) {
402
+ switch (node.type) {
403
+ case "yaml": {
404
+ // Frontmatter is metadata only — does not affect video config.
405
+ break;
406
+ }
407
+ case "heading": {
408
+ if (node.depth === 1) {
409
+ sceneStack = [];
410
+ currentScene = null;
411
+ inSceneMetadata = false;
412
+
413
+ // Extract heading text as variant name
414
+ const lineText = rawTextAtNode(lines, node);
415
+ const variantName = lineText.replace(/^#+\s*/, "").trim().split(/\s+/)[0]!;
416
+
417
+ if (variantName && variantName !== "video") {
418
+ // This is a named variant — create root config holder
419
+ activeVariant = variantName;
420
+ if (!variants.has(variantName)) {
421
+ variants.set(variantName, { children: [] });
422
+ }
423
+ } else {
424
+ // "video" or unnamed h1 — back to base
425
+ activeVariant = null;
426
+ }
427
+ } else {
428
+ // Extract heading text from the raw source line (skip '##' prefix)
429
+ const lineText = rawTextAtNode(lines, node);
430
+ const headingContent = lineText.replace(/^#+\s*/, "");
431
+ const scene = parseHeaderScene(headingContent, node.position?.start?.line);
432
+
433
+ while (sceneStack.length && sceneStack[sceneStack.length - 1]!.level >= node.depth) {
434
+ sceneStack.pop();
435
+ }
436
+ const parentScene = sceneStack[sceneStack.length - 1]?.scene;
437
+ if (parentScene) {
438
+ parentScene.children.push(scene);
439
+ } else {
440
+ targetRoot().children.push(scene);
441
+ }
442
+ sceneStack.push({ level: node.depth, scene });
443
+ currentScene = scene;
444
+ inSceneMetadata = true;
445
+ }
446
+ break;
447
+ }
448
+ case "paragraph": {
449
+ const text = rawTextAtNode(lines, node);
450
+ if (!text.trim()) break;
451
+
452
+ const tokens = splitTokens(text);
453
+ const attrs = parseKeyValueTokens(
454
+ tokens,
455
+ node.position?.start?.line ? { line: node.position.start.line, lineText: text } : undefined,
456
+ );
457
+
458
+ if (inSceneMetadata && currentScene) {
459
+ applySceneMetadata(currentScene, attrs);
460
+ } else {
461
+ applyRootAttrs(targetRoot(), attrs);
462
+ }
463
+ break;
464
+ }
465
+ case "list": {
466
+ inSceneMetadata = false;
467
+ if (!node.ordered) {
468
+ const parent: ParentNode = currentScene ?? targetRoot();
469
+ for (const item of node.children) {
470
+ processMDASTListItem(item, parent, lines);
471
+ }
472
+ }
473
+ break;
474
+ }
475
+ case "code": {
476
+ const lang = (node.lang ?? "").toLowerCase();
477
+ const meta = (node.meta ?? "").trim();
478
+ if ((lang === "js" && meta === "imports") || lang === "imports") {
479
+ targetRoot().importsBlock = node.value;
480
+ } else if (meta === "stylesheet") {
481
+ targetRoot().stylesheet = node.value;
482
+ }
483
+ break;
484
+ }
485
+ }
486
+ }
487
+
488
+ return { base, variants };
489
+ }
490
+
491
+ /** Extract raw text from an MDAST node using source line positions. */
492
+ function rawTextAtNode(lines: string[], node: any): string {
493
+ const startLine = node.position.start.line - 1; // 0-based
494
+ const endLine = node.position.end.line - 1;
495
+ const startCol = node.position.start.column - 1; // 0-based
496
+ const endCol = node.position.end.column;
497
+
498
+ if (startLine === endLine) {
499
+ // Single line: extract substring
500
+ const line = lines[startLine] ?? "";
501
+ return line.slice(startCol, endCol).trim();
502
+ }
503
+
504
+ // Multi-line: join lines
505
+ const parts: string[] = [];
506
+ for (let l = startLine; l <= endLine; l++) {
507
+ const line = lines[l] ?? "";
508
+ if (l === startLine) {
509
+ parts.push(line.slice(startCol));
510
+ } else if (l === endLine) {
511
+ parts.push(line.slice(0, endCol));
512
+ } else {
513
+ parts.push(line);
514
+ }
515
+ }
516
+ return parts.join("\n").trim();
517
+ }
518
+
519
+ /**
520
+ * Recursively process an MDAST listItem and add the resulting node to `parent`.
521
+ * Uses raw source lines for text content to preserve JSX/HTML.
522
+ */
523
+ function processMDASTListItem(item: any, parent: ParentNode, lines: string[]): void {
524
+ const children = item.children ?? [];
525
+ if (!children.length) return;
526
+
527
+ // Find the first paragraph (the "node line")
528
+ const firstPara = children.find((c: any) => c.type === "paragraph");
529
+ if (!firstPara) return;
530
+
531
+ // Use raw source text to preserve JSX values with angle brackets
532
+ const text = rawTextAtNode(lines, firstPara);
533
+ if (!text.trim()) return;
534
+
535
+ const node = parseNodeLine(text, firstPara.position?.start?.line);
536
+ pushChild(parent, node);
537
+
538
+ // Process remaining children (code blocks as properties, extra paragraphs as properties, nested lists as sub-children)
539
+ for (const child of children) {
540
+ if (child === firstPara) continue;
541
+
542
+ if (child.type === "code") {
543
+ const propName = (child.meta ?? "").trim() || (child.lang ?? "").toLowerCase() || "jsx";
544
+ (node as any)[propName] = child.value;
545
+ } else if (child.type === "paragraph") {
546
+ const extraText = rawTextAtNode(lines, child);
547
+ if (extraText.trim()) {
548
+ const tokens = splitTokens(extraText);
549
+ const attrs = parseKeyValueTokens(
550
+ tokens,
551
+ child.position?.start?.line ? { line: child.position.start.line, lineText: extraText } : undefined,
552
+ );
553
+ Object.assign(node, attrs);
554
+ }
555
+ } else if (child.type === "list") {
556
+ if (
557
+ node.type === "series" ||
558
+ node.type === "parallel" ||
559
+ node.type === "transitionSeries" ||
560
+ node.type === "effect" ||
561
+ node.type === "include" ||
562
+ node.type === "rhythm"
563
+ ) {
564
+ for (const subItem of child.children) {
565
+ processMDASTListItem(subItem, node as ParentNode, lines);
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ }
572
+
573
+ /** Apply key/value attrs to a scene node (subset of root attrs). */
574
+ function applySceneMetadata(scene: DescriptiveScene, attrs: Record<string, unknown>): void {
575
+ for (const [k, v] of Object.entries(attrs)) {
576
+ switch (k) {
577
+ case "layout":
578
+ scene.layout = v as any;
579
+ break;
580
+ case "transition":
581
+ scene.transition = v as any;
582
+ break;
583
+ case "transitionTime":
584
+ scene.transitionTime = Number(v);
585
+ break;
586
+ case "name":
587
+ if (!scene.name) scene.name = String(v);
588
+ break;
589
+ case "title":
590
+ scene.title = String(v);
591
+ break;
592
+ case "instruction":
593
+ scene.instruction = String(v);
594
+ break;
595
+ case "on":
596
+ scene.on = v as any;
597
+ break;
598
+ }
599
+ }
600
+ }
601
+
602
+
603
+
604
+ /** Apply a key/value bag onto the DescriptiveRoot using the same rules as the body parser. */
605
+ function applyRootAttrs(root: DescriptiveRoot, attrs: Record<string, unknown>): void {
606
+ for (const [k, v] of Object.entries(attrs)) {
607
+ switch (k) {
608
+ case "width":
609
+ root.width = Number(v);
610
+ break;
611
+ case "height":
612
+ root.height = Number(v);
613
+ break;
614
+ case "fps":
615
+ root.fps = Number(v);
616
+ break;
617
+ case "layout":
618
+ root.layout = v as any;
619
+ break;
620
+ case "transition":
621
+ root.transition = v as any;
622
+ break;
623
+ case "transitionTime":
624
+ root.transitionTime = Number(v);
625
+ break;
626
+ case "script":
627
+ // script key on root is no longer supported — ignored
628
+ break;
629
+ case "instruction":
630
+ root.instruction = String(v);
631
+ break;
632
+ case "metadata":
633
+ root.metadata = String(v);
634
+ break;
635
+ case "stylesheet":
636
+ root.stylesheet = String(v);
637
+ break;
638
+ case "title":
639
+ case "description":
640
+ // Metadata fields — store as instruction for now
641
+ root.instruction = root.instruction ?? String(v);
642
+ break;
643
+ case "tts":
644
+ root.tts = typeof v === "object" && v !== null ? String((v as any).cli ?? "") : String(v);
645
+ break;
646
+ case "stt":
647
+ root.stt = typeof v === "object" && v !== null ? String((v as any).cli ?? "") : String(v);
648
+ break;
649
+ case "tti":
650
+ root.tti = typeof v === "object" && v !== null ? String((v as any).cli ?? "") : String(v);
651
+ break;
652
+ case "ttv":
653
+ root.ttv = typeof v === "object" && v !== null ? String((v as any).cli ?? "") : String(v);
654
+ break;
655
+ case "subtitle": {
656
+ if (typeof v === "string") {
657
+ root.subtitle = { src: v };
658
+ } else if (v && typeof v === "object") {
659
+ const obj = v as Record<string, unknown>;
660
+ root.subtitle = {} as any;
661
+ if (typeof obj.src === "string") root.subtitle.src = obj.src;
662
+ if (typeof obj.type === "string") root.subtitle.type = obj.type;
663
+ if (typeof obj.style === "string") root.subtitle.style = obj.style;
664
+ if (obj.fontSize != null) root.subtitle.fontSize = obj.fontSize;
665
+ if (typeof obj.fontFamily === "string") root.subtitle.fontFamily = obj.fontFamily;
666
+ if (typeof obj.fontStyle === "string") root.subtitle.fontStyle = obj.fontStyle;
667
+ }
668
+ break;
669
+ }
670
+ default:
671
+ throw new Error(`unknown root key: ${k}`);
672
+ }
673
+ }
674
+ }