@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Asset-relative validation for stream trees.
3
+ *
4
+ * Render contract (2026-08-10): every local asset in the compiled tree is a
5
+ * path RELATIVE to the source folder — the source .md file's folder, the
6
+ * "default root". Remotion serves that folder via `--public-dir` (render) and
7
+ * the player serves it as the document root (preview). Absolute filesystem
8
+ * paths, paths starting with "/", or `..` escapes all break staticFile()
9
+ * resolution → 404 in render / broken media in preview.
10
+ *
11
+ * validateAssetsRelative(tree, baseDir) → string[] (empty = all OK)
12
+ *
13
+ * Used by:
14
+ * - `markcut verify` — walks the parsed descriptive tree (skips include.src,
15
+ * which legitimately resolves against its own baseDir)
16
+ * - `markcut render` — guards the compiled tree right before rendering
17
+ */
18
+ import { isAbsolute, relative, resolve } from "node:path";
19
+
20
+ const REMOTE_RE = /^(https?:|data:|blob:|file:)/i;
21
+ /** Subtitle `src` is only a file when it points at a .vtt — otherwise it is inline caption text. */
22
+ const VTT_RE = /\.vtt(?:$|[?#])/i;
23
+ const INLINE_VTT_RE = /-->/;
24
+
25
+ /**
26
+ * Classify a single asset reference:
27
+ * "remote" — http(s)/data/blob/file URI, loaded as-is (OK)
28
+ * "root-absolute" — starts with "/", rooted at the serve root, NOT source-folder-relative (ERROR)
29
+ * "absolute" — absolute filesystem path (ERROR)
30
+ * "escapes" — relative but starts with ".." (ERROR)
31
+ * "relative" — looks safe; callers should re-check against baseDir for nested ".."
32
+ * "text" — not a path at all (inline subtitle text / inline VTT body)
33
+ * "empty" — falsy
34
+ */
35
+ export function classifyAssetPath(src, { subtitle = false } = {}) {
36
+ if (!src) return "empty";
37
+ if (subtitle && (INLINE_VTT_RE.test(src) || !VTT_RE.test(src))) return "text";
38
+ if (REMOTE_RE.test(src)) return "remote";
39
+ if (src.startsWith("/")) return "root-absolute";
40
+ if (isAbsolute(src)) return "absolute";
41
+ if (src.startsWith("..")) return "escapes";
42
+ return "relative";
43
+ }
44
+
45
+ function collectNodeAssets(node, path, out, skipIncludeSrc) {
46
+ if (!node || typeof node !== "object") return;
47
+
48
+ if (node.type === "include") {
49
+ // include.src may legitimately point outside baseDir in *descriptive* trees
50
+ // (it is compiled against its own baseDir, then relativized to the outer
51
+ // baseDir). Callers that validate descriptive trees pass skipIncludeSrc.
52
+ if (!skipIncludeSrc && typeof node.src === "string" && node.src) {
53
+ out.push({ node, field: "src", value: node.src, path, subtitle: false });
54
+ }
55
+ // NOTE: include.imports is a module bundle URL (dynamic import), not a
56
+ // staticFile asset — intentionally not validated.
57
+ } else {
58
+ const isSubtitle = node.type === "subtitle";
59
+ if (typeof node.src === "string" && node.src) {
60
+ out.push({ node, field: "src", value: node.src, path, subtitle: isSubtitle });
61
+ }
62
+ if (Array.isArray(node.waypoints)) {
63
+ node.waypoints.forEach((wp, i) => {
64
+ if (wp && typeof wp.media === "string" && wp.media) {
65
+ out.push({ node, field: `waypoints[${i}].media`, value: wp.media, path, subtitle: false });
66
+ }
67
+ });
68
+ }
69
+ }
70
+
71
+ if (Array.isArray(node.children)) {
72
+ node.children.forEach((c, i) => collectNodeAssets(c, `${path}.children[${i}]`, out, skipIncludeSrc));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Collect every asset reference in a tree.
78
+ *
79
+ * @param {object} tree - root node (may carry root.subtitle) or plain node
80
+ * @param {{skipIncludeSrc?: boolean}} [opts]
81
+ * @returns {Array<{node: object, field: string, value: string, path: string, subtitle: boolean}>}
82
+ */
83
+ export function collectTreeAssets(tree, { skipIncludeSrc = false } = {}) {
84
+ const out = [];
85
+ if (tree && typeof tree.subtitle === "object" && tree.subtitle &&
86
+ typeof tree.subtitle.src === "string" && tree.subtitle.src) {
87
+ out.push({ node: tree, field: "subtitle.src", value: tree.subtitle.src, path: "root", subtitle: true });
88
+ }
89
+ if (tree && Array.isArray(tree.children)) {
90
+ tree.children.forEach((c, i) => collectNodeAssets(c, `root.children[${i}]`, out, skipIncludeSrc));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function formatError(ref, problem, baseDir) {
96
+ const where = ref.node?.id
97
+ ? `node "${ref.node.id}"`
98
+ : ref.node?.name
99
+ ? `node "${ref.node.name}"`
100
+ : ref.path;
101
+ const type = ref.node?.type ? ` (type: ${ref.node.type})` : "";
102
+ return [
103
+ `Asset not relative to baseDir: ${where}${type} — field ${ref.field} = "${ref.value}"`,
104
+ ` ${problem}. Local assets must be relative to the source folder: ${baseDir}`,
105
+ ` (e.g. assets/x.png or .markcut/generated/...) — absolute paths and ".." escapes 404 in render`,
106
+ ` because Remotion serves media from --public-dir = the source folder.`,
107
+ ` Fix: re-run resolve/render so the path is emitted source-folder-relative, or move the file under the source folder.`,
108
+ ].join("\n");
109
+ }
110
+
111
+ /**
112
+ * Walk a tree and return actionable errors for every asset that is not
113
+ * relative to `baseDir`. Empty array = all assets are baseDir-relative.
114
+ *
115
+ * @param {object} tree - root node of the (descriptive or compiled) tree
116
+ * @param {string} baseDir - source folder (md file's folder) assets must stay inside
117
+ * @param {{skipIncludeSrc?: boolean}} [opts] - pass skipIncludeSrc:true for descriptive trees
118
+ * @returns {string[]}
119
+ */
120
+ export function validateAssetsRelative(tree, baseDir, opts = {}) {
121
+ const errors = [];
122
+ for (const ref of collectTreeAssets(tree, opts)) {
123
+ const kind = classifyAssetPath(ref.value, { subtitle: ref.subtitle });
124
+ let problem = null;
125
+ if (kind === "root-absolute") {
126
+ problem = `It starts with "/" — that is rooted at the serve root, not the source folder`;
127
+ } else if (kind === "absolute") {
128
+ problem = "It is an absolute filesystem path";
129
+ } else if (kind === "escapes") {
130
+ problem = 'It escapes the source folder via ".."';
131
+ } else if (kind === "relative") {
132
+ // Nested ".." like "a/../../x" isn't caught by the prefix check.
133
+ if (relative(baseDir, resolve(baseDir, ref.value)).startsWith("..")) {
134
+ problem = 'It escapes the source folder via ".."';
135
+ }
136
+ }
137
+ if (problem) errors.push(formatError(ref, problem, baseDir));
138
+ }
139
+ return errors;
140
+ }
@@ -133,7 +133,7 @@ export type Image = z.infer<typeof image>;
133
133
  export const component = base.extend({
134
134
  type: z.literal("component").default("component"),
135
135
  jsx: z.string().describe("usage JSX expression compiled at runtime; tag names resolved from imports"),
136
- data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
136
+ data: z.record(z.string(), z.unknown()).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
137
137
  });
138
138
  export type Component = z.infer<typeof component>;
139
139
 
@@ -143,6 +143,7 @@ export type Component = z.infer<typeof component>;
143
143
  export const effect = base.extend({
144
144
  type: z.literal("effect").default("effect"),
145
145
  animation: z.string().optional().describe("builtin keyframe name or 'custom'"),
146
+ animationDurationSeconds: z.number().optional().describe("animation duration (separate from wrapper durationInSeconds which getDurationInSeconds may overwrite)"),
146
147
  animationTimingFunction: z
147
148
  .enum(["linear", "ease", "ease-in", "ease-out", "ease-in-out"])
148
149
  .optional(),
@@ -211,7 +212,7 @@ export const scene = base.extend({
211
212
  export type Scene = z.infer<typeof scene>;
212
213
 
213
214
  // ---------------------------------------------------------------------------
214
- // Map — animated route visualization
215
+ // Map — animated route visualization with dynamic camera views
215
216
  // ---------------------------------------------------------------------------
216
217
  export const mapWaypoint = z.object({
217
218
  lat: z.number(),
@@ -220,8 +221,60 @@ export const mapWaypoint = z.object({
220
221
  media: z.string().optional().describe("image/video src for waypoint marker"),
221
222
  });
222
223
 
224
+ /**
225
+ * A tween expression: `tween(from, to, easing?)` → `{__tween:[from,to,easing?]}`,
226
+ * parsed by the descriptive DSL (see parseProps in dsl.ts).
227
+ */
228
+ export const tweenSpec = z.object({
229
+ __tween: z.array(z.union([z.number(), z.string()])),
230
+ });
231
+ export type TweenSpec = z.infer<typeof tweenSpec>;
232
+
233
+ /** A static number OR an animated tween spec. */
234
+ export const tweenableNumber = z.union([z.number(), tweenSpec]);
235
+ export type TweenableNumber = z.infer<typeof tweenableNumber>;
236
+
237
+ /** Generic camera tween shared by overview/route/cinematic-lite. */
238
+ export const mapCamera = z.object({
239
+ zoom: tweenableNumber.optional(),
240
+ center: z.object({ lat: tweenableNumber, lng: tweenableNumber }).optional(),
241
+ heading: tweenableNumber.optional(),
242
+ tilt: tweenableNumber.optional(),
243
+ });
244
+ export type MapCamera = z.infer<typeof mapCamera>;
245
+
246
+ /** Cinematic camera behavior (chase/tilt/flyTo/orbit, 3D flyover). All fields
247
+ * optional — the renderer applies defaults (mode flyAlong, tilt 45, etc.). */
248
+ export const mapCinematic = z.object({
249
+ mode: z.enum(["flyAlong", "flyTo", "orbit"]).optional().describe("camera move; default flyAlong"),
250
+ followRoute: z.boolean().optional().describe("center follows the animated marker; default true"),
251
+ headingFollow: z.boolean().optional().describe("heading = route bearing (forward is up); default true"),
252
+ tilt: tweenableNumber.optional().describe("0 = top-down reveal, 45 = chase; tween(0,45) = tilt reveal; default 45"),
253
+ range: tweenableNumber.optional().describe("3D camera distance (m); tween(8000,300) = fly-to"),
254
+ altitude: z.number().optional().describe("3D center altitude (m)"),
255
+ roll: tweenableNumber.optional().describe("3D bank (deg); default 0"),
256
+ fallback: z.enum(["2d", "none"]).optional().describe("'2d' renders safe 2D chase; 'none' opts into experimental Map3D; default '2d'"),
257
+ });
258
+ export type MapCinematic = z.infer<typeof mapCinematic>;
259
+
260
+ /** Immersive Street View config with POV/position animation. */
261
+ export const mapStreetView = z.object({
262
+ pano: z.string().optional().describe("explicit panorama id (deterministic)"),
263
+ location: z.object({ lat: z.number(), lng: z.number() }).optional().describe("else nearest-pano search"),
264
+ route: z.array(z.object({ lat: z.number(), lng: z.number() })).optional().describe("walk/drive path (nearest pano per stop)"),
265
+ radius: z.number().optional().describe("nearest-pano search radius (m); default 50"),
266
+ source: z.enum(["default", "outdoor", "indoor"]).optional().describe("default 'default'"),
267
+ pov: z.object({
268
+ heading: tweenableNumber.optional().describe("tween(200,320) = pan"),
269
+ pitch: tweenableNumber.optional().describe("tween(0,-10) = tilt sweep"),
270
+ }).optional(),
271
+ zoom: tweenableNumber.optional().describe("street view field-of-view zoom; tween(0,1) = dolly-zoom feel"),
272
+ });
273
+ export type MapStreetView = z.infer<typeof mapStreetView>;
274
+
223
275
  export const mapStream = base.extend({
224
276
  type: z.literal("map").default("map"),
277
+ view: z.enum(["overview", "route", "cinematic", "streetview"]).default("route").describe("camera experience: static/dolly overview, animated route, cinematic flyover, immersive street view"),
225
278
  waypoints: z.array(mapWaypoint).default(() => []),
226
279
  routeColor: z.string().default("#4285F4"),
227
280
  routeWeight: z.number().default(4),
@@ -232,6 +285,9 @@ export const mapStream = base.extend({
232
285
  region: z.string().optional().describe("Google Maps region code, e.g. CN"),
233
286
  travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
234
287
  routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
288
+ camera: mapCamera.optional().describe("generic camera tween (dolly/pan/tilt)"),
289
+ cinematic: mapCinematic.optional().describe("cinematic camera behavior"),
290
+ streetView: mapStreetView.optional().describe("immersive street view config"),
235
291
  googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
236
292
  });
237
293
  export type MapStream = z.infer<typeof mapStream>;
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `markcut spots` — Discover points of interest (spots) along a route.
4
+ *
5
+ * Given a set of waypoints, this fetches the driving/walking route via the
6
+ * Directions REST API, samples points along it, and runs a Places Nearby Search
7
+ * at each sample to find notable POIs (landmarks, parks, museums, etc.). Results
8
+ * are deduplicated, ranked by prominence/rating, and emitted as both JSON and
9
+ * copy-pasteable `waypoints:[...]` markdown so an agent can drop them into a
10
+ * storyboard.
11
+ *
12
+ * The agent then picks the spots it wants to narrate and composes the video
13
+ * (this tool only DISCOVERS spots — it doesn't write the storyboard).
14
+ *
15
+ * Usage:
16
+ * node src/spots/cli.mjs --waypoints "37.77,-122.41;34.05,-118.25"
17
+ * node src/spots/cli.mjs --waypoints "..." --travelMode DRIVING --limit 8 --photos
18
+ * node src/spots/cli.mjs --waypoints "..." --output spots.json
19
+ *
20
+ * Options:
21
+ * --waypoints "lat,lng;lat,lng[,...]" Semicolon-separated coordinates (required)
22
+ * --travelMode DRIVING|WALKING|BICYCLING Directions travel mode (default DRIVING)
23
+ * --radius <m> Nearby search radius around each route sample (default 1500)
24
+ * --samples <n> Number of points to sample along the route (default 6)
25
+ * --type <type> Place type filter, e.g. tourist_attraction, museum, park
26
+ * (default: tourist_attraction|point_of_interest)
27
+ * --limit <n> Max spots to return after ranking (default 8)
28
+ * --photos Fetch one photo URL per spot (waypoint.media)
29
+ * --output <path> Write JSON to file (default: print to stdout)
30
+ * --api-key <key> Google Maps API key (default: $GOOGLE_MAPS_API_KEY)
31
+ * --markdown Also print waypoints:[...] markdown to stderr
32
+ * --help Show this help
33
+ *
34
+ * Requires: GOOGLE_MAPS_API_KEY env var (Directions + Places API enabled).
35
+ */
36
+ import { writeFileSync } from "node:fs";
37
+ import { resolve } from "node:path";
38
+
39
+ const args = parseArgs(process.argv.slice(2));
40
+ if (args.help || !args.waypoints) {
41
+ process.stderr.write(`Usage: markcut spots --waypoints "lat,lng;lat,lng" [options]
42
+
43
+ Discovers points of interest along a route via the Directions + Places APIs,
44
+ ranked by prominence, for an agent to narrate in a video.
45
+
46
+ Options:
47
+ --waypoints "lat,lng;lat,lng" Semicolon-separated coordinates (required)
48
+ --travelMode <mode> DRIVING | WALKING | BICYCLING (default DRIVING)
49
+ --radius <m> Nearby search radius per sample (default 1500)
50
+ --samples <n> Route sample points (default 6)
51
+ --type <type> Place type filter (default tourist_attraction|point_of_interest)
52
+ --limit <n> Max spots (default 8)
53
+ --photos Fetch one photo URL per spot
54
+ --output <path> Write JSON to file
55
+ --markdown Print waypoints:[...] markdown to stderr
56
+ --api-key <key> Google Maps API key (default $GOOGLE_MAPS_API_KEY)
57
+ --help Show this help
58
+ `);
59
+ process.exit(args.help ? 0 : 1);
60
+ }
61
+
62
+ const API_KEY = args.apiKey || process.env.GOOGLE_MAPS_API_KEY;
63
+ if (!API_KEY) {
64
+ console.error("❌ GOOGLE_MAPS_API_KEY not set (or pass --api-key)");
65
+ process.exit(1);
66
+ }
67
+
68
+ const waypoints = parseWaypoints(args.waypoints);
69
+ if (waypoints.length < 2) {
70
+ console.error("❌ Need at least 2 waypoints");
71
+ process.exit(1);
72
+ }
73
+
74
+ const travelMode = (args.travelMode || "DRIVING").toUpperCase();
75
+ const radius = Number(args.radius || 1500);
76
+ const samples = Number(args.samples || 6);
77
+ const placeType = args.type || "tourist_attraction|point_of_interest";
78
+ const limit = Number(args.limit || 8);
79
+ const wantPhotos = !!args.photos;
80
+
81
+ emitInfo(`Route: ${waypoints.length} waypoints, mode=${travelMode}, samples=${samples}, radius=${radius}m`);
82
+
83
+ // 1. Fetch the route → decoded polyline → sampled points
84
+ const routePts = await fetchRoutePoints(waypoints, travelMode, samples);
85
+ if (routePts.length === 0) {
86
+ console.error("❌ No route found between waypoints");
87
+ process.exit(1);
88
+ }
89
+ emitInfo(`Route decoded: ${routePts.length} sample points`);
90
+
91
+ // 2. Nearby search at each sample, merge + dedupe by place_id
92
+ const seen = new Map(); // place_id → spot
93
+ for (let i = 0; i < routePts.length; i++) {
94
+ const pt = routePts[i];
95
+ const places = await nearbySearch(pt, radius, placeType);
96
+ for (const p of places) {
97
+ if (seen.has(p.place_id)) continue;
98
+ seen.set(p.place_id, {
99
+ lat: p.geometry.location.lat,
100
+ lng: p.geometry.location.lng,
101
+ label: p.name,
102
+ types: p.types ?? [],
103
+ rating: p.rating,
104
+ userRatings: p.user_ratings_total,
105
+ vicinity: p.vicinity,
106
+ routeSample: i,
107
+ });
108
+ }
109
+ }
110
+ emitInfo(`Places found: ${seen.size} unique (before ranking)`);
111
+
112
+ // 3. Rank: rating × log(ratings), boosted by earlier route position (narrative arc)
113
+ const ranked = [...seen.values()]
114
+ .map((s) => ({
115
+ ...s,
116
+ score: (s.rating ?? 3) * Math.log10((s.userRatings ?? 10) + 10),
117
+ }))
118
+ .sort((a, b) => b.score - a.score)
119
+ .slice(0, limit);
120
+
121
+ // 4. Optional photos
122
+ if (wantPhotos) {
123
+ for (const s of ranked) {
124
+ const photo = await fetchPlacePhoto(s, routePts[0]);
125
+ if (photo) s.media = photo;
126
+ }
127
+ }
128
+
129
+ // 5. Emit
130
+ const out = { waypoints, travelMode, radius, samples, spots: ranked };
131
+ const json = JSON.stringify(out, null, 2);
132
+ if (args.output) {
133
+ writeFileSync(resolve(args.output), json);
134
+ emitSuccess(`Wrote ${ranked.length} spots → ${args.output}`);
135
+ } else {
136
+ process.stdout.write(json + "\n");
137
+ }
138
+
139
+ if (args.markdown) {
140
+ const md = "waypoints:[" + ranked
141
+ .map((s) => `${s.lat},${s.lng},"${s.label.replace(/"/g, "")}"${s.media ? `,"${s.media}"` : ""}`)
142
+ .join(";") + "]";
143
+ process.stderr.write(`\n📋 waypoints markdown:\n - map ... ${md}\n\n`);
144
+ }
145
+
146
+ emitSuccess(`${ranked.length} spots ready`);
147
+
148
+ // ── Helpers ───────────────────────────────────────────────────────────────
149
+
150
+ function emitInfo(m) { console.error(` ℹ️ ${m}`); }
151
+ function emitSuccess(m) { console.error(`✅ ${m}`); }
152
+
153
+ function parseArgs(argv) {
154
+ const out = {};
155
+ for (let i = 0; i < argv.length; i++) {
156
+ const a = argv[i];
157
+ if (a.startsWith("--")) {
158
+ const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
159
+ const next = argv[i + 1];
160
+ if (next && !next.startsWith("--")) { out[key] = next; i++; }
161
+ else out[key] = true;
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function parseWaypoints(s) {
168
+ return s.split(";").map((part) => {
169
+ const [lat, lng] = part.split(",").map(Number);
170
+ return { lat, lng };
171
+ }).filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng));
172
+ }
173
+
174
+ /** Fetch the route via Directions REST API, decode the polyline, sample N points. */
175
+ async function fetchRoutePoints(wps, mode, sampleCount) {
176
+ const origin = wps[0];
177
+ const dest = wps[wps.length - 1];
178
+ const via = wps.slice(1, -1);
179
+ const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
180
+ url.searchParams.set("origin", `${origin.lat},${origin.lng}`);
181
+ url.searchParams.set("destination", `${dest.lat},${dest.lng}`);
182
+ if (via.length) url.searchParams.set("waypoints", via.map((w) => `${w.lat},${w.lng}`).join("|"));
183
+ url.searchParams.set("mode", mode.toLowerCase());
184
+ url.searchParams.set("key", API_KEY);
185
+
186
+ const res = await fetch(url);
187
+ const data = await res.json();
188
+ if (data.status !== "OK" || !data.routes?.length) return [];
189
+
190
+ // Flatten all legs' steps' polyline points into one path
191
+ const path = [];
192
+ for (const leg of data.routes[0].legs ?? []) {
193
+ for (const step of leg.steps ?? []) {
194
+ if (step.polyline?.points) {
195
+ path.push(...decodePolyline(step.polyline.points));
196
+ }
197
+ }
198
+ }
199
+ if (path.length === 0) return [];
200
+
201
+ // Sample N evenly-spaced points along the path
202
+ if (path.length <= sampleCount) return path;
203
+ const out = [];
204
+ for (let i = 0; i < sampleCount; i++) {
205
+ out.push(path[Math.floor((i / (sampleCount - 1)) * (path.length - 1))]);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** Places Nearby Search (REST) at a point. */
211
+ async function nearbySearch(pt, searchRadius, type) {
212
+ const url = new URL("https://maps.googleapis.com/maps/api/place/nearbysearch/json");
213
+ url.searchParams.set("location", `${pt.lat},${pt.lng}`);
214
+ url.searchParams.set("radius", String(searchRadius));
215
+ url.searchParams.set("type", type);
216
+ url.searchParams.set("key", API_KEY);
217
+ const res = await fetch(url);
218
+ const data = await res.json();
219
+ return data.results ?? [];
220
+ }
221
+
222
+ /** Fetch one photo URL for a place via the Place Details + Photos API. */
223
+ async function fetchPlacePhoto(spot) {
224
+ // Use Place Details to get photo references
225
+ const detUrl = new URL("https://maps.googleapis.com/maps/api/place/details/json");
226
+ detUrl.searchParams.set("place_id", spot.placeId ?? "");
227
+ // Nearby search doesn't return place_id in our shape; re-query by location+name
228
+ // Simpler: use Find Place From Text by name to get place_id, then photo.
229
+ // To keep this lightweight, fall back to a Static Maps thumbnail of the spot.
230
+ const sm = new URL("https://maps.googleapis.com/maps/api/staticmap");
231
+ sm.searchParams.set("center", `${spot.lat},${spot.lng}`);
232
+ sm.searchParams.set("zoom", "16");
233
+ sm.searchParams.set("size", "128x128");
234
+ sm.searchParams.set("maptype", "satellite");
235
+ sm.searchParams.set("key", API_KEY);
236
+ return sm.toString();
237
+ }
238
+
239
+ /**
240
+ * Decode an encoded polyline string into {lat, lng} points.
241
+ * Google's polyline encoding format (Algorithm: https://developers.google.com/maps/documentation/utilities/polylinealgorithm).
242
+ */
243
+ function decodePolyline(encoded) {
244
+ const coords = [];
245
+ let index = 0, lat = 0, lng = 0;
246
+ while (index < encoded.length) {
247
+ let b, shift = 0, result = 0;
248
+ do {
249
+ b = encoded.charCodeAt(index++) - 63;
250
+ result |= (b & 0x1f) << shift;
251
+ shift += 5;
252
+ } while (b >= 0x20);
253
+ const dLat = (result & 1 ? ~(result >> 1) : result >> 1);
254
+ lat += dLat;
255
+ shift = 0; result = 0;
256
+ do {
257
+ b = encoded.charCodeAt(index++) - 63;
258
+ result |= (b & 0x1f) << shift;
259
+ shift += 5;
260
+ } while (b >= 0x20);
261
+ const dLng = (result & 1 ? ~(result >> 1) : result >> 1);
262
+ lng += dLng;
263
+ coords.push({ lat: lat / 1e5, lng: lng / 1e5 });
264
+ }
265
+ return coords;
266
+ }
@@ -72,7 +72,30 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
72
72
  [stream.data, components, eventState],
73
73
  );
74
74
 
75
- if (!stream.jsx) return null;
75
+ if (!stream.jsx) {
76
+ // Event-only stub: no JSX to render, but may fire events on `on`.
77
+ // Still needs EventAwareComponent for useFrameEvents to register
78
+ // and fire at the right frame.
79
+ const start = stream.start ?? 0;
80
+ const end = stream.end ?? start + (stream.duration ?? 1);
81
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
82
+ return (
83
+ <Sequence
84
+ durationInFrames={durFrames}
85
+ from={Math.floor(fps * start)}
86
+ layout="none"
87
+ >
88
+ <EventAwareComponent
89
+ jsx=""
90
+ components={components}
91
+ data={bindings}
92
+ action={{ start, end }}
93
+ durFrames={durFrames}
94
+ on={stream.on}
95
+ />
96
+ </Sequence>
97
+ );
98
+ }
76
99
 
77
100
  const start = stream.start ?? 0;
78
101
  const end = stream.end ?? start + (stream.duration ?? 1);
@@ -118,6 +141,9 @@ function EventAwareComponent({
118
141
  // Fire events at the right frame for this node's timeline
119
142
  useFrameEvents(on, durFrames);
120
143
 
144
+ // If no JSX, this is an event-only stub — nothing to render
145
+ if (!jsx) return null;
146
+
121
147
  return (
122
148
  <TweenedJsxParser
123
149
  jsx={jsx}
@@ -34,17 +34,24 @@ export function EffectWrapper({
34
34
  const durationInFrames = end - start;
35
35
  if (durationInFrames <= 0) return [] as Record<string, string>[];
36
36
 
37
+ // Use animationDurationSeconds for animation timing when available
38
+ // (set by wrapWithEffects for background nodes where end is set to parent
39
+ // duration but the animation spec duration is preserved separately).
40
+ const animDurationSec = stream.animationDurationSeconds ?? stream.durationInSeconds ?? (durationInFrames / fps);
41
+ const animDurationFrames = Math.ceil(animDurationSec * fps);
42
+
37
43
  const animation = stream.animation;
38
44
  const timingFn = stream.animationTimingFunction;
39
45
  const iterCount = stream.animationIterationCount ?? 1;
40
46
  const style = (cssJS(stream.style) ?? {}) as Record<string, string>;
41
47
 
42
- // Handle iteration count: loop the animation within the span
48
+ // Handle iteration count: loop the animation within the span.
49
+ // The animation period is animDurationFrames (not the full span duration).
43
50
  let currentFrame = frame;
44
- if (iterCount > 0 && durationInFrames > 0) {
45
- const iteration = Math.floor((frame - start) / durationInFrames);
51
+ if (iterCount > 0 && animDurationFrames > 0) {
52
+ const iteration = Math.floor((frame - start) / animDurationFrames);
46
53
  if (iteration < iterCount) {
47
- currentFrame = start + ((frame - start) % durationInFrames);
54
+ currentFrame = start + ((frame - start) % animDurationFrames);
48
55
  }
49
56
  }
50
57
 
@@ -56,7 +63,7 @@ export function EffectWrapper({
56
63
  if (config) {
57
64
  const animStyle = interpolateKeyframes(config, actionFrame, {
58
65
  fps,
59
- durationInSeconds: durationInFrames / fps,
66
+ durationInSeconds: animDurationSec,
60
67
  timingFunction: timingFn,
61
68
  });
62
69
  if (animStyle) Object.assign(style, animStyle);
@@ -65,7 +72,7 @@ export function EffectWrapper({
65
72
  }
66
73
 
67
74
  return Object.keys(style).length > 0 ? [style] : [];
68
- }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style]);
75
+ }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style, stream.animationDurationSeconds, stream.durationInSeconds]);
69
76
 
70
77
  if (styles.length === 0) return <>{children}</>;
71
78
 
@@ -62,7 +62,7 @@ export function FolderLeaf({ stream }: { stream: FolderStream }) {
62
62
  // Background children are rendered outside the series (parallel overlays),
63
63
  // so TransitionSeries doesn't reject the <Loop> wrapper.
64
64
  const bgChildren = visibleChildren.filter((c) => c.isBackground);
65
- const seriesChildren = isSeries ? visibleChildren.filter((c) => !c.isBackground) : visibleChildren;
65
+ const seriesChildren = visibleChildren.filter((c) => !c.isBackground);
66
66
 
67
67
  // When all non-background series children are audio, skip transitions to
68
68
  // avoid audio overlap (both audio tracks play simultaneously during a fade).