@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,751 @@
1
+ # Markdown Descriptive (Agent Contract)
2
+
3
+ Complete reference for LLM-driven video generation. The parser uses **remark** (`unified` + `remark-parse` + `remark-frontmatter`) for structural parsing (headings, lists, code fences) and extracts raw text from source positions, so JSX values with `<`/`>` are preserved correctly.
4
+
5
+ ## Output Contract
6
+
7
+ A markdown document compiled into a renderable scene tree.
8
+
9
+ - Top heading `# <name>` (heading text ignored, just marks document root; `# video`, `# sub-video`, `# anything` all work)
10
+ - Optional YAML frontmatter block `---\n...\n---\n` at the very top
11
+ - Root config line: `width:<n> height:<n> fps:<n> layout:<mode>` (key:value pairs on the line after `# video`)
12
+ - Scenes via `##`/`###`/`####` headings
13
+ - Leaf nodes via `- typeToken ...` bullets
14
+ - Component registrations via `` ~~~js imports `` code fence (or inline JSX definitions)
15
+ - Properties via indented code fences (`~~~<lang> <propName>`); `~~~script` only valid on audio nodes
16
+
17
+ ## Frontmatter (metadata only)
18
+
19
+ A YAML block at the top of the document, delimited by `---`. Frontmatter is
20
+ **metadata only** — it does NOT affect video configuration. All video config
21
+ (width, height, fps, layout, tts, stt, stylesheet, etc.) comes from the
22
+ **root config line** (key:value pairs on the line after `# video`).
23
+
24
+ ```yaml
25
+ ---
26
+ title: My Campaign
27
+ description: Q4 product launch
28
+ ---
29
+ ```
30
+
31
+ ## Root Config Line
32
+
33
+ The line after `# video` contains all video configuration as space-separated
34
+ `key:value` pairs:
35
+
36
+ ```markdown
37
+ # video
38
+ width:1920 height:1080 fps:30 layout:series tts:"edge-tts --voice 'en-US-GuyNeural' --text '{input}' --write-media '{output}'" stylesheet:".bg { background: #000; }" subtitle:captions.vtt
39
+ ```
40
+
41
+ Supported keys: `width`, `height`, `fps`, `layout`, `tts`, `stt`, `tti`, `ttv`,
42
+ `transition`, `transitionTime`, `instruction`, `metadata`, `stylesheet`, `subtitle`.
43
+
44
+ Values containing spaces must be quoted with double or single quotes.
45
+
46
+ ### Subtitle on the config line
47
+
48
+ ```markdown
49
+ # video
50
+ width:640 height:480 subtitle:captions.vtt
51
+ ```
52
+
53
+ Currently only `src` (a VTT file path) is supported via the config line.
54
+
55
+ ## Template Variables
56
+
57
+ `${width}`, `${height}`, `${fps}`, and `${variant}` can be used in `src`,
58
+ `prompt`, and `stylesheet` values. They are resolved at compile time using
59
+ the root config values.
60
+
61
+ ```markdown
62
+ # video
63
+ width:1920 height:1080
64
+ ```
65
+
66
+ - `src:photo_${width}x${height}.jpg` → `photo_1920x1080.jpg`
67
+ - `prompt:"generate an image at ${width}x${height}"` → `generate an image at 1920x1080`
68
+ - `stylesheet:".hero { width: ${width}px; }"` → `.hero { width: 1920px; }`
69
+
70
+ > Template variables are NOT resolved in root config keys, jsx, script, style,
71
+ > or other string fields — only in `src`, `prompt`, and `stylesheet`.
72
+ style: "color: yellow;"
73
+ ```
74
+
75
+ | Field | Required | Type | Notes |
76
+ |---|---|---|---|
77
+ | `src` | yes | string | VTT path/URL, inline VTT body, or plain text |
78
+ | `type` | opt | string | caption animation: `Bounce`, `Fade`, `Typewriter`, `Colorful`, etc. Default: plain static caption |
79
+ | `fontSize` | opt | number | default 56 |
80
+ | `fontFamily` | opt | string | font family |
81
+ | `fontStyle` | opt | string | `normal`, `italic`, `bold`, etc. |
82
+ | `style` | opt | string | inline CSS for the overlay container |
83
+
84
+ > **HTML in cue text**: Cue text supports HTML tags with inline CSS, so you can style individual words:
85
+ > ```vtt
86
+ > 00:00:01.000 --> 00:00:03.000
87
+ > Hello <span style="color:#ff6b6b">world</span>, welcome to <b>our show</b>!
88
+ > ```
89
+ > The engine renders cue text via `dangerouslySetInnerHTML`, making `<span>`, `<b>`, `<i>`, `<br>`, and inline `style` attributes all work.
90
+
91
+ If `src` is plain text (no `-->` markers), it renders as a single caption for the full video duration. The `type` field maps to a `remotion-subtitle` animation component — omit for a plain static caption.
92
+
93
+
94
+ ### Imports block (recommended)
95
+
96
+ Use a `` ~~~js imports `` code fence at end of the document (or anywhere in the body). The block acts as a **component registry** — it re-exports components from external packages or defines them inline, making them available to JSX expressions throughout the video.
97
+
98
+ ```
99
+ ~~~js imports
100
+ export { PieChart } from "recharts"
101
+ export { BarChart, LineChart } from "recharts"
102
+ export { StatCounter as Counter } from "stat-counter"
103
+
104
+ export function Hello({ name }) {
105
+ return <div style={{color: '#fff'}}>Hello {name}</div>;
106
+ }
107
+ ~~~
108
+ ```
109
+
110
+ Think of this block as the **index file** for the video's component scope. `export { Name } from "spec"` re-exports an external component (conceptually correct — the block is the public API of available components). `export function Name()` defines an inline component directly.
111
+
112
+ The imports block is the **primary** way to register components. The legacy YAML `imports:` array in frontmatter is still supported as a fallback, but the code block is preferred.
113
+
114
+ > **How it works**: The server extracts the imports block from the raw source, parses it with `parseImportsBlock`, then runs `bundleFromEntries` which creates a temp npm project, installs packages, and bundles everything into a single ESM file with esbuild. The resulting bundle URL is set on `root.imports` (e.g. `/.component-cache/be710e5c.js`). At render time, `MarkCut.useComponentRegistry` dynamically imports this URL and feeds the named exports to react-jsx-parser.
115
+
116
+ Supported patterns inside the block:
117
+
118
+ | Pattern | Effect |
119
+ |---|---|
120
+ | `export { Name } from "spec"` | Re-exports `Name` from the resolved source (recommended) |
121
+ | `export { Name as Alias } from "spec"` | Re-exports under `Alias` instead |
122
+ | `export { N1, N2 } from "spec"` | Re-exports multiple from the same source |
123
+ | `export default Name from "spec"` | Re-exports default export |
124
+ | `export function Name(...) { ... }` | Inline component definition |
125
+ | `export default function Name(...) { ... }` | Inline component definition (default) |
126
+
127
+ For compatibility, `import { Name } from "spec"` also works and produces the same result — both syntaxes register the name identically.
128
+
129
+ `from:` spec forms:
130
+
131
+ | Prefix | Resolved by bundler as |
132
+ |---|---|
133
+ | `pkg` or `npm:pkg` | npm package — `npm install pkg`, then `esbuild` re-exports it |
134
+ | `pkg@1.2.3` or `npm:pkg@1.2.3` | npm package with pinned version |
135
+ | `@scope/pkg` or `npm:@scope/pkg` | npm scoped package |
136
+ | `git:user/repo/path` | Raw specifier passed to esbuild; requires resolvable module |
137
+ | `github:user/repo/path` | Same as `git:` |
138
+ | `https://...`, `http://...`, path | Used as-is by esbuild |
139
+
140
+ ## Key Reference (use these names)
141
+
142
+ | Key | Means | Applies to | Note
143
+ |---|---|---|
144
+ | `width` | canvas width | root |
145
+ | `height` | canvas height | root |
146
+ | `fps` | frame rate | root |
147
+ | `theme` | *removed — use `style` on root* | root |
148
+ | `tts` | CLI template string (e.g. `edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"`) | root |
149
+ | `stt` | CLI template string (e.g. `whisper "{input}" --output_format vtt --output_dir "{output}"`) | root |
150
+ | `layout` | `series\|parallel\|transitionSeries` | root, scene |
151
+ | `transition` | `fade\|slide\|wipe\|flip\|clockWipe` | transitionSeries |
152
+ | `transitionTime` | seconds | transitionSeries |
153
+ | `src` | media path/URL (for image/video/audio/include) | leaves (not component) |
154
+ | `duration` | seconds | leaves |
155
+ | `start` | parallel-only offset | parallel children |
156
+ | `startFrom` | trim from source start | video, audio |
157
+ | `endAt` | trim at source position | video, audio |
158
+ | `volume` | 0–1 | video, audio, rhythm |
159
+ | `foreground` | bool; ducks parent video audio while playing | audio |
160
+ | `spots` | number[] beat timestamps | rhythm |
161
+ | `fit` | `contain\|cover\|fill` | image |
162
+ | `loop` | int >1 | audio |
163
+ | `playbackRate` | number | video |
164
+ | `jsx` | usage JSX expression (`"<ComA value={42} />"`); compiled at runtime with registered imports in scope | component |
165
+ | `effects` | `[name, name(params...)]` e.g. `[fadeIn, bounceIn(1, ease-out, 2)]` | any leaf/container | Apply animations directly — no wrapper node needed |
166
+ | `waypoints` | `[lat,lng,"label";...]` | map |
167
+ | `travelMode` | `DRIVING\|WALKING\|BICYCLING\|TRANSIT` | map |
168
+ | `routeColor` | hex color e.g. `"#FF5733"` | map |
169
+ | `routeWeight` | int (default 4) | map |
170
+ | `zoom` | int (default 10) | map |
171
+ | `center` | `{lat:n,lng:n}` JSON | map |
172
+ | `mapType` | `roadmap\|satellite\|hybrid\|terrain` | map |
173
+ | `routeMarker` | emoji string e.g. `"🚗"` | map |
174
+ | `title` | display title | scene |
175
+ | `instruction` | visual intent / style / any prompt; NOT rendered | any |
176
+ | `script` | narration/dialogue text; TTS source; NOT rendered directly | audio | Only on audio nodes — see Narration section below |
177
+ | `tts` | CLI template string; per-scene TTS override (overrides root `tts`) | root, scene |
178
+ | `metadata` | arbitrary metadata string | root |
179
+ | `stylesheet` | global CSS string; selectors use `.className` on elements | root |
180
+ | `style` | inline CSS applied to the node's container div e.g. `"border-radius:12px"` | any |
181
+ | `visible` | bool default true; `false` hides without removing | any |
182
+ | `isBackground` | bool; loops to fill parent duration; does NOT count toward container duration — use for BGM or looping bg imagery | any |
183
+ | `on` | `on:(when, state)` event spec; fires JS expression at a specific frame, mutating registered component state | any | See [Events](#events) section |
184
+ | `id` | unique string within parent scope | any |
185
+
186
+ ## Type Catalog
187
+
188
+ Each type below shows: when to use, required keys, markdown syntax.
189
+
190
+ ### `scene` (heading) — organizer (preferred)
191
+
192
+ When: always. `scene` is a container — scenes can nest inside other scenes via deeper headings (`##` → `###` → `####`).
193
+
194
+ Syntax:
195
+
196
+ ```md
197
+ ## <title>
198
+ layout:<x> [transition:<t> transitionTime:<n>] [instruction:..]
199
+ - <children>
200
+ ```
201
+
202
+ Scene metadata (layout, instruction, transition) goes on the line(s) immediately below the heading, before any child bullets. This keeps the heading clean. `name` comes from the heading text (must be a single token — no spaces). For multi-word titles, use key-value `title:"Long Title"` on the metadata line. `title` optionally follows ` - ` in the heading (e.g. `## Chapter1 - The Beginning` splits to name=`Chapter1`, title=`The Beginning`).
203
+
204
+ For narration, use the `- script "..."` audio alias as a child of the scene (see [Narration / TTS](#narration--tts) below).
205
+
206
+ ### `image`
207
+
208
+ When: photos, stills, title cards. Required: `src`, `duration`.
209
+
210
+ `- image src:cover.jpg duration:2 fit:cover`
211
+
212
+ ### `video`
213
+
214
+ When: moving footage. Required: `src` + (`duration` or `endAt`).
215
+
216
+ `- video src:clip.mp4 startFrom:1 endAt:4 volume:0.8`
217
+
218
+ ### `audio`
219
+
220
+ When: voiceover, BGM, SFX, TTS narration. Required: `src` + (`duration` or `endAt`).
221
+
222
+ `- audio src:bgm.mp3 duration:6 volume:0.4 loop:2`
223
+
224
+ ### Narration / TTS
225
+
226
+ Narration text is set via the `script` field on audio nodes. There are three ways to provide it:
227
+
228
+ **1. Inline `script:` attribute on an audio node**
229
+
230
+ ```md
231
+ - audio src:bg.mp3 duration:5 script:"Welcome to the course"
232
+ ```
233
+
234
+ **2. Standalone `- script "..."` bullet (alias for audio)**
235
+
236
+ This is a shorthand that creates an audio node with the `script` field. Supports all standard audio keys:
237
+
238
+ ```md
239
+ - script "Welcome to the course"
240
+ - script "Voiceover" volume:0.8 start:2 duration:5 foreground:true
241
+ ```
242
+
243
+ **3. Code fence `~~~script` on an audio node**
244
+
245
+ For longer text, use an indented code fence:
246
+
247
+ ```md
248
+ - audio src:bg.mp3 duration:10
249
+ ~~~script
250
+ This is a longer narration
251
+ that spans multiple lines.
252
+ ~~~
253
+ ```
254
+
255
+ All three patterns produce an `audio` node with a `script` field. The pipeline's TTS resolver (`resolveScripts`) picks up these nodes, generates speech audio files, and sets the `src` field to the output path. The `script` field is consumed by the resolver and is not present in the compiled stream tree.
256
+
257
+ ### `subtitle` (root-level overlay)
258
+
259
+ Subtitles are configured at the root level as a VTT overlay, not as tree nodes. Set via YAML frontmatter `subtitle:` or `root.subtitle` in JSON.
260
+
261
+ The `type` field selects an animated caption component from `remotion-subtitle`:
262
+
263
+ | Value | Component |
264
+ |---|---|
265
+ | *(omit)* | `Caption` — plain static text |
266
+ | `Bounce` | `BounceCaption` — bouncing entrance |
267
+ | `Fade` | `FadeCaption` — fade in |
268
+ | `Typewriter` | `TypewriterCaption` — typewriter reveal |
269
+ | `Colorful` | `ColorfulCaption` — rainbow text |
270
+ | `Glowing` | `GlowingCaption` — glow effect |
271
+ | `Neon` | `NeonCaption` — neon sign |
272
+ | `Zoom` | `ZoomCaption` — zoom in |
273
+
274
+ > **HTML in cue text**: Each cue's text is rendered via `dangerouslySetInnerHTML`, so you can use HTML tags with inline CSS to style individual words:
275
+ > ```vtt
276
+ > 00:00:01.000 --> 00:00:03.000
277
+ > The <span style="color:#ff6b6b;font-weight:bold">quick</span> brown <span style="font-style:italic">fox</span> jumps over the lazy dog
278
+ > ```
279
+ > The `Typewriter` caption animation correctly respects HTML tag boundaries (character reveal skips over tags, only animates visible text).
280
+
281
+ Each cue is rendered as a separate `<Sequence>` for optimal performance — inactive cues consume zero CPU.
282
+
283
+ See [JSON Descriptive](json-descriptive.md#subtitle-root-level-overlay) for the full field reference.
284
+
285
+ ## Effects on Any Node
286
+
287
+ Apply animations directly via the `effects` key on **any** node (leaf or container). No wrapper node needed.
288
+
289
+ ```md
290
+ - image src:hero.jpg duration:3 effects:[fadeIn]
291
+ - component duration:2 jsx:"<Title />" effects:[bounceIn]
292
+ - video src:clip.mp4 duration:4 effects:[fadeIn, slideInLeft]
293
+ ```
294
+
295
+ ### Parameterized syntax
296
+
297
+ Add positional parameters in parentheses — comma-separated.
298
+
299
+ ```md
300
+ - image src:card.jpg duration:3 effects:[fadeIn(1.5)]
301
+ - image src:card.jpg duration:3 effects:[fadeIn(1.5, ease-out)]
302
+ - image src:card.jpg duration:3 effects:[fadeIn(1.5, ease-out, 2)]
303
+ ```
304
+
305
+ Order: `(duration, timingFunction, iterationCount)`
306
+
307
+ | Position | Parameter | Example |
308
+ |---|---|---|
309
+ | 1st | `duration` (seconds) | `fadeIn(1.5)` |
310
+ | 2nd | `timingFunction` | `fadeIn(1.5, ease-out)` |
311
+ | 3rd | `iterationCount` | `fadeIn(1.5, ease-out, 2)` |
312
+
313
+ All parameters are optional — omit trailing ones.
314
+
315
+ ### Multiple effects
316
+
317
+ Effects are applied outermost-first (first in the array is the outermost wrapper):
318
+
319
+ ```md
320
+ - image src:hero.jpg duration:3 effects:[fadeIn, bounceIn]
321
+ ```
322
+
323
+ ### `component`
324
+
325
+ When: JSX expression rendered at runtime with registered imports in scope. Required for non-background components: `duration` . The `jsx` value can come from an inline attribute or an indented code fence.
326
+
327
+ Components must be registered via a `` ~~~js imports `` code block. Usage is via `jsx:"<TagName ... />"` on the component node.
328
+
329
+ ```md
330
+ ~~~js imports
331
+ import { StatCounter } from "stat-counter"
332
+ import { Logo } from "github:myorg/design#Logo"
333
+ ~~~
334
+
335
+ # JSX usage (references registered components as tags)
336
+ - component duration:1 jsx:"<StatCounter value={42} />"
337
+ ```
338
+
339
+ ### Code fence properties
340
+
341
+ Properties that are too long for a single line can be provided via an indented code fence under the bullet item. The fence language (`~~~<lang> <propName>`) specifies which property to set:
342
+
343
+ ```md
344
+ - component duration:4 isBackground:true
345
+ ~~~jsx jsx
346
+ <div style={{color:'#fff'}}>Hello</div>
347
+ ~~~
348
+
349
+ - video start:5 volume:0
350
+ ~~~prompt prompt
351
+ animation of a robot learning to walk, cinematic lighting
352
+ ~~~
353
+ ```
354
+
355
+ The fence syntax is `~~~<lang> <propName>`. If `propName` is omitted, it defaults to `lang`. Common patterns:
356
+
357
+ | Fence | Sets property | Use case |
358
+ |---|---|---|
359
+ | `~~~jsx jsx` or `~~~jsx` | `jsx` | Component JSX expression |
360
+ | `~~~prompt prompt` | `prompt` | TTI/TTV generation prompt |
361
+ | `~~~script script` or `~~~script` | `script` | Narration text on audio nodes |
362
+
363
+ ### `rhythm`
364
+
365
+ When: beat-synced audio (music drops, music-reactive reveals). Required: `src`, `spots`, `children`.
366
+
367
+ Each child is assigned to a beat slot: child[i] starts at `spots[i]`, ends at `spots[i+1]` (last child ends at the final beat). No `duration` field — it is derived from `spots` and children count.
368
+
369
+ ```md
370
+ - rhythm src:beat.mp3 spots:[0.5,1.2,1.9]
371
+ - image src:flash1.jpg
372
+ - image src:flash2.jpg
373
+ - image src:flash3.jpg
374
+ ```
375
+
376
+ ### `effect`
377
+
378
+ When: CSS keyframe animation wrapper. Required: `children`. `duration` falls back to children max.
379
+
380
+ `- effect animation:fadeIn duration:2` then indented children.
381
+
382
+ Built-in `animation` values:
383
+ `fadeIn fadeOut fadeInDown fadeInUp fadeInLeft fadeInRight fadeOutDown fadeOutUp fadeOutLeft fadeOutRight slideInDown slideInUp slideInLeft slideInRight slideOutDown slideOutUp slideOutLeft slideOutRight zoomIn zoomOut zoomInDown zoomInUp zoomInLeft zoomInRight bounce bounceIn rotateIn rotateOut rotateInDownLeft rotateInDownRight rotateInUpLeft rotateInUpRight flipInX flipInY pulse flash heartBeat rubberBand shakeX shakeY swing tada wobble jello rollIn rollOut jackInTheBox lightSpeedIn lightSpeedOut`
384
+
385
+ ### `map`
386
+
387
+ When: animated route. Required: `duration`, `waypoints`.
388
+
389
+ `- map duration:3 travelMode:DRIVING waypoints:[37.77,-122.41,"SF";34.05,-118.24,"LA"]`
390
+
391
+ ### `include`
392
+
393
+ When: embed an external markdown file as a sub-video. The sub-video is
394
+ fully resolved by the pipeline (TTS, media, includes, component imports).
395
+
396
+ The sub-video can have its own ` ```js imports ``` ` block — components
397
+ are bundled independently and available in an isolated `ComposeContext`
398
+ at render time (nested context, "sub-video wins" priority).
399
+
400
+ `- include src:./sub-video.md`
401
+
402
+ > **Note:** `duration` is optional — the pipeline compiles the sub-video
403
+ > to determine its real duration. The compiled JSON is cached at
404
+ > `.markcut/generated/includes/<hash>.json` so repeated runs are fast.
405
+ >
406
+ > Component imports from the sub-video are bundled to
407
+ > `.markcut/<sub-basename>/components/<hash>.js`.
408
+
409
+ ## Events
410
+
411
+ Fire a JavaScript expression at a specific frame to mutate a registered component's state. Useful for syncing UI state with narration beats.
412
+
413
+ ### Registering a component for events
414
+
415
+ Add an `id` attribute to a `component` node to register it in the global event context:
416
+
417
+ ```md
418
+ - component id:slide1 duration:4 jsx:"<Slide current={current}>{source}</Slide>"
419
+ ```
420
+
421
+ The `id` becomes the variable name available in event expressions.
422
+
423
+ ### Firing an event
424
+
425
+ Use `on:(when, state)` on any node (audio, video, image, component, scene, etc.):
426
+
427
+ ```md
428
+ - script "Narration 1" on:(start, slide1.current=0)
429
+ - script "Narration 2" on:(start, slide1.current++)
430
+ ```
431
+
432
+ The `when` argument selects the target frame, and `state` is a JavaScript expression evaluated with all registered component proxies in scope.
433
+
434
+ ### Supported `when` values
435
+
436
+ | Value | Fires at |
437
+ |---|---|
438
+ | `start` | Frame 0 (beginning of the node's timeline) |
439
+ | `end` | Last frame (end of the node's duration) |
440
+ | `50%` | 50% through the node's duration |
441
+ | `2.5s` | 2.5 seconds into the node (multiplied by root `fps`) |
442
+
443
+ Any percentage (`0%`–`100%`) or seconds value (`0s`, `1.5s`, `10s`) works.
444
+
445
+ ### `state` expression
446
+
447
+ Any valid JavaScript expression — assign values, increment counters, toggle booleans:
448
+
449
+ ```md
450
+ - script "Narration" on:(start, slide1.current=0)
451
+ - script "Beat2" on:(start, slide1.current++)
452
+ - script "Done" on:(end, slide1.visible=false)
453
+ ```
454
+
455
+ The expression is evaluated with all registered component IDs as scope variables. Each component variable is a Proxy whose property setter triggers a React re-render.
456
+
457
+ ### JSON form
458
+
459
+ In the compiled stream tree, events are represented as an `on` field on any node:
460
+
461
+ ```json
462
+ {
463
+ "type": "audio",
464
+ "src": "narration.mp3",
465
+ "duration": 3,
466
+ "on": { "when": "start", "state": "slide1.current=0" }
467
+ }
468
+ ```
469
+
470
+ ## Variants (Language / Platform / Any Override)
471
+
472
+ Produce different versions of the same video from a single markdown file
473
+ — for example, Chinese and English narration, portrait and landscape layouts,
474
+ or TikTok and YouTube versions.
475
+
476
+ ### How it works
477
+
478
+ The document has a **base section** (`# video`) that defines the default video.
479
+ Additional **variant sections** (`# zh`, `# portrait`, `# tiktok`) contain
480
+ root-level config overrides. Leaf nodes in the base section carry
481
+ variant-prefixed keys to override specific fields per variant.
482
+
483
+ ### 1. Define a variant section
484
+
485
+ A variant is a top-level `# <name>` heading. It can override root config
486
+ keys (like `tts`, `width`, `height`):
487
+
488
+ ```markdown
489
+ # video
490
+ layout:series width:1920 height:1080 fps:30
491
+ tts:"edge-tts --voice 'en-US-GuyNeural' --text '{input}' --write-media '{output}'"
492
+
493
+ ## Hook
494
+ - image duration:3 prompt:"..."
495
+
496
+ ## Title
497
+ - script "Welcome to the course"
498
+ - component jsx:"<Slide>{source}</Slide>"
499
+ ~~~md source
500
+ # Hello
501
+ ~~~
502
+
503
+ # zh
504
+ tts:"edge-tts --voice 'zh-CN-YunxiNeural' --text '{input}' --write-media '{output}'"
505
+ ```
506
+
507
+ The `# zh` section only needs the keys that differ from the base —
508
+ here the TTS voice is switched to Chinese. The video's width, height, fps,
509
+ scenes and all content remain inherited from `# video`.
510
+
511
+ ### 2. Override leaf values
512
+
513
+ Always write variant overrides on a **new indented line** under the bullet,
514
+ never inline on the same line. This keeps the base declaration clean and
515
+ makes variants easy to scan:
516
+
517
+ ```markdown
518
+ # video
519
+ - script "Welcome to the course"
520
+ zh:"欢迎来到本课程"
521
+ - component jsx:"<Slide>{source}</Slide>"
522
+ ~~~md source
523
+ ## English title
524
+ ~~~
525
+ ~~~md zh-source
526
+ ## 中文标题
527
+ ~~~
528
+ ```
529
+
530
+ Two mechanisms for overriding content per-variant on individual nodes:
531
+
532
+ **Variant-prefixed keys** (`zh-<key>`) — replace a specific field.
533
+ The prefix (`zh-`) matches the variant section name (`# zh`):
534
+
535
+ ```markdown
536
+ - component jsx:"<Slide>{source}</Slide>"
537
+ ~~~md source ← base value for key "source"
538
+ ## English title
539
+ ~~~
540
+ ~~~md zh-source ← overrides "source" when --variant zh is used
541
+ ## 中文标题
542
+ ~~~
543
+ ```
544
+
545
+ Here `zh-source` replaces `source` when the `zh` variant is active.
546
+ Code-fence-backed props (`~~~md source`, `~~~js jsx`) use variant-prefixed
547
+ keys the same way as inline attributes: the code fence's prop name gets
548
+ the variant prefix.
549
+
550
+ **Bare variant keys** (`zh`) — replaces the node's "primary content" field
551
+ (meaning depends on node type):
552
+
553
+ | Node type | Primary key | `zh` replaces |
554
+ |---|---|---|
555
+ | `audio` / `- script` | `script` | narration text |
556
+ | `component` | `jsx` | JSX expression |
557
+ | `image` | `src` | image path |
558
+ | `video` | `src` | video path |
559
+
560
+ ```markdown
561
+ - script "Welcome to the course"
562
+ zh:"欢迎来到本课程"
563
+ ```
564
+
565
+ Here `zh` replaces `script` on the audio node, switching the narration text
566
+ to Chinese when the variant is active.
567
+
568
+ ### 3. Run with a variant
569
+
570
+ ```bash
571
+ # Preview Chinese version
572
+ npx markcut preview courseware.md --variant zh
573
+
574
+ # Preview with multiple variants (e.g. Chinese + TikTok portrait)
575
+ npx markcut preview courseware.md --variant zh --variant tiktok
576
+
577
+ # Render Chinese version
578
+ npx markcut render courseware.md --variant zh --output zh-video.mp4
579
+ ```
580
+
581
+ ### Combined variants
582
+
583
+ Multiple `--variant` flags are applied in order. Each subsequent variant
584
+ can override values set by a previous one. The directory is named with
585
+ all variants joined by `-`:
586
+
587
+ ```bash
588
+ npx markcut preview courseware.md --variant zh --variant tiktok
589
+ # Uses .markcut/courseware/zh-tiktok/ for variant artifacts
590
+ ```
591
+
592
+ ### Root config override priority
593
+
594
+ When a variant section provides a root-level key (e.g., `tts`), it merges
595
+ into the base config. Scene-level `tts` overrides root-level `tts`,
596
+ and the `ttsCli` option (if provided) overrides both.
597
+
598
+ ### How overrides are resolved
599
+
600
+ 1. The parser extracts `# video` as the base root and each `# <name>` as a
601
+ variant config map.
602
+ 2. When `--variant zh` is used, the `# zh` section's root keys are merged
603
+ into the base root (e.g., `tts` is replaced).
604
+ 3. `resolveVariantOverrides` walks the entire tree: for each node,
605
+ variant-prefixed keys (like `zh-src`) replace their unprefixed
606
+ counterparts. Bare variant keys (like `zh`) replace the node's
607
+ primary content key (see table above).
608
+ 4. All `<variant>-*` keys and bare variant keys are stripped from the
609
+ output — the compiled tree contains only the resolved values.
610
+ 5. The pipeline then runs TTS/STT/media generation as usual with the
611
+ overridden config and content.
612
+
613
+ ### Directory layout for variant artifacts
614
+
615
+ ```
616
+ .markcut/
617
+ ├── generated/ ← shared, content-addressed
618
+ │ ├── tts/ ← TTS audio (same script → same file)
619
+ │ ├── media/ ← TTI/TTV images/videos
620
+ │ └── includes/ ← compiled sub-video JSON
621
+ ├── courseware/ ← per-source-file
622
+ │ ├── components/ ← component bundles (shared)
623
+ │ ├── compiled.json ← default (en) compiled output
624
+ │ └── zh/
625
+ │ ├── compiled.json ← zh variant compiled output
626
+ │ └── subtitles.vtt ← zh variant subtitles
627
+ ```
628
+
629
+ TTS audio files are content-addressed (hash of `script + CLI`), so
630
+ identical scripts across variants produce the same file. The merged
631
+ `subtitles.vtt` is per-variant because script content and timing offsets
632
+ differ.
633
+
634
+ ## Timing Rules
635
+
636
+ 1. `duration` authoritative for non-trimmed leaves.
637
+ 2. `video`/`audio` with `startFrom`+`endAt`: clip = `endAt − startFrom`.
638
+ 3. `start` only in `parallel`.
639
+ 4. `transitionTime` subtracted between `transitionSeries` items.
640
+ 5. Containers derive duration from children (parallel=max, series=sum, ts=sum−overlap).
641
+
642
+ ## Generation Workflow
643
+
644
+ 1. Root: `# video` + `width:<n> height:<n> fps:<n> layout:<mode>` on the next line.
645
+ 2. Frontmatter (optional): `---` block for root attrs + tts/stt pipeline config + `stylesheet`.
646
+ 3. Component registrations: `` ~~~js imports `` block for external components.
647
+ 4. Scenes via `##` with `layout:` metadata on the line below.
648
+ 5. Leaves as `- type key:value ...` bullets indented under scenes.
649
+ 6. Long values (JSX, prompts, scripts) use indented `~~~<lang> <propName>` code fences.
650
+ 7. Verify each leaf has resolvable `duration`.
651
+
652
+ ## Tween Animation
653
+
654
+ Animate numeric props over time by using `tween(from?, to, easing?)` expressions inside JSX usage expressions. Tweens resolve at render time using Remotion's `interpolate()`.
655
+
656
+ ### Usage
657
+
658
+ Call `tween(from, to)` directly as a function inside the usage expression — it's available in the compiled scope:
659
+
660
+ ```md
661
+ - component duration:4 jsx:"<BarChart data={[{name:'A',value:tween(0,80)},{name:'B',value:tween(0,60)}]}/>"
662
+ ```
663
+
664
+ You can also use `tween()` in inline SVG:
665
+
666
+ ```md
667
+ - component duration:4 jsx:"<svg viewBox='0 0 400 300'><rect y={260-tween(0,200)} width={80} height={tween(0,200)} fill='#E38627' /></svg>"
668
+ ```
669
+
670
+ ### Supported syntax
671
+
672
+ ```
673
+ tween(to) — 0 → to, linear
674
+ tween(from, to) — from → to, linear
675
+ tween(from, to, easeOut) — with easing
676
+ tween(from, to, spring) — spring animation
677
+ tween(#000, #FFF) — color interpolation
678
+ ```
679
+
680
+ ### Important notes
681
+
682
+ - Tween values only work on `component` nodes.
683
+ - Frame range is derived from the component's `duration` (minus `start` offset).
684
+ - `tween()` uses Remotion's `interpolate()` with the component's action duration.
685
+ - At frame 0, `tween(from, to)` returns `from`.
686
+
687
+ ## Self-Check
688
+
689
+ - [ ] Root has `width`, `height`, `fps`, `layout`.
690
+ - [ ] Every scene has ≥1 child or `script`.
691
+ - [ ] All values use explicit `key:value` syntax (no bare tokens).
692
+ - [ ] `start` only used inside `parallel` containers.
693
+ - [ ] No `src` on component nodes (use `jsx:` instead).
694
+ - [ ] Component registrations use `` ~~~js imports `` block — the ONLY supported method.
695
+ - [ ] Every component `jsx:` references a name registered in `` ~~~js imports ``.
696
+ - [ ] Every `jsx:` on a component node is a usage expression (JSX tag), not a definition.
697
+ - [ ] Inline component definitions go inside `` ~~~js imports `` as `export function Name(...) { ... }`.
698
+ - [ ] Scene names are single tokens (no spaces) — use `title:"..."` for multi-word titles.
699
+ - [ ] Code fence properties are indented under their parent bullet.
700
+ - [ ] Event targets (`id:name` on component) match the `id` used in `on:(when, id.prop=value)` expressions.
701
+
702
+ ## Validation with CLI
703
+
704
+ Use `markcut verify` to parse and validate a descriptive markdown file without rendering:
705
+
706
+ ```bash
707
+ markcut verify courseware.md
708
+ ```
709
+
710
+ ## Example
711
+
712
+ ```md
713
+ ---
714
+ width: 1080
715
+ height: 1920
716
+ fps: 30
717
+ ---
718
+ # video
719
+ layout:series
720
+ ~~~js imports
721
+ export { StatCounter } from "stat-counter"
722
+ export { Logo } from "github:myorg/design-system#Logo"
723
+
724
+ export function Greeting({ name }) {
725
+ return <div style={{color: '#fff', fontSize: 28, textAlign: 'center'}}>Hello {name}!</div>
726
+ }
727
+ ~~~
728
+
729
+ ## Hook
730
+ layout:parallel
731
+ - script "Set location and emotional tone"
732
+ - image src:cover.jpg duration:2
733
+
734
+ ## Journey
735
+ layout:transitionSeries transition:fade transitionTime:0.4
736
+ - script "Move through moments"
737
+ - video src:clips/arrival.mp4 startFrom:0 endAt:3.5
738
+ - video src:clips/fire.mp4 startFrom:1 endAt:4
739
+
740
+ ## Stat
741
+ layout:parallel
742
+ - component duration:2 jsx:"<StatCounter value={42} label='S-mores' />"
743
+
744
+ ## Logo
745
+ layout:parallel
746
+ - component duration:1 jsx:"<Logo />"
747
+
748
+ ## Route
749
+ layout:parallel
750
+ - map duration:3 travelMode:DRIVING waypoints:[37.7749,-122.4194,"SF";34.0522,-118.2437,"LA"]
751
+ ```