@lalalic/markcut 3.0.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/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +41 -0
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +5 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +448 -99
- package/src/player/pipeline.mjs +64 -13
- 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 +56 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Map.tsx +501 -127
- 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 +9 -7
- 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
- package/B] +0 -2
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
|
@@ -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
|
+
}
|