@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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +328 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +101 -1
- package/src/descriptive/dsl.ts +64 -7
- package/src/descriptive/markdown.ts +7 -1
- package/src/descriptive/resolve.test.ts +103 -5
- package/src/descriptive/resolve.ts +207 -24
- package/src/player/bundle/player.js +223296 -222155
- package/src/player/pipeline.mjs +314 -25
- 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 +60 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +1078 -130
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +78 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +35 -0
- package/tests/fixtures/md/map-children.md +11 -0
- package/tests/fixtures/md/map-multimode.md +9 -0
- package/tests/fixtures/streetview-walk.json +36 -0
- package/tests/md-descriptive.test.ts +133 -0
- package/tests/render.test.ts +93 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +76 -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
package/src/render/cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/Effect.tsx
CHANGED
|
@@ -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(
|
|
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}
|