Imported from lalalic/markcut (
AGENTS.md). Install upstream withnpx skills add lalalic/markcut. Copyright stays with the author.
markcut — AGENT.md
Markdown-to-video engine. Describe scenes in markdown, get a rendered video with TTS narration.
Quick Start
npm run render storyboard.md # render markdown → MP4 (9x16 default)
npm run preview storyboard.md --edit # live preview with auto-reload
npm run preview storyboard.md --storyboard # fast structure preview (skips TTI/TTV, shows prompts as placeholders)
npm test # unit + integration tests
npm run typecheck # TypeScript type check
Input formats
| Format | CLI | Description |
|---|---|---|
| Markdown | markcut render storyboard.md |
## Scene headings, - image src:... bullets, script:"narration" |
| Descriptive JSON | markcut render video.json |
Same schema as markdown, JSON syntax |
| Compiled JSON | markcut render tree.json |
Pre-compiled stream tree (no pipeline needed) |
See docs/markdown-strict-descriptive.md for the complete markdown syntax reference.
Key concepts
- Scene — narrative unit with
name,script(TTS),instruction(visual intent),children - Layout —
parallel(simultaneous),series(sequential),transitionSeries(sequential with transitions) - Script → TTS → STT —
scriptfield generates edge-tts audio, whisper transcribes to VTT subtitles - Theme — preset colors/fonts/effects:
cinematic,neon,minimal,corporate - TTL config — root-level
tts.cli,tts.voice,tts.ratewith per-scenettsoverrides - Subtitle — root-level VTT overlay from merged per-clip STT. Not a tree node. Supports animated caption types (
typewriter,fade,bounce,glowing, etc.) viaremotion-subtitle, HTML in cue text, and per-cue<Sequence>rendering for zero CPU when inactive.
Stream types
| Type | Purpose |
|---|---|
root |
Canvas config: width, height, fps, theme, subtitle, stylesheet |
scene |
Storyboard container: name, layout, script, children |
folder |
Internal container (series/parallel) |
image |
Still photo with fit mode |
video |
Video clip with startFrom/endAt trimming, playbackRate |
audio |
Soundtrack/SFX with foreground ducking |
component |
External React component by componentName + props |
effect |
CSS keyframe animation wrapper (fadeIn, zoomIn, bounceIn, etc.) |
include |
Embed external video JSON (file, URL, or data URI) |
CLI
markcut render <file.json|.md> [--output path]
markcut preview <file.json|.md> [--edit] [--label] [--port 3001]
Project structure
src/
├── entry.tsx → MarkCut + DescriptiveComposition
├── descriptive/ → Compiler, markdown parser, resolve pipeline
├── types/ → React renderers (one per stream type)
├── schema/ → Zod stream tree schemas
├── themes/ → Theme presets + ThemeProvider
├── render/cli.mjs → CLI entry
├── render/tts.ts → TTS via CLI template + variable substitution
├── player/pipeline.mjs → Bundled pipeline (server imports this)
├── player/server.mjs → Unified server (--edit, --label, preview)
├── player/components/ → React control components (edit, label, variant bar)
│ ├── index.ts
│ ├── EditControls.tsx
│ ├── LabelControls.tsx
│ └── VariantBar.tsx
└── tests/ → Vitest integration tests
External component contract
Register components at render time:
<MarkCut
root={descriptiveJson}
compose={{ components: { AnimatedHeadline, StatCounter } }}
/>
In descriptive markdown:
- component componentName:StatCounter duration:3 props:{value:42}
Components receive props as defined in the descriptive JSON. No eval, no JSX parsing — just React.createElement(registry[name], props).
## Stream Tree Architecture
### Core Concept
Everything is a **stream tree** — nested JSON nodes keyed by `type`.
The tree is validated by Zod and rendered by Remotion via recursive React components.
```json
{ "id":"root", "type":"root", "width":1080, "height":1920, "fps":30,
"isSeries":true, "transition":"fade", "children":[...] }
Composition Rules
| Concept | Rule |
|---|---|
Series (isSeries:true) |
Children play sequentially. Optional transition (fade/slide/wipe/flip/clockWipe) + transitionTime (default 0.5s). |
Parallel (isSeries:false) |
Children play simultaneously. Duration = max child duration. |
Background (isBackground:true) |
Loops for parent duration. Excluded from duration calculation. |
| Timing | Every leaf carries base fields start/end (seconds, relative to parent) + optional startFrom/endAt (source trim) + duration (convenience). end is the source of truth; duration normalizes to end = start + duration when end is absent. Inside series parents start is 0 (positioning is implicit). |
Scene (type:"scene") |
UI storyboard card; engine treats as folder. Has name + description + script. |
Duration Calculation
Computed by getDurationInSeconds() in src/utils/index.ts:
- Leaf nodes: duration =
leafEnd(stream)=end ?? (start + duration)(single deterministic read;start/end/durationlive on the base node) - Series: sum of children durations minus transition overlaps
- Parallel: max of children durations
- Background children: excluded from calculation
- Include with
src: treated as leaf (duration from baseend) - Sets
durationInSecondson every node as a side effect
12 Stream Types
All types share base fields from BaseShape: id, name, title, description, script, src, style, visible, isBackground, start, end, duration, startFrom, endAt, durationInSeconds.
| Type | Purpose | Key Fields |
|---|---|---|
root |
Canvas container | width, height, fps, isSeries, transition, stylesheet |
folder |
Group children | isSeries, transition, children[] |
scene |
Storyboard node (alias for folder) | name, description, script, children[] |
image |
Still photo | src, fit (contain/cover/fill) |
video |
Video clip | src, volume, playbackRate |
audio |
Soundtrack/SFX | src, volume, foreground (ducks parent) |
subtitle |
Text overlay | src (text/VTT) or cues[], fontSize, style |
component |
Built-in <Markdown> |
Renders markdown with GFM. Props: source/children, plugins, components |
component |
Built-in <Mermaid> |
Renders Mermaid diagrams as inline SVG. Props: source/children, theme |
component |
React component | componentName, props, src (remote ESM) |
effect |
CSS animation wrapper | animation, customKeyframes, children[] |
rhythm |
Beat-synced loop | src, spots[], children[] |
map |
Route visualization | waypoints[{lat,lng,label}], routeColor |
include |
Embed sub-tree | src (path/URL/data URI), children[] |
Key Implementation Details
Folder.tsx — Series/Parallel Engine
FolderLeafis the recursive workhorse that renders all stream types- For
isSeries:true: uses Remotion<Series>or<TransitionSeries>(if transition set) - For
isSeries:false: renders children as parallel<Sequence>elements - Each child's duration comes from
child.durationInSeconds(set bygetDurationInSeconds) - Transition elements are interleaved between series children
- Background children are wrapped in
<Loop> - Scene nodes delegate to
SceneLeaf→FolderLeaf(identity) - Effect nodes delegate to
EffectWrapper→FolderLeaf
Video.tsx — startFrom/endAt Trimming
- Uses Remotion
<OffthreadVideo>for CPU-efficient video decoding startFrom/endAton the base node trim the source video (in seconds)playbackRateis capped at 1 (Math.min(1, ...)) so source longer than timeline plays at normal speed and is truncated by the Sequence- Critical formula:
OffthreadVideo endAt = startFrom_frames + ((endAt_source - startFrom_source) * fps / playbackRate)This setsendAtbeyond the actual source end whenplaybackRate < 1, which prevents Remotion from rendering blank frames before the Sequence ends. endAtwithout explicit value falls back tostream.durationInSeconds ?? end - start
Rendering Pipeline
Root.tsxreadsgetInputProps()→ parses via ZodrootSchemagetDurationInSeconds()stampsdurationInSecondson every nodeComposition.durationInFramesis set to total durationMarkCutwraps everything inComposeContext+ThemeProvider+AbsoluteFillFolderLeafrecursively renders the tree
CLI Usage
node src/render/cli.mjs render <file.json> [--output path]
node src/render/cli.mjs render --template <id> --data <data.json>
node src/render/cli.mjs preview <file.json> [--edit] [--label] [--port 3001]
node src/render/cli.mjs templates
Player Server (Edit Mode)
--editflag starts a live-reload loop: edit JSON → player auto-refreshes--labelflag starts a labeling UI for selecting and annotating media--portflag sets the server port (default 3001)- Unified server:
server.mjsserves all modes (preview, edit, label). React control components incomponents/. - Persistent AI agent via
pi --mode rpc(one cold start, many edits): at boot,startAgentProcess()spawns the configured agent CLI (DEFAULT_AGENT_CLI) with--mode rpcappended (the-p/--promptflag is stripped — prompts arrive over stdin). Each/api/editPOST writes{"id","type":"prompt","message"}to stdin and resolves on the streamedagent_settledevent (NOT idle-timeout guessing — pi's default interactive mode is a TUI, not a stdin REPL, so text/stdin mode does not work). Assistant text is collected frommessage_endevents (message.role==="assistant", text content blocks).--session-id(derived from the file path) resumes conversation history from disk across restarts. Do not revert to one-shotspawnper edit — that re-incurs the full cold-start every request.
Testing
Test Framework
- Vitest with custom config in
tests/vitest.config.ts - Non-parallel (
pool: "forks", fileParallelism: false) - 600s timeout per test (integration tests render real MP4s)
Key Test Utilities (tests/utils.ts)
| Function | Purpose |
|---|---|
renderFixture(path, opts) |
Render a stream tree JSON → MP4 using npx remotion render |
renderScenes(path, opts) |
Render scene-based video.json using Main16x9 composition |
getVideoInfo(path) |
Extract metadata via ffprobe (duration, dimensions, FPS, audio) |
extractFrame(video, time, out) |
Extract PNG frame at timestamp via ffmpeg |
isFrameNonBlank(path) |
Check center pixel isn't black (>10 in any RGB channel) |
getFrameFileSize(path) |
Get PNG file size (proxy for visual content) |
extractAudio(video, out) |
Extract audio track to WAV |
transcribeAudio(path) |
Run whisper STT on audio |
Test Categories (30 tests)
- Basic Rendering — empty composition, dimension verification
- Image + Subtitle — image rendering, inline/VTT/karaoke subtitles
- Effects — fadeIn, bounceIn, custom keyframes
- Map Rendering — canvas route visualization
- Audio Rendering — audio track, non-silent verification
- Include — nested composition via include
- Full Feature Combination — multiple features
- Scene Node — scene-based rendering
- Audio STT — speech-to-text verification via whisper
- Multiple Aspect Ratios — 16x9, 9x16, 1x1
- Cross-Stream Types — parallel rendering, effect-wrapped children
- Frame-Accurate — timeline consistency, frame count
- Description Field — schema validation, render preservation
- Scene as Folder Alias — scene/folder interchangeability
- Video startFrom/endAt Trimming — video trimming + photo in series
Running Tests
npm test # all tests
npm run test:integration # render integration tests only
npx vitest run tests/render.test.ts -t "Test Name" # single test
Adding a new built-in component
Built-in components are always available in jsx:"..." fields without imports. To add one:
- Create the component in
src/components/<Name>.tsx— a standard React component. - Export from barrel — add the re-export in
src/components/index.tsand add the name toBUILTIN_COMPONENT_NAMESinsrc/components/builtin-registry.ts.
3-Level Authoring Workflow (see SKILL.md)
| Level | Format | Purpose |
|---|---|---|
| Label | labels.json |
Browse media, label clips, add descriptions |
| Storyboard | storyboard.json |
scene nodes with high-level structure + script |
| Assemble | video.json |
Full stream tree with all types, render to MP4 |
Script → TTS → STT → VTT Pipeline
Every node carries an optional script field for narration/dialogue text. The pipeline:
- Storyboard — agent writes
scripton any node (scene, folder, leaf) as narrative text - TTS — walk the tree, collect all
scriptvalues with node id + timing, generate WAV audio per segment viaedge-tts - STT — transcribe each TTS audio or concatenated audio to VTT via whisper
- Subtitle — attach resulting VTT as a
subtitlechild node for final rendering
This separates concerns: script = authoring text (human/agent-friendly), subtitle = rendered output (timed VTT cues).
Multi-turn Dialogue
When script text contains multiple SpeakerName: text lines (2+), the pipeline auto-expands it into a multi-turn dialogue. Each line becomes a separate audio node with speaker field, wrapped in a series container for sequential playback.
- script "Ray: Hello everyone
Alice: Good day to you
Ray: Let's begin"
Per-speaker voices are configured via voices on root. Each value is extra CLI flags appended to the TTS template:
voices:{"Ray":"--voice en-US-GuyNeural","Alice":"--voice en-US-JennyNeural"}
The speaker name is matched against voices map and the extra flags are appended to the TTS CLI template, naturally supporting voice cloning or edge-tts features (rate, pitch, etc.). Subtitles include the speaker prefix (Ray: Hello everyone).
Dependencies
- Remotion 4.0.469 — core rendering engine
- React 19 — component model
- Zod 4 — schema validation
- @remotion/transitions — scene transitions (fade, slide, wipe, flip, clockWipe)
- @vis.gl/react-google-maps — map visualization
- immer — state management (player app)
- vitest — test runner
- TypeScript 5.6 — strict mode with
noUncheckedIndexedAccess