@lalalic/markcut 2.9.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/components.md +45 -2
  4. package/skills/markcut/docs/map-dynamic-camera.md +244 -0
  5. package/skills/markcut/docs/markdown-descriptive.md +7 -2
  6. package/src/components/Markdown.tsx +138 -24
  7. package/src/components/Mermaid.tsx +223 -22
  8. package/src/context/EventContext.tsx +3 -0
  9. package/src/descriptive/compiler.ts +105 -29
  10. package/src/descriptive/dsl.ts +42 -5
  11. package/src/descriptive/markdown.ts +23 -0
  12. package/src/descriptive/resolve.test.ts +5 -5
  13. package/src/descriptive/resolve.ts +51 -12
  14. package/src/player/bundle/player.js +751 -143
  15. package/src/player/pipeline.mjs +130 -32
  16. package/src/player/pipeline.ts +5 -4
  17. package/src/player/server.mjs +22 -42
  18. package/src/render/cli.mjs +54 -3
  19. package/src/render/validate-assets.mjs +140 -0
  20. package/src/schema/index.ts +58 -2
  21. package/src/spots/cli.mjs +266 -0
  22. package/src/types/Component.tsx +27 -1
  23. package/src/types/Effect.tsx +13 -6
  24. package/src/types/Folder.tsx +1 -1
  25. package/src/types/Map.tsx +501 -127
  26. package/src/utils/index.ts +14 -2
  27. package/src/utils/tween.ts +49 -1
  28. package/tests/dsl.test.ts +43 -0
  29. package/tests/fixtures/map-dynamic.json +52 -0
  30. package/tests/fixtures/md/animate-diagrams.md +42 -0
  31. package/tests/fixtures/md/electricity-grow.md +130 -0
  32. package/tests/fixtures/md/map-all-views.md +28 -0
  33. package/tests/md-descriptive.test.ts +58 -0
  34. package/tests/render.test.ts +1 -0
  35. package/tests/schema.test.ts +58 -1
  36. package/tests/validate-assets.test.ts +106 -0
package/src/types/Map.tsx CHANGED
@@ -1,20 +1,30 @@
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
  */
@@ -22,9 +32,11 @@ import React from "react";
22
32
  import { Sequence, 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";
38
+ import type { Map3DRef } from "@vis.gl/react-google-maps";
39
+ import { resolveTween } from "../utils/tween";
28
40
  import type { MapStream } from "../schema/index";
29
41
 
30
42
  // API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
@@ -49,7 +61,7 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
49
61
  }
50
62
 
51
63
  // ============================================================
52
- // MapLeaf — entry point, renders each action as a Sequence
64
+ // MapLeaf — entry point, dispatches to the view renderer
53
65
  // ============================================================
54
66
  export function MapLeaf({ stream }: { stream: MapStream }) {
55
67
  const { fps } = useVideoConfig();
@@ -58,15 +70,12 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
58
70
  const end = stream.end ?? start + (stream.duration ?? 1);
59
71
  const totalDur = stream.durationInSeconds ?? end;
60
72
  const apiKey = resolveApiKey(stream);
73
+ const view = stream.view ?? "route";
61
74
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
62
- if (waypoints.length === 0) return null;
75
+ // route/cinematic animate along waypoints; overview/streetview are standalone views.
76
+ if ((view === "route" || view === "cinematic") && waypoints.length === 0) return null;
63
77
 
64
78
  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
79
  const mapLocale = React.useMemo(
71
80
  () => resolveMapLocale(stream.language, stream.region),
72
81
  [stream.language, stream.region],
@@ -75,10 +84,10 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
75
84
  const mapLoadContinuedRef = React.useRef(false);
76
85
 
77
86
  React.useEffect(() => {
78
- mapLoadHandleRef.current = delayRender("Waiting for map tiles to load...");
87
+ mapLoadHandleRef.current = delayRender("Waiting for map to load...");
79
88
  mapLoadContinuedRef.current = false;
80
89
 
81
- // Avoid hanging indefinitely when map tiles fail to load.
90
+ // Avoid hanging indefinitely when tiles/pano/3D fail to load.
82
91
  const fallbackTimer = window.setTimeout(() => {
83
92
  if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
84
93
  continueRender(mapLoadHandleRef.current);
@@ -96,13 +105,20 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
96
105
  };
97
106
  }, [stream.id, start, end]);
98
107
 
99
- const handleTilesLoaded = React.useCallback(() => {
108
+ const handleMapReady = React.useCallback(() => {
100
109
  if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
101
110
  continueRender(mapLoadHandleRef.current);
102
111
  mapLoadContinuedRef.current = true;
103
112
  }
104
113
  }, []);
105
114
 
115
+ // Experimental Map3D is opt-in via `cinematic.fallback:"none"` (requires the
116
+ // Google Maps 3D preview API). Default renders the safe 2D chase camera.
117
+ const use3d =
118
+ view === "cinematic" &&
119
+ stream.cinematic?.fallback === "none" &&
120
+ (stream.cinematic.mode === "flyTo" || stream.cinematic.mode === "orbit");
121
+
106
122
  return (
107
123
  <Sequence
108
124
  durationInFrames={durFrames}
@@ -114,84 +130,356 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
114
130
  language={mapLocale.language}
115
131
  region={mapLocale.region}
116
132
  >
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>
133
+ {view === "overview" && <OverviewMap stream={stream} onTilesLoaded={handleMapReady} />}
134
+ {view === "cinematic" && (use3d
135
+ ? <CinematicMap3D stream={stream} onReady={handleMapReady} />
136
+ : <CinematicMap stream={stream} onTilesLoaded={handleMapReady} />)}
137
+ {view === "streetview" && <StreetViewLeaf stream={stream} onPanoReady={handleMapReady} />}
138
+ {view === "route" && <RouteMap stream={stream} onTilesLoaded={handleMapReady} />}
136
139
  </APIProvider>
137
140
  </Sequence>
138
141
  );
139
142
  }
140
143
 
141
144
  // ============================================================
142
- // RouteWithMarker — gets the route via DirectionsService, then
143
- // renders an animated marker that follows the route path
145
+ // OverviewMap — static or dolly camera (far → near) over the map
144
146
  // ============================================================
145
- function RouteWithMarker({
146
- waypoints, travelMode, markerEmoji, actionDuration,
147
- }: {
148
- waypoints: { lat: number; lng: number; label?: string }[];
149
- travelMode: string;
150
- markerEmoji: string;
151
- actionDuration: number;
152
- }) {
153
- const map = useMap();
154
- const routesLibrary = useMapsLibrary("routes");
155
- const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
156
- const [routeIndex, setRouteIndex] = React.useState(0);
157
- const handle = React.useRef<number | null>(null);
147
+ function OverviewMap({
148
+ stream, onTilesLoaded,
149
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
150
+ const { fps } = useVideoConfig();
151
+ const frame = useCurrentFrame();
152
+ const start = stream.start ?? 0;
153
+ const end = stream.end ?? start + (stream.duration ?? 1);
154
+ const fallbackCenter = stream.center ?? { lat: 37.7749, lng: -122.4194 };
155
+ const center = {
156
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
157
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
158
+ };
159
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
160
+ const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
161
+ const tilt = resolveTween(frame, fps, stream.camera?.tilt, start, end, 0);
162
+ return (
163
+ <GoogleMap
164
+ mapId={String(stream.id ?? "map-overview")}
165
+ center={center}
166
+ zoom={zoom}
167
+ heading={heading}
168
+ tilt={tilt}
169
+ mapTypeId={stream.mapType ?? "roadmap"}
170
+ disableDefaultUI
171
+ zoomControl={false}
172
+ onTilesLoaded={onTilesLoaded}
173
+ style={{ width: "100%", height: "100%", position: "absolute" }}
174
+ />
175
+ );
176
+ }
177
+
178
+ // ============================================================
179
+ // RouteMap — classic animated route (view:"route", the default)
180
+ // ============================================================
181
+ function RouteMap({
182
+ stream, onTilesLoaded,
183
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
184
+ const { fps } = useVideoConfig();
185
+ const frame = useCurrentFrame();
186
+ const waypoints = stream.waypoints ?? [];
187
+ const start = stream.start ?? 0;
188
+ const end = stream.end ?? start + (stream.duration ?? 1);
189
+ const fallbackCenter = stream.center ?? { lat: waypoints[0]!.lat, lng: waypoints[0]!.lng };
190
+ const center = {
191
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, fallbackCenter.lat),
192
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, fallbackCenter.lng),
193
+ };
194
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 10);
195
+ return (
196
+ <GoogleMap
197
+ mapId={String(stream.id ?? "map-route")}
198
+ center={center}
199
+ zoom={zoom}
200
+ mapTypeId={stream.mapType ?? "roadmap"}
201
+ disableDefaultUI
202
+ zoomControl={false}
203
+ onTilesLoaded={onTilesLoaded}
204
+ style={{ width: "100%", height: "100%", position: "absolute" }}
205
+ >
206
+ <RouteWithMarker
207
+ waypoints={waypoints}
208
+ travelMode={stream.travelMode ?? "DRIVING"}
209
+ markerEmoji={stream.routeMarker ?? "🚗"}
210
+ actionDuration={end - start}
211
+ />
212
+ </GoogleMap>
213
+ );
214
+ }
215
+
216
+ // ============================================================
217
+ // CinematicMap — 2D movie flyover (view:"cinematic")
218
+ //
219
+ // flyAlong (default): drone chase — center follows the marker,
220
+ // heading = route bearing, tilt 45°
221
+ // flyTo: dolly from first waypoint toward the destination
222
+ // orbit: rotate heading around a fixed center
223
+ // ============================================================
224
+ function CinematicMap({
225
+ stream, onTilesLoaded,
226
+ }: { stream: MapStream; onTilesLoaded: () => void }) {
227
+ const { fps } = useVideoConfig();
228
+ const frame = useCurrentFrame();
229
+ const waypoints = stream.waypoints ?? [];
230
+ const start = stream.start ?? 0;
231
+ const end = stream.end ?? start + (stream.duration ?? 1);
232
+ const actionDuration = Math.max(0.1, end - start);
233
+ const mode = stream.cinematic?.mode ?? "flyAlong";
234
+ const headingFollow = stream.cinematic?.headingFollow ?? true;
235
+ const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
236
+
237
+ const seconds = frame / fps;
238
+ const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
239
+ const lookahead = routePositionAt(leg, waypoints, actionDuration, seconds + 0.4);
240
+ const first = waypoints[0]!;
241
+ const fallbackCenter = stream.center ?? first;
242
+
243
+ let center: { lat: number; lng: number };
244
+ if (mode === "flyAlong" && pos) {
245
+ center = pos;
246
+ } else if (mode === "flyTo") {
247
+ center = {
248
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
249
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
250
+ };
251
+ } else {
252
+ center = fallbackCenter;
253
+ }
254
+
255
+ let heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
256
+ if (headingFollow && pos && lookahead) {
257
+ heading = bearing(pos, lookahead);
258
+ }
259
+
260
+ const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 45);
261
+ const zoom = resolveTween(frame, fps, stream.camera?.zoom, start, end, stream.zoom ?? 13);
262
+
263
+ return (
264
+ <GoogleMap
265
+ mapId={String(stream.id ?? "map-cinematic")}
266
+ center={center}
267
+ zoom={zoom}
268
+ heading={heading}
269
+ tilt={tilt}
270
+ mapTypeId={stream.mapType ?? "roadmap"}
271
+ disableDefaultUI
272
+ zoomControl={false}
273
+ onTilesLoaded={onTilesLoaded}
274
+ style={{ width: "100%", height: "100%", position: "absolute" }}
275
+ >
276
+ <RouteWithMarker
277
+ waypoints={waypoints}
278
+ travelMode={stream.travelMode ?? "DRIVING"}
279
+ markerEmoji={stream.routeMarker ?? "🚗"}
280
+ actionDuration={actionDuration}
281
+ />
282
+ </GoogleMap>
283
+ );
284
+ }
285
+
286
+ // ============================================================
287
+ // CinematicMap3D — experimental Map3D flyover (opt-in via fallback:"none")
288
+ //
289
+ // flyTo: controlled `range` tween (far → near) over the route
290
+ // orbit: controlled heading/roll tween around a fixed center
291
+ // Route drawn with a raw <gmp-polyline-3d> element; markers via <Marker3D>.
292
+ // ============================================================
293
+ function CinematicMap3D({
294
+ stream, onReady,
295
+ }: { stream: MapStream; onReady: () => void }) {
296
+ const { fps } = useVideoConfig();
297
+ const frame = useCurrentFrame();
298
+ const waypoints = stream.waypoints ?? [];
299
+ const start = stream.start ?? 0;
300
+ const end = stream.end ?? start + (stream.duration ?? 1);
301
+ const actionDuration = Math.max(0.1, end - start);
302
+ const first = waypoints[0] ?? { lat: 37.7749, lng: -122.4194 };
303
+ const map3dRef = React.useRef<Map3DRef>(null);
304
+ const map3d = useMap3D();
305
+ const leg = useRouteLeg(waypoints, stream.travelMode ?? "DRIVING");
306
+
307
+ const range = resolveTween(frame, fps, stream.cinematic?.range, start, end, 2000);
308
+ const tilt = resolveTween(frame, fps, stream.cinematic?.tilt, start, end, 60);
309
+ const roll = resolveTween(frame, fps, stream.cinematic?.roll, start, end, 0);
310
+ const heading = resolveTween(frame, fps, stream.camera?.heading, start, end, 0);
311
+ const center = {
312
+ lat: resolveTween(frame, fps, stream.camera?.center?.lat, start, end, first.lat),
313
+ lng: resolveTween(frame, fps, stream.camera?.center?.lng, start, end, first.lng),
314
+ altitude: stream.cinematic?.altitude ?? 100,
315
+ };
316
+
317
+ const seconds = frame / fps;
318
+ const pos = routePositionAt(leg, waypoints, actionDuration, seconds);
319
+
320
+ // Route line via the raw gmp-polyline-3d custom element (no wrapper in 1.8.x).
321
+ const legPath = React.useMemo(() => {
322
+ if (!leg) return null;
323
+ const pts: Array<{ lat: number; lng: number; altitude: number }> = [];
324
+ for (const step of leg.steps ?? []) {
325
+ for (const p of step.path ?? []) pts.push({ lat: p.lat(), lng: p.lng(), altitude: 0 });
326
+ }
327
+ return pts.length ? pts : null;
328
+ }, [leg]);
158
329
 
159
- // Load directions
160
330
  React.useEffect(() => {
161
- if (!routesLibrary || !map || waypoints.length < 2) return;
162
- const renderHandle = delayRender("Loading map directions...");
163
- handle.current = renderHandle;
331
+ if (!map3d || !legPath) return;
332
+ const el = document.createElement("gmp-polyline-3d") as unknown as google.maps.maps3d.Polyline3DElement;
333
+ el.coordinates = legPath;
334
+ el.strokeColor = stream.routeColor ?? "#4285F4";
335
+ el.strokeWidth = stream.routeWeight ?? 4;
336
+ map3d.appendChild(el);
337
+ return () => {
338
+ el.remove();
339
+ };
340
+ }, [map3d, legPath, stream.routeColor, stream.routeWeight]);
164
341
 
165
- const renderer = new routesLibrary.DirectionsRenderer({ map, suppressMarkers: true });
166
- const service = new routesLibrary.DirectionsService();
342
+ return (
343
+ <Map3D
344
+ ref={map3dRef}
345
+ mode={stream.mapType === "hybrid" ? "HYBRID" : "SATELLITE"}
346
+ center={center}
347
+ range={range}
348
+ heading={heading}
349
+ tilt={tilt}
350
+ roll={roll}
351
+ onSteadyChange={onReady}
352
+ onAnimationEnd={onReady}
353
+ style={{ width: "100%", height: "100%", position: "absolute" }}
354
+ >
355
+ {pos ? (
356
+ <Marker3D position={{ lat: pos.lat, lng: pos.lng, altitude: 0 }} />
357
+ ) : null}
358
+ </Map3D>
359
+ );
360
+ }
167
361
 
168
- service
169
- .route({
170
- origin: waypoints[0],
171
- destination: waypoints[waypoints.length - 1],
172
- waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
173
- travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
174
- provideRouteAlternatives: false,
175
- })
176
- .then((response) => {
177
- renderer.setDirections(response);
178
- setRouteIndex(0);
179
- setLeg(response.routes[0]?.legs[0] ?? null);
180
- if (handle.current !== null) continueRender(handle.current);
181
- })
182
- .catch(() => {
183
- if (handle.current !== null) continueRender(handle.current);
184
- });
362
+ // ============================================================
363
+ // StreetViewLeaf — immersive StreetViewPanorama with animated POV/position
364
+ // ============================================================
365
+ function StreetViewLeaf({
366
+ stream, onPanoReady,
367
+ }: { stream: MapStream; onPanoReady: () => void }) {
368
+ const containerRef = React.useRef<HTMLDivElement>(null);
369
+ const svLibrary = useMapsLibrary("streetView");
370
+ const [pano, setPano] = React.useState<google.maps.StreetViewPanorama | null>(null);
371
+ const { fps } = useVideoConfig();
372
+ const frame = useCurrentFrame();
373
+ const start = stream.start ?? 0;
374
+ const end = stream.end ?? start + (stream.duration ?? 1);
375
+ const sv = stream.streetView;
185
376
 
377
+ // Create the panorama once (no React wrapper component in this library version).
378
+ React.useEffect(() => {
379
+ if (!svLibrary || !containerRef.current) return;
380
+ const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
381
+ disableDefaultUI: true,
382
+ });
383
+ if (sv?.pano) {
384
+ pan.setPano(sv.pano);
385
+ } else if (sv?.location) {
386
+ pan.setPosition(sv.location);
387
+ } else if (sv?.route?.length) {
388
+ pan.setPosition(sv.route[0]!);
389
+ }
390
+ if (sv?.pov) {
391
+ pan.setPov({
392
+ heading: typeof sv.pov.heading === "number" ? sv.pov.heading : 0,
393
+ pitch: typeof sv.pov.pitch === "number" ? sv.pov.pitch : 0,
394
+ });
395
+ }
396
+ if (typeof sv?.zoom === "number") {
397
+ pan.setZoom(sv.zoom);
398
+ }
399
+ setPano(pan);
186
400
  return () => {
187
- renderer.setMap(null);
401
+ pan.setVisible(false);
188
402
  };
189
- }, [routesLibrary, map, waypoints, travelMode]);
403
+ }, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom]);
190
404
 
191
- // Update route index
405
+ // Per-frame POV + walk/drive position, with per-frame tile-load gating.
406
+ //
407
+ // Street View imagery loads asynchronously after setPosition/pano_changed, so
408
+ // signaling ready on the metadata event alone captures dark frames. Each frame
409
+ // change delays render until the panorama's imagery is actually loaded (with a
410
+ // grace period for tile fetch), so every captured frame has visible content.
411
+ // POV-only changes (no position change → no new pano) settle quickly.
192
412
  React.useEffect(() => {
193
- setRouteIndex((prev) => prev);
194
- }, [routeIndex]);
413
+ if (!pano) return;
414
+ const pov = pano.getPov();
415
+ const heading = resolveTween(frame, fps, sv?.pov?.heading, start, end, pov.heading);
416
+ const pitch = resolveTween(frame, fps, sv?.pov?.pitch, start, end, pov.pitch);
417
+ pano.setPov({ heading, pitch });
418
+ const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
419
+ pano.setZoom(zoom);
420
+
421
+ let movedPosition = false;
422
+ if (sv?.route && sv.route.length > 1) {
423
+ const t = Math.min(Math.max((frame / fps) / Math.max(0.1, end - start), 0), 1);
424
+ const total = sv.route.length - 1;
425
+ const segI = Math.min(Math.floor(t * total), total - 1);
426
+ const segT = t * total - segI;
427
+ const a = sv.route[segI]!;
428
+ const b = sv.route[segI + 1]!;
429
+ pano.setPosition({
430
+ lat: a.lat + (b.lat - a.lat) * segT,
431
+ lng: a.lng + (b.lng - a.lng) * segT,
432
+ });
433
+ movedPosition = true;
434
+ }
435
+
436
+ // Gate this frame on the panorama's imagery actually loading.
437
+ const handle = delayRender(`Street View frame ${frame}`);
438
+ let done = false;
439
+ const finish = () => {
440
+ if (done) return;
441
+ done = true;
442
+ continueRender(handle);
443
+ onPanoReady();
444
+ };
445
+ // pano_changed fires when a new panorama's metadata+imagery request resolves.
446
+ // Add a grace period so tiles finish rendering before capture.
447
+ const graceMs = movedPosition ? 1200 : 400;
448
+ const onPano = () => setTimeout(finish, graceMs);
449
+ const lPano = pano.addListener("pano_changed", onPano);
450
+ // POV-only frames (no position change) settle fast; if no pano change fires,
451
+ // the imagery is already present — settle after a short buffer.
452
+ const fallback = setTimeout(finish, movedPosition ? 3000 : 600);
453
+
454
+ return () => {
455
+ google.maps.event.removeListener(lPano);
456
+ clearTimeout(fallback);
457
+ finish();
458
+ };
459
+ }, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, onPanoReady]);
460
+
461
+ return (
462
+ <div
463
+ ref={containerRef}
464
+ style={{ width: "100%", height: "100%", position: "absolute" }}
465
+ />
466
+ );
467
+ }
468
+
469
+ // ============================================================
470
+ // RouteWithMarker — fetches the Directions route, renders the
471
+ // route line + waypoint markers (label or media thumbnail) +
472
+ // the animated traveling marker
473
+ // ============================================================
474
+ function RouteWithMarker({
475
+ waypoints, travelMode, markerEmoji, actionDuration,
476
+ }: {
477
+ waypoints: { lat: number; lng: number; label?: string; media?: string }[];
478
+ travelMode: string;
479
+ markerEmoji: string;
480
+ actionDuration: number;
481
+ }) {
482
+ const leg = useRouteLeg(waypoints, travelMode);
195
483
 
196
484
  // Compute animated marker position
197
485
  const position = useAnimatedPosition({ leg, actionDuration, waypoints });
@@ -200,7 +488,27 @@ function RouteWithMarker({
200
488
  <>
201
489
  {waypoints.map((wp, i) => (
202
490
  <AdvancedMarker key={i} position={wp}>
203
- {wp.label ? (
491
+ {wp.media ? (
492
+ <div
493
+ style={{
494
+ width: 44,
495
+ height: 44,
496
+ borderRadius: 6,
497
+ overflow: "hidden",
498
+ border: "2px solid #fff",
499
+ boxShadow: "0 1px 4px rgba(0,0,0,0.45)",
500
+ background: "#fff",
501
+ position: "relative",
502
+ top: "-24px",
503
+ }}
504
+ >
505
+ <img
506
+ src={wp.media}
507
+ alt=""
508
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
509
+ />
510
+ </div>
511
+ ) : wp.label ? (
204
512
  <div
205
513
  style={{
206
514
  background: "rgba(255,255,255,0.9)",
@@ -228,6 +536,54 @@ function RouteWithMarker({
228
536
  );
229
537
  }
230
538
 
539
+ // ============================================================
540
+ // useRouteLeg — loads the Directions route (shared by RouteMap,
541
+ // CinematicMap and CinematicMap3D). Uses the map instance from
542
+ // the nearest <GoogleMap> / <Map3D> context.
543
+ // ============================================================
544
+ function useRouteLeg(
545
+ waypoints: { lat: number; lng: number }[],
546
+ travelMode: string,
547
+ ): google.maps.DirectionsLeg | null {
548
+ const map = useMap();
549
+ const routesLibrary = useMapsLibrary("routes");
550
+ const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
551
+ const handle = React.useRef<number | null>(null);
552
+
553
+ // Load directions
554
+ React.useEffect(() => {
555
+ if (!routesLibrary || !map || waypoints.length < 2) return;
556
+ const renderHandle = delayRender("Loading map directions...");
557
+ handle.current = renderHandle;
558
+
559
+ const renderer = new routesLibrary.DirectionsRenderer({ map, suppressMarkers: true });
560
+ const service = new routesLibrary.DirectionsService();
561
+
562
+ service
563
+ .route({
564
+ origin: waypoints[0]!,
565
+ destination: waypoints[waypoints.length - 1]!,
566
+ waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
567
+ travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
568
+ provideRouteAlternatives: false,
569
+ })
570
+ .then((response) => {
571
+ renderer.setDirections(response);
572
+ setLeg(response.routes[0]?.legs[0] ?? null);
573
+ if (handle.current !== null) continueRender(handle.current);
574
+ })
575
+ .catch(() => {
576
+ if (handle.current !== null) continueRender(handle.current);
577
+ });
578
+
579
+ return () => {
580
+ renderer.setMap(null);
581
+ };
582
+ }, [routesLibrary, map, waypoints, travelMode]);
583
+
584
+ return leg;
585
+ }
586
+
231
587
  // ============================================================
232
588
  // useAnimatedPosition — returns the current lat/lng of the
233
589
  // animated marker along the route path
@@ -242,51 +598,69 @@ function useAnimatedPosition({
242
598
  const frame = useCurrentFrame();
243
599
  const { fps } = useVideoConfig();
244
600
 
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
- }
601
+ return React.useMemo(
602
+ () => routePositionAt(leg, waypoints, actionDuration, frame / fps),
603
+ [leg, frame, fps, actionDuration, waypoints],
604
+ );
605
+ }
261
606
 
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
- }
607
+ // ============================================================
608
+ // routePositionAt — pure position math shared by the marker and
609
+ // the cinematic camera (deterministic per second)
610
+ // ============================================================
611
+ function routePositionAt(
612
+ leg: google.maps.DirectionsLeg | null,
613
+ waypoints: { lat: number; lng: number }[],
614
+ actionDuration: number,
615
+ seconds: number,
616
+ ): { lat: number; lng: number } | null {
617
+ const linearFallback = (): { lat: number; lng: number } | null => {
618
+ if (waypoints.length < 2) return null;
619
+ const t = Math.min(seconds / actionDuration, 1);
620
+ const total = waypoints.length - 1;
621
+ const segI = Math.min(Math.floor(t * total), total - 1);
622
+ const segT = t * total - segI;
623
+ const a = waypoints[segI];
624
+ const b = waypoints[segI + 1];
625
+ if (!a || !b) return null;
626
+ return { lat: a.lat + (b.lat - a.lat) * segT, lng: a.lng + (b.lng - a.lng) * segT };
627
+ };
628
+
629
+ if (!leg || !leg.duration?.value) {
630
+ return linearFallback();
631
+ }
632
+
633
+ // Follow the route path using leg steps
634
+ const currentInSecond = seconds * (leg.duration.value / actionDuration);
635
+ const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
636
+ if (!step || !step.path) {
637
+ return linearFallback();
638
+ }
639
+
640
+ const stepElapsed = currentInSecond - elapsedInSeconds;
641
+ const stepProgress = stepElapsed / (step.duration?.value ?? 1);
642
+ const pathIdx = Math.min(
643
+ Math.max(0, Math.floor(stepProgress * step.path.length)),
644
+ step.path.length - 1,
645
+ );
646
+ const pt = step.path[pathIdx];
647
+ if (!pt) return null;
648
+ return { lat: pt.lat(), lng: pt.lng() };
649
+ }
279
650
 
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]);
651
+ // ============================================================
652
+ // bearing — initial bearing (degrees clockwise from north) between
653
+ // two lat/lng points. Haversine; no geometry library required.
654
+ // ============================================================
655
+ function bearing(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
656
+ const toRad = (d: number) => (d * Math.PI) / 180;
657
+ const toDeg = (d: number) => (d * 180) / Math.PI;
658
+ const lat1 = toRad(a.lat);
659
+ const lat2 = toRad(b.lat);
660
+ const dLng = toRad(b.lng - a.lng);
661
+ const y = Math.sin(dLng) * Math.cos(lat2);
662
+ const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
663
+ return (toDeg(Math.atan2(y, x)) + 360) % 360;
290
664
  }
291
665
 
292
666
  // ============================================================