@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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/components.md +45 -2
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +7 -2
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +105 -29
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +23 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +751 -143
- package/src/player/pipeline.mjs +130 -32
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +58 -2
- package/src/spots/cli.mjs +266 -0
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/types/Map.tsx +501 -127
- package/src/utils/index.ts +14 -2
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +42 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -1
- 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
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/types/Component.tsx
CHANGED
|
@@ -72,7 +72,30 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
|
|
|
72
72
|
[stream.data, components, eventState],
|
|
73
73
|
);
|
|
74
74
|
|
|
75
|
-
if (!stream.jsx)
|
|
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}
|
package/src/types/Effect.tsx
CHANGED
|
@@ -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 &&
|
|
45
|
-
const iteration = Math.floor((frame - start) /
|
|
51
|
+
if (iterCount > 0 && animDurationFrames > 0) {
|
|
52
|
+
const iteration = Math.floor((frame - start) / animDurationFrames);
|
|
46
53
|
if (iteration < iterCount) {
|
|
47
|
-
currentFrame = start + ((frame - start) %
|
|
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:
|
|
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
|
|
package/src/types/Folder.tsx
CHANGED
|
@@ -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 =
|
|
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).
|