@lalalic/markcut 1.2.0 → 2.1.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 (61) hide show
  1. package/AGENTS.md +40 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -1
  7. package/docs/system-prompt-edit.md +19 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1149 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +60 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +336 -239
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
package/AGENTS.md CHANGED
@@ -63,8 +63,12 @@ src/
63
63
  ├── render/cli.mjs → CLI entry
64
64
  ├── render/tts.ts → TTS via CLI template + variable substitution
65
65
  ├── player/pipeline.mjs → Bundled pipeline (server imports this)
66
- ├── player/server.mjs → --edit server
67
- ├── player/label-server.mjs--label server
66
+ ├── player/server.mjs → Unified server (--edit, --label, preview)
67
+ ├── player/components/ React control components (edit, label, variant bar)
68
+ │ ├── index.ts
69
+ │ ├── EditControls.tsx
70
+ │ ├── LabelControls.tsx
71
+ │ └── VariantBar.tsx
68
72
  └── tests/ → Vitest integration tests
69
73
  ```
70
74
 
@@ -107,22 +111,22 @@ The tree is validated by Zod and rendered by Remotion via recursive React compon
107
111
  | **Series** (`isSeries:true`) | Children play sequentially. Optional `transition` (fade/slide/wipe/flip/clockWipe) + `transitionTime` (default 0.5s). |
108
112
  | **Parallel** (`isSeries:false`) | Children play simultaneously. Duration = max child duration. |
109
113
  | **Background** (`isBackground:true`) | Loops for parent duration. Excluded from duration calculation. |
110
- | **Actions** | Every leaf has `actions[{start, end, startFrom?, endAt?, loop?, volume?, style?}]` seconds relative to parent. |
114
+ | **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). |
111
115
  | **Scene** (`type:"scene"`) | UI storyboard card; engine treats as folder. Has `name` + `description` + `script`. |
112
116
 
113
117
  ### Duration Calculation
114
118
 
115
119
  Computed by `getDurationInSeconds()` in `src/utils/index.ts`:
116
- - **Leaf nodes**: duration = last action's `end` value
120
+ - **Leaf nodes**: duration = `leafEnd(stream)` = `end ?? (start + duration)` (single deterministic read; `start`/`end`/`duration` live on the base node)
117
121
  - **Series**: sum of children durations minus transition overlaps
118
122
  - **Parallel**: max of children durations
119
123
  - **Background children**: excluded from calculation
120
- - **Include with `src`**: treated as leaf (duration from action end)
124
+ - **Include with `src`**: treated as leaf (duration from base `end`)
121
125
  - Sets `durationInSeconds` on every node as a side effect
122
126
 
123
127
  ### 12 Stream Types
124
128
 
125
- All types share base fields from `BaseShape`: `id`, `name`, `title`, `description`, `script`, `src`, `style`, `visible`, `isBackground`, `durationInSeconds`.
129
+ All types share base fields from `BaseShape`: `id`, `name`, `title`, `description`, `script`, `src`, `style`, `visible`, `isBackground`, `start`, `end`, `duration`, `startFrom`, `endAt`, `durationInSeconds`.
126
130
 
127
131
  | Type | Purpose | Key Fields |
128
132
  |------|---------|------------|
@@ -130,8 +134,8 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
130
134
  | `folder` | Group children | `isSeries`, `transition`, `children[]` |
131
135
  | `scene` | Storyboard node (alias for folder) | `name`, `description`, `script`, `children[]` |
132
136
  | `image` | Still photo | `src`, `fit` (contain/cover/fill) |
133
- | `video` | Video clip | `src`, `volume`, `playbackRate` |
134
- | `audio` | Soundtrack/SFX | `src`, `volume`, `foreground` (ducks parent) |
137
+ | `video` | Video clip | `src`, `volume`, `playbackRate`, `loop` |
138
+ | `audio` | Soundtrack/SFX | `src`, `volume`, `foreground` (ducks parent), `loop` |
135
139
  | `subtitle` | Text overlay | `src` (text/VTT) or `cues[]`, `fontSize`, `style` |
136
140
  | `component` | React component | `componentName`, `props`, `src` (remote ESM) |
137
141
  | `effect` | CSS animation wrapper | `animation`, `customKeyframes`, `children[]` |
@@ -155,7 +159,7 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
155
159
  ### Video.tsx — startFrom/endAt Trimming
156
160
 
157
161
  - Uses Remotion `<OffthreadVideo>` for CPU-efficient video decoding
158
- - `startFrom` / `endAt` on actions trim the source video (in seconds)
162
+ - `startFrom` / `endAt` on the base node trim the source video (in seconds)
159
163
  - `playbackRate` is capped at 1 (`Math.min(1, ...)`) so source longer than timeline plays at normal speed and is truncated by the Sequence
160
164
  - **Critical formula**: `OffthreadVideo endAt = startFrom_frames + ((endAt_source - startFrom_source) * fps / playbackRate)`
161
165
  This sets `endAt` beyond the actual source end when `playbackRate < 1`, which prevents Remotion from rendering blank frames before the Sequence ends.
@@ -183,7 +187,8 @@ node src/render/cli.mjs templates
183
187
  - `--edit` flag starts a live-reload loop: edit JSON → player auto-refreshes
184
188
  - `--label` flag starts a labeling UI for selecting and annotating media
185
189
  - `--port` flag sets the server port (default 3001)
186
- - Two implementations: `server.mjs` (Remotion Player) and `label-server.mjs` (label input overlay)
190
+ - Unified server: `server.mjs` serves all modes (preview, edit, label). React control components in `components/`.
191
+ - **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 rpc` appended (the `-p/--prompt` flag is stripped — prompts arrive over stdin). Each `/api/edit` POST writes `{"id","type":"prompt","message"}` to stdin and resolves on the streamed `agent_settled` event (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 from `message_end` events (`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-shot `spawn` per edit — that re-incurs the full cold-start every request.
187
192
 
188
193
  ## Testing
189
194
 
@@ -235,13 +240,13 @@ npx vitest run tests/render.test.ts -t "Test Name" # single test
235
240
  ## Known Issues & Gotchas
236
241
 
237
242
  1. **TransitionSeries + isBackground**: `TransitionSeries` from `@remotion/transitions` rejects non-Sequence/Transition children. Don't mix `isBackground:true` children with `isSeries:true, transition:"..."` at the same level.
238
- 2. **Subvideo internal actions**: Should use `start:0` when placed in a series parent (series handles positioning).
239
- 3. **Video endAt formula**: Must be `startFrom_frames + ((endAt_source - startFrom_source) * fps / playbackRate)` changing to `endAt_source * fps` breaks slow-motion playback.
240
- 4. **`signalstats` ffmpeg filter**: Doesn't output YAVG on all systems; use file size as proxy for visual content.
241
- 5. **Asset paths**: Local files are resolved via `staticFile()` files go in `public/` and are referenced without the `public/` prefix.
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
+ 2. **Video endAt formula**: Must be `startFrom_frames + ((endAt_source - startFrom_source) * fps / playbackRate)` changing to `endAt_source * fps` breaks slow-motion playback.
244
+ 3. **`signalstats` ffmpeg filter**: Doesn't output YAVG on all systems; use file size as proxy for visual content.
245
+ 4. **Asset paths**: Local files are resolved via `staticFile()` files go in `public/` and are referenced without the `public/` prefix.
246
+ 5. **Map rendering**: Depends on external tile availability; may produce blank frames offline.
247
+ 6. **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`).
248
+ 7. **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.
249
+ 8. **Clean break from `actions[]`**: leaf timing is now flat base fields (`start`/`end`/`duration`/`startFrom`/`endAt`). Old compiled JSON carrying `actions:[{...}]` no longer parses into a duration re-compile from markdown/descriptive JSON, or migrate `actions[0]` flat fields (drop `id`/`volume`/`style`/`effectId`; keep `start`/`end`/`startFrom`/`endAt`/`loop`).
245
250
 
246
251
  ## 3-Level Authoring Workflow (see SKILL.md)
247
252
 
@@ -261,6 +266,24 @@ Every node carries an optional `script` field for narration/dialogue text. The p
261
266
 
262
267
  This separates concerns: `script` = authoring text (human/agent-friendly), `subtitle` = rendered output (timed VTT cues).
263
268
 
269
+ ### Multi-turn Dialogue
270
+
271
+ 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.
272
+
273
+ ```md
274
+ - script "Ray: Hello everyone
275
+ Alice: Good day to you
276
+ Ray: Let's begin"
277
+ ```
278
+
279
+ Per-speaker voices are configured via `voices` on root. Each value is extra CLI flags appended to the TTS template:
280
+
281
+ ```md
282
+ voices:{"Ray":"--voice en-US-GuyNeural","Alice":"--voice en-US-JennyNeural"}
283
+ ```
284
+
285
+ 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`).
286
+
264
287
  ## Dependencies
265
288
 
266
289
  - **Remotion 4.0.469** — core rendering engine
package/README.md CHANGED
@@ -201,10 +201,14 @@ src/
201
201
  │ ├── cli.mjs ← CLI entry point
202
202
  │ └── tts.ts ← TTS via CLI template + variable substitution
203
203
  ├── player/
204
- │ ├── server.mjs ← --edit player server
205
- │ ├── label-server.mjs --label player server
204
+ │ ├── server.mjs ← Unified server (--edit, --label, preview)
205
+ │ ├── components/ React control components for all modes
206
+ │ │ ├── index.ts
207
+ │ │ ├── EditControls.tsx
208
+ │ │ ├── LabelControls.tsx
209
+ │ │ └── VariantBar.tsx
206
210
  │ ├── pipeline.ts ← Pipeline entry (bundled to pipeline.mjs)
207
- │ └── browser.tsx ← Browser player (bundled with esbuild)
211
+ │ └── browser.tsx ← Browser player (React, bundled to player.js)
208
212
  ├── utils/ ← Duration calc, VTT parser, helpers
209
213
  └── tests/ ← Vitest integration tests
210
214
  ```
package/SKILL.md CHANGED
@@ -58,8 +58,6 @@ some common issues (photo or video can't be displayed, audio missing), take belo
58
58
  - `remark-toc` — generate table of contents
59
59
  - `remark-math` — support math formulas
60
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
61
 
64
62
  - `@remotion/shapes` — render shapes like arrows, circles, rectangles, etc
65
63
  - `@remotion/starburst` — render starburst animations
package/docs/edit-mode.md CHANGED
@@ -177,7 +177,7 @@ The tree description walks the JSON recursively, showing:
177
177
  - **Nesting depth** via indentation
178
178
  - **Type** and **id/name** of each node
179
179
  - **Key fields** (dimensions, fps, theme, series/parallel, transitions)
180
- - **Timing** from actions (`[startend]`)
180
+ - **Timing** from leaf span (`[start→end]`)
181
181
  - **isBackground** flag (`[bg]`)
182
182
  - **Component props** and node-specific fields (fontSize, fit, style, volume, etc.)
183
183
 
@@ -36,7 +36,7 @@ Labels are saved to the source file.
36
36
  "id": "scene-1-media",
37
37
  "type": "image",
38
38
  "src": "/media/photo.jpg",
39
- "actions": [{ "start": 0, "end": 5 }],
39
+ "start": 0, "end": 5,
40
40
  "description": "Best group shot from the campfire"
41
41
  }
42
42
  ]
@@ -49,7 +49,7 @@ Labels are saved to the source file.
49
49
  "id": "scene-2-media",
50
50
  "type": "video",
51
51
  "src": "/media/clip.mp4",
52
- "actions": [{ "start": 0, "end": 5 }],
52
+ "start": 0, "end": 5,
53
53
  "description": "Sunset timelapse — use for intro"
54
54
  }
55
55
  ]
@@ -94,12 +94,14 @@ Stream tree JSON (labels.json)
94
94
 
95
95
  ## Implementation
96
96
 
97
- Source: `src/player/label-server.mjs`
97
+ Source: `src/player/server.mjs` (unified server, use `--label` flag)
98
+ UI controls: `src/player/components/LabelControls.tsx` (React component)
98
99
 
99
100
  - Built on Node.js `http` module (no Express dependency)
100
101
  - Serves bundled `player.js` (esbuild output from `src/player/browser.tsx`)
101
102
  - Labels saved atomically to disk on each POST
102
- - Accepts stream tree JSON only (no media folder mode)
103
+ - Supports stream tree JSON, descriptive JSON, and markdown input
104
+ - React components in `src/player/components/` replace the old HTML-based `ui/` directory
103
105
 
104
106
  ## Reference
105
107
 
@@ -174,7 +174,9 @@ For compatibility, `import { Name } from "spec"` also works and produces the sam
174
174
  | `title` | display title | scene |
175
175
  | `instruction` | visual intent / style / any prompt; NOT rendered | any |
176
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 |
177
+ | `speaker` | speaker name for multi-turn dialogue; set automatically by dialogue expansion | audio | Used for per-speaker voice lookup and subtitle prefix |
178
+ | `voices` | JSON object mapping speaker names to extra TTS CLI flags e.g. `{"Ray":"--voice en-US-GuyNeural"}` | root | Flags are appended to the resolved TTS CLI template |
179
+ | `tts` | CLI template string e.g. `"edge-tts --voice en-US-GuyNeural --text {input} --write-media {output}"` | root | Set once at the root level; all TTS uses this template |
178
180
  | `metadata` | arbitrary metadata string | root |
179
181
  | `stylesheet` | global CSS string; selectors use `.className` on elements | root |
180
182
  | `style` | inline CSS applied to the node's container div e.g. `"border-radius:12px"` | any |
@@ -254,6 +256,37 @@ For longer text, use an indented code fence:
254
256
 
255
257
  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
258
 
259
+ #### Multi-turn Dialogue
260
+
261
+ When a `script` contains multiple lines matching `SpeakerName: text` format, the pipeline automatically expands it into a multi-turn dialogue where each line becomes a separate audio node with its own TTS generation. This allows different speakers to have different voices.
262
+
263
+ ```md
264
+ - script "Ray: Hello everyone and welcome
265
+ Alice: Good day to you all
266
+ Ray: Let's get started"
267
+ ```
268
+
269
+ The dialogue lines play sequentially. Each line is transcribed separately, and subtitles include the speaker prefix (e.g., `Ray: Hello everyone and welcome`).
270
+
271
+ ##### Per-Speaker Voices
272
+
273
+ Configure different TTS voices for each speaker via the root `voices` config. Each value is **extra CLI flags** appended to the TTS template:
274
+
275
+ ```md
276
+ # video
277
+ voices:{"Ray":"--voice en-US-GuyNeural","Alice":"--voice en-US-JennyNeural"}
278
+ - script "Ray: Tell us about your project
279
+ Alice: I'm working on something exciting"
280
+ ```
281
+
282
+ Since values are raw CLI flags, they naturally support voice cloning, rate, pitch, and any edge-tts feature:
283
+
284
+ ```md
285
+ voices:{"Ray":"--voice en-US-GuyNeural --rate +20%","Clone":"--voice clone-xxx --pitch +5Hz"}
286
+ ```
287
+
288
+ The final TTS command for a speaker: `<root tts cli> <speaker voice flags>`.
289
+
257
290
  ### `subtitle` (root-level overlay)
258
291
 
259
292
  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.
@@ -0,0 +1,19 @@
1
+ You are a video director editing a markdown-descriptive (.md) markcut video project.
2
+
3
+ The markcut engine compiles markdown descriptions into rendered videos with TTS narration, transitions, and media generation.
4
+
5
+ The file you are editing is: ${fileName}
6
+ Full path: ${filePath}
7
+
8
+ --- Markdown-Descriptive Format Reference ---
9
+ @{docs/markdown-descriptive.md}
10
+
11
+ TASKS:
12
+ 1. Read the .md file directly using your file tools to see its current content
13
+ 2. Edit the .md file directly — change content, add/remove sections, update frontmatter
14
+ 3. Save the changes to the .md file using your write/edit tools
15
+ 4. Output ONLY a JSON object on a single line:
16
+ {"summary":"what specific change you made","fileChanged":true}
17
+ - `summary`: short description of the change
18
+ - `fileChanged`: true if you edited the file, false if no change was needed
19
+ 5. Do NOT output any other text, explanations, or reasoning — only the JSON line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "1.2.0",
3
+ "version": "2.1.0",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
package/src/config.mjs CHANGED
@@ -37,6 +37,8 @@ export const DEFAULT_ITT_CLI =
37
37
  /**
38
38
  * Seconds between sampled video frames when using the default ITT-based VTT.
39
39
  */
40
+ export const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
41
+
40
42
  export const DEFAULT_VTT_SAMPLE_INTERVAL = Number(process.env.MARKCUT_VTT_SAMPLE_INTERVAL) || 5;
41
43
 
42
44
  /**
@@ -73,11 +75,14 @@ export const DEFAULT_TTS_CLI =
73
75
 
74
76
  /**
75
77
  * Default agent CLI — general-purpose text LLM.
76
- * Used for text-only tasks like detect-scenes.
77
- * Placeholders: {prompt}=the prompt text.
78
+ * Used for text-only tasks like detect-scenes and editing.
79
+ * Placeholders:
80
+ * {systemprompt} = system prompt (role, instructions, knowledge)
81
+ * {prompt} = user prompt (current context + edit request)
82
+ * {sessionid} = unique session ID for conversation continuity
78
83
  * Override with MARKCUT_AGENT_CLI env var.
79
84
  */
80
- export const DEFAULT_AGENT_CLI = process.env.MARKCUT_AGENT_CLI || 'npx pi -p {prompt}';
85
+ export const DEFAULT_AGENT_CLI = process.env.MARKCUT_AGENT_CLI || 'npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}';
81
86
 
82
87
  // ── Render-only pipeline CLI templates ─────────────────────────────────────
83
88
  // These are specific to the render pipeline (no vision pipeline equivalent).
@@ -132,7 +132,7 @@ export { BarChart } from "npm:recharts"`,
132
132
  });
133
133
 
134
134
  describe("compileDescriptiveRoot", () => {
135
- it("compiles simple series into legacy root+actions", () => {
135
+ it("compiles simple series into flat start/end timing", () => {
136
136
  const compiled = compileDescriptiveRoot({
137
137
  width: 640,
138
138
  height: 480,
@@ -149,13 +149,13 @@ describe("compileDescriptiveRoot", () => {
149
149
  expect(compiled.children).toHaveLength(2);
150
150
 
151
151
  const first = compiled.children[0] as any;
152
- expect(first.actions[0].start).toBe(0);
153
- expect(first.actions[0].end).toBe(2);
152
+ expect(first.start).toBe(0);
153
+ expect(first.end).toBe(2);
154
154
 
155
155
  const second = compiled.children[1] as any;
156
- expect(second.actions[0].startFrom).toBe(1);
157
- expect(second.actions[0].endAt).toBe(4);
158
- expect(second.actions[0].end).toBe(3);
156
+ expect(second.startFrom).toBe(1);
157
+ expect(second.endAt).toBe(4);
158
+ expect(second.end).toBe(3);
159
159
  });
160
160
 
161
161
  it("allows explicit start in parallel root", () => {
@@ -170,8 +170,8 @@ describe("compileDescriptiveRoot", () => {
170
170
  expect(compiled.isSeries).toBe(false);
171
171
  expect(compiled.durationInSeconds).toBe(3);
172
172
  const first = compiled.children[0] as any;
173
- expect(first.actions[0].start).toBe(1);
174
- expect(first.actions[0].end).toBe(3);
173
+ expect(first.start).toBe(1);
174
+ expect(first.end).toBe(3);
175
175
  });
176
176
 
177
177
  it("warns on duplicate ids", () => {
@@ -253,8 +253,8 @@ describe("compileDescriptiveRoot", () => {
253
253
 
254
254
  const includeWithSrc = withSrc.children[0] as any;
255
255
  expect(includeWithSrc.type).toBe("include");
256
- expect(includeWithSrc.actions[0].start).toBe(0);
257
- expect(includeWithSrc.actions[0].end).toBe(4);
256
+ expect(includeWithSrc.start).toBe(0);
257
+ expect(includeWithSrc.end).toBe(4);
258
258
 
259
259
  const withChildren = compileDescriptiveRoot({
260
260
  layout: "series",
@@ -273,7 +273,7 @@ describe("compileDescriptiveRoot", () => {
273
273
  const includeInline = withChildren.children[0] as any;
274
274
  expect(includeInline.type).toBe("include");
275
275
  expect(includeInline.children).toHaveLength(2);
276
- expect(includeInline.actions[0].end).toBe(3);
276
+ expect(includeInline.end).toBe(3);
277
277
  });
278
278
 
279
279
  it("covers remaining types: effect (via effects:[]) and map", () => {
@@ -304,13 +304,13 @@ describe("compileDescriptiveRoot", () => {
304
304
  // effects:[] on image compiles into Effect wrapper stream
305
305
  const effect = compiled.children[0] as any;
306
306
  expect(effect.type).toBe("effect");
307
- expect(effect.actions[0].start).toBe(0);
308
- expect(effect.actions[0].end).toBe(2);
307
+ expect(effect.start).toBe(0);
308
+ expect(effect.end).toBe(2);
309
309
  expect(effect.children).toHaveLength(1);
310
310
 
311
311
  const map = compiled.children.find((c: any) => c.id === "map-1") as any;
312
312
  expect(map.type).toBe("map");
313
- expect(map.actions[0].end).toBe(4);
313
+ expect(map.end).toBe(4);
314
314
  expect(map.waypoints).toHaveLength(2);
315
315
  expect(map.routeMarker).toBe("🚲");
316
316
  expect(map.travelMode).toBe("BICYCLING");
@@ -339,11 +339,11 @@ describe("compileDescriptiveRoot", () => {
339
339
  expect(fx.children[0].type).toBe("image");
340
340
  expect(fx.children[0].src).toBe("hero.jpg");
341
341
  // Effect action spans leaf duration
342
- expect(fx.actions[0].start).toBe(0);
343
- expect(fx.actions[0].end).toBe(3);
342
+ expect(fx.start).toBe(0);
343
+ expect(fx.end).toBe(3);
344
344
  // Inner leaf action is relative (start=0)
345
- expect(fx.children[0].actions[0].start).toBe(0);
346
- expect(fx.children[0].actions[0].end).toBe(3);
345
+ expect(fx.children[0].start).toBe(0);
346
+ expect(fx.children[0].end).toBe(3);
347
347
  });
348
348
 
349
349
  it("compiles leaf node with effects as object specs", () => {
@@ -371,11 +371,11 @@ describe("compileDescriptiveRoot", () => {
371
371
  expect(fx.animationTimingFunction).toBe("ease-out");
372
372
  expect(fx.animationIterationCount).toBe(2);
373
373
  // Outermost effect carries the absolute timing from parallel layout
374
- expect(fx.actions[0].start).toBe(1);
375
- expect(fx.actions[0].end).toBe(3);
374
+ expect(fx.start).toBe(1);
375
+ expect(fx.end).toBe(3);
376
376
  // Inner leaf timing is relative
377
- expect(fx.children[0].actions[0].start).toBe(0);
378
- expect(fx.children[0].actions[0].end).toBe(2);
377
+ expect(fx.children[0].start).toBe(0);
378
+ expect(fx.children[0].end).toBe(2);
379
379
  });
380
380
 
381
381
  it("compiles effects on container (parallel) nodes", () => {
@@ -471,9 +471,9 @@ describe("compileDescriptiveRoot", () => {
471
471
  expect(fx.animationTimingFunction).toBe("ease-out");
472
472
  expect(fx.animationIterationCount).toBe(3);
473
473
  // Effect duration from param overrides to 2s
474
- expect(fx.actions[0].end - fx.actions[0].start).toBe(2);
474
+ expect(fx.end - fx.start).toBe(2);
475
475
  // Inner leaf still plays full 5s
476
- expect(fx.children[0].actions[0].end - fx.children[0].actions[0].start).toBe(5);
476
+ expect(fx.children[0].end - fx.children[0].start).toBe(5);
477
477
  });
478
478
 
479
479
  it("compiles effect with comma-separated positional params (duration, timingFunction, iterationCount)", () => {
@@ -495,7 +495,7 @@ describe("compileDescriptiveRoot", () => {
495
495
  expect(fx.animation).toBe("fadeIn");
496
496
  expect(fx.animationTimingFunction).toBe("ease-in");
497
497
  expect(fx.animationIterationCount).toBe(3);
498
- expect(fx.actions[0].end - fx.actions[0].start).toBe(2);
498
+ expect(fx.end - fx.start).toBe(2);
499
499
  });
500
500
 
501
501
  it("compiles effect with positional params — duration only", () => {
@@ -515,7 +515,7 @@ describe("compileDescriptiveRoot", () => {
515
515
  const fx = compiled.children[0] as any;
516
516
  expect(fx.type).toBe("effect");
517
517
  expect(fx.animation).toBe("fadeIn");
518
- expect(fx.actions[0].end - fx.actions[0].start).toBe(1.5);
518
+ expect(fx.end - fx.start).toBe(1.5);
519
519
  });
520
520
 
521
521
  it("compiles effect with partial positional params — duration + timingFunction", () => {
@@ -536,7 +536,7 @@ describe("compileDescriptiveRoot", () => {
536
536
  expect(fx.animation).toBe("fadeIn");
537
537
  expect(fx.animationTimingFunction).toBe("ease-out");
538
538
  expect(fx.animationIterationCount).toBe(1);
539
- expect(fx.actions[0].end - fx.actions[0].start).toBe(2.5);
539
+ expect(fx.end - fx.start).toBe(2.5);
540
540
  });
541
541
 
542
542
  it("supports deep nested layout containers across all modes", () => {
@@ -726,15 +726,15 @@ describe("compileDescriptiveRoot", () => {
726
726
  expect(rhythm.children).toHaveLength(4);
727
727
 
728
728
  // child[0] starts at beat 0.5, ends at beat 1.5 => duration 1
729
- expect(rhythm.children[0].actions[0].start).toBe(0.5);
730
- expect(rhythm.children[0].actions[0].end).toBe(1.5);
729
+ expect(rhythm.children[0].start).toBe(0.5);
730
+ expect(rhythm.children[0].end).toBe(1.5);
731
731
 
732
732
  // child[1] starts at beat 1.5, ends at beat 2.5 => duration 1
733
- expect(rhythm.children[1].actions[0].start).toBe(1.5);
734
- expect(rhythm.children[1].actions[0].end).toBe(2.5);
733
+ expect(rhythm.children[1].start).toBe(1.5);
734
+ expect(rhythm.children[1].end).toBe(2.5);
735
735
 
736
736
  // last child starts at last beat (3.5), extends by avg gap (1.0) => ends at 4.5
737
- expect(rhythm.children[3].actions[0].end).toBe(4.5);
737
+ expect(rhythm.children[3].end).toBe(4.5);
738
738
  // rhythm duration = last spot + avg gap
739
739
  expect(rhythm.durationInSeconds).toBe(4.5);
740
740
  });
@@ -778,11 +778,11 @@ describe("compileDescriptiveRoot", () => {
778
778
  expect(bgm.type).toBe("audio");
779
779
  expect(bgm.volume).toBe(0.3);
780
780
  expect(bgm.foreground).toBe(false);
781
- expect(bgm.actions[0].end).toBe(5);
781
+ expect(bgm.end).toBe(5);
782
782
 
783
783
  const sfx = compiled.children.find((c: any) => c.id === "sfx") as any;
784
- expect(sfx.actions[0].start).toBe(2);
785
- expect(sfx.actions[0].end).toBe(3);
784
+ expect(sfx.start).toBe(2);
785
+ expect(sfx.end).toBe(3);
786
786
  });
787
787
 
788
788
  it("compiles component node with props", () => {
@@ -801,7 +801,7 @@ describe("compileDescriptiveRoot", () => {
801
801
  const c = compiled.children[0] as any;
802
802
  expect(c.type).toBe("component");
803
803
  expect(c.jsx).toContain("AnimatedHeadline");
804
- expect(c.actions[0].end).toBe(3);
804
+ expect(c.end).toBe(3);
805
805
  });
806
806
 
807
807
  it("compiles rhythm without children as audio leaf", () => {
@@ -817,7 +817,7 @@ describe("compileDescriptiveRoot", () => {
817
817
  expect(r.src).toBe("beat.mp3");
818
818
  expect(r.children).toEqual([]);
819
819
  // rhythm duration = last spot + avg gap = 2.5 + 2.0 = 4.5
820
- expect(r.actions[0].end).toBeCloseTo(4.5);
820
+ expect(r.end).toBeCloseTo(4.5);
821
821
  });
822
822
 
823
823
  it("compiles include with src as leaf", () => {
@@ -831,7 +831,7 @@ describe("compileDescriptiveRoot", () => {
831
831
  const inc = compiled.children[0] as any;
832
832
  expect(inc.type).toBe("include");
833
833
  expect(inc.src).toBe("./child.json");
834
- expect(inc.actions[0].end).toBe(3);
834
+ expect(inc.end).toBe(3);
835
835
  });
836
836
 
837
837
  it("compiles effects:[] with named params (duration, timingFunction, iterationCount)", () => {
@@ -865,10 +865,10 @@ describe("compileDescriptiveRoot", () => {
865
865
  });
866
866
 
867
867
  const img = compiled.children[0] as any;
868
- expect(img.actions[0].end).toBe(3); // image default is 3s
868
+ expect(img.end).toBe(3); // image default is 3s
869
869
 
870
870
  const vid = compiled.children[1] as any;
871
- expect(vid.actions[0].end).toBe(3); // video default is 3s
871
+ expect(vid.end).toBe(3); // video default is 3s
872
872
  });
873
873
 
874
874
  it("uses default duration when duration is missing", () => {
@@ -879,7 +879,7 @@ describe("compileDescriptiveRoot", () => {
879
879
  ],
880
880
  });
881
881
  const img = compiled.children[0] as any;
882
- expect(img.actions[0].end).toBe(3); // image default is 3s
882
+ expect(img.end).toBe(3); // image default is 3s
883
883
  });
884
884
 
885
885
  it("preserves root-level instruction and stylesheet", () => {
@@ -916,7 +916,7 @@ describe("compileDescriptiveRoot", () => {
916
916
  expect(m.type).toBe("map");
917
917
  expect(m.waypoints).toHaveLength(2);
918
918
  expect(m.travelMode).toBe("DRIVING");
919
- expect(m.actions[0].end).toBe(5);
919
+ expect(m.end).toBe(5);
920
920
  });
921
921
 
922
922
  it("filters invisible children", () => {