@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/src/types/Map.tsx CHANGED
@@ -29,7 +29,7 @@
29
29
  * }
30
30
  */
31
31
  import React from "react";
32
- import { Sequence, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
32
+ import { Sequence, AbsoluteFill, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
33
33
  import { useFrameEvents } from "../context/index";
34
34
  import {
35
35
  APIProvider, Map as GoogleMap, useMap, useMapsLibrary, useMap3D,
@@ -37,7 +37,15 @@ import {
37
37
  } from "@vis.gl/react-google-maps";
38
38
  import type { Map3DRef } from "@vis.gl/react-google-maps";
39
39
  import { resolveTween } from "../utils/tween";
40
- import type { MapStream } from "../schema/index";
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";
41
49
 
42
50
  // API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
43
51
  // This fallback handles the case where Map.tsx is used directly without the compiler.
@@ -60,10 +68,245 @@ function resolveMapLocale(language?: string, region?: string): { language?: stri
60
68
  return { language, region };
61
69
  }
62
70
 
71
+ // ============================================================
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
+
63
305
  // ============================================================
64
306
  // MapLeaf — entry point, dispatches to the view renderer
65
307
  // ============================================================
66
308
  export function MapLeaf({ stream }: { stream: MapStream }) {
309
+ const [mapInstance, setMapInstance] = React.useState<google.maps.Map | null>(null);
67
310
  const { fps } = useVideoConfig();
68
311
  const waypoints = stream.waypoints ?? [];
69
312
  const start = stream.start ?? 0;
@@ -84,6 +327,9 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
84
327
  const mapLoadContinuedRef = React.useRef(false);
85
328
 
86
329
  React.useEffect(() => {
330
+ // Strip Google Maps attribution / UI text from the rendered video.
331
+ hideGoogleMapsUi();
332
+
87
333
  mapLoadHandleRef.current = delayRender("Waiting for map to load...");
88
334
  mapLoadContinuedRef.current = false;
89
335
 
@@ -130,12 +376,15 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
130
376
  language={mapLocale.language}
131
377
  region={mapLocale.region}
132
378
  >
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} />}
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>
139
388
  </APIProvider>
140
389
  </Sequence>
141
390
  );
@@ -171,7 +420,9 @@ function OverviewMap({
171
420
  zoomControl={false}
172
421
  onTilesLoaded={onTilesLoaded}
173
422
  style={{ width: "100%", height: "100%", position: "absolute" }}
174
- />
423
+ >
424
+ <MapBridge />
425
+ </GoogleMap>
175
426
  );
176
427
  }
177
428
 
@@ -203,11 +454,15 @@ function RouteMap({
203
454
  onTilesLoaded={onTilesLoaded}
204
455
  style={{ width: "100%", height: "100%", position: "absolute" }}
205
456
  >
206
- <RouteWithMarker
457
+ <MapBridge />
458
+ <RouteWithMarkerLegs
207
459
  waypoints={waypoints}
208
460
  travelMode={stream.travelMode ?? "DRIVING"}
209
461
  markerEmoji={stream.routeMarker ?? "🚗"}
210
462
  actionDuration={end - start}
463
+ routeColor={stream.routeColor ?? "#4285F4"}
464
+ routeWeight={stream.routeWeight ?? 4}
465
+ children={stream.children as Stream[]}
211
466
  />
212
467
  </GoogleMap>
213
468
  );
@@ -368,24 +623,125 @@ function StreetViewLeaf({
368
623
  const containerRef = React.useRef<HTMLDivElement>(null);
369
624
  const svLibrary = useMapsLibrary("streetView");
370
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
+
371
645
  const { fps } = useVideoConfig();
372
646
  const frame = useCurrentFrame();
373
647
  const start = stream.start ?? 0;
374
648
  const end = stream.end ?? start + (stream.duration ?? 1);
375
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]);
376
725
 
377
726
  // Create the panorama once (no React wrapper component in this library version).
378
727
  React.useEffect(() => {
379
728
  if (!svLibrary || !containerRef.current) return;
729
+ loadedRef.current = false;
730
+ lastWpIndexRef.current = -1;
380
731
  const pan = new svLibrary.StreetViewPanorama(containerRef.current, {
381
732
  disableDefaultUI: true,
382
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);
383
739
  if (sv?.pano) {
384
740
  pan.setPano(sv.pano);
385
741
  } else if (sv?.location) {
386
- pan.setPosition(sv.location);
742
+ panSetPosition(sv.location);
387
743
  } else if (sv?.route?.length) {
388
- pan.setPosition(sv.route[0]!);
744
+ panSetPosition(sv.route[0]!);
389
745
  }
390
746
  if (sv?.pov) {
391
747
  pan.setPov({
@@ -396,19 +752,27 @@ function StreetViewLeaf({
396
752
  if (typeof sv?.zoom === "number") {
397
753
  pan.setZoom(sv.zoom);
398
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);
399
763
  setPano(pan);
764
+ // Gate the initial load (persistent — see startWait).
765
+ startWait();
400
766
  return () => {
767
+ loadedRef.current = false;
768
+ panoRef.current = null;
401
769
  pan.setVisible(false);
402
770
  };
403
- }, [svLibrary, stream.id, sv?.pano, sv?.location, sv?.route, sv?.pov, sv?.zoom]);
404
-
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.
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).
412
776
  React.useEffect(() => {
413
777
  if (!pano) return;
414
778
  const pov = pano.getPov();
@@ -418,45 +782,25 @@ function StreetViewLeaf({
418
782
  const zoom = resolveTween(frame, fps, sv?.zoom, start, end, pano.getZoom() ?? 0);
419
783
  pano.setZoom(zoom);
420
784
 
421
- let movedPosition = false;
422
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.
423
790
  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;
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();
434
802
  }
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]);
803
+ }, [pano, frame, fps, start, end, sv?.route, sv?.pov, sv?.zoom, svRadius, startWait]);
460
804
 
461
805
  return (
462
806
  <div
@@ -467,23 +811,13 @@ function StreetViewLeaf({
467
811
  }
468
812
 
469
813
  // ============================================================
470
- // RouteWithMarker — fetches the Directions route, renders the
471
- // route line + waypoint markers (label or media thumbnail) +
472
- // the animated traveling marker
814
+ // WaypointMarkers — one marker per waypoint (media thumbnail or label)
473
815
  // ============================================================
474
- function RouteWithMarker({
475
- waypoints, travelMode, markerEmoji, actionDuration,
816
+ function WaypointMarkers({
817
+ waypoints,
476
818
  }: {
477
819
  waypoints: { lat: number; lng: number; label?: string; media?: string }[];
478
- travelMode: string;
479
- markerEmoji: string;
480
- actionDuration: number;
481
820
  }) {
482
- const leg = useRouteLeg(waypoints, travelMode);
483
-
484
- // Compute animated marker position
485
- const position = useAnimatedPosition({ leg, actionDuration, waypoints });
486
-
487
821
  return (
488
822
  <>
489
823
  {waypoints.map((wp, i) => (
@@ -527,15 +861,255 @@ function RouteWithMarker({
527
861
  ) : null}
528
862
  </AdvancedMarker>
529
863
  ))}
530
- {position ? (
531
- <AdvancedMarker position={position}>
532
- <Pin glyphText={markerEmoji} scale={4} />
533
- </AdvancedMarker>
534
- ) : null}
535
864
  </>
536
865
  );
537
866
  }
538
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
935
+ // ============================================================
936
+ function RouteWithMarker({
937
+ waypoints, travelMode, markerEmoji, actionDuration,
938
+ }: {
939
+ waypoints: { lat: number; lng: number; label?: string; media?: string }[];
940
+ travelMode: string;
941
+ markerEmoji: string;
942
+ actionDuration: number;
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
+
539
1113
  // ============================================================
540
1114
  // useRouteLeg — loads the Directions route (shared by RouteMap,
541
1115
  // CinematicMap and CinematicMap3D). Uses the map instance from