@lalalic/markcut 3.0.0 → 3.1.1
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/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +328 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +101 -1
- package/src/descriptive/dsl.ts +64 -7
- package/src/descriptive/markdown.ts +7 -1
- package/src/descriptive/resolve.test.ts +103 -5
- package/src/descriptive/resolve.ts +207 -24
- package/src/player/bundle/player.js +223296 -222155
- package/src/player/pipeline.mjs +314 -25
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +60 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +1078 -130
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +78 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +35 -0
- package/tests/fixtures/md/map-children.md +11 -0
- package/tests/fixtures/md/map-multimode.md +9 -0
- package/tests/fixtures/streetview-walk.json +36 -0
- package/tests/md-descriptive.test.ts +133 -0
- package/tests/render.test.ts +93 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +76 -1
- package/tests/validate-assets.test.ts +106 -0
- package/B] +0 -2
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
package/package.json
CHANGED
package/skills/markcut/SKILL.md
CHANGED
|
@@ -36,6 +36,11 @@ Implies `--edit` so you can chat to reshape the story and see live reloads.
|
|
|
36
36
|
### Video Variants
|
|
37
37
|
- define variants for different video configurations, such as different languages, aspects, platforms
|
|
38
38
|
|
|
39
|
+
### Route / Vlog videos (map clips)
|
|
40
|
+
- For **route / travel-vlog clips** (drone flyover, route map with photo stops, Street View walks) use the `map` stream type with `view: overview | route | cinematic | streetview` and `tween(from,to,easing)` camera moves.
|
|
41
|
+
- Discover interesting stops along a route with `markcut spots --waypoints "lat,lng;lat,lng" --photos --markdown`, then compose a `map` storyboard from the returned `waypoints:[...]` line.
|
|
42
|
+
- See [docs/map-dynamic-camera.md](docs/map-dynamic-camera.md) — the agent guide with effects, spots workflow, and copy-paste markdown examples.
|
|
43
|
+
|
|
39
44
|
### Viral Story requires
|
|
40
45
|
- **Hooks** : why should the viewer watch this?
|
|
41
46
|
- **Conflict** : what challenges or obstacles do the characters face?
|
|
@@ -60,6 +65,7 @@ npx @lalalic/markcut preview <file> --storyboard # fast structure preview: repla
|
|
|
60
65
|
npx @lalalic/markcut render <file> # render the video to mp4
|
|
61
66
|
npx @lalalic/markcut vision <folder> # vision understanding medias in folder
|
|
62
67
|
npx @lalalic/markcut vision <folder> --label # an extra step to provide UI to label the medias with text, time ranges
|
|
68
|
+
npx @lalalic/markcut spots --waypoints "lat,lng;lat,lng" # discover POIs along a route (for route/vlog map clips)
|
|
63
69
|
|
|
64
70
|
```
|
|
65
71
|
|
|
@@ -80,6 +86,7 @@ some common issues (photo or video can't be displayed, audio missing), take belo
|
|
|
80
86
|
| Topic | File |
|
|
81
87
|
|-------|------|
|
|
82
88
|
| Markdown descriptive format (primary authoring format) | [docs/markdown-descriptive.md](docs/markdown-descriptive.md) |
|
|
89
|
+
| Route / vlog map clips (effects, spots, examples) | [docs/map-dynamic-camera.md](docs/map-dynamic-camera.md) |
|
|
83
90
|
| Built-in components & common npm packages | [docs/components.md](docs/components.md) |
|
|
84
91
|
| Sound effects | [docs/sound-effects.md](docs/sound-effects.md) |
|
|
85
92
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Route & Vlog Map Videos — Agent Guide
|
|
2
|
+
|
|
3
|
+
> Make route / travel-vlog video clips with the `map` stream type: establishing
|
|
4
|
+
> dolly shots, route flyovers with photo stops, cinematic chase cameras, and
|
|
5
|
+
> immersive Street View moves — all narrated by TTS. This is the **agent-facing**
|
|
6
|
+
> reference: what effects you can get, how to discover spots, and copy-paste
|
|
7
|
+
> markdown. No implementation details.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. When to reach for this
|
|
12
|
+
|
|
13
|
+
Use `map` clips when the video is about **a place and getting around**:
|
|
14
|
+
|
|
15
|
+
- travel vlog: "we drove from the Golden Gate to SFO, stopping for photos"
|
|
16
|
+
- city tour: establishing shot → flyover → street level
|
|
17
|
+
- route explainers: commute, road trip, walking tour, "how to get there"
|
|
18
|
+
|
|
19
|
+
Every map clip can carry a `script` narration next to it (see §7 for the
|
|
20
|
+
scene pattern that plays map + narration together).
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. The 4 views = 4 effects
|
|
25
|
+
|
|
26
|
+
`view` is the one field that decides the whole feel. Default is `route`.
|
|
27
|
+
|
|
28
|
+
| view | effect | best for |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `overview` | **Dolly** — camera starts far (satellite) and flies down to the city/street | establishing shot, "this is where we are" |
|
|
31
|
+
| `route` | **Route map** — path + animated marker + optional photo thumbnails at each stop | itinerary, travel log |
|
|
32
|
+
| `cinematic` | **Chase flyover** — camera follows the route like a drone, road always "forward-up" | exciting transitions between stops |
|
|
33
|
+
| `streetview` | **Street View** — immersive pan / tilt / walk at ground level | being on location, "here we are" |
|
|
34
|
+
|
|
35
|
+
Example — one line each:
|
|
36
|
+
|
|
37
|
+
```md
|
|
38
|
+
- map view:overview duration:4 camera:{zoom:tween(6, 12, easeInOut)}
|
|
39
|
+
- map view:route duration:6 waypoints:[...]
|
|
40
|
+
- map view:cinematic duration:8 waypoints:[...] cinematic:{tilt:tween(0, 45)}
|
|
41
|
+
- map view:streetview duration:6 streetView:{location:{lat:37.77,lng:-122.41}, pov:{heading:tween(200, 320)}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 3. Animating the camera — `tween()`
|
|
47
|
+
|
|
48
|
+
Any camera value can be **static** (a number) or **animated** with a tween
|
|
49
|
+
expression written directly in markdown:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
tween(from, to, easing?)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| easing | feel |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `easeInOut` (default) | smooth ease at both ends — default for camera moves |
|
|
58
|
+
| `easeIn` | starts slow, finishes fast |
|
|
59
|
+
| `easeOut` | starts fast, settles |
|
|
60
|
+
| `linear` | constant speed — use for constant pan |
|
|
61
|
+
|
|
62
|
+
Fields that accept `tween(...)`:
|
|
63
|
+
|
|
64
|
+
- `camera.zoom` — `tween(6, 12)` = dolly in (higher zoom = closer)
|
|
65
|
+
- `camera.heading` — `tween(0, 90)` = rotate the map
|
|
66
|
+
- `camera.tilt` — `tween(0, 45)` = tilt reveal (top-down → perspective)
|
|
67
|
+
- `camera.center.{lat,lng}` — fly across the map
|
|
68
|
+
- `cinematic.tilt` / `cinematic.range` / `cinematic.roll` — 3D camera moves
|
|
69
|
+
- `streetView.pov.heading` — pan: `tween(200, 320)` sweeps the view;
|
|
70
|
+
`tween(200, 560)` = whip pan
|
|
71
|
+
- `streetView.pov.pitch` — `tween(0, -10)` tilts from sky to street
|
|
72
|
+
- `streetView.zoom` — Street View field-of-view: `tween(0, 1)` = dolly-zoom feel
|
|
73
|
+
|
|
74
|
+
Rules of thumb:
|
|
75
|
+
|
|
76
|
+
- **Zoom is not linear in perceived distance** — use `easeInOut` to hide the
|
|
77
|
+
"pop". For a strong dolly: `zoom:tween(5, 13)` (city region → streets).
|
|
78
|
+
- Combine tweens for a richer move, e.g. dolly + tilt reveal:
|
|
79
|
+
`camera:{zoom:tween(12, 14, easeInOut)} cinematic:{tilt:tween(0, 45, easeInOut)}`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 4. Photo stops (the vlog touch)
|
|
84
|
+
|
|
85
|
+
`waypoints` take an optional **4th field = media** (image path or URL). It
|
|
86
|
+
renders as a small thumbnail marker at that stop — perfect for "here's the
|
|
87
|
+
place" moments in a vlog:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
[lat,lng,"Label"] # plain stop
|
|
91
|
+
[lat,lng,"Label","assets/photo.jpg"] # stop with photo thumbnail
|
|
92
|
+
[lat,lng,"","assets/photo.jpg"] # empty label = thumbnail only
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Full example:
|
|
96
|
+
|
|
97
|
+
```md
|
|
98
|
+
- map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗"
|
|
99
|
+
waypoints:[37.8199,-122.4783,"Golden Gate","https://picsum.photos/seed/gg/96/96"; 37.7749,-122.4194,"Civic Center","https://picsum.photos/seed/civic/96/96"; 37.6213,-122.3790,"SFO","https://picsum.photos/seed/sfo/96/96"]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 5. Per-leg travel modes (flight ✈️ / boat 🚢 / walk 🚶 / drive 🚗)
|
|
105
|
+
|
|
106
|
+
A route is a sequence of **legs** — one per consecutive waypoint pair. Each
|
|
107
|
+
waypoint can tag the leg that **leaves it** with its own travel mode. The
|
|
108
|
+
parser is smart: a **bare (unquoted) mode word anywhere after lat/lng** is the
|
|
109
|
+
outgoing-leg mode — no empty slots needed. Untagged waypoints fall back to
|
|
110
|
+
the map's `travelMode` (default DRIVING).
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
[lat,lng,"Label","media",MODE] # full
|
|
114
|
+
[lat,lng,"Label",MODE] # mode without media (no empty "") — smart
|
|
115
|
+
[lat,lng,MODE] # just a mode
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| mode | how it renders |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `DRIVING` (default) | Google Directions road route — solid line, 🚗 marker |
|
|
121
|
+
| `WALKING` | Google Directions pedestrian route — 🚶 marker |
|
|
122
|
+
| `BICYCLING` | Google Directions bike route — 🚲 marker |
|
|
123
|
+
| `TRANSIT` | Google Directions transit route — 🚌 marker |
|
|
124
|
+
| `FLIGHT` | **synthetic great-circle arc** (no road) — dashed yellow line, ✈️ marker |
|
|
125
|
+
| `BOAT` | **synthetic arc across water** — dashed teal line, 🚢 marker |
|
|
126
|
+
|
|
127
|
+
FLIGHT/BOAT have no Directions route, so they're drawn as curved "as the crow
|
|
128
|
+
flies" arcs and timed by distance at a cruise speed (✈️ ~850 km/h, 🚢 ~40 km/h).
|
|
129
|
+
The traveling marker switches glyph to the current leg's mode as it moves, and
|
|
130
|
+
time is split across legs **proportionally to each leg's duration**.
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
- A mode word must be **unquoted** — quote it (`"FLIGHT"`) and it's a literal
|
|
134
|
+
label/media instead. That's the escape hatch if a media file is literally
|
|
135
|
+
named `FLIGHT`.
|
|
136
|
+
- Quoted-empty `""` slots are still accepted (backward compat).
|
|
137
|
+
- Remaining values map positionally: 1st = label, 2nd = media.
|
|
138
|
+
|
|
139
|
+
Example — fly in, take a boat, walk, then drive:
|
|
140
|
+
|
|
141
|
+
```md
|
|
142
|
+
- map view:route duration:12 travelMode:DRIVING
|
|
143
|
+
waypoints:[37.8199,-122.4783,"SFO",FLIGHT; 33.94,-118.41,"LAX",BOAT; 33.75,-118.28,"Long Beach",WALKING; 33.77,-118.19,"Promenade",DRIVING; 33.94,-118.41,"LAX"]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Legs above: SFO→LAX by ✈️, LAX→Long Beach by 🚢, Long Beach→Promenade by 🚶,
|
|
147
|
+
Promenade→LAX by 🚗 (the last waypoint has no outgoing leg, so its mode is
|
|
148
|
+
ignored).
|
|
149
|
+
|
|
150
|
+
## 6. Stops & anchored overlays (the vlog pattern)
|
|
151
|
+
|
|
152
|
+
The typical vlog: the pin moves leg-to-leg, then **stops at a hotspot** while a
|
|
153
|
+
photo/video/narration plays, then continues. Make other streams **children of
|
|
154
|
+
the map** and anchor them with `at:"WaypointLabel"` — the map renders them at
|
|
155
|
+
that waypoint's screen position (projected from the live map), so a photo
|
|
156
|
+
grows out of the pin's location.
|
|
157
|
+
|
|
158
|
+
```md
|
|
159
|
+
- map view:route duration:16 travelMode:DRIVING
|
|
160
|
+
waypoints:[37.81,-122.48,"Golden Gate"; 37.77,-122.42,"Civic Center"; 37.62,-122.38,"SFO"]
|
|
161
|
+
- image src:gg.jpg at:"Golden Gate" duration:3 effects:[zoomIn]
|
|
162
|
+
- image src:civic.jpg at:"Civic Center" duration:3 effects:[zoomIn]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `at:"Label"` anchors the child at the waypoint with that label. The renderer
|
|
166
|
+
projects the waypoint's lat/lng to its screen pixel each frame and centers
|
|
167
|
+
the child there — combine with `effects:[zoomIn]` for a "grows out of the
|
|
168
|
+
pin" reveal.
|
|
169
|
+
- Children **without** `at` render full-screen over the map.
|
|
170
|
+
- **Auto-timing (default)** — give the child a `duration` and *no* `start`; at
|
|
171
|
+
resolve time the map computes each leg's travel time (Directions REST for
|
|
172
|
+
road modes, cruise speed for `FLIGHT`/`BOAT`) and schedules the child to
|
|
173
|
+
start **at the pin's arrival** at that waypoint. The pin pauses (dwells)
|
|
174
|
+
while the child plays, then resumes. Real drive times are used only for
|
|
175
|
+
*ratios* — the video length stays the map's `duration`, or a default 10s
|
|
176
|
+
drive budget + dwells when the map has none, so a 40-minute drive still
|
|
177
|
+
plays as a short clip with the pin timing proportional to each leg.
|
|
178
|
+
- **Manual timing (opt-out)** — set `start` explicitly and the resolver leaves
|
|
179
|
+
it untouched (it still contributes to the pin's pause window).
|
|
180
|
+
- The map's own `duration`/`end` defines its span; children run inside it.
|
|
181
|
+
|
|
182
|
+
## 7. Discovering spots along a route — `markcut spots`
|
|
183
|
+
|
|
184
|
+
Don't hand-pick coordinates for a route video — **discover** interesting stops
|
|
185
|
+
with the spots CLI. It samples points along a route (Directions API) and ranks
|
|
186
|
+
nearby places by `rating × log(ratings)`, then hands you ready-to-paste
|
|
187
|
+
markdown.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npx @lalalic/markcut spots --waypoints "37.8199,-122.4783;37.6213,-122.3790" \
|
|
191
|
+
--travelMode DRIVING --limit 8 --photos --markdown
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| flag | meaning |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `--waypoints "lat,lng;lat,lng"` | route endpoints (semicolon-separated) — required |
|
|
197
|
+
| `--travelMode DRIVING\|WALKING\|BICYCLING` | how you travel the route (default DRIVING) |
|
|
198
|
+
| `--limit <n>` | max spots after ranking (default 8) |
|
|
199
|
+
| `--photos` | attach a photo URL per spot (becomes `waypoint.media`) |
|
|
200
|
+
| `--markdown` | print a copy-paste `waypoints:[...]` line to stderr |
|
|
201
|
+
| `--output spots.json` | also save the full JSON |
|
|
202
|
+
|
|
203
|
+
**Agent workflow — the tool only discovers, you compose:**
|
|
204
|
+
|
|
205
|
+
1. Get route endpoints from the user's media/GPS or pick two landmarks.
|
|
206
|
+
2. `markcut spots --waypoints "..." --photos --markdown`
|
|
207
|
+
3. Pick the 2–5 spots that fit the narrative arc (opening, highlights, ending).
|
|
208
|
+
4. Build a `map` storyboard with those `waypoints` + `script` narration.
|
|
209
|
+
5. `markcut preview` to check, `markcut render` to export.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 8. Markdown examples (copy-paste)
|
|
214
|
+
|
|
215
|
+
### 6.1 Simple route clip with narration
|
|
216
|
+
|
|
217
|
+
Map + narration play together inside a `layout:parallel` scene; scenes are
|
|
218
|
+
sequenced by the root `layout:series`:
|
|
219
|
+
|
|
220
|
+
```md
|
|
221
|
+
# video
|
|
222
|
+
width:1080 height:1920 layout:series transition:fade transitionTime:0.5
|
|
223
|
+
|
|
224
|
+
## The-Drive
|
|
225
|
+
layout:parallel
|
|
226
|
+
- script "We leave the Golden Gate and head down to the airport."
|
|
227
|
+
- map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗"
|
|
228
|
+
waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 6.2 Establishing dolly → cinematic chase (two scenes)
|
|
232
|
+
|
|
233
|
+
```md
|
|
234
|
+
# video
|
|
235
|
+
width:1080 height:1920 layout:series transition:fade
|
|
236
|
+
|
|
237
|
+
## Opening-Dive-In
|
|
238
|
+
layout:parallel
|
|
239
|
+
- script "High above San Francisco, then we dive into the streets."
|
|
240
|
+
- map view:overview mapType:satellite duration:4 camera:{zoom:tween(6, 12, easeInOut)}
|
|
241
|
+
|
|
242
|
+
## Chase-The-Road
|
|
243
|
+
layout:parallel
|
|
244
|
+
- script "The camera chases the route like a drone."
|
|
245
|
+
- map view:cinematic duration:8 travelMode:DRIVING mapType:satellite routeMarker:"🚗"
|
|
246
|
+
cinematic:{mode:flyAlong, headingFollow:true, tilt:tween(0, 45, easeInOut)}
|
|
247
|
+
camera:{zoom:tween(12, 14, easeInOut)}
|
|
248
|
+
waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 6.3 Street-level: pan, then walk
|
|
252
|
+
|
|
253
|
+
```md
|
|
254
|
+
# video
|
|
255
|
+
width:1080 height:1920 layout:series transition:fade
|
|
256
|
+
|
|
257
|
+
## On-The-Street-Pan
|
|
258
|
+
layout:parallel
|
|
259
|
+
- script "And here we are, right on the sidewalk."
|
|
260
|
+
- map view:streetview duration:8 streetView:{location:{lat:37.7793,lng:-122.4193}, radius:50, pov:{heading:tween(200, 420, easeInOut), pitch:tween(0, -8)}, zoom:tween(0, 0.6, easeInOut)}
|
|
261
|
+
|
|
262
|
+
## A-Quick-Walk
|
|
263
|
+
layout:parallel
|
|
264
|
+
- script "A short stroll down the block."
|
|
265
|
+
- map view:streetview duration:6 streetView:{route:[{lat:37.7785,lng:-122.4185},{lat:37.7777,lng:-122.4178}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
> **Walk = snap between covered waypoints.** A `route` walk holds at each
|
|
269
|
+
> waypoint's panorama and jumps between them (not continuous per-frame
|
|
270
|
+
> movement — that would fire hundreds of Street View requests and get
|
|
271
|
+
> rate-limited into black frames). Every waypoint needs imagery; gaps show the
|
|
272
|
+
> last loaded panorama instead of black.
|
|
273
|
+
|
|
274
|
+
### 6.4 Full route-vlog recipe (5 scenes)
|
|
275
|
+
|
|
276
|
+
Combine everything for a complete vlog arc: **establish → route with photos →
|
|
277
|
+
cinematic → street view pan → walk**.
|
|
278
|
+
|
|
279
|
+
```md
|
|
280
|
+
# video
|
|
281
|
+
width:1080 height:1920 layout:series transition:fade transitionTime:0.5
|
|
282
|
+
|
|
283
|
+
## Satellite-Dolly
|
|
284
|
+
layout:parallel
|
|
285
|
+
- script "We begin high above San Francisco, then dive into the city."
|
|
286
|
+
- map view:overview mapType:satellite duration:4 center:{lat:37.7749,lng:-122.4194} camera:{zoom:tween(6, 12, easeInOut)}
|
|
287
|
+
|
|
288
|
+
## Route
|
|
289
|
+
layout:parallel
|
|
290
|
+
- script "The route winds from the Golden Gate to the airport, with photos at each stop."
|
|
291
|
+
- map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗" waypoints:[37.8199,-122.4783,"Golden Gate","https://picsum.photos/seed/gg-bridge/96/96"; 37.7749,-122.4194,"Civic Center","https://picsum.photos/seed/civic-center/96/96"; 37.6213,-122.3790,"SFO","https://picsum.photos/seed/sfo-airport/96/96"]
|
|
292
|
+
|
|
293
|
+
## Cinematic
|
|
294
|
+
layout:parallel
|
|
295
|
+
- script "The camera tilts and chases the road like a drone."
|
|
296
|
+
- map view:cinematic duration:8 travelMode:DRIVING mapType:satellite routeMarker:"🚗" cinematic:{mode:flyAlong, headingFollow:true, tilt:tween(0, 45, easeInOut)} camera:{zoom:tween(12, 14, easeInOut)} waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
|
|
297
|
+
|
|
298
|
+
## Street-View
|
|
299
|
+
layout:parallel
|
|
300
|
+
- script "And finally, we land on the street itself."
|
|
301
|
+
- map view:streetview duration:8 streetView:{location:{lat:37.7793,lng:-122.4193}, radius:50, pov:{heading:tween(200, 420, easeInOut), pitch:tween(0, -8)}, zoom:tween(0, 0.6, easeInOut)}
|
|
302
|
+
|
|
303
|
+
## Street-View-Walk
|
|
304
|
+
layout:parallel
|
|
305
|
+
- script "A quick walk down the block."
|
|
306
|
+
- map view:streetview duration:6 streetView:{route:[{lat:37.7785,lng:-122.4185},{lat:37.7777,lng:-122.4178}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## 9. Recipe — build a route/vlog clip
|
|
312
|
+
|
|
313
|
+
1. **Get the route** — endpoints from the user's media/GPS, or two landmarks.
|
|
314
|
+
2. **Discover spots** — `markcut spots --waypoints "lat,lng;lat,lng" --photos --markdown`; pick 2–5 that tell the story.
|
|
315
|
+
3. **Pick views per scene** — overview (establish) → route with photo stops (log) → cinematic (transition) → streetview (arrive).
|
|
316
|
+
4. **Write narration** — one `script` per scene; the resolver sizes the clip from the TTS audio automatically (don't hand-set durations that depend on script length).
|
|
317
|
+
5. **Verify** — `markcut preview book.md` (screenshot key frames, check map/pano loaded and captions), then `markcut render book.md --output out/vlog.mp4`.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 10. Tips & pitfalls
|
|
322
|
+
|
|
323
|
+
- **Map + narration**: put `- map ...` and `- script "..."` as sibling bullets in a `layout:parallel` scene so they play together. If the scene is `series`, narration plays alone first and the map shows nothing while speaking.
|
|
324
|
+
- **Street View coverage is not universal** — a spot may have no panorama (Google returns "no imagery here"); interpolating between sparse points also crosses uncovered blocks. Pick waypoints you've verified have imagery (open the coords in Google Maps, drag the pegman), keep a `route` walk's waypoints on the same covered street, and set `radius` (meters) so the panorama search is constrained nearby. If a `streetview` scene still looks dark, use `view:cinematic` instead.
|
|
325
|
+
- **Route maps need ≥2 waypoints**; a single point falls back to a plain marker.
|
|
326
|
+
- **Media assets** — photo thumbnails (`waypoint.media`), images, bgm etc. should live in the md file's folder (e.g. `assets/...`). See the asset-path rules in `markdown-descriptive.md` §14 (verify enforces md-folder-relative paths).
|
|
327
|
+
- **Don't set duration from script length** — the resolver computes it from TTS audio.
|
|
328
|
+
- **3D (Map3D)** is experimental and opt-in (`cinematic.fallback:"none"`); the default `fallback:"2d"` gives the safe drone-chase look.
|
|
@@ -268,6 +268,8 @@ all src paths are resolved relative to the markdown file's location. For example
|
|
|
268
268
|
### 14. verify
|
|
269
269
|
- all assets path are resolved relative to the markdown file's location.
|
|
270
270
|
- `npx @lalalic/markcut verify book.md` to verify the markdown document is valid and can be rendered. It will check for missing required fields, invalid values, and other common issues.
|
|
271
|
+
- **Asset-relative check**: `verify` also walks the tree and requires every local asset (image/video/audio/subtitle `.vtt`, map `waypoints[].media`, root `subtitle.src`) to be a path **relative to the markdown file's folder** — no absolute paths (`/...`), no `..` escapes. Remote URLs (`https:`, `data:`, etc.) and inline subtitle text are fine. Violations are printed as actionable errors (exit 1) so an agent can fix the source and re-run.
|
|
272
|
+
- `markcut render` runs the same check on the compiled tree right before rendering and aborts with errors instead of failing mid-render with 404s.
|
|
271
273
|
|
|
272
274
|
|
|
273
275
|
## Template Variables
|
|
@@ -137,11 +137,22 @@ export interface DescriptiveMapWaypoint {
|
|
|
137
137
|
lng: number;
|
|
138
138
|
label?: string;
|
|
139
139
|
media?: string;
|
|
140
|
+
/** Travel mode for the leg leaving this waypoint (DRIVING|WALKING|BICYCLING|TRANSIT|FLIGHT|BOAT). */
|
|
141
|
+
mode?: string;
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/** A tween expression `tween(from, to, easing?)` parsed into a tagged spec. */
|
|
145
|
+
export interface DescriptiveTween {
|
|
146
|
+
__tween: Array<number | string>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A static number OR an animated tween expression. */
|
|
150
|
+
export type DescriptiveTweenable = number | DescriptiveTween;
|
|
151
|
+
|
|
142
152
|
export interface DescriptiveMap extends DescriptiveBaseNode {
|
|
143
153
|
type: "map";
|
|
144
154
|
waypoints: DescriptiveMapWaypoint[];
|
|
155
|
+
view?: "overview" | "route" | "cinematic" | "streetview";
|
|
145
156
|
routeColor?: string;
|
|
146
157
|
routeWeight?: number;
|
|
147
158
|
zoom?: number;
|
|
@@ -151,6 +162,37 @@ export interface DescriptiveMap extends DescriptiveBaseNode {
|
|
|
151
162
|
region?: string;
|
|
152
163
|
travelMode?: "DRIVING" | "WALKING" | "BICYCLING" | "TRANSIT";
|
|
153
164
|
routeMarker?: string;
|
|
165
|
+
camera?: {
|
|
166
|
+
zoom?: DescriptiveTweenable;
|
|
167
|
+
center?: { lat: DescriptiveTweenable; lng: DescriptiveTweenable };
|
|
168
|
+
heading?: DescriptiveTweenable;
|
|
169
|
+
tilt?: DescriptiveTweenable;
|
|
170
|
+
};
|
|
171
|
+
cinematic?: {
|
|
172
|
+
mode?: "flyAlong" | "flyTo" | "orbit";
|
|
173
|
+
followRoute?: boolean;
|
|
174
|
+
headingFollow?: boolean;
|
|
175
|
+
tilt?: DescriptiveTweenable;
|
|
176
|
+
range?: DescriptiveTweenable;
|
|
177
|
+
altitude?: number;
|
|
178
|
+
roll?: DescriptiveTweenable;
|
|
179
|
+
fallback?: "2d" | "none";
|
|
180
|
+
};
|
|
181
|
+
streetView?: {
|
|
182
|
+
pano?: string;
|
|
183
|
+
location?: { lat: number; lng: number };
|
|
184
|
+
route?: Array<{ lat: number; lng: number }>;
|
|
185
|
+
radius?: number;
|
|
186
|
+
source?: "default" | "outdoor" | "indoor";
|
|
187
|
+
zoom?: DescriptiveTweenable;
|
|
188
|
+
pov?: {
|
|
189
|
+
heading?: DescriptiveTweenable;
|
|
190
|
+
pitch?: DescriptiveTweenable;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
/** Overlay children rendered on top of the map.
|
|
194
|
+
* A child with `at:"Label"` is positioned at that waypoint's screen pixel. */
|
|
195
|
+
children?: DescriptiveNode[];
|
|
154
196
|
}
|
|
155
197
|
|
|
156
198
|
export interface DescriptiveContainer extends DescriptiveBaseNode {
|
|
@@ -494,6 +536,7 @@ function wrapWithEffects(
|
|
|
494
536
|
start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
|
|
495
537
|
end: isOutermost && isBgNoEnd ? undefined : effEnd,
|
|
496
538
|
visible: innerStream.visible ?? true,
|
|
539
|
+
at: (node as any).at,
|
|
497
540
|
...pickOn(node),
|
|
498
541
|
} as Effect;
|
|
499
542
|
}
|
|
@@ -523,6 +566,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
523
566
|
style: node.style,
|
|
524
567
|
visible: node.visible ?? true,
|
|
525
568
|
isBackground: node.isBackground,
|
|
569
|
+
at: (node as any).at,
|
|
526
570
|
start: isBgNoOwnTiming ? (typeof node.start === "number" ? node.start : undefined) : start,
|
|
527
571
|
end,
|
|
528
572
|
startFrom: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.startFrom : undefined),
|
|
@@ -601,6 +645,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
601
645
|
const stream: MapStream = {
|
|
602
646
|
...base,
|
|
603
647
|
type: "map",
|
|
648
|
+
view: node.view ?? "route",
|
|
604
649
|
waypoints: node.waypoints,
|
|
605
650
|
routeColor: node.routeColor ?? "#4285F4",
|
|
606
651
|
routeWeight: node.routeWeight ?? 4,
|
|
@@ -611,6 +656,9 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
611
656
|
region: node.region,
|
|
612
657
|
travelMode: node.travelMode ?? "DRIVING",
|
|
613
658
|
routeMarker: node.routeMarker ?? "🚗",
|
|
659
|
+
camera: node.camera,
|
|
660
|
+
cinematic: node.cinematic,
|
|
661
|
+
streetView: node.streetView,
|
|
614
662
|
googleMapsApiKey: ctx.googleMapsApiKey,
|
|
615
663
|
};
|
|
616
664
|
return { stream, duration: end ?? 0 };
|
|
@@ -636,7 +684,7 @@ function compileChildren(
|
|
|
636
684
|
} else if (isRhythm(child)) {
|
|
637
685
|
result = compileRhythm(child, ctx, parentKind);
|
|
638
686
|
} else if (isMap(child)) {
|
|
639
|
-
result =
|
|
687
|
+
result = compileMap(child, ctx, parentKind);
|
|
640
688
|
} else {
|
|
641
689
|
result = compileLeaf(child, ctx, parentKind);
|
|
642
690
|
}
|
|
@@ -852,6 +900,58 @@ function compileRhythm(
|
|
|
852
900
|
return { stream, duration: end };
|
|
853
901
|
}
|
|
854
902
|
|
|
903
|
+
function compileMap(
|
|
904
|
+
node: DescriptiveMap,
|
|
905
|
+
ctx: CompileContext,
|
|
906
|
+
parentKind: "series" | "parallel" | "transitionSeries",
|
|
907
|
+
): CompileResult {
|
|
908
|
+
const id = node.id ?? uid();
|
|
909
|
+
const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
|
|
910
|
+
const ownDuration = deriveLeafDuration(node as any, ctx);
|
|
911
|
+
const end = ownDuration != null ? start + ownDuration : undefined;
|
|
912
|
+
|
|
913
|
+
// Compile overlay children (they play in parallel on top of the map).
|
|
914
|
+
// Children with `at:"Label"` carry it through compilation on their base.
|
|
915
|
+
const children = node.children ?? [];
|
|
916
|
+
const compiledChildren = children.length
|
|
917
|
+
? compileChildren(children, ctx, "parallel")
|
|
918
|
+
: [];
|
|
919
|
+
|
|
920
|
+
// Map duration covers its own span + any children that extend past it.
|
|
921
|
+
const maxChildEnd = compiledChildren.reduce((max, c) => Math.max(max, c.duration), 0);
|
|
922
|
+
const mapDuration = Math.max(end ?? 0, maxChildEnd, ownDuration ?? 0);
|
|
923
|
+
|
|
924
|
+
const stream: MapStream = {
|
|
925
|
+
id,
|
|
926
|
+
type: "map",
|
|
927
|
+
style: node.style,
|
|
928
|
+
visible: node.visible ?? true,
|
|
929
|
+
isBackground: node.isBackground,
|
|
930
|
+
start,
|
|
931
|
+
end: mapDuration,
|
|
932
|
+
durationInSeconds: mapDuration,
|
|
933
|
+
view: node.view ?? "route",
|
|
934
|
+
waypoints: node.waypoints,
|
|
935
|
+
routeColor: node.routeColor ?? "#4285F4",
|
|
936
|
+
routeWeight: node.routeWeight ?? 4,
|
|
937
|
+
zoom: node.zoom ?? 10,
|
|
938
|
+
center: node.center,
|
|
939
|
+
mapType: node.mapType ?? "roadmap",
|
|
940
|
+
language: node.language,
|
|
941
|
+
region: node.region,
|
|
942
|
+
travelMode: node.travelMode ?? "DRIVING",
|
|
943
|
+
routeMarker: node.routeMarker ?? "🚗",
|
|
944
|
+
camera: node.camera,
|
|
945
|
+
cinematic: node.cinematic,
|
|
946
|
+
streetView: node.streetView,
|
|
947
|
+
googleMapsApiKey: ctx.googleMapsApiKey,
|
|
948
|
+
children: compiledChildren.map((c) => c.stream),
|
|
949
|
+
...pickOn(node),
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
return { stream, duration: mapDuration };
|
|
953
|
+
}
|
|
954
|
+
|
|
855
955
|
function compileContainer(node: DescriptiveContainer, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
|
|
856
956
|
const id = node.id ?? uid();
|
|
857
957
|
ensureUniqueIds(node.children, id);
|
package/src/descriptive/dsl.ts
CHANGED
|
@@ -162,7 +162,17 @@ export function parseNumberMaybe(v: string): number | string | boolean {
|
|
|
162
162
|
* Parse a map `waypoints:[...]` value into an array of waypoints.
|
|
163
163
|
*
|
|
164
164
|
* Format: `[lat,lng,"Label"; lat,lng,"Label"]` — semicolon-separated entries,
|
|
165
|
-
* comma-separated fields.
|
|
165
|
+
* comma-separated fields. After lat/lng, the optional fields are parsed
|
|
166
|
+
* smartly:
|
|
167
|
+
* - a BARE (unquoted) token that names a travel mode (DRIVING, WALKING,
|
|
168
|
+
* BICYCLING, TRANSIT, FLIGHT, BOAT) is the outgoing-leg mode, wherever it
|
|
169
|
+
* appears — so `[lat,lng,"Label",FLIGHT]` or `[lat,lng,FLIGHT]` work with
|
|
170
|
+
* no empty media slot;
|
|
171
|
+
* - quoted-empty `""` slots are skipped (backward compat for
|
|
172
|
+
* `[lat,lng,"Label","",MODE]`);
|
|
173
|
+
* - remaining values map positionally: 1st = label, 2nd = media.
|
|
174
|
+
* Quoted tokens are always literal (a quoted "FLIGHT" is a label/media, not a
|
|
175
|
+
* mode). Returns `[]` on any structural mismatch.
|
|
166
176
|
*/
|
|
167
177
|
export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
|
|
168
178
|
const s = raw.trim();
|
|
@@ -173,29 +183,76 @@ export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
|
|
|
173
183
|
const bits = splitTokens(part.replace(/,/g, " "));
|
|
174
184
|
const lat = Number(bits[0] ?? 0);
|
|
175
185
|
const lng = Number(bits[1] ?? 0);
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
186
|
+
let mode: string | undefined;
|
|
187
|
+
const values: string[] = [];
|
|
188
|
+
for (const tok of bits.slice(2)) {
|
|
189
|
+
const value = unquote(tok);
|
|
190
|
+
if (!isQuoted(tok) && value && KNOWN_TRAVEL_MODES.has(value.toUpperCase())) {
|
|
191
|
+
mode = value.toUpperCase();
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
values.push(value); // keep position — a quoted-empty "" means "no value here"
|
|
195
|
+
}
|
|
196
|
+
const label = values[0] || undefined;
|
|
197
|
+
const media = values[1] || undefined;
|
|
198
|
+
return { lat, lng, label, media, mode };
|
|
179
199
|
});
|
|
180
200
|
}
|
|
181
201
|
|
|
202
|
+
/** Bare (unquoted) waypoint tokens recognized as a travel mode. */
|
|
203
|
+
const KNOWN_TRAVEL_MODES = new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT", "FLIGHT", "BOAT"]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rewrite `tween(from, to, easing?)` expressions inside a JSON-ish string into
|
|
207
|
+
* a tagged literal `{"__tween":[from,to,"easing"]}` so the regular JSON parser
|
|
208
|
+
* can handle them. `from`/`to` may be numbers or quoted strings (e.g. colors);
|
|
209
|
+
* `easing` is a bare word or quoted string. No eval — deterministic, no scope.
|
|
210
|
+
*
|
|
211
|
+
* tween(6, 12, easeInOut) → {"__tween":[6,12,"easeInOut"]}
|
|
212
|
+
*/
|
|
213
|
+
function rewriteTweenExprs(s: string): string {
|
|
214
|
+
return s.replace(
|
|
215
|
+
/tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
|
|
216
|
+
(_match, fromRaw: string, toRaw: string, easingRaw?: string) => {
|
|
217
|
+
// Scalar → JSON literal: numbers stay, quoted strings stay, bare words get quoted.
|
|
218
|
+
const scalar = (v: string): string => {
|
|
219
|
+
const t = v.trim();
|
|
220
|
+
if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
|
|
221
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
|
|
222
|
+
if (t === "true" || t === "false" || t === "null") return t;
|
|
223
|
+
return JSON.stringify(t);
|
|
224
|
+
};
|
|
225
|
+
const items = [scalar(fromRaw), scalar(toRaw)];
|
|
226
|
+
if (easingRaw !== undefined && easingRaw.trim()) {
|
|
227
|
+
items.push(scalar(easingRaw));
|
|
228
|
+
}
|
|
229
|
+
return `{"__tween":[${items.join(",")}]}`;
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
182
234
|
/**
|
|
183
235
|
* Parse a JSON-like props/imports string into an object or array.
|
|
184
236
|
*
|
|
185
237
|
* Accepts standard JSON, then falls back to a lenient two-pass normalization
|
|
186
238
|
* that quotes bare keys (`{foo:` → `{"foo":`) and bare string values, and
|
|
187
239
|
* finally to `eval` for JSX-like expressions. Returns `{}` on total failure.
|
|
240
|
+
*
|
|
241
|
+
* Also understands `tween(from, to, easing?)` expressions (see
|
|
242
|
+
* {@link rewriteTweenExprs}), which are rewritten to a tagged JSON literal
|
|
243
|
+
* before parsing so camera/map configs can animate values.
|
|
188
244
|
*/
|
|
189
245
|
export function parseProps(raw: string): unknown {
|
|
190
246
|
const s = raw.trim();
|
|
191
247
|
if (!s.startsWith("{") && !s.startsWith("[")) return {};
|
|
192
248
|
if (!s.endsWith("}") && !s.endsWith("]")) return {};
|
|
249
|
+
const withTweens = rewriteTweenExprs(s);
|
|
193
250
|
try {
|
|
194
|
-
return JSON.parse(
|
|
251
|
+
return JSON.parse(withTweens);
|
|
195
252
|
} catch {
|
|
196
253
|
// Lenient parse: add quotes around unquoted keys and string values.
|
|
197
254
|
// First pass: quote bare keys: {foo: → {"foo":
|
|
198
|
-
let normalized =
|
|
255
|
+
let normalized = withTweens.replace(
|
|
199
256
|
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
|
|
200
257
|
'$1"$2":',
|
|
201
258
|
);
|
|
@@ -214,7 +271,7 @@ export function parseProps(raw: string): unknown {
|
|
|
214
271
|
} catch {
|
|
215
272
|
// Last resort: eval (safe since this is a CLI tool)
|
|
216
273
|
try {
|
|
217
|
-
const result = (0, eval)("(" +
|
|
274
|
+
const result = (0, eval)("(" + withTweens + ")");
|
|
218
275
|
return typeof result === "object" && result !== null ? result : {};
|
|
219
276
|
} catch {
|
|
220
277
|
return {};
|