@lalalic/markcut 3.0.0 → 3.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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +41 -0
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +5 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +448 -99
- package/src/player/pipeline.mjs +64 -13
- 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 +56 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Map.tsx +501 -127
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -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,244 @@
|
|
|
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 §6 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. Discovering spots along a route — `markcut spots`
|
|
105
|
+
|
|
106
|
+
Don't hand-pick coordinates for a route video — **discover** interesting stops
|
|
107
|
+
with the spots CLI. It samples points along a route (Directions API) and ranks
|
|
108
|
+
nearby places by `rating × log(ratings)`, then hands you ready-to-paste
|
|
109
|
+
markdown.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx @lalalic/markcut spots --waypoints "37.8199,-122.4783;37.6213,-122.3790" \
|
|
113
|
+
--travelMode DRIVING --limit 8 --photos --markdown
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| flag | meaning |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `--waypoints "lat,lng;lat,lng"` | route endpoints (semicolon-separated) — required |
|
|
119
|
+
| `--travelMode DRIVING\|WALKING\|BICYCLING` | how you travel the route (default DRIVING) |
|
|
120
|
+
| `--limit <n>` | max spots after ranking (default 8) |
|
|
121
|
+
| `--photos` | attach a photo URL per spot (becomes `waypoint.media`) |
|
|
122
|
+
| `--markdown` | print a copy-paste `waypoints:[...]` line to stderr |
|
|
123
|
+
| `--output spots.json` | also save the full JSON |
|
|
124
|
+
|
|
125
|
+
**Agent workflow — the tool only discovers, you compose:**
|
|
126
|
+
|
|
127
|
+
1. Get route endpoints from the user's media/GPS or pick two landmarks.
|
|
128
|
+
2. `markcut spots --waypoints "..." --photos --markdown`
|
|
129
|
+
3. Pick the 2–5 spots that fit the narrative arc (opening, highlights, ending).
|
|
130
|
+
4. Build a `map` storyboard with those `waypoints` + `script` narration.
|
|
131
|
+
5. `markcut preview` to check, `markcut render` to export.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 6. Markdown examples (copy-paste)
|
|
136
|
+
|
|
137
|
+
### 6.1 Simple route clip with narration
|
|
138
|
+
|
|
139
|
+
Map + narration play together inside a `layout:parallel` scene; scenes are
|
|
140
|
+
sequenced by the root `layout:series`:
|
|
141
|
+
|
|
142
|
+
```md
|
|
143
|
+
# video
|
|
144
|
+
width:1080 height:1920 layout:series transition:fade transitionTime:0.5
|
|
145
|
+
|
|
146
|
+
## The-Drive
|
|
147
|
+
layout:parallel
|
|
148
|
+
- script "We leave the Golden Gate and head down to the airport."
|
|
149
|
+
- map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗"
|
|
150
|
+
waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 6.2 Establishing dolly → cinematic chase (two scenes)
|
|
154
|
+
|
|
155
|
+
```md
|
|
156
|
+
# video
|
|
157
|
+
width:1080 height:1920 layout:series transition:fade
|
|
158
|
+
|
|
159
|
+
## Opening-Dive-In
|
|
160
|
+
layout:parallel
|
|
161
|
+
- script "High above San Francisco, then we dive into the streets."
|
|
162
|
+
- map view:overview mapType:satellite duration:4 camera:{zoom:tween(6, 12, easeInOut)}
|
|
163
|
+
|
|
164
|
+
## Chase-The-Road
|
|
165
|
+
layout:parallel
|
|
166
|
+
- script "The camera chases the route like a drone."
|
|
167
|
+
- map view:cinematic duration:8 travelMode:DRIVING mapType:satellite routeMarker:"🚗"
|
|
168
|
+
cinematic:{mode:flyAlong, headingFollow:true, tilt:tween(0, 45, easeInOut)}
|
|
169
|
+
camera:{zoom:tween(12, 14, easeInOut)}
|
|
170
|
+
waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 6.3 Street-level: pan, then walk
|
|
174
|
+
|
|
175
|
+
```md
|
|
176
|
+
# video
|
|
177
|
+
width:1080 height:1920 layout:series transition:fade
|
|
178
|
+
|
|
179
|
+
## On-The-Street-Pan
|
|
180
|
+
layout:parallel
|
|
181
|
+
- script "And here we are, right on the sidewalk."
|
|
182
|
+
- 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)}
|
|
183
|
+
|
|
184
|
+
## A-Quick-Walk
|
|
185
|
+
layout:parallel
|
|
186
|
+
- script "A short stroll down the block."
|
|
187
|
+
- map view:streetview duration:6 streetView:{route:[{lat:37.7793,lng:-122.4193},{lat:37.7785,lng:-122.4185},{lat:37.7777,lng:-122.4178}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 6.4 Full route-vlog recipe (5 scenes)
|
|
191
|
+
|
|
192
|
+
Combine everything for a complete vlog arc: **establish → route with photos →
|
|
193
|
+
cinematic → street view pan → walk**.
|
|
194
|
+
|
|
195
|
+
```md
|
|
196
|
+
# video
|
|
197
|
+
width:1080 height:1920 layout:series transition:fade transitionTime:0.5
|
|
198
|
+
|
|
199
|
+
## Satellite-Dolly
|
|
200
|
+
layout:parallel
|
|
201
|
+
- script "We begin high above San Francisco, then dive into the city."
|
|
202
|
+
- map view:overview mapType:satellite duration:4 center:{lat:37.7749,lng:-122.4194} camera:{zoom:tween(6, 12, easeInOut)}
|
|
203
|
+
|
|
204
|
+
## Route
|
|
205
|
+
layout:parallel
|
|
206
|
+
- script "The route winds from the Golden Gate to the airport, with photos at each stop."
|
|
207
|
+
- 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"]
|
|
208
|
+
|
|
209
|
+
## Cinematic
|
|
210
|
+
layout:parallel
|
|
211
|
+
- script "The camera tilts and chases the road like a drone."
|
|
212
|
+
- 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"]
|
|
213
|
+
|
|
214
|
+
## Street-View
|
|
215
|
+
layout:parallel
|
|
216
|
+
- script "And finally, we land on the street itself."
|
|
217
|
+
- 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)}
|
|
218
|
+
|
|
219
|
+
## Street-View-Walk
|
|
220
|
+
layout:parallel
|
|
221
|
+
- script "A quick walk down the block."
|
|
222
|
+
- map view:streetview duration:6 streetView:{route:[{lat:37.7793,lng:-122.4193},{lat:37.7785,lng:-122.4185},{lat:37.7777,lng:-122.4178},{lat:37.7769,lng:-122.4170}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 7. Recipe — build a route/vlog clip
|
|
228
|
+
|
|
229
|
+
1. **Get the route** — endpoints from the user's media/GPS, or two landmarks.
|
|
230
|
+
2. **Discover spots** — `markcut spots --waypoints "lat,lng;lat,lng" --photos --markdown`; pick 2–5 that tell the story.
|
|
231
|
+
3. **Pick views per scene** — overview (establish) → route with photo stops (log) → cinematic (transition) → streetview (arrive).
|
|
232
|
+
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).
|
|
233
|
+
5. **Verify** — `markcut preview book.md` (screenshot key frames, check map/pano loaded and captions), then `markcut render book.md --output out/vlog.mp4`.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 8. Tips & pitfalls
|
|
238
|
+
|
|
239
|
+
- **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.
|
|
240
|
+
- **Street View coverage is not universal** — a spot may have no panorama. If a `streetview` scene looks dark/empty, pick a nearby well-covered location or use `view:cinematic` instead.
|
|
241
|
+
- **Route maps need ≥2 waypoints**; a single point falls back to a plain marker.
|
|
242
|
+
- **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).
|
|
243
|
+
- **Don't set duration from script length** — the resolver computes it from TTS audio.
|
|
244
|
+
- **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
|
|
@@ -139,9 +139,18 @@ export interface DescriptiveMapWaypoint {
|
|
|
139
139
|
media?: string;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** A tween expression `tween(from, to, easing?)` parsed into a tagged spec. */
|
|
143
|
+
export interface DescriptiveTween {
|
|
144
|
+
__tween: Array<number | string>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A static number OR an animated tween expression. */
|
|
148
|
+
export type DescriptiveTweenable = number | DescriptiveTween;
|
|
149
|
+
|
|
142
150
|
export interface DescriptiveMap extends DescriptiveBaseNode {
|
|
143
151
|
type: "map";
|
|
144
152
|
waypoints: DescriptiveMapWaypoint[];
|
|
153
|
+
view?: "overview" | "route" | "cinematic" | "streetview";
|
|
145
154
|
routeColor?: string;
|
|
146
155
|
routeWeight?: number;
|
|
147
156
|
zoom?: number;
|
|
@@ -151,6 +160,34 @@ export interface DescriptiveMap extends DescriptiveBaseNode {
|
|
|
151
160
|
region?: string;
|
|
152
161
|
travelMode?: "DRIVING" | "WALKING" | "BICYCLING" | "TRANSIT";
|
|
153
162
|
routeMarker?: string;
|
|
163
|
+
camera?: {
|
|
164
|
+
zoom?: DescriptiveTweenable;
|
|
165
|
+
center?: { lat: DescriptiveTweenable; lng: DescriptiveTweenable };
|
|
166
|
+
heading?: DescriptiveTweenable;
|
|
167
|
+
tilt?: DescriptiveTweenable;
|
|
168
|
+
};
|
|
169
|
+
cinematic?: {
|
|
170
|
+
mode?: "flyAlong" | "flyTo" | "orbit";
|
|
171
|
+
followRoute?: boolean;
|
|
172
|
+
headingFollow?: boolean;
|
|
173
|
+
tilt?: DescriptiveTweenable;
|
|
174
|
+
range?: DescriptiveTweenable;
|
|
175
|
+
altitude?: number;
|
|
176
|
+
roll?: DescriptiveTweenable;
|
|
177
|
+
fallback?: "2d" | "none";
|
|
178
|
+
};
|
|
179
|
+
streetView?: {
|
|
180
|
+
pano?: string;
|
|
181
|
+
location?: { lat: number; lng: number };
|
|
182
|
+
route?: Array<{ lat: number; lng: number }>;
|
|
183
|
+
radius?: number;
|
|
184
|
+
source?: "default" | "outdoor" | "indoor";
|
|
185
|
+
zoom?: DescriptiveTweenable;
|
|
186
|
+
pov?: {
|
|
187
|
+
heading?: DescriptiveTweenable;
|
|
188
|
+
pitch?: DescriptiveTweenable;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
154
191
|
}
|
|
155
192
|
|
|
156
193
|
export interface DescriptiveContainer extends DescriptiveBaseNode {
|
|
@@ -601,6 +638,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
601
638
|
const stream: MapStream = {
|
|
602
639
|
...base,
|
|
603
640
|
type: "map",
|
|
641
|
+
view: node.view ?? "route",
|
|
604
642
|
waypoints: node.waypoints,
|
|
605
643
|
routeColor: node.routeColor ?? "#4285F4",
|
|
606
644
|
routeWeight: node.routeWeight ?? 4,
|
|
@@ -611,6 +649,9 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
611
649
|
region: node.region,
|
|
612
650
|
travelMode: node.travelMode ?? "DRIVING",
|
|
613
651
|
routeMarker: node.routeMarker ?? "🚗",
|
|
652
|
+
camera: node.camera,
|
|
653
|
+
cinematic: node.cinematic,
|
|
654
|
+
streetView: node.streetView,
|
|
614
655
|
googleMapsApiKey: ctx.googleMapsApiKey,
|
|
615
656
|
};
|
|
616
657
|
return { stream, duration: end ?? 0 };
|
package/src/descriptive/dsl.ts
CHANGED
|
@@ -174,28 +174,65 @@ export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
|
|
|
174
174
|
const lat = Number(bits[0] ?? 0);
|
|
175
175
|
const lng = Number(bits[1] ?? 0);
|
|
176
176
|
const labelRaw = bits[2];
|
|
177
|
-
const
|
|
178
|
-
|
|
177
|
+
const labelRawUq = labelRaw ? unquote(labelRaw) : undefined;
|
|
178
|
+
const label = labelRawUq ? labelRawUq : undefined;
|
|
179
|
+
const mediaRaw = bits[3];
|
|
180
|
+
const media = mediaRaw ? unquote(mediaRaw) : undefined;
|
|
181
|
+
return { lat, lng, label, media };
|
|
179
182
|
});
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Rewrite `tween(from, to, easing?)` expressions inside a JSON-ish string into
|
|
187
|
+
* a tagged literal `{"__tween":[from,to,"easing"]}` so the regular JSON parser
|
|
188
|
+
* can handle them. `from`/`to` may be numbers or quoted strings (e.g. colors);
|
|
189
|
+
* `easing` is a bare word or quoted string. No eval — deterministic, no scope.
|
|
190
|
+
*
|
|
191
|
+
* tween(6, 12, easeInOut) → {"__tween":[6,12,"easeInOut"]}
|
|
192
|
+
*/
|
|
193
|
+
function rewriteTweenExprs(s: string): string {
|
|
194
|
+
return s.replace(
|
|
195
|
+
/tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
|
|
196
|
+
(_match, fromRaw: string, toRaw: string, easingRaw?: string) => {
|
|
197
|
+
// Scalar → JSON literal: numbers stay, quoted strings stay, bare words get quoted.
|
|
198
|
+
const scalar = (v: string): string => {
|
|
199
|
+
const t = v.trim();
|
|
200
|
+
if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
|
|
201
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
|
|
202
|
+
if (t === "true" || t === "false" || t === "null") return t;
|
|
203
|
+
return JSON.stringify(t);
|
|
204
|
+
};
|
|
205
|
+
const items = [scalar(fromRaw), scalar(toRaw)];
|
|
206
|
+
if (easingRaw !== undefined && easingRaw.trim()) {
|
|
207
|
+
items.push(scalar(easingRaw));
|
|
208
|
+
}
|
|
209
|
+
return `{"__tween":[${items.join(",")}]}`;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
/**
|
|
183
215
|
* Parse a JSON-like props/imports string into an object or array.
|
|
184
216
|
*
|
|
185
217
|
* Accepts standard JSON, then falls back to a lenient two-pass normalization
|
|
186
218
|
* that quotes bare keys (`{foo:` → `{"foo":`) and bare string values, and
|
|
187
219
|
* finally to `eval` for JSX-like expressions. Returns `{}` on total failure.
|
|
220
|
+
*
|
|
221
|
+
* Also understands `tween(from, to, easing?)` expressions (see
|
|
222
|
+
* {@link rewriteTweenExprs}), which are rewritten to a tagged JSON literal
|
|
223
|
+
* before parsing so camera/map configs can animate values.
|
|
188
224
|
*/
|
|
189
225
|
export function parseProps(raw: string): unknown {
|
|
190
226
|
const s = raw.trim();
|
|
191
227
|
if (!s.startsWith("{") && !s.startsWith("[")) return {};
|
|
192
228
|
if (!s.endsWith("}") && !s.endsWith("]")) return {};
|
|
229
|
+
const withTweens = rewriteTweenExprs(s);
|
|
193
230
|
try {
|
|
194
|
-
return JSON.parse(
|
|
231
|
+
return JSON.parse(withTweens);
|
|
195
232
|
} catch {
|
|
196
233
|
// Lenient parse: add quotes around unquoted keys and string values.
|
|
197
234
|
// First pass: quote bare keys: {foo: → {"foo":
|
|
198
|
-
let normalized =
|
|
235
|
+
let normalized = withTweens.replace(
|
|
199
236
|
/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
|
|
200
237
|
'$1"$2":',
|
|
201
238
|
);
|
|
@@ -214,7 +251,7 @@ export function parseProps(raw: string): unknown {
|
|
|
214
251
|
} catch {
|
|
215
252
|
// Last resort: eval (safe since this is a CLI tool)
|
|
216
253
|
try {
|
|
217
|
-
const result = (0, eval)("(" +
|
|
254
|
+
const result = (0, eval)("(" + withTweens + ")");
|
|
218
255
|
return typeof result === "object" && result !== null ? result : {};
|
|
219
256
|
} catch {
|
|
220
257
|
return {};
|
|
@@ -101,6 +101,7 @@ function preserveVariantAttrs(node: Record<string, unknown>, attrs: Record<strin
|
|
|
101
101
|
"foreground", "visible", "isBackground", "instruction", "style", "effects", "on",
|
|
102
102
|
"spots", "waypoints", "routeColor", "routeWeight", "routeMarker",
|
|
103
103
|
"travelMode", "zoom", "center", "mapType", "data", "prompt",
|
|
104
|
+
"view", "camera", "cinematic", "streetView",
|
|
104
105
|
"name", "title", "transition", "transitionTime", "layout",
|
|
105
106
|
"componentName", "props", "speaker",
|
|
106
107
|
]);
|
|
@@ -338,6 +339,7 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
|
|
|
338
339
|
waypoints: (attrs.waypoints as DescriptiveMapWaypoint[] | undefined) ?? [],
|
|
339
340
|
duration: attrs.duration as any,
|
|
340
341
|
start: attrs.start as any,
|
|
342
|
+
view: attrs.view as any,
|
|
341
343
|
routeMarker: attrs.routeMarker as any,
|
|
342
344
|
travelMode: attrs.travelMode as any,
|
|
343
345
|
routeColor: attrs.routeColor as any,
|
|
@@ -345,6 +347,9 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
|
|
|
345
347
|
zoom: attrs.zoom as any,
|
|
346
348
|
center: attrs.center as any,
|
|
347
349
|
mapType: attrs.mapType as any,
|
|
350
|
+
camera: attrs.camera as any,
|
|
351
|
+
cinematic: attrs.cinematic as any,
|
|
352
|
+
streetView: attrs.streetView as any,
|
|
348
353
|
language: (attrs.language as any) ?? (attrs.lang as any),
|
|
349
354
|
region: attrs.region as any,
|
|
350
355
|
instruction: attrs.instruction as any,
|
|
@@ -755,7 +755,7 @@ describe("resolveMediaDurations", () => {
|
|
|
755
755
|
{ type: "video", src: "nonexistent.mp4", duration: 5 },
|
|
756
756
|
],
|
|
757
757
|
};
|
|
758
|
-
const result = await resolveMediaDurations(root);
|
|
758
|
+
const result = await resolveMediaDurations(root, { baseDir: tmpDir });
|
|
759
759
|
expect(result).not.toBe(root);
|
|
760
760
|
expect(result.children[0]!.duration).toBe(5);
|
|
761
761
|
});
|
|
@@ -767,7 +767,7 @@ describe("resolveMediaDurations", () => {
|
|
|
767
767
|
{ type: "video", src: "anything.mp4", duration: 7 },
|
|
768
768
|
],
|
|
769
769
|
};
|
|
770
|
-
const result = await resolveMediaDurations(root);
|
|
770
|
+
const result = await resolveMediaDurations(root, { baseDir: tmpDir });
|
|
771
771
|
expect(result.children[0]!.duration).toBe(7);
|
|
772
772
|
});
|
|
773
773
|
|
|
@@ -778,7 +778,7 @@ describe("resolveMediaDurations", () => {
|
|
|
778
778
|
{ type: "video", src: "anything.mp4", startFrom: 2, endAt: 5 },
|
|
779
779
|
],
|
|
780
780
|
};
|
|
781
|
-
const result = await resolveMediaDurations(root);
|
|
781
|
+
const result = await resolveMediaDurations(root, { baseDir: tmpDir });
|
|
782
782
|
expect((result.children[0] as any).endAt).toBe(5);
|
|
783
783
|
expect((result.children[0] as any).duration).toBeUndefined();
|
|
784
784
|
});
|
|
@@ -791,7 +791,7 @@ describe("resolveMediaDurations", () => {
|
|
|
791
791
|
{ type: "image", src: "photo.jpg" },
|
|
792
792
|
],
|
|
793
793
|
};
|
|
794
|
-
const result = await resolveMediaDurations(root, { skip: /\.jpg$/ });
|
|
794
|
+
const result = await resolveMediaDurations(root, { baseDir: tmpDir, skip: /\.jpg$/ });
|
|
795
795
|
// .jpg skipped, .mp4 tried but file doesn't exist so duration stays undefined
|
|
796
796
|
expect(result.children[0]!.duration).toBeUndefined();
|
|
797
797
|
expect(result.children[1]!.duration).toBeUndefined();
|
|
@@ -805,7 +805,7 @@ describe("resolveMediaDurations", () => {
|
|
|
805
805
|
],
|
|
806
806
|
};
|
|
807
807
|
const originalChildren = root.children[0];
|
|
808
|
-
const result = await resolveMediaDurations(root);
|
|
808
|
+
const result = await resolveMediaDurations(root, { baseDir: tmpDir });
|
|
809
809
|
expect(result).not.toBe(root);
|
|
810
810
|
expect(root.children[0]).toBe(originalChildren);
|
|
811
811
|
});
|
|
@@ -46,8 +46,9 @@ function firstWords(text: string, n: number): string {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface ResolveMediaOptions {
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/** Source file's folder — every asset path is resolved/emitted relative to it.
|
|
50
|
+
* Always set by the CLI/server (dirname of the source file); no fallback. */
|
|
51
|
+
baseDir: string;
|
|
51
52
|
/** Skip nodes whose src matches this regex */
|
|
52
53
|
skip?: RegExp;
|
|
53
54
|
}
|
|
@@ -56,7 +57,7 @@ export interface ResolveMediaOptions {
|
|
|
56
57
|
* Probe actual media duration via ffprobe.
|
|
57
58
|
* Returns duration in seconds, or null if probe fails.
|
|
58
59
|
*/
|
|
59
|
-
function probeDuration(src: string, baseDir
|
|
60
|
+
function probeDuration(src: string, baseDir: string): number | null {
|
|
60
61
|
const absPath = resolveSrc(src, baseDir);
|
|
61
62
|
try {
|
|
62
63
|
const out = execSync(
|
|
@@ -70,9 +71,37 @@ function probeDuration(src: string, baseDir?: string): number | null {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
function resolveSrc(src: string, baseDir
|
|
74
|
+
function resolveSrc(src: string, baseDir: string): string {
|
|
74
75
|
if (/^(https?:|file:|\/)/.test(src)) return src;
|
|
75
|
-
return resolvePath(baseDir
|
|
76
|
+
return resolvePath(baseDir, src);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert every generated asset path that lives under `baseDir` (the source
|
|
81
|
+
* .md folder) into a path relative to it, so the compiled JSON references
|
|
82
|
+
* every asset from the md folder's perspective. Render serves that folder via
|
|
83
|
+
* --public-dir and the preview server serves it as the document root — no
|
|
84
|
+
* render-time normalization needed. Paths outside baseDir (or remote) stay
|
|
85
|
+
* unchanged.
|
|
86
|
+
*
|
|
87
|
+
* Applied once at the end of resolveAll — all resolvers keep emitting
|
|
88
|
+
* absolute paths internally (needed for ffprobe/whisper), and this single
|
|
89
|
+
* walk relativizes them for the final JSON.
|
|
90
|
+
*/
|
|
91
|
+
function relativizeAssetsUnder(node: any, baseDir: string): any {
|
|
92
|
+
if (!node || typeof node !== "object") return node;
|
|
93
|
+
if (Array.isArray(node)) return node.map((v) => relativizeAssetsUnder(v, baseDir));
|
|
94
|
+
const out: Record<string, any> = {};
|
|
95
|
+
for (const [k, v] of Object.entries(node)) {
|
|
96
|
+
if (typeof v === "string" && v.startsWith(baseDir + "/")) {
|
|
97
|
+
out[k] = v.slice(baseDir.length + 1);
|
|
98
|
+
} else if (v && typeof v === "object") {
|
|
99
|
+
out[k] = relativizeAssetsUnder(v, baseDir);
|
|
100
|
+
} else {
|
|
101
|
+
out[k] = v;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
/**
|
|
@@ -111,7 +140,7 @@ export function resolveMediaSrc(
|
|
|
111
140
|
src: string,
|
|
112
141
|
targetWidth: number,
|
|
113
142
|
targetHeight: number,
|
|
114
|
-
baseDir
|
|
143
|
+
baseDir: string,
|
|
115
144
|
): string {
|
|
116
145
|
// If no pattern, direct resolve
|
|
117
146
|
if (!src.includes("${")) {
|
|
@@ -157,7 +186,7 @@ export function resolveMediaSrc(
|
|
|
157
186
|
*/
|
|
158
187
|
export async function resolveMediaSrcs(
|
|
159
188
|
root: DescriptiveRoot,
|
|
160
|
-
options: ResolveMediaOptions
|
|
189
|
+
options: ResolveMediaOptions,
|
|
161
190
|
): Promise<DescriptiveRoot> {
|
|
162
191
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
163
192
|
const baseDir = options.baseDir;
|
|
@@ -190,7 +219,7 @@ export async function resolveMediaSrcs(
|
|
|
190
219
|
*/
|
|
191
220
|
export async function resolveMediaDurations(
|
|
192
221
|
root: DescriptiveRoot,
|
|
193
|
-
options: ResolveMediaOptions
|
|
222
|
+
options: ResolveMediaOptions,
|
|
194
223
|
): Promise<DescriptiveRoot> {
|
|
195
224
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
196
225
|
const baseDir = options.baseDir;
|
|
@@ -411,7 +440,8 @@ export async function resolveScripts(
|
|
|
411
440
|
}
|
|
412
441
|
if (!generated) continue;
|
|
413
442
|
|
|
414
|
-
// Set resolved src (
|
|
443
|
+
// Set resolved src (absolute for reliable probing; the final
|
|
444
|
+
// relativizeAssetsUnder pass in resolveAll makes it md-folder-relative)
|
|
415
445
|
node.src = resolvePath(generated);
|
|
416
446
|
delete node.script;
|
|
417
447
|
}
|
|
@@ -485,6 +515,8 @@ export async function resolveSubtitles(
|
|
|
485
515
|
const effectiveOffset = nodeStart + actionStart;
|
|
486
516
|
|
|
487
517
|
if (node.type === "audio" && node.src) {
|
|
518
|
+
// node.src is still absolute here (relativization happens at the end of
|
|
519
|
+
// resolveAll), so whisper/existsSync can read the file directly.
|
|
488
520
|
clips.push({ audioSrc: node.src, offset: effectiveOffset, speaker: node.speaker });
|
|
489
521
|
}
|
|
490
522
|
|
|
@@ -937,10 +969,10 @@ export interface ResolveAllOptions extends ResolveMediaOptions {
|
|
|
937
969
|
*/
|
|
938
970
|
export async function resolveIncludes(
|
|
939
971
|
root: DescriptiveRoot,
|
|
940
|
-
options: ResolveAllOptions
|
|
972
|
+
options: ResolveAllOptions,
|
|
941
973
|
): Promise<DescriptiveRoot> {
|
|
942
974
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
943
|
-
const baseDir = options.baseDir
|
|
975
|
+
const baseDir = options.baseDir;
|
|
944
976
|
const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
|
|
945
977
|
mkdirSync(outputDir, { recursive: true });
|
|
946
978
|
|
|
@@ -1076,7 +1108,7 @@ export async function resolveIncludes(
|
|
|
1076
1108
|
*/
|
|
1077
1109
|
export async function resolveAll(
|
|
1078
1110
|
root: DescriptiveRoot,
|
|
1079
|
-
options: ResolveAllOptions
|
|
1111
|
+
options: ResolveAllOptions,
|
|
1080
1112
|
): Promise<DescriptiveRoot> {
|
|
1081
1113
|
let result = root;
|
|
1082
1114
|
|
|
@@ -1180,5 +1212,12 @@ export async function resolveAll(
|
|
|
1180
1212
|
});
|
|
1181
1213
|
}
|
|
1182
1214
|
|
|
1215
|
+
// Final step: emit every generated asset path relative to the source .md
|
|
1216
|
+
// folder so the compiled JSON carries md-folder-relative paths. Render
|
|
1217
|
+
// serves that folder via --public-dir and the preview server serves it as
|
|
1218
|
+
// the document root — no render-time normalization needed. baseDir is
|
|
1219
|
+
// always the source file's folder (set by the CLI/server) — no fallback.
|
|
1220
|
+
result = relativizeAssetsUnder(result, options.baseDir);
|
|
1221
|
+
|
|
1183
1222
|
return result;
|
|
1184
1223
|
}
|