@lalalic/markcut 1.0.1 → 1.2.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.
- package/AGENTS.md +2 -0
- package/SKILL.md +28 -172
- package/docs/markdown-descriptive.md +12 -0
- package/package.json +3 -3
- package/src/entry.tsx +8 -2
- package/src/render/cli.mjs +88 -38
- package/src/render/cli.test.ts +13 -0
- package/src/utils/component-import-map.ts +93 -0
- package/templates/courseware/TEMPLATE.md +311 -0
- package/templates/courseware/agents/reviewer.md +84 -0
- package/templates/courseware/prompts/fix.md +30 -0
- package/templates/courseware/prompts/outline.md +53 -0
- package/templates/courseware/prompts/scene.md +64 -0
- package/docs/dynamic-components.md +0 -191
- package/docs/templates.md +0 -52
package/AGENTS.md
CHANGED
|
@@ -240,6 +240,8 @@ npx vitest run tests/render.test.ts -t "Test Name" # single test
|
|
|
240
240
|
4. **`signalstats` ffmpeg filter**: Doesn't output YAVG on all systems; use file size as proxy for visual content.
|
|
241
241
|
5. **Asset paths**: Local files are resolved via `staticFile()` — files go in `public/` and are referenced without the `public/` prefix.
|
|
242
242
|
6. **Map rendering**: Depends on external tile availability; may produce blank frames offline.
|
|
243
|
+
7. **CLI md render needs three things preview gets for free**: (a) `parseMarkdownVariants(raw)` (returns `{base, variants}`) — `parseMarkdownDescriptive` returns the root directly; (b) bundling the ` ```js imports ``` ` block via `bundleFromEntries` and setting `root.imports` (otherwise jsx components are unregistered → slides render as unstyled black frames); (c) staging `.markcut/` assets into `public/.render-assets/` and rewriting srcs to relative paths (remotion render only serves its publicDir). All three live in `src/render/cli.mjs`; the import-map shim for shared react/remotion instances is `src/utils/component-import-map.ts` (must stay in sync with `player/browser.tsx` + `bundler.mjs getSharedExternals`).
|
|
244
|
+
8. **Publishing**: verify `npx @lalalic/markcut render <md>` from a clean temp dir before/after publish — v1.1.1 shipped with cli.mjs/pipeline.mjs out of sync and md rendering was completely broken. Also beware stale npx caches (`~/.npm/_npx`) silently running old code.
|
|
243
245
|
|
|
244
246
|
## 3-Level Authoring Workflow (see SKILL.md)
|
|
245
247
|
|
package/SKILL.md
CHANGED
|
@@ -1,194 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: markcut
|
|
3
|
-
description:
|
|
4
|
-
Compose and render videos from streamline trees. CLI renders stream tree
|
|
5
|
-
to MP4. Run via `npx markcut` — no install, no code.
|
|
3
|
+
description: use markdown to describe stream trees, provide CLI to render stream trees to video via `npx @lalalic/markcut`. use it to structure video scenes, and generate TTS, TTI, TTV, STT media automatically.
|
|
6
4
|
---
|
|
7
5
|
|
|
8
6
|
## Stream Tree Specs
|
|
9
7
|
|
|
10
|
-
Everything video is a **stream tree
|
|
8
|
+
Everything video is a **stream tree** described with markdown. see [docs/markdown-descriptive.md](docs/markdown-descriptive.md) for full details.
|
|
11
9
|
|
|
12
|
-
## .markcut/ Directory Layout
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
## video design utitlies
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
generated/ ← shared, content-addressed
|
|
19
|
-
tts/ ← TTS audio (content-hash filenames)
|
|
20
|
-
media/ ← TTI/TTV media (content-hash filenames)
|
|
21
|
-
includes/ ← compiled sub-video JSON (content-hash)
|
|
22
|
-
<basename>/ ← per-source-file artifacts
|
|
23
|
-
components/ ← component bundles (per-file: imports differ)
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
- `generated/` — shared artifacts using content-hash filenames, so identical scripts/prompts across different source files reuse the same cached file
|
|
27
|
-
- `<basename>/components/` — per-file component bundles; each source file has its own `imports` block, so bundles live under its basename
|
|
28
|
-
- The server serves `.markcut/` as a document root, so components at `.markcut/slides/components/abc.js` are served as `/slides/components/abc.js`
|
|
29
|
-
|
|
30
|
-
## Include (Sub-Video Embedding)
|
|
31
|
-
|
|
32
|
-
The `include` node embeds an external `.md` file as a sub-video. The pipeline fully resolves it recursively:
|
|
33
|
-
|
|
34
|
-
1. **`resolveIncludes()`** (step 0 of `resolveAll`): reads the referenced `.md`, parses it, resolves its own includes/TTS/media, compiles to a stream tree, writes the compiled JSON to `.markcut/generated/includes/<hash>.json`
|
|
35
|
-
2. **`resolveIncludeImports()`** (server post-compile): if the sub-video has a ` ```js imports ``` ` block, bundles its components independently and writes the bundle URL into the compiled JSON
|
|
36
|
-
3. **`IncludeLeaf`** (render): loads the compiled JSON, dynamically imports the sub-video's component bundle, creates a nested `ComposeContext` with the sub-video's registry merged, and renders via `FolderLeaf`
|
|
37
|
-
|
|
38
|
-
The sub-video's own components are isolated from the parent's — naming conflicts are resolved by "sub-video wins" priority.
|
|
39
|
-
|
|
40
|
-
```markdown
|
|
41
|
-
# video
|
|
42
|
-
## Main
|
|
43
|
-
- component duration:3 jsx:"<MainTitle />"
|
|
44
|
-
## Embedded
|
|
45
|
-
- include src:./sub-video.md
|
|
46
|
-
## End
|
|
47
|
-
- component duration:2 jsx:"<Outro />"
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
```markdown
|
|
51
|
-
# sub-video
|
|
52
|
-
- component duration:3 jsx:"<SubSlide scene='1' />"
|
|
53
|
-
|
|
54
|
-
```js imports
|
|
55
|
-
export function SubSlide({ scene }) {
|
|
56
|
-
return <div style={{background:'#667eea', ...}}>Scene {scene}</div>;
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
```
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## Video Design Best Practice (How-To)
|
|
63
|
-
|
|
64
|
-
The engine supports four authoring phases. Each has a dedicated doc with full detail.
|
|
65
|
-
|
|
66
|
-
### Label — annotate clips in a stream tree
|
|
67
|
-
|
|
68
|
-
Load a stream tree, preview each clip, and attach descriptive labels.
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
npx markcut preview <flat stream tree file> --label
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Labels save to `labels.json`. See [docs/label-mode.md](docs/label-mode.md) for:
|
|
75
|
-
- Label player UI (thumbnails, player, label input)
|
|
76
|
-
- Labels persistence format
|
|
77
|
-
- Full workflow diagram
|
|
13
|
+
### User labels — label media with text, time ranges
|
|
14
|
+
- `uvx @lalalic/markcut preview --label` provides tool to label video and images with text.
|
|
78
15
|
|
|
79
16
|
### Storyboard — plan video structure with scene nodes
|
|
80
17
|
|
|
81
|
-
Use `scene` nodes to organize your video.
|
|
18
|
+
- Use `scene` nodes to organize your video. Scenes can nest inside other scenes.
|
|
19
|
+
- Use `description`, `scene.instruction`, `script`, `image|video.prompt` to structure your video content.
|
|
82
20
|
|
|
83
|
-
```bash
|
|
84
21
|
see [docs/markdown-descriptive.md](docs/markdown-descriptive.md) for full details.
|
|
85
22
|
|
|
86
|
-
###
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
npx markcut render <stream tree file> --aspect all # MP4 output
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
---
|
|
93
|
-
|
|
94
|
-
## Template Variables
|
|
95
|
-
|
|
96
|
-
`${width}`, `${height}`, `${fps}`, `${variant}` can be used in `src`,
|
|
97
|
-
`prompt`, and `stylesheet` values. They resolve to the root config values.
|
|
23
|
+
### Video Variants
|
|
24
|
+
- define variants for different video configurations, such as different languages, aspects, platforms
|
|
98
25
|
|
|
99
|
-
```markdown
|
|
100
|
-
# video
|
|
101
|
-
width:1920 height:1080
|
|
102
|
-
## Scene
|
|
103
|
-
- image src:photo_${width}x${height}.jpg
|
|
104
|
-
- component jsx:"<h1>${width}</h1>" ← NOT resolved (jsx excluded)
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
> Only `src`, `prompt`, and `stylesheet` support template variables.
|
|
108
|
-
> Root config keys, jsx, script, style, etc. do not.
|
|
109
|
-
|
|
110
|
-
## Frontmatter
|
|
111
|
-
|
|
112
|
-
The YAML `---` block is **metadata only**. It does NOT affect video config.
|
|
113
|
-
All configuration comes from the root config line after `# video`:
|
|
114
|
-
|
|
115
|
-
```markdown
|
|
116
|
-
---
|
|
117
|
-
title: My Campaign
|
|
118
|
-
---
|
|
119
|
-
# video
|
|
120
|
-
width:640 height:480 fps:30 layout:series tts:"edge-tts --voice '...' --text '{input}' --write-media '{output}'"
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
## AI Media Generation (TTS / STT / TTI(text-to-image) / TTV(text-to-video))
|
|
124
|
-
|
|
125
|
-
All four pipelines are configured via a **single CLI string** on the root config line. The user/LLM embeds every tool-specific parameter directly in the string — only `{input}` and `{output}` are substituted by the engine.
|
|
126
|
-
|
|
127
|
-
Default CLI templates (used when not overridden on the root config line):
|
|
128
|
-
|
|
129
|
-
| Config | Default |
|
|
130
|
-
|--------|---------|
|
|
131
|
-
| `tts` | `uvx edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"` |
|
|
132
|
-
| `stt` | `uvx --from openai-whisper whisper "{input}" --output_format vtt --output_dir "{output}"` |
|
|
133
|
-
| `tti` | `uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 5 --prompt "{input}" --output "{output}"` |
|
|
134
|
-
| `ttv` | (uses TTI to create an image, then ffmpeg to loop it for 5s) |
|
|
135
|
-
|
|
136
|
-
Override on the root config line:
|
|
137
|
-
|
|
138
|
-
```markdown
|
|
139
|
-
# video
|
|
140
|
-
width:1920 height:1080 tts:"uvx edge-tts --voice 'zh-CN-YunxiNeural' --text '{input}' --write-media '{output}'"
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
---
|
|
144
26
|
|
|
145
27
|
## 3. CLI
|
|
146
28
|
|
|
147
29
|
```bash
|
|
148
|
-
npx markcut <command> [options]
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
### Commands
|
|
152
|
-
|
|
153
|
-
| Command | Description |
|
|
154
|
-
|---------|-------------|
|
|
155
|
-
| `render <file>` | Render stream tree to MP4 |
|
|
156
|
-
| `preview <file> --label` | Label clips in a simplified stream tree |
|
|
157
|
-
| `preview <file> --edit` | Live editing loop (auto-reload on file change) |
|
|
158
|
-
| `verify <file>` | Validate descriptive file + check imports |
|
|
159
|
-
### Options
|
|
160
|
-
|
|
161
|
-
| Flag | Values | Default |
|
|
162
|
-
|------|--------|---------|
|
|
163
|
-
| `--aspect` | `16x9` / `9x16` / `1x1` / `all` | `16x9` |
|
|
164
|
-
| `--output` | path | `out/video-{aspect}.mp4` |
|
|
165
|
-
| `--port` | number | `3001` |
|
|
166
|
-
| `--verbose` | flag | `false` (compact progress) |
|
|
167
|
-
| `--compile` | flag | `false` (resolve only) |
|
|
168
|
-
| `--script-output-dir` | dir | `.markcut/generated/tts/` |
|
|
169
|
-
| `--media-output-dir` | dir | `.markcut/generated/media/` |
|
|
170
|
-
|
|
171
|
-
> Default output dirs auto-resolve to `.markcut/<basename>/<type>/` relative to the source file. You only need `--script-output-dir` / `--media-output-dir` to override.
|
|
172
|
-
|
|
173
|
-
### Edit Mode for Agents
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
# Start player in background
|
|
177
|
-
npx markcut preview <stream tree file> --edit --port 3001 &
|
|
178
|
-
|
|
179
|
-
# Player auto-opens browser. Agent edits JSON → player auto-reloads.
|
|
180
|
-
# User clicks ✕ → server exits → agent regains control.
|
|
181
|
-
# Browser feedback input writes to feedback.txt.
|
|
30
|
+
npx @lalalic/markcut <command> [options]
|
|
31
|
+
npx @lalalic/markcut --help # get overall information
|
|
182
32
|
```
|
|
183
33
|
|
|
184
34
|
---
|
|
185
35
|
|
|
186
36
|
## self verification
|
|
187
37
|
some common issues (photo or video can't be displayed, audio missing), take below actions to verify
|
|
188
|
-
### preview
|
|
189
|
-
- screenshot some key frames, and understand image to verify
|
|
38
|
+
### preview
|
|
39
|
+
- take screenshot for some key frames in player, and understand image to verify intent
|
|
40
|
+
|
|
190
41
|
### final video
|
|
191
|
-
- screenshot some key frames, and understand image to verify
|
|
42
|
+
- screenshot some key frames, and understand image to verify intent
|
|
192
43
|
- stt the final video audio, and verify if vtt result is correct
|
|
193
44
|
|
|
194
45
|
|
|
@@ -197,13 +48,18 @@ some common issues (photo or video can't be displayed, audio missing), take belo
|
|
|
197
48
|
| Topic | File |
|
|
198
49
|
|-------|------|
|
|
199
50
|
| Markdown descriptive format (primary authoring format) | [docs/markdown-descriptive.md](docs/markdown-descriptive.md) |
|
|
200
|
-
| **Video Templates** — ready-to-use markdown for common scenarios | **[docs/templates/](docs/templates/)** |
|
|
201
|
-
| ┣ Courseware / 课件 | [docs/templates/courseware.md](docs/templates/courseware.md) |
|
|
202
|
-
| ┣ Product Ad / 产品广告 | [docs/templates/product-ad.md](docs/templates/product-ad.md) |
|
|
203
|
-
| ┣ Movie Review / 影视讲解 | [docs/templates/movie-review.md](docs/templates/movie-review.md) |
|
|
204
|
-
| ┣ Audiobook / 有声图书 | [docs/templates/audiobook.md](docs/templates/audiobook.md) |
|
|
205
|
-
| ┣ Story Video / 故事视频 | [docs/templates/story-video.md](docs/templates/story-video.md) |
|
|
206
|
-
| ┣ Travel Log / 旅行日志 | [docs/templates/travel-log.md](docs/templates/travel-log.md) |
|
|
207
51
|
| Label system (browse, label, export labels.json) | [docs/label-mode.md](docs/label-mode.md) |
|
|
208
52
|
| Player servers (label + edit mode) | [docs/edit-mode.md](docs/edit-mode.md) |
|
|
209
|
-
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## common used components
|
|
56
|
+
- `react-markdown` — render markdown content, use plugins to extend functionality
|
|
57
|
+
- `remark-gfm` — support GitHub Flavored Markdown (tables, strikethrough, task lists)
|
|
58
|
+
- `remark-toc` — generate table of contents
|
|
59
|
+
- `remark-math` — support math formulas
|
|
60
|
+
- `rehype-katex` — render math formulas with KaTeX
|
|
61
|
+
- `remark-mermaidjs` — render mermaid diagrams in
|
|
62
|
+
- `react-markdown-mermaid` — render mermaid diagrams in standalone mode (no need to install mermaid separately)
|
|
63
|
+
|
|
64
|
+
- `@remotion/shapes` — render shapes like arrows, circles, rectangles, etc
|
|
65
|
+
- `@remotion/starburst` — render starburst animations
|
|
@@ -707,6 +707,18 @@ Use `markcut verify` to parse and validate a descriptive markdown file without r
|
|
|
707
707
|
markcut verify courseware.md
|
|
708
708
|
```
|
|
709
709
|
|
|
710
|
+
# common used components
|
|
711
|
+
- `react-markdown` — render markdown content, use plugins to extend functionality
|
|
712
|
+
- `remark-gfm` — support GitHub Flavored Markdown (tables, strikethrough, task lists)
|
|
713
|
+
- `remark-toc` — generate table of contents
|
|
714
|
+
- `remark-math` — support math formulas
|
|
715
|
+
- `rehype-katex` — render math formulas with KaTeX
|
|
716
|
+
- `remark-mermaidjs` — render mermaid diagrams in
|
|
717
|
+
- `react-markdown-mermaid` — render mermaid diagrams in standalone mode (no need to install mermaid separately)
|
|
718
|
+
|
|
719
|
+
- `@remotion/shapes` — render shapes like arrows, circles, rectangles, etc
|
|
720
|
+
- `@remotion/starburst` — render starburst animations
|
|
721
|
+
|
|
710
722
|
## Example
|
|
711
723
|
|
|
712
724
|
```md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lalalic/markcut",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"markcut": "bin/markcut"
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"render": "node src/render/cli.mjs render",
|
|
20
|
-
"render:all": "node src/render/cli.mjs render
|
|
21
|
-
"render:demo": "node src/render/cli.mjs render src/demo-components.json
|
|
20
|
+
"render:all": "node src/render/cli.mjs render",
|
|
21
|
+
"render:demo": "node src/render/cli.mjs render src/demo-components.json",
|
|
22
22
|
"templates": "node src/render/cli.mjs templates",
|
|
23
23
|
"preview": "node src/render/cli.mjs preview",
|
|
24
24
|
"verify": "node src/render/cli.mjs verify",
|
package/src/entry.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { AbsoluteFill, continueRender, delayRender } from "remotion";
|
|
2
|
+
import { AbsoluteFill, continueRender, delayRender, staticFile } from "remotion";
|
|
3
|
+
import { ensureSharedImportMap } from "./utils/component-import-map";
|
|
3
4
|
import { ComposeContext, EventProvider, type ComposeContextValue } from "./context/index";
|
|
4
5
|
|
|
5
6
|
import { FolderLeaf } from "./types/Folder";
|
|
@@ -58,7 +59,12 @@ function useComponentRegistry(imports: unknown): Record<string, React.ComponentT
|
|
|
58
59
|
if (!handleRef.current) {
|
|
59
60
|
handleRef.current = delayRender("Loading component registry: " + imports);
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
// Absolute URLs and "/"-rooted paths (preview server) load as-is;
|
|
63
|
+
// relative paths (render CLI stages bundles into publicDir) resolve
|
|
64
|
+
// through staticFile so the Remotion render server can serve them.
|
|
65
|
+
const moduleUrl = /^(https?:|data:|blob:|\/)/.test(imports) ? imports : staticFile(imports);
|
|
66
|
+
ensureSharedImportMap();
|
|
67
|
+
import(/* webpackIgnore: true */ moduleUrl)
|
|
62
68
|
.then((mod: any) => {
|
|
63
69
|
// The bundle exports all components as named exports
|
|
64
70
|
setRegistry(mod.default ?? mod);
|
package/src/render/cli.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* (TTS/STT/durations)
|
|
8
8
|
*/
|
|
9
9
|
import { execSync, spawn } from "node:child_process";
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
11
|
-
import {
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { resolve, dirname, join, extname } from "node:path";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -45,47 +46,46 @@ markcut CLI — Markdown/JSON → video pipeline
|
|
|
45
46
|
|
|
46
47
|
Commands:
|
|
47
48
|
|
|
48
|
-
compile <file> --output <path> Parse + compile → stream tree JSON (sync, no I/O)
|
|
49
|
-
|
|
50
49
|
verify <file> Parse + validate descriptive file
|
|
51
50
|
--cli Check required CLI tools are installed
|
|
52
51
|
|
|
53
|
-
resolve <file> --output <path> Run async pipeline: TTS, STT, media durations
|
|
54
|
-
--script-output-dir <dir> Directory for generated TTS/STT files
|
|
55
|
-
--media-output-dir <dir> Directory for generated TTI/TTV media files
|
|
56
|
-
--compile Also compile to stream tree after resolving
|
|
57
|
-
|
|
58
|
-
render <file.json|.md> Resolve + compile + render to MP4
|
|
59
|
-
--aspect <16x9|9x16|1x1|all> Aspect ratio (default: 16x9)
|
|
60
|
-
--output <path> Output path (default: out/video-{aspect}.mp4)
|
|
61
|
-
--verbose Show full per-frame progress (default: compact)
|
|
62
|
-
|
|
63
52
|
preview <file.json|.md> Open player with live preview
|
|
64
53
|
--edit Auto-reload on file change
|
|
65
54
|
--label Open label input overlay
|
|
66
55
|
--no-browser Skip opening browser automatically
|
|
67
56
|
--port <num> Port for the player server (default: 3001)
|
|
68
57
|
|
|
69
|
-
|
|
58
|
+
render <file.json|.md> Resolve + compile + render to MP4
|
|
59
|
+
--output <path> Output path (default: out/video.mp4)
|
|
60
|
+
--verbose Show full per-frame progress (default: compact)
|
|
61
|
+
|
|
62
|
+
vision <folder> Extract metadata into metadata.json
|
|
70
63
|
--label Full pipeline: preview → label → normalize → percept → segments
|
|
71
64
|
--agent <template> Custom text LLM CLI for detect-scenes ({prompt})
|
|
72
65
|
--itt <template> Custom ITT CLI template with {input}, {prompt}
|
|
73
66
|
--vtt <template> Custom VTT CLI template with {input}, {prompt}
|
|
74
67
|
--stt <template> Custom STT CLI template with {input}, {output}
|
|
75
|
-
--
|
|
76
|
-
|
|
68
|
+
--prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
|
|
69
|
+
--vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
|
|
70
|
+
--context "text" Background context about people/places (injected into prompts)
|
|
71
|
+
--pick <files> Comma-separated filenames to process
|
|
72
|
+
--skip-stt Skip speech-to-text for videos
|
|
73
|
+
--dry-run Show what would be processed without running AI
|
|
74
|
+
--show-prompts Print the prompts file and exit
|
|
75
|
+
--show-clis Print the default ITT/VTT/STT CLI templates
|
|
76
|
+
--help Show this help
|
|
77
|
+
--<prompt-name> "text" Override any prompt template from vision_prompts.md
|
|
77
78
|
`);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
function parseArgs(argv) {
|
|
81
|
-
const args = { command: "", file: "",
|
|
81
|
+
export function parseArgs(argv) {
|
|
82
|
+
const args = { command: "", file: "", output: "", forceNew: false, verbose: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, scriptOutputDir: "", mediaOutputDir: "", variant: [] };
|
|
82
83
|
let i = 2;
|
|
83
84
|
if (argv[i]) args.command = argv[i++];
|
|
84
85
|
if (argv[i] && !argv[i].startsWith("--")) args.file = argv[i++];
|
|
85
86
|
while (i < argv.length) {
|
|
86
87
|
const flag = argv[i++];
|
|
87
|
-
if (flag === "--
|
|
88
|
-
else if (flag === "--output" && argv[i]) args.output = argv[i++];
|
|
88
|
+
if (flag === "--output" && argv[i]) args.output = argv[i++];
|
|
89
89
|
else if (flag === "--script-output-dir" && argv[i]) args.scriptOutputDir = argv[i++];
|
|
90
90
|
else if (flag === "--media-output-dir" && argv[i]) args.mediaOutputDir = argv[i++];
|
|
91
91
|
else if (flag === "--cli") args.cli = true;
|
|
@@ -112,11 +112,42 @@ function parseArgs(argv) {
|
|
|
112
112
|
*
|
|
113
113
|
* Use --verbose to see every frame line (original behavior).
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* Stage local absolute-path assets (TTS audio, TTI/TTV media, subtitles from
|
|
117
|
+
* .markcut/) into ROOT/public/.render-assets/ and rewrite srcs to relative
|
|
118
|
+
* paths, so `npx remotion render` (publicDir = ROOT/public) can serve them.
|
|
119
|
+
* The preview server handles these paths via multi-root serving; the render
|
|
120
|
+
* CLI needs them staged into the one public dir Remotion knows about.
|
|
121
|
+
*/
|
|
122
|
+
function stageLocalAssets(tree) {
|
|
123
|
+
const assetsDir = join(ROOT, "public", ".render-assets");
|
|
124
|
+
const seen = new Map();
|
|
125
|
+
const walk = (node) => {
|
|
126
|
+
if (!node || typeof node !== "object") return;
|
|
127
|
+
if (typeof node.src === "string" && node.src.startsWith("/") && !node.src.startsWith("//") && existsSync(node.src)) {
|
|
128
|
+
let staged = seen.get(node.src);
|
|
129
|
+
if (!staged) {
|
|
130
|
+
const name = createHash("md5").update(node.src).digest("hex").slice(0, 12) + extname(node.src);
|
|
131
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
132
|
+
copyFileSync(node.src, join(assetsDir, name));
|
|
133
|
+
staged = `.render-assets/${name}`;
|
|
134
|
+
seen.set(node.src, staged);
|
|
135
|
+
}
|
|
136
|
+
node.src = staged;
|
|
137
|
+
}
|
|
138
|
+
for (const value of Object.values(node)) {
|
|
139
|
+
if (value && typeof value === "object") walk(value);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(tree);
|
|
143
|
+
return tree;
|
|
144
|
+
}
|
|
145
|
+
|
|
115
146
|
function renderOne(streamTree, aspect, outputPath, verbose) {
|
|
116
147
|
const dims = ASPECTS[aspect];
|
|
117
148
|
if (!dims) throw new Error(`Unknown aspect: ${aspect}`);
|
|
118
149
|
|
|
119
|
-
const adapted = { ...streamTree, width: dims.width, height: dims.height };
|
|
150
|
+
const adapted = stageLocalAssets(JSON.parse(JSON.stringify({ ...streamTree, width: dims.width, height: dims.height })));
|
|
120
151
|
const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
|
|
121
152
|
mkdirSync(dirname(tmpProps), { recursive: true });
|
|
122
153
|
writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
|
|
@@ -263,10 +294,12 @@ async function main() {
|
|
|
263
294
|
|
|
264
295
|
if (args.command === "render") {
|
|
265
296
|
let streamTree;
|
|
297
|
+
let rawInput = "";
|
|
266
298
|
|
|
267
299
|
if (args.file) {
|
|
268
300
|
const filePath = resolve(args.file);
|
|
269
301
|
const raw = readFileSync(filePath, "utf-8");
|
|
302
|
+
rawInput = raw;
|
|
270
303
|
|
|
271
304
|
// Helper: all generated artifacts live under .markcut/generated/
|
|
272
305
|
function generatedDir(filePath, sub) {
|
|
@@ -286,14 +319,14 @@ async function main() {
|
|
|
286
319
|
// Helper: resolve variants from the parsed descriptive root
|
|
287
320
|
async function resolveWithVariants(descriptive, options) {
|
|
288
321
|
const { resolveVariantOverrides, compileDescriptiveRoot, resolveAll } = await import("../player/pipeline.mjs");
|
|
289
|
-
const {
|
|
322
|
+
const { parseMarkdownVariants } = await import("../player/pipeline.mjs");
|
|
290
323
|
|
|
291
324
|
let resolved = descriptive;
|
|
292
325
|
|
|
293
326
|
// If variants are specified, apply variant-prefixed overrides
|
|
294
327
|
if (args.variant && args.variant.length > 0) {
|
|
295
328
|
// Also apply root config overrides from variant sections
|
|
296
|
-
const parsed =
|
|
329
|
+
const parsed = parseMarkdownVariants(raw);
|
|
297
330
|
const variantRoot = parsed.variants.get(args.variant[0]);
|
|
298
331
|
if (variantRoot) {
|
|
299
332
|
// Merge variant root config into base (tts, stt, width, height, etc.)
|
|
@@ -309,9 +342,9 @@ async function main() {
|
|
|
309
342
|
}
|
|
310
343
|
|
|
311
344
|
if (filePath.endsWith(".md")) {
|
|
312
|
-
const {
|
|
345
|
+
const { parseMarkdownVariants } = await import("../player/pipeline.mjs");
|
|
313
346
|
const fileDir = dirname(filePath);
|
|
314
|
-
const parsed =
|
|
347
|
+
const parsed = parseMarkdownVariants(raw);
|
|
315
348
|
streamTree = await resolveWithVariants(parsed.base, {
|
|
316
349
|
baseDir: fileDir,
|
|
317
350
|
scriptOutputDir: generatedDir(filePath, "tts"),
|
|
@@ -341,16 +374,31 @@ async function main() {
|
|
|
341
374
|
process.exit(1);
|
|
342
375
|
}
|
|
343
376
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
377
|
+
// Bundle the ```js imports``` component block (the preview server does this
|
|
378
|
+
// post-compile; the render CLI must do it too, otherwise jsx components are
|
|
379
|
+
// unregistered and slides render as unstyled raw text).
|
|
380
|
+
if (!streamTree.imports) {
|
|
381
|
+
const fenceMatch = rawInput.match(/^(```|~~~)\s*js imports\s*\n([\s\S]*?)^\1\s*$/m);
|
|
382
|
+
const rawSource = fenceMatch ? fenceMatch[2] : (streamTree.importsBlock ?? null);
|
|
383
|
+
if (rawSource && rawSource.trim()) {
|
|
384
|
+
const { parseImportsBlock, extractDependencySpecs } = await import("../player/pipeline.mjs");
|
|
385
|
+
const { bundleFromEntries } = await import("../player/bundler.mjs");
|
|
386
|
+
const entries = parseImportsBlock(rawSource);
|
|
387
|
+
const extraSpecs = extractDependencySpecs(rawSource);
|
|
388
|
+
const bundleDir = join(ROOT, "public", ".render-assets");
|
|
389
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
390
|
+
const bundle = await bundleFromEntries(entries, extraSpecs, rawSource, bundleDir);
|
|
391
|
+
if (bundle.url) {
|
|
392
|
+
streamTree.imports = ".render-assets/" + bundle.url.split("/").pop();
|
|
393
|
+
console.log(` \u2705 components \u2192 ${bundle.exports.join(", ")}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
351
396
|
}
|
|
352
397
|
|
|
353
|
-
|
|
398
|
+
const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
|
|
399
|
+
await renderOne(streamTree, "16x9", output, args.verbose);
|
|
400
|
+
|
|
401
|
+
console.log("\n✅ Render complete.");
|
|
354
402
|
process.exit(0);
|
|
355
403
|
}
|
|
356
404
|
|
|
@@ -622,7 +670,9 @@ function hasScript(root) {
|
|
|
622
670
|
process.exit(1);
|
|
623
671
|
}
|
|
624
672
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
673
|
+
if (process.argv[1] && process.argv[1].endsWith("cli.mjs")) {
|
|
674
|
+
main().catch((e) => {
|
|
675
|
+
console.error(e);
|
|
676
|
+
process.exit(1);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseArgs } from "./cli.mjs";
|
|
3
|
+
|
|
4
|
+
describe("parseArgs", () => {
|
|
5
|
+
it("supports render without an aspect flag", () => {
|
|
6
|
+
const args = parseArgs(["node", "markcut", "render", "input.md", "--output", "out/video.mp4"]);
|
|
7
|
+
|
|
8
|
+
expect(args.command).toBe("render");
|
|
9
|
+
expect(args.file).toBe("input.md");
|
|
10
|
+
expect(args.output).toBe("out/video.mp4");
|
|
11
|
+
expect(args).not.toHaveProperty("aspect");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-module import map for dynamically loaded component bundles.
|
|
3
|
+
*
|
|
4
|
+
* Component bundles are built with react/react-dom/remotion marked as
|
|
5
|
+
* --external (see player/bundler.mjs getSharedExternals), so their output
|
|
6
|
+
* contains bare imports like `import { useState } from "react"`. The page
|
|
7
|
+
* that dynamically imports such a bundle must provide an import map that
|
|
8
|
+
* resolves those bare specifiers back to the already-loaded module
|
|
9
|
+
* instances — otherwise the import fails ("Failed to resolve module
|
|
10
|
+
* specifier") or, worse, a duplicate React breaks hooks and context.
|
|
11
|
+
*
|
|
12
|
+
* The preview player injects this map itself (player/browser.tsx). This
|
|
13
|
+
* helper provides the same shim for the Remotion render page (headless
|
|
14
|
+
* Chrome during `markcut render`). Both use the `#rmtr-import-map` guard,
|
|
15
|
+
* so whichever runs first wins and the other becomes a no-op.
|
|
16
|
+
*/
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
import * as ReactDOM from "react-dom";
|
|
19
|
+
import * as Remotion from "remotion";
|
|
20
|
+
|
|
21
|
+
export function ensureSharedImportMap(): void {
|
|
22
|
+
if (typeof document === "undefined") return;
|
|
23
|
+
if (document.querySelector("#rmtr-import-map")) return;
|
|
24
|
+
|
|
25
|
+
const g = globalThis as any;
|
|
26
|
+
g.__remotionShared ??= {};
|
|
27
|
+
g.__remotionShared["react"] ??= React;
|
|
28
|
+
g.__remotionShared["react-dom"] ??= ReactDOM;
|
|
29
|
+
g.__remotionShared["remotion"] ??= Remotion;
|
|
30
|
+
|
|
31
|
+
// Stub for node:* imports that some component deps reference at module
|
|
32
|
+
// level for build-time helpers never called during rendering.
|
|
33
|
+
if (!g.__nodeModuleStub) {
|
|
34
|
+
const nodeModuleStubCode = `export function createRequire() { return () => ({ resolve: () => { throw new Error("node:module.createRequire is not available in the browser"); } }); }`;
|
|
35
|
+
g.__nodeModuleStub = URL.createObjectURL(
|
|
36
|
+
new Blob([nodeModuleStubCode], { type: "application/javascript" }),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const imports: Record<string, string> = {};
|
|
42
|
+
for (const spec of ["node:module", "node:fs", "node:path", "node:os"]) {
|
|
43
|
+
imports[spec] = g.__nodeModuleStub;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const [specifier, mod] of Object.entries(g.__remotionShared) as [string, any][]) {
|
|
47
|
+
const lines: string[] = [
|
|
48
|
+
`const _mod = globalThis.__remotionShared[${JSON.stringify(specifier)}];`,
|
|
49
|
+
];
|
|
50
|
+
for (const name of Object.keys(mod)) {
|
|
51
|
+
if (name === "default") continue;
|
|
52
|
+
lines.push(
|
|
53
|
+
`const __${name} = _mod[${JSON.stringify(name)}];`,
|
|
54
|
+
`export { __${name} as ${name} };`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if ("default" in mod) lines.push("export default _mod.default;");
|
|
58
|
+
imports[specifier] = URL.createObjectURL(
|
|
59
|
+
new Blob([lines.join("\n")], { type: "application/javascript" }),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// react/jsx-runtime — built manually because it's a sub-path of react.
|
|
64
|
+
const blobJsx = new Blob(
|
|
65
|
+
[
|
|
66
|
+
`
|
|
67
|
+
const R = globalThis.__remotionShared["react"];
|
|
68
|
+
const { createElement, Fragment } = R;
|
|
69
|
+
export { Fragment };
|
|
70
|
+
export function jsx(type, props, key) {
|
|
71
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
72
|
+
}
|
|
73
|
+
export function jsxs(type, props, key) {
|
|
74
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
75
|
+
}
|
|
76
|
+
export function jsxDEV(type, props, key, isStaticChildren, source, self) {
|
|
77
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
78
|
+
}
|
|
79
|
+
`,
|
|
80
|
+
],
|
|
81
|
+
{ type: "application/javascript" },
|
|
82
|
+
);
|
|
83
|
+
imports["react/jsx-runtime"] = URL.createObjectURL(blobJsx);
|
|
84
|
+
|
|
85
|
+
const script = document.createElement("script");
|
|
86
|
+
script.type = "importmap";
|
|
87
|
+
script.id = "rmtr-import-map";
|
|
88
|
+
script.textContent = JSON.stringify({ imports });
|
|
89
|
+
document.head.appendChild(script);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.warn("Failed to create shared module import map:", e);
|
|
92
|
+
}
|
|
93
|
+
}
|