@lalalic/markcut 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/map-dynamic-camera.md +328 -0
  4. package/skills/markcut/docs/markdown-descriptive.md +2 -0
  5. package/src/descriptive/compiler.ts +101 -1
  6. package/src/descriptive/dsl.ts +64 -7
  7. package/src/descriptive/markdown.ts +7 -1
  8. package/src/descriptive/resolve.test.ts +103 -5
  9. package/src/descriptive/resolve.ts +207 -24
  10. package/src/player/bundle/player.js +223296 -222155
  11. package/src/player/pipeline.mjs +314 -25
  12. package/src/player/pipeline.ts +5 -4
  13. package/src/player/server.mjs +22 -42
  14. package/src/render/cli.mjs +54 -3
  15. package/src/render/validate-assets.mjs +140 -0
  16. package/src/schema/index.ts +60 -1
  17. package/src/spots/cli.mjs +266 -0
  18. package/src/types/Effect.tsx +12 -1
  19. package/src/types/Map.tsx +1078 -130
  20. package/src/utils/directions.ts +101 -0
  21. package/src/utils/index.ts +11 -0
  22. package/src/utils/route-legs.ts +199 -0
  23. package/src/utils/tween.ts +49 -1
  24. package/tests/dsl.test.ts +78 -0
  25. package/tests/fixtures/map-dynamic.json +52 -0
  26. package/tests/fixtures/map-overlay.json +56 -0
  27. package/tests/fixtures/md/animate-diagrams.md +9 -7
  28. package/tests/fixtures/md/map-all-views.md +35 -0
  29. package/tests/fixtures/md/map-children.md +11 -0
  30. package/tests/fixtures/md/map-multimode.md +9 -0
  31. package/tests/fixtures/streetview-walk.json +36 -0
  32. package/tests/md-descriptive.test.ts +133 -0
  33. package/tests/render.test.ts +93 -0
  34. package/tests/route-legs.test.ts +178 -0
  35. package/tests/schema.test.ts +76 -1
  36. package/tests/validate-assets.test.ts +106 -0
  37. package/B] +0 -2
  38. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  39. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  40. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  41. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
  42. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  43. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
@@ -80,6 +80,12 @@ Commands:
80
80
  vision <folder> Full pipeline: extract → normalize → percept → segments
81
81
  --label Add interactive labeling step before AI pipeline
82
82
  --instruct "text" Background context about people/places (injected into prompts)
83
+
84
+ spots --waypoints "lat,lng;..." Discover POIs along a route (Directions + Places API)
85
+ --travelMode DRIVING DRIVING | WALKING | BICYCLING (default DRIVING)
86
+ --limit 8 Max spots after ranking
87
+ --photos Attach a static-map thumbnail per spot
88
+ --markdown Print waypoints:[...] markdown to stderr
83
89
  --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
84
90
  --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
85
91
  --skip-stt Skip speech-to-text for videos
@@ -90,6 +96,7 @@ Commands:
90
96
  }
91
97
 
92
98
 
99
+ /**
93
100
  /**
94
101
  * Render a stream tree to an MP4 video with compact progress output.
95
102
  *
@@ -100,7 +107,7 @@ Commands:
100
107
  * Use --verbose to see every frame line (original behavior).
101
108
  */
102
109
 
103
- function renderOne(streamTree, outputPath, verbose) {
110
+ function renderOne(streamTree, outputPath, verbose, publicDir) {
104
111
  const tmpProps = join(ROOT, ".tmp", "render-stream.json");
105
112
  mkdirSync(dirname(tmpProps), { recursive: true });
106
113
  writeFileSync(tmpProps, JSON.stringify({ root: streamTree }));
@@ -116,7 +123,10 @@ console.log(`\n▶ Rendering → ${outputPath}`);
116
123
  if (args.dev) {
117
124
  spawnOpts.env = { ...process.env, NODE_ENV: "development" };
118
125
  }
119
- const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], spawnOpts);
126
+ // Serve local media (TTS, subtitles, images) from the .markcut base dir.
127
+ const renderArgs = ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"];
128
+ if (publicDir) renderArgs.push("--public-dir", publicDir);
129
+ const proc = spawn("npx", renderArgs, spawnOpts);
120
130
 
121
131
  let lastLoggedFrame = 0;
122
132
  let totalFrames = 0;
@@ -213,6 +223,11 @@ edit=${DEFAULT_EDIT_CLI}`);
213
223
  process.exit(0);
214
224
  }
215
225
 
226
+ if (args.command === "spots") {
227
+ await import("../spots/cli.mjs"); // self-executing top-level script
228
+ process.exit(0);
229
+ }
230
+
216
231
  if (args.command === "preview") {
217
232
  // --storyboard implies --edit: show story structure fast (prompts as
218
233
  // placeholder components) and let user chat to reshape before generation.
@@ -276,6 +291,7 @@ edit=${DEFAULT_EDIT_CLI}`);
276
291
  if (args.command === "render") {
277
292
  let streamTree;
278
293
  let rawInput = "";
294
+ let renderPublicDir; // .markcut base dir → Remotion publicDir for local media
279
295
 
280
296
  if (args.file) {
281
297
  const filePath = resolve(args.file);
@@ -334,6 +350,10 @@ edit=${DEFAULT_EDIT_CLI}`);
334
350
  includeOutputDir: generatedDir(filePath, "includes"),
335
351
  subtitleOutputDir: variantDir(filePath),
336
352
  });
353
+ // Serve the source .md folder as Remotion's public dir (the "default
354
+ // root"): every asset in the JSON is md-folder-relative, so paths like
355
+ // .markcut/generated/... and assets/... resolve via staticFile.
356
+ renderPublicDir = fileDir;
337
357
  } else {
338
358
  const parsed = JSON.parse(raw);
339
359
  const root = parsed.root ?? parsed;
@@ -348,6 +368,7 @@ edit=${DEFAULT_EDIT_CLI}`);
348
368
  includeOutputDir: generatedDir(filePath, "includes"),
349
369
  subtitleOutputDir: variantDir(filePath),
350
370
  });
371
+ renderPublicDir = fileDir;
351
372
  } else {
352
373
  streamTree = root;
353
374
  }
@@ -378,8 +399,24 @@ edit=${DEFAULT_EDIT_CLI}`);
378
399
  }
379
400
  }
380
401
 
402
+ // Guard: every local asset must be relative to the source folder (the
403
+ // render publicDir). Absolute paths or ".." escapes would 404 in Remotion
404
+ // (staticFile serves --public-dir), so fail fast with actionable errors.
405
+ if (renderPublicDir) {
406
+ const { validateAssetsRelative } = await import("./validate-assets.mjs");
407
+ const assetErrors = validateAssetsRelative(streamTree, renderPublicDir);
408
+ if (assetErrors.length > 0) {
409
+ for (const e of assetErrors) emitError(e);
410
+ emitError(
411
+ `Aborting render: ${assetErrors.length} asset(s) not relative to baseDir ` +
412
+ `(${renderPublicDir}). Fix the source or re-run resolve, then retry.`
413
+ );
414
+ process.exit(1);
415
+ }
416
+ }
417
+
381
418
  const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
382
- await renderOne(streamTree, output, args.verbose);
419
+ await renderOne(streamTree, output, args.verbose, renderPublicDir);
383
420
 
384
421
  console.log("\n✅ Render complete.");
385
422
  process.exit(0);
@@ -487,6 +524,20 @@ function hasScript(root) {
487
524
  }
488
525
  walkMedia(descriptive.children);
489
526
 
527
+ // ── Check: all assets relative to baseDir ───────────────────────────
528
+ // Every local asset must be a path relative to the source file's folder
529
+ // (the render publicDir). Absolute paths, "/..." and ".." escapes would
530
+ // 404 in render / break media in preview.
531
+ {
532
+ const { validateAssetsRelative } = await import("./validate-assets.mjs");
533
+ const baseDir = dirname(filePath);
534
+ // Descriptive trees: include.src may point outside baseDir legitimately
535
+ // (it is compiled against its own baseDir, then relativized).
536
+ for (const e of validateAssetsRelative(descriptive, baseDir, { skipIncludeSrc: true })) {
537
+ errors.push(e);
538
+ }
539
+ }
540
+
490
541
  // ── Results ─────────────────────────────────────────────────────────
491
542
  for (const w of warnings) emitWarn(w);
492
543
  if (errors.length > 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
+ }
@@ -42,6 +42,7 @@ const BaseShape = {
42
42
  endAt: z.number().min(0).optional().describe("trim seconds at source end (video/audio)"),
43
43
  durationInSeconds: z.number().optional().describe("set by engine; do not edit by hand"),
44
44
  on: eventSpec.optional().describe("event that fires at a specific frame, mutating registered component state"),
45
+ at: z.string().optional().describe("when child of a map: waypoint label to anchor at (renderer positions at that waypoint's screen pixel)"),
45
46
  };
46
47
 
47
48
  export const base = z.object(BaseShape);
@@ -212,17 +213,71 @@ export const scene = base.extend({
212
213
  export type Scene = z.infer<typeof scene>;
213
214
 
214
215
  // ---------------------------------------------------------------------------
215
- // Map — animated route visualization
216
+ // Map — animated route visualization with dynamic camera views
216
217
  // ---------------------------------------------------------------------------
217
218
  export const mapWaypoint = z.object({
218
219
  lat: z.number(),
219
220
  lng: z.number(),
220
221
  label: z.string().optional(),
221
222
  media: z.string().optional().describe("image/video src for waypoint marker"),
223
+ mode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT", "FLIGHT", "BOAT"]).optional()
224
+ .describe("travel mode for the leg FROM this waypoint to the next; defaults to map travelMode"),
222
225
  });
223
226
 
227
+ /**
228
+ * A tween expression: `tween(from, to, easing?)` → `{__tween:[from,to,easing?]}`,
229
+ * parsed by the descriptive DSL (see parseProps in dsl.ts).
230
+ */
231
+ export const tweenSpec = z.object({
232
+ __tween: z.array(z.union([z.number(), z.string()])),
233
+ });
234
+ export type TweenSpec = z.infer<typeof tweenSpec>;
235
+
236
+ /** A static number OR an animated tween spec. */
237
+ export const tweenableNumber = z.union([z.number(), tweenSpec]);
238
+ export type TweenableNumber = z.infer<typeof tweenableNumber>;
239
+
240
+ /** Generic camera tween shared by overview/route/cinematic-lite. */
241
+ export const mapCamera = z.object({
242
+ zoom: tweenableNumber.optional(),
243
+ center: z.object({ lat: tweenableNumber, lng: tweenableNumber }).optional(),
244
+ heading: tweenableNumber.optional(),
245
+ tilt: tweenableNumber.optional(),
246
+ });
247
+ export type MapCamera = z.infer<typeof mapCamera>;
248
+
249
+ /** Cinematic camera behavior (chase/tilt/flyTo/orbit, 3D flyover). All fields
250
+ * optional — the renderer applies defaults (mode flyAlong, tilt 45, etc.). */
251
+ export const mapCinematic = z.object({
252
+ mode: z.enum(["flyAlong", "flyTo", "orbit"]).optional().describe("camera move; default flyAlong"),
253
+ followRoute: z.boolean().optional().describe("center follows the animated marker; default true"),
254
+ headingFollow: z.boolean().optional().describe("heading = route bearing (forward is up); default true"),
255
+ tilt: tweenableNumber.optional().describe("0 = top-down reveal, 45 = chase; tween(0,45) = tilt reveal; default 45"),
256
+ range: tweenableNumber.optional().describe("3D camera distance (m); tween(8000,300) = fly-to"),
257
+ altitude: z.number().optional().describe("3D center altitude (m)"),
258
+ roll: tweenableNumber.optional().describe("3D bank (deg); default 0"),
259
+ fallback: z.enum(["2d", "none"]).optional().describe("'2d' renders safe 2D chase; 'none' opts into experimental Map3D; default '2d'"),
260
+ });
261
+ export type MapCinematic = z.infer<typeof mapCinematic>;
262
+
263
+ /** Immersive Street View config with POV/position animation. */
264
+ export const mapStreetView = z.object({
265
+ pano: z.string().optional().describe("explicit panorama id (deterministic)"),
266
+ location: z.object({ lat: z.number(), lng: z.number() }).optional().describe("else nearest-pano search"),
267
+ route: z.array(z.object({ lat: z.number(), lng: z.number() })).optional().describe("walk/drive path (nearest pano per stop)"),
268
+ radius: z.number().optional().describe("nearest-pano search radius (m); default 50"),
269
+ source: z.enum(["default", "outdoor", "indoor"]).optional().describe("default 'default'"),
270
+ pov: z.object({
271
+ heading: tweenableNumber.optional().describe("tween(200,320) = pan"),
272
+ pitch: tweenableNumber.optional().describe("tween(0,-10) = tilt sweep"),
273
+ }).optional(),
274
+ zoom: tweenableNumber.optional().describe("street view field-of-view zoom; tween(0,1) = dolly-zoom feel"),
275
+ });
276
+ export type MapStreetView = z.infer<typeof mapStreetView>;
277
+
224
278
  export const mapStream = base.extend({
225
279
  type: z.literal("map").default("map"),
280
+ view: z.enum(["overview", "route", "cinematic", "streetview"]).default("route").describe("camera experience: static/dolly overview, animated route, cinematic flyover, immersive street view"),
226
281
  waypoints: z.array(mapWaypoint).default(() => []),
227
282
  routeColor: z.string().default("#4285F4"),
228
283
  routeWeight: z.number().default(4),
@@ -233,7 +288,11 @@ export const mapStream = base.extend({
233
288
  region: z.string().optional().describe("Google Maps region code, e.g. CN"),
234
289
  travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
235
290
  routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
291
+ camera: mapCamera.optional().describe("generic camera tween (dolly/pan/tilt)"),
292
+ cinematic: mapCinematic.optional().describe("cinematic camera behavior"),
293
+ streetView: mapStreetView.optional().describe("immersive street view config"),
236
294
  googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
295
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []).describe("overlay children rendered on top of the map; use at:\"Label\" to anchor at a waypoint"),
237
296
  });
238
297
  export type MapStream = z.infer<typeof mapStream>;
239
298
 
@@ -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
+ }
@@ -17,9 +17,12 @@ import type { Effect as EffectStream } from "../schema/index";
17
17
  export function EffectWrapper({
18
18
  stream,
19
19
  children,
20
+ contained,
20
21
  }: {
21
22
  stream: EffectStream;
22
23
  children: React.ReactNode;
24
+ /** Fill the parent box instead of the whole canvas (map overlay children). */
25
+ contained?: boolean;
23
26
  }) {
24
27
  const frame = useCurrentFrame();
25
28
  const { fps, width, height } = useVideoConfig();
@@ -78,7 +81,15 @@ export function EffectWrapper({
78
81
 
79
82
  return (
80
83
  <div
81
- style={Object.assign({ width, height, position: "absolute" as const, inset: 0 }, ...styles)}
84
+ style={Object.assign(
85
+ {
86
+ width: contained ? "100%" : width,
87
+ height: contained ? "100%" : height,
88
+ position: "absolute" as const,
89
+ inset: 0,
90
+ },
91
+ ...styles,
92
+ )}
82
93
  className="effect"
83
94
  >
84
95
  {children}