@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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/map-dynamic-camera.md +328 -0
  4. package/skills/markcut/docs/markdown-descriptive.md +2 -0
  5. package/src/descriptive/compiler.ts +101 -1
  6. package/src/descriptive/dsl.ts +64 -7
  7. package/src/descriptive/markdown.ts +7 -1
  8. package/src/descriptive/resolve.test.ts +103 -5
  9. package/src/descriptive/resolve.ts +207 -24
  10. package/src/player/bundle/player.js +223296 -222155
  11. package/src/player/pipeline.mjs +314 -25
  12. package/src/player/pipeline.ts +5 -4
  13. package/src/player/server.mjs +22 -42
  14. package/src/render/cli.mjs +54 -3
  15. package/src/render/validate-assets.mjs +140 -0
  16. package/src/schema/index.ts +60 -1
  17. package/src/spots/cli.mjs +266 -0
  18. package/src/types/Effect.tsx +12 -1
  19. package/src/types/Map.tsx +1078 -130
  20. package/src/utils/directions.ts +101 -0
  21. package/src/utils/index.ts +11 -0
  22. package/src/utils/route-legs.ts +199 -0
  23. package/src/utils/tween.ts +49 -1
  24. package/tests/dsl.test.ts +78 -0
  25. package/tests/fixtures/map-dynamic.json +52 -0
  26. package/tests/fixtures/map-overlay.json +56 -0
  27. package/tests/fixtures/md/animate-diagrams.md +9 -7
  28. package/tests/fixtures/md/map-all-views.md +35 -0
  29. package/tests/fixtures/md/map-children.md +11 -0
  30. package/tests/fixtures/md/map-multimode.md +9 -0
  31. package/tests/fixtures/streetview-walk.json +36 -0
  32. package/tests/md-descriptive.test.ts +133 -0
  33. package/tests/render.test.ts +93 -0
  34. package/tests/route-legs.test.ts +178 -0
  35. package/tests/schema.test.ts +76 -1
  36. package/tests/validate-assets.test.ts +106 -0
  37. package/B] +0 -2
  38. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  39. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  40. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  41. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
  42. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  43. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
package/src/types/Map.tsx CHANGED
@@ -1,31 +1,51 @@
1
+ /// <reference types="google.maps" />
1
2
  /**
2
- * Map stream type — animated route on Google Maps.
3
+ * Map stream type — Google Maps visualizations with dynamic, movie-like cameras.
4
+ *
5
+ * Four views, selected by `view`:
6
+ * - overview: static or dolly camera over the map (mapType: satellite for a city shot)
7
+ * - route: animated marker traveling the Directions route (default)
8
+ * - cinematic: chase/flyover camera — 2D tilt+heading follow (default), or
9
+ * experimental Map3D flyTo/orbit when `cinematic.fallback:"none"`
10
+ * - streetview: immersive StreetViewPanorama with animated POV/position
11
+ *
12
+ * Camera values are written with `tween(from, to, easing?)` in the descriptive
13
+ * layer and resolved per frame via `resolveTween` (utils/tween.ts) — every
14
+ * frame renders deterministically from `useCurrentFrame()`.
3
15
  *
4
- * Renders a Google Map with Directions API route between waypoints and an
5
- * animated marker that travels along the path in sync with the current frame.
6
16
  * Uses @vis.gl/react-google-maps (Google Maps JS API wrapper) — no separate
7
17
  * API key management needed beyond what's embedded in the engine build.
8
18
  *
9
- * Adapted from qili-ai studio's map component.
10
- *
11
19
  * Usage in stream tree:
12
20
  * {
13
21
  * type: "map",
14
- * waypoints: [{ lat, lng, label? }],
15
- * travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
16
- * mapType: "roadmap", // roadmap | satellite | hybrid | terrain
17
- * routeMarker: "🚗", // emoji/char for animated pin
22
+ * view: "route", // overview | route | cinematic | streetview
23
+ * waypoints: [{ lat, lng, label?, media? }],
24
+ * travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
25
+ * mapType: "roadmap", // roadmap | satellite | hybrid | terrain
26
+ * camera: { zoom: { __tween: [6, 12, "easeInOut"] } }, // tween(6,12,easeInOut)
27
+ * routeMarker: "🚗", // emoji/char for animated pin
18
28
  * start: 0, end: 5
19
29
  * }
20
30
  */
21
31
  import React from "react";
22
- import { Sequence, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
32
+ import { Sequence, AbsoluteFill, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
23
33
  import { useFrameEvents } from "../context/index";
24
34
  import {
25
- APIProvider, Map as GoogleMap, useMap, useMapsLibrary,
26
- AdvancedMarker, Pin,
35
+ APIProvider, Map as GoogleMap, useMap, useMapsLibrary, useMap3D,
36
+ AdvancedMarker, Pin, Map3D, Marker3D,
27
37
  } from "@vis.gl/react-google-maps";
28
- import type { MapStream } from "../schema/index";
38
+ import type { Map3DRef } from "@vis.gl/react-google-maps";
39
+ import { resolveTween } from "../utils/tween";
40
+ import {
41
+ makeSyntheticLeg,
42
+ isSyntheticMode,
43
+ routePositionAtLegs,
44
+ modeEmoji,
45
+ type RouteLeg,
46
+ type RouteStopWindow,
47
+ } from "../utils/route-legs";
48
+ import type { MapStream, Stream } from "../schema/index";
29
49
 
30
50
  // API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
31
51
  // This fallback handles the case where Map.tsx is used directly without the compiler.
@@ -49,24 +69,256 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
49
69
  }
50
70
 
51
71
  // ============================================================
52
- // MapLeaf — entry point, renders each action as a Sequence
72
+ // MapRefContext — shares the live google.maps.Map instance
73
+ // between the view renderer and the overlay layer so anchored
74
+ // children can project lat/lng → screen pixel each frame.
75
+ // ============================================================
76
+ const MapRefContext = React.createContext<{
77
+ map: google.maps.Map | null;
78
+ setMap: (m: google.maps.Map | null) => void;
79
+ }>({ map: null, setMap: () => {} });
80
+
81
+ /** Invisible bridge rendered inside <GoogleMap> to capture the map instance. */
82
+ function MapBridge() {
83
+ const map = useMap();
84
+ const { setMap } = React.useContext(MapRefContext);
85
+ React.useEffect(() => {
86
+ setMap(map);
87
+ return () => setMap(null);
88
+ }, [map, setMap]);
89
+ return null;
90
+ }
91
+
92
+ // ============================================================
93
+ // Pure Mercator projection — converts a lat/lng to the map
94
+ // container's screen pixel, given the live center/zoom/size.
95
+ //
96
+ // `map.getProjection()` returns null until the map finishes
97
+ // initializing, which never happens reliably during Remotion's
98
+ // per-frame renders. This replicates `fromLatLngToPoint` with
99
+ // the same 256px-at-zoom-0 world coordinates, so anchored
100
+ // overlays align with the map deterministically every frame.
101
+ // ============================================================
102
+ const PROJECTION_TILE = 256;
103
+ function worldPoint(lat: number, lng: number): { x: number; y: number } {
104
+ const sin = Math.sin((lat * Math.PI) / 180);
105
+ return {
106
+ x: PROJECTION_TILE * (0.5 + lng / 360),
107
+ y: PROJECTION_TILE * (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)),
108
+ };
109
+ }
110
+
111
+ /** Screen pixel of a lat/lng inside the map container (Mercator, no projection API). */
112
+ function latLngToScreen(
113
+ map: google.maps.Map,
114
+ lat: number,
115
+ lng: number,
116
+ ): { x: number; y: number } | null {
117
+ const center = map.getCenter();
118
+ if (!center) return null;
119
+ const zoom = map.getZoom() ?? 10;
120
+ const container = map.getDiv();
121
+ const w = container.offsetWidth;
122
+ const h = container.offsetHeight;
123
+ if (!w || !h) return null;
124
+ const scale = Math.pow(2, zoom);
125
+ const wp = worldPoint(lat, lng);
126
+ const cp = worldPoint(center.lat(), center.lng());
127
+ return {
128
+ x: w / 2 + (wp.x - cp.x) * scale,
129
+ y: h / 2 + (wp.y - cp.y) * scale,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Default size of an anchored overlay box — a fraction of the map's smaller
135
+ * dimension. Children render at 100%×100% of this box (`objectFit` keeps the
136
+ * media proportional), so without explicit dimensions the wrapper would
137
+ * collapse to 0×0 and nothing would draw.
138
+ */
139
+ function anchoredOverlaySize(map: google.maps.Map): number {
140
+ const d = map.getDiv();
141
+ const w = d.offsetWidth || 640;
142
+ const h = d.offsetHeight || 480;
143
+ return Math.round(Math.min(w, h) * 0.5);
144
+ }
145
+
146
+ // ============================================================
147
+ // MapOverlays — renders the map's children as overlay layers.
148
+ // Children with `at:"Label"` are positioned at that waypoint's
149
+ // screen pixel (projected from the live map each frame).
150
+ // ============================================================
151
+ const LeafRenderers: Record<string, React.ComponentType<any>> = {};
152
+ async function ensureLeafRenderers() {
153
+ if (Object.keys(LeafRenderers).length) return;
154
+ const [v, a, i, c, e, f] = await Promise.all([
155
+ import("./Video"), import("./Audio"), import("./Image"), import("./Component"),
156
+ import("./Effect"), import("./Folder"),
157
+ ]);
158
+ LeafRenderers.video = v.VideoLeaf;
159
+ LeafRenderers.audio = a.AudioLeaf;
160
+ LeafRenderers.image = i.ImageLeaf;
161
+ LeafRenderers.component = c.ComponentLeaf;
162
+ LeafRenderers.effect = e.EffectWrapper;
163
+ LeafRenderers.folder = f.FolderLeaf;
164
+ }
165
+
166
+ function MapOverlays({
167
+ children, waypoints,
168
+ }: {
169
+ children: Stream[];
170
+ waypoints: { lat: number; lng: number; label?: string }[];
171
+ }) {
172
+ const { fps } = useVideoConfig();
173
+ const frame = useCurrentFrame();
174
+ const { map } = React.useContext(MapRefContext);
175
+ const [renderersReady, setRenderersReady] = React.useState(Object.keys(LeafRenderers).length > 0);
176
+
177
+ React.useEffect(() => {
178
+ ensureLeafRenderers().then(() => setRenderersReady(true));
179
+ }, []);
180
+
181
+ if (!renderersReady || !children || children.length === 0) return null;
182
+
183
+ return (
184
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
185
+ {children.map((child, i) => {
186
+ const childStart = child.start ?? 0;
187
+ const childEnd = child.end ?? childStart + (child.duration ?? 1);
188
+ const durFrames = Math.max(1, Math.floor(fps * (childEnd - childStart)));
189
+ const fromFrame = Math.floor(fps * childStart);
190
+
191
+ // Look up waypoint lat/lng for anchored children.
192
+ const anchorLabel = (child as any).at as string | undefined;
193
+ const wp = anchorLabel
194
+ ? waypoints.find((w) => w.label === anchorLabel)
195
+ : undefined;
196
+
197
+ return (
198
+ <Sequence key={i} durationInFrames={durFrames} from={fromFrame} layout="none">
199
+ <MapChildPositioner map={map} anchorLatLng={wp ? { lat: wp.lat, lng: wp.lng } : undefined} frame={frame} fps={fps}>
200
+ <MapChildRender child={child} />
201
+ </MapChildPositioner>
202
+ </Sequence>
203
+ );
204
+ })}
205
+ </AbsoluteFill>
206
+ );
207
+ }
208
+
209
+ /** Positions a child overlay: full-screen or anchored to a lat/lng on the map. */
210
+ function MapChildPositioner({
211
+ map, anchorLatLng, frame, fps, children,
212
+ }: {
213
+ map: google.maps.Map | null;
214
+ anchorLatLng?: { lat: number; lng: number };
215
+ frame: number;
216
+ fps: number;
217
+ children: React.ReactNode;
218
+ }) {
219
+ // Unanchored: full-screen overlay
220
+ if (!anchorLatLng) {
221
+ return <AbsoluteFill>{children}</AbsoluteFill>;
222
+ }
223
+
224
+ // Anchored: project lat/lng → container pixel each frame. Uses pure
225
+ // Mercator math (not map.getProjection(), which is null until the map
226
+ // initializes and never reliably during Remotion frame renders).
227
+ const overlay = React.useMemo(() => {
228
+ if (!map) return null;
229
+ return latLngToScreen(map, anchorLatLng.lat, anchorLatLng.lng);
230
+ }, [map, anchorLatLng?.lat, anchorLatLng?.lng, frame]);
231
+
232
+ if (!overlay) {
233
+ return <AbsoluteFill style={{ display: "none" }}>{children}</AbsoluteFill>;
234
+ }
235
+
236
+ const box = map ? anchoredOverlaySize(map) : 0;
237
+
238
+ return (
239
+ <div style={{
240
+ position: "absolute",
241
+ left: overlay.x,
242
+ top: overlay.y,
243
+ width: box,
244
+ height: box,
245
+ transform: "translate(-50%, -50%)",
246
+ }}>
247
+ {children}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ /** Renders a single compiled child stream inside a map overlay. */
253
+ function MapChildRender({ child }: { child: Stream }): React.ReactElement | null {
254
+ const type = child.type;
255
+
256
+ // Effect: render its children with the animation applied. Uses `contained`
257
+ // so the animated box fills the anchored overlay box (not the whole canvas),
258
+ // and preloaded renderers (no React.lazy — lazy chunks never resolve before
259
+ // Remotion captures the frame).
260
+ if (type === "effect") {
261
+ const EW = LeafRenderers.effect as React.ComponentType<{ stream: any; contained?: boolean; children?: React.ReactNode }>;
262
+ if (!EW) return null;
263
+ const effectChildren = ((child as any).children ?? []) as Stream[];
264
+ return React.createElement(
265
+ EW,
266
+ { stream: child, contained: true },
267
+ ...effectChildren.map((c, i) =>
268
+ React.createElement(MapChildRender, { key: i, child: c }),
269
+ ),
270
+ );
271
+ }
272
+
273
+ if (type === "folder") {
274
+ const FolderLeaf = LeafRenderers.folder;
275
+ if (!FolderLeaf) return null;
276
+ return React.createElement(FolderLeaf, { stream: child });
277
+ }
278
+
279
+ const Renderer = LeafRenderers[type];
280
+ if (!Renderer) return null;
281
+ return React.createElement(Renderer, { stream: child });
282
+ }
283
+
284
+ // ============================================================
285
+ // Hide Google Maps attribution / UI text (Google logo, "Keyboard
286
+ // shortcuts", "Terms", "Report a map error", "Map data ©...") from the
287
+ // rendered output. Both the 2D map and Street View share the `.gm-style`
288
+ // DOM, so two rules cover every view. Injected once, globally.
289
+ // ============================================================
290
+ let googleMapsUiHidden = false;
291
+ function hideGoogleMapsUi(): void {
292
+ if (googleMapsUiHidden || typeof document === "undefined") return;
293
+ googleMapsUiHidden = true;
294
+ const style = document.createElement("style");
295
+ style.textContent = [
296
+ // Attribution / copyright blocks: "Map data ©Google", "Keyboard shortcuts",
297
+ // "Terms", "Report a map error" / "Report a problem".
298
+ ".gm-style-cc { display: none !important; }",
299
+ // Google logo link ("Google" in the bottom-left).
300
+ '.gm-style a[href*="maps.google.com/maps"] { display: none !important; }',
301
+ ].join("\n");
302
+ document.head.appendChild(style);
303
+ }
304
+
305
+ // ============================================================
306
+ // MapLeaf — entry point, dispatches to the view renderer
53
307
  // ============================================================
54
308
  export function MapLeaf({ stream }: { stream: MapStream }) {
309
+ const [mapInstance, setMapInstance] = React.useState<google.maps.Map | null>(null);
55
310
  const { fps } = useVideoConfig();
56
311
  const waypoints = stream.waypoints ?? [];
57
312
  const start = stream.start ?? 0;
58
313
  const end = stream.end ?? start + (stream.duration ?? 1);
59
314
  const totalDur = stream.durationInSeconds ?? end;
60
315
  const apiKey = resolveApiKey(stream);
316
+ const view = stream.view ?? "route";
61
317
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
62
- if (waypoints.length === 0) return null;
318
+ // route/cinematic animate along waypoints; overview/streetview are standalone views.
319
+ if ((view === "route" || view === "cinematic") && waypoints.length === 0) return null;
63
320
 
64
321
  const durFrames = Math.max(1, Math.floor(fps * (end - start)));
65
- const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
66
- const zoom = stream.zoom ?? 10;
67
- const mapType = stream.mapType ?? "roadmap";
68
- const travelMode = stream.travelMode ?? "DRIVING";
69
- const markerEmoji = stream.routeMarker ?? "🚗";
70
322
  const mapLocale = React.useMemo(
71
323
  () => resolveMapLocale(stream.language, stream.region),
72
324
  [stream.language, stream.region],
@@ -75,10 +327,13 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
75
327
  const mapLoadContinuedRef = React.useRef(false);
76
328
 
77
329
  React.useEffect(() => {
78
- mapLoadHandleRef.current = delayRender("Waiting for map tiles to load...");
330
+ // Strip Google Maps attribution / UI text from the rendered video.
331
+ hideGoogleMapsUi();
332
+
333
+ mapLoadHandleRef.current = delayRender("Waiting for map to load...");
79
334
  mapLoadContinuedRef.current = false;
80
335
 
81
- // Avoid hanging indefinitely when map tiles fail to load.
336
+ // Avoid hanging indefinitely when tiles/pano/3D fail to load.
82
337
  const fallbackTimer = window.setTimeout(() => {
83
338
  if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
84
339
  continueRender(mapLoadHandleRef.current);
@@ -96,13 +351,20 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
96
351
  };
97
352
  }, [stream.id, start, end]);
98
353
 
99
- const handleTilesLoaded = React.useCallback(() => {
354
+ const handleMapReady = React.useCallback(() => {
100
355
  if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
101
356
  continueRender(mapLoadHandleRef.current);
102
357
  mapLoadContinuedRef.current = true;
103
358
  }
104
359
  }, []);
105
360
 
361
+ // Experimental Map3D is opt-in via `cinematic.fallback:"none"` (requires the
362
+ // Google Maps 3D preview API). Default renders the safe 2D chase camera.
363
+ const use3d =
364
+ view === "cinematic" &&
365
+ stream.cinematic?.fallback === "none" &&
366
+ (stream.cinematic.mode === "flyTo" || stream.cinematic.mode === "orbit");
367
+
106
368
  return (
107
369
  <Sequence
108
370
  durationInFrames={durFrames}
@@ -114,46 +376,752 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
114
376
  language={mapLocale.language}
115
377
  region={mapLocale.region}
116
378
  >
117
- <GoogleMap
118
- mapId={String(stream.id ?? "map")}
119
- defaultCenter={center}
120
- defaultZoom={zoom}
121
- defaultOptions={{
122
- mapTypeId: mapType,
123
- disableDefaultUI: true,
124
- zoomControl: false,
125
- }}
126
- onTilesLoaded={handleTilesLoaded}
127
- style={{ width: "100%", height: "100%", position: "absolute" }}
128
- >
129
- <RouteWithMarker
130
- waypoints={waypoints}
131
- travelMode={travelMode}
132
- markerEmoji={markerEmoji}
133
- actionDuration={end - start}
134
- />
135
- </GoogleMap>
379
+ <MapRefContext.Provider value={{ map: mapInstance, setMap: setMapInstance }}>
380
+ {view === "overview" && <OverviewMap stream={stream} onTilesLoaded={handleMapReady} />}
381
+ {view === "cinematic" && (use3d
382
+ ? <CinematicMap3D stream={stream} onReady={handleMapReady} />
383
+ : <CinematicMap stream={stream} onTilesLoaded={handleMapReady} />)}
384
+ {view === "streetview" && <StreetViewLeaf stream={stream} onPanoReady={handleMapReady} />}
385
+ {view === "route" && <RouteMap stream={stream} onTilesLoaded={handleMapReady} />}
386
+ <MapOverlays children={(stream.children as Stream[]) ?? []} waypoints={stream.waypoints} />
387
+ </MapRefContext.Provider>
136
388
  </APIProvider>
137
389
  </Sequence>
138
390
  );
139
391
  }
140
392
 
141
393
  // ============================================================
142
- // RouteWithMarker — gets the route via DirectionsService, then
143
- // renders an animated marker that follows the route path
394
+ // OverviewMap — static or dolly camera (far → near) over the map
395
+ // ============================================================
396
+ function OverviewMap({
397
+ stream, onTilesLoaded,
398
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
399
+ const { fps } = useVideoConfig();
400
+ const frame = useCurrentFrame();
401
+ const start = stream.start ?? 0;
402
+ const end = stream.end ?? start + (stream.duration ?? 1);
403
+ const fallbackCenter = stream.center ?? { lat: 37.7749, lng: -122.4194 };
404
+ const center = {
405
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
406
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
407
+ };
408
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
409
+ const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
410
+ const tilt = resolveTween(frame, fps, stream.camera?.tilt, start, end, 0);
411
+ return (
412
+ <GoogleMap
413
+ mapId={String(stream.id ?? "map-overview")}
414
+ center={center}
415
+ zoom={zoom}
416
+ heading={heading}
417
+ tilt={tilt}
418
+ mapTypeId={stream.mapType ?? "roadmap"}
419
+ disableDefaultUI
420
+ zoomControl={false}
421
+ onTilesLoaded={onTilesLoaded}
422
+ style={{ width: "100%", height: "100%", position: "absolute" }}
423
+ >
424
+ <MapBridge />
425
+ </GoogleMap>
426
+ );
427
+ }
428
+
429
+ // ============================================================
430
+ // RouteMap — classic animated route (view:"route", the default)
431
+ // ============================================================
432
+ function RouteMap({
433
+ stream, onTilesLoaded,
434
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
435
+ const { fps } = useVideoConfig();
436
+ const frame = useCurrentFrame();
437
+ const waypoints = stream.waypoints ?? [];
438
+ const start = stream.start ?? 0;
439
+ const end = stream.end ?? start + (stream.duration ?? 1);
440
+ const fallbackCenter = stream.center ?? { lat: waypoints[0]!.lat, lng: waypoints[0]!.lng };
441
+ const center = {
442
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
443
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
444
+ };
445
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
446
+ return (
447
+ <GoogleMap
448
+ mapId={String(stream.id ?? "map-route")}
449
+ center={center}
450
+ zoom={zoom}
451
+ mapTypeId={stream.mapType ?? "roadmap"}
452
+ disableDefaultUI
453
+ zoomControl={false}
454
+ onTilesLoaded={onTilesLoaded}
455
+ style={{ width: "100%", height: "100%", position: "absolute" }}
456
+ >
457
+ <MapBridge />
458
+ <RouteWithMarkerLegs
459
+ waypoints={waypoints}
460
+ travelMode={stream.travelMode ?? "DRIVING"}
461
+ markerEmoji={stream.routeMarker ?? "🚗"}
462
+ actionDuration={end - start}
463
+ routeColor={stream.routeColor ?? "#4285F4"}
464
+ routeWeight={stream.routeWeight ?? 4}
465
+ children={stream.children as Stream[]}
466
+ />
467
+ </GoogleMap>
468
+ );
469
+ }
470
+
471
+ // ============================================================
472
+ // CinematicMap — 2D movie flyover (view:"cinematic")
473
+ //
474
+ // flyAlong (default): drone chase — center follows the marker,
475
+ // heading = route bearing, tilt 45°
476
+ // flyTo: dolly from first waypoint toward the destination
477
+ // orbit: rotate heading around a fixed center
478
+ // ============================================================
479
+ function CinematicMap({
480
+ stream, onTilesLoaded,
481
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
482
+ const { fps } = useVideoConfig();
483
+ const frame = useCurrentFrame();
484
+ const waypoints = stream.waypoints ?? [];
485
+ const start = stream.start ?? 0;
486
+ const end = stream.end ?? start + (stream.duration ?? 1);
487
+ const actionDuration = Math.max(0.1, end - start);
488
+ const mode = stream.cinematic?.mode ?? "flyAlong";
489
+ const headingFollow = stream.cinematic?.headingFollow ?? true;
490
+ const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
491
+
492
+ const seconds = frame / fps;
493
+ const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
494
+ const lookahead = routePositionAt(leg, waypoints, actionDuration, seconds + 0.4);
495
+ const first = waypoints[0]!;
496
+ const fallbackCenter = stream.center ?? first;
497
+
498
+ let center: { lat: number; lng: number };
499
+ if (mode === "flyAlong" && pos) {
500
+ center = pos;
501
+ } else if (mode === "flyTo") {
502
+ center = {
503
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
504
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
505
+ };
506
+ } else {
507
+ center = fallbackCenter;
508
+ }
509
+
510
+ let heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
511
+ if (headingFollow && pos && lookahead) {
512
+ heading = bearing(pos, lookahead);
513
+ }
514
+
515
+ const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 45);
516
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 13);
517
+
518
+ return (
519
+ <GoogleMap
520
+ mapId={String(stream.id ?? "map-cinematic")}
521
+ center={center}
522
+ zoom={zoom}
523
+ heading={heading}
524
+ tilt={tilt}
525
+ mapTypeId={stream.mapType ?? "roadmap"}
526
+ disableDefaultUI
527
+ zoomControl={false}
528
+ onTilesLoaded={onTilesLoaded}
529
+ style={{ width: "100%", height: "100%", position: "absolute" }}
530
+ >
531
+ <RouteWithMarker
532
+ waypoints={waypoints}
533
+ travelMode={stream.travelMode ?? "DRIVING"}
534
+ markerEmoji={stream.routeMarker ?? "🚗"}
535
+ actionDuration={actionDuration}
536
+ />
537
+ </GoogleMap>
538
+ );
539
+ }
540
+
541
+ // ============================================================
542
+ // CinematicMap3D — experimental Map3D flyover (opt-in via fallback:"none")
543
+ //
544
+ // flyTo: controlled `range` tween (far → near) over the route
545
+ // orbit: controlled heading/roll tween around a fixed center
546
+ // Route drawn with a raw <gmp-polyline-3d> element; markers via <Marker3D>.
547
+ // ============================================================
548
+ function CinematicMap3D({
549
+ stream, onReady,
550
+ }: { stream: MapStream; onReady: () => void }) {
551
+ const { fps } = useVideoConfig();
552
+ const frame = useCurrentFrame();
553
+ const waypoints = stream.waypoints ?? [];
554
+ const start = stream.start ?? 0;
555
+ const end = stream.end ?? start + (stream.duration ?? 1);
556
+ const actionDuration = Math.max(0.1, end - start);
557
+ const first = waypoints[0] ?? { lat: 37.7749, lng: -122.4194 };
558
+ const map3dRef = React.useRef<Map3DRef>(null);
559
+ const map3d = useMap3D();
560
+ const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
561
+
562
+ const range = resolveTween(frame, fps, stream.cinematic?.range, start, end, 2000);
563
+ const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 60);
564
+ const roll = resolveTween(frame, fps, stream.cinematic?.roll, start, end, 0);
565
+ const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
566
+ const center = {
567
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
568
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
569
+ altitude: stream.cinematic?.altitude ?? 100,
570
+ };
571
+
572
+ const seconds = frame / fps;
573
+ const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
574
+
575
+ // Route line via the raw gmp-polyline-3d custom element (no wrapper in 1.8.x).
576
+ const legPath = React.useMemo(() => {
577
+ if (!leg) return null;
578
+ const pts: Array<{ lat: number; lng: number; altitude: number }> = [];
579
+ for (const step of leg.steps ?? []) {
580
+ for (const p of step.path ?? []) pts.push({ lat: p.lat(), lng: p.lng(), altitude: 0 });
581
+ }
582
+ return pts.length ? pts : null;
583
+ }, [leg]);
584
+
585
+ React.useEffect(() => {
586
+ if (!map3d || !legPath) return;
587
+ const el = document.createElement("gmp-polyline-3d") as unknown as google.maps.maps3d.Polyline3DElement;
588
+ el.coordinates = legPath;
589
+ el.strokeColor = stream.routeColor ?? "#4285F4";
590
+ el.strokeWidth = stream.routeWeight ?? 4;
591
+ map3d.appendChild(el);
592
+ return () => {
593
+ el.remove();
594
+ };
595
+ }, [map3d, legPath, stream.routeColor, stream.routeWeight]);
596
+
597
+ return (
598
+ <Map3D
599
+ ref={map3dRef}
600
+ mode={stream.mapType === "hybrid" ? "HYBRID" : "SATELLITE"}
601
+ center={center}
602
+ range={range}
603
+ heading={heading}
604
+ tilt={tilt}
605
+ roll={roll}
606
+ onSteadyChange={onReady}
607
+ onAnimationEnd={onReady}
608
+ style={{ width: "100%", height: "100%", position: "absolute" }}
609
+ >
610
+ {pos ? (
611
+ <Marker3D position={{ lat: pos.lat, lng: pos.lng, altitude: 0 }} />
612
+ ) : null}
613
+ </Map3D>
614
+ );
615
+ }
616
+
617
+ // ============================================================
618
+ // StreetViewLeaf — immersive StreetViewPanorama with animated POV/position
619
+ // ============================================================
620
+ function StreetViewLeaf({
621
+ stream, onPanoReady,
622
+ }: { stream: MapStream; onPanoReady: () => void }) {
623
+ const containerRef = React.useRef<HTMLDivElement>(null);
624
+ const svLibrary = useMapsLibrary("streetView");
625
+ const [pano, setPano] = React.useState<google.maps.StreetViewPanorama | null>(null);
626
+ const panoRef = React.useRef<google.maps.StreetViewPanorama | null>(null);
627
+ // Tracks whether the current panorama's imagery has reached StreetViewStatus.OK.
628
+ const loadedRef = React.useRef(false);
629
+ // Id of the last panorama that reached StreetViewStatus.OK — used to hold a
630
+ // loaded view when a walk waypoint has no imagery (instead of flashing black).
631
+ const lastGoodPanoRef = React.useRef<string | null>(null);
632
+ // Index of the waypoint the walk's panorama is currently parked at (snap walk).
633
+ const lastWpIndexRef = React.useRef(-1);
634
+
635
+ // ── Persistent pano-load gate ────────────────────────────────────────────
636
+ // Unlike a per-frame delayRender, this wait SURVIVES per-frame effect
637
+ // re-runs. In the Player, frames advance faster than a pano loads, and a
638
+ // per-frame delayRender is continued by the next frame's cleanup before the
639
+ // imagery arrives — capturing black frames when a scene is entered by
640
+ // playback. The wait completes only when the pano actually reaches OK.
641
+ const waitHandleRef = React.useRef<number | null>(null);
642
+ const waitPollRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
643
+ const waitCapRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
644
+
645
+ const { fps } = useVideoConfig();
646
+ const frame = useCurrentFrame();
647
+ const start = stream.start ?? 0;
648
+ const end = stream.end ?? start + (stream.duration ?? 1);
649
+ const sv = stream.streetView;
650
+ const svRadius = typeof sv?.radius === "number" && sv.radius > 0 ? sv.radius : 50;
651
+
652
+ const finishWait = React.useCallback(() => {
653
+ if (waitHandleRef.current != null) {
654
+ continueRender(waitHandleRef.current);
655
+ waitHandleRef.current = null;
656
+ }
657
+ if (waitPollRef.current != null) {
658
+ clearInterval(waitPollRef.current);
659
+ waitPollRef.current = null;
660
+ }
661
+ if (waitCapRef.current != null) {
662
+ clearTimeout(waitCapRef.current);
663
+ waitCapRef.current = null;
664
+ }
665
+ }, []);
666
+
667
+ const statusOk = React.useCallback(() => {
668
+ const p = panoRef.current;
669
+ return !!p && typeof p.getStatus === "function" && p.getStatus() === google.maps.StreetViewStatus.OK;
670
+ }, []);
671
+
672
+ const startWait = React.useCallback(() => {
673
+ if (waitHandleRef.current != null) return; // already waiting
674
+ waitHandleRef.current = delayRender("Street View pano");
675
+ waitPollRef.current = setInterval(() => {
676
+ if (!statusOk()) return;
677
+ const p = panoRef.current;
678
+ const panoId = p && typeof p.getPano === "function" ? p.getPano() : null;
679
+ // Status OK = imagery metadata ready, but the TILES may not have painted
680
+ // yet. When a NEW panorama just became OK, wait a grace for tiles; if the
681
+ // same (already painted) panorama is OK, finish immediately.
682
+ const isNewPano = panoId !== lastGoodPanoRef.current;
683
+ lastGoodPanoRef.current = panoId;
684
+ loadedRef.current = true;
685
+ if (isNewPano) {
686
+ clearInterval(waitPollRef.current ?? undefined);
687
+ waitPollRef.current = null;
688
+ waitCapRef.current = setTimeout(() => {
689
+ finishWait();
690
+ onPanoReady();
691
+ }, 1200);
692
+ } else {
693
+ finishWait();
694
+ onPanoReady();
695
+ }
696
+ }, 200);
697
+ // Generous cap for slow or rate-limited loads (Google 429s under load).
698
+ // On cap without OK, hold the last loaded panorama instead of showing black.
699
+ waitCapRef.current = setTimeout(() => {
700
+ if (statusOk()) {
701
+ const p = panoRef.current;
702
+ lastGoodPanoRef.current = p && typeof p.getPano === "function" ? p.getPano() : null;
703
+ loadedRef.current = true;
704
+ finishWait();
705
+ onPanoReady();
706
+ return;
707
+ }
708
+ const fallbackId = lastGoodPanoRef.current;
709
+ const p = panoRef.current;
710
+ if (fallbackId && p && typeof p.setPano === "function") {
711
+ p.setPano(fallbackId);
712
+ setTimeout(() => {
713
+ finishWait();
714
+ onPanoReady();
715
+ }, 400);
716
+ } else {
717
+ finishWait();
718
+ onPanoReady();
719
+ }
720
+ }, 8000);
721
+ }, [statusOk, finishWait, onPanoReady]);
722
+
723
+ // Unmount: always finish any pending wait so Remotion never hangs.
724
+ React.useEffect(() => finishWait, [finishWait]);
725
+
726
+ // Create the panorama once (no React wrapper component in this library version).
727
+ React.useEffect(() => {
728
+ if (!svLibrary || !containerRef.current) return;
729
+ loadedRef.current = false;
730
+ lastWpIndexRef.current = -1;
731
+ const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
732
+ disableDefaultUI: true,
733
+ });
734
+ panoRef.current = pan;
735
+ // setPosition(latLng, radius?) — radius (m) constrains the panorama search.
736
+ // The public typings only expose the 1-arg overload, so cast the call.
737
+ const panSetPosition = (loc: google.maps.LatLngLiteral) =>
738
+ (pan.setPosition as unknown as (l: google.maps.LatLngLiteral, r: number) => void)(loc, svRadius);
739
+ if (sv?.pano) {
740
+ pan.setPano(sv.pano);
741
+ } else if (sv?.location) {
742
+ panSetPosition(sv.location);
743
+ } else if (sv?.route?.length) {
744
+ panSetPosition(sv.route[0]!);
745
+ }
746
+ if (sv?.pov) {
747
+ pan.setPov({
748
+ heading: typeof sv.pov.heading === "number" ? sv.pov.heading : 0,
749
+ pitch: typeof sv.pov.pitch === "number" ? sv.pov.pitch : 0,
750
+ });
751
+ }
752
+ if (typeof sv?.zoom === "number") {
753
+ pan.setZoom(sv.zoom);
754
+ }
755
+ // Keep loadedRef fresh whenever the API reports OK (the persistent wait's
756
+ // poll owns lastGoodPanoRef so it can detect when a NEW pano just loaded).
757
+ const onStatus = () => {
758
+ if (typeof pan.getStatus === "function" && pan.getStatus() === google.maps.StreetViewStatus.OK) {
759
+ loadedRef.current = true;
760
+ }
761
+ };
762
+ pan.addListener("status_changed", onStatus);
763
+ setPano(pan);
764
+ // Gate the initial load (persistent — see startWait).
765
+ startWait();
766
+ return () => {
767
+ loadedRef.current = false;
768
+ panoRef.current = null;
769
+ pan.setVisible(false);
770
+ };
771
+ }, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
772
+
773
+ // Per-frame POV + snap-walk position. The panorama only MOVES when the walk
774
+ // crosses a waypoint boundary; a new pano load is gated by the persistent
775
+ // startWait (never a per-frame finish, so playback can't capture black).
776
+ React.useEffect(() => {
777
+ if (!pano) return;
778
+ const pov = pano.getPov();
779
+ const heading = resolveTween(frame, fps, sv?.pov?.heading, start, end, pov.heading);
780
+ const pitch = resolveTween(frame, fps, sv?.pov?.pitch, start, end, pov.pitch);
781
+ pano.setPov({ heading, pitch });
782
+ const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
783
+ pano.setZoom(zoom);
784
+
785
+ if (sv?.route && sv.route.length > 1) {
786
+ // Discrete "snap walk": hold at the nearest waypoint and jump between
787
+ // waypoints. Continuous per-frame interpolation requests a (slightly
788
+ // different) panorama every frame — hundreds of Google Street View API
789
+ // calls per render, which gets rate-limited (429) and renders black.
790
+ const t = Math.min(Math.max((frame / fps) / Math.max(0.1, end - start), 0), 1);
791
+ const wpIndex = Math.min(Math.floor(t * sv.route.length), sv.route.length - 1);
792
+ if (wpIndex !== lastWpIndexRef.current) {
793
+ lastWpIndexRef.current = wpIndex;
794
+ const pt = sv.route[wpIndex]!;
795
+ (pano.setPosition as unknown as (l: google.maps.LatLngLiteral, r: number) => void)(pt, svRadius);
796
+ // New waypoint → new panorama may be needed; gate until it loads.
797
+ startWait();
798
+ }
799
+ } else if (!loadedRef.current) {
800
+ // Static/POV-only scene: keep the initial load gated until ready.
801
+ startWait();
802
+ }
803
+ }, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
804
+
805
+ return (
806
+ <div
807
+ ref={containerRef}
808
+ style={{ width: "100%", height: "100%", position: "absolute" }}
809
+ />
810
+ );
811
+ }
812
+
813
+ // ============================================================
814
+ // WaypointMarkers — one marker per waypoint (media thumbnail or label)
815
+ // ============================================================
816
+ function WaypointMarkers({
817
+ waypoints,
818
+ }: {
819
+ waypoints: { lat: number; lng: number; label?: string; media?: string }[];
820
+ }) {
821
+ return (
822
+ <>
823
+ {waypoints.map((wp, i) => (
824
+ <AdvancedMarker key={i} position={wp}>
825
+ {wp.media ? (
826
+ <div
827
+ style={{
828
+ width: 44,
829
+ height: 44,
830
+ borderRadius: 6,
831
+ overflow: "hidden",
832
+ border: "2px solid #fff",
833
+ boxShadow: "0 1px 4px rgba(0,0,0,0.45)",
834
+ background: "#fff",
835
+ position: "relative",
836
+ top: "-24px",
837
+ }}
838
+ >
839
+ <img
840
+ src={wp.media}
841
+ alt=""
842
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
843
+ />
844
+ </div>
845
+ ) : wp.label ? (
846
+ <div
847
+ style={{
848
+ background: "rgba(255,255,255,0.9)",
849
+ borderRadius: "4px",
850
+ padding: "2px 6px",
851
+ fontSize: "12px",
852
+ fontWeight: 700,
853
+ color: "#333",
854
+ whiteSpace: "nowrap",
855
+ position: "relative",
856
+ top: "-24px",
857
+ }}
858
+ >
859
+ {wp.label}
860
+ </div>
861
+ ) : null}
862
+ </AdvancedMarker>
863
+ ))}
864
+ </>
865
+ );
866
+ }
867
+
868
+ // ============================================================
869
+ // TravelingMarker — the animated marker along the route
870
+ // ============================================================
871
+ function TravelingMarker({
872
+ position,
873
+ glyph,
874
+ }: {
875
+ position: { lat: number; lng: number } | null;
876
+ glyph: string;
877
+ }) {
878
+ if (!position) return null;
879
+ return (
880
+ <AdvancedMarker position={position}>
881
+ <Pin glyphText={glyph} scale={4} />
882
+ </AdvancedMarker>
883
+ );
884
+ }
885
+
886
+ // ============================================================
887
+ // RoutePolylines — draws each leg's route line. Road legs are a
888
+ // solid line in the route color; synthetic legs (FLIGHT ✈️ / BOAT 🚢)
889
+ // are a dashed arc in a distinct color.
890
+ // ============================================================
891
+ function RoutePolylines({
892
+ legs, routeColor, routeWeight,
893
+ }: {
894
+ legs: RouteLeg[];
895
+ routeColor: string;
896
+ routeWeight: number;
897
+ }) {
898
+ const map = useMap();
899
+
900
+ React.useEffect(() => {
901
+ if (!map) return;
902
+ const created = legs.map((leg) => {
903
+ const path = leg.steps.flatMap((s) => s.path);
904
+ const synthetic = isSyntheticMode(leg.mode);
905
+ const color = synthetic
906
+ ? leg.mode.toUpperCase() === "BOAT" ? "#00ACC1" : "#FBBC04"
907
+ : routeColor;
908
+ const opts: google.maps.PolylineOptions = {
909
+ map,
910
+ path,
911
+ strokeColor: color,
912
+ strokeWeight: synthetic ? Math.max(3, routeWeight - 1) : routeWeight,
913
+ strokeOpacity: 0.95,
914
+ zIndex: 1,
915
+ };
916
+ if (synthetic) {
917
+ opts.icons = [{
918
+ icon: { path: "M 0 -1 0 1", strokeColor: color, strokeWeight: 2, scale: 2 },
919
+ offset: "0",
920
+ repeat: "12px",
921
+ }];
922
+ }
923
+ return new google.maps.Polyline(opts);
924
+ });
925
+ return () => created.forEach((p) => p.setMap(null));
926
+ }, [map, legs, routeColor, routeWeight]);
927
+
928
+ return null;
929
+ }
930
+
931
+ // ============================================================
932
+ // RouteWithMarker — single-leg route (used by cinematic views).
933
+ // Fetches the Directions route, renders the route line + waypoint
934
+ // markers (label or media thumbnail) + the animated traveling marker
144
935
  // ============================================================
145
936
  function RouteWithMarker({
146
937
  waypoints, travelMode, markerEmoji, actionDuration,
147
938
  }: {
148
- waypoints: { lat: number; lng: number; label?: string }[];
939
+ waypoints: { lat: number; lng: number; label?: string; media?: string }[];
149
940
  travelMode: string;
150
941
  markerEmoji: string;
151
942
  actionDuration: number;
152
943
  }) {
944
+ const leg = useRouteLeg(waypoints, travelMode);
945
+
946
+ // Compute animated marker position
947
+ const position = useAnimatedPosition({ leg, actionDuration, waypoints });
948
+
949
+ return (
950
+ <>
951
+ <WaypointMarkers waypoints={waypoints} />
952
+ <TravelingMarker position={position} glyph={markerEmoji} />
953
+ </>
954
+ );
955
+ }
956
+
957
+ // ============================================================
958
+ // RouteWithMarkerLegs — multi-leg route (view:"route"). Each
959
+ // waypoint may tag its outgoing leg with a travel mode
960
+ // (waypoint.mode ?? map travelMode); FLIGHT/BOAT legs are synthetic
961
+ // dashed arcs. The traveling marker switches glyph per leg
962
+ // (✈️ 🚢 🚶 🚲 🚌 🚗).
963
+ // ============================================================
964
+ function RouteWithMarkerLegs({
965
+ waypoints, travelMode, markerEmoji, actionDuration, routeColor, routeWeight, children,
966
+ }: {
967
+ waypoints: { lat: number; lng: number; label?: string; media?: string; mode?: string }[];
968
+ travelMode: string;
969
+ markerEmoji: string;
970
+ actionDuration: number;
971
+ routeColor: string;
972
+ routeWeight: number;
973
+ children?: Stream[];
974
+ }) {
975
+ const legs = useRouteLegs(waypoints, travelMode);
976
+ const stops = useRouteStops(children, waypoints, legs);
977
+ const position = useAnimatedPositionLegs({ legs, actionDuration, stops });
978
+ const glyph = position ? modeEmoji(position.mode) : markerEmoji;
979
+
980
+ return (
981
+ <>
982
+ <RoutePolylines legs={legs} routeColor={routeColor} routeWeight={routeWeight} />
983
+ <WaypointMarkers waypoints={waypoints} />
984
+ <TravelingMarker position={position} glyph={glyph} />
985
+ </>
986
+ );
987
+ }
988
+
989
+ // ============================================================
990
+ // useRouteStops — derives the pin's dwell windows from the map's
991
+ // overlay children (each has at:"Label" + start/end). The pin holds
992
+ // at that waypoint while its child plays. Windows at the same
993
+ // waypoint are merged.
994
+ // ============================================================
995
+ function useRouteStops(
996
+ children: Stream[] | undefined,
997
+ waypoints: { lat: number; lng: number; label?: string; mode?: string }[],
998
+ legs: RouteLeg[],
999
+ ): RouteStopWindow[] {
1000
+ return React.useMemo(() => {
1001
+ if (!children || children.length === 0) return [];
1002
+ const byLabel = new Map<string, RouteStopWindow>();
1003
+ for (const child of children) {
1004
+ const label = (child as any).at as string | undefined;
1005
+ if (!label) continue;
1006
+ const wp = waypoints.find((w) => w.label === label);
1007
+ if (!wp) continue;
1008
+ const fromSec = child.start ?? 0;
1009
+ const toSec = child.end ?? fromSec + (child.duration ?? 1);
1010
+ // Mode of the leg that arrives at this waypoint (leg i → waypoint i+1).
1011
+ const idx = waypoints.findIndex((w) => w.label === label) - 1;
1012
+ const mode = legs[idx]?.mode ?? "DRIVING";
1013
+ const existing = byLabel.get(label);
1014
+ if (existing) {
1015
+ existing.fromSec = Math.min(existing.fromSec, fromSec);
1016
+ existing.toSec = Math.max(existing.toSec, toSec);
1017
+ } else {
1018
+ byLabel.set(label, { label, at: { lat: wp.lat, lng: wp.lng }, mode, fromSec, toSec });
1019
+ }
1020
+ }
1021
+ return [...byLabel.values()];
1022
+ }, [children, waypoints, legs]);
1023
+ }
1024
+
1025
+
1026
+ // ============================================================
1027
+ // useRouteLegs — loads one Directions leg per consecutive waypoint
1028
+ // pair (view:"route"). Each waypoint's `mode` tags its OUTGOING leg;
1029
+ // FLIGHT/BOAT legs are synthetic arcs (no Directions route). Time is
1030
+ // split across legs proportionally to each leg's duration.
1031
+ // ============================================================
1032
+ function useRouteLegs(
1033
+ waypoints: { lat: number; lng: number; mode?: string }[],
1034
+ travelMode: string,
1035
+ ): RouteLeg[] {
1036
+ const map = useMap();
1037
+ const routesLibrary = useMapsLibrary("routes");
1038
+ const [legs, setLegs] = React.useState<RouteLeg[]>([]);
1039
+ const handle = React.useRef<number | null>(null);
1040
+
1041
+ React.useEffect(() => {
1042
+ if (!routesLibrary || !map || waypoints.length < 2) return;
1043
+ let active = true;
1044
+ const renderHandle = delayRender("Loading map directions...");
1045
+ handle.current = renderHandle;
1046
+
1047
+ const service = new routesLibrary.DirectionsService();
1048
+
1049
+ Promise.all(
1050
+ waypoints.slice(0, -1).map((wp, i) => {
1051
+ const to = waypoints[i + 1]!;
1052
+ const mode = (wp.mode ?? travelMode).toUpperCase();
1053
+ if (isSyntheticMode(mode)) {
1054
+ return Promise.resolve<RouteLeg | null>(makeSyntheticLeg(wp, to, mode));
1055
+ }
1056
+ return service
1057
+ .route({
1058
+ origin: wp,
1059
+ destination: to,
1060
+ travelMode: google.maps.TravelMode[mode as keyof typeof google.maps.TravelMode] ?? google.maps.TravelMode.DRIVING,
1061
+ provideRouteAlternatives: false,
1062
+ })
1063
+ .then((response) => {
1064
+ const leg = response.routes[0]?.legs[0];
1065
+ if (!leg) return null;
1066
+ const steps = (leg.steps ?? []).map((s) => ({
1067
+ path: (s.path ?? []).map((p) => ({ lat: p.lat(), lng: p.lng() })),
1068
+ durationSec: s.duration?.value ?? 1,
1069
+ }));
1070
+ return {
1071
+ mode,
1072
+ from: wp,
1073
+ to,
1074
+ durationSec: leg.duration?.value ?? (steps.reduce((sum, st) => sum + st.durationSec, 0) || 1),
1075
+ steps,
1076
+ } satisfies RouteLeg;
1077
+ })
1078
+ .catch(() => null);
1079
+ }),
1080
+ ).then((resolved) => {
1081
+ if (!active) return;
1082
+ setLegs(resolved.filter((l): l is RouteLeg => l !== null));
1083
+ if (handle.current !== null) continueRender(handle.current);
1084
+ });
1085
+
1086
+ return () => {
1087
+ active = false;
1088
+ };
1089
+ }, [routesLibrary, map, waypoints, travelMode]);
1090
+
1091
+ return legs;
1092
+ }
1093
+
1094
+ // ============================================================
1095
+ // useAnimatedPositionLegs — leg-aware animated marker position
1096
+ // (view:"route"): returns { lat, lng, mode } for the current frame
1097
+ // ============================================================
1098
+ function useAnimatedPositionLegs({
1099
+ legs, actionDuration, stops = [],
1100
+ }: {
1101
+ legs: RouteLeg[];
1102
+ actionDuration: number;
1103
+ stops?: RouteStopWindow[];
1104
+ }) {
1105
+ const frame = useCurrentFrame();
1106
+ const { fps } = useVideoConfig();
1107
+ return React.useMemo(
1108
+ () => routePositionAtLegs(legs, actionDuration, frame / fps, stops),
1109
+ [legs, frame, fps, actionDuration, stops],
1110
+ );
1111
+ }
1112
+
1113
+ // ============================================================
1114
+ // useRouteLeg — loads the Directions route (shared by RouteMap,
1115
+ // CinematicMap and CinematicMap3D). Uses the map instance from
1116
+ // the nearest <GoogleMap> / <Map3D> context.
1117
+ // ============================================================
1118
+ function useRouteLeg(
1119
+ waypoints: { lat: number; lng: number }[],
1120
+ travelMode: string,
1121
+ ): google.maps.DirectionsLeg | null {
153
1122
  const map = useMap();
154
1123
  const routesLibrary = useMapsLibrary("routes");
155
1124
  const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
156
- const [routeIndex, setRouteIndex] = React.useState(0);
157
1125
  const handle = React.useRef<number | null>(null);
158
1126
 
159
1127
  // Load directions
@@ -167,15 +1135,14 @@ function RouteWithMarker({
167
1135
 
168
1136
  service
169
1137
  .route({
170
- origin: waypoints[0],
171
- destination: waypoints[waypoints.length - 1],
1138
+ origin: waypoints[0]!,
1139
+ destination: waypoints[waypoints.length - 1]!,
172
1140
  waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
173
1141
  travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
174
1142
  provideRouteAlternatives: false,
175
1143
  })
176
1144
  .then((response) => {
177
1145
  renderer.setDirections(response);
178
- setRouteIndex(0);
179
1146
  setLeg(response.routes[0]?.legs[0] ?? null);
180
1147
  if (handle.current !== null) continueRender(handle.current);
181
1148
  })
@@ -188,44 +1155,7 @@ function RouteWithMarker({
188
1155
  };
189
1156
  }, [routesLibrary, map, waypoints, travelMode]);
190
1157
 
191
- // Update route index
192
- React.useEffect(() => {
193
- setRouteIndex((prev) => prev);
194
- }, [routeIndex]);
195
-
196
- // Compute animated marker position
197
- const position = useAnimatedPosition({ leg, actionDuration, waypoints });
198
-
199
- return (
200
- <>
201
- {waypoints.map((wp, i) => (
202
- <AdvancedMarker key={i} position={wp}>
203
- {wp.label ? (
204
- <div
205
- style={{
206
- background: "rgba(255,255,255,0.9)",
207
- borderRadius: "4px",
208
- padding: "2px 6px",
209
- fontSize: "12px",
210
- fontWeight: 700,
211
- color: "#333",
212
- whiteSpace: "nowrap",
213
- position: "relative",
214
- top: "-24px",
215
- }}
216
- >
217
- {wp.label}
218
- </div>
219
- ) : null}
220
- </AdvancedMarker>
221
- ))}
222
- {position ? (
223
- <AdvancedMarker position={position}>
224
- <Pin glyphText={markerEmoji} scale={4} />
225
- </AdvancedMarker>
226
- ) : null}
227
- </>
228
- );
1158
+ return leg;
229
1159
  }
230
1160
 
231
1161
  // ============================================================
@@ -242,51 +1172,69 @@ function useAnimatedPosition({
242
1172
  const frame = useCurrentFrame();
243
1173
  const { fps } = useVideoConfig();
244
1174
 
245
- return React.useMemo(() => {
246
- if (!leg || !leg.duration?.value) {
247
- // Fallback: linear interpolation between waypoints
248
- if (waypoints.length < 2) return null;
249
- const t = Math.min(frame / (actionDuration * fps), 1);
250
- const total = waypoints.length - 1;
251
- const segI = Math.min(Math.floor(t * total), total - 1);
252
- const segT = (t * total) - segI;
253
- const a = waypoints[segI];
254
- const b = waypoints[segI + 1];
255
- if (!a || !b) return null;
256
- return {
257
- lat: a.lat + (b.lat - a.lat) * segT,
258
- lng: a.lng + (b.lng - a.lng) * segT,
259
- };
260
- }
1175
+ return React.useMemo(
1176
+ () => routePositionAt(leg, waypoints, actionDuration, frame / fps),
1177
+ [leg, frame, fps, actionDuration, waypoints],
1178
+ );
1179
+ }
261
1180
 
262
- // Follow the route path using leg steps
263
- const currentInSecond = (frame / fps) * (leg.duration.value / actionDuration);
264
- const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
265
- if (!step || !step.path) {
266
- // Fallback to linear
267
- const t = Math.min(frame / (actionDuration * fps), 1);
268
- const total = waypoints.length - 1;
269
- const segI = Math.min(Math.floor(t * total), total - 1);
270
- const segT = (t * total) - segI;
271
- const a = waypoints[segI];
272
- const b = waypoints[segI + 1];
273
- if (!a || !b) return null;
274
- return {
275
- lat: a.lat + (b.lat - a.lat) * segT,
276
- lng: a.lng + (b.lng - a.lng) * segT,
277
- };
278
- }
1181
+ // ============================================================
1182
+ // routePositionAt — pure position math shared by the marker and
1183
+ // the cinematic camera (deterministic per second)
1184
+ // ============================================================
1185
+ function routePositionAt(
1186
+ leg: google.maps.DirectionsLeg | null,
1187
+ waypoints: { lat: number; lng: number }[],
1188
+ actionDuration: number,
1189
+ seconds: number,
1190
+ ): { lat: number; lng: number } | null {
1191
+ const linearFallback = (): { lat: number; lng: number } | null => {
1192
+ if (waypoints.length < 2) return null;
1193
+ const t = Math.min(seconds / actionDuration, 1);
1194
+ const total = waypoints.length - 1;
1195
+ const segI = Math.min(Math.floor(t * total), total - 1);
1196
+ const segT = t * total - segI;
1197
+ const a = waypoints[segI];
1198
+ const b = waypoints[segI + 1];
1199
+ if (!a || !b) return null;
1200
+ return { lat: a.lat + (b.lat - a.lat) * segT, lng: a.lng + (b.lng - a.lng) * segT };
1201
+ };
279
1202
 
280
- const stepElapsed = currentInSecond - elapsedInSeconds;
281
- const stepProgress = stepElapsed / (step.duration?.value ?? 1);
282
- const pathIdx = Math.min(
283
- Math.max(0, Math.floor(stepProgress * step.path.length)),
284
- step.path.length - 1,
285
- );
286
- const pt = step.path[pathIdx];
287
- if (!pt) return null;
288
- return { lat: pt.lat(), lng: pt.lng() };
289
- }, [leg, frame, fps, actionDuration, waypoints]);
1203
+ if (!leg || !leg.duration?.value) {
1204
+ return linearFallback();
1205
+ }
1206
+
1207
+ // Follow the route path using leg steps
1208
+ const currentInSecond = seconds * (leg.duration.value / actionDuration);
1209
+ const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
1210
+ if (!step || !step.path) {
1211
+ return linearFallback();
1212
+ }
1213
+
1214
+ const stepElapsed = currentInSecond - elapsedInSeconds;
1215
+ const stepProgress = stepElapsed / (step.duration?.value ?? 1);
1216
+ const pathIdx = Math.min(
1217
+ Math.max(0, Math.floor(stepProgress * step.path.length)),
1218
+ step.path.length - 1,
1219
+ );
1220
+ const pt = step.path[pathIdx];
1221
+ if (!pt) return null;
1222
+ return { lat: pt.lat(), lng: pt.lng() };
1223
+ }
1224
+
1225
+ // ============================================================
1226
+ // bearing — initial bearing (degrees clockwise from north) between
1227
+ // two lat/lng points. Haversine; no geometry library required.
1228
+ // ============================================================
1229
+ function bearing(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
1230
+ const toRad = (d: number) => (d * Math.PI) / 180;
1231
+ const toDeg = (d: number) => (d * 180) / Math.PI;
1232
+ const lat1 = toRad(a.lat);
1233
+ const lat2 = toRad(b.lat);
1234
+ const dLng = toRad(b.lng - a.lng);
1235
+ const y = Math.sin(dLng) * Math.cos(lat2);
1236
+ const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
1237
+ return (toDeg(Math.atan2(y, x)) + 360) % 360;
290
1238
  }
291
1239
 
292
1240
  // ============================================================