@lalalic/markcut 3.1.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/docs/map-dynamic-camera.md +92 -8
- package/src/descriptive/compiler.ts +60 -1
- package/src/descriptive/dsl.ts +27 -7
- package/src/descriptive/markdown.ts +2 -1
- package/src/descriptive/resolve.test.ts +98 -0
- package/src/descriptive/resolve.ts +156 -12
- package/src/player/bundle/player.js +222961 -222169
- package/src/player/pipeline.mjs +255 -17
- package/src/schema/index.ts +4 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +649 -75
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/tests/dsl.test.ts +35 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/map-all-views.md +8 -1
- 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 +75 -0
- package/tests/render.test.ts +92 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +18 -0
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ Use `map` clips when the video is about **a place and getting around**:
|
|
|
16
16
|
- city tour: establishing shot → flyover → street level
|
|
17
17
|
- route explainers: commute, road trip, walking tour, "how to get there"
|
|
18
18
|
|
|
19
|
-
Every map clip can carry a `script` narration next to it (see §
|
|
19
|
+
Every map clip can carry a `script` narration next to it (see §7 for the
|
|
20
20
|
scene pattern that plays map + narration together).
|
|
21
21
|
|
|
22
22
|
---
|
|
@@ -101,7 +101,85 @@ Full example:
|
|
|
101
101
|
|
|
102
102
|
---
|
|
103
103
|
|
|
104
|
-
## 5.
|
|
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`
|
|
105
183
|
|
|
106
184
|
Don't hand-pick coordinates for a route video — **discover** interesting stops
|
|
107
185
|
with the spots CLI. It samples points along a route (Directions API) and ranks
|
|
@@ -132,7 +210,7 @@ npx @lalalic/markcut spots --waypoints "37.8199,-122.4783;37.6213,-122.3790" \
|
|
|
132
210
|
|
|
133
211
|
---
|
|
134
212
|
|
|
135
|
-
##
|
|
213
|
+
## 8. Markdown examples (copy-paste)
|
|
136
214
|
|
|
137
215
|
### 6.1 Simple route clip with narration
|
|
138
216
|
|
|
@@ -184,9 +262,15 @@ layout:parallel
|
|
|
184
262
|
## A-Quick-Walk
|
|
185
263
|
layout:parallel
|
|
186
264
|
- script "A short stroll down the block."
|
|
187
|
-
- map view:streetview duration:6 streetView:{route:[{lat:37.
|
|
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}}
|
|
188
266
|
```
|
|
189
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
|
+
|
|
190
274
|
### 6.4 Full route-vlog recipe (5 scenes)
|
|
191
275
|
|
|
192
276
|
Combine everything for a complete vlog arc: **establish → route with photos →
|
|
@@ -219,12 +303,12 @@ layout:parallel
|
|
|
219
303
|
## Street-View-Walk
|
|
220
304
|
layout:parallel
|
|
221
305
|
- script "A quick walk down the block."
|
|
222
|
-
- map view:streetview duration:6 streetView:{route:[{lat:37.
|
|
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}}
|
|
223
307
|
```
|
|
224
308
|
|
|
225
309
|
---
|
|
226
310
|
|
|
227
|
-
##
|
|
311
|
+
## 9. Recipe — build a route/vlog clip
|
|
228
312
|
|
|
229
313
|
1. **Get the route** — endpoints from the user's media/GPS, or two landmarks.
|
|
230
314
|
2. **Discover spots** — `markcut spots --waypoints "lat,lng;lat,lng" --photos --markdown`; pick 2–5 that tell the story.
|
|
@@ -234,10 +318,10 @@ layout:parallel
|
|
|
234
318
|
|
|
235
319
|
---
|
|
236
320
|
|
|
237
|
-
##
|
|
321
|
+
## 10. Tips & pitfalls
|
|
238
322
|
|
|
239
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.
|
|
240
|
-
- **Street View coverage is not universal** — a spot may have no panorama.
|
|
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.
|
|
241
325
|
- **Route maps need ≥2 waypoints**; a single point falls back to a plain marker.
|
|
242
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).
|
|
243
327
|
- **Don't set duration from script length** — the resolver computes it from TTS audio.
|
|
@@ -137,6 +137,8 @@ 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
|
|
|
142
144
|
/** A tween expression `tween(from, to, easing?)` parsed into a tagged spec. */
|
|
@@ -188,6 +190,9 @@ export interface DescriptiveMap extends DescriptiveBaseNode {
|
|
|
188
190
|
pitch?: DescriptiveTweenable;
|
|
189
191
|
};
|
|
190
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[];
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
export interface DescriptiveContainer extends DescriptiveBaseNode {
|
|
@@ -531,6 +536,7 @@ function wrapWithEffects(
|
|
|
531
536
|
start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
|
|
532
537
|
end: isOutermost && isBgNoEnd ? undefined : effEnd,
|
|
533
538
|
visible: innerStream.visible ?? true,
|
|
539
|
+
at: (node as any).at,
|
|
534
540
|
...pickOn(node),
|
|
535
541
|
} as Effect;
|
|
536
542
|
}
|
|
@@ -560,6 +566,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
|
|
|
560
566
|
style: node.style,
|
|
561
567
|
visible: node.visible ?? true,
|
|
562
568
|
isBackground: node.isBackground,
|
|
569
|
+
at: (node as any).at,
|
|
563
570
|
start: isBgNoOwnTiming ? (typeof node.start === "number" ? node.start : undefined) : start,
|
|
564
571
|
end,
|
|
565
572
|
startFrom: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.startFrom : undefined),
|
|
@@ -677,7 +684,7 @@ function compileChildren(
|
|
|
677
684
|
} else if (isRhythm(child)) {
|
|
678
685
|
result = compileRhythm(child, ctx, parentKind);
|
|
679
686
|
} else if (isMap(child)) {
|
|
680
|
-
result =
|
|
687
|
+
result = compileMap(child, ctx, parentKind);
|
|
681
688
|
} else {
|
|
682
689
|
result = compileLeaf(child, ctx, parentKind);
|
|
683
690
|
}
|
|
@@ -893,6 +900,58 @@ function compileRhythm(
|
|
|
893
900
|
return { stream, duration: end };
|
|
894
901
|
}
|
|
895
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
|
+
|
|
896
955
|
function compileContainer(node: DescriptiveContainer, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
|
|
897
956
|
const id = node.id ?? uid();
|
|
898
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,15 +183,25 @@ 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
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 };
|
|
182
199
|
});
|
|
183
200
|
}
|
|
184
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
|
+
|
|
185
205
|
/**
|
|
186
206
|
* Rewrite `tween(from, to, easing?)` expressions inside a JSON-ish string into
|
|
187
207
|
* a tagged literal `{"__tween":[from,to,"easing"]}` so the regular JSON parser
|
|
@@ -588,7 +588,8 @@ function processMDASTListItem(item: any, parent: ParentNode, lines: string[]): v
|
|
|
588
588
|
node.type === "transitionSeries" ||
|
|
589
589
|
node.type === "effect" ||
|
|
590
590
|
node.type === "include" ||
|
|
591
|
-
node.type === "rhythm"
|
|
591
|
+
node.type === "rhythm" ||
|
|
592
|
+
node.type === "map"
|
|
592
593
|
) {
|
|
593
594
|
for (const subItem of child.children) {
|
|
594
595
|
processMDASTListItem(subItem, node as ParentNode, lines);
|
|
@@ -962,3 +962,101 @@ describe("resolveIncludes — variant overrides", () => {
|
|
|
962
962
|
expect(incNode.durationInSeconds).toBe(2);
|
|
963
963
|
});
|
|
964
964
|
});
|
|
965
|
+
|
|
966
|
+
// ── resolveRouteStops (map overlay auto-timing) ─────────────────────────────
|
|
967
|
+
|
|
968
|
+
describe("resolveRouteStops", () => {
|
|
969
|
+
it("auto-times map children from synthetic FLIGHT leg durations (no network)", async () => {
|
|
970
|
+
const { resolveRouteStops } = await import("./resolve");
|
|
971
|
+
const root: any = {
|
|
972
|
+
type: "root",
|
|
973
|
+
children: [
|
|
974
|
+
{
|
|
975
|
+
type: "scene",
|
|
976
|
+
children: [
|
|
977
|
+
{
|
|
978
|
+
type: "map",
|
|
979
|
+
view: "route",
|
|
980
|
+
travelMode: "FLIGHT",
|
|
981
|
+
waypoints: [
|
|
982
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
983
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
984
|
+
{ lat: 33.9425, lng: -118.4081, label: "PIER" },
|
|
985
|
+
],
|
|
986
|
+
children: [
|
|
987
|
+
{ type: "image", at: "LA", duration: 4 },
|
|
988
|
+
{ type: "image", at: "PIER", duration: 2 },
|
|
989
|
+
],
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
await resolveRouteStops(root);
|
|
997
|
+
|
|
998
|
+
const map = root.children[0].children[0];
|
|
999
|
+
const [la, pier] = map.children;
|
|
1000
|
+
|
|
1001
|
+
// Map duration = default drive budget (10s) + dwells (4 + 2) = 16s.
|
|
1002
|
+
expect(map.duration).toBeCloseTo(16, 1);
|
|
1003
|
+
|
|
1004
|
+
// First child arrives at LA after leg 1 (SF→LA ≈ 2367s of 2481s total)
|
|
1005
|
+
// scaled into the 10s drive budget → ≈ 9.5s, dwells 4s.
|
|
1006
|
+
expect(la.start).toBeGreaterThan(9);
|
|
1007
|
+
expect(la.start).toBeLessThan(10);
|
|
1008
|
+
expect(la.end - la.start).toBeCloseTo(4, 6);
|
|
1009
|
+
|
|
1010
|
+
// Second child arrives at PIER after leg 2 + the LA dwell → ≈ 14s.
|
|
1011
|
+
expect(pier.start).toBeGreaterThan(13.5);
|
|
1012
|
+
expect(pier.start).toBeLessThan(14.5);
|
|
1013
|
+
expect(pier.end - pier.start).toBeCloseTo(2, 6);
|
|
1014
|
+
|
|
1015
|
+
// Children with explicit `start` are left untouched.
|
|
1016
|
+
const explicit: any = {
|
|
1017
|
+
type: "map",
|
|
1018
|
+
view: "route",
|
|
1019
|
+
travelMode: "FLIGHT",
|
|
1020
|
+
waypoints: [
|
|
1021
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
1022
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
1023
|
+
],
|
|
1024
|
+
children: [{ type: "image", at: "LA", duration: 4, start: 3, end: 7 }],
|
|
1025
|
+
};
|
|
1026
|
+
const explicitRoot: any = { type: "root", children: [explicit] };
|
|
1027
|
+
await resolveRouteStops(explicitRoot);
|
|
1028
|
+
expect(explicit.children[0].start).toBe(3);
|
|
1029
|
+
expect(explicit.children[0].end).toBe(7);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("respects an explicit map duration (drives shrink to fit drives+dwells)", async () => {
|
|
1033
|
+
const { resolveRouteStops } = await import("./resolve");
|
|
1034
|
+
const root: any = {
|
|
1035
|
+
type: "root",
|
|
1036
|
+
children: [
|
|
1037
|
+
{
|
|
1038
|
+
type: "map",
|
|
1039
|
+
view: "route",
|
|
1040
|
+
duration: 20,
|
|
1041
|
+
travelMode: "FLIGHT",
|
|
1042
|
+
waypoints: [
|
|
1043
|
+
{ lat: 37.7749, lng: -122.4194, label: "SF" },
|
|
1044
|
+
{ lat: 34.0522, lng: -118.2437, label: "LA" },
|
|
1045
|
+
{ lat: 33.9425, lng: -118.4081, label: "PIER" },
|
|
1046
|
+
],
|
|
1047
|
+
children: [{ type: "image", at: "LA", duration: 4 }],
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
await resolveRouteStops(root);
|
|
1053
|
+
|
|
1054
|
+
const map = root.children[0];
|
|
1055
|
+
expect(map.duration).toBe(20);
|
|
1056
|
+
// Dwell 4s stays; drives get the remaining 16s budget proportionally.
|
|
1057
|
+
const la = map.children[0];
|
|
1058
|
+
expect(la.start).toBeGreaterThan(14);
|
|
1059
|
+
expect(la.start).toBeLessThan(17);
|
|
1060
|
+
expect(la.end - la.start).toBeCloseTo(4, 6);
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
@@ -384,15 +384,20 @@ export async function resolveScripts(
|
|
|
384
384
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
385
385
|
mkdirSync(options.outputDir, { recursive: true });
|
|
386
386
|
|
|
387
|
-
// Collect all audio nodes that have script text but no src yet
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
387
|
+
// Collect all audio nodes that have script text but no src yet. Each node
|
|
388
|
+
// carries the nearest ancestor's `tts` override (root.tts by default), so a
|
|
389
|
+
// scene-level `tts` wins over root while other scenes keep root's voice.
|
|
390
|
+
const allScriptNodes: Array<{ node: any; id: string; ttsOverride?: string }> = [];
|
|
391
|
+
const collect = (node: any, inherited?: string): void => {
|
|
392
|
+
const ttsOverride =
|
|
393
|
+
typeof node.tts === "string" && node.tts.length > 0 ? node.tts : inherited;
|
|
394
|
+
if (node.type === "audio" && node.script && typeof node.script === "string" && !node.src) {
|
|
395
|
+
const id = node.id ?? `audio-${allScriptNodes.length}`;
|
|
396
|
+
allScriptNodes.push({ node, id, ttsOverride });
|
|
397
|
+
}
|
|
398
|
+
for (const c of node.children ?? []) collect(c, ttsOverride);
|
|
399
|
+
};
|
|
400
|
+
collect(clone as any);
|
|
396
401
|
|
|
397
402
|
const totalScripts = allScriptNodes.length;
|
|
398
403
|
let scriptsDone = 0;
|
|
@@ -403,10 +408,10 @@ export async function resolveScripts(
|
|
|
403
408
|
console.log(` 🔊 TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
|
|
404
409
|
}
|
|
405
410
|
|
|
406
|
-
for (const { node, id } of allScriptNodes) {
|
|
411
|
+
for (const { node, id, ttsOverride } of allScriptNodes) {
|
|
407
412
|
scriptsDone++;
|
|
408
|
-
// TTS CLI
|
|
409
|
-
let ttsCli =
|
|
413
|
+
// TTS CLI: nearest ancestor `tts` (root.tts by default) → options → default
|
|
414
|
+
let ttsCli = ttsOverride ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
410
415
|
|
|
411
416
|
// Per-speaker voice appends extra CLI flags from root voices config
|
|
412
417
|
if (node.speaker && clone.voices) {
|
|
@@ -1212,6 +1217,11 @@ export async function resolveAll(
|
|
|
1212
1217
|
});
|
|
1213
1218
|
}
|
|
1214
1219
|
|
|
1220
|
+
// Step 6: Auto-time map overlay children (at:"Waypoint") from the route —
|
|
1221
|
+
// arrival + dwell, so the author writes no timing. Only maps with anchored
|
|
1222
|
+
// children lacking explicit start trigger Directions calls.
|
|
1223
|
+
result = await resolveRouteStops(result);
|
|
1224
|
+
|
|
1215
1225
|
// Final step: emit every generated asset path relative to the source .md
|
|
1216
1226
|
// folder so the compiled JSON carries md-folder-relative paths. Render
|
|
1217
1227
|
// serves that folder via --public-dir and the preview server serves it as
|
|
@@ -1221,3 +1231,137 @@ export async function resolveAll(
|
|
|
1221
1231
|
|
|
1222
1232
|
return result;
|
|
1223
1233
|
}
|
|
1234
|
+
|
|
1235
|
+
// ── Route stops (auto-time map overlay children) ─────────────────────────
|
|
1236
|
+
|
|
1237
|
+
/** Default duration for a map child with no explicit duration / audio. */
|
|
1238
|
+
const DEFAULT_CHILD_SECONDS = 3;
|
|
1239
|
+
|
|
1240
|
+
/** Default drive budget (seconds) when the map has no explicit duration —
|
|
1241
|
+
* drives fill this, dwells are added on top. Real Directions durations are
|
|
1242
|
+
* only used for leg ratios. */
|
|
1243
|
+
const DEFAULT_MAP_DRIVE_SECONDS = 10;
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* For every `map` node with children anchored via `at:"WaypointLabel"`, derive
|
|
1247
|
+
* each child's `start`/`end` from the route's travel times:
|
|
1248
|
+
*
|
|
1249
|
+
* - leg durations from Directions REST (road modes) or haversine cruise
|
|
1250
|
+
* speed (FLIGHT/BOAT synthetic) — same math the renderer uses
|
|
1251
|
+
* - the pin holds (dwells) at a waypoint while its children play
|
|
1252
|
+
* - drives are scaled proportionally to leg duration across the remaining
|
|
1253
|
+
* budget, so arrivals match the renderer's `routePositionAtLegs`
|
|
1254
|
+
*
|
|
1255
|
+
* Children that already have an explicit `start` are left untouched (but still
|
|
1256
|
+
* contribute to the pin's pause window). When the map has no explicit duration,
|
|
1257
|
+
* it is set to drives + dwells.
|
|
1258
|
+
*/
|
|
1259
|
+
export async function resolveRouteStops(
|
|
1260
|
+
root: DescriptiveRoot,
|
|
1261
|
+
): Promise<DescriptiveRoot> {
|
|
1262
|
+
const { routeLegTimings } = await import("../utils/directions");
|
|
1263
|
+
|
|
1264
|
+
async function resolveMap(node: any): Promise<void> {
|
|
1265
|
+
if (!node || typeof node !== "object") return;
|
|
1266
|
+
if (Array.isArray(node)) {
|
|
1267
|
+
for (const n of node) await resolveMap(n);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (node.type === "map" && Array.isArray(node.children) && node.children.length) {
|
|
1272
|
+
const children = node.children as any[];
|
|
1273
|
+
const anchored = children.filter((c) => c && c.at);
|
|
1274
|
+
if (anchored.length) {
|
|
1275
|
+
await timeMapChildren(node, anchored, routeLegTimings);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
for (const child of node.children ?? []) await resolveMap(child);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
await resolveMap((root as any).children);
|
|
1283
|
+
return root;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function timeMapChildren(
|
|
1287
|
+
map: any,
|
|
1288
|
+
anchored: any[],
|
|
1289
|
+
routeLegTimings: (waypoints: any[], defaultMode?: string) => Promise<{ durationSec: number }[]>,
|
|
1290
|
+
): Promise<void> {
|
|
1291
|
+
const waypoints = map.waypoints ?? [];
|
|
1292
|
+
if (waypoints.length < 2) return;
|
|
1293
|
+
|
|
1294
|
+
// 1. Per-leg travel times.
|
|
1295
|
+
const legs = await routeLegTimings(waypoints, map.travelMode ?? "DRIVING");
|
|
1296
|
+
const totalDrive = legs.reduce((s, l) => s + (l.durationSec || 0), 0);
|
|
1297
|
+
if (totalDrive <= 0) return;
|
|
1298
|
+
|
|
1299
|
+
// 2. Per-waypoint dwell = max duration of anchored children there.
|
|
1300
|
+
const childDuration = (c: any): number =>
|
|
1301
|
+
typeof c.duration === "number" && c.duration > 0 ? c.duration : DEFAULT_CHILD_SECONDS;
|
|
1302
|
+
const dwellAt = new Map<string, number>();
|
|
1303
|
+
for (const c of anchored) {
|
|
1304
|
+
const d = childDuration(c);
|
|
1305
|
+
dwellAt.set(c.at, Math.max(dwellAt.get(c.at) ?? 0, d));
|
|
1306
|
+
}
|
|
1307
|
+
const totalDwell = [...dwellAt.values()].reduce((s, d) => s + d, 0);
|
|
1308
|
+
|
|
1309
|
+
// 3. Timeline: drives scaled proportionally across the drive budget; dwells
|
|
1310
|
+
// inserted after each arrival. arrival(i) = S·cumDrive(i)/D + cumDwells(<i).
|
|
1311
|
+
// Real Directions durations only provide RATIOS — the video length is the
|
|
1312
|
+
// author's map `duration` (or a default), so a 40-minute drive still plays
|
|
1313
|
+
// as a short clip with the pin timing proportional to each leg.
|
|
1314
|
+
const hasOwnDur = typeof map.duration === "number" || typeof map.endAt === "number";
|
|
1315
|
+
const totalDwellBudget = Math.max(0, map.duration ?? map.endAt ?? DEFAULT_MAP_DRIVE_SECONDS);
|
|
1316
|
+
const mapDur = hasOwnDur
|
|
1317
|
+
? (map.duration ?? map.endAt ?? totalDwellBudget + totalDwell)
|
|
1318
|
+
: totalDwellBudget + totalDwell;
|
|
1319
|
+
const driveBudget = Math.max(0.1, mapDur - totalDwell);
|
|
1320
|
+
const scale = driveBudget / totalDrive;
|
|
1321
|
+
|
|
1322
|
+
const arrivalAt = new Map<string, number>();
|
|
1323
|
+
// The pin is at waypoint[0] from t=0 — `at` there means "from the start".
|
|
1324
|
+
const wp0 = waypoints[0]!;
|
|
1325
|
+
arrivalAt.set(wp0.label ?? String(wp0.lat) + "," + String(wp0.lng), 0);
|
|
1326
|
+
let cumDrive = 0;
|
|
1327
|
+
let cumDwells = 0;
|
|
1328
|
+
for (let i = 0; i < legs.length; i++) {
|
|
1329
|
+
cumDrive += legs[i]!.durationSec || 0;
|
|
1330
|
+
const wp = waypoints[i + 1]!;
|
|
1331
|
+
const arrival = (cumDrive * scale) + cumDwells;
|
|
1332
|
+
arrivalAt.set(wp.label ?? String(wp.lat) + "," + String(wp.lng), arrival);
|
|
1333
|
+
if (dwellAt.has(wp.label)) cumDwells += dwellAt.get(wp.label)!;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// 4. Stamp children that lack explicit timing.
|
|
1337
|
+
const schedule: string[] = [];
|
|
1338
|
+
for (const c of anchored) {
|
|
1339
|
+
const arrival = arrivalAt.get(c.at);
|
|
1340
|
+
if (arrival == null) {
|
|
1341
|
+
console.warn(` ⚠ map child at:"${c.at}" — no waypoint with that label; renders full-screen`);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof c.start === "number") {
|
|
1345
|
+
schedule.push(` 🛑 ${c.at}: manual ${c.start.toFixed(1)}s (unchanged)`);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const dur = childDuration(c);
|
|
1349
|
+
c.start = arrival;
|
|
1350
|
+
c.end = arrival + dur;
|
|
1351
|
+
schedule.push(` 🛑 ${c.at}: ${arrival.toFixed(1)}s → ${(arrival + dur).toFixed(1)}s (${dur.toFixed(1)}s)`);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (schedule.length) {
|
|
1355
|
+
console.log(` 🗺 route stops (${mapDur.toFixed(1)}s total):`);
|
|
1356
|
+
for (const line of schedule) console.log(line);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// 5. Extend map duration if the author didn't set one (or it's too short).
|
|
1360
|
+
if (!hasOwnDur) {
|
|
1361
|
+
map.duration = mapDur;
|
|
1362
|
+
} else if ((map.duration ?? 0) < mapDur) {
|
|
1363
|
+
map.duration = mapDur;
|
|
1364
|
+
console.log(` 🗺 extended map duration → ${mapDur.toFixed(1)}s (drives + dwells)`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|